Add battle speed controls
This commit is contained in:
@@ -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());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user