feat: complete campaign flow and optimize battle assets
This commit is contained in:
202
src/game/data/battleForecast.ts
Normal file
202
src/game/data/battleForecast.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
15
src/game/data/unitActionAssetPolicy.json
Normal file
15
src/game/data/unitActionAssetPolicy.json
Normal 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
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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);
|
||||
|
||||
119
src/game/render/loaderLifecycle.ts
Normal file
119
src/game/render/loaderLifecycle.ts
Normal 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
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
108
src/game/state/battleSaveKeys.ts
Normal file
108
src/game/state/battleSaveKeys.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
159
src/game/state/battleSaveStorage.ts
Normal file
159
src/game/state/battleSaveStorage.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user