feat: complete campaign flow and optimize battle assets

This commit is contained in:
2026-07-19 21:31:07 +09:00
parent f83296db7b
commit 31e1396ac8
211 changed files with 4219 additions and 281 deletions

View File

@@ -0,0 +1,202 @@
export type IntentForecastKind = 'attack' | 'usable' | 'approach' | 'wait';
export type IntentForecastSnapshot = {
enemyId: string;
kind: IntentForecastKind;
targetId?: string;
usableId?: string;
damage: number;
hitRate: number;
criticalRate?: number;
};
export type IntentRiskSummary = {
immediateCount: number;
approachCount: number;
expectedDamage: number;
rawDamage: number;
criticalAdjustedMaxDamage: number;
lethal: boolean;
immediateEnemyIds: string[];
};
export type MoveIntentRiskTone = 'safer' | 'steady' | 'riskier';
export type MoveIntentRiskDelta = {
targetId: string;
before: IntentRiskSummary;
after: IntentRiskSummary;
immediateDelta: number;
expectedDamageDelta: number;
teamExpectedDamageBefore: number;
teamExpectedDamageAfter: number;
teamExpectedDamageDelta: number;
retargetedToCount: number;
retargetedAwayCount: number;
neutralizedImmediateCount: number;
tone: MoveIntentRiskTone;
};
export type CombatExchangeProjectionInput = {
attackerHp: number;
defenderHp: number;
attackDamage: number;
attackHitRate: number;
attackCriticalRate: number;
counter?: {
damage: number;
hitRate: number;
criticalRate: number;
};
};
export type CombatExchangeProjection = {
defenderHpAfterHit: number;
defenderHpAfterCriticalHit: number;
attackerHpAfterCounterHit: number;
attackerHpAfterCounterCriticalHit: number;
attackExpectedDamage: number;
counterExpectedDamage: number;
attackLethalOnHit: boolean;
attackLethalOnCritical: boolean;
counterLethalOnHit: boolean;
counterLethalOnCritical: boolean;
counterCondition: 'none' | 'if-defender-survives' | 'on-primary-miss';
};
const isImmediateIntent = (snapshot: IntentForecastSnapshot) =>
snapshot.kind === 'attack' || snapshot.kind === 'usable';
const criticalDamage = (damage: number) => Math.round(Math.max(0, damage) * 1.5);
const expectedDamage = (snapshot: IntentForecastSnapshot) =>
isImmediateIntent(snapshot)
? Math.max(0, snapshot.damage) *
Math.max(0, Math.min(100, snapshot.hitRate)) / 100 *
(1 + Math.max(0, Math.min(100, snapshot.criticalRate ?? 0)) / 200)
: 0;
export function summarizeIntentRisk(
snapshots: readonly IntentForecastSnapshot[],
targetId: string,
targetHp: number
): IntentRiskSummary {
const targeted = snapshots.filter((snapshot) => snapshot.targetId === targetId);
const immediate = targeted.filter(isImmediateIntent);
const expected = Math.round(immediate.reduce((total, snapshot) => total + expectedDamage(snapshot), 0));
const rawDamage = immediate.reduce((total, snapshot) => total + Math.max(0, snapshot.damage), 0);
const criticalAdjustedMaxDamage = immediate.reduce(
(total, snapshot) => total + (
(snapshot.criticalRate ?? 0) > 0 ? criticalDamage(snapshot.damage) : Math.max(0, snapshot.damage)
),
0
);
return {
immediateCount: immediate.length,
approachCount: targeted.filter((snapshot) => snapshot.kind === 'approach').length,
expectedDamage: expected,
rawDamage,
criticalAdjustedMaxDamage,
lethal: targetHp > 0 && criticalAdjustedMaxDamage >= targetHp,
immediateEnemyIds: immediate.map((snapshot) => snapshot.enemyId)
};
}
export function compareMoveIntentRisk(
beforeSnapshots: readonly IntentForecastSnapshot[],
afterSnapshots: readonly IntentForecastSnapshot[],
targetId: string,
targetHp: number
): MoveIntentRiskDelta {
const before = summarizeIntentRisk(beforeSnapshots, targetId, targetHp);
const after = summarizeIntentRisk(afterSnapshots, targetId, targetHp);
const beforeByEnemy = new Map(beforeSnapshots.map((snapshot) => [snapshot.enemyId, snapshot] as const));
const afterByEnemy = new Map(afterSnapshots.map((snapshot) => [snapshot.enemyId, snapshot] as const));
const enemyIds = new Set([...beforeByEnemy.keys(), ...afterByEnemy.keys()]);
let retargetedToCount = 0;
let retargetedAwayCount = 0;
let neutralizedImmediateCount = 0;
enemyIds.forEach((enemyId) => {
const previous = beforeByEnemy.get(enemyId);
const next = afterByEnemy.get(enemyId);
if (previous?.targetId !== targetId && next?.targetId === targetId) {
retargetedToCount += 1;
}
if (previous?.targetId === targetId && next?.targetId !== targetId) {
retargetedAwayCount += 1;
}
if (previous && isImmediateIntent(previous) && (!next || !isImmediateIntent(next))) {
neutralizedImmediateCount += 1;
}
});
const teamExpectedDamageBefore = Math.round(beforeSnapshots.reduce((total, snapshot) => total + expectedDamage(snapshot), 0));
const teamExpectedDamageAfter = Math.round(afterSnapshots.reduce((total, snapshot) => total + expectedDamage(snapshot), 0));
const immediateDelta = after.immediateCount - before.immediateCount;
const expectedDamageDelta = after.expectedDamage - before.expectedDamage;
const teamExpectedDamageDelta = teamExpectedDamageAfter - teamExpectedDamageBefore;
const beforeRiskScore = before.expectedDamage * 2 + teamExpectedDamageBefore + before.immediateCount * 5 + (before.lethal ? 50 : 0);
const afterRiskScore = after.expectedDamage * 2 + teamExpectedDamageAfter + after.immediateCount * 5 + (after.lethal ? 50 : 0);
const tone: MoveIntentRiskTone = afterRiskScore < beforeRiskScore
? 'safer'
: afterRiskScore > beforeRiskScore
? 'riskier'
: 'steady';
return {
targetId,
before,
after,
immediateDelta,
expectedDamageDelta,
teamExpectedDamageBefore,
teamExpectedDamageAfter,
teamExpectedDamageDelta,
retargetedToCount,
retargetedAwayCount,
neutralizedImmediateCount,
tone
};
}
const expectedCriticalAdjustedDamage = (damage: number, hitRate: number, criticalRate: number) =>
Math.round(
Math.max(0, damage) *
Math.max(0, Math.min(100, hitRate)) / 100 *
(1 + Math.max(0, Math.min(100, criticalRate)) / 200)
);
export function buildCombatExchangeProjection(input: CombatExchangeProjectionInput): CombatExchangeProjection {
const defenderHpAfterHit = Math.max(0, input.defenderHp - Math.max(0, input.attackDamage));
const defenderHpAfterCriticalHit = Math.max(0, input.defenderHp - criticalDamage(input.attackDamage));
const attackLethalOnHit = input.defenderHp > 0 && defenderHpAfterHit <= 0;
const attackLethalOnCritical = input.attackCriticalRate > 0 && input.defenderHp > 0 && defenderHpAfterCriticalHit <= 0;
const counterDamage = input.counter?.damage ?? 0;
const attackerHpAfterCounterHit = Math.max(0, input.attackerHp - Math.max(0, counterDamage));
const attackerHpAfterCounterCriticalHit = Math.max(0, input.attackerHp - criticalDamage(counterDamage));
const counterLethalOnHit = Boolean(input.counter && input.attackerHp > 0 && attackerHpAfterCounterHit <= 0);
const counterLethalOnCritical = Boolean(
input.counter && input.counter.criticalRate > 0 && input.attackerHp > 0 && attackerHpAfterCounterCriticalHit <= 0
);
return {
defenderHpAfterHit,
defenderHpAfterCriticalHit,
attackerHpAfterCounterHit,
attackerHpAfterCounterCriticalHit,
attackExpectedDamage: expectedCriticalAdjustedDamage(input.attackDamage, input.attackHitRate, input.attackCriticalRate),
counterExpectedDamage: input.counter
? expectedCriticalAdjustedDamage(input.counter.damage, input.counter.hitRate, input.counter.criticalRate)
: 0,
attackLethalOnHit,
attackLethalOnCritical,
counterLethalOnHit,
counterLethalOnCritical,
counterCondition: !input.counter
? 'none'
: attackLethalOnHit
? 'on-primary-miss'
: 'if-defender-survives'
};
}

View File

@@ -0,0 +1,15 @@
{
"version": 3,
"sourceFrameSize": 313,
"optimizedFrameSize": 192,
"baseColumns": 16,
"baseRows": 4,
"actionColumns": 36,
"actionRows": 4,
"format": "webp",
"lossless": true,
"method": 6,
"optimizeAll": true,
"expectedBaseAssetCount": 91,
"expectedActionAssetCount": 91
}

View File

