From 891c89ff9e84d4cbf5979cb31afa96b97ad96d4d Mon Sep 17 00:00:00 2001 From: Wickedness Date: Mon, 22 Jun 2026 18:41:40 +0900 Subject: [PATCH] Add save slot UI and growth result gauges --- scripts/verify-flow.mjs | 23 ++++ src/game/scenes/BattleScene.ts | 227 +++++++++++++++++++++++++++++---- src/game/scenes/CampScene.ts | 15 ++- 3 files changed, 238 insertions(+), 27 deletions(-) diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index e0af259..cd19b40 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -96,6 +96,29 @@ try { throw new Error('Expected all unit action textures to be loaded.'); } + await page.waitForTimeout(700); + await page.mouse.click(260, 300, { button: 'right' }); + await page.waitForTimeout(120); + await page.mouse.click(182, 213); + await page.waitForTimeout(120); + await page.mouse.click(400, 213); + await page.waitForTimeout(180); + + const battleSlotSave = await page.evaluate(() => { + const raw = window.localStorage.getItem('heros-web:battle:first-battle-zhuo-commandery:slot-1'); + return raw ? JSON.parse(raw) : undefined; + }); + if (!battleSlotSave || battleSlotSave.battleId !== 'first-battle-zhuo-commandery') { + throw new Error(`Expected slot 1 battle save from save UI: ${JSON.stringify(battleSlotSave)}`); + } + + await page.mouse.click(260, 300, { button: 'right' }); + await page.waitForTimeout(120); + await page.mouse.click(182, 247); + await page.waitForTimeout(120); + await page.mouse.click(400, 213); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.scene === 'BattleScene'); + const cameraBeforeScroll = result.battleState.camera; if ( !cameraBeforeScroll || diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 3da6418..7eba73a 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -10,7 +10,16 @@ import { getItem, type EquipmentSlot } from '../data/battleItems'; -import { getCampaignState, markCampaignStep, saveCampaignState, setFirstBattleReport, type CampaignStep } from '../state/campaignState'; +import { + campaignSaveSlotCount, + getCampaignState, + listCampaignSaveSlots, + loadCampaignState, + markCampaignStep, + saveCampaignState, + setFirstBattleReport, + type CampaignStep +} from '../state/campaignState'; import { palette } from '../ui/palette'; const unitTexture: Record = { @@ -98,6 +107,7 @@ type ActiveFaction = 'ally' | 'enemy'; type MapMenuAction = 'endTurn' | 'save' | 'load' | 'roster' | 'bond' | 'situation' | 'bgm' | 'close'; type EnemyAiBehavior = 'aggressive' | 'guard' | 'hold'; type BattleOutcome = 'victory' | 'defeat'; +type SaveSlotMode = 'save' | 'load'; type BattleSceneData = { battleId?: string; @@ -517,6 +527,7 @@ export class BattleScene extends Phaser.Scene { private turnPromptObjects: Phaser.GameObjects.GameObject[] = []; private combatCutInObjects: Phaser.GameObjects.GameObject[] = []; private resultObjects: Phaser.GameObjects.GameObject[] = []; + private saveSlotPanelObjects: Phaser.GameObjects.GameObject[] = []; private unitViews = new Map(); private actedUnitIds = new Set(); private bondStates = new Map(); @@ -966,6 +977,7 @@ export class BattleScene extends Phaser.Scene { private selectUnit(unit: UnitData) { this.hideMapMenu(); + this.hideSaveSlotPanel(); this.hideTurnEndPrompt(); if (this.battleOutcome) { @@ -1832,7 +1844,7 @@ export class BattleScene extends Phaser.Scene { unitTitle.setDepth(depth + 2); allies.forEach((unit, index) => { - this.renderResultUnitRow(unit, left + 44, top + 366 + index * 64, panelWidth - 88, depth + 2); + this.renderResultUnitRow(unit, left + 44, top + 366 + index * 70, panelWidth - 88, depth + 2); }); if (outcome === 'victory') { @@ -2021,7 +2033,7 @@ export class BattleScene extends Phaser.Scene { outcome === 'victory' ? `군자금 ${this.resultGold(outcome)} 목표 ${achievedCount}/${objectives.length}` : '패배: 보상 없음', mvp ? `MVP ${mvp.unit.name}: 피해 ${mvp.stats.damageDealt}, 격파 ${mvp.stats.defeats}` : 'MVP 없음', bondSummary ? `공명 성장 ${bondSummary}` : '공명 성장 없음', - outcome === 'victory' ? '전리품: 콩 1, 탁주 1, 의용군 명성 +1' : '재도전하면 목표 보상을 다시 노릴 수 있습니다.' + outcome === 'victory' ? `전리품: ${battleScenario.itemRewards.join(', ')}` : '재도전하면 목표 보상을 다시 노릴 수 있습니다.' ]; lines.forEach((line, index) => { @@ -2061,7 +2073,7 @@ export class BattleScene extends Phaser.Scene { private renderResultUnitRow(unit: UnitData, x: number, y: number, width: number, depth: number) { const alive = unit.hp > 0; - const bg = this.trackResultObject(this.add.rectangle(x, y, width, 64, 0x101820, alive ? 0.94 : 0.66)); + const bg = this.trackResultObject(this.add.rectangle(x, y, width, 68, 0x101820, alive ? 0.94 : 0.66)); bg.setOrigin(0); bg.setDepth(depth); bg.setStrokeStyle(1, alive ? palette.blue : 0x646464, alive ? 0.62 : 0.5); @@ -2096,16 +2108,43 @@ export class BattleScene extends Phaser.Scene { fontStyle: '700' })); expText.setDepth(depth + 1); + this.renderResultProgressGauge(x + 392, y + 36, 200, 8, unit.exp / 100, palette.gold, depth + 1); - const equipment = this.trackResultObject(this.add.text(x + 14, y + 32, this.equipmentResultText(unit), { + equipmentSlots.forEach((slot, index) => { + const state = unit.equipment[slot]; + const color = slot === 'weapon' ? 0xffc45f : slot === 'armor' ? 0x77b7ff : 0xb7df7a; + const slotX = x + 632 + index * 96; + const label = this.trackResultObject(this.add.text(slotX, y + 9, equipmentSlotLabels[slot], { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '11px', + color: alive ? '#9fb0bf' : '#777777', + fontStyle: '700' + })); + label.setDepth(depth + 1); + this.renderResultProgressGauge(slotX, y + 36, 76, 8, state.exp / equipmentExpToNext(state.level), color, depth + 1); + }); + + const equipment = this.trackResultObject(this.add.text(x + 14, y + 48, this.equipmentResultText(unit), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '12px', + fontSize: '11px', color: alive ? '#c8d2dd' : '#7e858d', wordWrap: { width: width - 28, useAdvancedWrap: true } })); equipment.setDepth(depth + 1); } + private renderResultProgressGauge(x: number, y: number, width: number, height: number, ratio: number, color: number, depth: number) { + const track = this.trackResultObject(this.add.rectangle(x, y, width, height, 0x05090e, 0.9)); + track.setOrigin(0); + track.setDepth(depth); + track.setStrokeStyle(1, 0x40515e, 0.5); + + const fillWidth = Math.max(2, Math.floor((width - 2) * Phaser.Math.Clamp(ratio, 0, 1))); + const fill = this.trackResultObject(this.add.rectangle(x + 1, y + 1, fillWidth, Math.max(2, height - 2), color, 0.96)); + fill.setOrigin(0); + fill.setDepth(depth + 1); + } + private characterResultText(unit: UnitData) { const initial = initialBattleUnits.find((candidate) => candidate.id === unit.id); const gained = initial ? Math.max(0, this.characterProgressScore(unit.level, unit.exp) - this.characterProgressScore(initial.level, initial.exp)) : 0; @@ -2851,11 +2890,11 @@ export class BattleScene extends Phaser.Scene { return; } if (action === 'save') { - this.saveBattleState(); + this.showSaveSlotPanel('save'); return; } if (action === 'load') { - this.loadBattleState(); + this.showSaveSlotPanel('load'); return; } if (action === 'roster') { @@ -2892,38 +2931,176 @@ export class BattleScene extends Phaser.Scene { } private hasBattleSave() { - return Boolean(window.localStorage.getItem(battleSaveStorageKey) ?? window.localStorage.getItem(legacyBattleSaveStorageKey)); + return Array.from({ length: campaignSaveSlotCount }, (_, index) => index + 1).some((slot) => Boolean(this.readBattleSaveState(slot))); } - private saveBattleState() { - try { - const state = this.createBattleSaveState(); - window.localStorage.setItem(battleSaveStorageKey, JSON.stringify(state)); - window.localStorage.removeItem(legacyBattleSaveStorageKey); - saveCampaignState(); - this.renderSituationPanel(`전투 상태를 저장했습니다.\n${this.formatSavedAt(state.savedAt)}`); - } catch { - this.renderSituationPanel('전투 상태를 저장하지 못했습니다.'); + private showSaveSlotPanel(mode: SaveSlotMode) { + this.hideSaveSlotPanel(); + const depth = 70; + const width = 520; + const height = 312; + const left = this.layout.mapX + 118; + const top = this.layout.mapY + 82; + const campaignSlots = listCampaignSaveSlots(); + + const panel = this.add.rectangle(left, top, width, height, 0x0f1720, 0.98); + panel.setOrigin(0); + panel.setDepth(depth); + panel.setStrokeStyle(2, mode === 'save' ? palette.gold : palette.blue, 0.9); + panel.setInteractive(); + this.saveSlotPanelObjects.push(panel); + + const title = this.add.text(left + 22, top + 18, mode === 'save' ? '전투 저장 슬롯' : '전투 불러오기 슬롯', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '22px', + color: '#f2e3bf', + fontStyle: '700' + }); + title.setDepth(depth + 1); + this.saveSlotPanelObjects.push(title); + + const guide = this.add.text( + left + 22, + top + 50, + mode === 'save' ? '현재 전투와 캠페인 진행 상태를 함께 저장합니다.' : '저장된 전투 상태와 캠페인 슬롯을 함께 불러옵니다.', + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '13px', + color: '#aeb7c2' + } + ); + guide.setDepth(depth + 1); + this.saveSlotPanelObjects.push(guide); + + for (let slot = 1; slot <= campaignSaveSlotCount; slot += 1) { + const rowTop = top + 82 + (slot - 1) * 62; + const battleSave = this.readBattleSaveState(slot); + const campaignSlot = campaignSlots.find((candidate) => candidate.slot === slot); + const disabled = mode === 'load' && !battleSave; + const row = this.add.rectangle(left + 18, rowTop, width - 36, 50, disabled ? 0x121820 : 0x172230, disabled ? 0.76 : 0.94); + row.setOrigin(0); + row.setDepth(depth + 1); + row.setStrokeStyle(1, disabled ? 0x53606c : slot === getCampaignState().activeSaveSlot ? palette.gold : palette.blue, disabled ? 0.34 : 0.72); + this.saveSlotPanelObjects.push(row); + + const titleText = this.add.text(left + 34, rowTop + 8, `슬롯 ${slot}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '16px', + color: disabled ? '#78818a' : '#f2e3bf', + fontStyle: '700' + }); + titleText.setDepth(depth + 2); + this.saveSlotPanelObjects.push(titleText); + + const battleSummary = battleSave + ? `${this.formatSavedAt(battleSave.savedAt)} · ${battleSave.turnNumber}턴 · ${factionLabels[battleSave.activeFaction]}` + : '전투 저장 없음'; + const campaignSummary = campaignSlot?.exists + ? `군자금 ${campaignSlot.gold ?? 0} · ${campaignSlot.battleTitle ?? '진행 중'}` + : '캠페인 저장 없음'; + const detail = this.add.text(left + 104, rowTop + 9, `${battleSummary}\n${campaignSummary}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: disabled ? '#6d7580' : '#d4dce6', + lineSpacing: 3 + }); + detail.setDepth(depth + 2); + this.saveSlotPanelObjects.push(detail); + + if (!disabled) { + const run = () => { + soundDirector.playSelect(); + if (mode === 'save') { + this.saveBattleState(slot); + } else { + this.loadBattleState(slot); + } + this.hideSaveSlotPanel(); + }; + row.setInteractive({ useHandCursor: true }); + row.on('pointerover', () => row.setFillStyle(0x263647, 0.98)); + row.on('pointerout', () => row.setFillStyle(0x172230, 0.94)); + row.on('pointerdown', run); + titleText.setInteractive({ useHandCursor: true }); + titleText.on('pointerdown', run); + detail.setInteractive({ useHandCursor: true }); + detail.on('pointerdown', run); + } } + + const close = this.add.text(left + width - 52, top + 20, '닫기', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '14px', + color: '#d8b15f', + fontStyle: '700' + }); + close.setDepth(depth + 2); + close.setInteractive({ useHandCursor: true }); + close.on('pointerdown', () => { + soundDirector.playSelect(); + this.hideSaveSlotPanel(); + }); + this.saveSlotPanelObjects.push(close); } - private loadBattleState() { - const raw = window.localStorage.getItem(battleSaveStorageKey) ?? window.localStorage.getItem(legacyBattleSaveStorageKey); + private hideSaveSlotPanel() { + this.saveSlotPanelObjects.forEach((object) => object.destroy()); + this.saveSlotPanelObjects = []; + } + + private battleSaveStorageKeyForSlot(slot: number) { + return `${battleSaveStorageKey}:slot-${Phaser.Math.Clamp(Math.floor(slot), 1, campaignSaveSlotCount)}`; + } + + private readBattleSaveState(slot: number) { + const normalizedSlot = Phaser.Math.Clamp(Math.floor(slot), 1, campaignSaveSlotCount); + const raw = + window.localStorage.getItem(this.battleSaveStorageKeyForSlot(normalizedSlot)) ?? + (normalizedSlot === 1 ? window.localStorage.getItem(battleSaveStorageKey) ?? window.localStorage.getItem(legacyBattleSaveStorageKey) : undefined); if (!raw) { - this.renderSituationPanel('불러올 저장 데이터가 없습니다.'); - return; + return undefined; } try { const state = JSON.parse(raw) as BattleSaveState; if (!state || state.version !== 1 || !Array.isArray(state.units)) { - throw new Error('Invalid battle save'); + return undefined; } if (state.battleId && state.battleId !== battleScenario.id) { - throw new Error('Battle save does not belong to this scenario'); + return undefined; } + return state; + } catch { + return undefined; + } + } + + private saveBattleState(slot = 1) { + try { + const state = this.createBattleSaveState(); + window.localStorage.setItem(this.battleSaveStorageKeyForSlot(slot), JSON.stringify(state)); + if (slot === 1) { + window.localStorage.setItem(battleSaveStorageKey, JSON.stringify(state)); + window.localStorage.removeItem(legacyBattleSaveStorageKey); + } + saveCampaignState(getCampaignState(), slot); + this.renderSituationPanel(`슬롯 ${slot}에 전투 상태를 저장했습니다.\n${this.formatSavedAt(state.savedAt)}`); + } catch { + this.renderSituationPanel('전투 상태를 저장하지 못했습니다.'); + } + } + + private loadBattleState(slot = 1) { + const state = this.readBattleSaveState(slot); + if (!state) { + this.renderSituationPanel('불러올 저장 데이터가 없습니다.'); + return; + } + + try { + loadCampaignState(slot); this.applyBattleSaveState(state); - this.renderSituationPanel(`저장된 전투를 불러왔습니다.\n${this.formatSavedAt(state.savedAt)}`); + this.renderSituationPanel(`슬롯 ${slot}의 전투를 불러왔습니다.\n${this.formatSavedAt(state.savedAt)}`); } catch { this.renderSituationPanel('저장 데이터를 불러오지 못했습니다.'); } diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 76e04d1..d6b938f 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -8,6 +8,7 @@ import { getCampaignState, getFirstBattleReport, markCampaignStep, + saveCampaignState, type CampaignState, type FirstBattleReport } from '../state/campaignState'; @@ -150,7 +151,13 @@ export class CampScene extends Phaser.Scene { this.addTabButton('장수', 'status', 742, 38); this.addTabButton('대화', 'dialogue', 830, 38); this.addTabButton('정비', 'supplies', 918, 38); - this.addCommandButton('다음 이야기', 1080, 38, 150, () => { + this.addCommandButton('저장', 1002, 38, 76, () => { + soundDirector.playSelect(); + this.campaign = saveCampaignState(getCampaignState()); + this.showCampNotice(`슬롯 ${this.campaign.activeSaveSlot}에 군영 진행을 저장했습니다.`); + this.render(); + }); + this.addCommandButton('다음 이야기', 1120, 38, 142, () => { soundDirector.playSelect(); markCampaignStep('first-victory-story'); this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' }); @@ -448,12 +455,16 @@ export class CampScene extends Phaser.Scene { } private showDialogueReward(dialogue: CampDialogue) { + this.showCampNotice(`${dialogue.title} 완료 · 공명 +${dialogue.rewardExp}`); + } + + private showCampNotice(message: string) { this.dialogueObjects.forEach((object) => object.destroy()); this.dialogueObjects = []; const box = this.add.rectangle(this.scale.width / 2, 104, 440, 52, 0x101820, 0.96); box.setStrokeStyle(2, palette.gold, 0.84); box.setDepth(30); - const text = this.add.text(this.scale.width / 2, 104, `${dialogue.title} 완료 · 공명 +${dialogue.rewardExp}`, this.textStyle(17, '#f2e3bf', true)); + const text = this.add.text(this.scale.width / 2, 104, message, this.textStyle(17, '#f2e3bf', true)); text.setOrigin(0.5); text.setDepth(31); this.dialogueObjects.push(box, text);