diff --git a/docs/battle-strategy-motion-audit.md b/docs/battle-strategy-motion-audit.md new file mode 100644 index 0000000..6f7718d --- /dev/null +++ b/docs/battle-strategy-motion-audit.md @@ -0,0 +1,26 @@ +# Battle Strategy Motion Audit + +## Scope + +Audited all battle strategy motions defined in `src/game/scenes/BattleScene.ts`. + +## Findings + +- `aid`: support/heal strategy. Uses the support cut-in, green healing effect, and does not use a projectile. +- `encourage`: support/focus strategy. Uses the support cut-in, gold buff effect, and does not use a projectile. +- `fireTactic`: enemy-target damage strategy named `화계`. It applies `burn`, but previously reused the generic blue-white strategy orb projectile. +- `roar`: enemy-target damage strategy named `고함`. It applies `confusion`, but also reused the same generic strategy orb projectile. + +## Fix + +- `fireTactic` now uses an orange/red moving flame projectile, fire-colored caster pulse, and flame burst impact. +- `roar` now uses a short-range shockwave projectile and impact ring instead of a magic orb. +- The generic blue strategy orb remains only as a fallback for future damage strategies without a specific motion. +- The final combat impact flash now matches the selected strategy motion color, so fire tactics no longer end with a blue-white impact star. + +## Users Covered + +- Player fire tactic users: Guan Yu, Jian Yong, Sun Qian, Zhuge Liang, Ma Liang, Pang Tong, Fa Zheng, Jiang Wei. +- Player roar users: Zhang Fei, Wei Yan, Ma Chao. +- Enemy AI fire tactic users: strategist, quartermaster, advisor, shaman, Sima Yi, and Chen Gong style units selected by enemy behavior. +- Enemy AI roar users: leader and officer style units selected by enemy behavior. diff --git a/docs/battle-strategy-motion-fire-local-check.png b/docs/battle-strategy-motion-fire-local-check.png new file mode 100644 index 0000000..9dfa138 Binary files /dev/null and b/docs/battle-strategy-motion-fire-local-check.png differ diff --git a/docs/battle-strategy-motion-roar-local-check.png b/docs/battle-strategy-motion-roar-local-check.png new file mode 100644 index 0000000..3d65502 Binary files /dev/null and b/docs/battle-strategy-motion-roar-local-check.png differ diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 2c4a61f..f58a0c6 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1119,6 +1119,7 @@ type DamageCommand = Exclude; type UsableCommand = Extract; type UsableEffect = 'damage' | 'heal' | 'focus'; type UsableTarget = 'enemy' | 'ally' | 'self'; +type SpecialDamageMotionKind = 'item' | 'arcane' | 'fire' | 'roar'; type RosterTab = 'ally' | 'enemy'; type ActiveFaction = 'ally' | 'enemy'; const battleSpeedOptions = ['normal', 'fast', 'very-fast'] as const; @@ -13805,6 +13806,7 @@ export class BattleScene extends Phaser.Scene { defenderDirection: UnitDirection, direction: number ) { + const motionKind = this.specialDamageMotionKind(result); if (result.action === 'item') { soundDirector.playItemLaunch(); } else { @@ -13814,7 +13816,7 @@ export class BattleScene extends Phaser.Scene { void this.playUnitActionFrames(attackerSprite, result.attacker, 'east', this.actionPoseForCommand(result.action), 92); const baseScaleX = attackerSprite.scaleX; const baseScaleY = attackerSprite.scaleY; - this.createCasterPulse(attackerX + direction * 48, groundY - 50, depth + 2, result.action === 'item' ? 0xd8b15f : 0x6cc5ff); + this.createCasterPulse(attackerX + direction * 48, groundY - 50, depth + 2, this.specialCasterColor(motionKind)); this.tweens.add({ targets: attackerSprite, scaleX: baseScaleX * 1.08, @@ -13826,29 +13828,89 @@ export class BattleScene extends Phaser.Scene { }); await this.delay(155); - const projectile = - result.action === 'item' - ? this.createCombatCart(attackerX + direction * 78, groundY - 10, depth) - : this.createStrategyEffect(attackerX + direction * 78, groundY - 48, depth); + const projectile = this.createSpecialProjectile(motionKind, attackerX + direction * 78, this.specialProjectileY(motionKind, groundY), direction, depth); projectile.setDepth(depth); this.combatCutInObjects.push(projectile); this.tweens.add({ targets: projectile, x: defenderX - direction * (result.hit ? 72 : 12), - y: result.hit ? projectile.y : projectile.y - 24, + y: result.hit ? projectile.y : projectile.y - this.specialProjectileMissLift(motionKind), duration: this.scaledBattleDuration(430, 100), ease: 'Cubic.easeIn' }); await this.delay(450); if (result.hit) { void this.playUnitActionFrames(defenderSprite, result.defender, defenderDirection, 'hurt', 70); - this.createSpecialImpact(defenderX - direction * 68, result.action === 'item' ? groundY - 20 : groundY - 56, depth + 4, result.action); + this.createSpecialImpact(defenderX - direction * 68, this.specialImpactY(motionKind, groundY), depth + 4, motionKind); this.shakeCombatSprite(defenderSprite, defenderX, groundY, direction, result.critical); } projectile.destroy(); await this.delay(110); } + private specialDamageMotionKind(result: Pick): SpecialDamageMotionKind { + if (result.action === 'item') { + return 'item'; + } + if (result.usable?.id === 'fireTactic' || result.usable?.statusEffect === 'burn') { + return 'fire'; + } + if (result.usable?.id === 'roar' || result.usable?.statusEffect === 'confusion') { + return 'roar'; + } + return 'arcane'; + } + + private specialCasterColor(kind: SpecialDamageMotionKind) { + if (kind === 'item') { + return 0xd8b15f; + } + if (kind === 'fire') { + return 0xff7a2a; + } + if (kind === 'roar') { + return 0xd8b15f; + } + return 0x6cc5ff; + } + + private specialProjectileY(kind: SpecialDamageMotionKind, groundY: number) { + if (kind === 'item') { + return groundY - 10; + } + if (kind === 'roar') { + return groundY - 58; + } + return groundY - 48; + } + + private specialImpactY(kind: SpecialDamageMotionKind, groundY: number) { + if (kind === 'item') { + return groundY - 20; + } + if (kind === 'roar') { + return groundY - 50; + } + return groundY - 56; + } + + private specialProjectileMissLift(kind: SpecialDamageMotionKind) { + return kind === 'item' ? 18 : kind === 'roar' ? 6 : 24; + } + + private createSpecialProjectile(kind: SpecialDamageMotionKind, x: number, y: number, direction: number, depth: number) { + if (kind === 'item') { + return this.createCombatCart(x, y, depth); + } + if (kind === 'fire') { + return this.createFireTacticProjectile(x, y, direction, depth); + } + if (kind === 'roar') { + return this.createRoarWaveProjectile(x, y, direction, depth); + } + return this.createStrategyEffect(x, y, depth); + } + private createDustPuff(x: number, y: number, depth: number, scale = 1) { const puff = this.trackCombatObject(this.add.container(x, y)); puff.setDepth(depth); @@ -13968,16 +14030,59 @@ export class BattleScene extends Phaser.Scene { this.tweens.add({ targets: [ring, core], scale: 1.85, alpha: 0, duration: 420, ease: 'Sine.easeOut' }); } - private createSpecialImpact(x: number, y: number, depth: number, action: DamageCommand) { - const color = action === 'item' ? 0xffdf7b : 0x6cc5ff; + private createSpecialImpact(x: number, y: number, depth: number, kind: SpecialDamageMotionKind) { + if (kind === 'fire') { + this.createFireTacticImpact(x, y, depth); + return; + } + if (kind === 'roar') { + this.createRoarImpact(x, y, depth); + return; + } + + const color = kind === 'item' ? 0xffdf7b : 0x6cc5ff; const impact = this.trackCombatObject(this.add.container(x, y)); impact.setDepth(depth); - const ring = this.add.circle(0, 0, action === 'item' ? 30 : 38); - ring.setStrokeStyle(action === 'item' ? 4 : 3, color, 0.9); - const core = this.add.star(0, 0, action === 'item' ? 7 : 6, 9, action === 'item' ? 34 : 28, color, 0.78); + const ring = this.add.circle(0, 0, kind === 'item' ? 30 : 38); + ring.setStrokeStyle(kind === 'item' ? 4 : 3, color, 0.9); + const core = this.add.star(0, 0, kind === 'item' ? 7 : 6, 9, kind === 'item' ? 34 : 28, color, 0.78); const flash = this.add.circle(0, 0, 12, 0xffffff, 0.5); impact.add([ring, core, flash]); - this.tweens.add({ targets: impact, scale: action === 'item' ? 1.38 : 1.65, alpha: 0, angle: action === 'item' ? 45 : 180, duration: 340, ease: 'Sine.easeOut' }); + this.tweens.add({ targets: impact, scale: kind === 'item' ? 1.38 : 1.65, alpha: 0, angle: kind === 'item' ? 45 : 180, duration: 340, ease: 'Sine.easeOut' }); + } + + private createFireTacticImpact(x: number, y: number, depth: number) { + const impact = this.trackCombatObject(this.add.container(x, y)); + impact.setDepth(depth); + const smokeBack = this.add.circle(-9, 10, 27, 0x3c2b24, 0.2); + const smokeFront = this.add.circle(15, 7, 21, 0x5a3a26, 0.18); + const outer = this.add.circle(0, 0, 42); + outer.setStrokeStyle(4, 0xff6a2f, 0.82); + const flameA = this.add.triangle(-13, 4, 0, 22, 16, -18, 30, 18, 0xe24a1e, 0.88); + const flameB = this.add.triangle(12, -1, 0, 18, 11, -25, 24, 16, 0xff8a2a, 0.94); + const core = this.add.star(0, 0, 7, 10, 31, 0xffcf5a, 0.92); + const flash = this.add.circle(0, -2, 12, 0xfff2b8, 0.7); + impact.add([smokeBack, smokeFront, outer, flameA, flameB, core, flash]); + this.tweens.add({ targets: impact, scale: 1.55, alpha: 0, angle: 18, duration: 360, ease: 'Sine.easeOut' }); + this.tweens.add({ targets: [smokeBack, smokeFront], scale: 1.9, y: '+=12', duration: 420, ease: 'Sine.easeOut' }); + } + + private createRoarImpact(x: number, y: number, depth: number) { + const impact = this.trackCombatObject(this.add.container(x, y)); + impact.setDepth(depth); + const outer = this.add.ellipse(0, 0, 92, 54); + outer.setStrokeStyle(5, 0xd8b15f, 0.8); + const middle = this.add.ellipse(0, 0, 62, 36); + middle.setStrokeStyle(3, 0xfff0b8, 0.72); + const inner = this.add.ellipse(0, 0, 32, 22, 0xb36dff, 0.18); + const burst = this.add.graphics(); + burst.lineStyle(4, 0xffdf7b, 0.78); + for (let index = 0; index < 8; index += 1) { + const angle = Phaser.Math.DegToRad(index * 45); + burst.lineBetween(Math.cos(angle) * 15, Math.sin(angle) * 10, Math.cos(angle) * 46, Math.sin(angle) * 27); + } + impact.add([outer, middle, inner, burst]); + this.tweens.add({ targets: impact, scaleX: 1.5, scaleY: 1.2, alpha: 0, duration: 320, ease: 'Sine.easeOut' }); } private createCombatCart(x: number, y: number, depth: number) { @@ -13997,6 +14102,46 @@ export class BattleScene extends Phaser.Scene { return container; } + private createFireTacticProjectile(x: number, y: number, direction: number, depth: number) { + const container = this.add.container(x, y); + container.setDepth(depth); + const smoke = this.add.circle(-direction * 31, 7, 12, 0x4a3328, 0.24); + const backFlame = this.add.triangle(-direction * 8, 3, -direction * 38, -16, direction * 23, -2, -direction * 35, 18, 0xd9471f, 0.72); + const hotFlame = this.add.triangle(direction * 2, -1, -direction * 25, -11, direction * 29, -2, -direction * 22, 13, 0xff8a2a, 0.86); + const tail = this.add.circle(-direction * 19, 4, 17, 0xd9471f, 0.7); + const mid = this.add.circle(-direction * 6, -1, 15, 0xff7a25, 0.92); + const core = this.add.circle(direction * 7, -4, 10, 0xffd36a, 0.98); + const heart = this.add.circle(direction * 12, -5, 5, 0xfff2b8, 0.96); + const emberTop = this.add.star(-direction * 25, -13, 5, 3, 8, 0xffb341, 0.78); + const emberBottom = this.add.star(-direction * 34, 14, 5, 3, 7, 0xff6a2f, 0.68); + container.add([smoke, backFlame, hotFlame, tail, mid, core, heart, emberTop, emberBottom]); + this.tweens.add({ targets: [backFlame, hotFlame, tail, mid, core], scale: 1.12, duration: 120, yoyo: true, repeat: 2, ease: 'Sine.easeInOut' }); + this.tweens.add({ targets: [emberTop, emberBottom], x: `-=${direction * 12}`, alpha: 0.22, angle: 160 * direction, duration: 430, ease: 'Sine.easeOut' }); + this.tweens.add({ targets: smoke, scale: 1.55, alpha: 0.04, duration: 430, ease: 'Sine.easeOut' }); + return container; + } + + private createRoarWaveProjectile(x: number, y: number, direction: number, depth: number) { + const container = this.add.container(x, y); + container.setDepth(depth); + const outer = this.add.ellipse(0, 0, 64, 42); + outer.setStrokeStyle(4, 0xd8b15f, 0.74); + const middle = this.add.ellipse(-direction * 12, 0, 42, 28); + middle.setStrokeStyle(3, 0xf2e0a8, 0.62); + const inner = this.add.ellipse(-direction * 24, 0, 23, 16); + inner.setStrokeStyle(2, 0xb36dff, 0.52); + const pulse = this.add.circle(direction * 16, 0, 8, 0xffdf7b, 0.46); + const slash = this.add.graphics(); + slash.lineStyle(4, 0xfff0b8, 0.66); + slash.lineBetween(-direction * 44, -16, direction * 22, -5); + slash.lineBetween(-direction * 42, 16, direction * 26, 5); + container.add([outer, middle, inner, pulse, slash]); + this.tweens.add({ targets: [outer, middle, inner], scaleX: 1.28, scaleY: 1.08, alpha: 0.22, duration: 430, ease: 'Sine.easeOut' }); + this.tweens.add({ targets: pulse, scale: 1.8, alpha: 0.12, duration: 430, ease: 'Sine.easeOut' }); + this.tweens.add({ targets: slash, x: direction * 18, alpha: 0.1, duration: 430, ease: 'Sine.easeOut' }); + return container; + } + private createStrategyEffect(x: number, y: number, depth: number) { const container = this.add.container(x, y); container.setDepth(depth); @@ -14122,9 +14267,7 @@ export class BattleScene extends Phaser.Scene { soundDirector.playEffect('combat-impact', { volume: result.critical ? 0.62 : result.action === 'strategy' ? 0.3 : 0.48, stopAfterMs: 650 }); } - const impact = this.trackCombatObject( - this.add.star(x, y, 8, 12, result.critical ? 54 : 42, result.critical ? 0xff8f5f : result.action === 'strategy' ? 0x6cc5ff : 0xffdf7b, 0.92) - ); + const impact = this.trackCombatObject(this.add.star(x, y, 8, 12, result.critical ? 54 : 42, this.combatImpactColor(result), 0.92)); impact.setDepth(depth); this.tweens.add({ targets: impact, @@ -14136,6 +14279,23 @@ export class BattleScene extends Phaser.Scene { }); } + private combatImpactColor(result: Pick) { + if (result.critical) { + return 0xff8f5f; + } + if (result.action === 'strategy') { + const motionKind = this.specialDamageMotionKind(result); + if (motionKind === 'fire') { + return 0xff6a2f; + } + if (motionKind === 'roar') { + return 0xd8b15f; + } + return 0x6cc5ff; + } + return 0xffdf7b; + } + private showCombatMiss(x: number, y: number, depth: number) { const miss = this.trackCombatObject(this.add.text(x, y, '회피', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',