Files
heros_web/src/game/scenes/TitleScene.ts

791 lines
24 KiB
TypeScript

import Phaser from 'phaser';
import { soundDirector } from '../audio/SoundDirector';
import { battleIdForCampaignStep, isCampCampaignStep } from '../state/campaignRouting';
import {
hasCampaignSave,
listCampaignSaveSlots,
loadCampaignState,
summarizeCampaignProgress,
startNewCampaign,
type CampaignSaveSlotSummary
} from '../state/campaignState';
import { palette } from '../ui/palette';
import { startLazyScene } from './lazyScenes';
const fhdUiScale = 1.5;
const ui = (value: number) => value * fhdUiScale;
const uiPx = (value: number) => `${Math.round(ui(value))}px`;
export class TitleScene extends Phaser.Scene {
private focusedButton?: Phaser.GameObjects.Text;
private settingsPanel?: Phaser.GameObjects.Container;
private newGameConfirmPanel?: Phaser.GameObjects.Container;
private saveSlotPanel?: Phaser.GameObjects.Container;
private navigating = false;
constructor() {
super('TitleScene');
}
create() {
this.focusedButton = undefined;
this.settingsPanel = undefined;
this.newGameConfirmPanel = undefined;
this.saveSlotPanel = undefined;
this.navigating = false;
const debugBattleId = this.debugBattleId();
if (debugBattleId) {
this.time.delayedCall(0, () => {
void this.navigateTo('BattleScene', { battleId: debugBattleId });
});
return;
}
if (this.shouldOpenDebugSortiePrep()) {
this.time.delayedCall(0, () => {
void this.navigateTo('CampScene', {
openSortiePrep: true,
skipIntroStory: true
});
});
return;
}
const { width, height } = this.scale;
this.drawBackground(width, height);
this.drawAtmosphere(width, height);
this.drawTitleMark(width, height);
this.drawMenu(width, height);
soundDirector.playMusic('title-theme');
const unlockAudio = () => {
soundDirector.start();
soundDirector.resume();
soundDirector.playMusic('title-theme');
};
this.input.once('pointerdown', unlockAudio);
this.input.keyboard?.once('keydown', unlockAudio);
this.input.keyboard?.on('keydown-ENTER', () => this.handleEnterKey());
this.input.keyboard?.on('keydown', (event: KeyboardEvent) => this.handleSaveSlotKey(event));
this.input.keyboard?.on('keydown-ESC', () => this.handleEscapeKey());
}
private shouldOpenDebugSortiePrep() {
if (typeof window === 'undefined') {
return false;
}
const params = new URLSearchParams(window.location.search);
return params.has('debugSortiePrep') && (params.has('debug') || import.meta.env.DEV);
}
private debugBattleId() {
if (typeof window === 'undefined') {
return undefined;
}
const params = new URLSearchParams(window.location.search);
if (!params.has('debugBattle') || (!params.has('debug') && !import.meta.env.DEV)) {
return undefined;
}
return params.get('debugBattle')?.trim() || undefined;
}
private drawBackground(width: number, height: number) {
const background = this.add.image(width / 2, height / 2, 'title-taoyuan');
const texture = this.textures.get('title-taoyuan').getSourceImage();
const coverScale = Math.max(width / texture.width, height / texture.height);
background.setScale(coverScale * 1.08);
background.setPosition(width / 2 - ui(18), height / 2 + ui(4));
this.tweens.add({
targets: background,
x: width / 2 + ui(18),
y: height / 2 - ui(6),
scale: coverScale * 1.12,
duration: 18000,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1
});
this.add.rectangle(0, 0, width, height, 0x080a0e, 0.2).setOrigin(0);
this.add.image(0, 0, this.createShadeTexture(width, height)).setOrigin(0);
}
private createShadeTexture(width: number, height: number) {
const key = 'title-shade';
if (this.textures.exists(key)) {
this.textures.remove(key);
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) {
return key;
}
const rightShade = context.createLinearGradient(width * 0.42, 0, width, 0);
rightShade.addColorStop(0, 'rgba(8, 10, 14, 0)');
rightShade.addColorStop(0.42, 'rgba(8, 10, 14, 0.2)');
rightShade.addColorStop(1, 'rgba(8, 10, 14, 0.66)');
context.fillStyle = rightShade;
context.fillRect(0, 0, width, height);
const bottomShade = context.createLinearGradient(0, height * 0.55, 0, height);
bottomShade.addColorStop(0, 'rgba(8, 10, 14, 0)');
bottomShade.addColorStop(1, 'rgba(8, 10, 14, 0.56)');
context.fillStyle = bottomShade;
context.fillRect(0, 0, width, height);
this.textures.addCanvas(key, canvas);
return key;
}
private drawAtmosphere(width: number, height: number) {
const mist = this.add.ellipse(width / 2, height * 0.78, width * 1.25, ui(190), 0xd8b15f, 0.04);
this.tweens.add({
targets: mist,
alpha: 0.11,
x: width / 2 + ui(22),
duration: 8000,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1
});
for (let index = 0; index < 34; index += 1) {
const petal = this.add.image(
Phaser.Math.Between(0, width),
Phaser.Math.Between(-ui(80), height),
'petal'
);
const scale = Phaser.Math.FloatBetween(0.35, 0.9);
petal.setScale(scale * fhdUiScale);
petal.setAlpha(Phaser.Math.FloatBetween(0.22, 0.62));
petal.setRotation(Phaser.Math.FloatBetween(-1.2, 1.2));
this.tweens.add({
targets: petal,
x: petal.x + Phaser.Math.Between(ui(80), ui(220)),
y: height + Phaser.Math.Between(ui(40), ui(180)),
rotation: petal.rotation + Phaser.Math.FloatBetween(2.4, 5.6),
duration: Phaser.Math.Between(12000, 24000),
delay: Phaser.Math.Between(0, 6000),
ease: 'Sine.easeInOut',
repeat: -1,
onRepeat: () => {
petal.setPosition(Phaser.Math.Between(-ui(120), width), Phaser.Math.Between(-ui(180), -ui(40)));
}
});
}
}
private drawTitleMark(width: number, height: number) {
const compact = width < ui(720);
const titleX = compact ? width / 2 : ui(92);
const title = this.add.text(titleX, height - ui(compact ? 190 : 204), '삼국지', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", serif',
fontSize: uiPx(compact ? 50 : 64),
color: '#f1e3c2',
fontStyle: '700',
padding: { top: ui(8), bottom: ui(8) },
shadow: { offsetX: 0, offsetY: ui(4), color: '#080a0e', blur: ui(10), fill: true }
});
title.setOrigin(compact ? 0.5 : 0, 0);
const subtitle = this.add.text(compact ? width / 2 : ui(96), height - ui(compact ? 112 : 118), '세 형제의 맹세', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", serif',
fontSize: uiPx(compact ? 26 : 34),
color: '#d8b15f',
fontStyle: '700',
padding: { top: ui(10), bottom: ui(8) },
shadow: { offsetX: 0, offsetY: ui(2), color: '#080a0e', blur: ui(6), fill: true }
});
subtitle.setOrigin(compact ? 0.5 : 0, 0);
const lineX = compact ? width / 2 - ui(110) : ui(98);
const lineY = height - ui(compact ? 76 : 70);
this.add.rectangle(lineX, lineY, ui(compact ? 220 : 310), ui(2), palette.gold, 0.68).setOrigin(0, 0.5);
}
private drawMenu(width: number, height: number) {
const compact = width < ui(720);
const menuX = compact ? width / 2 : width - ui(318);
const menuY = height * (compact ? 0.36 : 0.43);
const canContinue = hasCampaignSave();
const campaign = canContinue ? loadCampaignState() : undefined;
const isEndingComplete = campaign?.step === 'ending-complete';
const plateHeight = ui(campaign ? 348 : 252);
const plate = this.add.rectangle(menuX, menuY, ui(250), plateHeight, 0x111821, 0.66);
plate.setStrokeStyle(ui(1), palette.gold, 0.45);
this.add.rectangle(menuX, menuY - plateHeight / 2, ui(186), ui(2), palette.gold, 0.7);
this.add.rectangle(menuX, menuY + plateHeight / 2, ui(186), ui(2), palette.gold, 0.36);
this.createMenuButton(menuX, menuY - ui(70), '새 게임', true, () => this.requestStartGame());
this.createMenuButton(menuX, menuY, isEndingComplete ? '엔딩 보기' : '이어하기', canContinue, () => this.requestContinueGame());
this.createMenuButton(menuX, menuY + ui(70), '설정', true, () => this.openSettingsPanel(menuX, menuY));
if (campaign) {
this.drawCampaignSaveBadge(menuX, menuY + ui(132), campaign, isEndingComplete);
}
}
private drawCampaignSaveBadge(
x: number,
y: number,
campaign: ReturnType<typeof loadCampaignState>,
isEndingComplete: boolean
) {
const badge = this.add.container(x, y);
const background = this.add.rectangle(0, 0, ui(216), ui(78), 0x21180f, 0.92);
background.setStrokeStyle(ui(1), palette.gold, 0.52);
const title = this.add.text(0, -ui(24), isEndingComplete ? '완결 저장' : '진행 저장', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(18),
color: '#f6e6bd',
fontStyle: '700',
fixedWidth: ui(198),
align: 'center',
padding: { top: ui(3), bottom: ui(3) }
});
title.setOrigin(0.5);
const detail = this.add.text(0, 0, this.campaignSaveDetail(campaign, isEndingComplete), {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(14),
color: '#d8b15f',
fixedWidth: ui(198),
align: 'center'
});
detail.setOrigin(0.5);
const meta = this.add.text(0, ui(24), this.campaignSaveMeta(campaign), {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(12),
color: '#9fb0bf',
fixedWidth: ui(198),
align: 'center'
});
meta.setOrigin(0.5);
badge.add([background, title, detail, meta]);
}
private campaignSaveDetail(campaign: ReturnType<typeof loadCampaignState>, isEndingComplete: boolean) {
if (isEndingComplete) {
return this.compactSaveDetail(`${Object.keys(campaign.battleHistory).length}전 완료 · ${campaign.roster.length}명 합류`);
}
const progress = summarizeCampaignProgress(campaign);
return this.compactSaveDetail(`${progress.meta} · ${progress.title}`);
}
private compactSaveDetail(label: string) {
return label.length > 19 ? `${label.slice(0, 18)}...` : label;
}
private campaignSaveMeta(campaign: ReturnType<typeof loadCampaignState>) {
return `슬롯 ${campaign.activeSaveSlot} · ${this.formatSaveUpdatedAt(campaign.updatedAt)}`;
}
private formatSaveUpdatedAt(updatedAt: string) {
const date = new Date(updatedAt);
if (Number.isNaN(date.getTime())) {
return '저장 시각 미상';
}
const month = date.getMonth() + 1;
const day = date.getDate();
const hour = String(date.getHours()).padStart(2, '0');
const minute = String(date.getMinutes()).padStart(2, '0');
return `${month}/${day} ${hour}:${minute}`;
}
private createMenuButton(
x: number,
y: number,
label: string,
enabled: boolean,
onSelect: () => void
) {
const text = this.add.text(x, y, label, {
fontSize: uiPx(30),
color: enabled ? '#f1e3c2' : '#7d8189',
fontStyle: '700',
fixedWidth: ui(190),
align: 'center',
padding: { top: ui(10), bottom: ui(10) }
});
text.setOrigin(0.5);
text.setShadow(0, ui(3), '#080a0e', ui(8), true, true);
if (!enabled) {
return text;
}
const hitArea = this.add.zone(x, y, ui(216), ui(58));
hitArea.setOrigin(0.5);
hitArea.setInteractive({ useHandCursor: true });
hitArea.on('pointerover', () => {
this.focusButton(text);
soundDirector.playHover();
});
hitArea.on('pointerout', () => this.unfocusButton(text));
hitArea.on('pointerdown', () => {
soundDirector.start();
soundDirector.resume();
onSelect();
});
return text;
}
private focusButton(text: Phaser.GameObjects.Text) {
this.focusedButton = text;
text.setColor('#111821');
text.setBackgroundColor('#d8b15f');
}
private unfocusButton(text: Phaser.GameObjects.Text) {
if (this.focusedButton === text) {
this.focusedButton = undefined;
}
text.setColor('#f1e3c2');
text.setBackgroundColor('rgba(0,0,0,0)');
}
private openSettingsPanel(x: number, y: number) {
if (this.settingsPanel) {
this.closeSettingsPanel();
return;
}
soundDirector.playSelect();
this.closeNewGameConfirmPanel();
this.closeSaveSlotPanel();
const panel = this.add.container(x, y + ui(238));
const backdrop = this.add.rectangle(0, 0, ui(286), ui(204), 0x0e151d, 0.9);
backdrop.setStrokeStyle(ui(1), palette.gold, 0.52);
const title = this.add.text(0, -ui(74), '설정', {
fontSize: uiPx(24),
color: '#f1e3c2',
fontStyle: '700',
fixedWidth: ui(210),
align: 'center'
});
title.setOrigin(0.5);
const soundText = this.createSettingsButton(0, -ui(16), this.soundLabel());
soundText.on('pointerdown', () => {
soundDirector.setMuted(!soundDirector.isMuted());
soundText.setText(this.soundLabel());
soundDirector.playSelect();
});
const close = this.createSettingsButton(0, ui(42), '닫기');
close.on('pointerdown', () => {
soundDirector.playSelect();
this.closeSettingsPanel();
});
const hint = this.add.text(0, ui(78), 'Esc 닫기', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(11),
color: '#9fb0bf',
fontStyle: '700',
fixedWidth: ui(210),
align: 'center'
});
hint.setOrigin(0.5);
panel.add([backdrop, title, soundText, close, hint]);
panel.setAlpha(0);
this.settingsPanel = panel;
this.tweens.add({ targets: panel, alpha: 1, y: y + ui(228), duration: 160, ease: 'Sine.easeOut' });
}
private createSettingsButton(x: number, y: number, label: string) {
const text = this.add.text(x, y, label, {
fontSize: uiPx(20),
color: '#d8b15f',
fixedWidth: ui(210),
align: 'center',
padding: { top: ui(8), bottom: ui(8) }
});
text.setOrigin(0.5);
text.setInteractive({ useHandCursor: true });
text.on('pointerover', () => text.setColor('#f1e3c2'));
text.on('pointerout', () => text.setColor('#d8b15f'));
return text;
}
private soundLabel() {
return `음향: ${soundDirector.isMuted() ? '꺼짐' : '켜짐'}`;
}
private closeSettingsPanel() {
this.settingsPanel?.destroy();
this.settingsPanel = undefined;
}
private requestContinueGame() {
if (!hasCampaignSave()) {
return;
}
const slots = listCampaignSaveSlots().filter((slot) => slot.exists);
if (slots.length <= 1) {
this.continueGame(slots[0]?.slot);
return;
}
const { width, height } = this.scale;
const compact = width < ui(720);
const menuX = compact ? width / 2 : width - ui(318);
const menuY = height * (compact ? 0.36 : 0.43);
this.openSaveSlotPanel(menuX, menuY);
}
private openSaveSlotPanel(x: number, y: number) {
if (this.saveSlotPanel) {
this.closeSaveSlotPanel();
return;
}
soundDirector.playSelect();
this.closeSettingsPanel();
this.closeNewGameConfirmPanel();
const panel = this.add.container(x, y + ui(252));
const backdrop = this.add.rectangle(0, 0, ui(318), ui(280), 0x0e151d, 0.95);
backdrop.setStrokeStyle(ui(1), palette.gold, 0.58);
const title = this.add.text(0, -ui(116), '이어갈 기록 선택 (1~3)', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(22),
color: '#f1e3c2',
fontStyle: '700',
fixedWidth: ui(266),
align: 'center'
});
title.setOrigin(0.5);
const rows = listCampaignSaveSlots().map((slot, index) => this.createSaveSlotRow(slot, -ui(82) + index * ui(58)));
const close = this.createSettingsButton(0, ui(96), '닫기');
close.on('pointerdown', () => {
soundDirector.playSelect();
this.closeSaveSlotPanel();
});
const hint = this.add.text(0, ui(126), '숫자키 1~3 선택 · Esc 닫기', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(11),
color: '#9fb0bf',
fontStyle: '700',
fixedWidth: ui(260),
align: 'center'
});
hint.setOrigin(0.5);
panel.add([backdrop, title, ...rows, close, hint]);
panel.setAlpha(0);
this.saveSlotPanel = panel;
this.tweens.add({ targets: panel, alpha: 1, y: y + ui(242), duration: 160, ease: 'Sine.easeOut' });
}
private createSaveSlotRow(slot: CampaignSaveSlotSummary, y: number) {
const row = this.add.container(0, y);
const enabled = slot.exists;
const background = this.add.rectangle(0, 0, ui(270), ui(48), enabled ? 0x1b2530 : 0x121820, enabled ? 0.9 : 0.56);
background.setStrokeStyle(ui(1), enabled ? palette.gold : 0x56616d, enabled ? 0.42 : 0.2);
const campaign = enabled ? loadCampaignState(slot.slot) : undefined;
const isEndingComplete = campaign?.step === 'ending-complete';
const title = this.add.text(-ui(122), -ui(12), `슬롯 ${slot.slot}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(16),
color: enabled ? '#f6e6bd' : '#7d8189',
fontStyle: '700',
fixedWidth: ui(70),
align: 'left'
});
title.setOrigin(0, 0.5);
const detail = this.add.text(-ui(42), -ui(12), campaign ? this.campaignSaveDetail(campaign, isEndingComplete) : '저장 없음', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(13),
color: enabled ? '#d8b15f' : '#747c86',
fixedWidth: ui(154),
align: 'left'
});
detail.setOrigin(0, 0.5);
const meta = this.add.text(-ui(42), ui(12), campaign ? this.campaignSaveMeta(campaign) : '빈 슬롯', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(11),
color: enabled ? '#9fb0bf' : '#626b75',
fixedWidth: ui(154),
align: 'left'
});
meta.setOrigin(0, 0.5);
const keycap = this.add.rectangle(ui(112), 0, ui(28), ui(28), enabled ? 0x263647 : 0x151c25, enabled ? 0.92 : 0.58);
keycap.setStrokeStyle(ui(1), enabled ? palette.gold : 0x53606c, enabled ? 0.54 : 0.24);
const keyText = this.add.text(ui(112), 0, String(slot.slot), {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(15),
color: enabled ? '#f2e3bf' : '#737c86',
fontStyle: '700'
});
keyText.setOrigin(0.5);
row.add([background, title, detail, meta, keycap, keyText]);
if (enabled) {
background.setInteractive({ useHandCursor: true });
background.on('pointerover', () => {
background.setFillStyle(0xd8b15f, 0.22);
background.setStrokeStyle(ui(1), palette.gold, 0.72);
soundDirector.playHover();
});
background.on('pointerout', () => {
background.setFillStyle(0x1b2530, 0.9);
background.setStrokeStyle(ui(1), palette.gold, 0.42);
});
background.on('pointerdown', () => {
this.activateSaveSlot(slot.slot);
});
}
return row;
}
private handleSaveSlotKey(event: KeyboardEvent) {
if (!this.saveSlotPanel) {
return;
}
const slot = Number(event.key);
if (!Number.isInteger(slot)) {
return;
}
event.preventDefault();
this.activateSaveSlot(slot);
}
private activateSaveSlot(slot: number) {
if (!listCampaignSaveSlots().some((summary) => summary.slot === slot && summary.exists)) {
return;
}
this.closeSaveSlotPanel();
this.continueGame(slot);
}
private closeSaveSlotPanel() {
this.saveSlotPanel?.destroy();
this.saveSlotPanel = undefined;
}
private handleEnterKey() {
if (this.saveSlotPanel) {
return;
}
if (this.newGameConfirmPanel) {
this.closeNewGameConfirmPanel();
this.startGame();
return;
}
if (this.settingsPanel) {
return;
}
this.activateDefaultMenuAction();
}
private handleEscapeKey() {
if (this.saveSlotPanel) {
soundDirector.playSelect();
this.closeSaveSlotPanel();
return;
}
if (this.newGameConfirmPanel) {
soundDirector.playSelect();
this.closeNewGameConfirmPanel();
return;
}
if (this.settingsPanel) {
soundDirector.playSelect();
this.closeSettingsPanel();
}
}
private requestStartGame() {
if (!hasCampaignSave()) {
this.startGame();
return;
}
const { width, height } = this.scale;
const compact = width < ui(720);
const menuX = compact ? width / 2 : width - ui(318);
const menuY = height * (compact ? 0.36 : 0.43);
this.openNewGameConfirmPanel(menuX, menuY);
}
private openNewGameConfirmPanel(x: number, y: number) {
if (this.newGameConfirmPanel) {
this.closeNewGameConfirmPanel();
return;
}
soundDirector.playSelect();
this.closeSettingsPanel();
this.closeSaveSlotPanel();
const panel = this.add.container(x, y + ui(240));
const backdrop = this.add.rectangle(0, 0, ui(286), ui(198), 0x0e151d, 0.94);
backdrop.setStrokeStyle(ui(1), palette.gold, 0.58);
const title = this.add.text(0, -ui(72), '새로 시작', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(23),
color: '#f1e3c2',
fontStyle: '700',
fixedWidth: ui(220),
align: 'center'
});
title.setOrigin(0.5);
const message = this.add.text(0, -ui(32), '현재 저장을 덮고\n처음부터 시작합니다.', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(15),
color: '#bfc8d3',
fixedWidth: ui(236),
align: 'center',
lineSpacing: ui(4)
});
message.setOrigin(0.5);
const hint = this.add.text(0, ui(13), 'Enter 새 게임 · Esc 취소', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(11),
color: '#9fb0bf',
fontStyle: '700',
fixedWidth: ui(220),
align: 'center'
});
hint.setOrigin(0.5);
const confirm = this.createSettingsButton(0, ui(48), '새 게임 시작');
confirm.setColor('#f1e3c2');
confirm.on('pointerdown', () => {
this.closeNewGameConfirmPanel();
this.startGame();
});
const cancel = this.createSettingsButton(0, ui(88), '취소');
cancel.on('pointerdown', () => {
soundDirector.playSelect();
this.closeNewGameConfirmPanel();
});
panel.add([backdrop, title, message, hint, confirm, cancel]);
panel.setAlpha(0);
this.newGameConfirmPanel = panel;
this.tweens.add({ targets: panel, alpha: 1, y: y + ui(230), duration: 160, ease: 'Sine.easeOut' });
}
private closeNewGameConfirmPanel() {
this.newGameConfirmPanel?.destroy();
this.newGameConfirmPanel = undefined;
}
private activateDefaultMenuAction() {
if (hasCampaignSave()) {
this.requestContinueGame();
return;
}
this.startGame();
}
private startGame() {
soundDirector.start();
soundDirector.resume();
soundDirector.playSelect();
startNewCampaign();
void this.navigateTo('StoryScene');
}
private continueGame(slot?: number) {
soundDirector.start();
soundDirector.resume();
soundDirector.playSelect();
void this.continueGameAsync(slot);
}
private async continueGameAsync(slot?: number) {
const campaign = loadCampaignState(slot);
if (campaign.step === 'ending-complete') {
await this.navigateTo('EndingScene');
return;
}
if (isCampCampaignStep(campaign.step)) {
await this.navigateTo('CampScene');
return;
}
const battleId = battleIdForCampaignStep(campaign.step);
if (battleId) {
if (campaign.step !== 'first-battle' && campaign.firstBattleReport?.outcome === 'victory') {
await this.navigateTo('CampScene', {
openSortiePrep: true,
skipIntroStory: true
});
return;
}
await this.navigateTo('BattleScene', { battleId });
return;
}
if (campaign.step === 'first-victory-story') {
const { firstBattleVictoryPages } = await import('../data/scenario');
await this.navigateTo('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' });
return;
}
await this.navigateTo('StoryScene');
}
private async navigateTo(sceneKey: 'StoryScene' | 'BattleScene' | 'CampScene' | 'EndingScene', data?: Record<string, unknown>) {
if (this.navigating) {
return;
}
this.navigating = true;
try {
await startLazyScene(this, sceneKey, data);
} catch (error) {
console.error(`Failed to start ${sceneKey}`, error);
this.navigating = false;
}
}
}