Add save slot UI and growth result gauges
This commit is contained in:
@@ -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('저장 데이터를 불러오지 못했습니다.');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user