Add battle speed controls

This commit is contained in:
2026-07-02 23:48:34 +09:00
parent 136766e676
commit 9856fdb442

View File

@@ -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<BattleSpeed, string> = {
normal: '보통',
fast: '빠름',
'very-fast': '매우 빠름'
};
const battleSpeedFactors: Record<BattleSpeed, number> = {
normal: 1,
fast: 1.45,
'very-fast': 2.05
};
type TurnEndPromptOptions = {
mode?: TurnPromptMode;
title?: string;
@@ -2812,6 +2826,7 @@ const mapMenuLabels: Partial<Record<MapMenuAction, string>> = {
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<Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text> = [];
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<void>((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<void>((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<void>((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());
});
}