From 30cac6c7ed0518d33cc57573e05f29a65b238914 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sun, 28 Jun 2026 16:13:07 +0900 Subject: [PATCH] Enhance combat action feedback --- src/game/audio/SoundDirector.ts | 45 +++++ src/game/scenes/BattleScene.ts | 344 +++++++++++++++++++++++++++++--- 2 files changed, 363 insertions(+), 26 deletions(-) diff --git a/src/game/audio/SoundDirector.ts b/src/game/audio/SoundDirector.ts index 2102c41..15a4acf 100644 --- a/src/game/audio/SoundDirector.ts +++ b/src/game/audio/SoundDirector.ts @@ -109,6 +109,51 @@ class SoundDirector { window.setTimeout(() => this.playTone(980, 0.12, 0.034, 'sine'), 168); } + playMeleeRush(mounted: boolean) { + if (mounted) { + this.playEffect('horse-gallop', { volume: 0.2, rate: 1.18, stopAfterMs: 360 }); + return; + } + + this.playEffect('footstep-walk', { volume: 0.2, rate: 1.35, stopAfterMs: 320 }); + } + + playMeleeSwing(hit: boolean, critical: boolean) { + this.playEffect('sword-slash', { + volume: critical ? 0.62 : hit ? 0.52 : 0.38, + rate: critical ? 0.94 : hit ? 1 : 1.1, + stopAfterMs: 640 + }); + this.playTone(critical ? 180 : 220, 0.04, critical ? 0.03 : 0.02, 'sawtooth'); + } + + playArrowShot() { + this.playEffect('sword-slash', { volume: 0.18, rate: 1.58, stopAfterMs: 280 }); + this.playTone(760, 0.05, 0.012, 'triangle'); + window.setTimeout(() => this.playTone(1180, 0.045, 0.01, 'sine'), 46); + } + + playArrowImpact(hit: boolean, critical: boolean) { + if (hit) { + this.playEffect('combat-impact', { volume: critical ? 0.5 : 0.34, rate: critical ? 1.06 : 1.18, stopAfterMs: 420 }); + this.playTone(critical ? 260 : 340, 0.05, critical ? 0.026 : 0.018, 'square'); + return; + } + + this.playTone(940, 0.06, 0.011, 'sine'); + } + + playStrategyPulse() { + this.playEffect('strategy-cast', { volume: 0.44, rate: 0.96, stopAfterMs: 960 }); + this.playTone(460, 0.08, 0.012, 'sine'); + window.setTimeout(() => this.playTone(690, 0.08, 0.012, 'triangle'), 78); + } + + playItemLaunch() { + this.playEffect('cart-roll', { volume: 0.34, rate: 1.06, stopAfterMs: 740 }); + this.playTone(180, 0.055, 0.02, 'sawtooth'); + } + playEffect(key: string, options: PlayEffectOptions = {}) { if (!this.started || this.muted) { return false; diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 7139a43..2a0006e 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -7529,35 +7529,183 @@ export class BattleScene extends Phaser.Scene { depth: number, defenderDirection: UnitDirection ) { - const direction = result.attacker.faction === 'ally' ? 1 : -1; + const direction = attackerX <= defenderX ? 1 : -1; if (result.action === 'attack') { - soundDirector.playEffect('sword-slash', { volume: result.hit ? 0.52 : 0.38, stopAfterMs: 700 }); - this.tweens.add({ - targets: attackerSprite, - x: attackerX + direction * 84, - duration: 170, - yoyo: true, - ease: 'Sine.easeInOut' - }); - await this.delay(180); - if (result.hit) { - defenderSprite.setTexture(this.unitActionTexture(result.defender), this.unitActionFrameIndex(defenderDirection, 'hurt')); + if (this.isRangedAttack(result)) { + await this.playRangedAttackMotion(result, attackerSprite, defenderSprite, attackerX, defenderX, groundY, depth, defenderDirection, direction); + return; } - this.tweens.add({ - targets: defenderSprite, - x: defenderX + direction * (result.hit ? 12 : 28), - y: result.hit ? defenderSprite.y : defenderSprite.y - 10, - duration: result.hit ? 70 : 120, - yoyo: true - }); - await this.delay(230); + + await this.playMeleeAttackMotion(result, attackerSprite, defenderSprite, attackerX, defenderX, groundY, depth, defenderDirection, direction); return; } - soundDirector.playEffect(result.action === 'item' ? 'cart-roll' : 'strategy-cast', { - volume: result.action === 'item' ? 0.34 : 0.44, - stopAfterMs: result.action === 'item' ? 720 : 950 + await this.playSpecialActionMotion(result, attackerSprite, defenderSprite, attackerX, defenderX, groundY, depth, defenderDirection, direction); + } + + private isRangedAttack(result: Pick) { + return result.action === 'attack' && (result.attacker.classKey === 'archer' || this.attackRange(result.attacker) > 1); + } + + private async playMeleeAttackMotion( + result: CombatResult, + attackerSprite: Phaser.GameObjects.Sprite, + defenderSprite: Phaser.GameObjects.Sprite, + attackerX: number, + defenderX: number, + groundY: number, + depth: number, + defenderDirection: UnitDirection, + direction: number + ) { + const mounted = result.attacker.classKey === 'cavalry'; + const windupX = attackerX - direction * 28; + const contactX = defenderX - direction * 126; + + soundDirector.playMeleeRush(mounted); + this.createDustPuff(attackerX - direction * 18, groundY + 48, depth - 1, mounted ? 1.08 : 0.82); + this.tweens.add({ + targets: attackerSprite, + x: windupX, + y: groundY + 5, + scaleX: 0.96, + duration: 110, + ease: 'Sine.easeIn' }); + await this.delay(115); + + this.createDashTrail(attackerX - direction * 6, groundY - 30, contactX - direction * 18, groundY - 40, direction, depth); + this.createDustPuff(attackerX + direction * 18, groundY + 48, depth - 1, mounted ? 1.2 : 0.92); + this.tweens.add({ + targets: attackerSprite, + x: contactX, + y: groundY - (mounted ? 4 : 2), + scaleX: 1.05, + duration: mounted ? 170 : 205, + ease: 'Cubic.easeIn' + }); + await this.delay(mounted ? 170 : 205); + + soundDirector.playMeleeSwing(result.hit, result.critical); + attackerSprite.setTexture(this.unitActionTexture(result.attacker), this.unitActionFrameIndex('east', 'attack')); + this.createSlashArc(defenderX - direction * 68, groundY - 48, direction, depth + 2, result.critical); + + if (result.hit) { + defenderSprite.setTexture(this.unitActionTexture(result.defender), this.unitActionFrameIndex(defenderDirection, 'hurt')); + this.cameras.main.shake(result.critical ? 130 : 90, result.critical ? 0.006 : 0.0035); + this.shakeCombatSprite(defenderSprite, defenderX, groundY, direction, result.critical); + await this.delay(result.critical ? 130 : 92); + } else { + this.tweens.add({ + targets: defenderSprite, + x: defenderX + direction * 36, + y: groundY - 16, + duration: 120, + yoyo: true, + ease: 'Sine.easeOut' + }); + await this.delay(126); + } + + this.tweens.add({ + targets: attackerSprite, + x: attackerX, + y: groundY, + scaleX: 1, + duration: 180, + ease: 'Sine.easeOut' + }); + await this.delay(190); + } + + private async playRangedAttackMotion( + result: CombatResult, + attackerSprite: Phaser.GameObjects.Sprite, + defenderSprite: Phaser.GameObjects.Sprite, + attackerX: number, + defenderX: number, + groundY: number, + depth: number, + defenderDirection: UnitDirection, + direction: number + ) { + attackerSprite.setTexture(this.unitActionTexture(result.attacker), this.unitActionFrameIndex('east', 'attack')); + this.createBowDrawEffect(attackerX + direction * 44, groundY - 52, direction, depth + 2); + this.tweens.add({ + targets: attackerSprite, + x: attackerX - direction * 12, + y: groundY + 2, + duration: 150, + yoyo: true, + ease: 'Sine.easeInOut' + }); + await this.delay(150); + + soundDirector.playArrowShot(); + const arrow = this.createArrowProjectile(attackerX + direction * 74, groundY - 62, direction, depth + 4); + const targetX = defenderX - direction * (result.hit ? 70 : 18); + const targetY = result.hit ? groundY - 62 : groundY - 104; + this.tweens.add({ + targets: arrow, + x: targetX, + y: targetY, + angle: result.hit ? -4 * direction : -24 * direction, + duration: result.hit ? 330 : 380, + ease: result.hit ? 'Cubic.easeIn' : 'Sine.easeOut' + }); + await this.delay(result.hit ? 332 : 382); + + if (result.hit) { + defenderSprite.setTexture(this.unitActionTexture(result.defender), this.unitActionFrameIndex(defenderDirection, 'hurt')); + this.createArrowImpact(defenderX - direction * 58, groundY - 60, direction, depth + 5, result.critical); + this.shakeCombatSprite(defenderSprite, defenderX, groundY, direction, result.critical); + arrow.destroy(); + await this.delay(result.critical ? 115 : 82); + return; + } + + soundDirector.playArrowImpact(false, false); + this.tweens.add({ + targets: arrow, + x: arrow.x + direction * 58, + y: arrow.y + 42, + alpha: 0, + angle: arrow.angle + direction * 36, + duration: 180, + ease: 'Sine.easeIn' + }); + await this.delay(190); + arrow.destroy(); + } + + private async playSpecialActionMotion( + result: CombatResult, + attackerSprite: Phaser.GameObjects.Sprite, + defenderSprite: Phaser.GameObjects.Sprite, + attackerX: number, + defenderX: number, + groundY: number, + depth: number, + defenderDirection: UnitDirection, + direction: number + ) { + if (result.action === 'item') { + soundDirector.playItemLaunch(); + } else { + soundDirector.playStrategyPulse(); + } + + attackerSprite.setTexture(this.unitActionTexture(result.attacker), this.unitActionFrameIndex('east', this.actionPoseForCommand(result.action))); + this.createCasterPulse(attackerX + direction * 48, groundY - 50, depth + 2, result.action === 'item' ? 0xd8b15f : 0x6cc5ff); + this.tweens.add({ + targets: attackerSprite, + scale: 1.08, + y: groundY - 4, + duration: 150, + yoyo: true, + ease: 'Sine.easeInOut' + }); + await this.delay(155); const projectile = result.action === 'item' @@ -7575,8 +7723,142 @@ export class BattleScene extends Phaser.Scene { await this.delay(450); if (result.hit) { defenderSprite.setTexture(this.unitActionTexture(result.defender), this.unitActionFrameIndex(defenderDirection, 'hurt')); + this.createSpecialImpact(defenderX - direction * 68, result.action === 'item' ? groundY - 20 : groundY - 56, depth + 4, result.action); + this.shakeCombatSprite(defenderSprite, defenderX, groundY, direction, result.critical); } projectile.destroy(); + await this.delay(110); + } + + private createDustPuff(x: number, y: number, depth: number, scale = 1) { + const puff = this.trackCombatObject(this.add.container(x, y)); + puff.setDepth(depth); + const left = this.add.circle(-12, 0, 9 * scale, 0xcdb48c, 0.34); + const center = this.add.circle(0, -4, 13 * scale, 0xd9c49a, 0.28); + const right = this.add.circle(12, 1, 8 * scale, 0xb99b72, 0.3); + puff.add([left, center, right]); + this.tweens.add({ targets: puff, y: y - 8, scale: 1.4, alpha: 0, duration: 360, ease: 'Sine.easeOut' }); + return puff; + } + + private createDashTrail(startX: number, startY: number, endX: number, endY: number, direction: number, depth: number) { + const trail = this.trackCombatObject(this.add.graphics()); + trail.setDepth(depth); + trail.lineStyle(9, 0xffdf7b, 0.22); + trail.lineBetween(startX - direction * 16, startY + 8, endX - direction * 20, endY + 6); + trail.lineStyle(5, 0xf7f0d0, 0.36); + trail.lineBetween(startX, startY - 3, endX, endY - 8); + trail.lineStyle(3, 0x84c8ff, 0.18); + trail.lineBetween(startX - direction * 8, startY + 20, endX - direction * 12, endY + 16); + this.tweens.add({ + targets: trail, + alpha: 0, + x: -direction * 18, + duration: 260, + ease: 'Sine.easeOut', + onComplete: () => trail.destroy() + }); + return trail; + } + + private createSlashArc(x: number, y: number, direction: number, depth: number, critical: boolean) { + const slash = this.trackCombatObject(this.add.graphics()); + slash.setDepth(depth); + slash.lineStyle(critical ? 12 : 9, critical ? 0xff8f5f : 0xfff0b8, critical ? 0.94 : 0.86); + slash.beginPath(); + slash.moveTo(x - direction * 42, y - 34); + slash.lineTo(x + direction * 8, y - 4); + slash.lineTo(x + direction * 46, y + 30); + slash.strokePath(); + slash.lineStyle(critical ? 5 : 4, 0xffffff, 0.68); + slash.beginPath(); + slash.moveTo(x - direction * 32, y - 26); + slash.lineTo(x + direction * 36, y + 24); + slash.strokePath(); + const spark = this.trackCombatObject(this.add.star(x + direction * 14, y + 2, 7, 7, critical ? 34 : 25, critical ? 0xff6f4f : 0xffdf7b, 0.86)); + spark.setDepth(depth + 1); + this.tweens.add({ targets: slash, alpha: 0, scale: 1.24, duration: 230, ease: 'Sine.easeOut', onComplete: () => slash.destroy() }); + this.tweens.add({ targets: spark, alpha: 0, scale: critical ? 1.8 : 1.45, angle: 160 * direction, duration: 280, ease: 'Sine.easeOut' }); + } + + private shakeCombatSprite(sprite: Phaser.GameObjects.Sprite, baseX: number, baseY: number, direction: number, critical: boolean) { + this.tweens.add({ + targets: sprite, + x: baseX + direction * (critical ? 24 : 15), + y: baseY - (critical ? 8 : 4), + duration: critical ? 46 : 54, + yoyo: true, + repeat: critical ? 2 : 1, + ease: 'Sine.easeInOut' + }); + } + + private createBowDrawEffect(x: number, y: number, direction: number, depth: number) { + const draw = this.trackCombatObject(this.add.graphics()); + draw.setDepth(depth); + draw.lineStyle(4, 0xd8b15f, 0.86); + draw.beginPath(); + draw.arc(x, y, 32, Phaser.Math.DegToRad(-62), Phaser.Math.DegToRad(62), false); + draw.strokePath(); + draw.lineStyle(2, 0xf4e7c2, 0.74); + draw.lineBetween(x + direction * 9, y - 28, x - direction * 23, y); + draw.lineBetween(x - direction * 23, y, x + direction * 9, y + 28); + const notch = this.trackCombatObject(this.add.circle(x - direction * 25, y, 5, 0xffdf7b, 0.82)); + notch.setDepth(depth + 1); + this.tweens.add({ targets: [draw, notch], alpha: 0, x: -direction * 10, duration: 260, ease: 'Sine.easeOut' }); + } + + private createArrowProjectile(x: number, y: number, direction: number, depth: number) { + const arrow = this.trackCombatObject(this.add.container(x, y)); + arrow.setDepth(depth); + const shaft = this.add.rectangle(0, 0, 64, 4, 0xf2d7a1, 1); + shaft.setStrokeStyle(1, 0x6f5130, 0.8); + const head = this.add.triangle(direction * 39, 0, direction * 25, -8, direction * 45, 0, direction * 25, 8, 0xd8dfe6, 1); + head.setStrokeStyle(1, 0x45515c, 0.9); + const featherTop = this.add.triangle(-direction * 31, -4, -direction * 45, -11, -direction * 39, -1, -direction * 27, 3, 0xb84f3f, 0.96); + const featherBottom = this.add.triangle(-direction * 31, 4, -direction * 45, 11, -direction * 39, 1, -direction * 27, -3, 0xb84f3f, 0.96); + const glint = this.add.rectangle(direction * 10, -5, 32, 2, 0xffffff, 0.48); + arrow.add([shaft, head, featherTop, featherBottom, glint]); + arrow.setAngle(-5 * direction); + return arrow; + } + + private createArrowImpact(x: number, y: number, direction: number, depth: number, critical: boolean) { + const burst = this.trackCombatObject(this.add.graphics()); + burst.setDepth(depth); + burst.lineStyle(critical ? 5 : 4, critical ? 0xff8f5f : 0xffdf7b, 0.88); + for (let index = 0; index < 7; index += 1) { + const angle = -70 + index * 22; + const radians = Phaser.Math.DegToRad(angle); + const length = critical ? 38 : 28; + burst.lineBetween(x, y, x - direction * Math.cos(radians) * length, y + Math.sin(radians) * length); + } + const stuck = this.trackCombatObject(this.add.rectangle(x - direction * 10, y + 2, 42, 4, 0xf2d7a1, 0.92)); + stuck.setDepth(depth + 1); + stuck.setAngle(-12 * direction); + this.tweens.add({ targets: burst, alpha: 0, scale: critical ? 1.5 : 1.25, duration: 260, ease: 'Sine.easeOut', onComplete: () => burst.destroy() }); + this.tweens.add({ targets: stuck, alpha: 0, x: stuck.x - direction * 8, duration: 360, ease: 'Sine.easeOut' }); + } + + private createCasterPulse(x: number, y: number, depth: number, color: number) { + const ring = this.trackCombatObject(this.add.circle(x, y, 28)); + ring.setDepth(depth); + ring.setStrokeStyle(3, color, 0.82); + const core = this.trackCombatObject(this.add.circle(x, y, 12, color, 0.28)); + core.setDepth(depth - 1); + 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; + 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 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' }); } private createCombatCart(x: number, y: number, depth: number) { @@ -7589,7 +7871,10 @@ export class BattleScene extends Phaser.Scene { const rightWheel = this.add.circle(18, 15, 7, 0x1b1511, 1); const blade = this.add.triangle(35, -4, 0, -10, 22, 0, 0, 10, 0xd8dfe6, 1); blade.setStrokeStyle(1, 0x4d5960, 1); - container.add([body, cover, leftWheel, rightWheel, blade]); + const spark = this.add.star(34, -18, 5, 4, 12, 0xffdf7b, 0.72); + container.add([body, cover, leftWheel, rightWheel, blade, spark]); + this.tweens.add({ targets: [leftWheel, rightWheel], angle: 720, duration: 430 }); + this.tweens.add({ targets: spark, angle: 180, alpha: 0.2, duration: 430, yoyo: true }); return container; } @@ -7600,7 +7885,10 @@ export class BattleScene extends Phaser.Scene { const ring = this.add.circle(0, 0, 24); ring.setStrokeStyle(3, 0xd8b15f, 0.9); const spark = this.add.star(0, 0, 5, 7, 18, 0xf7e6a1, 0.9); - container.add([ring, core, spark]); + const outer = this.add.circle(0, 0, 38); + outer.setStrokeStyle(2, 0x6cc5ff, 0.58); + container.add([outer, ring, core, spark]); + this.tweens.add({ targets: outer, scale: 1.28, alpha: 0.18, duration: 430 }); this.tweens.add({ targets: ring, angle: 180, duration: 430 }); this.tweens.add({ targets: spark, angle: -240, duration: 430 }); return container; @@ -7708,7 +7996,11 @@ export class BattleScene extends Phaser.Scene { } private showCombatImpact(result: CombatResult, x: number, y: number, depth: number) { - soundDirector.playEffect('combat-impact', { volume: result.critical ? 0.62 : result.action === 'strategy' ? 0.3 : 0.48, stopAfterMs: 650 }); + if (this.isRangedAttack(result)) { + soundDirector.playArrowImpact(true, result.critical); + } else { + 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)