Add save slot UI and growth result gauges

This commit is contained in:
2026-06-22 18:41:40 +09:00
parent 49822831f5
commit 891c89ff9e
3 changed files with 238 additions and 27 deletions

View File

@@ -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<string, string> = {
@@ -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<string, UnitView>();
private actedUnitIds = new Set<string>();
private bondStates = new Map<string, BondState>();
@@ -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('저장 데이터를 불러오지 못했습니다.');
}