diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 0e7f806..a98de06 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1100,12 +1100,26 @@ type UsableEffect = 'damage' | 'heal' | 'focus'; type UsableTarget = 'enemy' | 'ally' | 'self'; type RosterTab = 'ally' | 'enemy'; type ActiveFaction = 'ally' | 'enemy'; -type MapMenuAction = 'endTurn' | 'threat' | 'save' | 'load' | 'roster' | 'bond' | 'situation' | 'bgm' | 'close'; +const battleSpeedOptions = ['normal', 'fast', 'very-fast'] as const; +type BattleSpeed = (typeof battleSpeedOptions)[number]; +type MapMenuAction = 'endTurn' | 'threat' | 'save' | 'load' | 'roster' | 'bond' | 'situation' | 'speed' | 'bgm' | 'close'; type EnemyAiBehavior = 'aggressive' | 'guard' | 'hold'; type BattleOutcome = 'victory' | 'defeat'; type SaveSlotMode = 'save' | 'load'; type TurnPromptMode = 'turn-end' | 'post-move'; +const battleSpeedStorageKey = 'heros-web:battle-speed'; +const battleSpeedLabels: Record = { + normal: '보통', + fast: '빠름', + 'very-fast': '매우 빠름' +}; +const battleSpeedFactors: Record = { + normal: 1, + fast: 1.45, + 'very-fast': 2.05 +}; + type TurnEndPromptOptions = { mode?: TurnPromptMode; title?: string; @@ -2812,6 +2826,7 @@ const mapMenuLabels: Partial> = { roster: '부대 일람', bond: '공명', situation: '전황', + speed: '속도', bgm: 'BGM', close: '닫기' }; @@ -2914,6 +2929,9 @@ export class BattleScene extends Phaser.Scene { private commandMenuObjects: Phaser.GameObjects.GameObject[] = []; private mapMenuObjects: Array = []; private mapMenuLayout?: MapMenuLayout; + private battleSpeed: BattleSpeed = 'normal'; + private fastForwardHeld = false; + private battleSpeedControlObjects: Phaser.GameObjects.GameObject[] = []; private alertObjects: Phaser.GameObjects.GameObject[] = []; private alertDismissEvent?: Phaser.Time.TimerEvent; private battleEventObjects: Phaser.GameObjects.GameObject[] = []; @@ -2994,6 +3012,8 @@ export class BattleScene extends Phaser.Scene { this.lockedTargetPreview = undefined; this.battleOutcome = undefined; this.phase = 'idle'; + this.battleSpeed = this.loadBattleSpeed(); + this.fastForwardHeld = false; soundDirector.playMusic('battle-prep'); this.input.mouse?.disableContextMenu(); this.installBattleAudioUnlock(); @@ -3013,6 +3033,24 @@ export class BattleScene extends Phaser.Scene { this.input.keyboard?.on('keydown-ESC', () => { this.cancelAttackTargeting(); }); + this.input.keyboard?.on('keydown-SPACE', (event: KeyboardEvent) => { + if (this.activeFaction !== 'enemy' || this.fastForwardHeld) { + return; + } + event.preventDefault(); + this.fastForwardHeld = true; + this.renderBattleSpeedControl(); + }); + this.input.keyboard?.on('keyup-SPACE', () => { + if (!this.fastForwardHeld) { + return; + } + this.fastForwardHeld = false; + this.renderBattleSpeedControl(); + }); + this.input.keyboard?.on('keydown-PERIOD', () => { + this.cycleBattleSpeed(); + }); this.installDebugHotkeys(); this.add.rectangle(0, 0, width, height, 0x080b0d).setOrigin(0); @@ -3329,10 +3367,97 @@ export class BattleScene extends Phaser.Scene { color: '#9aa3ad', wordWrap: { width: panelWidth - 48, useAdvancedWrap: true } }); + this.renderBattleSpeedControl(); this.updateObjectiveTracker(); this.drawMiniMap(); } + private renderBattleSpeedControl() { + this.battleSpeedControlObjects.forEach((object) => object.destroy()); + this.battleSpeedControlObjects = []; + + const { panelX, panelY, panelWidth } = this.layout; + const width = 112; + const height = 28; + const left = panelX + panelWidth - width - 24; + const top = panelY + 74; + const isHoldingFastForward = this.activeFaction === 'enemy' && this.fastForwardHeld; + const label = isHoldingFastForward ? '가속 중' : battleSpeedLabels[this.battleSpeed]; + const accent = isHoldingFastForward ? palette.green : this.battleSpeed === 'normal' ? 0x5a7588 : palette.gold; + + const button = this.add.rectangle(left, top, width, height, 0x17232e, 0.96); + button.setOrigin(0); + button.setDepth(16); + button.setStrokeStyle(1, accent, 0.86); + button.setInteractive({ useHandCursor: true }); + + const text = this.add.text(left + width / 2, top + height / 2 - 1, `속도 ${label}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '13px', + color: '#f2e3bf', + fontStyle: '700' + }); + text.setOrigin(0.5); + text.setDepth(17); + text.setInteractive({ useHandCursor: true }); + + const onSelect = () => this.cycleBattleSpeed(); + button.on('pointerover', () => button.setFillStyle(0x243746, 0.98)); + button.on('pointerout', () => button.setFillStyle(0x17232e, 0.96)); + button.on('pointerdown', onSelect); + text.on('pointerdown', onSelect); + + this.battleSpeedControlObjects.push(button, text); + } + + private isBattleSpeed(value: string | null): value is BattleSpeed { + return battleSpeedOptions.includes(value as BattleSpeed); + } + + private loadBattleSpeed(): BattleSpeed { + if (typeof window === 'undefined') { + return 'normal'; + } + + try { + const saved = window.localStorage.getItem(battleSpeedStorageKey); + return this.isBattleSpeed(saved) ? saved : 'normal'; + } catch { + return 'normal'; + } + } + + private saveBattleSpeed() { + if (typeof window === 'undefined') { + return; + } + + try { + window.localStorage.setItem(battleSpeedStorageKey, this.battleSpeed); + } catch { + // Local storage can fail in private or restricted browser contexts. + } + } + + private cycleBattleSpeed(playSound = true) { + const currentIndex = battleSpeedOptions.indexOf(this.battleSpeed); + this.battleSpeed = battleSpeedOptions[(currentIndex + 1) % battleSpeedOptions.length]; + this.saveBattleSpeed(); + this.renderBattleSpeedControl(); + if (playSound) { + soundDirector.playSelect(); + } + } + + private battleSpeedFactor() { + const heldFastForward = this.activeFaction === 'enemy' && this.fastForwardHeld ? 1.75 : 1; + return battleSpeedFactors[this.battleSpeed] * heldFastForward; + } + + private scaledBattleDuration(duration: number, minDuration = 55) { + return Math.max(minDuration, Math.round(duration / this.battleSpeedFactor())); + } + private drawDebugSpritePreview() { const textureKey = this.debugSpritePreviewKey(); if (!textureKey) { @@ -7740,7 +7865,7 @@ export class BattleScene extends Phaser.Scene { this.phase = 'idle'; this.refreshUnitLegibilityStyles(); - const actions: MapMenuAction[] = ['endTurn', 'threat', 'save', 'load', 'roster', 'bond', 'situation', 'bgm', 'close']; + const actions: MapMenuAction[] = ['endTurn', 'threat', 'save', 'load', 'roster', 'bond', 'situation', 'speed', 'bgm', 'close']; const menuWidth = 148; const buttonHeight = 34; const padding = 8; @@ -7821,6 +7946,10 @@ export class BattleScene extends Phaser.Scene { this.renderSituationPanel(); return; } + if (action === 'speed') { + this.cycleBattleSpeed(false); + return; + } if (action === 'bgm') { soundDirector.setMuted(!soundDirector.isMuted()); this.renderSituationPanel(`BGM을 ${soundDirector.isMuted() ? '껐습니다' : '켰습니다'}.`); @@ -7864,6 +7993,9 @@ export class BattleScene extends Phaser.Scene { if (action === 'load') { return '불러오기'; } + if (action === 'speed') { + return `속도 ${battleSpeedLabels[this.battleSpeed]}`; + } return mapMenuLabels[action] ?? action; } @@ -8233,6 +8365,7 @@ export class BattleScene extends Phaser.Scene { this.activeFaction = 'enemy'; this.updateTurnText(); + this.renderBattleSpeedControl(); this.updateObjectiveTracker(); const recoveryMessage = this.applyTerrainRecovery('enemy'); this.renderSituationPanel(['아군 턴을 종료했습니다.', recoveryMessage, '적군이 행동을 시작합니다.'].filter(Boolean).join('\n')); @@ -8252,6 +8385,7 @@ export class BattleScene extends Phaser.Scene { return; } + this.renderSituationPanel([...messages.slice(-2), `적 행동: ${enemy.name}`].filter(Boolean).join('\n')); const message = await this.executeEnemyAction(enemy); if (message) { messages.push(message); @@ -8271,7 +8405,7 @@ export class BattleScene extends Phaser.Scene { return; } - this.time.delayedCall(520, () => this.beginNextAllyTurn()); + this.time.delayedCall(this.scaledBattleDuration(520, 120), () => this.beginNextAllyTurn()); } private async executeEnemyAction(enemy: UnitData) { @@ -8282,6 +8416,7 @@ export class BattleScene extends Phaser.Scene { let target = this.chooseEnemyTarget(enemy, behavior); if (!target) { + this.renderSituationPanel(`적 행동: ${enemy.name}\n대기`); return `${enemy.name}: 대기`; } @@ -8292,6 +8427,7 @@ export class BattleScene extends Phaser.Scene { const fromY = enemy.y; enemy.x = destination.x; enemy.y = destination.y; + this.renderSituationPanel(`적 행동: ${enemy.name}\n${target.name} 쪽으로 이동`); this.centerCameraOnTile(destination.x, destination.y); await this.moveUnitViewAsync(enemy, destination.x, destination.y, this.unitViews.get(enemy.id), 260, fromX, fromY); } @@ -8299,6 +8435,7 @@ export class BattleScene extends Phaser.Scene { target = this.chooseTargetInRange(enemy) ?? target; if (this.canAttack(enemy, target)) { + this.renderSituationPanel(`적 행동: ${enemy.name}\n${target.name} 공격`); this.centerCameraOnTile(Math.floor((enemy.x + target.x) / 2), Math.floor((enemy.y + target.y) / 2)); const result = this.resolveAttack(enemy, target); await this.playCombatCutIn(result); @@ -9589,7 +9726,7 @@ export class BattleScene extends Phaser.Scene { y: groundY + 5, scaleX: baseScaleX * 0.96, scaleY: baseScaleY, - duration: 110, + duration: this.scaledBattleDuration(110), ease: 'Sine.easeIn' }); await this.delay(115); @@ -9602,7 +9739,7 @@ export class BattleScene extends Phaser.Scene { y: groundY - (mounted ? 4 : 2), scaleX: baseScaleX * 1.05, scaleY: baseScaleY, - duration: mounted ? 170 : 205, + duration: this.scaledBattleDuration(mounted ? 170 : 205), ease: 'Cubic.easeIn' }); await this.delay(mounted ? 170 : 205); @@ -9621,7 +9758,7 @@ export class BattleScene extends Phaser.Scene { targets: defenderSprite, x: defenderX + direction * 36, y: groundY - 16, - duration: 120, + duration: this.scaledBattleDuration(120), yoyo: true, ease: 'Sine.easeOut' }); @@ -9634,7 +9771,7 @@ export class BattleScene extends Phaser.Scene { y: groundY, scaleX: baseScaleX, scaleY: baseScaleY, - duration: 180, + duration: this.scaledBattleDuration(180), ease: 'Sine.easeOut' }); await this.delay(190); @@ -9657,7 +9794,7 @@ export class BattleScene extends Phaser.Scene { targets: attackerSprite, x: attackerX - direction * 12, y: groundY + 2, - duration: 150, + duration: this.scaledBattleDuration(150), yoyo: true, ease: 'Sine.easeInOut' }); @@ -9672,7 +9809,7 @@ export class BattleScene extends Phaser.Scene { x: targetX, y: targetY, angle: result.hit ? -4 * direction : -24 * direction, - duration: result.hit ? 330 : 380, + duration: this.scaledBattleDuration(result.hit ? 330 : 380, 90), ease: result.hit ? 'Cubic.easeIn' : 'Sine.easeOut' }); await this.delay(result.hit ? 332 : 382); @@ -9693,7 +9830,7 @@ export class BattleScene extends Phaser.Scene { y: arrow.y + 42, alpha: 0, angle: arrow.angle + direction * 36, - duration: 180, + duration: this.scaledBattleDuration(180), ease: 'Sine.easeIn' }); await this.delay(190); @@ -9726,7 +9863,7 @@ export class BattleScene extends Phaser.Scene { scaleX: baseScaleX * 1.08, scaleY: baseScaleY * 1.08, y: groundY - 4, - duration: 150, + duration: this.scaledBattleDuration(150), yoyo: true, ease: 'Sine.easeInOut' }); @@ -9742,7 +9879,7 @@ export class BattleScene extends Phaser.Scene { targets: projectile, x: defenderX - direction * (result.hit ? 72 : 12), y: result.hit ? projectile.y : projectile.y - 24, - duration: 430, + duration: this.scaledBattleDuration(430, 100), ease: 'Cubic.easeIn' }); await this.delay(450); @@ -9811,7 +9948,7 @@ export class BattleScene extends Phaser.Scene { targets: sprite, x: baseX + direction * (critical ? 24 : 15), y: baseY - (critical ? 8 : 4), - duration: critical ? 46 : 54, + duration: this.scaledBattleDuration(critical ? 46 : 54, 34), yoyo: true, repeat: critical ? 2 : 1, ease: 'Sine.easeInOut' @@ -10035,7 +10172,7 @@ export class BattleScene extends Phaser.Scene { targets: impact, scale: result.critical ? 1.7 : 1.45, alpha: 0, - duration: 320, + duration: this.scaledBattleDuration(320, 80), ease: 'Sine.easeOut', onComplete: () => impact.destroy() }); @@ -10056,19 +10193,20 @@ export class BattleScene extends Phaser.Scene { targets: miss, y: y - 20, alpha: 0, - duration: 460, + duration: this.scaledBattleDuration(460, 90), ease: 'Sine.easeOut', onComplete: () => miss.destroy() }); } private animateCombatGauge(target: Phaser.GameObjects.Rectangle, fromRatio: number, toRatio: number, duration: number) { + const scaledDuration = this.scaledBattleDuration(duration, 90); target.setScale(Phaser.Math.Clamp(fromRatio, 0, 1), 1); return new Promise((resolve) => { this.tweens.add({ targets: target, scaleX: Phaser.Math.Clamp(toRatio, 0, 1), - duration, + duration: scaledDuration, ease: 'Sine.easeInOut', onComplete: () => resolve() }); @@ -10086,6 +10224,7 @@ export class BattleScene extends Phaser.Scene { leveled: boolean, duration: number ) { + const scaledDuration = this.scaledBattleDuration(duration, 120); target.setScale(Phaser.Math.Clamp(fromRatio, 0, 1), 1); text.setText(`${fromValue} / ${next}`); @@ -10097,7 +10236,7 @@ export class BattleScene extends Phaser.Scene { targets: firstTracker, ratio: 1, value: next, - duration: Math.floor(duration * 0.62), + duration: Math.floor(scaledDuration * 0.62), ease: 'Sine.easeInOut', onUpdate: () => { target.setScale(firstTracker.ratio, 1); @@ -10112,7 +10251,7 @@ export class BattleScene extends Phaser.Scene { targets: secondTracker, ratio: Phaser.Math.Clamp(toRatio, 0, 1), value: toValue, - duration: Math.floor(duration * 0.46), + duration: Math.floor(scaledDuration * 0.46), ease: 'Sine.easeInOut', onUpdate: () => { target.setScale(secondTracker.ratio, 1); @@ -10140,7 +10279,7 @@ export class BattleScene extends Phaser.Scene { targets: tracker, ratio: Phaser.Math.Clamp(toRatio, 0, 1), value: toValue, - duration, + duration: scaledDuration, ease: 'Sine.easeInOut', onUpdate: () => { target.setScale(tracker.ratio, 1); @@ -10174,7 +10313,7 @@ export class BattleScene extends Phaser.Scene { private delay(ms: number) { return new Promise((resolve) => { - this.time.delayedCall(ms, () => resolve()); + this.time.delayedCall(this.scaledBattleDuration(ms), () => resolve()); }); } @@ -10238,11 +10377,13 @@ export class BattleScene extends Phaser.Scene { this.turnNumber += 1; this.tickBattleBuffs(); this.activeFaction = 'ally'; + this.fastForwardHeld = false; this.actedUnitIds.clear(); this.attackIntents = []; const recoveryMessage = this.applyTerrainRecovery('ally'); this.resetActedStyles(); this.updateTurnText(); + this.renderBattleSpeedControl(); this.updateObjectiveTracker(); this.checkBattleEvents(); this.centerCameraOnAllyLine(); @@ -10848,10 +10989,12 @@ export class BattleScene extends Phaser.Scene { } private movementDuration(baseDuration: number, tileDistance: number) { + const duration = + tileDistance <= 1 ? baseDuration : Math.max(baseDuration, Math.min(760, 150 + tileDistance * 105)); if (tileDistance <= 1) { - return baseDuration; + return this.scaledBattleDuration(duration, 82); } - return Math.max(baseDuration, Math.min(760, 150 + tileDistance * 105)); + return this.scaledBattleDuration(duration, 110); } private playMovementSound(unit: UnitData, duration: number, tileDistance = 1) { @@ -11209,15 +11352,16 @@ export class BattleScene extends Phaser.Scene { ) { const frameCount = unitActionFrameCounts[pose]; const totalFrames = frameCount * Math.max(1, loops); + const scaledFrameDuration = this.scaledBattleDuration(frameDuration, 42); return new Promise((resolve) => { for (let index = 0; index < totalFrames; index += 1) { - this.time.delayedCall(index * frameDuration, () => { + this.time.delayedCall(index * scaledFrameDuration, () => { if (sprite.active) { this.setUnitActionFrame(sprite, unit, direction, pose, index % frameCount); } }); } - this.time.delayedCall(totalFrames * frameDuration, () => resolve()); + this.time.delayedCall(totalFrames * scaledFrameDuration, () => resolve()); }); }