diff --git a/scripts/generate-action-sprites.py b/scripts/generate-action-sprites.py new file mode 100644 index 0000000..5498d1b --- /dev/null +++ b/scripts/generate-action-sprites.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +from pathlib import Path + +from PIL import Image, ImageChops, ImageDraw, ImageEnhance, ImageFilter + + +FRAME_SIZE = 313 +ROOT = Path(__file__).resolve().parents[1] +UNIT_DIR = ROOT / "src" / "assets" / "images" / "units" + +DIRECTIONS = ("south", "east", "north", "west") +FRAME_BY_ACTION = { + "attack": 2, + "strategy": 0, + "item": 1, + "hurt": 0, +} + +FORWARD = { + "south": (0, 15), + "east": (18, 0), + "north": (0, -10), + "west": (-18, 0), +} + +BACKWARD = { + "south": (0, -12), + "east": (-14, 0), + "north": (0, 12), + "west": (14, 0), +} + +ITEM_POS = { + "south": (184, 142), + "east": (222, 143), + "north": (134, 136), + "west": (88, 143), +} + +ACTION_ORDER = ("attack", "strategy", "item", "hurt") + + +def main() -> None: + for source in sorted(UNIT_DIR.glob("unit-*.png")): + if source.name.endswith("-actions.png"): + continue + sheet = Image.open(source).convert("RGBA") + output = Image.new("RGBA", (FRAME_SIZE * len(ACTION_ORDER), FRAME_SIZE * len(DIRECTIONS)), (0, 0, 0, 0)) + + for row, direction in enumerate(DIRECTIONS): + for col, action in enumerate(ACTION_ORDER): + frame = crop_frame(sheet, row, FRAME_BY_ACTION[action]) + if action == "attack": + action_frame = make_attack(frame, direction) + elif action == "strategy": + action_frame = make_strategy(frame, direction) + elif action == "item": + action_frame = make_item(frame, direction) + else: + action_frame = make_hurt(frame, direction) + output.alpha_composite(action_frame, (col * FRAME_SIZE, row * FRAME_SIZE)) + + output.save(source.with_name(f"{source.stem}-actions.png")) + + +def crop_frame(sheet: Image.Image, row: int, col: int) -> Image.Image: + return sheet.crop((col * FRAME_SIZE, row * FRAME_SIZE, (col + 1) * FRAME_SIZE, (row + 1) * FRAME_SIZE)).copy() + + +def offset_subject(frame: Image.Image, dx: int, dy: int) -> Image.Image: + canvas = Image.new("RGBA", frame.size, (0, 0, 0, 0)) + bbox = frame.getbbox() + if not bbox: + return frame + subject = frame.crop(bbox) + canvas.alpha_composite(subject, (bbox[0] + dx, bbox[1] + dy)) + return canvas + + +def underlay(frame: Image.Image, color: tuple[int, int, int, int], blur: int, expand: int = 0) -> Image.Image: + alpha = frame.getchannel("A") + if expand > 0: + alpha = alpha.filter(ImageFilter.MaxFilter(expand * 2 + 1)) + alpha = alpha.filter(ImageFilter.GaussianBlur(blur)) + glow = Image.new("RGBA", frame.size, color) + glow.putalpha(alpha.point(lambda value: min(255, int(value * color[3] / 255)))) + result = Image.new("RGBA", frame.size, (0, 0, 0, 0)) + result.alpha_composite(glow) + result.alpha_composite(frame) + return result + + +def make_attack(frame: Image.Image, direction: str) -> Image.Image: + dx, dy = FORWARD[direction] + body = offset_subject(frame, dx, dy) + body = underlay(body, (255, 224, 120, 92), 4, 1) + slash = Image.new("RGBA", frame.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(slash) + + if direction == "east": + points = [(166, 76), (264, 126), (234, 220)] + elif direction == "west": + points = [(147, 76), (49, 126), (79, 220)] + elif direction == "south": + points = [(74, 104), (166, 184), (248, 112)] + else: + points = [(74, 202), (166, 112), (248, 198)] + + draw.line(points, fill=(255, 247, 214, 218), width=15, joint="curve") + draw.line(points, fill=(98, 210, 255, 178), width=6, joint="curve") + draw.line(points, fill=(255, 178, 86, 158), width=2, joint="curve") + slash = slash.filter(ImageFilter.GaussianBlur(0.45)) + + result = Image.new("RGBA", frame.size, (0, 0, 0, 0)) + result.alpha_composite(slash) + result.alpha_composite(body) + return result + + +def make_strategy(frame: Image.Image, direction: str) -> Image.Image: + aura = Image.new("RGBA", frame.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(aura) + center = (156, 160) + + for radius, alpha in ((92, 42), (66, 66), (38, 92)): + draw.ellipse( + (center[0] - radius, center[1] - radius, center[0] + radius, center[1] + radius), + outline=(98, 195, 255, alpha), + width=4, + ) + + for index in range(10): + x = 72 + (index * 23) % 168 + y = 58 + (index * 47) % 188 + draw.polygon( + [(x, y - 7), (x + 4, y), (x, y + 7), (x - 4, y)], + fill=(255, 230, 132, 140), + ) + + aura = aura.filter(ImageFilter.GaussianBlur(0.35)) + body = underlay(frame, (60, 160, 255, 92), 6, 2) + result = Image.new("RGBA", frame.size, (0, 0, 0, 0)) + result.alpha_composite(aura) + result.alpha_composite(body) + return result + + +def make_item(frame: Image.Image, direction: str) -> Image.Image: + body = underlay(frame, (95, 230, 150, 70), 4, 1) + effect = Image.new("RGBA", frame.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(effect) + x, y = ITEM_POS[direction] + + draw.rounded_rectangle((x - 18, y - 15, x + 18, y + 17), radius=7, fill=(137, 91, 45, 235), outline=(245, 207, 122, 230), width=3) + draw.ellipse((x - 10, y - 28, x + 10, y - 9), fill=(95, 221, 142, 210), outline=(224, 255, 219, 230), width=2) + draw.line((x - 26, y + 2, x + 26, y + 2), fill=(224, 255, 219, 184), width=4) + draw.line((x, y - 24, x, y + 27), fill=(224, 255, 219, 184), width=4) + + for px, py in ((x - 38, y - 30), (x + 36, y - 26), (x - 30, y + 36), (x + 28, y + 34)): + draw.ellipse((px - 4, py - 4, px + 4, py + 4), fill=(159, 255, 200, 160)) + + result = Image.new("RGBA", frame.size, (0, 0, 0, 0)) + result.alpha_composite(body) + result.alpha_composite(effect) + return result + + +def make_hurt(frame: Image.Image, direction: str) -> Image.Image: + dx, dy = BACKWARD[direction] + body = offset_subject(frame, dx, dy) + body = ImageEnhance.Color(body).enhance(0.62) + + alpha = body.getchannel("A") + red = Image.new("RGBA", body.size, (255, 78, 60, 70)) + red.putalpha(alpha.point(lambda value: min(88, value // 3))) + body = Image.alpha_composite(body, red) + + impact = Image.new("RGBA", frame.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(impact) + if direction in ("west", "north"): + cx, cy = 190, 118 + else: + cx, cy = 124, 118 + spikes = [(cx, cy - 28), (cx + 9, cy - 8), (cx + 31, cy - 6), (cx + 12, cy + 7), (cx + 20, cy + 28), (cx, cy + 13), (cx - 22, cy + 27), (cx - 12, cy + 6), (cx - 31, cy - 7), (cx - 8, cy - 9)] + draw.polygon(spikes, fill=(255, 222, 118, 194), outline=(255, 84, 57, 224)) + draw.line((cx - 38, cy - 35, cx + 38, cy + 35), fill=(255, 245, 205, 186), width=5) + draw.line((cx + 34, cy - 32, cx - 32, cy + 34), fill=(255, 245, 205, 154), width=3) + + result = Image.new("RGBA", frame.size, (0, 0, 0, 0)) + result.alpha_composite(body) + result.alpha_composite(impact.filter(ImageFilter.GaussianBlur(0.2))) + return result + + +if __name__ == "__main__": + main() diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index c1de40e..6e74974 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -74,6 +74,22 @@ try { throw new Error(`Debug battle state was not available: ${JSON.stringify(result.battleState)}`); } + const actionTexturesLoaded = await page.evaluate(() => { + const textures = window.__HEROS_GAME__?.textures; + return [ + 'unit-liu-bei-actions', + 'unit-guan-yu-actions', + 'unit-zhang-fei-actions', + 'unit-rebel-actions', + 'unit-rebel-archer-actions', + 'unit-rebel-cavalry-actions', + 'unit-rebel-leader-actions' + ].every((key) => textures?.exists(key)); + }); + if (!actionTexturesLoaded) { + throw new Error('Expected all unit action textures to be loaded.'); + } + const cameraBeforeScroll = result.battleState.camera; if ( !cameraBeforeScroll || diff --git a/src/assets/images/units/unit-guan-yu-actions.png b/src/assets/images/units/unit-guan-yu-actions.png new file mode 100644 index 0000000..4ca81c9 Binary files /dev/null and b/src/assets/images/units/unit-guan-yu-actions.png differ diff --git a/src/assets/images/units/unit-liu-bei-actions.png b/src/assets/images/units/unit-liu-bei-actions.png new file mode 100644 index 0000000..56994ae Binary files /dev/null and b/src/assets/images/units/unit-liu-bei-actions.png differ diff --git a/src/assets/images/units/unit-rebel-actions.png b/src/assets/images/units/unit-rebel-actions.png new file mode 100644 index 0000000..73ffdb9 Binary files /dev/null and b/src/assets/images/units/unit-rebel-actions.png differ diff --git a/src/assets/images/units/unit-rebel-archer-actions.png b/src/assets/images/units/unit-rebel-archer-actions.png new file mode 100644 index 0000000..7688a59 Binary files /dev/null and b/src/assets/images/units/unit-rebel-archer-actions.png differ diff --git a/src/assets/images/units/unit-rebel-cavalry-actions.png b/src/assets/images/units/unit-rebel-cavalry-actions.png new file mode 100644 index 0000000..ef58da3 Binary files /dev/null and b/src/assets/images/units/unit-rebel-cavalry-actions.png differ diff --git a/src/assets/images/units/unit-rebel-leader-actions.png b/src/assets/images/units/unit-rebel-leader-actions.png new file mode 100644 index 0000000..06b70f7 Binary files /dev/null and b/src/assets/images/units/unit-rebel-leader-actions.png differ diff --git a/src/assets/images/units/unit-zhang-fei-actions.png b/src/assets/images/units/unit-zhang-fei-actions.png new file mode 100644 index 0000000..1d031dc Binary files /dev/null and b/src/assets/images/units/unit-zhang-fei-actions.png differ diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 67900d3..be1c073 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -42,6 +42,13 @@ const unitFrameRows: Record = { west: 3 }; +const unitActionColumns: Record = { + attack: 0, + strategy: 1, + item: 2, + hurt: 3 +}; + type BattleLayout = { mapX: number; mapY: number; @@ -83,6 +90,7 @@ type UnitView = { }; type UnitDirection = 'south' | 'east' | 'north' | 'west'; +type UnitActionPose = 'attack' | 'strategy' | 'item' | 'hurt'; type BattlePhase = 'idle' | 'moving' | 'command' | 'targeting' | 'animating' | 'resolved'; type BattleCommand = 'attack' | 'strategy' | 'item' | 'wait'; @@ -2805,7 +2813,12 @@ export class BattleScene extends Phaser.Scene { const attackerDirection = 'east'; const defenderDirection = 'west'; const attackerSprite = this.trackCombatObject( - this.add.sprite(attackerX, groundY, unitTexture[result.attacker.id] ?? 'unit-rebel', this.unitFrameIndex(attackerDirection)) + this.add.sprite( + attackerX, + groundY, + this.unitActionTexture(result.attacker), + this.unitActionFrameIndex(attackerDirection, this.actionPoseForCommand(result.action)) + ) ); attackerSprite.setDepth(depth + 6); attackerSprite.setDisplaySize(126, 126); @@ -2824,7 +2837,7 @@ export class BattleScene extends Phaser.Scene { attackerStatus.expText.setText(`${result.characterGrowth.previousExp} / ${result.characterGrowth.next}`); await this.delay(180); - await this.playCombatActionMotion(result, attackerSprite, defenderSprite, attackerX, defenderX, groundY, depth + 10); + await this.playCombatActionMotion(result, attackerSprite, defenderSprite, attackerX, defenderX, groundY, depth + 10, defenderDirection); if (result.hit) { this.showCombatImpact(result, defenderX, groundY - 46, depth + 14); } else { @@ -2902,7 +2915,10 @@ export class BattleScene extends Phaser.Scene { title.setOrigin(0.5, 0); title.setDepth(depth + 2); - const userSprite = this.trackCombatObject(this.add.sprite(left + 160, top + 146, unitTexture[result.user.id] ?? 'unit-rebel', this.unitFrameIndex('east'))); + const supportPose: UnitActionPose = result.usable.command === 'strategy' ? 'strategy' : 'item'; + const userSprite = this.trackCombatObject( + this.add.sprite(left + 160, top + 146, this.unitActionTexture(result.user), this.unitActionFrameIndex('east', supportPose)) + ); userSprite.setDepth(depth + 3); userSprite.setDisplaySize(112, 112); @@ -3053,7 +3069,8 @@ export class BattleScene extends Phaser.Scene { attackerX: number, defenderX: number, groundY: number, - depth: number + depth: number, + defenderDirection: UnitDirection ) { const direction = result.attacker.faction === 'ally' ? 1 : -1; if (result.action === 'attack') { @@ -3066,6 +3083,9 @@ export class BattleScene extends Phaser.Scene { ease: 'Sine.easeInOut' }); await this.delay(180); + if (result.hit) { + defenderSprite.setTexture(this.unitActionTexture(result.defender), this.unitActionFrameIndex(defenderDirection, 'hurt')); + } this.tweens.add({ targets: defenderSprite, x: defenderX + direction * (result.hit ? 12 : 28), @@ -3096,6 +3116,9 @@ export class BattleScene extends Phaser.Scene { ease: 'Cubic.easeIn' }); await this.delay(450); + if (result.hit) { + defenderSprite.setTexture(this.unitActionTexture(result.defender), this.unitActionFrameIndex(defenderDirection, 'hurt')); + } projectile.destroy(); } @@ -3746,6 +3769,26 @@ export class BattleScene extends Phaser.Scene { return unitFrameRows[direction] * 4 + frame; } + private unitActionTexture(unit: UnitData) { + const base = unitTexture[unit.id] ?? 'unit-rebel'; + const actionKey = `${base}-actions`; + return this.textures.exists(actionKey) ? actionKey : base; + } + + private unitActionFrameIndex(direction: UnitDirection, pose: UnitActionPose) { + return unitFrameRows[direction] * 4 + unitActionColumns[pose]; + } + + private actionPoseForCommand(action: DamageCommand): UnitActionPose { + if (action === 'strategy') { + return 'strategy'; + } + if (action === 'item') { + return 'item'; + } + return 'attack'; + } + private applyActedStyle(unit: UnitData) { const view = this.unitViews.get(unit.id); if (!view) { diff --git a/src/game/scenes/BootScene.ts b/src/game/scenes/BootScene.ts index ac9db42..083d798 100644 --- a/src/game/scenes/BootScene.ts +++ b/src/game/scenes/BootScene.ts @@ -9,12 +9,19 @@ import storyThreeHeroesUrl from '../../assets/images/story/03-three-heroes-meet. import storyMilitiaUrl from '../../assets/images/story/04-volunteer-militia.png'; import storySortieUrl from '../../assets/images/story/05-first-sortie.png'; import titleBackgroundUrl from '../../assets/images/taoyuan-oath-title.png'; +import guanYuActionSheetUrl from '../../assets/images/units/unit-guan-yu-actions.png'; import guanYuUnitSheetUrl from '../../assets/images/units/unit-guan-yu.png'; +import liuBeiActionSheetUrl from '../../assets/images/units/unit-liu-bei-actions.png'; import liuBeiUnitSheetUrl from '../../assets/images/units/unit-liu-bei.png'; +import rebelArcherActionSheetUrl from '../../assets/images/units/unit-rebel-archer-actions.png'; import rebelArcherUnitSheetUrl from '../../assets/images/units/unit-rebel-archer.png'; +import rebelCavalryActionSheetUrl from '../../assets/images/units/unit-rebel-cavalry-actions.png'; import rebelCavalryUnitSheetUrl from '../../assets/images/units/unit-rebel-cavalry.png'; +import rebelLeaderActionSheetUrl from '../../assets/images/units/unit-rebel-leader-actions.png'; import rebelLeaderUnitSheetUrl from '../../assets/images/units/unit-rebel-leader.png'; +import rebelActionSheetUrl from '../../assets/images/units/unit-rebel-actions.png'; import rebelUnitSheetUrl from '../../assets/images/units/unit-rebel.png'; +import zhangFeiActionSheetUrl from '../../assets/images/units/unit-zhang-fei-actions.png'; import zhangFeiUnitSheetUrl from '../../assets/images/units/unit-zhang-fei.png'; type UnitDirection = 'south' | 'east' | 'north' | 'west'; @@ -31,13 +38,13 @@ const unitSheetRows: Record = { }; const unitSheets = [ - { key: 'unit-liu-bei', url: liuBeiUnitSheetUrl }, - { key: 'unit-guan-yu', url: guanYuUnitSheetUrl }, - { key: 'unit-zhang-fei', url: zhangFeiUnitSheetUrl }, - { key: 'unit-rebel', url: rebelUnitSheetUrl }, - { key: 'unit-rebel-archer', url: rebelArcherUnitSheetUrl }, - { key: 'unit-rebel-cavalry', url: rebelCavalryUnitSheetUrl }, - { key: 'unit-rebel-leader', url: rebelLeaderUnitSheetUrl } + { key: 'unit-liu-bei', url: liuBeiUnitSheetUrl, actionKey: 'unit-liu-bei-actions', actionUrl: liuBeiActionSheetUrl }, + { key: 'unit-guan-yu', url: guanYuUnitSheetUrl, actionKey: 'unit-guan-yu-actions', actionUrl: guanYuActionSheetUrl }, + { key: 'unit-zhang-fei', url: zhangFeiUnitSheetUrl, actionKey: 'unit-zhang-fei-actions', actionUrl: zhangFeiActionSheetUrl }, + { key: 'unit-rebel', url: rebelUnitSheetUrl, actionKey: 'unit-rebel-actions', actionUrl: rebelActionSheetUrl }, + { key: 'unit-rebel-archer', url: rebelArcherUnitSheetUrl, actionKey: 'unit-rebel-archer-actions', actionUrl: rebelArcherActionSheetUrl }, + { key: 'unit-rebel-cavalry', url: rebelCavalryUnitSheetUrl, actionKey: 'unit-rebel-cavalry-actions', actionUrl: rebelCavalryActionSheetUrl }, + { key: 'unit-rebel-leader', url: rebelLeaderUnitSheetUrl, actionKey: 'unit-rebel-leader-actions', actionUrl: rebelLeaderActionSheetUrl } ]; export class BootScene extends Phaser.Scene { @@ -57,11 +64,15 @@ export class BootScene extends Phaser.Scene { this.load.image('portrait-guan-yu', guanYuPortraitUrl); this.load.image('portrait-zhang-fei', zhangFeiPortraitUrl); - unitSheets.forEach(({ key, url }) => { + unitSheets.forEach(({ key, url, actionKey, actionUrl }) => { this.load.spritesheet(key, url, { frameWidth: unitSheetFrameSize, frameHeight: unitSheetFrameSize }); + this.load.spritesheet(actionKey, actionUrl, { + frameWidth: unitSheetFrameSize, + frameHeight: unitSheetFrameSize + }); }); }