import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; import { getBattleScenario } from '../data/battles'; import { battleIdForCampaignStep, isCampCampaignStep } from '../state/campaignRouting'; import { hasCampaignSave, listCampaignSaveSlots, loadCampaignState, startNewCampaign, type CampaignSaveSlotSummary } from '../state/campaignState'; import { palette } from '../ui/palette'; import { startLazyScene } from './lazyScenes'; 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?.once('keydown-ENTER', () => this.activateDefaultMenuAction()); } 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 - 18, height / 2 + 4); this.tweens.add({ targets: background, x: width / 2 + 18, y: height / 2 - 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, 190, 0xd8b15f, 0.04); this.tweens.add({ targets: mist, alpha: 0.11, x: width / 2 + 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(-80, height), 'petal' ); const scale = Phaser.Math.FloatBetween(0.35, 0.9); petal.setScale(scale); 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(80, 220), y: height + Phaser.Math.Between(40, 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(-120, width), Phaser.Math.Between(-180, -40)); } }); } } private drawTitleMark(width: number, height: number) { const compact = width < 720; const titleX = compact ? width / 2 : 92; const title = this.add.text(titleX, height - (compact ? 190 : 204), '삼국지', { fontFamily: '"Malgun Gothic", "Noto Sans KR", serif', fontSize: compact ? '50px' : '64px', color: '#f1e3c2', fontStyle: '700', padding: { top: 8, bottom: 8 }, shadow: { offsetX: 0, offsetY: 4, color: '#080a0e', blur: 10, fill: true } }); title.setOrigin(compact ? 0.5 : 0, 0); const subtitle = this.add.text(compact ? width / 2 : 96, height - (compact ? 112 : 118), '세 형제의 맹세', { fontFamily: '"Malgun Gothic", "Noto Sans KR", serif', fontSize: compact ? '26px' : '34px', color: '#d8b15f', fontStyle: '700', padding: { top: 10, bottom: 8 }, shadow: { offsetX: 0, offsetY: 2, color: '#080a0e', blur: 6, fill: true } }); subtitle.setOrigin(compact ? 0.5 : 0, 0); const lineX = compact ? width / 2 - 110 : 98; const lineY = height - (compact ? 76 : 70); this.add.rectangle(lineX, lineY, compact ? 220 : 310, 2, palette.gold, 0.68).setOrigin(0, 0.5); } private drawMenu(width: number, height: number) { const compact = width < 720; const menuX = compact ? width / 2 : width - 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 = campaign ? 348 : 252; const plate = this.add.rectangle(menuX, menuY, 250, plateHeight, 0x111821, 0.66); plate.setStrokeStyle(1, palette.gold, 0.45); this.add.rectangle(menuX, menuY - plateHeight / 2, 186, 2, palette.gold, 0.7); this.add.rectangle(menuX, menuY + plateHeight / 2, 186, 2, palette.gold, 0.36); this.createMenuButton(menuX, menuY - 70, '새 게임', true, () => this.requestStartGame()); this.createMenuButton(menuX, menuY, isEndingComplete ? '엔딩 보기' : '이어하기', canContinue, () => this.requestContinueGame()); this.createMenuButton(menuX, menuY + 70, '설정', true, () => this.openSettingsPanel(menuX, menuY)); if (campaign) { this.drawCampaignSaveBadge(menuX, menuY + 132, campaign, isEndingComplete); } } private drawCampaignSaveBadge( x: number, y: number, campaign: ReturnType, isEndingComplete: boolean ) { const badge = this.add.container(x, y); const background = this.add.rectangle(0, 0, 216, 78, 0x21180f, 0.92); background.setStrokeStyle(1, palette.gold, 0.52); const title = this.add.text(0, -24, isEndingComplete ? '완결 저장' : '진행 저장', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '18px', color: '#f6e6bd', fontStyle: '700', fixedWidth: 198, align: 'center', padding: { top: 3, bottom: 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: '14px', color: '#d8b15f', fixedWidth: 198, align: 'center' }); detail.setOrigin(0.5); const meta = this.add.text(0, 24, this.campaignSaveMeta(campaign), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#9fb0bf', fixedWidth: 198, align: 'center' }); meta.setOrigin(0.5); badge.add([background, title, detail, meta]); } private campaignSaveDetail(campaign: ReturnType, isEndingComplete: boolean) { if (isEndingComplete) { return `${Object.keys(campaign.battleHistory).length}전 완수 · ${campaign.roster.length}명 합류`; } const nextBattleId = battleIdForCampaignStep(campaign.step); if (nextBattleId) { return this.compactSaveDetail(`다음 전장 · ${getBattleScenario(nextBattleId).title}`); } if (isCampCampaignStep(campaign.step)) { const latestTitle = campaign.latestBattleId ? getBattleScenario(campaign.latestBattleId).title : '전투 이후'; return this.compactSaveDetail(`군영 정비 · ${latestTitle}`); } if (campaign.step === 'prologue') { return '도원 결의 · 첫 출진 전'; } if (campaign.step === 'first-victory-story') { return '승리 후일담 · 군영 복귀'; } return `군자금 ${campaign.gold} · ${campaign.roster.length}명`; } private compactSaveDetail(label: string) { return label.length > 19 ? `${label.slice(0, 18)}...` : label; } private campaignSaveMeta(campaign: ReturnType) { 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: '30px', color: enabled ? '#f1e3c2' : '#7d8189', fontStyle: '700', fixedWidth: 190, align: 'center', padding: { top: 10, bottom: 10 } }); text.setOrigin(0.5); text.setShadow(0, 3, '#080a0e', 8, true, true); if (!enabled) { return text; } text.setInteractive({ useHandCursor: true }); text.on('pointerover', () => { this.focusButton(text); soundDirector.playHover(); }); text.on('pointerout', () => this.unfocusButton(text)); text.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 + 238); const backdrop = this.add.rectangle(0, 0, 286, 188, 0x0e151d, 0.9); backdrop.setStrokeStyle(1, palette.gold, 0.52); const title = this.add.text(0, -66, '설정', { fontSize: '24px', color: '#f1e3c2', fontStyle: '700', fixedWidth: 210, align: 'center' }); title.setOrigin(0.5); const soundText = this.createSettingsButton(0, -10, this.soundLabel()); soundText.on('pointerdown', () => { soundDirector.setMuted(!soundDirector.isMuted()); soundText.setText(this.soundLabel()); soundDirector.playSelect(); }); const close = this.createSettingsButton(0, 48, '닫기'); close.on('pointerdown', () => { soundDirector.playSelect(); this.closeSettingsPanel(); }); panel.add([backdrop, title, soundText, close]); panel.setAlpha(0); this.settingsPanel = panel; this.tweens.add({ targets: panel, alpha: 1, y: y + 228, duration: 160, ease: 'Sine.easeOut' }); } private createSettingsButton(x: number, y: number, label: string) { const text = this.add.text(x, y, label, { fontSize: '20px', color: '#d8b15f', fixedWidth: 210, align: 'center', padding: { top: 8, bottom: 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 < 720; const menuX = compact ? width / 2 : width - 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 + 252); const backdrop = this.add.rectangle(0, 0, 318, 256, 0x0e151d, 0.95); backdrop.setStrokeStyle(1, palette.gold, 0.58); const title = this.add.text(0, -104, '이어갈 기록 선택', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '22px', color: '#f1e3c2', fontStyle: '700', fixedWidth: 266, align: 'center' }); title.setOrigin(0.5); const rows = listCampaignSaveSlots().map((slot, index) => this.createSaveSlotRow(slot, -74 + index * 58)); const close = this.createSettingsButton(0, 104, '닫기'); close.on('pointerdown', () => { soundDirector.playSelect(); this.closeSaveSlotPanel(); }); panel.add([backdrop, title, ...rows, close]); panel.setAlpha(0); this.saveSlotPanel = panel; this.tweens.add({ targets: panel, alpha: 1, y: y + 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, 270, 48, enabled ? 0x1b2530 : 0x121820, enabled ? 0.9 : 0.56); background.setStrokeStyle(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(-122, -12, `슬롯 ${slot.slot}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '16px', color: enabled ? '#f6e6bd' : '#7d8189', fontStyle: '700', fixedWidth: 70, align: 'left' }); title.setOrigin(0, 0.5); const detail = this.add.text(-42, -12, campaign ? this.campaignSaveDetail(campaign, isEndingComplete) : '저장 없음', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: enabled ? '#d8b15f' : '#747c86', fixedWidth: 154, align: 'left' }); detail.setOrigin(0, 0.5); const meta = this.add.text(-42, 12, campaign ? this.campaignSaveMeta(campaign) : '빈 슬롯', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '11px', color: enabled ? '#9fb0bf' : '#626b75', fixedWidth: 154, align: 'left' }); meta.setOrigin(0, 0.5); row.add([background, title, detail, meta]); if (enabled) { background.setInteractive({ useHandCursor: true }); background.on('pointerover', () => { background.setFillStyle(0xd8b15f, 0.22); background.setStrokeStyle(1, palette.gold, 0.72); soundDirector.playHover(); }); background.on('pointerout', () => { background.setFillStyle(0x1b2530, 0.9); background.setStrokeStyle(1, palette.gold, 0.42); }); background.on('pointerdown', () => { this.closeSaveSlotPanel(); this.continueGame(slot.slot); }); } return row; } private closeSaveSlotPanel() { this.saveSlotPanel?.destroy(); this.saveSlotPanel = undefined; } private requestStartGame() { if (!hasCampaignSave()) { this.startGame(); return; } const { width, height } = this.scale; const compact = width < 720; const menuX = compact ? width / 2 : width - 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 + 240); const backdrop = this.add.rectangle(0, 0, 286, 174, 0x0e151d, 0.94); backdrop.setStrokeStyle(1, palette.gold, 0.58); const title = this.add.text(0, -58, '새로 시작', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '23px', color: '#f1e3c2', fontStyle: '700', fixedWidth: 220, align: 'center' }); title.setOrigin(0.5); const message = this.add.text(0, -20, '현재 저장을 덮고\n처음부터 시작합니다.', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '15px', color: '#bfc8d3', fixedWidth: 236, align: 'center', lineSpacing: 4 }); message.setOrigin(0.5); const confirm = this.createSettingsButton(0, 30, '새 게임 시작'); confirm.setColor('#f1e3c2'); confirm.on('pointerdown', () => { this.closeNewGameConfirmPanel(); this.startGame(); }); const cancel = this.createSettingsButton(0, 74, '취소'); cancel.on('pointerdown', () => { soundDirector.playSelect(); this.closeNewGameConfirmPanel(); }); panel.add([backdrop, title, message, confirm, cancel]); panel.setAlpha(0); this.newGameConfirmPanel = panel; this.tweens.add({ targets: panel, alpha: 1, y: y + 230, duration: 160, ease: 'Sine.easeOut' }); } private closeNewGameConfirmPanel() { this.newGameConfirmPanel?.destroy(); this.newGameConfirmPanel = undefined; } private activateDefaultMenuAction() { if (hasCampaignSave()) { this.continueGame(); 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) { 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; } } }