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(); 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(); 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(); 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, 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, 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, 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, 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 | 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) { 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; } } }