1438 lines
43 KiB
TypeScript
1438 lines
43 KiB
TypeScript
import Phaser from 'phaser';
|
|
import { soundDirector } from '../audio/SoundDirector';
|
|
import {
|
|
combatPresentationModeLabel,
|
|
loadCombatPresentationMode,
|
|
nextCombatPresentationMode,
|
|
saveCombatPresentationMode
|
|
} from '../settings/combatPresentation';
|
|
import {
|
|
audioLevelLabel,
|
|
loadAudioPreferences,
|
|
nextAudioLevel,
|
|
saveAudioPreferences,
|
|
updateAudioPreference
|
|
} from '../settings/audioPreferences';
|
|
import {
|
|
isVisualMotionReduced,
|
|
loadVisualMotionMode,
|
|
nextVisualMotionMode,
|
|
saveVisualMotionMode,
|
|
visualMotionModeLabel
|
|
} from '../settings/visualMotion';
|
|
import {
|
|
prologueBrotherhoodPages,
|
|
prologueDeparturePages,
|
|
prologueOpeningPages
|
|
} from '../data/prologueVillage';
|
|
import { battleIdForCampaignStep, isCampCampaignStep } from '../state/campaignRouting';
|
|
import { readCampaignBattleResume, type CampaignBattleResume } from '../state/battleSaveStorage';
|
|
import {
|
|
hasCampaignSave,
|
|
listCampaignSaveSlots,
|
|
loadCampaignState,
|
|
prologueMilitiaCampCampaignTutorialIds,
|
|
prologueVillageCampaignTutorialIds,
|
|
summarizeCampaignProgress,
|
|
startNewCampaign,
|
|
type CampaignSaveSlotSummary
|
|
} from '../state/campaignState';
|
|
import { palette } from '../ui/palette';
|
|
import { startLazyScene, type LazySceneKey } from './lazyScenes';
|
|
|
|
const fhdUiScale = 1.5;
|
|
const ui = (value: number) => value * fhdUiScale;
|
|
const uiPx = (value: number) => `${Math.round(ui(value))}px`;
|
|
|
|
type TitleFocusScope = 'main' | 'settings' | 'save-slots' | 'new-game-confirm';
|
|
|
|
type TitleFocusItem = {
|
|
id: string;
|
|
enabled: boolean;
|
|
marker: Phaser.GameObjects.Text;
|
|
activate: () => void;
|
|
render: (focused: boolean) => void;
|
|
bounds: () => Phaser.Geom.Rectangle;
|
|
};
|
|
|
|
export class TitleScene extends Phaser.Scene {
|
|
private settingsPanel?: Phaser.GameObjects.Container;
|
|
private newGameConfirmPanel?: Phaser.GameObjects.Container;
|
|
private saveSlotPanel?: Phaser.GameObjects.Container;
|
|
private readonly focusItems = new Map<TitleFocusScope, TitleFocusItem[]>();
|
|
private focusScope: TitleFocusScope = 'main';
|
|
private focusedItemId?: string;
|
|
private titleBackground?: Phaser.GameObjects.Image;
|
|
private titleBackgroundCoverScale = 1;
|
|
private titleMist?: Phaser.GameObjects.Ellipse;
|
|
private titlePetals: Phaser.GameObjects.Image[] = [];
|
|
private titleMotionTweens: Phaser.Tweens.Tween[] = [];
|
|
private visualMotionReduced = false;
|
|
private handledKeyboardEvents = new WeakSet<KeyboardEvent>();
|
|
private navigating = false;
|
|
|
|
constructor() {
|
|
super('TitleScene');
|
|
}
|
|
|
|
create() {
|
|
this.settingsPanel = undefined;
|
|
this.newGameConfirmPanel = undefined;
|
|
this.saveSlotPanel = undefined;
|
|
this.focusItems.clear();
|
|
this.focusScope = 'main';
|
|
this.focusedItemId = undefined;
|
|
this.titleBackground = undefined;
|
|
this.titleMist = undefined;
|
|
this.titlePetals = [];
|
|
this.titleMotionTweens = [];
|
|
this.visualMotionReduced = isVisualMotionReduced();
|
|
this.handledKeyboardEvents = new WeakSet<KeyboardEvent>();
|
|
this.navigating = false;
|
|
|
|
const debugBattleId = this.debugBattleId();
|
|
if (debugBattleId) {
|
|
this.time.delayedCall(0, () => {
|
|
void this.navigateTo('BattleScene', { battleId: debugBattleId });
|
|
});
|
|
return;
|
|
}
|
|
|
|
const debugStoryPageId = this.debugStoryPageId();
|
|
if (debugStoryPageId) {
|
|
this.time.delayedCall(0, () => {
|
|
void this.openDebugStoryPage(debugStoryPageId);
|
|
});
|
|
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.applyTitleMotionPreference(this.visualMotionReduced);
|
|
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', this.handleKeyboardInput, this);
|
|
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
|
|
this.input.keyboard?.off('keydown', this.handleKeyboardInput, this);
|
|
this.stopTitleMotionTweens();
|
|
});
|
|
}
|
|
|
|
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 debugStoryPageId() {
|
|
if (typeof window === 'undefined') {
|
|
return undefined;
|
|
}
|
|
const params = new URLSearchParams(window.location.search);
|
|
if (!params.has('debugStory') || (!params.has('debug') && !import.meta.env.DEV)) {
|
|
return undefined;
|
|
}
|
|
return params.get('debugStory')?.trim() || undefined;
|
|
}
|
|
|
|
private async openDebugStoryPage(pageId: string) {
|
|
const scenarioModule = await import('../data/scenario');
|
|
let selectedPage: unknown;
|
|
|
|
for (const value of Object.values(scenarioModule)) {
|
|
if (!Array.isArray(value)) {
|
|
continue;
|
|
}
|
|
selectedPage = value.find(
|
|
(candidate) =>
|
|
candidate &&
|
|
typeof candidate === 'object' &&
|
|
'id' in candidate &&
|
|
candidate.id === pageId
|
|
);
|
|
if (selectedPage) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!selectedPage) {
|
|
console.error(`Unknown debug story page: ${pageId}`);
|
|
return;
|
|
}
|
|
|
|
await this.navigateTo('StoryScene', {
|
|
pages: [selectedPage],
|
|
nextScene: 'TitleScene'
|
|
});
|
|
}
|
|
|
|
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);
|
|
|
|
this.titleBackground = background;
|
|
this.titleBackgroundCoverScale = coverScale;
|
|
|
|
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.titleMist = mist;
|
|
|
|
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.titlePetals.push(petal);
|
|
}
|
|
}
|
|
|
|
private applyTitleMotionPreference(reduced: boolean) {
|
|
this.visualMotionReduced = reduced;
|
|
this.stopTitleMotionTweens();
|
|
|
|
const { width, height } = this.scale;
|
|
this.titleBackground
|
|
?.setPosition(width / 2, height / 2)
|
|
.setScale(this.titleBackgroundCoverScale * 1.1);
|
|
this.titleMist?.setPosition(width / 2, height * 0.78).setAlpha(reduced ? 0.07 : 0.04);
|
|
|
|
this.titlePetals.forEach((petal, index) => {
|
|
const x = ((index * 211 + 97) % Math.max(1, width + ui(120))) - ui(60);
|
|
const y = ((index * 137 + 43) % Math.max(1, height + ui(100))) - ui(80);
|
|
petal
|
|
.setVisible(true)
|
|
.setPosition(x, y)
|
|
.setRotation(-0.9 + (index % 9) * 0.21);
|
|
});
|
|
|
|
if (reduced) {
|
|
return;
|
|
}
|
|
|
|
if (this.titleBackground) {
|
|
this.titleBackground
|
|
.setPosition(width / 2 - ui(18), height / 2 + ui(4))
|
|
.setScale(this.titleBackgroundCoverScale * 1.08);
|
|
this.trackTitleMotionTween({
|
|
targets: this.titleBackground,
|
|
x: width / 2 + ui(18),
|
|
y: height / 2 - ui(6),
|
|
scale: this.titleBackgroundCoverScale * 1.12,
|
|
duration: 18000,
|
|
ease: 'Sine.easeInOut',
|
|
yoyo: true,
|
|
repeat: -1
|
|
});
|
|
}
|
|
|
|
if (this.titleMist) {
|
|
this.trackTitleMotionTween({
|
|
targets: this.titleMist,
|
|
alpha: 0.11,
|
|
x: width / 2 + ui(22),
|
|
duration: 8000,
|
|
ease: 'Sine.easeInOut',
|
|
yoyo: true,
|
|
repeat: -1
|
|
});
|
|
}
|
|
|
|
this.titlePetals.forEach((petal) => {
|
|
this.trackTitleMotionTween({
|
|
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 trackTitleMotionTween(config: Phaser.Types.Tweens.TweenBuilderConfig) {
|
|
const tween = this.tweens.add(config);
|
|
this.titleMotionTweens.push(tween);
|
|
return tween;
|
|
}
|
|
|
|
private stopTitleMotionTweens() {
|
|
this.titleMotionTweens.forEach((tween) => tween.stop());
|
|
this.titleMotionTweens = [];
|
|
}
|
|
|
|
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('new-game', menuX, menuY - ui(70), '새 게임', true, () => this.requestStartGame());
|
|
this.createMenuButton(
|
|
'continue',
|
|
menuX,
|
|
menuY,
|
|
isEndingComplete ? '엔딩 보기' : '이어하기',
|
|
canContinue,
|
|
() => this.requestContinueGame()
|
|
);
|
|
this.createMenuButton('settings', menuX, menuY + ui(70), '설정', true, () => this.openSettingsPanel(menuX, menuY));
|
|
|
|
if (campaign) {
|
|
this.drawCampaignSaveBadge(menuX, menuY + ui(132), campaign, isEndingComplete);
|
|
}
|
|
|
|
this.setFocusScope('main', canContinue ? 'continue' : 'new-game');
|
|
}
|
|
|
|
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,
|
|
battleResume = readCampaignBattleResume(campaign)
|
|
) {
|
|
if (battleResume) {
|
|
const progress = summarizeCampaignProgress(campaign);
|
|
return this.compactSaveDetail(`전투 이어하기 · ${progress.title}`);
|
|
}
|
|
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>,
|
|
battleResume = readCampaignBattleResume(campaign)
|
|
) {
|
|
if (battleResume) {
|
|
return this.campaignBattleResumeMeta(battleResume);
|
|
}
|
|
return `슬롯 ${campaign.activeSaveSlot} · ${this.formatSaveUpdatedAt(campaign.updatedAt)}`;
|
|
}
|
|
|
|
private campaignBattleResumeMeta(resume: CampaignBattleResume) {
|
|
const faction = resume.activeFaction === 'ally' ? '아군 차례' : '적군 차례';
|
|
return `슬롯 ${resume.slot} · ${resume.turnNumber}턴 · ${faction}`;
|
|
}
|
|
|
|
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(
|
|
id: string,
|
|
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);
|
|
|
|
const marker = this.add.text(x - ui(116), y, '▶', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: uiPx(17),
|
|
color: '#f6e6bd',
|
|
fontStyle: '700'
|
|
});
|
|
marker.setOrigin(0.5).setVisible(false);
|
|
|
|
const hitArea = this.add.zone(x, y, ui(216), ui(58));
|
|
hitArea.setOrigin(0.5);
|
|
const render = (focused: boolean) => {
|
|
marker.setVisible(enabled && focused);
|
|
text.setColor(!enabled ? '#7d8189' : focused ? '#111821' : '#f1e3c2');
|
|
text.setBackgroundColor(enabled && focused ? '#d8b15f' : 'rgba(0,0,0,0)');
|
|
};
|
|
|
|
this.registerFocusItem('main', {
|
|
id,
|
|
enabled,
|
|
marker,
|
|
activate: onSelect,
|
|
render,
|
|
bounds: () => hitArea.getBounds()
|
|
});
|
|
render(false);
|
|
|
|
if (enabled) {
|
|
hitArea.setInteractive({ useHandCursor: true });
|
|
hitArea.on('pointerover', () => this.focusItem('main', id, true));
|
|
hitArea.on('pointerdown', () => {
|
|
if (this.activeModalScope()) {
|
|
return;
|
|
}
|
|
this.focusItem('main', id);
|
|
soundDirector.start();
|
|
soundDirector.resume();
|
|
onSelect();
|
|
});
|
|
}
|
|
|
|
return text;
|
|
}
|
|
|
|
private openSettingsPanel(x: number, y: number) {
|
|
if (this.settingsPanel) {
|
|
this.closeSettingsPanel();
|
|
return;
|
|
}
|
|
|
|
soundDirector.playSelect();
|
|
this.closeNewGameConfirmPanel(false);
|
|
this.closeSaveSlotPanel(false);
|
|
this.clearFocusScope('settings');
|
|
|
|
const panel = this.add.container(x, y + ui(126));
|
|
const backdrop = this.add.rectangle(0, 0, ui(320), ui(468), 0x0b1118, 1);
|
|
backdrop.setStrokeStyle(ui(1), palette.gold, 0.52);
|
|
backdrop.setInteractive();
|
|
|
|
const title = this.add.text(0, -ui(174), '설정', {
|
|
fontSize: uiPx(24),
|
|
color: '#f1e3c2',
|
|
fontStyle: '700',
|
|
fixedWidth: ui(244),
|
|
align: 'center'
|
|
});
|
|
title.setOrigin(0.5);
|
|
|
|
this.createSettingsButton(
|
|
panel,
|
|
'settings',
|
|
'sound',
|
|
0,
|
|
-ui(126),
|
|
this.soundLabel(),
|
|
(text) => {
|
|
const preferences = loadAudioPreferences();
|
|
const muted = !soundDirector.isMuted();
|
|
soundDirector.setMuted(muted);
|
|
saveAudioPreferences({ ...preferences, muted });
|
|
text.setText(this.soundLabel());
|
|
soundDirector.playSelect();
|
|
}
|
|
);
|
|
|
|
this.createSettingsButton(
|
|
panel,
|
|
'settings',
|
|
'music',
|
|
0,
|
|
-ui(84),
|
|
this.audioCategoryLabel('music'),
|
|
(text) => this.cycleAudioLevel('music', text)
|
|
);
|
|
|
|
this.createSettingsButton(
|
|
panel,
|
|
'settings',
|
|
'effects',
|
|
0,
|
|
-ui(42),
|
|
this.audioCategoryLabel('effects'),
|
|
(text) => this.cycleAudioLevel('effects', text)
|
|
);
|
|
|
|
this.createSettingsButton(
|
|
panel,
|
|
'settings',
|
|
'ambience',
|
|
0,
|
|
0,
|
|
this.audioCategoryLabel('ambience'),
|
|
(text) => this.cycleAudioLevel('ambience', text)
|
|
);
|
|
|
|
this.createSettingsButton(
|
|
panel,
|
|
'settings',
|
|
'combat-presentation',
|
|
0,
|
|
ui(42),
|
|
this.combatPresentationLabel(),
|
|
(text) => {
|
|
const nextMode = nextCombatPresentationMode(loadCombatPresentationMode());
|
|
saveCombatPresentationMode(nextMode);
|
|
text.setText(this.combatPresentationLabel());
|
|
soundDirector.playSelect();
|
|
}
|
|
);
|
|
|
|
this.createSettingsButton(
|
|
panel,
|
|
'settings',
|
|
'visual-motion',
|
|
0,
|
|
ui(84),
|
|
this.visualMotionLabel(),
|
|
(text) => {
|
|
const nextMode = nextVisualMotionMode(loadVisualMotionMode());
|
|
saveVisualMotionMode(nextMode);
|
|
this.applyTitleMotionPreference(nextMode === 'reduced');
|
|
text.setText(this.visualMotionLabel());
|
|
soundDirector.playSelect();
|
|
}
|
|
);
|
|
|
|
this.createSettingsButton(
|
|
panel,
|
|
'settings',
|
|
'close',
|
|
0,
|
|
ui(132),
|
|
'닫기',
|
|
() => {
|
|
soundDirector.playSelect();
|
|
this.closeSettingsPanel();
|
|
}
|
|
);
|
|
|
|
const hint = this.add.text(0, ui(178), '↑↓/Tab 이동 · Enter/Space 변경\nEsc 닫기', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: uiPx(11),
|
|
color: '#9fb0bf',
|
|
fontStyle: '700',
|
|
fixedWidth: ui(244),
|
|
align: 'center'
|
|
});
|
|
hint.setOrigin(0.5);
|
|
|
|
panel.addAt([backdrop, title, hint], 0);
|
|
panel.setAlpha(0);
|
|
this.settingsPanel = panel;
|
|
this.tweens.add({
|
|
targets: panel,
|
|
alpha: 1,
|
|
y: y + ui(116),
|
|
duration: this.visualMotionReduced ? 0 : 160,
|
|
ease: 'Sine.easeOut'
|
|
});
|
|
this.setFocusScope('settings', 'sound');
|
|
}
|
|
|
|
private createSettingsButton(
|
|
panel: Phaser.GameObjects.Container,
|
|
scope: Exclude<TitleFocusScope, 'main'>,
|
|
id: string,
|
|
x: number,
|
|
y: number,
|
|
label: string,
|
|
onSelect: (text: Phaser.GameObjects.Text) => void,
|
|
baseColor = '#d8b15f'
|
|
) {
|
|
const text = this.add.text(x, y, label, {
|
|
fontSize: uiPx(20),
|
|
color: baseColor,
|
|
fixedWidth: ui(210),
|
|
align: 'center',
|
|
padding: { top: ui(8), bottom: ui(8) }
|
|
});
|
|
text.setOrigin(0.5);
|
|
text.setInteractive({ useHandCursor: true });
|
|
|
|
const marker = this.add.text(x - ui(124), y, '▶', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: uiPx(14),
|
|
color: '#f6e6bd',
|
|
fontStyle: '700'
|
|
});
|
|
marker.setOrigin(0.5).setVisible(false);
|
|
|
|
const render = (focused: boolean) => {
|
|
marker.setVisible(focused);
|
|
text.setColor(focused ? '#111821' : baseColor);
|
|
text.setBackgroundColor(focused ? '#d8b15f' : 'rgba(0,0,0,0)');
|
|
};
|
|
|
|
panel.add([marker, text]);
|
|
this.registerFocusItem(scope, {
|
|
id,
|
|
enabled: true,
|
|
marker,
|
|
activate: () => onSelect(text),
|
|
render,
|
|
bounds: () => text.getBounds()
|
|
});
|
|
render(false);
|
|
text.on('pointerover', () => this.focusItem(scope, id, true));
|
|
text.on('pointerdown', () => {
|
|
this.focusItem(scope, id);
|
|
soundDirector.start();
|
|
soundDirector.resume();
|
|
onSelect(text);
|
|
});
|
|
|
|
return text;
|
|
}
|
|
|
|
private soundLabel() {
|
|
return `음향: ${soundDirector.isMuted() ? '꺼짐' : '켜짐'}`;
|
|
}
|
|
|
|
private audioCategoryLabel(category: 'music' | 'effects' | 'ambience') {
|
|
const labels = {
|
|
music: '음악',
|
|
effects: '효과음',
|
|
ambience: '환경음'
|
|
} as const;
|
|
return `${labels[category]}: ${audioLevelLabel(loadAudioPreferences()[category])}`;
|
|
}
|
|
|
|
private cycleAudioLevel(category: 'music' | 'effects' | 'ambience', text: Phaser.GameObjects.Text) {
|
|
const preferences = loadAudioPreferences();
|
|
const nextLevel = nextAudioLevel(preferences[category]);
|
|
const nextPreferences = updateAudioPreference(preferences, category, nextLevel);
|
|
saveAudioPreferences(nextPreferences);
|
|
soundDirector.setCategoryMultiplier(category, nextLevel);
|
|
text.setText(this.audioCategoryLabel(category));
|
|
soundDirector.playSelect();
|
|
}
|
|
|
|
private combatPresentationLabel() {
|
|
return `전투 연출: ${combatPresentationModeLabel(loadCombatPresentationMode())}`;
|
|
}
|
|
|
|
private visualMotionLabel() {
|
|
return `화면 움직임: ${visualMotionModeLabel(loadVisualMotionMode())}`;
|
|
}
|
|
|
|
private closeSettingsPanel(restoreFocus = true) {
|
|
this.clearFocusScope('settings');
|
|
this.settingsPanel?.destroy();
|
|
this.settingsPanel = undefined;
|
|
if (restoreFocus) {
|
|
this.setFocusScope('main', 'settings');
|
|
}
|
|
}
|
|
|
|
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(false);
|
|
this.closeNewGameConfirmPanel(false);
|
|
this.clearFocusScope('save-slots');
|
|
|
|
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);
|
|
backdrop.setInteractive();
|
|
|
|
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)));
|
|
this.createSettingsButton(
|
|
panel,
|
|
'save-slots',
|
|
'close',
|
|
0,
|
|
ui(96),
|
|
'닫기',
|
|
() => {
|
|
soundDirector.playSelect();
|
|
this.closeSaveSlotPanel();
|
|
}
|
|
);
|
|
|
|
const hint = this.add.text(0, ui(120), '↑↓/Tab 이동 · Enter/Space 선택\n숫자키 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.addAt([backdrop, title, ...rows, hint], 0);
|
|
panel.setAlpha(0);
|
|
this.saveSlotPanel = panel;
|
|
this.tweens.add({
|
|
targets: panel,
|
|
alpha: 1,
|
|
y: y + ui(242),
|
|
duration: this.visualMotionReduced ? 0 : 160,
|
|
ease: 'Sine.easeOut'
|
|
});
|
|
const activeSlot = loadCampaignState().activeSaveSlot;
|
|
this.setFocusScope('save-slots', `slot-${activeSlot}`);
|
|
}
|
|
|
|
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 battleResume = campaign ? readCampaignBattleResume(campaign, slot.slot) : undefined;
|
|
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, battleResume) : '저장 없음', {
|
|
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, battleResume) : '빈 슬롯', {
|
|
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);
|
|
|
|
const marker = this.add.text(-ui(143), 0, '▶', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: uiPx(12),
|
|
color: '#f6e6bd',
|
|
fontStyle: '700'
|
|
});
|
|
marker.setOrigin(0.5).setVisible(false);
|
|
|
|
row.add([background, title, detail, meta, keycap, keyText, marker]);
|
|
|
|
const render = (focused: boolean) => {
|
|
marker.setVisible(enabled && focused);
|
|
background.setFillStyle(enabled && focused ? 0xd8b15f : enabled ? 0x1b2530 : 0x121820, enabled && focused ? 0.22 : enabled ? 0.9 : 0.56);
|
|
background.setStrokeStyle(
|
|
ui(focused ? 2 : 1),
|
|
enabled ? palette.gold : 0x56616d,
|
|
enabled && focused ? 0.9 : enabled ? 0.42 : 0.2
|
|
);
|
|
};
|
|
|
|
this.registerFocusItem('save-slots', {
|
|
id: `slot-${slot.slot}`,
|
|
enabled,
|
|
marker,
|
|
activate: () => this.activateSaveSlot(slot.slot),
|
|
render,
|
|
bounds: () => background.getBounds()
|
|
});
|
|
render(false);
|
|
|
|
if (enabled) {
|
|
background.setInteractive({ useHandCursor: true });
|
|
background.on('pointerover', () => this.focusItem('save-slots', `slot-${slot.slot}`, true));
|
|
background.on('pointerdown', () => {
|
|
this.focusItem('save-slots', `slot-${slot.slot}`);
|
|
this.activateSaveSlot(slot.slot);
|
|
});
|
|
}
|
|
|
|
return row;
|
|
}
|
|
|
|
private activateSaveSlot(slot: number) {
|
|
if (!listCampaignSaveSlots().some((summary) => summary.slot === slot && summary.exists)) {
|
|
return;
|
|
}
|
|
|
|
this.closeSaveSlotPanel(false);
|
|
this.continueGame(slot);
|
|
}
|
|
|
|
private closeSaveSlotPanel(restoreFocus = true) {
|
|
this.clearFocusScope('save-slots');
|
|
this.saveSlotPanel?.destroy();
|
|
this.saveSlotPanel = undefined;
|
|
if (restoreFocus) {
|
|
this.setFocusScope('main', 'continue');
|
|
}
|
|
}
|
|
|
|
private registerFocusItem(scope: TitleFocusScope, item: TitleFocusItem) {
|
|
const items = this.focusItems.get(scope) ?? [];
|
|
const duplicateIndex = items.findIndex((candidate) => candidate.id === item.id);
|
|
if (duplicateIndex >= 0) {
|
|
items[duplicateIndex] = item;
|
|
} else {
|
|
items.push(item);
|
|
}
|
|
this.focusItems.set(scope, items);
|
|
}
|
|
|
|
private setFocusScope(scope: TitleFocusScope, preferredId?: string) {
|
|
this.currentFocusItem()?.render(false);
|
|
this.focusScope = scope;
|
|
|
|
const enabledItems = (this.focusItems.get(scope) ?? []).filter((item) => item.enabled);
|
|
const nextItem = enabledItems.find((item) => item.id === preferredId) ?? enabledItems[0];
|
|
this.focusedItemId = nextItem?.id;
|
|
nextItem?.render(true);
|
|
}
|
|
|
|
private clearFocusScope(scope: TitleFocusScope) {
|
|
const items = this.focusItems.get(scope) ?? [];
|
|
items.forEach((item) => item.render(false));
|
|
this.focusItems.delete(scope);
|
|
if (this.focusScope === scope) {
|
|
this.focusedItemId = undefined;
|
|
}
|
|
}
|
|
|
|
private focusItem(scope: TitleFocusScope, id: string, playHover = false) {
|
|
const modalScope = this.activeModalScope();
|
|
if (modalScope && scope !== modalScope) {
|
|
return;
|
|
}
|
|
|
|
const nextItem = (this.focusItems.get(scope) ?? []).find((item) => item.id === id && item.enabled);
|
|
if (!nextItem) {
|
|
return;
|
|
}
|
|
|
|
const changed = this.focusScope !== scope || this.focusedItemId !== id;
|
|
this.currentFocusItem()?.render(false);
|
|
this.focusScope = scope;
|
|
this.focusedItemId = id;
|
|
nextItem.render(true);
|
|
if (changed && playHover) {
|
|
soundDirector.playHover();
|
|
}
|
|
}
|
|
|
|
private activeModalScope(): Exclude<TitleFocusScope, 'main'> | undefined {
|
|
if (this.settingsPanel) {
|
|
return 'settings';
|
|
}
|
|
if (this.saveSlotPanel) {
|
|
return 'save-slots';
|
|
}
|
|
if (this.newGameConfirmPanel) {
|
|
return 'new-game-confirm';
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
private moveFocus(direction: 1 | -1) {
|
|
const enabledItems = (this.focusItems.get(this.focusScope) ?? []).filter((item) => item.enabled);
|
|
if (enabledItems.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const currentIndex = enabledItems.findIndex((item) => item.id === this.focusedItemId);
|
|
const nextIndex = currentIndex < 0
|
|
? 0
|
|
: (currentIndex + direction + enabledItems.length) % enabledItems.length;
|
|
this.focusItem(this.focusScope, enabledItems[nextIndex].id, true);
|
|
}
|
|
|
|
private currentFocusItem() {
|
|
return (this.focusItems.get(this.focusScope) ?? []).find((item) => item.id === this.focusedItemId);
|
|
}
|
|
|
|
private activateFocusedItem() {
|
|
const item = this.currentFocusItem();
|
|
if (!item?.enabled) {
|
|
return;
|
|
}
|
|
|
|
soundDirector.start();
|
|
soundDirector.resume();
|
|
item.activate();
|
|
}
|
|
|
|
private handleKeyboardInput(event: KeyboardEvent) {
|
|
if (this.handledKeyboardEvents.has(event)) {
|
|
return;
|
|
}
|
|
this.handledKeyboardEvents.add(event);
|
|
|
|
if (event.repeat || this.navigating) {
|
|
return;
|
|
}
|
|
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
this.handleEscapeKey();
|
|
return;
|
|
}
|
|
|
|
if (this.saveSlotPanel && /^[1-3]$/.test(event.key)) {
|
|
event.preventDefault();
|
|
const slot = Number(event.key);
|
|
this.focusItem('save-slots', `slot-${slot}`);
|
|
this.activateSaveSlot(slot);
|
|
return;
|
|
}
|
|
|
|
if (event.key === 'ArrowUp' || (event.key === 'Tab' && event.shiftKey)) {
|
|
event.preventDefault();
|
|
this.moveFocus(-1);
|
|
return;
|
|
}
|
|
|
|
if (event.key === 'ArrowDown' || event.key === 'Tab') {
|
|
event.preventDefault();
|
|
this.moveFocus(1);
|
|
return;
|
|
}
|
|
|
|
if (event.key === 'Enter' || event.key === ' ' || event.code === 'Space') {
|
|
event.preventDefault();
|
|
this.activateFocusedItem();
|
|
}
|
|
}
|
|
|
|
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(false);
|
|
this.closeSaveSlotPanel(false);
|
|
this.clearFocusScope('new-game-confirm');
|
|
|
|
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);
|
|
backdrop.setInteractive();
|
|
|
|
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/Space 결정 · 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);
|
|
|
|
this.createSettingsButton(
|
|
panel,
|
|
'new-game-confirm',
|
|
'confirm',
|
|
0,
|
|
ui(48),
|
|
'새 게임 시작',
|
|
() => {
|
|
this.closeNewGameConfirmPanel(false);
|
|
this.startGame();
|
|
},
|
|
'#f1e3c2'
|
|
);
|
|
|
|
this.createSettingsButton(
|
|
panel,
|
|
'new-game-confirm',
|
|
'cancel',
|
|
0,
|
|
ui(88),
|
|
'취소',
|
|
() => {
|
|
soundDirector.playSelect();
|
|
this.closeNewGameConfirmPanel();
|
|
}
|
|
);
|
|
|
|
panel.addAt([backdrop, title, message, hint], 0);
|
|
panel.setAlpha(0);
|
|
this.newGameConfirmPanel = panel;
|
|
this.tweens.add({
|
|
targets: panel,
|
|
alpha: 1,
|
|
y: y + ui(230),
|
|
duration: this.visualMotionReduced ? 0 : 160,
|
|
ease: 'Sine.easeOut'
|
|
});
|
|
this.setFocusScope('new-game-confirm', 'confirm');
|
|
}
|
|
|
|
private closeNewGameConfirmPanel(restoreFocus = true) {
|
|
this.clearFocusScope('new-game-confirm');
|
|
this.newGameConfirmPanel?.destroy();
|
|
this.newGameConfirmPanel = undefined;
|
|
if (restoreFocus) {
|
|
this.setFocusScope('main', 'new-game');
|
|
}
|
|
}
|
|
|
|
getDebugState() {
|
|
const items = (this.focusItems.get(this.focusScope) ?? []).map((item) => {
|
|
const bounds = item.bounds();
|
|
return {
|
|
id: item.id,
|
|
enabled: item.enabled,
|
|
focused: item.id === this.focusedItemId,
|
|
focusIndicatorVisible: item.marker.visible,
|
|
bounds: {
|
|
x: bounds.x,
|
|
y: bounds.y,
|
|
width: bounds.width,
|
|
height: bounds.height
|
|
}
|
|
};
|
|
});
|
|
|
|
return {
|
|
scene: 'TitleScene',
|
|
navigating: this.navigating,
|
|
focus: {
|
|
scope: this.focusScope,
|
|
itemId: this.focusedItemId,
|
|
items
|
|
},
|
|
panels: {
|
|
settings: Boolean(this.settingsPanel),
|
|
saveSlots: Boolean(this.saveSlotPanel),
|
|
newGameConfirm: Boolean(this.newGameConfirmPanel)
|
|
},
|
|
motion: {
|
|
mode: loadVisualMotionMode(),
|
|
reduced: this.visualMotionReduced,
|
|
activeInfiniteTweenCount: this.titleMotionTweens.length,
|
|
background: this.titleBackground
|
|
? {
|
|
x: this.titleBackground.x,
|
|
y: this.titleBackground.y,
|
|
scale: this.titleBackground.scale
|
|
}
|
|
: undefined,
|
|
mistAlpha: this.titleMist?.alpha,
|
|
petalCount: this.titlePetals.length
|
|
}
|
|
};
|
|
}
|
|
|
|
private startGame() {
|
|
if (this.navigating) {
|
|
return;
|
|
}
|
|
soundDirector.start();
|
|
soundDirector.resume();
|
|
soundDirector.playSelect();
|
|
startNewCampaign();
|
|
void this.navigateTo('StoryScene', {
|
|
pages: prologueOpeningPages(),
|
|
nextScene: 'PrologueVillageScene',
|
|
presentationBattleId: 'first-battle-zhuo-commandery',
|
|
presentationStage: 'story'
|
|
});
|
|
}
|
|
|
|
private continueGame(slot?: number) {
|
|
if (this.navigating) {
|
|
return;
|
|
}
|
|
soundDirector.start();
|
|
soundDirector.resume();
|
|
soundDirector.playSelect();
|
|
void this.continueGameAsync(slot);
|
|
}
|
|
|
|
private async continueGameAsync(slot?: number) {
|
|
const campaign = loadCampaignState(slot);
|
|
const battleResume = readCampaignBattleResume(campaign, slot ?? campaign.activeSaveSlot);
|
|
if (battleResume) {
|
|
await this.navigateTo('BattleScene', {
|
|
battleId: battleResume.battleId,
|
|
resumeBattleSaveSlot: battleResume.slot
|
|
});
|
|
return;
|
|
}
|
|
if (campaign.pendingAftermathBattleId) {
|
|
const { battleScenarios } = await import('../data/battles');
|
|
const aftermathScenario = battleScenarios[campaign.pendingAftermathBattleId];
|
|
await this.navigateTo('StoryScene', {
|
|
pages: aftermathScenario.victoryPages,
|
|
nextScene: aftermathScenario.nextCampScene,
|
|
nextSceneData: { completedAftermathBattleId: aftermathScenario.id },
|
|
presentationBattleId: aftermathScenario.id,
|
|
presentationStage: 'aftermath'
|
|
});
|
|
return;
|
|
}
|
|
if (campaign.activeCityStayId) {
|
|
await this.navigateTo('CityStayScene', {
|
|
cityStayId: campaign.activeCityStayId
|
|
});
|
|
return;
|
|
}
|
|
if (campaign.step === 'ending-complete') {
|
|
await this.navigateTo('EndingScene');
|
|
return;
|
|
}
|
|
|
|
if (isCampCampaignStep(campaign.step)) {
|
|
await this.navigateTo('CampScene');
|
|
return;
|
|
}
|
|
|
|
if (campaign.step === 'prologue') {
|
|
await this.navigateTo('StoryScene', {
|
|
pages: prologueOpeningPages(),
|
|
nextScene: 'PrologueVillageScene',
|
|
presentationBattleId: 'first-battle-zhuo-commandery',
|
|
presentationStage: 'story'
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (campaign.step === 'prologue-town') {
|
|
if (campaign.completedTutorialIds.includes(prologueVillageCampaignTutorialIds.complete)) {
|
|
await this.navigateTo('StoryScene', {
|
|
pages: prologueBrotherhoodPages(),
|
|
nextScene: 'PrologueMilitiaCampScene',
|
|
presentationBattleId: 'first-battle-zhuo-commandery',
|
|
presentationStage: 'story'
|
|
});
|
|
} else {
|
|
await this.navigateTo('PrologueVillageScene');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (campaign.step === 'prologue-camp') {
|
|
if (campaign.completedTutorialIds.includes(prologueMilitiaCampCampaignTutorialIds.complete)) {
|
|
await this.navigateTo('StoryScene', {
|
|
pages: prologueDeparturePages(),
|
|
nextScene: 'BattleScene',
|
|
nextSceneData: { battleId: 'first-battle-zhuo-commandery' },
|
|
presentationBattleId: 'first-battle-zhuo-commandery',
|
|
presentationStage: 'story'
|
|
});
|
|
} else {
|
|
await this.navigateTo('PrologueMilitiaCampScene');
|
|
}
|
|
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',
|
|
presentationBattleId: 'first-battle-zhuo-commandery',
|
|
presentationStage: 'aftermath'
|
|
});
|
|
return;
|
|
}
|
|
|
|
await this.navigateTo('StoryScene', {
|
|
pages: prologueOpeningPages(),
|
|
nextScene: 'PrologueVillageScene',
|
|
presentationBattleId: 'first-battle-zhuo-commandery',
|
|
presentationStage: 'story'
|
|
});
|
|
}
|
|
|
|
private async navigateTo(sceneKey: LazySceneKey, 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;
|
|
}
|
|
}
|
|
}
|