Prepare battle scenario progression foundations
This commit is contained in:
@@ -76,6 +76,10 @@ try {
|
||||
throw new Error(`Debug battle state was not available: ${JSON.stringify(result.battleState)}`);
|
||||
}
|
||||
|
||||
if (result.battleState.battleId !== 'first-battle-zhuo-commandery') {
|
||||
throw new Error(`Expected first battle scenario id: ${JSON.stringify(result.battleState)}`);
|
||||
}
|
||||
|
||||
const actionTexturesLoaded = await page.evaluate(() => {
|
||||
const textures = window.__HEROS_GAME__?.textures;
|
||||
return [
|
||||
@@ -170,12 +174,33 @@ try {
|
||||
if (
|
||||
!campaignSaveAfterDialogue ||
|
||||
campaignSaveAfterDialogue.step !== 'first-camp' ||
|
||||
campaignSaveAfterDialogue.latestBattleId !== 'first-battle-zhuo-commandery' ||
|
||||
!campaignSaveAfterDialogue.battleHistory?.['first-battle-zhuo-commandery'] ||
|
||||
!campaignSaveAfterDialogue.completedCampDialogues?.length ||
|
||||
Object.keys(campaignSaveAfterDialogue.inventory ?? {}).length === 0
|
||||
) {
|
||||
throw new Error(`Expected campaign save to persist camp progress: ${JSON.stringify(campaignSaveAfterDialogue)}`);
|
||||
}
|
||||
|
||||
const campaignSlotAfterDialogue = await page.evaluate(() => {
|
||||
const raw = window.localStorage.getItem('heros-web:campaign-state:slot-1');
|
||||
return raw ? JSON.parse(raw) : undefined;
|
||||
});
|
||||
if (!campaignSlotAfterDialogue || campaignSlotAfterDialogue.latestBattleId !== 'first-battle-zhuo-commandery') {
|
||||
throw new Error(`Expected campaign slot 1 to persist progress: ${JSON.stringify(campaignSlotAfterDialogue)}`);
|
||||
}
|
||||
|
||||
await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene'));
|
||||
await page.waitForFunction(() => {
|
||||
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
|
||||
return activeScenes.includes('TitleScene');
|
||||
});
|
||||
await page.mouse.click(962, 310);
|
||||
await page.waitForFunction(() => {
|
||||
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
|
||||
return activeScenes.includes('CampScene');
|
||||
});
|
||||
|
||||
await page.evaluate(() => window.__HEROS_DEBUG__?.goToBattle());
|
||||
await page.waitForFunction(() => {
|
||||
const state = window.__HEROS_DEBUG__?.battle();
|
||||
|
||||
@@ -9,8 +9,27 @@ import {
|
||||
type UnitData
|
||||
} from './scenario';
|
||||
|
||||
export type BattleScenarioDefinition = {
|
||||
export type BattleScenarioId = 'first-battle-zhuo-commandery';
|
||||
|
||||
export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory';
|
||||
|
||||
export type BattleObjectiveDefinition = {
|
||||
id: string;
|
||||
kind: BattleObjectiveKind;
|
||||
label: string;
|
||||
rewardGold: number;
|
||||
unitId?: string;
|
||||
terrain?: string;
|
||||
maxTurn?: number;
|
||||
};
|
||||
|
||||
export type BattleDefeatConditionDefinition = {
|
||||
kind: 'unit-defeated';
|
||||
unitId: string;
|
||||
};
|
||||
|
||||
export type BattleScenarioDefinition = {
|
||||
id: BattleScenarioId;
|
||||
title: string;
|
||||
map: BattleMap;
|
||||
units: UnitData[];
|
||||
@@ -19,6 +38,8 @@ export type BattleScenarioDefinition = {
|
||||
leaderUnitId: string;
|
||||
quickVictoryTurnLimit: number;
|
||||
baseVictoryGold: number;
|
||||
objectives: BattleObjectiveDefinition[];
|
||||
defeatConditions: BattleDefeatConditionDefinition[];
|
||||
itemRewards: string[];
|
||||
victoryPages: StoryPage[];
|
||||
nextCampScene: string;
|
||||
@@ -34,7 +55,53 @@ export const firstBattleScenario: BattleScenarioDefinition = {
|
||||
leaderUnitId: 'rebel-leader',
|
||||
quickVictoryTurnLimit: 8,
|
||||
baseVictoryGold: 300,
|
||||
objectives: [
|
||||
{
|
||||
id: 'leader',
|
||||
kind: 'defeat-leader',
|
||||
label: '황건 두령 격파',
|
||||
rewardGold: 200,
|
||||
unitId: 'rebel-leader'
|
||||
},
|
||||
{
|
||||
id: 'liu-bei',
|
||||
kind: 'keep-unit-alive',
|
||||
label: '유비 생존',
|
||||
rewardGold: 100,
|
||||
unitId: 'liu-bei'
|
||||
},
|
||||
{
|
||||
id: 'village',
|
||||
kind: 'secure-terrain',
|
||||
label: '마을 확보',
|
||||
rewardGold: 150,
|
||||
terrain: 'village'
|
||||
},
|
||||
{
|
||||
id: 'quick',
|
||||
kind: 'quick-victory',
|
||||
label: '8턴 안에 승리',
|
||||
rewardGold: 120,
|
||||
maxTurn: 8
|
||||
}
|
||||
],
|
||||
defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }],
|
||||
itemRewards: ['콩 1', '탁주 1', '의용군 명성 +1'],
|
||||
victoryPages: firstBattleVictoryPages,
|
||||
nextCampScene: 'CampScene'
|
||||
};
|
||||
|
||||
export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id;
|
||||
|
||||
export const battleScenarios: Record<BattleScenarioId, BattleScenarioDefinition> = {
|
||||
[firstBattleScenario.id]: firstBattleScenario
|
||||
};
|
||||
|
||||
export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId];
|
||||
|
||||
export function getBattleScenario(id: string | undefined) {
|
||||
if (id && id in battleScenarios) {
|
||||
return battleScenarios[id as BattleScenarioId];
|
||||
}
|
||||
return defaultBattleScenario;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import Phaser from 'phaser';
|
||||
import { soundDirector } from '../audio/SoundDirector';
|
||||
import {
|
||||
firstBattleBonds,
|
||||
firstBattleMap,
|
||||
firstBattleUnits,
|
||||
type BattleBond,
|
||||
type UnitData,
|
||||
type UnitStats
|
||||
} from '../data/scenario';
|
||||
import { firstBattleScenario } from '../data/battles';
|
||||
import { type BattleBond, type UnitData, type UnitStats } from '../data/scenario';
|
||||
import { defaultBattleScenario, getBattleScenario, type BattleScenarioDefinition } from '../data/battles';
|
||||
import { getTerrainRule, getUnitClass, type TerrainType, type UnitClassKey } from '../data/battleRules';
|
||||
import {
|
||||
equipmentExpToNext,
|
||||
@@ -106,6 +99,10 @@ type MapMenuAction = 'endTurn' | 'save' | 'load' | 'roster' | 'bond' | 'situatio
|
||||
type EnemyAiBehavior = 'aggressive' | 'guard' | 'hold';
|
||||
type BattleOutcome = 'victory' | 'defeat';
|
||||
|
||||
type BattleSceneData = {
|
||||
battleId?: string;
|
||||
};
|
||||
|
||||
type CommandButton = {
|
||||
command: BattleCommand;
|
||||
background: Phaser.GameObjects.Rectangle;
|
||||
@@ -289,11 +286,14 @@ type BattleSaveState = {
|
||||
|
||||
const maxEquipmentLevel = 9;
|
||||
const maxCharacterLevel = 99;
|
||||
const battleScenario = firstBattleScenario;
|
||||
let battleScenario = defaultBattleScenario;
|
||||
let battleMap = battleScenario.map;
|
||||
let battleUnits = battleScenario.units;
|
||||
let battleBonds = battleScenario.bonds;
|
||||
const legacyBattleSaveStorageKey = 'heros-web:first-battle-state';
|
||||
const battleSaveStorageKey = `heros-web:battle:${battleScenario.id}`;
|
||||
const initialFirstBattleUnits = firstBattleUnits.map(cloneUnitData);
|
||||
const initialFirstBattleBonds = firstBattleBonds.map(cloneBattleBond);
|
||||
let battleSaveStorageKey = `heros-web:battle:${battleScenario.id}`;
|
||||
let initialBattleUnits = battleUnits.map(cloneUnitData);
|
||||
let initialBattleBonds = battleBonds.map(cloneBattleBond);
|
||||
|
||||
const usableCatalog: Record<string, BattleUsable> = {
|
||||
aid: {
|
||||
@@ -418,9 +418,9 @@ const defaultEnemyAiByClass: Partial<Record<UnitClassKey, EnemyAiBehavior>> = {
|
||||
};
|
||||
|
||||
const guardDetectionRange = 5;
|
||||
const leaderUnitId = battleScenario.leaderUnitId;
|
||||
const quickVictoryTurnLimit = battleScenario.quickVictoryTurnLimit;
|
||||
const baseVictoryGold = battleScenario.baseVictoryGold;
|
||||
let leaderUnitId = battleScenario.leaderUnitId;
|
||||
let quickVictoryTurnLimit = battleScenario.quickVictoryTurnLimit;
|
||||
let baseVictoryGold = battleScenario.baseVictoryGold;
|
||||
|
||||
const commandLabels: Record<BattleCommand, string> = {
|
||||
attack: '공격',
|
||||
@@ -483,6 +483,19 @@ function cloneBattleBond(bond: BattleBond): BattleBond {
|
||||
};
|
||||
}
|
||||
|
||||
function configureBattleScenario(scenario: BattleScenarioDefinition) {
|
||||
battleScenario = scenario;
|
||||
battleMap = scenario.map;
|
||||
battleUnits = scenario.units;
|
||||
battleBonds = scenario.bonds;
|
||||
battleSaveStorageKey = `heros-web:battle:${scenario.id}`;
|
||||
initialBattleUnits = battleUnits.map(cloneUnitData);
|
||||
initialBattleBonds = battleBonds.map(cloneBattleBond);
|
||||
leaderUnitId = scenario.leaderUnitId;
|
||||
quickVictoryTurnLimit = scenario.quickVictoryTurnLimit;
|
||||
baseVictoryGold = scenario.baseVictoryGold;
|
||||
}
|
||||
|
||||
export class BattleScene extends Phaser.Scene {
|
||||
private layout!: BattleLayout;
|
||||
private phase: BattlePhase = 'idle';
|
||||
@@ -532,6 +545,10 @@ export class BattleScene extends Phaser.Scene {
|
||||
super('BattleScene');
|
||||
}
|
||||
|
||||
init(data?: BattleSceneData) {
|
||||
configureBattleScenario(getBattleScenario(data?.battleId));
|
||||
}
|
||||
|
||||
create() {
|
||||
this.resetBattleData();
|
||||
const campaign = getCampaignState();
|
||||
@@ -540,7 +557,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
const { width, height } = this.scale;
|
||||
this.layout = this.createLayout(width, height);
|
||||
const startUnit = firstBattleUnits.find((unit) => unit.id === 'liu-bei') ?? firstBattleUnits[0];
|
||||
const startUnit = battleUnits.find((unit) => unit.id === 'liu-bei') ?? battleUnits[0];
|
||||
this.setCameraTilePosition(
|
||||
(startUnit?.x ?? 0) - Math.floor(this.layout.visibleColumns / 2),
|
||||
(startUnit?.y ?? 0) - Math.floor(this.layout.visibleRows / 2),
|
||||
@@ -585,8 +602,8 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private resetBattleData() {
|
||||
firstBattleUnits.splice(0, firstBattleUnits.length, ...initialFirstBattleUnits.map(cloneUnitData));
|
||||
firstBattleBonds.splice(0, firstBattleBonds.length, ...initialFirstBattleBonds.map(cloneBattleBond));
|
||||
battleUnits.splice(0, battleUnits.length, ...initialBattleUnits.map(cloneUnitData));
|
||||
battleBonds.splice(0, battleBonds.length, ...initialBattleBonds.map(cloneBattleBond));
|
||||
}
|
||||
|
||||
private createLayout(width: number, height: number): BattleLayout {
|
||||
@@ -595,9 +612,9 @@ export class BattleScene extends Phaser.Scene {
|
||||
const panelWidth = Phaser.Math.Clamp(Math.floor(width * 0.27), 320, 360);
|
||||
const mapWidth = width - panelWidth - gap - margin * 2;
|
||||
const mapHeight = height - margin * 2;
|
||||
const visibleRows = Math.min(12, firstBattleMap.height);
|
||||
const visibleRows = Math.min(12, battleMap.height);
|
||||
const rowBasedTileSize = Math.floor((mapHeight - 36) / visibleRows);
|
||||
const visibleColumns = Math.min(firstBattleMap.width, Math.max(12, Math.floor((mapWidth - 16) / rowBasedTileSize)));
|
||||
const visibleColumns = Math.min(battleMap.width, Math.max(12, Math.floor((mapWidth - 16) / rowBasedTileSize)));
|
||||
const tileSize = Math.floor(Math.min((mapHeight - 36) / visibleRows, (mapWidth - 16) / visibleColumns));
|
||||
const gridWidth = tileSize * visibleColumns;
|
||||
const gridHeight = tileSize * visibleRows;
|
||||
@@ -637,7 +654,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
const background = this.add.image(0, 0, battleScenario.mapTextureKey);
|
||||
background.setOrigin(0);
|
||||
background.setDepth(0);
|
||||
background.setDisplaySize(firstBattleMap.width * layout.tileSize, firstBattleMap.height * layout.tileSize);
|
||||
background.setDisplaySize(battleMap.width * layout.tileSize, battleMap.height * layout.tileSize);
|
||||
background.setMask(mapMask);
|
||||
this.mapBackground = background;
|
||||
|
||||
@@ -649,7 +666,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
.setDepth(8);
|
||||
|
||||
this.terrainTileViews = [];
|
||||
firstBattleMap.terrain.forEach((row, y) => {
|
||||
battleMap.terrain.forEach((row, y) => {
|
||||
row.forEach((terrain, x) => {
|
||||
const terrainRule = getTerrainRule(terrain);
|
||||
const tile = this.add.rectangle(0, 0, layout.tileSize, layout.tileSize, terrainRule.color, terrainRule.alpha);
|
||||
@@ -670,7 +687,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private drawUnits() {
|
||||
firstBattleUnits.forEach((unit) => {
|
||||
battleUnits.forEach((unit) => {
|
||||
const sizeRatio = unit.faction === 'ally' ? 1.16 : 1.1;
|
||||
const textureBase = unitTexture[unit.id] ?? 'unit-rebel';
|
||||
const sprite = this.add.sprite(this.tileCenterX(unit.x), this.tileCenterY(unit.y), textureBase, this.unitFrameIndex('south'));
|
||||
@@ -739,9 +756,9 @@ export class BattleScene extends Phaser.Scene {
|
||||
const { panelX, panelY, panelWidth, panelHeight } = this.layout;
|
||||
const maxWidth = panelWidth - 48;
|
||||
const maxHeight = 132;
|
||||
const cellSize = Math.floor(Math.min(maxWidth / firstBattleMap.width, maxHeight / firstBattleMap.height));
|
||||
const width = firstBattleMap.width * cellSize;
|
||||
const height = firstBattleMap.height * cellSize;
|
||||
const cellSize = Math.floor(Math.min(maxWidth / battleMap.width, maxHeight / battleMap.height));
|
||||
const width = battleMap.width * cellSize;
|
||||
const height = battleMap.height * cellSize;
|
||||
const x = panelX + Math.floor((panelWidth - width) / 2);
|
||||
const y = panelY + panelHeight - height - 24;
|
||||
this.miniMapLayout = { x, y, width, height, cellSize };
|
||||
@@ -759,7 +776,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
hitArea.on('pointerdown', (pointer: Phaser.Input.Pointer) => this.handleMiniMapPointer(pointer));
|
||||
this.miniMapObjects.push(hitArea);
|
||||
|
||||
firstBattleMap.terrain.forEach((row, tileY) => {
|
||||
battleMap.terrain.forEach((row, tileY) => {
|
||||
row.forEach((terrain, tileX) => {
|
||||
const rule = getTerrainRule(terrain);
|
||||
const cell = this.add.rectangle(
|
||||
@@ -778,7 +795,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
});
|
||||
});
|
||||
|
||||
firstBattleUnits.forEach((unit) => {
|
||||
battleUnits.forEach((unit) => {
|
||||
const dot = this.add.rectangle(0, 0, Math.max(3, cellSize), Math.max(3, cellSize), unit.faction === 'ally' ? 0x4aa9ff : 0xff715f, 0.96);
|
||||
dot.setOrigin(0.5);
|
||||
dot.setDepth(28);
|
||||
@@ -803,12 +820,12 @@ export class BattleScene extends Phaser.Scene {
|
||||
const tileX = Phaser.Math.Clamp(
|
||||
Math.floor((pointer.x - this.miniMapLayout.x) / this.miniMapLayout.cellSize),
|
||||
0,
|
||||
firstBattleMap.width - 1
|
||||
battleMap.width - 1
|
||||
);
|
||||
const tileY = Phaser.Math.Clamp(
|
||||
Math.floor((pointer.y - this.miniMapLayout.y) / this.miniMapLayout.cellSize),
|
||||
0,
|
||||
firstBattleMap.height - 1
|
||||
battleMap.height - 1
|
||||
);
|
||||
this.centerCameraOnTile(tileX, tileY);
|
||||
soundDirector.playSelect();
|
||||
@@ -870,11 +887,11 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private maxCameraTileX() {
|
||||
return Math.max(0, firstBattleMap.width - this.layout.visibleColumns);
|
||||
return Math.max(0, battleMap.width - this.layout.visibleColumns);
|
||||
}
|
||||
|
||||
private maxCameraTileY() {
|
||||
return Math.max(0, firstBattleMap.height - this.layout.visibleRows);
|
||||
return Math.max(0, battleMap.height - this.layout.visibleRows);
|
||||
}
|
||||
|
||||
private updateCameraView() {
|
||||
@@ -895,7 +912,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
});
|
||||
|
||||
firstBattleUnits.forEach((unit) => this.positionUnitView(unit));
|
||||
battleUnits.forEach((unit) => this.positionUnitView(unit));
|
||||
this.updateMiniMap();
|
||||
}
|
||||
|
||||
@@ -906,7 +923,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
|
||||
const { x, y, cellSize } = this.miniMapLayout;
|
||||
this.miniMapUnitDots.forEach((dot, unitId) => {
|
||||
const unit = firstBattleUnits.find((candidate) => candidate.id === unitId);
|
||||
const unit = battleUnits.find((candidate) => candidate.id === unitId);
|
||||
if (!unit) {
|
||||
return;
|
||||
}
|
||||
@@ -1115,7 +1132,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
|
||||
const terrain = firstBattleMap.terrain[nextY][nextX];
|
||||
const terrain = battleMap.terrain[nextY][nextX];
|
||||
const stepCost = this.movementCost(unit, terrain);
|
||||
if (!Number.isFinite(stepCost)) {
|
||||
return;
|
||||
@@ -1146,7 +1163,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private isInBounds(x: number, y: number) {
|
||||
return x >= 0 && y >= 0 && x < firstBattleMap.width && y < firstBattleMap.height;
|
||||
return x >= 0 && y >= 0 && x < battleMap.width && y < battleMap.height;
|
||||
}
|
||||
|
||||
private tileKey(x: number, y: number) {
|
||||
@@ -1154,7 +1171,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private isOccupied(x: number, y: number, exceptUnitId?: string) {
|
||||
return firstBattleUnits.some((unit) => unit.id !== exceptUnitId && unit.hp > 0 && unit.x === x && unit.y === y);
|
||||
return battleUnits.some((unit) => unit.id !== exceptUnitId && unit.hp > 0 && unit.x === x && unit.y === y);
|
||||
}
|
||||
|
||||
private tileCenterX(x: number) {
|
||||
@@ -1692,19 +1709,21 @@ export class BattleScene extends Phaser.Scene {
|
||||
return true;
|
||||
}
|
||||
|
||||
const liuBei = firstBattleUnits.find((unit) => unit.id === 'liu-bei');
|
||||
if (!liuBei || liuBei.hp <= 0) {
|
||||
const defeatTriggered = battleScenario.defeatConditions.some((condition) => {
|
||||
return condition.kind === 'unit-defeated' && !this.isUnitAlive(condition.unitId);
|
||||
});
|
||||
if (defeatTriggered) {
|
||||
this.completeBattle('defeat');
|
||||
return true;
|
||||
}
|
||||
|
||||
const leader = firstBattleUnits.find((unit) => unit.id === leaderUnitId);
|
||||
const leader = battleUnits.find((unit) => unit.id === leaderUnitId);
|
||||
if (leader && leader.hp <= 0) {
|
||||
this.completeBattle('victory');
|
||||
return true;
|
||||
}
|
||||
|
||||
const enemiesAlive = firstBattleUnits.some((unit) => unit.faction === 'enemy' && unit.hp > 0);
|
||||
const enemiesAlive = battleUnits.some((unit) => unit.faction === 'enemy' && unit.hp > 0);
|
||||
if (!enemiesAlive) {
|
||||
this.completeBattle('victory');
|
||||
return true;
|
||||
@@ -1753,9 +1772,9 @@ export class BattleScene extends Phaser.Scene {
|
||||
const panelHeight = 660;
|
||||
const left = Math.floor((this.scale.width - panelWidth) / 2);
|
||||
const top = Math.floor((this.scale.height - panelHeight) / 2);
|
||||
const allies = firstBattleUnits.filter((unit) => unit.faction === 'ally');
|
||||
const defeatedEnemies = firstBattleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp <= 0).length;
|
||||
const totalEnemies = firstBattleUnits.filter((unit) => unit.faction === 'enemy').length;
|
||||
const allies = battleUnits.filter((unit) => unit.faction === 'ally');
|
||||
const defeatedEnemies = battleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp <= 0).length;
|
||||
const totalEnemies = battleUnits.filter((unit) => unit.faction === 'enemy').length;
|
||||
const aliveAllies = allies.filter((unit) => unit.hp > 0).length;
|
||||
const objectives = this.resultObjectives(outcome);
|
||||
|
||||
@@ -1838,42 +1857,53 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private resultObjectives(outcome: BattleOutcome): BattleObjectiveResult[] {
|
||||
const leader = firstBattleUnits.find((unit) => unit.id === leaderUnitId);
|
||||
const leaderDefeated = !leader || leader.hp <= 0;
|
||||
const allAlliesSurvived = firstBattleUnits.filter((unit) => unit.faction === 'ally').every((unit) => unit.hp > 0);
|
||||
const villageSecured = !this.hasEnemyOnVillageTile();
|
||||
const quickVictory = outcome === 'victory' && this.turnNumber <= quickVictoryTurnLimit;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'leader',
|
||||
label: '황건 두령 격파',
|
||||
achieved: outcome === 'victory' && leaderDefeated,
|
||||
detail: leaderDefeated ? '두령 퇴각' : '두령 생존',
|
||||
rewardGold: 200
|
||||
},
|
||||
{
|
||||
id: 'liu-bei',
|
||||
label: '유비 생존',
|
||||
achieved: allAlliesSurvived || firstBattleUnits.some((unit) => unit.id === 'liu-bei' && unit.hp > 0),
|
||||
detail: allAlliesSurvived ? '삼형제 생존' : '유비 생존',
|
||||
rewardGold: 100
|
||||
},
|
||||
{
|
||||
id: 'village',
|
||||
label: '마을 확보',
|
||||
achieved: outcome === 'victory' && villageSecured,
|
||||
detail: villageSecured ? '적 점거 없음' : '적 잔존',
|
||||
rewardGold: 150
|
||||
},
|
||||
{
|
||||
id: 'quick',
|
||||
label: `${quickVictoryTurnLimit}턴 안에 승리`,
|
||||
achieved: quickVictory,
|
||||
detail: `${this.turnNumber}턴 종료`,
|
||||
rewardGold: 120
|
||||
return battleScenario.objectives.map((objective) => {
|
||||
if (objective.kind === 'defeat-leader') {
|
||||
const targetId = objective.unitId ?? leaderUnitId;
|
||||
const defeated = this.isUnitDefeated(targetId);
|
||||
return {
|
||||
id: objective.id,
|
||||
label: objective.label,
|
||||
achieved: outcome === 'victory' && defeated,
|
||||
detail: defeated ? `${this.unitName(targetId)} 격파` : `${this.unitName(targetId)} 생존`,
|
||||
rewardGold: objective.rewardGold
|
||||
};
|
||||
}
|
||||
];
|
||||
|
||||
if (objective.kind === 'keep-unit-alive') {
|
||||
const targetId = objective.unitId ?? 'liu-bei';
|
||||
const alive = this.isUnitAlive(targetId);
|
||||
return {
|
||||
id: objective.id,
|
||||
label: objective.label,
|
||||
achieved: alive,
|
||||
detail: alive ? `${this.unitName(targetId)} 생존` : `${this.unitName(targetId)} 퇴각`,
|
||||
rewardGold: objective.rewardGold
|
||||
};
|
||||
}
|
||||
|
||||
if (objective.kind === 'secure-terrain') {
|
||||
const terrain = objective.terrain ?? 'village';
|
||||
const secured = !this.hasEnemyOnTerrain(terrain);
|
||||
return {
|
||||
id: objective.id,
|
||||
label: objective.label,
|
||||
achieved: outcome === 'victory' && secured,
|
||||
detail: secured ? '점거한 적 없음' : '적이 점거 중',
|
||||
rewardGold: objective.rewardGold
|
||||
};
|
||||
}
|
||||
|
||||
const maxTurn = objective.maxTurn ?? quickVictoryTurnLimit;
|
||||
const achieved = outcome === 'victory' && this.turnNumber <= maxTurn;
|
||||
return {
|
||||
id: objective.id,
|
||||
label: objective.label,
|
||||
achieved,
|
||||
detail: `${this.turnNumber}턴 종료`,
|
||||
rewardGold: objective.rewardGold
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private resultGold(outcome: BattleOutcome) {
|
||||
@@ -1885,7 +1915,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private resultMvp() {
|
||||
return firstBattleUnits
|
||||
return battleUnits
|
||||
.filter((unit) => unit.faction === 'ally')
|
||||
.map((unit) => {
|
||||
const stats = this.statsFor(unit.id);
|
||||
@@ -1898,17 +1928,19 @@ export class BattleScene extends Phaser.Scene {
|
||||
private publishFirstBattleReport(outcome: BattleOutcome) {
|
||||
const objectives = this.resultObjectives(outcome);
|
||||
const mvp = this.resultMvp();
|
||||
const defeatedEnemies = firstBattleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp <= 0).length;
|
||||
const totalEnemies = firstBattleUnits.filter((unit) => unit.faction === 'enemy').length;
|
||||
const defeatedEnemies = battleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp <= 0).length;
|
||||
const totalEnemies = battleUnits.filter((unit) => unit.faction === 'enemy').length;
|
||||
|
||||
setFirstBattleReport({
|
||||
battleId: battleScenario.id,
|
||||
battleTitle: battleScenario.title,
|
||||
outcome,
|
||||
turnNumber: this.turnNumber,
|
||||
rewardGold: this.resultGold(outcome),
|
||||
defeatedEnemies,
|
||||
totalEnemies,
|
||||
objectives: objectives.map((objective) => ({ ...objective })),
|
||||
units: firstBattleUnits.map(cloneUnitData),
|
||||
units: battleUnits.map(cloneUnitData),
|
||||
bonds: Array.from(this.bondStates.values()).map((bond) => ({
|
||||
...bond,
|
||||
unitIds: [...bond.unitIds] as [string, string]
|
||||
@@ -2075,7 +2107,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private characterResultText(unit: UnitData) {
|
||||
const initial = initialFirstBattleUnits.find((candidate) => candidate.id === unit.id);
|
||||
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;
|
||||
const levelDelta = initial ? unit.level - initial.level : 0;
|
||||
const levelText = levelDelta > 0 ? ` / Lv +${levelDelta}` : '';
|
||||
@@ -2083,7 +2115,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private equipmentResultText(unit: UnitData) {
|
||||
const initial = initialFirstBattleUnits.find((candidate) => candidate.id === unit.id);
|
||||
const initial = initialBattleUnits.find((candidate) => candidate.id === unit.id);
|
||||
return equipmentSlots
|
||||
.map((slot) => {
|
||||
const state = unit.equipment[slot];
|
||||
@@ -2133,7 +2165,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private bondResultText(bond: BondState) {
|
||||
const initial = initialFirstBattleBonds.find((candidate) => candidate.id === bond.id);
|
||||
const initial = initialBattleBonds.find((candidate) => candidate.id === bond.id);
|
||||
const gained = initial ? Math.max(0, this.characterProgressScore(bond.level, bond.exp) - this.characterProgressScore(initial.level, initial.exp)) : bond.battleExp;
|
||||
const levelDelta = initial ? bond.level - initial.level : 0;
|
||||
const levelText = levelDelta > 0 ? ` / Lv +${levelDelta}` : '';
|
||||
@@ -2171,7 +2203,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private damageableTargets(attacker: UnitData, action: DamageCommand, usable?: BattleUsable) {
|
||||
return firstBattleUnits.filter((target) => this.canUseDamageCommand(attacker, target, action, usable));
|
||||
return battleUnits.filter((target) => this.canUseDamageCommand(attacker, target, action, usable));
|
||||
}
|
||||
|
||||
private canAttack(attacker: UnitData, target: UnitData) {
|
||||
@@ -2201,7 +2233,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private supportTargets(user: UnitData, usable: BattleUsable) {
|
||||
return firstBattleUnits.filter((target) => this.canUseSupportCommand(user, target, usable));
|
||||
return battleUnits.filter((target) => this.canUseSupportCommand(user, target, usable));
|
||||
}
|
||||
|
||||
private canUseSupportCommand(user: UnitData, target: UnitData, usable: BattleUsable) {
|
||||
@@ -2224,7 +2256,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private combatPreview(attacker: UnitData, defender: UnitData, action: DamageCommand, usable?: BattleUsable): CombatPreview {
|
||||
const terrain = firstBattleMap.terrain[defender.y][defender.x];
|
||||
const terrain = battleMap.terrain[defender.y][defender.x];
|
||||
const terrainRule = getTerrainRule(terrain);
|
||||
const attackPower = this.actionPower(attacker, action, usable);
|
||||
const defensePower = this.actionDefense(defender, action, terrainRule.defenseBonus);
|
||||
@@ -2526,12 +2558,12 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private remainingAllyCount() {
|
||||
return firstBattleUnits.filter((unit) => unit.faction === 'ally' && unit.hp > 0 && !this.actedUnitIds.has(unit.id)).length;
|
||||
return battleUnits.filter((unit) => unit.faction === 'ally' && unit.hp > 0 && !this.actedUnitIds.has(unit.id)).length;
|
||||
}
|
||||
|
||||
private createBondStates() {
|
||||
return new Map(
|
||||
firstBattleBonds.map((bond) => [
|
||||
battleBonds.map((bond) => [
|
||||
bond.id,
|
||||
{
|
||||
...bond,
|
||||
@@ -2552,14 +2584,14 @@ export class BattleScene extends Phaser.Scene {
|
||||
|
||||
private createEnemyHomeTiles() {
|
||||
return new Map(
|
||||
firstBattleUnits
|
||||
battleUnits
|
||||
.filter((unit) => unit.faction === 'enemy')
|
||||
.map((unit) => [unit.id, { x: unit.x, y: unit.y }] as const)
|
||||
);
|
||||
}
|
||||
|
||||
private createBattleStats() {
|
||||
return new Map(firstBattleUnits.map((unit) => [unit.id, this.emptyBattleStats()] as const));
|
||||
return new Map(battleUnits.map((unit) => [unit.id, this.emptyBattleStats()] as const));
|
||||
}
|
||||
|
||||
private emptyBattleStats(): UnitBattleStats {
|
||||
@@ -2647,7 +2679,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private checkBattleEvents() {
|
||||
const leader = firstBattleUnits.find((unit) => unit.id === leaderUnitId);
|
||||
const leader = battleUnits.find((unit) => unit.id === leaderUnitId);
|
||||
if (leader && leader.hp > 0 && leader.hp <= Math.ceil(leader.maxHp / 2)) {
|
||||
this.triggerBattleEvent('leader-wavering', '두령 동요', [`${leader.name}의 기세가 꺾였습니다.`, '두령을 몰아붙이면 황건적의 전열이 무너집니다.']);
|
||||
}
|
||||
@@ -2662,11 +2694,24 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private hasEnemyOnVillageTile() {
|
||||
return firstBattleUnits.some((unit) => {
|
||||
return unit.faction === 'enemy' && unit.hp > 0 && firstBattleMap.terrain[unit.y][unit.x] === 'village';
|
||||
return this.hasEnemyOnTerrain('village');
|
||||
}
|
||||
|
||||
private hasEnemyOnTerrain(terrain: string) {
|
||||
return battleUnits.some((unit) => {
|
||||
return unit.faction === 'enemy' && unit.hp > 0 && battleMap.terrain[unit.y][unit.x] === terrain;
|
||||
});
|
||||
}
|
||||
|
||||
private isUnitAlive(unitId: string) {
|
||||
const unit = battleUnits.find((candidate) => candidate.id === unitId);
|
||||
return Boolean(unit && unit.hp > 0);
|
||||
}
|
||||
|
||||
private isUnitDefeated(unitId: string) {
|
||||
return !this.isUnitAlive(unitId);
|
||||
}
|
||||
|
||||
private handleRightClick(pointer: Phaser.Input.Pointer) {
|
||||
if (this.battleOutcome) {
|
||||
return;
|
||||
@@ -2896,7 +2941,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
actedUnitIds: Array.from(this.actedUnitIds),
|
||||
attackIntents: this.attackIntents.map((intent) => ({ ...intent })),
|
||||
battleLog: [...this.battleLog],
|
||||
units: firstBattleUnits.map((unit) => ({
|
||||
units: battleUnits.map((unit) => ({
|
||||
id: unit.id,
|
||||
level: unit.level,
|
||||
exp: unit.exp,
|
||||
@@ -2954,7 +2999,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.triggeredBattleEvents = new Set(state.triggeredBattleEvents ?? []);
|
||||
|
||||
state.units.forEach((savedUnit) => {
|
||||
const unit = firstBattleUnits.find((candidate) => candidate.id === savedUnit.id);
|
||||
const unit = battleUnits.find((candidate) => candidate.id === savedUnit.id);
|
||||
if (!unit) {
|
||||
return;
|
||||
}
|
||||
@@ -2965,13 +3010,13 @@ export class BattleScene extends Phaser.Scene {
|
||||
unit.maxHp = savedUnit.maxHp;
|
||||
unit.attack = savedUnit.attack;
|
||||
unit.move = savedUnit.move;
|
||||
unit.x = Phaser.Math.Clamp(savedUnit.x, 0, firstBattleMap.width - 1);
|
||||
unit.y = Phaser.Math.Clamp(savedUnit.y, 0, firstBattleMap.height - 1);
|
||||
unit.x = Phaser.Math.Clamp(savedUnit.x, 0, battleMap.width - 1);
|
||||
unit.y = Phaser.Math.Clamp(savedUnit.y, 0, battleMap.height - 1);
|
||||
unit.equipment = this.cloneEquipment(savedUnit.equipment);
|
||||
this.restoreUnitView(unit, savedUnit.direction);
|
||||
});
|
||||
|
||||
const cameraFocus = firstBattleUnits.find((unit) => unit.faction === 'ally' && unit.hp > 0) ?? firstBattleUnits[0];
|
||||
const cameraFocus = battleUnits.find((unit) => unit.faction === 'ally' && unit.hp > 0) ?? battleUnits[0];
|
||||
if (cameraFocus) {
|
||||
this.centerCameraOnTile(cameraFocus.x, cameraFocus.y);
|
||||
} else {
|
||||
@@ -3078,7 +3123,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
|
||||
const enemies = firstBattleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp > 0);
|
||||
const enemies = battleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp > 0);
|
||||
const messages: string[] = [];
|
||||
|
||||
for (const enemy of enemies) {
|
||||
@@ -3166,7 +3211,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private chooseTargetInRange(enemy: UnitData) {
|
||||
return firstBattleUnits
|
||||
return battleUnits
|
||||
.filter((unit) => unit.faction === 'ally' && unit.hp > 0 && this.canAttack(enemy, unit))
|
||||
.sort((a, b) => a.hp - b.hp || this.tileDistance(enemy, a) - this.tileDistance(enemy, b))[0];
|
||||
}
|
||||
@@ -3178,14 +3223,14 @@ export class BattleScene extends Phaser.Scene {
|
||||
.sort((a, b) => {
|
||||
const aDistance = Math.abs(a.x - target.x) + Math.abs(a.y - target.y);
|
||||
const bDistance = Math.abs(b.x - target.x) + Math.abs(b.y - target.y);
|
||||
const aTerrain = getTerrainRule(firstBattleMap.terrain[a.y][a.x]).defenseBonus;
|
||||
const bTerrain = getTerrainRule(firstBattleMap.terrain[b.y][b.x]).defenseBonus;
|
||||
const aTerrain = getTerrainRule(battleMap.terrain[a.y][a.x]).defenseBonus;
|
||||
const bTerrain = getTerrainRule(battleMap.terrain[b.y][b.x]).defenseBonus;
|
||||
return aDistance - bDistance || bTerrain - aTerrain || a.cost - b.cost;
|
||||
})[0];
|
||||
}
|
||||
|
||||
private nearestAliveUnit(unit: UnitData, faction: UnitData['faction']) {
|
||||
return firstBattleUnits
|
||||
return battleUnits
|
||||
.filter((candidate) => candidate.faction === faction && candidate.hp > 0)
|
||||
.sort((a, b) => this.tileDistance(unit, a) - this.tileDistance(unit, b))[0];
|
||||
}
|
||||
@@ -4145,10 +4190,10 @@ export class BattleScene extends Phaser.Scene {
|
||||
private applyTerrainRecovery(faction: ActiveFaction) {
|
||||
const messages: string[] = [];
|
||||
|
||||
firstBattleUnits
|
||||
battleUnits
|
||||
.filter((unit) => unit.faction === faction && unit.hp > 0)
|
||||
.forEach((unit) => {
|
||||
const terrain = firstBattleMap.terrain[unit.y][unit.x];
|
||||
const terrain = battleMap.terrain[unit.y][unit.x];
|
||||
const terrainRule = getTerrainRule(terrain);
|
||||
const hpGain = Math.min(terrainRule.recoveryHp ?? 0, unit.maxHp - unit.hp);
|
||||
const moraleBonus = terrainRule.moraleBonus ?? 0;
|
||||
@@ -4228,7 +4273,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
|
||||
private resetActedStyles() {
|
||||
this.unitViews.forEach((view, unitId) => {
|
||||
const unit = firstBattleUnits.find((candidate) => candidate.id === unitId);
|
||||
const unit = battleUnits.find((candidate) => candidate.id === unitId);
|
||||
if (unit && unit.hp <= 0) {
|
||||
this.applyDefeatedStyle(unit);
|
||||
return;
|
||||
@@ -4333,7 +4378,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private findEnemyPressureTarget() {
|
||||
const allies = firstBattleUnits.filter((unit) => unit.faction === 'ally' && unit.hp > 0);
|
||||
const allies = battleUnits.filter((unit) => unit.faction === 'ally' && unit.hp > 0);
|
||||
if (allies.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -4342,7 +4387,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private nearestEnemyDistance(unit: UnitData) {
|
||||
const distances = firstBattleUnits
|
||||
const distances = battleUnits
|
||||
.filter((candidate) => candidate.faction === 'enemy' && candidate.hp > 0)
|
||||
.map((enemy) => this.tileDistance(unit, enemy));
|
||||
return distances.length > 0 ? Math.min(...distances) : Number.POSITIVE_INFINITY;
|
||||
@@ -4415,7 +4460,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private findNearestEnemy(unit: UnitData) {
|
||||
const enemies = firstBattleUnits.filter((candidate) => candidate.faction === 'enemy' && candidate.hp > 0);
|
||||
const enemies = battleUnits.filter((candidate) => candidate.faction === 'enemy' && candidate.hp > 0);
|
||||
return enemies.sort((a, b) => this.tileDistance(unit, a) - this.tileDistance(unit, b))[0];
|
||||
}
|
||||
|
||||
@@ -4912,9 +4957,9 @@ export class BattleScene extends Phaser.Scene {
|
||||
const left = panelX + 24;
|
||||
const width = panelWidth - 48;
|
||||
const top = panelY + 132;
|
||||
const actedCount = firstBattleUnits.filter((unit) => unit.faction === 'ally' && this.actedUnitIds.has(unit.id)).length;
|
||||
const allyCount = firstBattleUnits.filter((unit) => unit.faction === 'ally').length;
|
||||
const enemyCount = firstBattleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp > 0).length;
|
||||
const actedCount = battleUnits.filter((unit) => unit.faction === 'ally' && this.actedUnitIds.has(unit.id)).length;
|
||||
const allyCount = battleUnits.filter((unit) => unit.faction === 'ally').length;
|
||||
const enemyCount = battleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp > 0).length;
|
||||
const bestBond = Array.from(this.bondStates.values()).sort((a, b) => b.level - a.level)[0];
|
||||
|
||||
this.trackSideObject(this.add.text(left, top, '전황', {
|
||||
@@ -4941,7 +4986,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
|
||||
private renderTerrainDetail(x: number, y: number) {
|
||||
this.clearSidePanelContent();
|
||||
const terrain = firstBattleMap.terrain[y][x];
|
||||
const terrain = battleMap.terrain[y][x];
|
||||
const terrainRule = getTerrainRule(terrain);
|
||||
const { panelX, panelY, panelWidth } = this.layout;
|
||||
const left = panelX + 24;
|
||||
@@ -5012,7 +5057,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private unitName(unitId: string) {
|
||||
return firstBattleUnits.find((unit) => unit.id === unitId)?.name ?? unitId;
|
||||
return battleUnits.find((unit) => unit.id === unitId)?.name ?? unitId;
|
||||
}
|
||||
|
||||
private renderRosterPanel(tab: RosterTab = this.rosterTab, message?: string) {
|
||||
@@ -5044,7 +5089,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
tabText.setOrigin(0.5);
|
||||
});
|
||||
|
||||
const units = firstBattleUnits.filter((unit) => unit.faction === tab);
|
||||
const units = battleUnits.filter((unit) => unit.faction === tab);
|
||||
const listTop = top + 54;
|
||||
units.forEach((unit, index) => {
|
||||
this.renderRosterRow(unit, left, listTop + index * 50, width);
|
||||
@@ -5111,7 +5156,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
const acted = this.actedUnitIds.has(unit.id);
|
||||
const factionColor = unit.faction === 'ally' ? palette.blue : 0xb86b55;
|
||||
const unitClass = getUnitClass(unit.classKey);
|
||||
const terrain = firstBattleMap.terrain[unit.y][unit.x];
|
||||
const terrain = battleMap.terrain[unit.y][unit.x];
|
||||
const terrainRule = getTerrainRule(terrain);
|
||||
const terrainRating = unitClass.terrainRatings[terrain];
|
||||
|
||||
@@ -5393,8 +5438,8 @@ export class BattleScene extends Phaser.Scene {
|
||||
y: this.cameraTileY,
|
||||
visibleColumns: this.layout.visibleColumns,
|
||||
visibleRows: this.layout.visibleRows,
|
||||
mapWidth: firstBattleMap.width,
|
||||
mapHeight: firstBattleMap.height
|
||||
mapWidth: battleMap.width,
|
||||
mapHeight: battleMap.height
|
||||
},
|
||||
selectedUnitId: this.selectedUnit?.id ?? null,
|
||||
pendingMove: this.pendingMove
|
||||
@@ -5413,7 +5458,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
selectedUsable: this.selectedUsable?.id ?? null,
|
||||
itemStocks: this.serializeItemStocks(),
|
||||
battleBuffs: Array.from(this.battleBuffs.values()).map((buff) => ({ ...buff })),
|
||||
units: firstBattleUnits.map((unit) => ({
|
||||
units: battleUnits.map((unit) => ({
|
||||
id: unit.id,
|
||||
name: unit.name,
|
||||
faction: unit.faction,
|
||||
@@ -5446,7 +5491,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
if (outcome === 'victory') {
|
||||
firstBattleUnits
|
||||
battleUnits
|
||||
.filter((unit) => unit.faction === 'enemy')
|
||||
.forEach((unit) => {
|
||||
unit.hp = 0;
|
||||
@@ -5454,7 +5499,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
});
|
||||
this.updateMiniMap();
|
||||
} else {
|
||||
const liuBei = firstBattleUnits.find((unit) => unit.id === 'liu-bei');
|
||||
const liuBei = battleUnits.find((unit) => unit.id === 'liu-bei');
|
||||
if (liuBei) {
|
||||
liuBei.hp = 0;
|
||||
this.applyDefeatedStyle(liuBei);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Phaser from 'phaser';
|
||||
import { soundDirector } from '../audio/SoundDirector';
|
||||
import { equipmentExpToNext, equipmentSlotLabels, equipmentSlots, getItem, type EquipmentSlot } from '../data/battleItems';
|
||||
import { defaultBattleScenario } from '../data/battles';
|
||||
import { firstBattleBonds, firstBattleUnits, firstBattleVictoryPages, type PortraitKey, type UnitData } from '../data/scenario';
|
||||
import {
|
||||
applyCampBondExp,
|
||||
@@ -119,6 +120,8 @@ export class CampScene extends Phaser.Scene {
|
||||
private createFallbackReport(): FirstBattleReport {
|
||||
const allies = firstBattleUnits.filter((unit) => unit.faction === 'ally');
|
||||
return {
|
||||
battleId: defaultBattleScenario.id,
|
||||
battleTitle: defaultBattleScenario.title,
|
||||
outcome: 'victory',
|
||||
turnNumber: 1,
|
||||
rewardGold: 300,
|
||||
|
||||
@@ -20,6 +20,8 @@ export type CampMvpSnapshot = {
|
||||
};
|
||||
|
||||
export type FirstBattleReport = {
|
||||
battleId: string;
|
||||
battleTitle: string;
|
||||
outcome: 'victory' | 'defeat';
|
||||
turnNumber: number;
|
||||
rewardGold: number;
|
||||
@@ -36,19 +38,62 @@ export type FirstBattleReport = {
|
||||
|
||||
export type CampaignStep = 'new' | 'prologue' | 'first-battle' | 'first-camp' | 'first-victory-story';
|
||||
|
||||
export type CampaignUnitProgressSnapshot = {
|
||||
unitId: string;
|
||||
name: string;
|
||||
level: number;
|
||||
exp: number;
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
equipment: UnitData['equipment'];
|
||||
};
|
||||
|
||||
export type CampaignBondProgressSnapshot = {
|
||||
id: string;
|
||||
title: string;
|
||||
level: number;
|
||||
exp: number;
|
||||
battleExp: number;
|
||||
};
|
||||
|
||||
export type CampaignBattleSettlement = {
|
||||
battleId: string;
|
||||
battleTitle: string;
|
||||
outcome: FirstBattleReport['outcome'];
|
||||
rewardGold: number;
|
||||
itemRewards: string[];
|
||||
objectives: BattleObjectiveSnapshot[];
|
||||
units: CampaignUnitProgressSnapshot[];
|
||||
bonds: CampaignBondProgressSnapshot[];
|
||||
completedAt: string;
|
||||
};
|
||||
|
||||
export type CampaignState = {
|
||||
version: 1;
|
||||
updatedAt: string;
|
||||
step: CampaignStep;
|
||||
activeSaveSlot: number;
|
||||
gold: number;
|
||||
roster: UnitData[];
|
||||
bonds: CampBondSnapshot[];
|
||||
inventory: Record<string, number>;
|
||||
completedCampDialogues: string[];
|
||||
battleHistory: Record<string, CampaignBattleSettlement>;
|
||||
latestBattleId?: string;
|
||||
firstBattleReport?: FirstBattleReport;
|
||||
};
|
||||
|
||||
export const campaignStorageKey = 'heros-web:campaign-state';
|
||||
export const campaignSaveSlotCount = 3;
|
||||
|
||||
export type CampaignSaveSlotSummary = {
|
||||
slot: number;
|
||||
exists: boolean;
|
||||
updatedAt?: string;
|
||||
step?: CampaignStep;
|
||||
gold?: number;
|
||||
battleTitle?: string;
|
||||
};
|
||||
|
||||
let campaignState: CampaignState | undefined;
|
||||
|
||||
@@ -57,11 +102,13 @@ export function createInitialCampaignState(): CampaignState {
|
||||
version: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
step: 'new',
|
||||
activeSaveSlot: 1,
|
||||
gold: 0,
|
||||
roster: [],
|
||||
bonds: [],
|
||||
inventory: {},
|
||||
completedCampDialogues: []
|
||||
completedCampDialogues: [],
|
||||
battleHistory: {}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,17 +124,20 @@ export function getCampaignState() {
|
||||
|
||||
export function setCampaignState(state: CampaignState) {
|
||||
campaignState = normalizeCampaignState(state);
|
||||
campaignState.updatedAt = new Date().toISOString();
|
||||
persistCampaignState(campaignState);
|
||||
return cloneCampaignState(campaignState);
|
||||
}
|
||||
|
||||
export function loadCampaignState() {
|
||||
campaignState = readStoredCampaignState() ?? createInitialCampaignState();
|
||||
export function loadCampaignState(slot?: number) {
|
||||
campaignState = readStoredCampaignState(slot) ?? readStoredCampaignState() ?? readLatestSlottedCampaignState() ?? createInitialCampaignState();
|
||||
return cloneCampaignState(campaignState);
|
||||
}
|
||||
|
||||
export function saveCampaignState(state = ensureCampaignState()) {
|
||||
export function saveCampaignState(state = ensureCampaignState(), slot = state.activeSaveSlot) {
|
||||
campaignState = normalizeCampaignState(state);
|
||||
campaignState.activeSaveSlot = normalizeSlot(slot);
|
||||
campaignState.updatedAt = new Date().toISOString();
|
||||
persistCampaignState(campaignState);
|
||||
return cloneCampaignState(campaignState);
|
||||
}
|
||||
@@ -95,11 +145,32 @@ export function saveCampaignState(state = ensureCampaignState()) {
|
||||
export function resetCampaignState() {
|
||||
campaignState = createInitialCampaignState();
|
||||
tryStorage()?.removeItem(campaignStorageKey);
|
||||
for (let slot = 1; slot <= campaignSaveSlotCount; slot += 1) {
|
||||
tryStorage()?.removeItem(campaignSaveSlotKey(slot));
|
||||
}
|
||||
return cloneCampaignState(campaignState);
|
||||
}
|
||||
|
||||
export function hasCampaignSave() {
|
||||
return Boolean(tryStorage()?.getItem(campaignStorageKey));
|
||||
return Boolean(tryStorage()?.getItem(campaignStorageKey) ?? readLatestSlottedCampaignState());
|
||||
}
|
||||
|
||||
export function listCampaignSaveSlots(): CampaignSaveSlotSummary[] {
|
||||
return Array.from({ length: campaignSaveSlotCount }, (_, index) => {
|
||||
const slot = index + 1;
|
||||
const state = readStoredCampaignState(slot);
|
||||
if (!state) {
|
||||
return { slot, exists: false };
|
||||
}
|
||||
return {
|
||||
slot,
|
||||
exists: true,
|
||||
updatedAt: state.updatedAt,
|
||||
step: state.step,
|
||||
gold: state.gold,
|
||||
battleTitle: state.firstBattleReport?.battleTitle
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function markCampaignStep(step: CampaignStep) {
|
||||
@@ -112,16 +183,20 @@ export function markCampaignStep(step: CampaignStep) {
|
||||
|
||||
export function setFirstBattleReport(report: FirstBattleReport) {
|
||||
const state = ensureCampaignState();
|
||||
const previousRewardGold = state.firstBattleReport?.rewardGold ?? 0;
|
||||
const reportClone = cloneReport(report);
|
||||
const battleId = reportClone.battleId;
|
||||
const previousSettlement = state.battleHistory[battleId];
|
||||
|
||||
state.firstBattleReport = reportClone;
|
||||
state.step = reportClone.outcome === 'victory' ? 'first-camp' : 'first-battle';
|
||||
state.gold = Math.max(0, state.gold - previousRewardGold + reportClone.rewardGold);
|
||||
state.gold = Math.max(0, state.gold - (previousSettlement?.rewardGold ?? 0) + 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.inventory = applyRewardDelta(state.inventory, previousSettlement?.itemRewards ?? [], -1);
|
||||
state.inventory = applyRewardDelta(state.inventory, reportClone.itemRewards, 1);
|
||||
state.battleHistory[battleId] = createBattleSettlement(reportClone);
|
||||
state.latestBattleId = battleId;
|
||||
state.updatedAt = new Date().toISOString();
|
||||
|
||||
persistCampaignState(state);
|
||||
@@ -171,19 +246,22 @@ function ensureCampaignState() {
|
||||
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 };
|
||||
normalized.updatedAt = normalized.updatedAt || new Date().toISOString();
|
||||
normalized.activeSaveSlot = normalizeSlot(normalized.activeSaveSlot);
|
||||
normalized.gold = normalized.gold ?? 0;
|
||||
normalized.roster = (normalized.roster ?? []).map(cloneUnit);
|
||||
normalized.bonds = (normalized.bonds ?? []).map(cloneBondSnapshot);
|
||||
normalized.completedCampDialogues = [...new Set(normalized.completedCampDialogues ?? [])];
|
||||
normalized.inventory = { ...(normalized.inventory ?? {}) };
|
||||
normalized.battleHistory = normalized.battleHistory ?? {};
|
||||
if (normalized.firstBattleReport) {
|
||||
normalized.firstBattleReport = cloneReport(normalized.firstBattleReport);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function readStoredCampaignState() {
|
||||
const raw = tryStorage()?.getItem(campaignStorageKey);
|
||||
function readStoredCampaignState(slot?: number) {
|
||||
const raw = tryStorage()?.getItem(slot ? campaignSaveSlotKey(slot) : campaignStorageKey);
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -200,7 +278,9 @@ function readStoredCampaignState() {
|
||||
}
|
||||
|
||||
function persistCampaignState(state: CampaignState) {
|
||||
tryStorage()?.setItem(campaignStorageKey, JSON.stringify(state));
|
||||
const storage = tryStorage();
|
||||
storage?.setItem(campaignStorageKey, JSON.stringify(state));
|
||||
storage?.setItem(campaignSaveSlotKey(state.activeSaveSlot), JSON.stringify(state));
|
||||
}
|
||||
|
||||
function tryStorage() {
|
||||
@@ -210,6 +290,24 @@ function tryStorage() {
|
||||
return window.localStorage;
|
||||
}
|
||||
|
||||
function campaignSaveSlotKey(slot: number) {
|
||||
return `${campaignStorageKey}:slot-${normalizeSlot(slot)}`;
|
||||
}
|
||||
|
||||
function normalizeSlot(slot: number | undefined) {
|
||||
if (!slot || Number.isNaN(slot)) {
|
||||
return 1;
|
||||
}
|
||||
return Math.min(campaignSaveSlotCount, Math.max(1, Math.floor(slot)));
|
||||
}
|
||||
|
||||
function readLatestSlottedCampaignState() {
|
||||
const latest = listCampaignSaveSlots()
|
||||
.filter((slot) => slot.exists && slot.updatedAt)
|
||||
.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))[0];
|
||||
return latest ? readStoredCampaignState(latest.slot) : undefined;
|
||||
}
|
||||
|
||||
function advanceBond(bond: CampBondSnapshot, amount: number) {
|
||||
bond.battleExp += amount;
|
||||
bond.exp += amount;
|
||||
@@ -219,12 +317,45 @@ function advanceBond(bond: CampBondSnapshot, amount: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function inventoryFromRewards(rewards: string[]) {
|
||||
return rewards.reduce<Record<string, number>>((inventory, reward) => {
|
||||
function createBattleSettlement(report: FirstBattleReport): CampaignBattleSettlement {
|
||||
return {
|
||||
battleId: report.battleId,
|
||||
battleTitle: report.battleTitle,
|
||||
outcome: report.outcome,
|
||||
rewardGold: report.rewardGold,
|
||||
itemRewards: [...report.itemRewards],
|
||||
objectives: report.objectives.map((objective) => ({ ...objective })),
|
||||
units: report.units
|
||||
.filter((unit) => unit.faction === 'ally')
|
||||
.map((unit) => ({
|
||||
unitId: unit.id,
|
||||
name: unit.name,
|
||||
level: unit.level,
|
||||
exp: unit.exp,
|
||||
hp: unit.hp,
|
||||
maxHp: unit.maxHp,
|
||||
equipment: cloneUnit(unit).equipment
|
||||
})),
|
||||
bonds: report.bonds.map((bond) => ({
|
||||
id: bond.id,
|
||||
title: bond.title,
|
||||
level: bond.level,
|
||||
exp: bond.exp,
|
||||
battleExp: bond.battleExp
|
||||
})),
|
||||
completedAt: report.createdAt
|
||||
};
|
||||
}
|
||||
|
||||
function applyRewardDelta(inventory: Record<string, number>, rewards: string[], direction: 1 | -1) {
|
||||
return rewards.reduce<Record<string, number>>((nextInventory, reward) => {
|
||||
const { label, amount } = parseRewardLabel(reward);
|
||||
inventory[label] = (inventory[label] ?? 0) + amount;
|
||||
return inventory;
|
||||
}, {});
|
||||
nextInventory[label] = Math.max(0, (nextInventory[label] ?? 0) + amount * direction);
|
||||
if (nextInventory[label] === 0) {
|
||||
delete nextInventory[label];
|
||||
}
|
||||
return nextInventory;
|
||||
}, { ...inventory });
|
||||
}
|
||||
|
||||
function parseRewardLabel(reward: string) {
|
||||
|
||||
Reference in New Issue
Block a user