From a3858db201837ae54e9da934fd6a537ee297f5c1 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 3 Jul 2026 00:31:20 +0900 Subject: [PATCH] Link sortie roles to battle deployment --- src/game/data/sortieDeployment.ts | 187 ++++++++++++++++++++++++++++++ src/game/scenes/BattleScene.ts | 56 ++++++++- src/game/scenes/CampScene.ts | 120 ++++++++++++++++--- src/game/scenes/TitleScene.ts | 6 +- src/game/state/campaignState.ts | 4 + 5 files changed, 356 insertions(+), 17 deletions(-) create mode 100644 src/game/data/sortieDeployment.ts diff --git a/src/game/data/sortieDeployment.ts b/src/game/data/sortieDeployment.ts new file mode 100644 index 0000000..cdb8825 --- /dev/null +++ b/src/game/data/sortieDeployment.ts @@ -0,0 +1,187 @@ +import type { UnitData } from './scenario'; + +export type SortieFormationRole = 'front' | 'flank' | 'support' | 'reserve'; +export type SortieFormationAssignments = Partial>; + +export const sortieFormationRoles: SortieFormationRole[] = ['front', 'flank', 'support', 'reserve']; + +export type SortieDeploymentPlanEntry = { + unitId: string; + role: SortieFormationRole; + x: number; + y: number; +}; + +type ScoredTile = { + x: number; + y: number; + forward: number; + lateral: number; + enemyDistance: number; +}; + +export function isSortieFormationRole(value: unknown): value is SortieFormationRole { + return sortieFormationRoles.includes(value as SortieFormationRole); +} + +export function normalizeSortieFormationAssignments( + assignments?: Record, + allowedUnitIds?: Set +): SortieFormationAssignments { + return Object.fromEntries( + Object.entries(assignments ?? {}).filter(([unitId, role]) => { + return (!allowedUnitIds || allowedUnitIds.has(unitId)) && isSortieFormationRole(role); + }) + ) as SortieFormationAssignments; +} + +export function createSortieDeploymentPlan( + deployedUnits: UnitData[], + sourceUnits: UnitData[], + selectedUnitIds: string[], + assignments: SortieFormationAssignments, + fallbackRoleForUnit: (unit: UnitData) => SortieFormationRole +) { + const selectedOrder = new Map(selectedUnitIds.map((unitId, index) => [unitId, index])); + const deployedAllies = deployedUnits + .filter((unit) => unit.faction === 'ally') + .sort((a, b) => (selectedOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (selectedOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER)); + const sourceAllyTiles = uniqueTiles(sourceUnits.filter((unit) => unit.faction === 'ally').map((unit) => ({ x: unit.x, y: unit.y }))); + + if (deployedAllies.length === 0 || sourceAllyTiles.length === 0) { + return new Map(); + } + + const enemyTiles = sourceUnits.filter((unit) => unit.faction === 'enemy').map((unit) => ({ x: unit.x, y: unit.y })); + const allyCenter = averagePoint(sourceAllyTiles); + const enemyCenter = enemyTiles.length ? averagePoint(enemyTiles) : { x: allyCenter.x + 1, y: allyCenter.y }; + const direction = normalizePoint({ x: enemyCenter.x - allyCenter.x, y: enemyCenter.y - allyCenter.y }); + const scoredTiles = sourceAllyTiles.map((tile) => scoreTile(tile, allyCenter, enemyCenter, direction)); + const plan = new Map(); + const usedTiles = new Set(); + + sortieFormationRoles.forEach((role) => { + const roleUnits = deployedAllies.filter((unit) => roleForUnit(unit, assignments, fallbackRoleForUnit) === role); + roleUnits.forEach((unit) => { + const tile = bestTileForRole(role, scoredTiles, usedTiles); + if (!tile) { + return; + } + usedTiles.add(tileKey(tile)); + plan.set(unit.id, { + unitId: unit.id, + role, + x: tile.x, + y: tile.y + }); + }); + }); + + deployedAllies.forEach((unit) => { + if (plan.has(unit.id)) { + return; + } + const tile = bestTileForRole('reserve', scoredTiles, usedTiles); + if (!tile) { + return; + } + usedTiles.add(tileKey(tile)); + plan.set(unit.id, { + unitId: unit.id, + role: roleForUnit(unit, assignments, fallbackRoleForUnit), + x: tile.x, + y: tile.y + }); + }); + + return plan; +} + +export function applySortieDeploymentPlan(units: UnitData[], plan: Map) { + return units.map((unit) => { + const entry = plan.get(unit.id); + if (!entry || unit.faction !== 'ally') { + return unit; + } + return { + ...unit, + x: entry.x, + y: entry.y + }; + }); +} + +function roleForUnit( + unit: UnitData, + assignments: SortieFormationAssignments, + fallbackRoleForUnit: (unit: UnitData) => SortieFormationRole +) { + return assignments[unit.id] ?? fallbackRoleForUnit(unit); +} + +function bestTileForRole(role: SortieFormationRole, tiles: ScoredTile[], usedTiles: Set) { + return [...tiles] + .filter((tile) => !usedTiles.has(tileKey(tile))) + .sort((a, b) => compareTilesForRole(role, a, b))[0]; +} + +function compareTilesForRole(role: SortieFormationRole, a: ScoredTile, b: ScoredTile) { + if (role === 'front') { + return b.forward - a.forward || a.enemyDistance - b.enemyDistance || a.lateral - b.lateral; + } + if (role === 'flank') { + return b.lateral - a.lateral || b.forward - a.forward || a.enemyDistance - b.enemyDistance; + } + if (role === 'support') { + return a.forward - b.forward || b.enemyDistance - a.enemyDistance || a.lateral - b.lateral; + } + return a.lateral - b.lateral || a.forward - b.forward || b.enemyDistance - a.enemyDistance; +} + +function scoreTile( + tile: { x: number; y: number }, + allyCenter: { x: number; y: number }, + enemyCenter: { x: number; y: number }, + direction: { x: number; y: number } +): ScoredTile { + const relative = { x: tile.x - allyCenter.x, y: tile.y - allyCenter.y }; + return { + x: tile.x, + y: tile.y, + forward: relative.x * direction.x + relative.y * direction.y, + lateral: Math.abs(relative.x * -direction.y + relative.y * direction.x), + enemyDistance: Math.hypot(tile.x - enemyCenter.x, tile.y - enemyCenter.y) + }; +} + +function uniqueTiles(tiles: { x: number; y: number }[]) { + const seen = new Set(); + return tiles.filter((tile) => { + const key = tileKey(tile); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + +function averagePoint(points: { x: number; y: number }[]) { + const total = points.reduce((sum, point) => ({ x: sum.x + point.x, y: sum.y + point.y }), { x: 0, y: 0 }); + return { + x: total.x / points.length, + y: total.y / points.length + }; +} + +function normalizePoint(point: { x: number; y: number }) { + const length = Math.hypot(point.x, point.y) || 1; + return { + x: point.x / length, + y: point.y / length + }; +} + +function tileKey(tile: { x: number; y: number }) { + return `${tile.x},${tile.y}`; +} diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 2f62d6d..bca079f 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -26,6 +26,13 @@ import { type EquipmentSlot, type ItemDefinition } from '../data/battleItems'; +import { + applySortieDeploymentPlan, + createSortieDeploymentPlan, + normalizeSortieFormationAssignments, + type SortieFormationAssignments, + type SortieFormationRole +} from '../data/sortieDeployment'; import { campaignSaveSlotCount, getCampaignState, @@ -1133,6 +1140,7 @@ type TurnEndPromptOptions = { type BattleSceneData = { battleId?: string; selectedSortieUnitIds?: string[]; + sortieFormationAssignments?: SortieFormationAssignments; }; type CommandButton = { @@ -2973,6 +2981,7 @@ export class BattleScene extends Phaser.Scene { private suppressNextLeftClick = false; private approachPathCostCache = new Map>(); private launchSortieUnitIds: string[] = []; + private launchSortieFormationAssignments: SortieFormationAssignments = {}; constructor() { super('BattleScene'); @@ -2981,6 +2990,7 @@ export class BattleScene extends Phaser.Scene { init(data?: BattleSceneData) { configureBattleScenario(getBattleScenario(data?.battleId)); this.launchSortieUnitIds = this.normalizeLaunchSortieUnitIds(data?.selectedSortieUnitIds); + this.launchSortieFormationAssignments = normalizeSortieFormationAssignments(data?.sortieFormationAssignments); } create() { @@ -3164,7 +3174,9 @@ export class BattleScene extends Phaser.Scene { } private resetBattleData(campaign?: CampaignState) { - const selected = new Set(this.effectiveSortieUnitIds(campaign)); + const effectiveSortieUnitIds = this.effectiveSortieUnitIds(campaign); + const selected = new Set(effectiveSortieUnitIds); + const assignments = this.effectiveSortieFormationAssignments(campaign); const forcedAllyIds = new Set( battleScenario.defeatConditions .filter((condition) => condition.kind === 'unit-defeated') @@ -3174,7 +3186,14 @@ export class BattleScene extends Phaser.Scene { const nextUnits = initialBattleUnits .filter((unit) => unit.faction === 'enemy' || !hasSelection || selected.has(unit.id) || forcedAllyIds.has(unit.id)) .map((unit) => applyScenarioAllyReadiness(this.applyCampaignUnitProgress(unit, campaign))); - battleUnits.splice(0, battleUnits.length, ...nextUnits); + const deploymentPlan = createSortieDeploymentPlan( + nextUnits.filter((unit) => unit.faction === 'ally'), + initialBattleUnits, + effectiveSortieUnitIds, + assignments, + (unit) => this.defaultSortieFormationRole(unit) + ); + battleUnits.splice(0, battleUnits.length, ...applySortieDeploymentPlan(nextUnits, deploymentPlan)); battleBonds.splice(0, battleBonds.length, ...initialBattleBonds.map((bond) => this.applyCampaignBondProgress(bond, campaign))); initialBattleUnits = battleUnits.map(cloneUnitData); initialBattleBonds = battleBonds.map(cloneBattleBond); @@ -3188,6 +3207,25 @@ export class BattleScene extends Phaser.Scene { return this.launchSortieUnitIds.length > 0 ? this.launchSortieUnitIds : campaign?.selectedSortieUnitIds ?? []; } + private effectiveSortieFormationAssignments(campaign?: CampaignState) { + return Object.keys(this.launchSortieFormationAssignments).length > 0 + ? this.launchSortieFormationAssignments + : campaign?.sortieFormationAssignments ?? {}; + } + + private defaultSortieFormationRole(unit: UnitData): SortieFormationRole { + if (unit.classKey === 'cavalry') { + return 'flank'; + } + if (unit.classKey === 'archer' || unit.classKey === 'strategist' || unit.classKey === 'quartermaster') { + return 'support'; + } + if (unit.classKey === 'lord' || unit.classKey === 'infantry' || unit.classKey === 'spearman') { + return 'front'; + } + return 'reserve'; + } + private applyCampaignUnitProgress(unit: UnitData, campaign?: CampaignState) { const cloned = cloneUnitData(unit); const progress = campaign?.roster.find((candidate) => candidate.id === unit.id); @@ -12489,7 +12527,9 @@ export class BattleScene extends Phaser.Scene { } getDebugState() { - const effectiveSortieUnitIds = this.effectiveSortieUnitIds(getCampaignState()); + const campaign = getCampaignState(); + const effectiveSortieUnitIds = this.effectiveSortieUnitIds(campaign); + const sortieFormationAssignments = this.effectiveSortieFormationAssignments(campaign); const deployedAllyIds = battleUnits.filter((unit) => unit.faction === 'ally').map((unit) => unit.id); return { @@ -12499,7 +12539,17 @@ export class BattleScene extends Phaser.Scene { victoryConditionLabel: battleScenario.victoryConditionLabel, defeatConditionLabel: battleScenario.defeatConditionLabel, selectedSortieUnitIds: effectiveSortieUnitIds, + sortieFormationAssignments: { ...sortieFormationAssignments }, deployedAllyIds, + deployedAllyPositions: battleUnits + .filter((unit) => unit.faction === 'ally') + .map((unit) => ({ + id: unit.id, + name: unit.name, + x: unit.x, + y: unit.y, + formationRole: sortieFormationAssignments[unit.id] ?? this.defaultSortieFormationRole(unit) + })), turnNumber: this.turnNumber, activeFaction: this.activeFaction, phase: this.phase, diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 1d42608..5ed7849 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -7,6 +7,12 @@ import { getUnitClass, terrainRules, type TerrainType } from '../data/battleRule import { defaultBattleScenario, getBattleScenario, type BattleScenarioDefinition, type BattleScenarioId } from '../data/battles'; import { getSortieFlow } from '../data/campaignFlow'; import { portraitAssetEntriesForKey, portraitTextureKey, type PortraitAssetEntry } from '../data/portraitAssets'; +import { + createSortieDeploymentPlan, + normalizeSortieFormationAssignments, + type SortieFormationAssignments, + type SortieFormationRole +} from '../data/sortieDeployment'; import { caoBreakRecruitBonds, caoBreakRecruitUnits, @@ -214,8 +220,6 @@ type SortieRuleDefinition = { note: string; }; -type SortieFormationRole = 'front' | 'flank' | 'support' | 'reserve'; - type SortieFormationSlot = { role: SortieFormationRole; label: string; @@ -10000,6 +10004,7 @@ export class CampScene extends Phaser.Scene { private tabButtons: CampTabButtonView[] = []; private visitedTabs = new Set(); private selectedSortieUnitIds: string[] = []; + private sortieFormationAssignments: SortieFormationAssignments = {}; private sortieFocusedUnitId = 'liu-bei'; private sortieRosterScroll = 0; private terrainCountCache = new Map(); @@ -10019,6 +10024,7 @@ export class CampScene extends Phaser.Scene { this.ensureCurrentCampRecruitment(); this.selectedUnitId = this.currentUnits().find((unit) => unit.faction === 'ally')?.id ?? 'liu-bei'; this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.campaign.selectedSortieUnitIds); + this.sortieFormationAssignments = this.normalizedSortieFormationAssignments(this.campaign.sortieFormationAssignments); this.selectedDialogueId = this.availableCampDialogues()[0]?.id ?? campDialogues[0].id; this.selectedVisitId = this.availableCampVisits()[0]?.id ?? campVisits[0].id; soundDirector.playMusic('militia-theme'); @@ -11200,6 +11206,7 @@ export class CampScene extends Phaser.Scene { this.campaign = getCampaignState(); this.report = this.campaign.firstBattleReport ?? this.report; this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.campaign.selectedSortieUnitIds); + this.sortieFormationAssignments = this.normalizedSortieFormationAssignments(this.campaign.sortieFormationAssignments); this.ensureSortieFocus(); const depth = 40; @@ -11511,16 +11518,29 @@ export class CampScene extends Phaser.Scene { const gap = 8; const slotWidth = Math.floor((width - gap) / 2); const slotHeight = Math.floor((height - 6) / 2); + const focusedUnit = this.sortieFocusedUnit(); + const focusedUnitSelected = Boolean(focusedUnit && this.isSortieSelected(focusedUnit.id)); plan.formationSlots.forEach((slot, index) => { const column = index % 2; const row = Math.floor(index / 2); const slotX = x + column * (slotWidth + gap); const slotY = y + row * (slotHeight + 6); const filled = slot.unitNames.length > 0; + const focusedRole = focusedUnitSelected && focusedUnit ? this.sortieFormationRole(focusedUnit) === slot.role : false; const bg = this.trackSortie(this.add.rectangle(slotX, slotY, slotWidth, slotHeight, filled ? 0x172a22 : 0x111922, filled ? 0.94 : 0.78)); bg.setOrigin(0); bg.setDepth(depth); - bg.setStrokeStyle(1, filled ? palette.green : 0x53606c, filled ? 0.58 : 0.34); + bg.setStrokeStyle(focusedRole ? 2 : 1, focusedRole ? palette.gold : filled ? palette.green : 0x53606c, focusedRole ? 0.92 : filled ? 0.58 : 0.34); + bg.setInteractive({ useHandCursor: true }); + bg.on('pointerdown', () => this.assignFocusedSortieRole(slot.role)); + bg.on('pointerover', () => { + if (focusedUnitSelected) { + bg.setFillStyle(filled ? 0x22342b : 0x182330, 0.96); + } + }); + bg.on('pointerout', () => { + bg.setFillStyle(filled ? 0x172a22 : 0x111922, filled ? 0.94 : 0.78); + }); this.renderSortieBattleUiIcon(this.formationRoleIconKey(slot.role), slotX + 13, slotY + slotHeight / 2, 18, depth + 1, filled ? 0.95 : 0.5); this.trackSortie(this.add.text(slotX + 28, slotY + 3, `${slot.label} ${slot.unitNames.length}`, this.textStyle(10, filled ? '#f2e3bf' : '#9fb0bf', true))).setDepth(depth + 1); this.trackSortie(this.add.text(slotX + 76, slotY + 3, this.compactText(slot.unitNames.join(', ') || slot.description, 12), this.textStyle(10, filled ? '#c8d2dd' : '#68727e'))).setDepth(depth + 1); @@ -11859,23 +11879,51 @@ export class CampScene extends Phaser.Scene { }); }); - const selected = new Set(this.selectedSortieUnitIds); + const deploymentPlan = this.sortieDeploymentPlanForScenario(scenario); scenario.units.forEach((unit) => { - if (unit.faction !== 'enemy' && !selected.has(unit.id)) { + if (unit.faction !== 'enemy') { return; } const dotX = originX + (unit.x + 0.5) * cell; const dotY = originY + (unit.y + 0.5) * cell; - const color = unit.faction === 'enemy' ? 0xd35f4a : unit.id === 'liu-bei' ? palette.gold : palette.green; - const alpha = unit.faction === 'enemy' ? 0.72 : 0.96; - const dot = this.trackSortie(this.add.circle(dotX, dotY, unit.faction === 'enemy' ? 2.2 : 3.7, color, alpha)); + const dot = this.trackSortie(this.add.circle(dotX, dotY, 2.2, 0xd35f4a, 0.72)); dot.setDepth(depth + 2); - if (unit.faction !== 'enemy') { - dot.setStrokeStyle(1, 0x05070a, 0.8); - } }); - this.trackSortie(this.add.text(x + 10, y + height - 16, '녹색: 출전 아군 · 붉은색: 적 배치', this.textStyle(10, '#9fb0bf'))).setDepth(depth + 2); + deploymentPlan.forEach((entry) => { + const dotX = originX + (entry.x + 0.5) * cell; + const dotY = originY + (entry.y + 0.5) * cell; + const dot = this.trackSortie(this.add.circle(dotX, dotY, entry.unitId === 'liu-bei' ? 4.2 : 3.7, this.sortieRoleDotColor(entry.role), 0.96)); + dot.setDepth(depth + 3); + dot.setStrokeStyle(1, 0x05070a, 0.82); + }); + + this.trackSortie(this.add.text(x + 10, y + height - 16, '전열/돌파/후원 배치점 · 붉은색: 적', this.textStyle(10, '#9fb0bf'))).setDepth(depth + 2); + } + + private sortieDeploymentPlanForScenario(scenario: BattleScenarioDefinition) { + const selected = new Set(this.selectedSortieUnitIds); + const deployedUnits = scenario.units.filter((unit) => unit.faction === 'ally' && selected.has(unit.id)); + return createSortieDeploymentPlan( + deployedUnits, + scenario.units, + this.selectedSortieUnitIds, + this.sortieFormationAssignments, + (unit) => this.defaultSortieFormationRole(unit) + ); + } + + private sortieRoleDotColor(role: SortieFormationRole) { + if (role === 'front') { + return palette.green; + } + if (role === 'flank') { + return palette.gold; + } + if (role === 'support') { + return palette.blue; + } + return 0x9fb0bf; } private sortiePlanSummary(): SortiePlanSummary { @@ -11970,6 +12018,10 @@ export class CampScene extends Phaser.Scene { } private sortieFormationRole(unit: UnitData): SortieFormationRole { + return this.sortieFormationAssignments[unit.id] ?? this.defaultSortieFormationRole(unit); + } + + private defaultSortieFormationRole(unit: UnitData): SortieFormationRole { if (unit.classKey === 'cavalry') { return 'flank'; } @@ -11982,6 +12034,27 @@ export class CampScene extends Phaser.Scene { return 'reserve'; } + private assignFocusedSortieRole(role: SortieFormationRole) { + const unit = this.sortieFocusedUnit(); + if (!unit) { + return; + } + + this.sortieFocusedUnitId = unit.id; + if (!this.isSortieSelected(unit.id)) { + this.showCampNotice(`${unit.name}을 먼저 출전 편성에 포함해야 역할을 바꿀 수 있습니다.`); + return; + } + + this.sortieFormationAssignments = { + ...this.sortieFormationAssignments, + [unit.id]: role + }; + this.persistSortieSelection(); + soundDirector.playSelect(); + this.showSortiePrep(); + } + private sortieTerrainScore(unit: UnitData, scenario: BattleScenarioDefinition | undefined) { if (!scenario) { return 100; @@ -12230,10 +12303,11 @@ export class CampScene extends Phaser.Scene { markCampaignStep(flow.campaignStep); const selectedSortieUnitIds = [...this.selectedSortieUnitIds]; + const sortieFormationAssignments = { ...this.sortieFormationAssignments }; void startLazyScene(this, 'StoryScene', { pages: flow.pages, nextScene: 'BattleScene', - nextSceneData: { battleId: flow.nextBattleId, selectedSortieUnitIds } + nextSceneData: { battleId: flow.nextBattleId, selectedSortieUnitIds, sortieFormationAssignments } }); } @@ -13338,6 +13412,10 @@ export class CampScene extends Phaser.Scene { return [...new Set([...required, ...optional])].slice(0, maxUnits); } + private normalizedSortieFormationAssignments(assignments?: Record) { + return normalizeSortieFormationAssignments(assignments, new Set(this.sortieAllies().map((unit) => unit.id))); + } + private isSortieSelected(unitId: string) { return this.selectedSortieUnitIds.includes(unitId); } @@ -13356,6 +13434,9 @@ export class CampScene extends Phaser.Scene { const selected = new Set(this.selectedSortieUnitIds); if (selected.has(unitId)) { selected.delete(unitId); + const nextAssignments = { ...this.sortieFormationAssignments }; + delete nextAssignments[unitId]; + this.sortieFormationAssignments = nextAssignments; } else if (selected.size >= this.sortieMaxUnits()) { this.showCampNotice(`이번 전투는 최대 ${this.sortieMaxUnits()}명까지 출전할 수 있습니다.`); return; @@ -13382,7 +13463,9 @@ export class CampScene extends Phaser.Scene { private persistSortieSelection() { const campaign = this.campaign ?? getCampaignState(); + this.sortieFormationAssignments = this.normalizedSortieFormationAssignments(this.sortieFormationAssignments); campaign.selectedSortieUnitIds = [...this.selectedSortieUnitIds]; + campaign.sortieFormationAssignments = { ...this.sortieFormationAssignments }; this.campaign = saveCampaignState(campaign); } @@ -13463,6 +13546,14 @@ export class CampScene extends Phaser.Scene { this.contentObjects = []; } + private sortieDeploymentPreviewDebug() { + const scenario = this.nextSortieScenario(); + if (!scenario) { + return []; + } + return [...this.sortieDeploymentPlanForScenario(scenario).values()]; + } + getDebugState() { return { scene: this.scene.key, @@ -13472,6 +13563,8 @@ export class CampScene extends Phaser.Scene { selectedDialogueId: this.selectedDialogueId, selectedVisitId: this.selectedVisitId, selectedSortieUnitIds: [...this.selectedSortieUnitIds], + sortieFormationAssignments: { ...this.sortieFormationAssignments }, + sortieDeploymentPreview: this.sortieDeploymentPreviewDebug(), sortieFocusedUnitId: this.sortieFocusedUnitId, sortieFocusedUnit: this.sortieFocusedUnitSummary(), reserveTrainingFocus: this.reserveTrainingFocusDefinition(), @@ -13512,6 +13605,7 @@ export class CampScene extends Phaser.Scene { gold: this.campaign.gold, inventory: this.campaign.inventory, selectedSortieUnitIds: this.campaign.selectedSortieUnitIds, + sortieFormationAssignments: this.campaign.sortieFormationAssignments, reserveTrainingFocus: this.campaign.reserveTrainingFocus, roster: this.campaign.roster.map((unit) => ({ id: unit.id, diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index 9af82e7..05de40a 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -319,7 +319,11 @@ export class TitleScene extends Phaser.Scene { const battleId = battleIdForCampaignStep(campaign.step); if (battleId) { - await this.navigateTo('BattleScene', { battleId, selectedSortieUnitIds: campaign.selectedSortieUnitIds }); + await this.navigateTo('BattleScene', { + battleId, + selectedSortieUnitIds: campaign.selectedSortieUnitIds, + sortieFormationAssignments: campaign.sortieFormationAssignments + }); return; } diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 7b52296..170f151 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -1,5 +1,6 @@ import { equipmentExpToNext, equipmentSlots } from '../data/battleItems'; import type { BattleBond, UnitData } from '../data/scenario'; +import { normalizeSortieFormationAssignments, type SortieFormationAssignments } from '../data/sortieDeployment'; export type BattleObjectiveSnapshot = { id: string; @@ -279,6 +280,7 @@ export type CampaignState = { bonds: CampBondSnapshot[]; inventory: Record; selectedSortieUnitIds: string[]; + sortieFormationAssignments: SortieFormationAssignments; reserveTrainingFocus: CampaignReserveTrainingFocusId; completedCampDialogues: string[]; completedCampVisits: string[]; @@ -381,6 +383,7 @@ export function createInitialCampaignState(): CampaignState { bonds: [], inventory: {}, selectedSortieUnitIds: [], + sortieFormationAssignments: {}, reserveTrainingFocus: defaultCampaignReserveTrainingFocusId, completedCampDialogues: [], completedCampVisits: [], @@ -666,6 +669,7 @@ function normalizeCampaignState(state: CampaignState): CampaignState { normalized.completedCampVisits = [...new Set(normalized.completedCampVisits ?? [])]; normalized.inventory = { ...(normalized.inventory ?? {}) }; normalized.selectedSortieUnitIds = [...new Set(normalized.selectedSortieUnitIds ?? [])]; + normalized.sortieFormationAssignments = normalizeSortieFormationAssignments(normalized.sortieFormationAssignments); normalized.reserveTrainingFocus = normalizeReserveTrainingFocusId(normalized.reserveTrainingFocus); normalized.battleHistory = normalized.battleHistory ?? {}; if (normalized.firstBattleReport) {