diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index e99d764..ad7f36d 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -2,7 +2,13 @@ import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; import { getBattleScenario } from '../data/battles'; import { battleIdForCampaignStep, isCampCampaignStep } from '../state/campaignRouting'; -import { hasCampaignSave, loadCampaignState, startNewCampaign } from '../state/campaignState'; +import { + hasCampaignSave, + listCampaignSaveSlots, + loadCampaignState, + startNewCampaign, + type CampaignSaveSlotSummary +} from '../state/campaignState'; import { palette } from '../ui/palette'; import { startLazyScene } from './lazyScenes'; @@ -10,6 +16,7 @@ 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() { @@ -20,6 +27,7 @@ export class TitleScene extends Phaser.Scene { this.focusedButton = undefined; this.settingsPanel = undefined; this.newGameConfirmPanel = undefined; + this.saveSlotPanel = undefined; this.navigating = false; const debugBattleId = this.debugBattleId(); @@ -213,7 +221,7 @@ export class TitleScene extends Phaser.Scene { 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.continueGame()); + this.createMenuButton(menuX, menuY, isEndingComplete ? '엔딩 보기' : '이어하기', canContinue, () => this.requestContinueGame()); this.createMenuButton(menuX, menuY + 70, '설정', true, () => this.openSettingsPanel(menuX, menuY)); if (campaign) { @@ -369,6 +377,7 @@ export class TitleScene extends Phaser.Scene { 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); @@ -427,6 +436,124 @@ export class TitleScene extends Phaser.Scene { 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(); @@ -448,6 +575,7 @@ export class TitleScene extends Phaser.Scene { 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); @@ -514,15 +642,15 @@ export class TitleScene extends Phaser.Scene { void this.navigateTo('StoryScene'); } - private continueGame() { + private continueGame(slot?: number) { soundDirector.start(); soundDirector.resume(); soundDirector.playSelect(); - void this.continueGameAsync(); + void this.continueGameAsync(slot); } - private async continueGameAsync() { - const campaign = loadCampaignState(); + private async continueGameAsync(slot?: number) { + const campaign = loadCampaignState(slot); if (campaign.step === 'ending-complete') { await this.navigateTo('EndingScene'); return;