@@ -1,11 +1,13 @@
import Phaser from 'phaser';
import type Phaser from 'phaser';
import unitActionAssetPolicy from './unitActionAssetPolicy.json';
import { cancelLoaderFiles, captureLoaderFiles, releaseTextureSource } from '../render/loaderLifecycle';
export type UnitDirection = 'south' | 'east' | 'north' | 'west';
const unitIdleFrameCount = 8;
const unitWalkFrameCount = 8;
export const unitBaseFramesPerDirection = unitIdleFrameCount + unitWalkFrameCount;
export const unitSheetFrameSize = 313;
export const unitSheetFrameSize = unitActionAssetPolicy.optimizedFrameSize;
const unitWalkFrameRate = 10;
const unitSheetRows: Record<UnitDirection, number> = {
south: 0,
@@ -17,8 +19,12 @@ const unitSheetRows: Record<UnitDirection, number> = {
type UnitSheetAsset = {
key: string;
url: string;
frameSize: number;
format: 'png' | 'webp';
actionKey: string;
actionUrl: string;
actionFrameSize: number;
actionFormat: 'png' | 'webp';
};
export type UnitActionSheetLoadResult = {
@@ -35,33 +41,138 @@ export type UnitActionSheetLoadOptions = {
const defaultUnitActionSheetWatchdogMs = 15_000;
const unitSheetModules = import.meta.glob('../../assets/images/units/unit-*.png', {
const unitWebpSheetModules = import.meta.glob('../../assets/images/units/unit-*.webp', {
eager: true,
import: 'default'
}) as Record<string, string>;
const unitSheetModules = { ...unitWebpSheetModules };
function textureKeyFromPath(path: string) {
const fileName = path.split('/').pop() ?? path;
return fileName.replace(/\.png$/i, '');
return fileName.replace(/\.(?:png|webp)$/i, '');
}
const unitSheetUrls = Object.fromEntries(
Object.entries(unitSheetModules).map(([path, url]) => [textureKeyFromPath(path), url])
);
function unitSheetFormatFromPath(path: string): 'png' | 'webp' {
return path.toLowerCase().endsWith('.webp') ? 'webp' : 'png';
}
const unitSheets = Object.entries(unitSheetUrls)
.filter(([key]) => !key.endsWith('-actions'))
.map(([key, url]) => ({
key,
url,
actionKey: `${key}-actions`,
actionUrl: unitSheetUrls[`${key}-actions`]
}))
.filter((sheet): sheet is UnitSheetAsset => Boolean(sheet.actionUrl))
const unitSheetEntries = Object.entries(unitSheetModules).map(([path, url]) => ({
key: textureKeyFromPath(path),
url,
format: unitSheetFormatFromPath(path)
}));
const unitSheetEntriesByKey = new Map(unitSheetEntries.map((entry) => [entry.key, entry]));
const unitSheets = unitSheetEntries
.filter(({ key }) => !key.endsWith('-actions'))
.map(({ key, url }) => {
const actionKey = `${key}-actions`;
const actionEntry = unitSheetEntriesByKey.get(actionKey);
return actionEntry
? {
key,
url,
frameSize: unitActionAssetPolicy.optimizedFrameSize,
format: unitActionAssetPolicy.format,
actionKey,
actionUrl: actionEntry.url,
actionFrameSize: actionEntry.format === unitActionAssetPolicy.format
? unitActionAssetPolicy.optimizedFrameSize
: unitSheetFrameSize,
actionFormat: actionEntry.format
}
: undefined;
})
.filter((sheet): sheet is UnitSheetAsset => Boolean(sheet))
.sort((left, right) => left.key.localeCompare(right.key));
const unitSheetAssetsByKey = new Map(unitSheets.map((sheet) => [sheet.key, sheet]));
const pendingUnitTextureKeys = new Set<string>();
const unitTextureCleanupScenes = new WeakSet<Phaser.Scene>();
export type UnitActionSheetAssetInfo = {
key: string;
actionKey: string;
url: string;
frameSize: number;
format: 'png' | 'webp';
};
export type UnitBaseSheetAssetInfo = {
key: string;
url: string;
frameSize: number;
format: 'png' | 'webp';
};
export function unitBaseSheetAssetInfo(key: string): UnitBaseSheetAssetInfo | undefined {
const sheet = unitSheetAssetsByKey.get(key);
return sheet
? {
key: sheet.key,
url: sheet.url,
frameSize: sheet.frameSize,
format: sheet.format
}
: undefined;
}
export function unitActionSheetAssetInfo(key: string): UnitActionSheetAssetInfo | undefined {
const sheet = unitSheetAssetsByKey.get(key);
return sheet
? {
key: sheet.key,
actionKey: sheet.actionKey,
url: sheet.actionUrl,
frameSize: sheet.actionFrameSize,
format: sheet.actionFormat
}
: undefined;
}
export function releaseUnitActionSheetTextures(scene: Phaser.Scene, keepKeys: Iterable<string> = []) {
const keepActionKeys = new Set(Array.from(keepKeys, (key) => `${key}-actions`));
scene.textures.getTextureKeys().forEach((textureKey) => {
if (textureKey.startsWith('unit-') && textureKey.endsWith('-actions') && !keepActionKeys.has(textureKey)) {
releaseTextureSource(scene, textureKey);
pendingUnitTextureKeys.delete(textureKey);
}
});
}
export function releaseUnitBaseSheetTextures(scene: Phaser.Scene, keepKeys: Iterable<string> = []) {
const keepBaseKeys = new Set(keepKeys);
scene.textures.getTextureKeys().forEach((textureKey) => {
if (textureKey.startsWith('unit-') && !textureKey.endsWith('-actions') && !keepBaseKeys.has(textureKey)) {
releaseUnitBaseSheetAnimations(scene, textureKey);
releaseTextureSource(scene, textureKey);
pendingUnitTextureKeys.delete(textureKey);
}
});
}
function releaseUnitBaseSheetAnimations(scene: Phaser.Scene, textureKey: string) {
(['south', 'east', 'north', 'west'] as UnitDirection[]).forEach((direction) => {
[`${textureKey}-walk-${direction}`, `${textureKey}-idle-${direction}`].forEach((animationKey) => {
if (scene.anims.exists(animationKey)) {
scene.anims.remove(animationKey);
}
});
});
}
function registerUnitTextureCleanup(scene: Phaser.Scene) {
if (unitTextureCleanupScenes.has(scene)) {
return;
}
unitTextureCleanupScenes.add(scene);
scene.events.once('shutdown', () => {
releaseUnitActionSheetTextures(scene);
releaseUnitBaseSheetTextures(scene);
unitTextureCleanupScenes.delete(scene);
});
}
export function unitTextureVariantKeys(baseKey: string) {
const prefix = `${baseKey}-`;
@@ -87,17 +198,21 @@ export function waitForUnitBaseSheets(scene: Phaser.Scene, keys: Iterable<string
export function loadUnitBaseSheets(scene: Phaser.Scene, keys: Iterable<string>, onReady: () => void) {
const uniqueKeys = Array.from(new Set(keys));
if (scene.scene.key === 'BattleScene') {
registerUnitTextureCleanup(scene);
releaseUnitBaseSheetTextures(scene, uniqueKeys);
}
if (unitBaseSheetsReady(scene, uniqueKeys)) {
onReady();
return;
}
const pendingLoads: Array<{ key: string; url: string }> = [];
const pendingLoads: Array<{ key: string; url: string; frameSize: number }> = [];
uniqueKeys.forEach((key) => {
const sheet = unitSheetAssetsByKey.get(key) ?? missingUnitSheet(key);
if (!scene.textures.exists(sheet.key) && !pendingUnitTextureKeys.has(sheet.key)) {
pendingUnitTextureKeys.add(sheet.key);
pendingLoads.push({ key: sheet.key, url: sheet.url });
pendingLoads.push({ key: sheet.key, url: sheet.url, frameSize: sheet.frameSize });
}
});
@@ -109,20 +224,30 @@ export function loadUnitBaseSheets(scene: Phaser.Scene, keys: Iterable<string>,
const clearPendingLoads = () => {
pendingLoads.forEach(({ key }) => pendingUnitTextureKeys.delete(key));
};
scene.load.once('complete', () => {
const handleLoadError = () => clearPendingLoads();
const handleShutdown = () => {
scene.load.off('complete', handleComplete);
scene.load.off('loaderror', handleLoadError);
clearPendingLoads();
};
const handleComplete = () => {
scene.load.off('loaderror', handleLoadError);
scene.events.off('shutdown', handleShutdown);
clearPendingLoads();
const missing = uniqueKeys.filter((key) => !scene.textures.exists(key));
if (missing.length > 0) {
console.warn(`Failed to load unit base sheet textures: ${missing.join(', ')}`);
}
onReady();
});
scene.load.once('loaderror', clearPendingLoads);
pendingLoads.forEach(({ key, url }) => {
};
scene.load.once('complete', handleComplete);
scene.load.once('loaderror', handleLoadError);
scene.events.once('shutdown', handleShutdown);
pendingLoads.forEach(({ key, url, frameSize }) => {
scene.load.spritesheet(key, url, {
frameWidth: unitSheetFrameSize,
frameHeight: unitSheetFrameSize
frameWidth: frameSize,
frameHeight: frameSize
});
});
scene.load.start();
@@ -146,17 +271,19 @@ export function loadUnitActionSheets(
options: UnitActionSheetLoadOptions = {}
) {
const uniqueKeys = Array.from(new Set(keys));
releaseUnitActionSheetTextures(scene, uniqueKeys);
registerUnitTextureCleanup(scene);
if (unitActionSheetsReady(scene, uniqueKeys)) {
onComplete({ readyKeys: uniqueKeys, missingKeys: [], failedKeys: [], timedOutKeys: [], timedOut: false });
return;
}
const pendingLoads: Array<{ key: string; url: string }> = [];
const pendingLoads: Array<{ key: string; url: string; frameSize: number }> = [];
uniqueKeys.forEach((key) => {
const sheet = unitSheetAssetsByKey.get(key) ?? missingUnitSheet(key);
if (!scene.textures.exists(sheet.actionKey) && !pendingUnitTextureKeys.has(sheet.actionKey)) {
pendingUnitTextureKeys.add(sheet.actionKey);
pendingLoads.push({ key: sheet.actionKey, url: sheet.actionUrl });
pendingLoads.push({ key: sheet.actionKey, url: sheet.actionUrl, frameSize: sheet.actionFrameSize });
}
});
@@ -166,6 +293,7 @@ export function loadUnitActionSheets(
let settled = false;
let pollTimer: ReturnType<typeof setInterval> | undefined;
let watchdogTimer: ReturnType<typeof setTimeout> | undefined;
let ownedLoaderFiles: Phaser.Loader.File[] = [];
const clearPendingLoads = () => {
pendingLoads.forEach(({ key }) => pendingUnitTextureKeys.delete(key));
};
@@ -189,6 +317,9 @@ export function loadUnitActionSheets(
}
settled = true;
cleanup();
if (timedOut || !notify) {
cancelLoaderFiles(scene, ownedLoaderFiles, timedOut);
}
if (notify) {
finishUnitActionSheetLoad(scene, uniqueKeys, failedActionKeys, timedOut, onComplete);
}
@@ -224,12 +355,13 @@ export function loadUnitActionSheets(
return;
}
pendingLoads.forEach(({ key, url }) => {
pendingLoads.forEach(({ key, url, frameSize }) => {
scene.load.spritesheet(key, url, {
frameWidth: unitSheetFrameSize,
frameHeight: unitSheetFrameSize
frameWidth: frameSize,
frameHeight: frameSize
});
});
ownedLoaderFiles = captureLoaderFiles(scene, pendingLoads.map(({ key }) => key));
scene.load.start();
}
@@ -269,7 +401,7 @@ function finishUnitActionSheetLoad(
console.warn(`Failed to load unit action sheets; base-sheet fallback will be used: ${failedKeys.join(', ')}`);
}
if (timedOutKeys.length > 0) {
console.info(`Unit action sheet loading exceeded the startup budget; base-sheet fallback is active while loading continues: ${timedOutKeys.join(', ')}`);
console.info(`Unit action sheet loading exceeded the startup budget; pending requests were cancelled and the base-sheet fallback is active: ${timedOutKeys.join(', ')}`);
}
onComplete({
readyKeys: keys.filter((key) => !missingKeys.includes(key)),

View File

@@ -14,6 +14,19 @@ const levelUpStatKeysByClass: Record<UnitClassKey, Array<keyof UnitStats>> = {
rebelLeader: ['might', 'intelligence', 'leadership']
};
export function progressUnitStatsForLevelUps(
stats: UnitStats,
classKey: UnitClassKey,
levelUps: number
): UnitStats {
const safeLevelUps = Math.max(0, Math.floor(levelUps));
const progressedStatKeys = new Set(levelUpStatKeysByClass[classKey]);
return (Object.keys(stats) as Array<keyof UnitStats>).reduce<UnitStats>((next, key) => {
next[key] = Math.min(255, stats[key] + (progressedStatKeys.has(key) ? safeLevelUps : 0));
return next;
}, {} as UnitStats);
}
export function applyCampaignUnitProgress(scenarioUnit: UnitData, progress?: UnitData): UnitData {
const cloned = cloneUnitProgressData(scenarioUnit);
if (!progress || scenarioUnit.faction !== 'ally' || progress.faction !== 'ally' || progress.id !== scenarioUnit.id) {
@@ -22,10 +35,9 @@ export function applyCampaignUnitProgress(scenarioUnit: UnitData, progress?: Uni
const level = Math.max(scenarioUnit.level, progress.level);
const levelUps = level - scenarioUnit.level;
const progressedStatKeys = new Set(levelUpStatKeysByClass[scenarioUnit.classKey]);
const authoredStats = progressUnitStatsForLevelUps(scenarioUnit.stats, scenarioUnit.classKey, levelUps);
const stats = (Object.keys(scenarioUnit.stats) as Array<keyof UnitStats>).reduce<UnitStats>((next, key) => {
const authoredGrowthFloor = scenarioUnit.stats[key] + (progressedStatKeys.has(key) ? levelUps : 0);
next[key] = Math.min(255, Math.max(progress.stats[key], authoredGrowthFloor));
next[key] = Math.max(progress.stats[key], authoredStats[key]);
return next;
}, {} as UnitStats);
const maxHp = Math.max(progress.maxHp, scenarioUnit.maxHp + levelUps * 2);

View File

@@ -0,0 +1,119 @@
import type Phaser from 'phaser';
// Phaser 3.90 marks completed-or-terminal loader files from state 17 onward.
// Keeping this local avoids evaluating Phaser's browser-only runtime in Node-based data checks.
const loaderFileCompleteState = 17;
function loaderFiles(scene: Phaser.Scene) {
return [
...scene.load.list.getArray(),
...scene.load.inflight.getArray(),
...scene.load.queue.getArray()
];
}
export function captureLoaderFiles(scene: Phaser.Scene, keys: Iterable<string>) {
const requestedKeys = new Set(keys);
return loaderFiles(scene).filter((file) => requestedKeys.has(String(file.key)));
}
function releaseImageSource(source: unknown) {
if (typeof HTMLImageElement !== 'undefined' && source instanceof HTMLImageElement) {
source.onload = null;
source.onerror = null;
const url = source.currentSrc || source.src;
if (url.startsWith('blob:') && typeof URL !== 'undefined') {
URL.revokeObjectURL(url);
}
source.removeAttribute('src');
return;
}
if (typeof ImageBitmap !== 'undefined' && source instanceof ImageBitmap) {
source.close();
return;
}
if (typeof HTMLCanvasElement !== 'undefined' && source instanceof HTMLCanvasElement) {
source.width = 1;
source.height = 1;
}
}
export function releaseTextureSource(scene: Phaser.Scene, textureKey: string) {
if (!scene.textures.exists(textureKey)) {
return false;
}
const texture = scene.textures.get(textureKey);
const sourceImages = [...texture.source, ...texture.dataSource].map((source) => source.image);
scene.textures.remove(texture);
sourceImages.forEach(releaseImageSource);
return true;
}
function cancelImageProcessing(file: Phaser.Loader.File) {
if (typeof HTMLImageElement === 'undefined' || !(file.data instanceof HTMLImageElement)) {
return;
}
const image = file.data;
image.onload = null;
image.onerror = null;
releaseImageSource(image);
}
function cancelXhr(file: Phaser.Loader.File) {
const xhr = file.xhrLoader;
if (!xhr) {
return;
}
file.resetXHR();
xhr.ontimeout = null;
xhr.onabort = null;
xhr.onreadystatechange = null;
if (xhr.readyState !== XMLHttpRequest.DONE) {
try {
xhr.abort();
} catch {
// The browser may already have finalized the request between the state check and abort.
}
}
file.xhrLoader = null;
}
export function cancelLoaderFiles(
scene: Phaser.Scene,
files: Iterable<Phaser.Loader.File>,
finalizeEmptyBatch = false
) {
const uniqueFiles = new Set(files);
let cancelledCount = 0;
uniqueFiles.forEach((file) => {
if (file.state >= loaderFileCompleteState || scene.textures.exists(String(file.key))) {
return;
}
scene.load.list.delete(file);
scene.load.inflight.delete(file);
scene.load.queue.delete(file);
cancelXhr(file);
cancelImageProcessing(file);
file.destroy();
cancelledCount += 1;
});
if (
finalizeEmptyBatch &&
scene.load.isLoading() &&
scene.load.list.size === 0 &&
scene.load.inflight.size === 0 &&
scene.load.queue.size === 0
) {
scene.load.loadComplete();
}
return cancelledCount;
}

File diff suppressed because it is too large Load Diff

View File

@@ -175,7 +175,9 @@ import {
ensureCampaignRosterUnits,
getCampaignState,
getFirstBattleReport,
getPendingCampaignVictoryRewardBattleIds,
getPendingCampaignVictoryRewardReport,
getPendingCampaignVictoryRewardCategories,
markCampaignStep,
listCampaignSaveSlots,
campaignSaveSlotCount,
@@ -195,8 +197,12 @@ import {
type CampaignSortiePerformanceSnapshot,
type CampaignSortiePresetId,
type CampaignSortieRecommendationSnapshot,
type CampaignVictoryRewardCategoryId,
type CampaignVictoryRewardReport,
type FirstBattleReport
} from '../state/campaignState';
import { clearCampaignBattleSavesForSlot } from '../state/battleSaveKeys';
import { releaseTextureSource } from '../render/loaderLifecycle';
import { palette } from '../ui/palette';
import { startLazyScene } from './lazyScenes';
@@ -255,7 +261,7 @@ type CampTabButtonView = {
hovered: boolean;
};
type VictoryRewardCardId = 'gold' | 'equipment' | 'supplies' | 'reputation' | 'recruits' | 'unlocks';
type VictoryRewardCardId = CampaignVictoryRewardCategoryId;
type VictoryRewardActionId = 'equipment' | 'supplies' | 'sortie' | 'close';
@@ -11276,6 +11282,7 @@ export class CampScene extends Phaser.Scene {
private campSkinTransitionRevision = 0;
private campSkinTransitionCompletedRevision = 0;
private campSkinTransitionActive = false;
private ownedCampTextureKeys = new Set<string>();
private campSkinTransitionLast?: {
kind: 'backdrop-fade';
durationMs: number;
@@ -11310,7 +11317,7 @@ export class CampScene extends Phaser.Scene {
private saveSlotObjects: Phaser.GameObjects.GameObject[] = [];
private saveSlotConfirmObjects: Phaser.GameObjects.GameObject[] = [];
private pendingSaveSlot?: number;
private pendingVictoryRewardReport?: FirstBattleReport;
private pendingVictoryRewardReport?: CampaignVictoryRewardReport;
private victoryRewardObjects: Phaser.GameObjects.GameObject[] = [];
private victoryRewardCards: VictoryRewardCardView[] = [];
private victoryRewardActions: VictoryRewardActionView[] = [];
@@ -11382,6 +11389,7 @@ export class CampScene extends Phaser.Scene {
}
init(data?: CampSceneData) {
this.ownedCampTextureKeys.clear();
this.openSortiePrepOnCreate = Boolean(data?.openSortiePrep);
this.openSortieImprovementOnCreate = Boolean(data?.openSortieImprovement);
this.retrySortieBattleId = data?.retryBattleId;
@@ -11389,6 +11397,7 @@ export class CampScene extends Phaser.Scene {
}
create() {
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.releaseOwnedCampTextures());
this.contentObjects = [];
this.campRosterPage = 0;
this.campRosterLayout = undefined;
@@ -11667,15 +11676,27 @@ export class CampScene extends Phaser.Scene {
});
if (missingCampBackdrop) {
this.ownedCampTextureKeys.add(selection.skin.textureKey);
this.load.image(selection.skin.textureKey, selection.skin.assetUrl);
}
if (missingFirstSortieArtwork) {
this.ownedCampTextureKeys.add('story-first-sortie');
this.load.image('story-first-sortie', storyFirstSortieUrl);
}
missingPortraits.forEach(({ textureKey, url }) => this.load.image(textureKey, url));
missingPortraits.forEach(({ textureKey, url }) => {
this.ownedCampTextureKeys.add(textureKey);
this.load.image(textureKey, url);
});
this.load.start();
}
private releaseOwnedCampTextures() {
this.ownedCampTextureKeys.forEach((textureKey) => {
releaseTextureSource(this, textureKey);
});
this.ownedCampTextureKeys.clear();
}
private createFallbackReport(): FirstBattleReport {
const allies = firstBattleUnits.filter((unit) => unit.faction === 'ally');
return {
@@ -12944,6 +12965,7 @@ export class CampScene extends Phaser.Scene {
this.sortiePresetUndoState = undefined;
this.closeSortiePresetBrowserState();
this.hideCampSaveSlotPanel();
clearCampaignBattleSavesForSlot(slot);
this.campaign = saveCampaignState(getCampaignState(), slot);
if (returnToSortiePrep) {
this.showSortiePrep();
@@ -12992,15 +13014,28 @@ export class CampScene extends Phaser.Scene {
}
private showVictoryRewardAcknowledgement() {
const report = this.pendingVictoryRewardReport ?? getPendingCampaignVictoryRewardReport();
let report = this.pendingVictoryRewardReport ?? getPendingCampaignVictoryRewardReport();
if (!report || report.outcome !== 'victory' || this.sortieObjects.length > 0) {
return;
}
this.pendingVictoryRewardReport = report;
this.hideVictoryRewardAcknowledgement();
getPendingCampaignVictoryRewardBattleIds().forEach((battleId) => {
const passiveCategories = getPendingCampaignVictoryRewardCategories(battleId)
.filter((category) => category === 'gold' || category === 'reputation');
if (passiveCategories.length > 0) {
this.campaign = acknowledgeCampaignVictoryReward(battleId, passiveCategories);
}
});
report = getPendingCampaignVictoryRewardReport();
if (!report) {
this.pendingVictoryRewardReport = undefined;
return;
}
this.pendingVictoryRewardReport = report;
const rewards = report.campaignRewards;
const presentation = campaignVictoryRewardPresentation(report);
const pendingCategorySet = new Set(getPendingCampaignVictoryRewardCategories(report.battleId));
const categorizedRewardItems = new Set([
...(rewards?.supplies ?? []),
...(rewards?.equipment ?? []),
@@ -13097,7 +13132,7 @@ export class CampScene extends Phaser.Scene {
icon: equipmentItem ? { kind: 'item', itemId: equipmentItem.id } : { kind: 'battle-ui', iconKey: 'sword' },
actionId: 'equipment',
actionHint: '눌러서 장비 비교',
isNew: Boolean(equipmentReward)
isNew: Boolean(equipmentReward && pendingCategorySet.has('equipment'))
},
{
id: 'supplies',
@@ -13106,7 +13141,8 @@ export class CampScene extends Phaser.Scene {
accent: palette.green,
icon: { kind: 'battle-ui', iconKey: supply?.usableId ?? 'bean' },
actionId: 'supplies',
actionHint: '눌러서 보급 확인'
actionHint: '눌러서 보급 확인',
isNew: pendingCategorySet.has('supplies')
},
{
id: 'reputation',
@@ -13124,7 +13160,7 @@ export class CampScene extends Phaser.Scene {
icon: recruit ? { kind: 'portrait', unitId: recruit.unitId } : { kind: 'battle-ui', iconKey: 'leadership' },
actionId: 'sortie',
actionHint: '눌러서 편성에 배치',
isNew: Boolean(recruit)
isNew: Boolean(recruit && pendingCategorySet.has('recruits'))
},
{
id: 'unlocks',
@@ -13134,7 +13170,7 @@ export class CampScene extends Phaser.Scene {
icon: { kind: 'battle-ui', iconKey: 'terrain' },
actionId: 'sortie',
actionHint: '눌러서 다음 전장 준비',
isNew: Boolean(rewards?.unlocks.length)
isNew: Boolean(rewards?.unlocks.length && pendingCategorySet.has('unlocks'))
}
];
const cardWidth = 252;
@@ -13200,7 +13236,7 @@ export class CampScene extends Phaser.Scene {
background.setStrokeStyle(2, definition.accent, 0.78);
iconPlate.setStrokeStyle(1, definition.accent, 0.72);
});
background.on('pointerdown', () => this.completeVictoryRewardAcknowledgement(definition.actionId!));
background.on('pointerdown', () => this.completeVictoryRewardAcknowledgement(definition.actionId!, [definition.id]));
}
this.victoryRewardCards.push({
id: definition.id,
@@ -13260,7 +13296,10 @@ export class CampScene extends Phaser.Scene {
label.setDepth(depth + 3);
label.setInteractive({ useHandCursor: true });
const run = () => this.completeVictoryRewardAcknowledgement(definition.id);
const run = () => this.completeVictoryRewardAcknowledgement(
definition.id,
this.victoryRewardCategoriesForAction(definition.id)
);
[button, label].forEach((target) => {
target.on('pointerover', () => button.setFillStyle(hoverFill, 0.99));
target.on('pointerout', () => button.setFillStyle(restingFill, 0.99));
@@ -13270,7 +13309,10 @@ export class CampScene extends Phaser.Scene {
});
}
private completeVictoryRewardAcknowledgement(action: VictoryRewardActionId) {
private completeVictoryRewardAcknowledgement(
action: VictoryRewardActionId,
categories = this.victoryRewardCategoriesForAction(action)
) {
const report = this.pendingVictoryRewardReport;
const rewardedRecruitId = report?.campaignRewards?.recruits[0]?.unitId;
if (action === 'sortie' && rewardedRecruitId && this.sortieAllies().some((unit) => unit.id === rewardedRecruitId)) {
@@ -13279,12 +13321,7 @@ export class CampScene extends Phaser.Scene {
if (action === 'equipment' && report) {
this.focusVictoryRewardEquipment(report);
}
if (report) {
this.campaign = acknowledgeCampaignVictoryReward(report.battleId);
if (this.campaign.acknowledgedVictoryRewardBattleIds.includes(report.battleId)) {
this.pendingVictoryRewardReport = undefined;
}
}
this.acknowledgePendingVictoryRewardCategories(categories);
soundDirector.playSelect();
this.hideVictoryRewardAcknowledgement();
this.updateTabButtons();
@@ -13304,6 +13341,33 @@ export class CampScene extends Phaser.Scene {
}
}
private victoryRewardCategoriesForAction(action: VictoryRewardActionId): CampaignVictoryRewardCategoryId[] {
if (action === 'equipment') {
return ['equipment'];
}
if (action === 'supplies') {
return ['supplies'];
}
if (action === 'sortie') {
return ['recruits', 'unlocks'];
}
return [];
}
private acknowledgePendingVictoryRewardCategories(categories: readonly CampaignVictoryRewardCategoryId[]) {
if (categories.length === 0) {
return;
}
getPendingCampaignVictoryRewardBattleIds().forEach((battleId) => {
const relevantCategories = getPendingCampaignVictoryRewardCategories(battleId)
.filter((category) => categories.includes(category));
if (relevantCategories.length > 0) {
this.campaign = acknowledgeCampaignVictoryReward(battleId, relevantCategories);
}
});
this.pendingVictoryRewardReport = getPendingCampaignVictoryRewardReport();
}
private showRequestedSortiePrep() {
if (this.openSortieImprovementOnCreate) {
this.prepareGuidedSortieImprovement();
@@ -13332,7 +13396,7 @@ export class CampScene extends Phaser.Scene {
return campSupplyByLabel.get(this.rewardInventoryLabel(label));
}
private focusVictoryRewardEquipment(report: FirstBattleReport) {
private focusVictoryRewardEquipment(report: CampaignVictoryRewardReport) {
const rewardItemIds = new Set(
(report.campaignRewards?.equipment ?? [])
.map((label) => this.itemForRewardLabel(label)?.id)
@@ -13540,6 +13604,7 @@ export class CampScene extends Phaser.Scene {
private updateTabButtons() {
const accentColor = this.campSkinSelection?.skin.accentColor ?? palette.gold;
const pendingCategories = new Set(getPendingCampaignVictoryRewardCategories());
this.tabButtons.forEach(({ tab, bg, text, indicator, newBadge, hovered }) => {
const active = this.activeTab === tab;
bg.setFillStyle(active ? 0x2b4052 : hovered ? 0x243747 : 0x182431, active || hovered ? 0.98 : 0.94);
@@ -13549,7 +13614,8 @@ export class CampScene extends Phaser.Scene {
indicator.setVisible(active || hovered);
indicator.setAlpha(active ? 1 : 0.66);
newBadge?.setVisible(Boolean(
this.pendingVictoryRewardReport && (tab === 'supplies' || tab === 'equipment')
(tab === 'supplies' && pendingCategories.has('supplies')) ||
(tab === 'equipment' && pendingCategories.has('equipment'))
));
});
}
@@ -13586,6 +13652,11 @@ export class CampScene extends Phaser.Scene {
this.hideReportFormationHistory();
this.hideCampSaveSlotPanel();
this.clearContent();
if (this.activeTab === 'equipment') {
this.acknowledgePendingVictoryRewardCategories(['equipment']);
} else if (this.activeTab === 'supplies') {
this.acknowledgePendingVictoryRewardCategories(['supplies']);
}
this.visitedTabs.add(this.activeTab);
this.updateTabButtons();
this.renderUnitColumn();
@@ -13621,6 +13692,7 @@ export class CampScene extends Phaser.Scene {
private showSortiePrep() {
const wasVisible = this.sortieObjects.length > 0;
this.acknowledgePendingVictoryRewardCategories(['recruits', 'unlocks']);
this.hideReportFormationReview();
this.hideReportFormationHistory();
this.hideCampSaveSlotPanel();

View File

@@ -30,6 +30,7 @@ import {
} from '../data/storyCutscenes';
import { prologuePages, type PortraitKey, type StoryPage } from '../data/scenario';
import { campaignVictoryRewardPresentation, getFirstBattleReport } from '../state/campaignState';
import { releaseTextureSource } from '../render/loaderLifecycle';
import { palette } from '../ui/palette';
import { ensureLazyScene, startGameScene } from './lazyScenes';
@@ -156,6 +157,7 @@ export class StoryScene extends Phaser.Scene {
private activeStoryLoadErrorHandler?: (file: Phaser.Loader.File) => void;
private storyAssetLoadGeneration = 0;
private storyAssetFailures = new Set<string>();
private ownedStoryTextureKeys = new Set<string>();
private rewardDisplay: StoryRewardDisplayModel = this.emptyRewardDisplay();
constructor() {
@@ -174,6 +176,7 @@ export class StoryScene extends Phaser.Scene {
this.activeStoryPageAssetRequest = undefined;
this.activeStoryLoadErrorHandler = undefined;
this.storyAssetFailures.clear();
this.ownedStoryTextureKeys.clear();
this.storyAssetLoadGeneration += 1;
this.rewardDisplay = this.emptyRewardDisplay();
}
@@ -261,6 +264,7 @@ export class StoryScene extends Phaser.Scene {
this.activeStoryLoadErrorHandler = undefined;
}
this.pageLoadingText = undefined;
this.releaseOwnedStoryTextures();
});
this.warmFirstBattleSceneModule();
@@ -414,10 +418,20 @@ export class StoryScene extends Phaser.Scene {
}
loadUnitBases();
});
imageLoads.forEach(({ key, url }) => this.load.image(key, url));
imageLoads.forEach(({ key, url }) => {
this.ownedStoryTextureKeys.add(key);
this.load.image(key, url);
});
this.load.start();
}
private releaseOwnedStoryTextures() {
this.ownedStoryTextureKeys.forEach((textureKey) => {
releaseTextureSource(this, textureKey);
});
this.ownedStoryTextureKeys.clear();
}
private storyPageAssetPlan(pageIndex: number): StoryPageAssetPlan {
const page = this.pages[pageIndex];
if (!page) {

View File

@@ -7,6 +7,7 @@ import {
saveCombatPresentationMode
} from '../settings/combatPresentation';
import { battleIdForCampaignStep, isCampCampaignStep } from '../state/campaignRouting';
import { readCampaignBattleResume, type CampaignBattleResume } from '../state/battleSaveStorage';
import {
hasCampaignSave,
listCampaignSaveSlots,
@@ -283,7 +284,15 @@ export class TitleScene extends Phaser.Scene {
badge.add([background, title, detail, meta]);
}
private campaignSaveDetail(campaign: ReturnType<typeof loadCampaignState>, isEndingComplete: boolean) {
private campaignSaveDetail(
campaign: ReturnType<typeof loadCampaignState>,
isEndingComplete: boolean,
battleResume = readCampaignBattleResume(campaign)
) {
if (battleResume) {
const progress = summarizeCampaignProgress(campaign);
return this.compactSaveDetail(`전투 이어하기 · ${progress.title}`);
}
if (isEndingComplete) {
return this.compactSaveDetail(`${Object.keys(campaign.battleHistory).length}전 완료 · ${campaign.roster.length}명 합류`);
}
@@ -296,10 +305,21 @@ export class TitleScene extends Phaser.Scene {
return label.length > 19 ? `${label.slice(0, 18)}...` : label;
}
private campaignSaveMeta(campaign: ReturnType<typeof loadCampaignState>) {
private campaignSaveMeta(
campaign: ReturnType<typeof loadCampaignState>,
battleResume = readCampaignBattleResume(campaign)
) {
if (battleResume) {
return this.campaignBattleResumeMeta(battleResume);
}
return `슬롯 ${campaign.activeSaveSlot} · ${this.formatSaveUpdatedAt(campaign.updatedAt)}`;
}
private campaignBattleResumeMeta(resume: CampaignBattleResume) {
const faction = resume.activeFaction === 'ally' ? '아군 차례' : '적군 차례';
return `슬롯 ${resume.slot} · ${resume.turnNumber}턴 · ${faction}`;
}
private formatSaveUpdatedAt(updatedAt: string) {
const date = new Date(updatedAt);
if (Number.isNaN(date.getTime())) {
@@ -528,6 +548,7 @@ export class TitleScene extends Phaser.Scene {
const campaign = enabled ? loadCampaignState(slot.slot) : undefined;
const isEndingComplete = campaign?.step === 'ending-complete';
const battleResume = campaign ? readCampaignBattleResume(campaign, slot.slot) : undefined;
const title = this.add.text(-ui(122), -ui(12), `슬롯 ${slot.slot}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(16),
@@ -538,7 +559,7 @@ export class TitleScene extends Phaser.Scene {
});
title.setOrigin(0, 0.5);
const detail = this.add.text(-ui(42), -ui(12), campaign ? this.campaignSaveDetail(campaign, isEndingComplete) : '저장 없음', {
const detail = this.add.text(-ui(42), -ui(12), campaign ? this.campaignSaveDetail(campaign, isEndingComplete, battleResume) : '저장 없음', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(13),
color: enabled ? '#d8b15f' : '#747c86',
@@ -547,7 +568,7 @@ export class TitleScene extends Phaser.Scene {
});
detail.setOrigin(0, 0.5);
const meta = this.add.text(-ui(42), ui(12), campaign ? this.campaignSaveMeta(campaign) : '빈 슬롯', {
const meta = this.add.text(-ui(42), ui(12), campaign ? this.campaignSaveMeta(campaign, battleResume) : '빈 슬롯', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(11),
color: enabled ? '#9fb0bf' : '#626b75',
@@ -759,6 +780,14 @@ export class TitleScene extends Phaser.Scene {
private async continueGameAsync(slot?: number) {
const campaign = loadCampaignState(slot);
const battleResume = readCampaignBattleResume(campaign, slot ?? campaign.activeSaveSlot);
if (battleResume) {
await this.navigateTo('BattleScene', {
battleId: battleResume.battleId,
resumeBattleSaveSlot: battleResume.slot
});
return;
}
if (campaign.step === 'ending-complete') {
await this.navigateTo('EndingScene');
return;

View File

@@ -0,0 +1,108 @@
import { battleScenarios } from '../data/battles';
export type BattleSaveKeyStorageLike = Pick<Storage, 'getItem' | 'removeItem'>;
export const legacyFirstBattleSaveStorageKey = 'heros-web:first-battle-state';
export const defaultBattleSaveSlotCount = 3;
export function battleSaveStorageBaseKey(battleId: string) {
return `heros-web:battle:${battleId}`;
}
export function battleSaveStorageSlotKey(
battleId: string,
slot: number,
slotCount = defaultBattleSaveSlotCount
) {
return `${battleSaveStorageBaseKey(battleId)}:slot-${normalizeBattleSaveKeySlot(slot, slotCount)}`;
}
export function campaignBattleResumeStorageKeys(
battleId: string,
slot: number,
slotCount = defaultBattleSaveSlotCount
) {
const normalizedSlot = normalizeBattleSaveKeySlot(slot, slotCount);
const keys = [battleSaveStorageSlotKey(battleId, normalizedSlot, slotCount)];
if (normalizedSlot === 1) {
keys.push(battleSaveStorageBaseKey(battleId));
if (battleId === 'first-battle-zhuo-commandery') {
keys.push(legacyFirstBattleSaveStorageKey);
}
}
return [...new Set(keys)];
}
export function allCampaignBattleSaveStorageKeys(slotCount = defaultBattleSaveSlotCount) {
return [
...Object.keys(battleScenarios).flatMap((battleId) => [
battleSaveStorageBaseKey(battleId),
...Array.from({ length: slotCount }, (_, index) => battleSaveStorageSlotKey(battleId, index + 1, slotCount))
]),
legacyFirstBattleSaveStorageKey
];
}
export function clearBattleSaveStorageForBattle(
battleId: string,
slot: number,
storage = browserStorage(),
slotCount = defaultBattleSaveSlotCount
) {
if (!storage) {
return 0;
}
return removeStorageKeys(storage, campaignBattleResumeStorageKeys(battleId, slot, slotCount));
}
export function clearAllCampaignBattleSaves(
storage = browserStorage(),
slotCount = defaultBattleSaveSlotCount
) {
if (!storage) {
return 0;
}
return removeStorageKeys(storage, allCampaignBattleSaveStorageKeys(slotCount));
}
export function clearCampaignBattleSavesForSlot(
slot: number,
storage = browserStorage(),
slotCount = defaultBattleSaveSlotCount
) {
if (!storage) {
return 0;
}
const keys = Object.keys(battleScenarios).flatMap((battleId) =>
campaignBattleResumeStorageKeys(battleId, slot, slotCount)
);
return removeStorageKeys(storage, [...new Set(keys)]);
}
function removeStorageKeys(storage: BattleSaveKeyStorageLike, keys: readonly string[]) {
return keys.reduce((removedCount, key) => {
try {
if (storage.getItem(key) === null) {
return removedCount;
}
storage.removeItem(key);
return removedCount + 1;
} catch {
return removedCount;
}
}, 0);
}
function normalizeBattleSaveKeySlot(slot: number, slotCount: number) {
const safeSlotCount = Number.isFinite(slotCount) ? Math.max(1, Math.floor(slotCount)) : defaultBattleSaveSlotCount;
const safeSlot = Number.isFinite(slot) ? Math.floor(slot) : 1;
return Math.min(Math.max(safeSlot, 1), safeSlotCount);
}
function browserStorage() {
try {
return typeof window === 'undefined' ? undefined : window.localStorage;
} catch {
return undefined;
}
}

View File

@@ -23,6 +23,7 @@ export type BattleSaveBondState = BattleBond & {
};
export type SavedBattleUnitState = Pick<UnitData, 'id' | 'level' | 'exp' | 'hp' | 'maxHp' | 'attack' | 'move' | 'x' | 'y'> & {
stats?: UnitData['stats'];
equipment: UnitData['equipment'];
direction?: UnitDirection;
};
@@ -112,7 +113,7 @@ export type BattleSaveState = {
triggeredBattleEvents?: string[];
};
type BattleSaveValidationOptions = {
export type BattleSaveValidationOptions = {
expectedBattleId?: string;
allowTacticalCommand?: boolean;
allowLegacyTacticalCommand?: boolean;
@@ -144,6 +145,7 @@ const maxBattleUnitStatValue = 999999;
const maxBattleUnitHp = 9999;
const maxBattleUnitAttack = 9999;
const maxBattleUnitMove = 99;
const maxBattleUnitAttribute = 999;
const maxBattleBondBattleExp = 999999;
const maxStatusKindsPerUnit = 2;
const tacticalCommandCounterplayThreshold = 3;
@@ -547,6 +549,7 @@ function isSavedBattleUnitState(value: unknown, options: BattleSaveValidationOpt
Number(value.hp) > Number(value.maxHp) ||
!isIntegerInRange(value.attack, 0, maxBattleUnitAttack) ||
!isIntegerInRange(value.move, 0, maxBattleUnitMove) ||
!isOptionalUnitAttributes(value.stats) ||
!Number.isInteger(value.x) ||
!Number.isInteger(value.y)
) {
@@ -564,6 +567,20 @@ function isSavedBattleUnitState(value: unknown, options: BattleSaveValidationOpt
return value.direction === undefined || unitDirections.has(value.direction as UnitDirection);
}
function isOptionalUnitAttributes(value: unknown): value is UnitData['stats'] | undefined {
if (value === undefined) {
return true;
}
if (!isRecord(value)) {
return false;
}
const keys: Array<keyof UnitData['stats']> = ['might', 'intelligence', 'leadership', 'agility', 'luck'];
return (
Object.keys(value).length === keys.length &&
keys.every((key) => isIntegerInRange(value[key], 0, maxBattleUnitAttribute))
);
}
function isEquipmentSet(value: unknown, options: BattleSaveValidationOptions): value is UnitData['equipment'] {
if (!isRecord(value) || Object.keys(value).length !== equipmentSlots.length) {
return false;

View File

@@ -0,0 +1,159 @@
import type { BattleScenarioId } from '../data/battles';
import {
parseBattleSaveState,
normalizeBattleSaveSlot,
type BattleSaveFaction,
type BattleSaveState,
type BattleSaveValidationOptions
} from './battleSaveState';
import {
battleSaveStorageBaseKey,
battleSaveStorageSlotKey,
campaignBattleResumeStorageKeys,
clearAllCampaignBattleSaves,
clearCampaignBattleSavesForSlot,
clearBattleSaveStorageForBattle,
legacyFirstBattleSaveStorageKey,
type BattleSaveKeyStorageLike
} from './battleSaveKeys';
import { battleIdForCampaignStep } from './campaignRouting';
import type { CampaignStep } from './campaignState';
export type BattleSaveStorageLike = BattleSaveKeyStorageLike;
export {
battleSaveStorageBaseKey,
battleSaveStorageSlotKey,
campaignBattleResumeStorageKeys,
clearAllCampaignBattleSaves,
clearCampaignBattleSavesForSlot,
clearBattleSaveStorageForBattle,
legacyFirstBattleSaveStorageKey
};
export type CampaignBattleResumeSource = {
step: CampaignStep;
activeSaveSlot: number;
};
export type CampaignBattleResume = {
slot: number;
battleId: BattleScenarioId;
campaignStep: CampaignStep;
storageKey: string;
savedAt: string;
turnNumber: number;
activeFaction: BattleSaveFaction;
};
export type BattleSaveStorageCandidate = {
storageKey: string;
state: BattleSaveState;
};
export function readBattleSaveStorageCandidate(
battleId: BattleScenarioId,
campaignStep: CampaignStep | undefined,
slot: number,
storage = browserStorage(),
slotCount = 3,
validationOptions: BattleSaveValidationOptions = {}
): BattleSaveStorageCandidate | undefined {
if (!storage) {
return undefined;
}
const normalizedSlot = normalizeBattleSaveSlot(slot, slotCount);
for (const storageKey of campaignBattleResumeStorageKeys(battleId, normalizedSlot, slotCount)) {
const raw = safeGetItem(storage, storageKey);
if (raw === null) {
continue;
}
const state = parseBattleSaveState(raw, {
...validationOptions,
expectedBattleId: battleId
});
if (state && (state.campaignStep === undefined || state.campaignStep === campaignStep)) {
return { storageKey, state };
}
safeRemoveItem(storage, storageKey);
}
return undefined;
}
export function readCampaignBattleResume(
campaign: CampaignBattleResumeSource,
slot = campaign.activeSaveSlot,
storage = browserStorage(),
slotCount = 3
): CampaignBattleResume | undefined {
const battleId = battleIdForCampaignStep(campaign.step);
if (!battleId || !storage) {
return undefined;
}
const normalizedSlot = normalizeBattleSaveSlot(slot, slotCount);
const candidate = readBattleSaveStorageCandidate(
battleId,
campaign.step,
normalizedSlot,
storage,
slotCount,
{ allowTacticalCommand: true, allowLegacyTacticalCommand: true }
);
if (candidate) {
return {
slot: normalizedSlot,
battleId,
campaignStep: campaign.step,
storageKey: candidate.storageKey,
savedAt: candidate.state.savedAt,
turnNumber: candidate.state.turnNumber,
activeFaction: candidate.state.activeFaction
};
}
return undefined;
}
export function clearCampaignBattleResume(
campaign: CampaignBattleResumeSource,
slot = campaign.activeSaveSlot,
storage = browserStorage(),
slotCount = 3
) {
const battleId = battleIdForCampaignStep(campaign.step);
if (!battleId || !storage) {
return false;
}
let removed = false;
campaignBattleResumeStorageKeys(battleId, slot, slotCount).forEach((storageKey) => {
if (safeGetItem(storage, storageKey) !== null) {
removed = safeRemoveItem(storage, storageKey) || removed;
}
});
return removed;
}
function browserStorage() {
try {
return typeof window === 'undefined' ? undefined : window.localStorage;
} catch {
return undefined;
}
}
function safeGetItem(storage: BattleSaveStorageLike, key: string) {
try {
return storage.getItem(key);
} catch {
return null;
}
}
function safeRemoveItem(storage: BattleSaveStorageLike, key: string) {
try {
storage.removeItem(key);
return true;
} catch {
return false;
}
}

View File

@@ -74,6 +74,12 @@ export function battleIdForCampaignStep(step: CampaignStep) {
return battleIdByCampaignStep[step];
}
export function campaignBattleRouteEntries() {
return Object.entries(battleIdByCampaignStep)
.filter((entry): entry is [CampaignStep, BattleScenarioId] => Boolean(entry[1]))
.map(([step, battleId]) => ({ step, battleId }));
}
export function isCampCampaignStep(step: CampaignStep) {
return step.endsWith('-camp');
}

View File

@@ -25,6 +25,7 @@ import {
type SortieOrderResultSnapshot
} from '../data/sortieOrders';
import { battleIdForCampaignStep, isCampCampaignStep } from './campaignRouting';
import { clearAllCampaignBattleSaves, clearCampaignBattleSavesForSlot } from './battleSaveKeys';
export type BattleObjectiveSnapshot = {
id: string;
@@ -65,6 +66,19 @@ export type CampaignRewardSnapshot = {
note?: string;
};
export const campaignVictoryRewardCategoryIds = [
'gold',
'equipment',
'supplies',
'reputation',
'recruits',
'unlocks'
] as const;
export type CampaignVictoryRewardCategoryId = (typeof campaignVictoryRewardCategoryIds)[number];
export type CampaignVictoryRewardAcknowledgements = Partial<Record<string, CampaignVictoryRewardCategoryId[]>>;
const campaignVictoryRewardCategoryIdSet = new Set<string>(campaignVictoryRewardCategoryIds);
export type CampaignSortieUnitPerformanceSnapshot = {
unitId: string;
hpBefore: number;
@@ -472,6 +486,7 @@ export type CampaignState = {
completedCampDialogues: string[];
completedCampVisits: string[];
acknowledgedVictoryRewardBattleIds: string[];
acknowledgedVictoryRewardCategories: CampaignVictoryRewardAcknowledgements;
battleHistory: Record<string, CampaignBattleSettlement>;
latestBattleId?: string;
firstBattleReport?: FirstBattleReport;
@@ -678,12 +693,14 @@ export function createInitialCampaignState(): CampaignState {
completedCampDialogues: [],
completedCampVisits: [],
acknowledgedVictoryRewardBattleIds: [],
acknowledgedVictoryRewardCategories: {},
battleHistory: {}
};
}
export function startNewCampaign() {
const state = createInitialCampaignState();
clearCampaignBattleSavesForSlot(state.activeSaveSlot);
state.step = 'prologue';
return setCampaignState(state);
}
@@ -718,6 +735,7 @@ export function saveCampaignState(state = ensureCampaignState(), slot = state.ac
}
export function resetCampaignState() {
clearAllCampaignBattleSaves();
campaignState = createInitialCampaignState();
tryStorage()?.removeItem(campaignStorageKey);
for (let slot = 1; slot <= campaignSaveSlotCount; slot += 1) {
@@ -1027,7 +1045,12 @@ export function getFirstBattleReport() {
return report ? cloneReport(report) : undefined;
}
export function campaignVictoryRewardPresentation(report: FirstBattleReport): CampaignVictoryRewardPresentation {
export type CampaignVictoryRewardReport = Pick<
FirstBattleReport,
'battleId' | 'battleTitle' | 'outcome' | 'rewardGold' | 'itemRewards' | 'campaignRewards' | 'sortieOrder'
>;
export function campaignVictoryRewardPresentation(report: CampaignVictoryRewardReport): CampaignVictoryRewardPresentation {
const definition = report.outcome === 'victory' && report.sortieOrder?.rewardGranted
? sortieOrderDefinition(report.sortieOrder.orderId)
: undefined;
@@ -1047,46 +1070,182 @@ export function campaignVictoryRewardPresentation(report: FirstBattleReport): Ca
};
}
export function getPendingCampaignVictoryRewardReport() {
const state = ensureCampaignState();
const report = state.firstBattleReport;
if (
!report ||
report.outcome !== 'victory' ||
state.acknowledgedVictoryRewardBattleIds.includes(report.battleId)
) {
return undefined;
type CampaignVictoryRewardSource = Pick<
FirstBattleReport,
'battleId' | 'rewardGold' | 'itemRewards' | 'campaignRewards' | 'sortieOrder'
>;
export function campaignVictoryRewardCategoriesFor(
report: CampaignVictoryRewardSource
): CampaignVictoryRewardCategoryId[] {
const categories: CampaignVictoryRewardCategoryId[] = [];
const rewards = report.campaignRewards;
const orderDefinition = report.sortieOrder?.rewardGranted
? sortieOrderDefinition(report.sortieOrder.orderId)
: undefined;
if (report.rewardGold + (orderDefinition?.rewardGold ?? 0) > 0) {
categories.push('gold');
}
return cloneReport(report);
const categorizedRewardItems = new Set([
...(rewards?.supplies ?? []),
...(rewards?.equipment ?? []),
...(rewards?.reputation ?? [])
]);
const legacyExtraItems = report.itemRewards.filter((item) => !categorizedRewardItems.has(item));
const supplyItems = rewards
? [...rewards.supplies, ...legacyExtraItems, ...(orderDefinition?.rewardItems ?? [])]
: [...report.itemRewards, ...(orderDefinition?.rewardItems ?? [])];
if ((rewards?.equipment.length ?? 0) > 0) {
categories.push('equipment');
}
if (supplyItems.length > 0) {
categories.push('supplies');
}
if ((rewards?.reputation.length ?? 0) > 0) {
categories.push('reputation');
}
if ((rewards?.recruits.length ?? 0) > 0) {
categories.push('recruits');
}
if ((rewards?.unlocks.length ?? 0) > 0) {
categories.push('unlocks');
}
return categories;
}
export function acknowledgeCampaignVictoryReward(battleId: string) {
export function getPendingCampaignVictoryRewardCategories(battleId?: string) {
const state = ensureCampaignState();
if (battleId === undefined) {
const pending = new Set(
campaignVictoryRewardBattleIds(state).flatMap((candidateBattleId) =>
pendingCampaignVictoryRewardCategoriesFor(state, candidateBattleId)
)
);
return campaignVictoryRewardCategoryIds.filter((category) => pending.has(category));
}
return pendingCampaignVictoryRewardCategoriesFor(state, normalizeKeyString(battleId));
}
export function getPendingCampaignVictoryRewardBattleIds() {
const state = ensureCampaignState();
return campaignVictoryRewardBattleIds(state).filter(
(battleId) => pendingCampaignVictoryRewardCategoriesFor(state, battleId).length > 0
);
}
function pendingCampaignVictoryRewardCategoriesFor(state: CampaignState, normalizedBattleId: string) {
const report = campaignVictoryRewardSourceFor(state, normalizedBattleId);
if (!normalizedBattleId || !report || !isCampaignVictory(report)) {
return [] as CampaignVictoryRewardCategoryId[];
}
if (state.acknowledgedVictoryRewardBattleIds.includes(normalizedBattleId)) {
return [] as CampaignVictoryRewardCategoryId[];
}
const acknowledged = new Set(state.acknowledgedVictoryRewardCategories[normalizedBattleId] ?? []);
return campaignVictoryRewardCategoriesFor(report).filter((category) => !acknowledged.has(category));
}
export function getPendingCampaignVictoryRewardReport() {
const state = ensureCampaignState();
const battleId = [...campaignVictoryRewardBattleIds(state)]
.reverse()
.find((candidateBattleId) => pendingCampaignVictoryRewardCategoriesFor(state, candidateBattleId).length > 0);
const report = battleId ? campaignVictoryRewardSourceFor(state, battleId) : undefined;
if (!report || report.outcome !== 'victory') {
return undefined;
}
return cloneCampaignVictoryRewardReport(report);
}
export function acknowledgeCampaignVictoryReward(
battleId: string,
requestedCategories?: readonly CampaignVictoryRewardCategoryId[]
) {
const state = ensureCampaignState();
const normalizedBattleId = normalizeKeyString(battleId);
const report = state.firstBattleReport;
const settlement = state.battleHistory[normalizedBattleId];
const hasVictory = settlement?.outcome === 'victory' || (
report?.battleId === normalizedBattleId && report.outcome === 'victory'
);
const report = campaignVictoryRewardSourceFor(state, normalizedBattleId);
if (
!normalizedBattleId ||
!(normalizedBattleId in battleScenarios) ||
!hasVictory
!report ||
!isCampaignVictory(report)
) {
return cloneCampaignState(state);
}
if (!state.acknowledgedVictoryRewardBattleIds.includes(normalizedBattleId)) {
const availableCategories = campaignVictoryRewardCategoriesFor(report);
const availableCategorySet = new Set(availableCategories);
const categories = requestedCategories === undefined
? availableCategories
: uniqueVictoryRewardCategories(requestedCategories).filter((category) => availableCategorySet.has(category));
const previousCategories = state.acknowledgedVictoryRewardCategories[normalizedBattleId] ?? [];
const acknowledgedCategories = uniqueVictoryRewardCategories([...previousCategories, ...categories]);
const fullyAcknowledged = availableCategories.length > 0 &&
availableCategories.every((category) => acknowledgedCategories.includes(category));
const categoryChanged = JSON.stringify(previousCategories) !== JSON.stringify(acknowledgedCategories);
const battleAcknowledged = state.acknowledgedVictoryRewardBattleIds.includes(normalizedBattleId);
if (categoryChanged) {
state.acknowledgedVictoryRewardCategories = {
...state.acknowledgedVictoryRewardCategories,
[normalizedBattleId]: acknowledgedCategories
};
}
if (fullyAcknowledged && !battleAcknowledged) {
state.acknowledgedVictoryRewardBattleIds = [
...state.acknowledgedVictoryRewardBattleIds,
normalizedBattleId
];
}
if (categoryChanged || (fullyAcknowledged && !battleAcknowledged)) {
state.updatedAt = new Date().toISOString();
persistCampaignState(state);
}
return cloneCampaignState(state);
}
function campaignVictoryRewardSourceFor(state: CampaignState, battleId: string) {
if (state.firstBattleReport?.battleId === battleId) {
return state.firstBattleReport;
}
return state.battleHistory[battleId];
}
function campaignVictoryRewardBattleIds(state: CampaignState) {
const battleIds = Object.keys(state.battleHistory).filter((battleId) => isCampaignVictory(state.battleHistory[battleId]));
const latestReportBattleId = state.firstBattleReport?.outcome === 'victory'
? state.firstBattleReport.battleId
: undefined;
if (latestReportBattleId && !battleIds.includes(latestReportBattleId)) {
battleIds.push(latestReportBattleId);
}
return battleIds;
}
function cloneCampaignVictoryRewardReport(
report: CampaignVictoryRewardReport
): CampaignVictoryRewardReport {
return {
battleId: report.battleId,
battleTitle: report.battleTitle,
outcome: report.outcome,
rewardGold: report.rewardGold,
itemRewards: normalizeRewardStrings(report.itemRewards),
campaignRewards: cloneCampaignRewardSnapshot(report.campaignRewards, report.battleId),
...(report.sortieOrder
? { sortieOrder: JSON.parse(JSON.stringify(report.sortieOrder)) as CampaignSortieOrderResultSnapshot }
: {})
};
}
function isCampaignVictory(report: FirstBattleReport | CampaignBattleSettlement) {
return report.outcome === 'victory';
}
function uniqueVictoryRewardCategories(value: unknown): CampaignVictoryRewardCategoryId[] {
const categories = new Set(uniqueStrings(value).filter((category) => campaignVictoryRewardCategoryIdSet.has(category)));
return campaignVictoryRewardCategoryIds.filter((category) => categories.has(category));
}
export function applyCampBondExp(dialogueId: string, bondId: string, amount: number) {
const state = ensureCampaignState();
if (!state.firstBattleReport) {
@@ -1346,6 +1505,9 @@ function normalizeCampaignState(state: Partial<CampaignState>): CampaignState {
.filter((id): id is CampaignTutorialId => campaignTutorialIdSet.has(id));
normalized.acknowledgedVictoryRewardBattleIds = uniqueStrings(normalized.acknowledgedVictoryRewardBattleIds)
.filter((battleId) => battleId in battleScenarios);
normalized.acknowledgedVictoryRewardCategories = normalizeCampaignVictoryRewardAcknowledgements(
normalized.acknowledgedVictoryRewardCategories
);
normalized.inventory = normalizeInventory(normalized.inventory);
normalized.battleHistory = normalizeBattleHistory(normalized.battleHistory);
normalized.firstBattleReport = normalizeFirstBattleReport(normalized.firstBattleReport);
@@ -1415,6 +1577,40 @@ function normalizeCampaignState(state: Partial<CampaignState>): CampaignState {
}
normalized.acknowledgedVictoryRewardBattleIds = normalized.acknowledgedVictoryRewardBattleIds
.filter((battleId) => recordedVictoryBattleIds.has(battleId));
const acknowledgedVictoryRewardCategories = Object.entries(normalized.acknowledgedVictoryRewardCategories)
.reduce<CampaignVictoryRewardAcknowledgements>((acknowledgements, [battleId, categories]) => {
if (!recordedVictoryBattleIds.has(battleId)) {
return acknowledgements;
}
const source = campaignVictoryRewardSourceFor(normalized, battleId);
if (!source || !isCampaignVictory(source)) {
return acknowledgements;
}
const availableCategories = new Set(campaignVictoryRewardCategoriesFor(source));
const validCategories = uniqueVictoryRewardCategories(categories)
.filter((category) => availableCategories.has(category));
if (validCategories.length > 0) {
acknowledgements[battleId] = validCategories;
}
return acknowledgements;
}, {});
normalized.acknowledgedVictoryRewardBattleIds.forEach((battleId) => {
const source = campaignVictoryRewardSourceFor(normalized, battleId);
if (source && isCampaignVictory(source)) {
acknowledgedVictoryRewardCategories[battleId] = campaignVictoryRewardCategoriesFor(source);
}
});
normalized.acknowledgedVictoryRewardCategories = acknowledgedVictoryRewardCategories;
normalized.acknowledgedVictoryRewardBattleIds = [...recordedVictoryBattleIds]
.filter((battleId) => {
const source = campaignVictoryRewardSourceFor(normalized, battleId);
if (!source || !isCampaignVictory(source)) {
return false;
}
const acknowledged = new Set(normalized.acknowledgedVictoryRewardCategories[battleId] ?? []);
const available = campaignVictoryRewardCategoriesFor(source);
return available.length > 0 && available.every((category) => acknowledged.has(category));
});
normalized.sortieOrderHistory = normalizeCampaignSortieOrderHistory(
normalized.sortieOrderHistory,
normalized.battleHistory,
@@ -1435,6 +1631,19 @@ function recordOrEmpty<T = unknown>(value: unknown): Record<string, T> {
return isPlainObject(value) ? value as Record<string, T> : {};
}
function normalizeCampaignVictoryRewardAcknowledgements(value: unknown): CampaignVictoryRewardAcknowledgements {
return Object.entries(recordOrEmpty(value))
.slice(0, maxCampaignStringListEntries)
.reduce<CampaignVictoryRewardAcknowledgements>((acknowledgements, [battleId, categories]) => {
const normalizedBattleId = normalizeKeyString(battleId);
const normalizedCategories = uniqueVictoryRewardCategories(categories);
if (normalizedBattleId && normalizedBattleId in battleScenarios && normalizedCategories.length > 0) {
acknowledgements[normalizedBattleId] = normalizedCategories;
}
return acknowledgements;
}, {});
}
function uniqueStrings(value: unknown): string[] {
return [
...new Set(