Solidify campaign progression state
This commit is contained in:
@@ -13,6 +13,8 @@ try {
|
|||||||
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
||||||
|
|
||||||
await page.goto(targetUrl, { waitUntil: 'networkidle' });
|
await page.goto(targetUrl, { waitUntil: 'networkidle' });
|
||||||
|
await page.evaluate(() => window.localStorage.clear());
|
||||||
|
await page.reload({ waitUntil: 'networkidle' });
|
||||||
await page.waitForSelector('canvas');
|
await page.waitForSelector('canvas');
|
||||||
await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined);
|
await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined);
|
||||||
await page.waitForFunction(() => window.__HEROS_DEBUG__ !== undefined);
|
await page.waitForFunction(() => window.__HEROS_DEBUG__ !== undefined);
|
||||||
@@ -161,6 +163,19 @@ try {
|
|||||||
throw new Error(`Expected camp dialogue to award bond exp: ${JSON.stringify(campStateAfterDialogue)}`);
|
throw new Error(`Expected camp dialogue to award bond exp: ${JSON.stringify(campStateAfterDialogue)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const campaignSaveAfterDialogue = await page.evaluate(() => {
|
||||||
|
const raw = window.localStorage.getItem('heros-web:campaign-state');
|
||||||
|
return raw ? JSON.parse(raw) : undefined;
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!campaignSaveAfterDialogue ||
|
||||||
|
campaignSaveAfterDialogue.step !== 'first-camp' ||
|
||||||
|
!campaignSaveAfterDialogue.completedCampDialogues?.length ||
|
||||||
|
Object.keys(campaignSaveAfterDialogue.inventory ?? {}).length === 0
|
||||||
|
) {
|
||||||
|
throw new Error(`Expected campaign save to persist camp progress: ${JSON.stringify(campaignSaveAfterDialogue)}`);
|
||||||
|
}
|
||||||
|
|
||||||
await page.evaluate(() => window.__HEROS_DEBUG__?.goToBattle());
|
await page.evaluate(() => window.__HEROS_DEBUG__?.goToBattle());
|
||||||
await page.waitForFunction(() => {
|
await page.waitForFunction(() => {
|
||||||
const state = window.__HEROS_DEBUG__?.battle();
|
const state = window.__HEROS_DEBUG__?.battle();
|
||||||
|
|||||||
40
src/game/data/battles.ts
Normal file
40
src/game/data/battles.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
firstBattleBonds,
|
||||||
|
firstBattleMap,
|
||||||
|
firstBattleUnits,
|
||||||
|
firstBattleVictoryPages,
|
||||||
|
type BattleBond,
|
||||||
|
type BattleMap,
|
||||||
|
type StoryPage,
|
||||||
|
type UnitData
|
||||||
|
} from './scenario';
|
||||||
|
|
||||||
|
export type BattleScenarioDefinition = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
map: BattleMap;
|
||||||
|
units: UnitData[];
|
||||||
|
bonds: BattleBond[];
|
||||||
|
mapTextureKey: string;
|
||||||
|
leaderUnitId: string;
|
||||||
|
quickVictoryTurnLimit: number;
|
||||||
|
baseVictoryGold: number;
|
||||||
|
itemRewards: string[];
|
||||||
|
victoryPages: StoryPage[];
|
||||||
|
nextCampScene: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const firstBattleScenario: BattleScenarioDefinition = {
|
||||||
|
id: 'first-battle-zhuo-commandery',
|
||||||
|
title: '탁현의 전투',
|
||||||
|
map: firstBattleMap,
|
||||||
|
units: firstBattleUnits,
|
||||||
|
bonds: firstBattleBonds,
|
||||||
|
mapTextureKey: 'battle-map-first',
|
||||||
|
leaderUnitId: 'rebel-leader',
|
||||||
|
quickVictoryTurnLimit: 8,
|
||||||
|
baseVictoryGold: 300,
|
||||||
|
itemRewards: ['콩 1', '탁주 1', '의용군 명성 +1'],
|
||||||
|
victoryPages: firstBattleVictoryPages,
|
||||||
|
nextCampScene: 'CampScene'
|
||||||
|
};
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type UnitData,
|
type UnitData,
|
||||||
type UnitStats
|
type UnitStats
|
||||||
} from '../data/scenario';
|
} from '../data/scenario';
|
||||||
|
import { firstBattleScenario } from '../data/battles';
|
||||||
import { getTerrainRule, getUnitClass, type TerrainType, type UnitClassKey } from '../data/battleRules';
|
import { getTerrainRule, getUnitClass, type TerrainType, type UnitClassKey } from '../data/battleRules';
|
||||||
import {
|
import {
|
||||||
equipmentExpToNext,
|
equipmentExpToNext,
|
||||||
@@ -16,7 +17,7 @@ import {
|
|||||||
getItem,
|
getItem,
|
||||||
type EquipmentSlot
|
type EquipmentSlot
|
||||||
} from '../data/battleItems';
|
} from '../data/battleItems';
|
||||||
import { setFirstBattleReport } from '../state/campaignState';
|
import { getCampaignState, markCampaignStep, saveCampaignState, setFirstBattleReport, type CampaignStep } from '../state/campaignState';
|
||||||
import { palette } from '../ui/palette';
|
import { palette } from '../ui/palette';
|
||||||
|
|
||||||
const unitTexture: Record<string, string> = {
|
const unitTexture: Record<string, string> = {
|
||||||
@@ -269,6 +270,8 @@ type SavedUnitState = Pick<UnitData, 'id' | 'level' | 'exp' | 'hp' | 'maxHp' | '
|
|||||||
|
|
||||||
type BattleSaveState = {
|
type BattleSaveState = {
|
||||||
version: 1;
|
version: 1;
|
||||||
|
battleId: string;
|
||||||
|
campaignStep?: CampaignStep;
|
||||||
savedAt: string;
|
savedAt: string;
|
||||||
turnNumber: number;
|
turnNumber: number;
|
||||||
activeFaction: ActiveFaction;
|
activeFaction: ActiveFaction;
|
||||||
@@ -286,7 +289,9 @@ type BattleSaveState = {
|
|||||||
|
|
||||||
const maxEquipmentLevel = 9;
|
const maxEquipmentLevel = 9;
|
||||||
const maxCharacterLevel = 99;
|
const maxCharacterLevel = 99;
|
||||||
const battleSaveStorageKey = 'heros-web:first-battle-state';
|
const battleScenario = firstBattleScenario;
|
||||||
|
const legacyBattleSaveStorageKey = 'heros-web:first-battle-state';
|
||||||
|
const battleSaveStorageKey = `heros-web:battle:${battleScenario.id}`;
|
||||||
const initialFirstBattleUnits = firstBattleUnits.map(cloneUnitData);
|
const initialFirstBattleUnits = firstBattleUnits.map(cloneUnitData);
|
||||||
const initialFirstBattleBonds = firstBattleBonds.map(cloneBattleBond);
|
const initialFirstBattleBonds = firstBattleBonds.map(cloneBattleBond);
|
||||||
|
|
||||||
@@ -413,9 +418,9 @@ const defaultEnemyAiByClass: Partial<Record<UnitClassKey, EnemyAiBehavior>> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const guardDetectionRange = 5;
|
const guardDetectionRange = 5;
|
||||||
const leaderUnitId = 'rebel-leader';
|
const leaderUnitId = battleScenario.leaderUnitId;
|
||||||
const quickVictoryTurnLimit = 8;
|
const quickVictoryTurnLimit = battleScenario.quickVictoryTurnLimit;
|
||||||
const baseVictoryGold = 300;
|
const baseVictoryGold = battleScenario.baseVictoryGold;
|
||||||
|
|
||||||
const commandLabels: Record<BattleCommand, string> = {
|
const commandLabels: Record<BattleCommand, string> = {
|
||||||
attack: '공격',
|
attack: '공격',
|
||||||
@@ -529,6 +534,10 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
|
|
||||||
create() {
|
create() {
|
||||||
this.resetBattleData();
|
this.resetBattleData();
|
||||||
|
const campaign = getCampaignState();
|
||||||
|
if (campaign.step === 'new' || campaign.step === 'prologue' || campaign.step === 'first-battle') {
|
||||||
|
markCampaignStep('first-battle');
|
||||||
|
}
|
||||||
const { width, height } = this.scale;
|
const { width, height } = this.scale;
|
||||||
this.layout = this.createLayout(width, height);
|
this.layout = this.createLayout(width, height);
|
||||||
const startUnit = firstBattleUnits.find((unit) => unit.id === 'liu-bei') ?? firstBattleUnits[0];
|
const startUnit = firstBattleUnits.find((unit) => unit.id === 'liu-bei') ?? firstBattleUnits[0];
|
||||||
@@ -625,7 +634,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
this.mapMask = maskShape.createGeometryMask();
|
this.mapMask = maskShape.createGeometryMask();
|
||||||
const mapMask = this.mapMask;
|
const mapMask = this.mapMask;
|
||||||
|
|
||||||
const background = this.add.image(0, 0, 'battle-map-first');
|
const background = this.add.image(0, 0, battleScenario.mapTextureKey);
|
||||||
background.setOrigin(0);
|
background.setOrigin(0);
|
||||||
background.setDepth(0);
|
background.setDepth(0);
|
||||||
background.setDisplaySize(firstBattleMap.width * layout.tileSize, firstBattleMap.height * layout.tileSize);
|
background.setDisplaySize(firstBattleMap.width * layout.tileSize, firstBattleMap.height * layout.tileSize);
|
||||||
@@ -1912,7 +1921,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
defeats: mvp.stats.defeats
|
defeats: mvp.stats.defeats
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
itemRewards: outcome === 'victory' ? ['콩 1', '탁주 1', '의용군 명성 +1'] : [],
|
itemRewards: outcome === 'victory' ? [...battleScenario.itemRewards] : [],
|
||||||
completedCampDialogues: [],
|
completedCampDialogues: [],
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString()
|
||||||
});
|
});
|
||||||
@@ -2838,13 +2847,15 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private hasBattleSave() {
|
private hasBattleSave() {
|
||||||
return Boolean(window.localStorage.getItem(battleSaveStorageKey));
|
return Boolean(window.localStorage.getItem(battleSaveStorageKey) ?? window.localStorage.getItem(legacyBattleSaveStorageKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
private saveBattleState() {
|
private saveBattleState() {
|
||||||
try {
|
try {
|
||||||
const state = this.createBattleSaveState();
|
const state = this.createBattleSaveState();
|
||||||
window.localStorage.setItem(battleSaveStorageKey, JSON.stringify(state));
|
window.localStorage.setItem(battleSaveStorageKey, JSON.stringify(state));
|
||||||
|
window.localStorage.removeItem(legacyBattleSaveStorageKey);
|
||||||
|
saveCampaignState();
|
||||||
this.renderSituationPanel(`전투 상태를 저장했습니다.\n${this.formatSavedAt(state.savedAt)}`);
|
this.renderSituationPanel(`전투 상태를 저장했습니다.\n${this.formatSavedAt(state.savedAt)}`);
|
||||||
} catch {
|
} catch {
|
||||||
this.renderSituationPanel('전투 상태를 저장하지 못했습니다.');
|
this.renderSituationPanel('전투 상태를 저장하지 못했습니다.');
|
||||||
@@ -2852,7 +2863,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private loadBattleState() {
|
private loadBattleState() {
|
||||||
const raw = window.localStorage.getItem(battleSaveStorageKey);
|
const raw = window.localStorage.getItem(battleSaveStorageKey) ?? window.localStorage.getItem(legacyBattleSaveStorageKey);
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
this.renderSituationPanel('불러올 저장 데이터가 없습니다.');
|
this.renderSituationPanel('불러올 저장 데이터가 없습니다.');
|
||||||
return;
|
return;
|
||||||
@@ -2863,6 +2874,9 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
if (!state || state.version !== 1 || !Array.isArray(state.units)) {
|
if (!state || state.version !== 1 || !Array.isArray(state.units)) {
|
||||||
throw new Error('Invalid battle save');
|
throw new Error('Invalid battle save');
|
||||||
}
|
}
|
||||||
|
if (state.battleId && state.battleId !== battleScenario.id) {
|
||||||
|
throw new Error('Battle save does not belong to this scenario');
|
||||||
|
}
|
||||||
this.applyBattleSaveState(state);
|
this.applyBattleSaveState(state);
|
||||||
this.renderSituationPanel(`저장된 전투를 불러왔습니다.\n${this.formatSavedAt(state.savedAt)}`);
|
this.renderSituationPanel(`저장된 전투를 불러왔습니다.\n${this.formatSavedAt(state.savedAt)}`);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -2873,6 +2887,8 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
private createBattleSaveState(): BattleSaveState {
|
private createBattleSaveState(): BattleSaveState {
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
|
battleId: battleScenario.id,
|
||||||
|
campaignStep: getCampaignState().step,
|
||||||
savedAt: new Date().toISOString(),
|
savedAt: new Date().toISOString(),
|
||||||
turnNumber: this.turnNumber,
|
turnNumber: this.turnNumber,
|
||||||
activeFaction: this.activeFaction,
|
activeFaction: this.activeFaction,
|
||||||
@@ -5366,6 +5382,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
getDebugState() {
|
getDebugState() {
|
||||||
return {
|
return {
|
||||||
scene: this.scene.key,
|
scene: this.scene.key,
|
||||||
|
battleId: battleScenario.id,
|
||||||
turnNumber: this.turnNumber,
|
turnNumber: this.turnNumber,
|
||||||
activeFaction: this.activeFaction,
|
activeFaction: this.activeFaction,
|
||||||
phase: this.phase,
|
phase: this.phase,
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ import Phaser from 'phaser';
|
|||||||
import { soundDirector } from '../audio/SoundDirector';
|
import { soundDirector } from '../audio/SoundDirector';
|
||||||
import { equipmentExpToNext, equipmentSlotLabels, equipmentSlots, getItem, type EquipmentSlot } from '../data/battleItems';
|
import { equipmentExpToNext, equipmentSlotLabels, equipmentSlots, getItem, type EquipmentSlot } from '../data/battleItems';
|
||||||
import { firstBattleBonds, firstBattleUnits, firstBattleVictoryPages, type PortraitKey, type UnitData } from '../data/scenario';
|
import { firstBattleBonds, firstBattleUnits, firstBattleVictoryPages, type PortraitKey, type UnitData } from '../data/scenario';
|
||||||
import { applyCampBondExp, getFirstBattleReport, type CampBondSnapshot, type FirstBattleReport } from '../state/campaignState';
|
import {
|
||||||
|
applyCampBondExp,
|
||||||
|
getCampaignState,
|
||||||
|
getFirstBattleReport,
|
||||||
|
markCampaignStep,
|
||||||
|
type CampaignState,
|
||||||
|
type FirstBattleReport
|
||||||
|
} from '../state/campaignState';
|
||||||
import { palette } from '../ui/palette';
|
import { palette } from '../ui/palette';
|
||||||
|
|
||||||
type CampTab = 'status' | 'dialogue' | 'supplies';
|
type CampTab = 'status' | 'dialogue' | 'supplies';
|
||||||
@@ -68,6 +75,7 @@ const campDialogues: CampDialogue[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export class CampScene extends Phaser.Scene {
|
export class CampScene extends Phaser.Scene {
|
||||||
|
private campaign?: CampaignState;
|
||||||
private report?: FirstBattleReport;
|
private report?: FirstBattleReport;
|
||||||
private selectedUnitId = 'liu-bei';
|
private selectedUnitId = 'liu-bei';
|
||||||
private activeTab: CampTab = 'status';
|
private activeTab: CampTab = 'status';
|
||||||
@@ -80,8 +88,9 @@ export class CampScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
this.report = getFirstBattleReport() ?? this.createFallbackReport();
|
this.campaign = getCampaignState();
|
||||||
this.selectedUnitId = this.report.units.find((unit) => unit.faction === 'ally')?.id ?? 'liu-bei';
|
this.report = this.campaign.firstBattleReport ?? getFirstBattleReport() ?? this.createFallbackReport();
|
||||||
|
this.selectedUnitId = this.currentUnits().find((unit) => unit.faction === 'ally')?.id ?? 'liu-bei';
|
||||||
soundDirector.playMusic('militia-theme');
|
soundDirector.playMusic('militia-theme');
|
||||||
|
|
||||||
const { width, height } = this.scale;
|
const { width, height } = this.scale;
|
||||||
@@ -130,7 +139,8 @@ export class CampScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const achieved = this.report.objectives.filter((objective) => objective.achieved).length;
|
const achieved = this.report.objectives.filter((objective) => objective.achieved).length;
|
||||||
return `첫 전투 ${this.report.turnNumber}턴 승리 · 군자금 ${this.report.rewardGold} · 목표 ${achieved}/${this.report.objectives.length} · 격파 ${this.report.defeatedEnemies}/${this.report.totalEnemies}`;
|
const gold = this.campaign?.gold ?? this.report.rewardGold;
|
||||||
|
return `첫 전투 ${this.report.turnNumber}턴 승리 · 군자금 ${gold} · 목표 ${achieved}/${this.report.objectives.length} · 격파 ${this.report.defeatedEnemies}/${this.report.totalEnemies}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderStaticButtons() {
|
private renderStaticButtons() {
|
||||||
@@ -139,6 +149,7 @@ export class CampScene extends Phaser.Scene {
|
|||||||
this.addTabButton('정비', 'supplies', 918, 38);
|
this.addTabButton('정비', 'supplies', 918, 38);
|
||||||
this.addCommandButton('다음 이야기', 1080, 38, 150, () => {
|
this.addCommandButton('다음 이야기', 1080, 38, 150, () => {
|
||||||
soundDirector.playSelect();
|
soundDirector.playSelect();
|
||||||
|
markCampaignStep('first-victory-story');
|
||||||
this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' });
|
this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -206,7 +217,7 @@ export class CampScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private renderUnitColumn() {
|
private renderUnitColumn() {
|
||||||
const allies = this.report?.units.filter((unit) => unit.faction === 'ally') ?? [];
|
const allies = this.currentUnits().filter((unit) => unit.faction === 'ally');
|
||||||
allies.forEach((unit, index) => {
|
allies.forEach((unit, index) => {
|
||||||
const x = 42;
|
const x = 42;
|
||||||
const y = 120 + index * 150;
|
const y = 120 + index * 150;
|
||||||
@@ -294,7 +305,7 @@ export class CampScene extends Phaser.Scene {
|
|||||||
this.track(this.add.text(x + 24, y + 56, '대화를 보면 해당 장수들의 공명 경험치가 오릅니다.', this.textStyle(14, '#d4dce6')));
|
this.track(this.add.text(x + 24, y + 56, '대화를 보면 해당 장수들의 공명 경험치가 오릅니다.', this.textStyle(14, '#d4dce6')));
|
||||||
|
|
||||||
campDialogues.forEach((dialogue, index) => {
|
campDialogues.forEach((dialogue, index) => {
|
||||||
const completed = this.report?.completedCampDialogues.includes(dialogue.id) ?? false;
|
const completed = this.completedCampDialogues().includes(dialogue.id);
|
||||||
const rowY = y + 96 + index * 64;
|
const rowY = y + 96 + index * 64;
|
||||||
const selected = this.selectedDialogueId === dialogue.id;
|
const selected = this.selectedDialogueId === dialogue.id;
|
||||||
const row = this.track(this.add.rectangle(x + 24, rowY, 320, 48, selected ? 0x25384a : 0x151f2a, selected ? 0.96 : 0.86));
|
const row = this.track(this.add.rectangle(x + 24, rowY, 320, 48, selected ? 0x25384a : 0x151f2a, selected ? 0.96 : 0.86));
|
||||||
@@ -316,7 +327,7 @@ export class CampScene extends Phaser.Scene {
|
|||||||
|
|
||||||
private renderSelectedDialogue(x: number, y: number, width: number, height: number) {
|
private renderSelectedDialogue(x: number, y: number, width: number, height: number) {
|
||||||
const dialogue = campDialogues.find((candidate) => candidate.id === this.selectedDialogueId) ?? campDialogues[0];
|
const dialogue = campDialogues.find((candidate) => candidate.id === this.selectedDialogueId) ?? campDialogues[0];
|
||||||
const completed = this.report?.completedCampDialogues.includes(dialogue.id) ?? false;
|
const completed = this.completedCampDialogues().includes(dialogue.id);
|
||||||
const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
|
const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
|
||||||
bg.setOrigin(0);
|
bg.setOrigin(0);
|
||||||
bg.setStrokeStyle(1, completed ? palette.green : palette.gold, 0.5);
|
bg.setStrokeStyle(1, completed ? palette.green : palette.gold, 0.5);
|
||||||
@@ -346,7 +357,7 @@ export class CampScene extends Phaser.Scene {
|
|||||||
|
|
||||||
private renderBondList(x: number, y: number, width: number) {
|
private renderBondList(x: number, y: number, width: number) {
|
||||||
this.track(this.add.text(x, y, '공명', this.textStyle(18, '#f2e3bf', true)));
|
this.track(this.add.text(x, y, '공명', this.textStyle(18, '#f2e3bf', true)));
|
||||||
this.report?.bonds.forEach((bond, index) => {
|
this.currentBonds().forEach((bond, index) => {
|
||||||
const rowY = y + 32 + index * 36;
|
const rowY = y + 32 + index * 36;
|
||||||
const first = this.unitName(bond.unitIds[0]);
|
const first = this.unitName(bond.unitIds[0]);
|
||||||
const second = this.unitName(bond.unitIds[1]);
|
const second = this.unitName(bond.unitIds[1]);
|
||||||
@@ -364,13 +375,13 @@ export class CampScene extends Phaser.Scene {
|
|||||||
this.track(this.add.text(x + 24, y + 22, '정비와 보급', this.textStyle(24, '#f2e3bf', true)));
|
this.track(this.add.text(x + 24, y + 22, '정비와 보급', this.textStyle(24, '#f2e3bf', true)));
|
||||||
this.track(this.add.text(x + 24, y + 58, '획득한 보상과 장비 성장 상태를 확인합니다. 장비 교체와 상점은 다음 확장에서 붙일 수 있습니다.', this.textStyle(14, '#d4dce6')));
|
this.track(this.add.text(x + 24, y + 58, '획득한 보상과 장비 성장 상태를 확인합니다. 장비 교체와 상점은 다음 확장에서 붙일 수 있습니다.', this.textStyle(14, '#d4dce6')));
|
||||||
|
|
||||||
const rewards = this.report?.itemRewards.length ? this.report.itemRewards : ['콩 1', '탁주 1'];
|
const rewards = this.inventoryLabels();
|
||||||
rewards.forEach((reward, index) => {
|
rewards.forEach((reward, index) => {
|
||||||
this.renderSupplyBox(reward, x + 24 + index * 160, y + 104);
|
this.renderSupplyBox(reward, x + 24 + index * 160, y + 104);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.track(this.add.text(x + 24, y + 190, '부대 장비 요약', this.textStyle(20, '#f2e3bf', true)));
|
this.track(this.add.text(x + 24, y + 190, '부대 장비 요약', this.textStyle(20, '#f2e3bf', true)));
|
||||||
this.report?.units
|
this.currentUnits()
|
||||||
.filter((unit) => unit.faction === 'ally')
|
.filter((unit) => unit.faction === 'ally')
|
||||||
.forEach((unit, index) => {
|
.forEach((unit, index) => {
|
||||||
const rowY = y + 232 + index * 58;
|
const rowY = y + 232 + index * 58;
|
||||||
@@ -418,6 +429,7 @@ export class CampScene extends Phaser.Scene {
|
|||||||
const updated = applyCampBondExp(dialogue.id, dialogue.bondId, dialogue.rewardExp);
|
const updated = applyCampBondExp(dialogue.id, dialogue.bondId, dialogue.rewardExp);
|
||||||
if (updated) {
|
if (updated) {
|
||||||
this.report = updated;
|
this.report = updated;
|
||||||
|
this.campaign = getCampaignState();
|
||||||
} else if (this.report && !this.report.completedCampDialogues.includes(dialogue.id)) {
|
} else if (this.report && !this.report.completedCampDialogues.includes(dialogue.id)) {
|
||||||
this.report.completedCampDialogues.push(dialogue.id);
|
this.report.completedCampDialogues.push(dialogue.id);
|
||||||
const bond = this.report.bonds.find((candidate) => candidate.id === dialogue.bondId);
|
const bond = this.report.bonds.find((candidate) => candidate.id === dialogue.bondId);
|
||||||
@@ -455,11 +467,41 @@ export class CampScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private selectedUnit() {
|
private selectedUnit() {
|
||||||
return this.report?.units.find((unit) => unit.id === this.selectedUnitId);
|
return this.currentUnits().find((unit) => unit.id === this.selectedUnitId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private unitName(unitId: string) {
|
private unitName(unitId: string) {
|
||||||
return this.report?.units.find((unit) => unit.id === unitId)?.name ?? unitId;
|
return this.currentUnits().find((unit) => unit.id === unitId)?.name ?? unitId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private currentUnits() {
|
||||||
|
if (this.campaign?.roster.length) {
|
||||||
|
return this.campaign.roster;
|
||||||
|
}
|
||||||
|
return this.report?.units ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private currentBonds() {
|
||||||
|
if (this.campaign?.bonds.length) {
|
||||||
|
return this.campaign.bonds;
|
||||||
|
}
|
||||||
|
return this.report?.bonds ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private completedCampDialogues() {
|
||||||
|
if (this.campaign) {
|
||||||
|
return this.campaign.completedCampDialogues;
|
||||||
|
}
|
||||||
|
return this.report?.completedCampDialogues ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private inventoryLabels() {
|
||||||
|
const inventory = this.campaign?.inventory ?? {};
|
||||||
|
const entries = Object.entries(inventory).filter(([, amount]) => amount > 0);
|
||||||
|
if (entries.length > 0) {
|
||||||
|
return entries.map(([label, amount]) => `${label} ${amount}`);
|
||||||
|
}
|
||||||
|
return this.report?.itemRewards.length ? this.report.itemRewards : ['콩 1', '탁주 1'];
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawBar(x: number, y: number, width: number, height: number, ratio: number, color: number) {
|
private drawBar(x: number, y: number, width: number, height: number, ratio: number, color: number) {
|
||||||
@@ -494,6 +536,14 @@ export class CampScene extends Phaser.Scene {
|
|||||||
activeTab: this.activeTab,
|
activeTab: this.activeTab,
|
||||||
selectedUnitId: this.selectedUnitId,
|
selectedUnitId: this.selectedUnitId,
|
||||||
selectedDialogueId: this.selectedDialogueId,
|
selectedDialogueId: this.selectedDialogueId,
|
||||||
|
campaign: this.campaign
|
||||||
|
? {
|
||||||
|
step: this.campaign.step,
|
||||||
|
gold: this.campaign.gold,
|
||||||
|
inventory: this.campaign.inventory,
|
||||||
|
completedCampDialogues: this.campaign.completedCampDialogues
|
||||||
|
}
|
||||||
|
: null,
|
||||||
report: this.report
|
report: this.report
|
||||||
? {
|
? {
|
||||||
rewardGold: this.report.rewardGold,
|
rewardGold: this.report.rewardGold,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import { soundDirector } from '../audio/SoundDirector';
|
import { soundDirector } from '../audio/SoundDirector';
|
||||||
|
import { firstBattleVictoryPages } from '../data/scenario';
|
||||||
|
import { hasCampaignSave, loadCampaignState, startNewCampaign } from '../state/campaignState';
|
||||||
import { palette } from '../ui/palette';
|
import { palette } from '../ui/palette';
|
||||||
|
|
||||||
export class TitleScene extends Phaser.Scene {
|
export class TitleScene extends Phaser.Scene {
|
||||||
@@ -160,8 +162,9 @@ export class TitleScene extends Phaser.Scene {
|
|||||||
this.add.rectangle(menuX, menuY - 126, 186, 2, palette.gold, 0.7);
|
this.add.rectangle(menuX, menuY - 126, 186, 2, palette.gold, 0.7);
|
||||||
this.add.rectangle(menuX, menuY + 126, 186, 2, palette.gold, 0.36);
|
this.add.rectangle(menuX, menuY + 126, 186, 2, palette.gold, 0.36);
|
||||||
|
|
||||||
|
const canContinue = hasCampaignSave();
|
||||||
this.createMenuButton(menuX, menuY - 70, '새 게임', true, () => this.startGame());
|
this.createMenuButton(menuX, menuY - 70, '새 게임', true, () => this.startGame());
|
||||||
this.createMenuButton(menuX, menuY, '이어하기', false, () => undefined);
|
this.createMenuButton(menuX, menuY, '이어하기', canContinue, () => this.continueGame());
|
||||||
this.createMenuButton(menuX, menuY + 70, '설정', true, () => this.openSettingsPanel(menuX, menuY));
|
this.createMenuButton(menuX, menuY + 70, '설정', true, () => this.openSettingsPanel(menuX, menuY));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,6 +288,31 @@ export class TitleScene extends Phaser.Scene {
|
|||||||
soundDirector.start();
|
soundDirector.start();
|
||||||
soundDirector.resume();
|
soundDirector.resume();
|
||||||
soundDirector.playSelect();
|
soundDirector.playSelect();
|
||||||
|
startNewCampaign();
|
||||||
|
this.scene.start('StoryScene');
|
||||||
|
}
|
||||||
|
|
||||||
|
private continueGame() {
|
||||||
|
soundDirector.start();
|
||||||
|
soundDirector.resume();
|
||||||
|
soundDirector.playSelect();
|
||||||
|
|
||||||
|
const campaign = loadCampaignState();
|
||||||
|
if (campaign.step === 'first-camp') {
|
||||||
|
this.scene.start('CampScene');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (campaign.step === 'first-battle') {
|
||||||
|
this.scene.start('BattleScene');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (campaign.step === 'first-victory-story') {
|
||||||
|
this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.scene.start('StoryScene');
|
this.scene.start('StoryScene');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,40 +34,228 @@ export type FirstBattleReport = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
let firstBattleReport: FirstBattleReport | undefined;
|
export type CampaignStep = 'new' | 'prologue' | 'first-battle' | 'first-camp' | 'first-victory-story';
|
||||||
|
|
||||||
|
export type CampaignState = {
|
||||||
|
version: 1;
|
||||||
|
updatedAt: string;
|
||||||
|
step: CampaignStep;
|
||||||
|
gold: number;
|
||||||
|
roster: UnitData[];
|
||||||
|
bonds: CampBondSnapshot[];
|
||||||
|
inventory: Record<string, number>;
|
||||||
|
completedCampDialogues: string[];
|
||||||
|
firstBattleReport?: FirstBattleReport;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const campaignStorageKey = 'heros-web:campaign-state';
|
||||||
|
|
||||||
|
let campaignState: CampaignState | undefined;
|
||||||
|
|
||||||
|
export function createInitialCampaignState(): CampaignState {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
step: 'new',
|
||||||
|
gold: 0,
|
||||||
|
roster: [],
|
||||||
|
bonds: [],
|
||||||
|
inventory: {},
|
||||||
|
completedCampDialogues: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startNewCampaign() {
|
||||||
|
const state = createInitialCampaignState();
|
||||||
|
state.step = 'prologue';
|
||||||
|
return setCampaignState(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCampaignState() {
|
||||||
|
return cloneCampaignState(ensureCampaignState());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCampaignState(state: CampaignState) {
|
||||||
|
campaignState = normalizeCampaignState(state);
|
||||||
|
persistCampaignState(campaignState);
|
||||||
|
return cloneCampaignState(campaignState);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadCampaignState() {
|
||||||
|
campaignState = readStoredCampaignState() ?? createInitialCampaignState();
|
||||||
|
return cloneCampaignState(campaignState);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveCampaignState(state = ensureCampaignState()) {
|
||||||
|
campaignState = normalizeCampaignState(state);
|
||||||
|
persistCampaignState(campaignState);
|
||||||
|
return cloneCampaignState(campaignState);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetCampaignState() {
|
||||||
|
campaignState = createInitialCampaignState();
|
||||||
|
tryStorage()?.removeItem(campaignStorageKey);
|
||||||
|
return cloneCampaignState(campaignState);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasCampaignSave() {
|
||||||
|
return Boolean(tryStorage()?.getItem(campaignStorageKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markCampaignStep(step: CampaignStep) {
|
||||||
|
const state = ensureCampaignState();
|
||||||
|
state.step = step;
|
||||||
|
state.updatedAt = new Date().toISOString();
|
||||||
|
persistCampaignState(state);
|
||||||
|
return cloneCampaignState(state);
|
||||||
|
}
|
||||||
|
|
||||||
export function setFirstBattleReport(report: FirstBattleReport) {
|
export function setFirstBattleReport(report: FirstBattleReport) {
|
||||||
firstBattleReport = cloneReport(report);
|
const state = ensureCampaignState();
|
||||||
|
const previousRewardGold = state.firstBattleReport?.rewardGold ?? 0;
|
||||||
|
const reportClone = cloneReport(report);
|
||||||
|
|
||||||
|
state.firstBattleReport = reportClone;
|
||||||
|
state.step = reportClone.outcome === 'victory' ? 'first-camp' : 'first-battle';
|
||||||
|
state.gold = Math.max(0, state.gold - previousRewardGold + reportClone.rewardGold);
|
||||||
|
state.roster = reportClone.units.filter((unit) => unit.faction === 'ally').map(cloneUnit);
|
||||||
|
state.bonds = reportClone.bonds.map(cloneBondSnapshot);
|
||||||
|
state.completedCampDialogues = [...reportClone.completedCampDialogues];
|
||||||
|
state.inventory = inventoryFromRewards(reportClone.itemRewards);
|
||||||
|
state.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
persistCampaignState(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFirstBattleReport() {
|
export function getFirstBattleReport() {
|
||||||
return firstBattleReport ? cloneReport(firstBattleReport) : undefined;
|
const report = ensureCampaignState().firstBattleReport;
|
||||||
|
return report ? cloneReport(report) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyCampBondExp(dialogueId: string, bondId: string, amount: number) {
|
export function applyCampBondExp(dialogueId: string, bondId: string, amount: number) {
|
||||||
if (!firstBattleReport) {
|
const state = ensureCampaignState();
|
||||||
|
if (!state.firstBattleReport) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bond = firstBattleReport.bonds.find((candidate) => candidate.id === bondId);
|
if (!state.completedCampDialogues.includes(dialogueId)) {
|
||||||
if (!bond) {
|
state.completedCampDialogues.push(dialogueId);
|
||||||
|
}
|
||||||
|
if (!state.firstBattleReport.completedCampDialogues.includes(dialogueId)) {
|
||||||
|
state.firstBattleReport.completedCampDialogues.push(dialogueId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const campaignBond = state.bonds.find((candidate) => candidate.id === bondId);
|
||||||
|
const reportBond = state.firstBattleReport.bonds.find((candidate) => candidate.id === bondId);
|
||||||
|
if (!campaignBond || !reportBond) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!firstBattleReport.completedCampDialogues.includes(dialogueId)) {
|
advanceBond(campaignBond, amount);
|
||||||
firstBattleReport.completedCampDialogues.push(dialogueId);
|
reportBond.level = campaignBond.level;
|
||||||
|
reportBond.exp = campaignBond.exp;
|
||||||
|
reportBond.battleExp = campaignBond.battleExp;
|
||||||
|
state.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
persistCampaignState(state);
|
||||||
|
return cloneReport(state.firstBattleReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureCampaignState() {
|
||||||
|
if (!campaignState) {
|
||||||
|
campaignState = readStoredCampaignState() ?? createInitialCampaignState();
|
||||||
|
}
|
||||||
|
return campaignState;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCampaignState(state: CampaignState): CampaignState {
|
||||||
|
const normalized = cloneCampaignState(state);
|
||||||
|
normalized.version = 1;
|
||||||
|
normalized.updatedAt = new Date().toISOString();
|
||||||
|
normalized.roster = normalized.roster.map(cloneUnit);
|
||||||
|
normalized.bonds = normalized.bonds.map(cloneBondSnapshot);
|
||||||
|
normalized.completedCampDialogues = [...new Set(normalized.completedCampDialogues)];
|
||||||
|
normalized.inventory = { ...normalized.inventory };
|
||||||
|
if (normalized.firstBattleReport) {
|
||||||
|
normalized.firstBattleReport = cloneReport(normalized.firstBattleReport);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredCampaignState() {
|
||||||
|
const raw = tryStorage()?.getItem(campaignStorageKey);
|
||||||
|
if (!raw) {
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as CampaignState;
|
||||||
|
if (!parsed || parsed.version !== 1) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return normalizeCampaignState(parsed);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistCampaignState(state: CampaignState) {
|
||||||
|
tryStorage()?.setItem(campaignStorageKey, JSON.stringify(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryStorage() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return window.localStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceBond(bond: CampBondSnapshot, amount: number) {
|
||||||
bond.battleExp += amount;
|
bond.battleExp += amount;
|
||||||
bond.exp += amount;
|
bond.exp += amount;
|
||||||
while (bond.exp >= 100) {
|
while (bond.exp >= 100) {
|
||||||
bond.exp -= 100;
|
bond.exp -= 100;
|
||||||
bond.level = Math.min(100, bond.level + 1);
|
bond.level = Math.min(100, bond.level + 1);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return cloneReport(firstBattleReport);
|
function inventoryFromRewards(rewards: string[]) {
|
||||||
|
return rewards.reduce<Record<string, number>>((inventory, reward) => {
|
||||||
|
const { label, amount } = parseRewardLabel(reward);
|
||||||
|
inventory[label] = (inventory[label] ?? 0) + amount;
|
||||||
|
return inventory;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRewardLabel(reward: string) {
|
||||||
|
const normalized = reward.replace(/\s+/g, ' ').trim();
|
||||||
|
const match = normalized.match(/^(.*?)(?:\s*[+xX]?\s*(\d+))$/);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return { label: normalized, amount: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: match[1].trim() || normalized,
|
||||||
|
amount: Number(match[2] ?? 1)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneCampaignState(state: CampaignState): CampaignState {
|
||||||
|
return JSON.parse(JSON.stringify(state)) as CampaignState;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cloneReport(report: FirstBattleReport): FirstBattleReport {
|
function cloneReport(report: FirstBattleReport): FirstBattleReport {
|
||||||
return JSON.parse(JSON.stringify(report)) as FirstBattleReport;
|
return JSON.parse(JSON.stringify(report)) as FirstBattleReport;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cloneUnit(unit: UnitData): UnitData {
|
||||||
|
return JSON.parse(JSON.stringify(unit)) as UnitData;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneBondSnapshot(bond: CampBondSnapshot): CampBondSnapshot {
|
||||||
|
return {
|
||||||
|
...bond,
|
||||||
|
unitIds: [...bond.unitIds] as [string, string]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user