diff --git a/docs/battle-ui-icons-v2-action-item-contact.png b/docs/battle-ui-icons-v2-action-item-contact.png
index c5bd702..63735e8 100644
Binary files a/docs/battle-ui-icons-v2-action-item-contact.png and b/docs/battle-ui-icons-v2-action-item-contact.png differ
diff --git a/docs/equipment-icon-quality-audit.md b/docs/equipment-icon-quality-audit.md
index daaf773..1ef1004 100644
--- a/docs/equipment-icon-quality-audit.md
+++ b/docs/equipment-icon-quality-audit.md
@@ -1,86 +1,86 @@
# Equipment Icon Quality Audit
-Generated by `scripts/audit-equipment-icon-quality.mjs`.
+Generated by `scripts/audit-equipment-icon-quality.mjs` from the current catalog, PNG headers, and runtime source.
-## Summary
+## Result
-- Catalog items: 23
-- Generated procedural item textures: 23
-- Slots: accessory 7, armor 5, weapon 11
-- Ranks: common 11, treasure 12
-- Texture size: 64px
-- Drawing grid: 28 units scaled 2.29x into the texture
-- Runtime item icon scene: `src/game/scenes/CampScene.ts`
-- Observed runtime display sizes: `14px`
-- Direct item texture render sites found: 5
+- Status: **PASS**
+- Catalog contract: 23/23 items
+- Complete 128/32 pairs: 23/23
+- Fully valid catalog rows: 23/23
+- 128px payload: 393,307 B (384.1 KiB) across 23 PNG files
+- 32px payload: 40,270 B (39.3 KiB) across 23 PNG files
- Browser visual QA baseline: `1920x1080` CSS viewport at 100% zoom
-## Main Findings
+## Asset Coverage
-- Equipment item icons are generated at runtime in `BootScene`, not stored as individual raster images.
-- The 64px textures are drawn from a compact 28-unit source grid, so they are crisp but intentionally simple.
-- Battle combat preview mostly uses the high-resolution `battle-ui-icons` slot frames and item names; per-item textures appear primarily in camp/sortie equipment UI.
-- Treasure/common distinction often relies on UI frame color plus a small palette change, not a strong unique silhouette.
-- Reused helper silhouettes are the strongest quality risk because several different items collapse to the same shape at 14-22px.
+- 128px missing: none
+- 32px missing: none
+- 128px uncataloged/extra: none
+- 32px uncataloged/extra: none
+- Required PNG format: exact square dimensions, 8-bit RGBA (PNG color type 6), non-empty file.
-## Completed Improvement Passes
+## Boot Loading Contract
-- weapon spear/polearm family: `green-dragon-glaive`, `serpent-spear`, `sky-piercer-halberd`, `iron-spear`, `western-cavalry-spear`
- - The previous shared spear/polearm silhouettes now use item-specific helper shapes with enough primitive detail for 20px and 30px equipment UI reads.
-- armor silhouettes: `oath-robe`, `reinforced-lamellar`, `cloth-armor`, `lamellar-armor`, `rebel-vest`
- - The previous shared armor and robe silhouettes now use item-specific helper shapes with stronger outer contours and material cues.
-- accessory charms and tokens: `bravery-token`, `mountain-guide-scroll`, `peach-charm`, `war-manual`, `wind-quiver`, `grain-pouch`, `yellow-scarf-charm`
- - Accessory icons now use distinct badge, scroll, charm, pouch, manual, and quiver silhouettes at small camp UI sizes.
-- remaining low-detail weapon icons: `leader-axe`, `training-sword`, `yellow-turban-saber`
- - The final low-detail common weapons now use dedicated axe, training sword, and Yellow Turban saber silhouettes.
+| check | result | contract |
+| --- | --- | --- |
+| 128px source glob | PASS | `equipment-icons/128/*.png` is imported eagerly |
+| 32px source glob | PASS | `equipment-icons/32/*.png` is imported eagerly |
+| full texture preload | PASS | 128px files load as `item-${id}` |
+| micro texture preload | PASS | 32px files load as `item-${id}-micro` |
+| catalog-derived texture key | PASS | file stem becomes the stable item texture id |
+| procedural fallback guard | PASS | procedural full icon is created only when the loaded texture key is absent |
+| procedural fallback coverage | PASS | 23/23 catalog ids have a base-key fallback |
-## Next Improvement Target
+The authored 128px files are the normal `item-${id}` textures and the authored 32px files are the `item-${id}-micro` textures. The existing procedural 64px drawing path is retained only as a **loaded-base-texture-missing fallback**: its guard leaves a successfully loaded authored base texture untouched. Micro textures are static assets, so their completeness is enforced by this audit rather than by the procedural fallback.
-- Target: no flagged equipment icon batch
-- Item ids: none currently flagged
-- Reason: No generated equipment icon currently has a low-detail or shared-helper audit concern; choose the next visual asset pass from in-game QA.
+## Camp Render Usage
-## Highest-Risk Shared Silhouette Groups
+- Micro render sites: 4
+- Full render sites: 2
-| score | signature | slots | ranks | items |
-| ---: | --- | --- | --- | --- |
-| 5 | `drawFanIcon` | weapon | treasure | `white-feather-fan` |
-| 5 | `drawGreenDragonGlaiveIcon` | weapon | treasure | `green-dragon-glaive` |
-| 5 | `drawSerpentSpearIcon` | weapon | treasure | `serpent-spear` |
-| 5 | `drawSkyPiercerHalberdIcon` | weapon | treasure | `sky-piercer-halberd` |
-| 5 | `drawSwordIcon+drawSwordIcon` | weapon | treasure | `twin-oath-blades` |
+| tier | source location | method | key expression | requested display size |
+| --- | --- | --- | --- | --- |
+| micro | `src/game/scenes/CampScene.ts:16367` | `renderSortieEquipmentIcons` | `item-${item.id}-micro` | `iconSize` |
+| micro | `src/game/scenes/CampScene.ts:21970` | `renderEquipmentCompareRow` | `item-${entry.item.id}-micro` | `22px` |
+| micro | `src/game/scenes/CampScene.ts:22012` | `renderEquipmentEffectSummary` | `item-${entry.item.id}-micro` | `20px` |
+| micro | `src/game/scenes/CampScene.ts:22158` | `renderEquipmentInventoryBox` | `item-${entry.item.id}-micro` | `21px` |
+| full | `src/game/scenes/CampScene.ts:22341` | `renderEquipmentCard` | `item-${item.id}` | `30px` |
+| full | `src/game/scenes/CampScene.ts:23078` | `renderEquipmentSwapComparisonCard` | `item-${item.id}` | `40px` |
-## Full Item Inventory
+Micro textures are used for compact sortie, inventory, and equipped-item rows. Full textures are used for larger equipment cards and the compare/confirm panel.
-| item id | slot | rank | draw helpers | calls | primitives | colors | concerns |
-| --- | --- | --- | --- | ---: | ---: | ---: | --- |
-| `green-dragon-glaive` | weapon | treasure | `drawGreenDragonGlaiveIcon` | 1 | 23 | 6 | none |
-| `serpent-spear` | weapon | treasure | `drawSerpentSpearIcon` | 1 | 29 | 4 | none |
-| `sky-piercer-halberd` | weapon | treasure | `drawSkyPiercerHalberdIcon` | 1 | 24 | 7 | none |
-| `twin-oath-blades` | weapon | treasure | `drawSwordIcon`
`drawSwordIcon` | 2 | 32 | 4 | none |
-| `white-feather-fan` | weapon | treasure | `drawFanIcon` | 1 | 19 | 3 | none |
-| `iron-spear` | weapon | common | `drawIronSpearIcon` | 1 | 26 | 7 | none |
-| `leader-axe` | weapon | common | `drawLeaderAxeIcon` | 1 | 26 | 8 | none |
-| `short-bow` | weapon | common | `drawBowIcon` | 1 | 18 | 3 | none |
-| `training-sword` | weapon | common | `drawTrainingSwordIcon` | 1 | 31 | 7 | none |
-| `western-cavalry-spear` | weapon | common | `drawWesternCavalrySpearIcon` | 1 | 27 | 7 | none |
-| `yellow-turban-saber` | weapon | common | `drawYellowTurbanSaberIcon` | 1 | 35 | 4 | none |
-| `oath-robe` | armor | treasure | `drawOathRobeIcon` | 1 | 25 | 6 | none |
-| `reinforced-lamellar` | armor | treasure | `drawReinforcedLamellarIcon` | 1 | 24 | 5 | none |
-| `cloth-armor` | armor | common | `drawClothArmorIcon` | 1 | 24 | 6 | none |
-| `lamellar-armor` | armor | common | `drawLamellarArmorIcon` | 1 | 23 | 4 | none |
-| `rebel-vest` | armor | common | `drawRebelVestIcon` | 1 | 24 | 7 | none |
-| `bravery-token` | accessory | treasure | `drawBraveryTokenIcon` | 1 | 19 | 5 | none |
-| `mountain-guide-scroll` | accessory | treasure | `drawMountainGuideScrollIcon` | 1 | 23 | 7 | none |
-| `peach-charm` | accessory | treasure | `drawPeachCharmIcon` | 1 | 22 | 5 | none |
-| `war-manual` | accessory | treasure | `drawWarManualIcon` | 1 | 20 | 7 | none |
-| `wind-quiver` | accessory | treasure | `drawWindQuiverIcon` | 1 | 24 | 6 | none |
-| `grain-pouch` | accessory | common | `drawGrainPouchIcon` | 1 | 22 | 5 | none |
-| `yellow-scarf-charm` | accessory | common | `drawYellowScarfCharmIcon` | 1 | 25 | 5 | none |
+## Full Catalog Inventory
-## Recommended Next Batch Definition
+| item id | slot | rank | 128px PNG | bytes | 32px PNG | bytes | pair | concerns |
+| --- | --- | --- | --- | ---: | --- | ---: | --- | --- |
+| `green-dragon-glaive` | weapon | treasure | 128x128 RGBA8 | 9,893 | 32x32 RGBA8 | 1,252 | yes | none |
+| `iron-spear` | weapon | common | 128x128 RGBA8 | 6,364 | 32x32 RGBA8 | 830 | yes | none |
+| `leader-axe` | weapon | common | 128x128 RGBA8 | 12,684 | 32x32 RGBA8 | 1,487 | yes | none |
+| `serpent-spear` | weapon | treasure | 128x128 RGBA8 | 6,832 | 32x32 RGBA8 | 952 | yes | none |
+| `short-bow` | weapon | common | 128x128 RGBA8 | 12,987 | 32x32 RGBA8 | 1,543 | yes | none |
+| `sky-piercer-halberd` | weapon | treasure | 128x128 RGBA8 | 11,490 | 32x32 RGBA8 | 1,473 | yes | none |
+| `training-sword` | weapon | common | 128x128 RGBA8 | 8,933 | 32x32 RGBA8 | 1,260 | yes | none |
+| `twin-oath-blades` | weapon | treasure | 128x128 RGBA8 | 15,345 | 32x32 RGBA8 | 1,852 | yes | none |
+| `western-cavalry-spear` | weapon | common | 128x128 RGBA8 | 7,505 | 32x32 RGBA8 | 883 | yes | none |
+| `white-feather-fan` | weapon | treasure | 128x128 RGBA8 | 24,987 | 32x32 RGBA8 | 2,422 | yes | none |
+| `yellow-turban-saber` | weapon | common | 128x128 RGBA8 | 12,895 | 32x32 RGBA8 | 1,568 | yes | none |
+| `cloth-armor` | armor | common | 128x128 RGBA8 | 23,329 | 32x32 RGBA8 | 2,185 | yes | none |
+| `lamellar-armor` | armor | common | 128x128 RGBA8 | 21,179 | 32x32 RGBA8 | 1,970 | yes | none |
+| `oath-robe` | armor | treasure | 128x128 RGBA8 | 23,579 | 32x32 RGBA8 | 2,207 | yes | none |
+| `rebel-vest` | armor | common | 128x128 RGBA8 | 20,584 | 32x32 RGBA8 | 1,768 | yes | none |
+| `reinforced-lamellar` | armor | treasure | 128x128 RGBA8 | 20,788 | 32x32 RGBA8 | 1,920 | yes | none |
+| `bravery-token` | accessory | treasure | 128x128 RGBA8 | 26,569 | 32x32 RGBA8 | 2,418 | yes | none |
+| `grain-pouch` | accessory | common | 128x128 RGBA8 | 25,980 | 32x32 RGBA8 | 2,336 | yes | none |
+| `mountain-guide-scroll` | accessory | treasure | 128x128 RGBA8 | 24,057 | 32x32 RGBA8 | 2,290 | yes | none |
+| `peach-charm` | accessory | treasure | 128x128 RGBA8 | 15,997 | 32x32 RGBA8 | 1,592 | yes | none |
+| `war-manual` | accessory | treasure | 128x128 RGBA8 | 21,080 | 32x32 RGBA8 | 1,978 | yes | none |
+| `wind-quiver` | accessory | treasure | 128x128 RGBA8 | 20,471 | 32x32 RGBA8 | 2,084 | yes | none |
+| `yellow-scarf-charm` | accessory | common | 128x128 RGBA8 | 19,779 | 32x32 RGBA8 | 2,000 | yes | none |
-- Scope: no generated equipment icon is currently below the audit threshold.
-- Item ids: none currently flagged
-- Success criteria: Keep the generated equipment icon audit green while selecting the next art target from runtime screenshots.
-- Suggested next area: map/background or unit sprite assets that still look low-resolution in gameplay.
+## Findings
+
+- All 23 catalog items have valid, paired 128px and 32px RGBA8 PNG assets.
+- File stems map to stable Boot texture keys for both full and micro tiers.
+- Camp compact rows use micro textures while larger cards and comparison panels use full textures.
+- Procedural icons remain a load-failure fallback for base item keys, not the normal rendered asset path.
diff --git a/scripts/audit-equipment-icon-quality.mjs b/scripts/audit-equipment-icon-quality.mjs
index 5be2115..1a9398e 100644
--- a/scripts/audit-equipment-icon-quality.mjs
+++ b/scripts/audit-equipment-icon-quality.mjs
@@ -1,9 +1,13 @@
-import { readFileSync, writeFileSync } from 'node:fs';
+import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
import { createServer } from 'vite';
import { desktopBrowserViewportLabel } from './desktop-browser-viewport.mjs';
+const expectedCatalogCount = 23;
const bootScenePath = 'src/game/scenes/BootScene.ts';
const campScenePath = 'src/game/scenes/CampScene.ts';
+const icon128Directory = 'src/assets/images/ui/equipment-icons/128';
+const icon32Directory = 'src/assets/images/ui/equipment-icons/32';
const reportPath = 'docs/equipment-icon-quality-audit.md';
const server = await createServer({
@@ -12,543 +16,393 @@ const server = await createServer({
appType: 'custom'
});
+let issueCount = 0;
+
try {
const { itemCatalog, equipmentSlots } = await server.ssrLoadModule('/src/game/data/battleItems.ts');
const bootSceneSource = readFileSync(bootScenePath, 'utf8');
const campSceneSource = readFileSync(campScenePath, 'utf8');
+ const catalogRows = collectCatalogRows(itemCatalog, equipmentSlots);
+ const directory128 = inspectDirectory(icon128Directory, 128);
+ const directory32 = inspectDirectory(icon32Directory, 32);
+ const assetRows = collectAssetRows(catalogRows, directory128, directory32);
+ const bootAudit = inspectBootContract(bootSceneSource, catalogRows.map((row) => row.itemId));
+ const campAudit = inspectCampUsage(campSceneSource);
+ const catalogIssues = collectCatalogIssues(catalogRows);
+ const issues = [
+ ...catalogIssues,
+ ...directory128.directoryIssues,
+ ...directory32.directoryIssues,
+ ...assetRows.flatMap((row) => row.issues.map((issue) => `${row.itemId}: ${issue}`)),
+ ...directory128.extraFiles.map((file) => `128px directory has an uncataloged file: ${file}`),
+ ...directory32.extraFiles.map((file) => `32px directory has an uncataloged file: ${file}`),
+ ...bootAudit.issues,
+ ...campAudit.issues
+ ];
- const iconSourceSize = numberConstant(bootSceneSource, 'itemIconSourceSize');
- const iconTextureSize = numberConstant(bootSceneSource, 'itemIconTextureSize');
- const drawFunctions = parseDrawFunctions(bootSceneSource);
- const iconDefinitions = parseIconDefinitions(bootSceneSource, drawFunctions);
- const sceneUsage = parseSceneUsage(campSceneSource);
- const itemRows = collectItemRows(itemCatalog, equipmentSlots, iconDefinitions);
- const groupedRows = groupBySignature(itemRows);
- const recommendations = buildRecommendations(itemRows, groupedRows);
- const report = renderReport({
- iconSourceSize,
- iconTextureSize,
- itemRows,
- groupedRows,
- recommendations,
- sceneUsage
- });
-
- writeFileSync(reportPath, report, 'utf8');
- console.log(`Wrote ${reportPath}`);
+ issueCount = issues.length;
+ writeFileSync(
+ reportPath,
+ renderReport({
+ catalogRows,
+ assetRows,
+ directory128,
+ directory32,
+ bootAudit,
+ campAudit,
+ issues
+ }),
+ 'utf8'
+ );
+ console.log(`Wrote ${reportPath}: ${issues.length === 0 ? 'PASS' : `FAIL (${issues.length} issues)`}`);
} finally {
await server.close();
}
-function numberConstant(source, name) {
- const match = source.match(new RegExp(`const\\s+${name}\\s*=\\s*(\\d+)`));
- if (!match) {
- throw new Error(`Could not find ${name}`);
- }
- return Number(match[1]);
+if (issueCount > 0) {
+ process.exitCode = 1;
}
-function parseIconDefinitions(source, drawFunctions) {
- const definitions = new Map();
- const matcher = /this\.createItemIcon\('item-([^']+)'/g;
- let match;
-
- while ((match = matcher.exec(source))) {
- const itemId = match[1];
- const callText = expressionSpan(source, match.index);
- const drawCalls = [...callText.matchAll(/this\.(draw[A-Za-z]+Icon)\(([^)]*)\)/g)].map((drawMatch) => ({
- functionName: drawMatch[1],
- colors: [...drawMatch[2].matchAll(/0x[0-9a-fA-F]{6}/g)].map((colorMatch) => colorMatch[0].toLowerCase())
- }));
- const colors = [
- ...new Set(
- drawCalls.flatMap((call) => [...call.colors, ...(drawFunctions.get(call.functionName)?.colors ?? [])])
- )
- ].sort();
- const primitiveCount = drawCalls.reduce((sum, call) => sum + (drawFunctions.get(call.functionName)?.primitiveCount ?? 0), 0);
- const lineNumber = source.slice(0, match.index).split(/\r?\n/).length;
-
- definitions.set(itemId, {
- itemId,
- lineNumber,
- drawCalls,
- drawFunctionNames: drawCalls.map((call) => call.functionName),
- colors,
- primitiveCount,
- signature: drawCalls.map((call) => call.functionName).sort().join('+')
- });
- }
-
- return definitions;
-}
-
-function expressionSpan(source, startIndex) {
- const openIndex = source.indexOf('(', startIndex);
- if (openIndex < 0) {
- throw new Error(`Could not find call expression at ${startIndex}`);
- }
-
- let depth = 0;
- let quote = '';
- let escaped = false;
-
- for (let index = openIndex; index < source.length; index += 1) {
- const char = source[index];
-
- if (quote) {
- if (escaped) {
- escaped = false;
- } else if (char === '\\') {
- escaped = true;
- } else if (char === quote) {
- quote = '';
- }
- continue;
- }
-
- if (char === "'" || char === '"' || char === '`') {
- quote = char;
- continue;
- }
-
- if (char === '(') {
- depth += 1;
- } else if (char === ')') {
- depth -= 1;
- if (depth === 0) {
- return source.slice(startIndex, index + 1);
- }
- }
- }
-
- throw new Error(`Unclosed call expression at ${startIndex}`);
-}
-
-function parseDrawFunctions(source) {
- const functions = new Map();
- const matcher = /private\s+(draw[A-Za-z]+Icon)\([^)]*\)\s*\{/g;
- let match;
-
- while ((match = matcher.exec(source))) {
- const functionName = match[1];
- const body = blockSpan(source, source.indexOf('{', match.index));
- functions.set(functionName, {
- functionName,
- primitiveCount: countGraphicsPrimitives(body),
- colors: [...new Set([...body.matchAll(/0x[0-9a-fA-F]{6}/g)].map((colorMatch) => colorMatch[0].toLowerCase()))].sort()
- });
- }
-
- return functions;
-}
-
-function blockSpan(source, openIndex) {
- let depth = 0;
- let quote = '';
- let escaped = false;
-
- for (let index = openIndex; index < source.length; index += 1) {
- const char = source[index];
-
- if (quote) {
- if (escaped) {
- escaped = false;
- } else if (char === '\\') {
- escaped = true;
- } else if (char === quote) {
- quote = '';
- }
- continue;
- }
-
- if (char === "'" || char === '"' || char === '`') {
- quote = char;
- continue;
- }
-
- if (char === '{') {
- depth += 1;
- } else if (char === '}') {
- depth -= 1;
- if (depth === 0) {
- return source.slice(openIndex + 1, index);
- }
- }
- }
-
- throw new Error(`Unclosed block at ${openIndex}`);
-}
-
-function countGraphicsPrimitives(body) {
- return [
- ...body.matchAll(
- /graphics\.(fillTriangle|fillRect|fillRoundedRect|fillEllipse|fillCircle|lineStyle|beginPath|moveTo|lineTo|strokePath|strokeRect|strokeRoundedRect|strokeCircle)/g
- )
- ].length;
-}
-
-function parseSceneUsage(source) {
- const sizes = new Set();
- const usageBlocks = [...source.matchAll(/add\.image\([^`]+`item-\$\{[^`]+`[\s\S]{0,240}?setDisplaySize\((\d+),\s*(\d+)\)/g)];
- const sortieSignature = source.match(/renderSortieEquipmentIcons\([^)]*iconSize\s*=\s*(\d+)/);
-
- usageBlocks.forEach((match) => {
- if (match[1] === match[2]) {
- sizes.add(Number(match[1]));
- } else {
- sizes.add(`${match[1]}x${match[2]}`);
- }
- });
-
- if (sortieSignature) {
- sizes.add(Number(sortieSignature[1]));
- }
-
- const directItemImageCount = [...source.matchAll(/add\.image\([^`]+`item-\$\{/g)].length;
- return {
- scene: campScenePath,
- displaySizes: [...sizes].sort((left, right) => Number(left) - Number(right)),
- directItemImageCount
- };
-}
-
-function collectItemRows(itemCatalog, equipmentSlots, iconDefinitions) {
+function collectCatalogRows(itemCatalog, equipmentSlots) {
const slotOrder = new Map(equipmentSlots.map((slot, index) => [slot, index]));
-
- const rows = Object.entries(itemCatalog)
- .map(([itemId, item]) => {
- const icon = iconDefinitions.get(itemId);
- const concerns = [];
- if (!icon) {
- concerns.push('missing generated icon');
- } else {
- if (icon.primitiveCount < 18) {
- concerns.push('low primitive detail');
- }
- }
-
- return {
- itemId,
- slot: item.slot,
- rank: item.rank,
- attackBonus: item.attackBonus ?? 0,
- defenseBonus: item.defenseBonus ?? 0,
- strategyBonus: item.strategyBonus ?? 0,
- icon,
- signature: icon?.signature ?? 'missing',
- drawFunctions: icon?.drawFunctionNames ?? [],
- drawCalls: icon?.drawCalls.length ?? 0,
- primitiveCount: icon?.primitiveCount ?? 0,
- colorCount: icon?.colors.length ?? 0,
- concerns
- };
- });
- const signatureCounts = countBy(rows.filter((row) => row.icon), (row) => row.signature);
- rows.forEach((row) => {
- if (row.icon && row.drawCalls <= 1 && (signatureCounts.get(row.signature) ?? 0) > 1) {
- row.concerns.unshift('shared helper silhouette');
- }
- });
-
- return rows.sort((left, right) => {
- const slotDiff = (slotOrder.get(left.slot) ?? 99) - (slotOrder.get(right.slot) ?? 99);
- if (slotDiff !== 0) {
- return slotDiff;
- }
- if (left.rank !== right.rank) {
- return left.rank === 'treasure' ? -1 : 1;
- }
- return left.itemId.localeCompare(right.itemId);
- });
-}
-
-function groupBySignature(itemRows) {
- const groups = new Map();
- itemRows.forEach((row) => {
- const group = groups.get(row.signature) ?? [];
- group.push(row);
- groups.set(row.signature, group);
- });
-
- return [...groups.entries()]
- .map(([signature, rows]) => ({
- signature,
- rows,
- slots: [...new Set(rows.map((row) => row.slot))].sort(),
- ranks: [...new Set(rows.map((row) => row.rank))].sort(),
- itemIds: rows.map((row) => row.itemId),
- score: groupRiskScore(rows)
+ return Object.entries(itemCatalog)
+ .map(([catalogKey, item]) => ({
+ catalogKey,
+ itemId: item.id,
+ slot: item.slot,
+ rank: item.rank
}))
- .sort((left, right) => right.score - left.score || right.rows.length - left.rows.length || left.signature.localeCompare(right.signature));
+ .sort((left, right) => {
+ const slotDifference = (slotOrder.get(left.slot) ?? 99) - (slotOrder.get(right.slot) ?? 99);
+ return slotDifference || left.itemId.localeCompare(right.itemId);
+ });
}
-function groupRiskScore(rows) {
- const slots = new Set(rows.map((row) => row.slot));
- const ranks = new Set(rows.map((row) => row.rank));
- let score = rows.length;
-
- if (rows.length >= 3) {
- score += 3;
- }
- if (slots.has('weapon')) {
- score += 3;
- }
- if (ranks.has('treasure') && ranks.has('common')) {
- score += 3;
- }
- if (rows.some((row) => row.primitiveCount < 18)) {
- score += 2;
- }
- if (rows.some((row) => row.rank === 'treasure')) {
- score += 1;
+function collectCatalogIssues(catalogRows) {
+ const issues = [];
+ if (catalogRows.length !== expectedCatalogCount) {
+ issues.push(`itemCatalog count is ${catalogRows.length}; expected ${expectedCatalogCount}`);
}
- return score;
-}
-
-function buildRecommendations(itemRows, groupedRows) {
- const weaponPolearmTargetIds = [
- 'green-dragon-glaive',
- 'serpent-spear',
- 'iron-spear',
- 'sky-piercer-halberd',
- 'western-cavalry-spear'
- ];
- const armorTargetIds = ['oath-robe', 'reinforced-lamellar', 'cloth-armor', 'lamellar-armor', 'rebel-vest'];
- const accessoryIds = itemRows.filter((row) => row.slot === 'accessory').map((row) => row.itemId);
- const lowDetailWeaponTargetIds = ['leader-axe', 'training-sword', 'yellow-turban-saber'];
- const targets = [
- {
- title: 'weapon spear/polearm family',
- itemIds: weaponPolearmTargetIds,
- completeReason:
- 'The previous shared spear/polearm silhouettes now use item-specific helper shapes with enough primitive detail for 20px and 30px equipment UI reads.',
- openReason:
- 'These are high-frequency equipment surfaces and several treasure/common weapons share a thin diagonal shaft silhouette; upgrading this group gives the clearest identity gain in sortie prep and equipment swap screens.',
- successCriteria:
- 'Each item should read as a distinct weapon silhouette at 20px and 30px, with treasure weapons visibly richer than common weapons.'
- },
- {
- title: 'armor silhouettes',
- itemIds: armorTargetIds,
- completeReason:
- 'The previous shared armor and robe silhouettes now use item-specific helper shapes with stronger outer contours and material cues.',
- openReason:
- 'Armor and robes are readable but still depend on simple triangular torsos and small color accents; this is now the largest remaining shared-equipment silhouette group.',
- successCriteria:
- 'Each armor item should keep its slot identity while gaining a distinct outer silhouette and material cue at 20px and 30px.'
- },
- {
- title: 'accessory charms and tokens',
- itemIds: accessoryIds,
- completeReason:
- 'Accessory icons now use distinct badge, scroll, charm, pouch, manual, and quiver silhouettes at small camp UI sizes.',
- openReason: 'Accessories are the smallest UI read and several use compact badge-like shapes.',
- successCriteria:
- 'Each accessory should keep a compact footprint while gaining a distinct readable prop shape at 20px and 30px.'
- },
- {
- title: 'remaining low-detail weapon icons',
- itemIds: lowDetailWeaponTargetIds,
- completeReason:
- 'The final low-detail common weapons now use dedicated axe, training sword, and Yellow Turban saber silhouettes.',
- openReason:
- 'These are the last generated equipment icons still below the primitive-detail threshold after the larger weapon, armor, and accessory passes.',
- successCriteria: 'Clear the low primitive detail concerns without changing stable item texture keys.'
+ const seenIds = new Set();
+ catalogRows.forEach((row) => {
+ if (row.catalogKey !== row.itemId) {
+ issues.push(`itemCatalog key/id mismatch: ${row.catalogKey} != ${row.itemId}`);
}
- ].map((target) => ({
- ...target,
- rows: itemRows.filter((row) => target.itemIds.includes(row.itemId))
- }));
- const completedTargets = targets
- .filter((target) => improvementTargetComplete(target))
- .map((target) => ({
- title: target.title,
- itemIds: target.rows.map((row) => row.itemId),
- reason: target.completeReason
- }));
- const remainingConcernRows = itemRows.filter((row) => row.concerns.length > 0);
- const nextTarget =
- targets.find((target) => !improvementTargetComplete(target)) ?? {
- title: remainingConcernRows.length ? 'remaining low-detail equipment icons' : 'no flagged equipment icon batch',
- rows: remainingConcernRows,
- openReason: remainingConcernRows.length
- ? 'All named batches are complete; remaining work should be selected from the low-detail inventory rows.'
- : 'No generated equipment icon currently has a low-detail or shared-helper audit concern; choose the next visual asset pass from in-game QA.',
- successCriteria: remainingConcernRows.length
- ? 'Clear remaining low-detail concerns without changing stable texture keys.'
- : 'Keep the generated equipment icon audit green while selecting the next art target from runtime screenshots.'
- };
- const nextTargetIndex = targets.findIndex((target) => target.title === nextTarget.title);
- const followUps = targets
- .filter((target, index) => index > nextTargetIndex && !improvementTargetComplete(target))
- .map((target) => ({
- title: target.title,
- itemIds: target.rows.map((row) => row.itemId),
- reason: target.openReason
- }));
+ if (seenIds.has(row.itemId)) {
+ issues.push(`itemCatalog has duplicate id: ${row.itemId}`);
+ }
+ seenIds.add(row.itemId);
+ });
+ return issues;
+}
+
+function inspectDirectory(directory, expectedSize) {
+ const entries = readdirSync(directory, { withFileTypes: true });
+ const files = entries.filter((entry) => entry.isFile()).map((entry) => entry.name).sort();
+ const pngFiles = files.filter((name) => name.toLowerCase().endsWith('.png'));
+ const nonPngFiles = files.filter((name) => !name.toLowerCase().endsWith('.png'));
+ const assets = new Map(
+ pngFiles.map((name) => {
+ const itemId = name.slice(0, -4);
+ return [itemId, inspectPng(join(directory, name), expectedSize)];
+ })
+ );
return {
- completedTargets,
- firstTargetTitle: nextTarget.title,
- firstTargetIds: nextTarget.rows.map((row) => row.itemId),
- firstTargetReason: nextTarget.openReason,
- firstTargetSuccessCriteria: nextTarget.successCriteria,
- followUps,
- topRiskGroups: groupedRows.slice(0, 5)
+ directory,
+ expectedSize,
+ files,
+ assets,
+ nonPngFiles,
+ extraFiles: [],
+ directoryIssues: nonPngFiles.map((name) => `${directory} contains a non-PNG file: ${name}`)
};
}
-function improvementTargetComplete(target) {
- return (
- target.rows.length === target.itemIds.length &&
- target.rows.every((row) => row.icon && row.primitiveCount >= 18) &&
- new Set(target.rows.map((row) => row.signature)).size === target.rows.length
- );
+function inspectPng(path, expectedSize) {
+ const bytes = statSync(path).size;
+ const buffer = readFileSync(path);
+ const signature = buffer.subarray(0, 8).toString('hex');
+ const validSignature = signature === '89504e470d0a1a0a';
+ const enoughHeader = buffer.length >= 26;
+ const width = enoughHeader ? buffer.readUInt32BE(16) : 0;
+ const height = enoughHeader ? buffer.readUInt32BE(20) : 0;
+ const bitDepth = enoughHeader ? buffer[24] : 0;
+ const colorType = enoughHeader ? buffer[25] : -1;
+ const issues = [];
+
+ if (!validSignature || !enoughHeader) {
+ issues.push('invalid PNG header');
+ } else {
+ if (width !== expectedSize || height !== expectedSize) {
+ issues.push(`expected ${expectedSize}x${expectedSize}, got ${width}x${height}`);
+ }
+ if (bitDepth !== 8 || colorType !== 6) {
+ issues.push(`expected RGBA8, got ${pngColorLabel(colorType, bitDepth)}`);
+ }
+ }
+ if (bytes <= 0) {
+ issues.push('empty file');
+ }
+
+ return { path, bytes, width, height, bitDepth, colorType, issues };
}
-function renderReport({ iconSourceSize, iconTextureSize, itemRows, groupedRows, recommendations, sceneUsage }) {
- const slotCounts = countBy(itemRows, (row) => row.slot);
- const rankCounts = countBy(itemRows, (row) => row.rank);
- const generatedCount = itemRows.filter((row) => row.icon).length;
- const scale = (iconTextureSize / iconSourceSize).toFixed(2);
+function collectAssetRows(catalogRows, directory128, directory32) {
+ const catalogIds = new Set(catalogRows.map((row) => row.itemId));
+ directory128.extraFiles = directory128.files.filter((name) => {
+ const itemId = name.toLowerCase().endsWith('.png') ? name.slice(0, -4) : name;
+ return !catalogIds.has(itemId);
+ });
+ directory32.extraFiles = directory32.files.filter((name) => {
+ const itemId = name.toLowerCase().endsWith('.png') ? name.slice(0, -4) : name;
+ return !catalogIds.has(itemId);
+ });
+
+ return catalogRows.map((catalogRow) => {
+ const icon128 = directory128.assets.get(catalogRow.itemId);
+ const icon32 = directory32.assets.get(catalogRow.itemId);
+ const issues = [];
+ if (!icon128) {
+ issues.push('missing 128px PNG');
+ } else {
+ issues.push(...icon128.issues.map((issue) => `128px ${issue}`));
+ }
+ if (!icon32) {
+ issues.push('missing 32px PNG');
+ } else {
+ issues.push(...icon32.issues.map((issue) => `32px ${issue}`));
+ }
+ if (!icon128 || !icon32) {
+ issues.push('incomplete 128/32 pair');
+ }
+ return { ...catalogRow, icon128, icon32, issues };
+ });
+}
+
+function inspectBootContract(source, catalogIds) {
+ const fallbackIds = [...source.matchAll(/this\.createItemIcon\('item-([^']+)'/g)].map((match) => match[1]);
+ const missingFallbackIds = catalogIds.filter((itemId) => !fallbackIds.includes(itemId));
+ const extraFallbackIds = fallbackIds.filter((itemId) => !catalogIds.includes(itemId));
+ const checks = [
+ {
+ name: '128px source glob',
+ pass: /import\.meta\.glob\('\.\.\/\.\.\/assets\/images\/ui\/equipment-icons\/128\/\*\.png'/.test(source),
+ detail: '`equipment-icons/128/*.png` is imported eagerly'
+ },
+ {
+ name: '32px source glob',
+ pass: /import\.meta\.glob\('\.\.\/\.\.\/assets\/images\/ui\/equipment-icons\/32\/\*\.png'/.test(source),
+ detail: '`equipment-icons/32/*.png` is imported eagerly'
+ },
+ {
+ name: 'full texture preload',
+ pass: /preloadEquipmentIcons\(equipmentIcon128Modules,\s*''\)/.test(source),
+ detail: '128px files load as `item-${id}`'
+ },
+ {
+ name: 'micro texture preload',
+ pass: /preloadEquipmentIcons\(equipmentIcon32Modules,\s*'-micro'\)/.test(source),
+ detail: '32px files load as `item-${id}-micro`'
+ },
+ {
+ name: 'catalog-derived texture key',
+ pass: /this\.load\.image\(`item-\$\{itemId\}\$\{suffix\}`,\s*url\)/.test(source),
+ detail: 'file stem becomes the stable item texture id'
+ },
+ {
+ name: 'procedural fallback guard',
+ pass: /private\s+createItemIcon\([\s\S]{0,260}?if\s*\(this\.textures\.exists\(key\)\)\s*\{\s*return;\s*\}/.test(source),
+ detail: 'procedural full icon is created only when the loaded texture key is absent'
+ },
+ {
+ name: 'procedural fallback coverage',
+ pass: missingFallbackIds.length === 0 && extraFallbackIds.length === 0,
+ detail: `${fallbackIds.length}/${catalogIds.length} catalog ids have a base-key fallback`
+ }
+ ];
+
+ return {
+ fallbackIds,
+ missingFallbackIds,
+ extraFallbackIds,
+ checks,
+ issues: [
+ ...checks.filter((check) => !check.pass).map((check) => `Boot preload contract failed: ${check.name}`),
+ ...missingFallbackIds.map((itemId) => `Boot procedural fallback missing: ${itemId}`),
+ ...extraFallbackIds.map((itemId) => `Boot procedural fallback is uncataloged: ${itemId}`)
+ ]
+ };
+}
+
+function inspectCampUsage(source) {
+ const lines = source.split(/\r?\n/);
+ const usages = [];
+ let currentMethod = '(scene body)';
+
+ lines.forEach((line, index) => {
+ const methodMatch = line.match(/^\s*(?:private|public|protected)\s+([A-Za-z][A-Za-z0-9_]*)\s*\(/);
+ if (methodMatch) {
+ currentMethod = methodMatch[1];
+ }
+ const imageMatch = line.match(/this\.add\.image\(.+?`item-\$\{([^}]+)\}(-micro)?`\)/);
+ if (!imageMatch) {
+ return;
+ }
+
+ const nearbySource = lines.slice(index, index + 7).join(' ');
+ const displayMatch = nearbySource.match(
+ /setDisplaySize\(this\.campUiLength\(([^)]+)\),\s*this\.campUiLength\(([^)]+)\)\)/
+ );
+ usages.push({
+ line: index + 1,
+ method: currentMethod,
+ itemExpression: imageMatch[1],
+ tier: imageMatch[2] ? 'micro' : 'full',
+ displaySize: displayMatch
+ ? displayMatch[1].trim() === displayMatch[2].trim()
+ ? displayMatch[1].trim()
+ : `${displayMatch[1].trim()} x ${displayMatch[2].trim()}`
+ : 'not detected'
+ });
+ });
+
+ const microUsages = usages.filter((usage) => usage.tier === 'micro');
+ const fullUsages = usages.filter((usage) => usage.tier === 'full');
+ const issues = [];
+ if (microUsages.length === 0) {
+ issues.push('CampScene has no `item-${id}-micro` render usage');
+ }
+ if (fullUsages.length === 0) {
+ issues.push('CampScene has no `item-${id}` full render usage');
+ }
+ usages.forEach((usage) => {
+ const numericSize = /^\d+$/.test(usage.displaySize) ? Number(usage.displaySize) : undefined;
+ if (usage.tier === 'micro' && numericSize !== undefined && numericSize > 32) {
+ issues.push(`CampScene line ${usage.line} uses a micro texture at ${numericSize}px`);
+ }
+ if (usage.displaySize === 'not detected') {
+ issues.push(`CampScene line ${usage.line} has no detected square display size`);
+ }
+ });
+
+ return { usages, microUsages, fullUsages, issues };
+}
+
+function renderReport({ catalogRows, assetRows, directory128, directory32, bootAudit, campAudit, issues }) {
+ const pairedCount = assetRows.filter((row) => row.icon128 && row.icon32).length;
+ const validCount = assetRows.filter((row) => row.issues.length === 0).length;
+ const total128Bytes = sumBytes(assetRows.map((row) => row.icon128));
+ const total32Bytes = sumBytes(assetRows.map((row) => row.icon32));
+ const missing128 = assetRows.filter((row) => !row.icon128).map((row) => row.itemId);
+ const missing32 = assetRows.filter((row) => !row.icon32).map((row) => row.itemId);
return `${[
'# Equipment Icon Quality Audit',
'',
- 'Generated by `scripts/audit-equipment-icon-quality.mjs`.',
+ 'Generated by `scripts/audit-equipment-icon-quality.mjs` from the current catalog, PNG headers, and runtime source.',
'',
- '## Summary',
+ '## Result',
'',
- `- Catalog items: ${itemRows.length}`,
- `- Generated procedural item textures: ${generatedCount}`,
- `- Slots: ${formatCounts(slotCounts)}`,
- `- Ranks: ${formatCounts(rankCounts)}`,
- `- Texture size: ${iconTextureSize}px`,
- `- Drawing grid: ${iconSourceSize} units scaled ${scale}x into the texture`,
- `- Runtime item icon scene: \`${sceneUsage.scene}\``,
- `- Observed runtime display sizes: ${sceneUsage.displaySizes.map((size) => `\`${size}px\``).join(', ') || 'none found'}`,
- `- Direct item texture render sites found: ${sceneUsage.directItemImageCount}`,
+ `- Status: **${issues.length === 0 ? 'PASS' : 'FAIL'}**`,
+ `- Catalog contract: ${catalogRows.length}/${expectedCatalogCount} items`,
+ `- Complete 128/32 pairs: ${pairedCount}/${catalogRows.length}`,
+ `- Fully valid catalog rows: ${validCount}/${catalogRows.length}`,
+ `- 128px payload: ${formatBytes(total128Bytes)} across ${directory128.assets.size} PNG files`,
+ `- 32px payload: ${formatBytes(total32Bytes)} across ${directory32.assets.size} PNG files`,
`- Browser visual QA baseline: \`${desktopBrowserViewportLabel}\` CSS viewport at 100% zoom`,
'',
- '## Main Findings',
+ '## Asset Coverage',
'',
- '- Equipment item icons are generated at runtime in `BootScene`, not stored as individual raster images.',
- '- The 64px textures are drawn from a compact 28-unit source grid, so they are crisp but intentionally simple.',
- '- Battle combat preview mostly uses the high-resolution `battle-ui-icons` slot frames and item names; per-item textures appear primarily in camp/sortie equipment UI.',
- '- Treasure/common distinction often relies on UI frame color plus a small palette change, not a strong unique silhouette.',
- '- Reused helper silhouettes are the strongest quality risk because several different items collapse to the same shape at 14-22px.',
+ `- 128px missing: ${formatIds(missing128)}`,
+ `- 32px missing: ${formatIds(missing32)}`,
+ `- 128px uncataloged/extra: ${formatFileNames(directory128.extraFiles)}`,
+ `- 32px uncataloged/extra: ${formatFileNames(directory32.extraFiles)}`,
+ '- Required PNG format: exact square dimensions, 8-bit RGBA (PNG color type 6), non-empty file.',
'',
- ...renderCompletedTargets(recommendations.completedTargets),
- '## Next Improvement Target',
+ '## Boot Loading Contract',
'',
- `- Target: ${recommendations.firstTargetTitle}`,
- `- Item ids: ${formatItemIds(recommendations.firstTargetIds)}`,
- `- Reason: ${recommendations.firstTargetReason}`,
- '',
- ...renderFollowUpTargets(recommendations.followUps),
- '## Highest-Risk Shared Silhouette Groups',
- '',
- '| score | signature | slots | ranks | items |',
- '| ---: | --- | --- | --- | --- |',
- ...recommendations.topRiskGroups.map(
- (group) =>
- `| ${group.score} | \`${group.signature}\` | ${group.slots.join(', ')} | ${group.ranks.join(', ')} | ${group.itemIds.map((itemId) => `\`${itemId}\``).join(', ')} |`
+ '| check | result | contract |',
+ '| --- | --- | --- |',
+ ...bootAudit.checks.map(
+ (check) => `| ${check.name} | ${check.pass ? 'PASS' : 'FAIL'} | ${check.detail} |`
),
'',
- '## Full Item Inventory',
+ 'The authored 128px files are the normal `item-${id}` textures and the authored 32px files are the `item-${id}-micro` textures. The existing procedural 64px drawing path is retained only as a **loaded-base-texture-missing fallback**: its guard leaves a successfully loaded authored base texture untouched. Micro textures are static assets, so their completeness is enforced by this audit rather than by the procedural fallback.',
'',
- '| item id | slot | rank | draw helpers | calls | primitives | colors | concerns |',
- '| --- | --- | --- | --- | ---: | ---: | ---: | --- |',
- ...itemRows.map((row) =>
- [
- `\`${row.itemId}\``,
- row.slot,
- row.rank,
- row.drawFunctions.map((name) => `\`${name}\``).join('
') || 'missing',
- row.drawCalls,
- row.primitiveCount,
- row.colorCount,
- row.concerns.join('
') || 'none'
- ].join(' | ')
- ).map((line) => `| ${line} |`),
+ '## Camp Render Usage',
'',
- ...renderRecommendedNextBatch(recommendations)
+ `- Micro render sites: ${campAudit.microUsages.length}`,
+ `- Full render sites: ${campAudit.fullUsages.length}`,
+ '',
+ '| tier | source location | method | key expression | requested display size |',
+ '| --- | --- | --- | --- | --- |',
+ ...campAudit.usages.map(
+ (usage) =>
+ `| ${usage.tier} | \`${campScenePath}:${usage.line}\` | \`${usage.method}\` | \`item-\${${usage.itemExpression}}${usage.tier === 'micro' ? '-micro' : ''}\` | ${formatDisplaySize(usage.displaySize)} |`
+ ),
+ '',
+ 'Micro textures are used for compact sortie, inventory, and equipped-item rows. Full textures are used for larger equipment cards and the compare/confirm panel.',
+ '',
+ '## Full Catalog Inventory',
+ '',
+ '| item id | slot | rank | 128px PNG | bytes | 32px PNG | bytes | pair | concerns |',
+ '| --- | --- | --- | --- | ---: | --- | ---: | --- | --- |',
+ ...assetRows.map(
+ (row) =>
+ `| \`${row.itemId}\` | ${row.slot} | ${row.rank} | ${formatPng(row.icon128)} | ${formatAssetBytes(row.icon128)} | ${formatPng(row.icon32)} | ${formatAssetBytes(row.icon32)} | ${row.icon128 && row.icon32 ? 'yes' : 'no'} | ${row.issues.join('
') || 'none'} |`
+ ),
+ '',
+ '## Findings',
+ '',
+ ...(issues.length === 0
+ ? [
+ '- All 23 catalog items have valid, paired 128px and 32px RGBA8 PNG assets.',
+ '- File stems map to stable Boot texture keys for both full and micro tiers.',
+ '- Camp compact rows use micro textures while larger cards and comparison panels use full textures.',
+ '- Procedural icons remain a load-failure fallback for base item keys, not the normal rendered asset path.'
+ ]
+ : issues.map((issue) => `- FAIL: ${issue}`)),
+ ''
].join('\n')}`;
}
-function formatItemIds(itemIds) {
- return itemIds.length ? itemIds.map((itemId) => `\`${itemId}\``).join(', ') : 'none currently flagged';
+function pngColorLabel(colorType, bitDepth) {
+ const labels = new Map([
+ [0, 'grayscale'],
+ [2, 'RGB'],
+ [3, 'indexed'],
+ [4, 'grayscale+alpha'],
+ [6, 'RGBA']
+ ]);
+ return `${labels.get(colorType) ?? `color-type-${colorType}`}${bitDepth}`;
}
-function renderRecommendedNextBatch(recommendations) {
- if (!recommendations.firstTargetIds.length) {
- return [
- '## Recommended Next Batch Definition',
- '',
- '- Scope: no generated equipment icon is currently below the audit threshold.',
- '- Item ids: none currently flagged',
- `- Success criteria: ${recommendations.firstTargetSuccessCriteria}`,
- '- Suggested next area: map/background or unit sprite assets that still look low-resolution in gameplay.',
- ''
- ];
- }
-
- return [
- '## Recommended Next Batch Definition',
- '',
- `- Scope: improve ${recommendations.firstTargetTitle} while keeping \`item-\${itemId}\` texture keys stable.`,
- `- Item ids: ${formatItemIds(recommendations.firstTargetIds)}`,
- `- Success criteria: ${recommendations.firstTargetSuccessCriteria}`,
- '- Keep the existing 64px texture size unless a UI-wide equipment icon pass is planned.',
- `- Add before/after contact sheets and verify the sortie equipment list and equipment swap panel in a desktop browser at the ${desktopBrowserViewportLabel} CSS viewport and 100% zoom.`,
- ''
- ];
+function formatPng(asset) {
+ return asset ? `${asset.width}x${asset.height} ${pngColorLabel(asset.colorType, asset.bitDepth)}` : 'missing';
}
-function renderCompletedTargets(completedTargets) {
- if (!completedTargets.length) {
- return [];
- }
-
- return [
- '## Completed Improvement Passes',
- '',
- ...completedTargets.flatMap((target) => [
- `- ${target.title}: ${target.itemIds.map((itemId) => `\`${itemId}\``).join(', ')}`,
- ` - ${target.reason}`
- ]),
- ''
- ];
+function formatAssetBytes(asset) {
+ return asset ? asset.bytes.toLocaleString('en-US') : '-';
}
-function renderFollowUpTargets(followUps) {
- if (!followUps.length) {
- return [];
- }
-
- return [
- '## Follow-Up Targets',
- '',
- ...followUps.flatMap((target) => [
- `- ${target.title}: ${target.itemIds.map((itemId) => `\`${itemId}\``).join(', ')}`,
- ` - ${target.reason}`
- ]),
- ''
- ];
+function formatDisplaySize(size) {
+ return /^\d+$/.test(size) ? `\`${size}px\`` : `\`${size}\``;
}
-function countBy(rows, keySelector) {
- return rows.reduce((counts, row) => {
- const key = keySelector(row);
- counts.set(key, (counts.get(key) ?? 0) + 1);
- return counts;
- }, new Map());
+function sumBytes(assets) {
+ return assets.reduce((total, asset) => total + (asset?.bytes ?? 0), 0);
}
-function formatCounts(counts) {
- return [...counts.entries()]
- .sort(([left], [right]) => String(left).localeCompare(String(right)))
- .map(([key, count]) => `${key} ${count}`)
- .join(', ');
+function formatBytes(bytes) {
+ return `${bytes.toLocaleString('en-US')} B (${(bytes / 1024).toFixed(1)} KiB)`;
+}
+
+function formatIds(ids) {
+ return ids.length ? ids.map((id) => `\`${id}\``).join(', ') : 'none';
+}
+
+function formatFileNames(names) {
+ return names.length ? names.map((name) => `\`${name}\``).join(', ') : 'none';
}
diff --git a/scripts/build-battle-ui-icons.py b/scripts/build-battle-ui-icons.py
index d5696f4..7a93581 100644
--- a/scripts/build-battle-ui-icons.py
+++ b/scripts/build-battle-ui-icons.py
@@ -19,7 +19,8 @@ FRAME = 128
MICRO_FRAME = 32
SCALE = 4
COLS = 5
-ROWS = 6
+SEED_ROWS = 6
+ROWS = 8
SOURCE_FRAMES = {
4: ("attack", "attack-v2.png"),
16: ("hit", "hit-v2.png"),
@@ -29,6 +30,16 @@ SOURCE_FRAMES = {
27: ("bean", "bean-v2.png"),
28: ("salve", "salve-v2.png"),
29: ("wine", "wine-v2.png"),
+ 30: ("confusion", "confusion-v2.png"),
+ 31: ("benevolent-command", "benevolent-command-v2.png"),
+ 32: ("azure-dragon-strike", "azure-dragon-strike-v2.png"),
+ 33: ("changban-roar", "changban-roar-v2.png"),
+ 34: ("single-rider-rescue", "single-rider-rescue-v2.png"),
+ 35: ("east-wind-fire", "east-wind-fire-v2.png"),
+ 36: ("hundred-pace-pierce", "hundred-pace-pierce-v2.png"),
+ 37: ("western-cavalry-charge", "western-cavalry-charge-v2.png"),
+ 38: ("qilin-stratagem", "qilin-stratagem-v2.png"),
+ 39: ("burn", "burn-v2.png"),
}
REPLACEMENT_FRAMES: dict[int, tuple[str, Callable[[Image.Image], Image.Image]]] = {
10: ("axe", lambda atlas: compose_axe_icon(atlas)),
@@ -44,11 +55,12 @@ def main() -> None:
raise FileNotFoundError(f"missing seed atlas: {seed_path}")
seed_atlas = Image.open(seed_path).convert("RGBA")
- expected_size = (COLS * FRAME, ROWS * FRAME)
- if seed_atlas.size != expected_size:
- raise ValueError(f"seed atlas must be {expected_size}, got {seed_atlas.size}: {seed_path}")
+ expected_seed_size = (COLS * FRAME, SEED_ROWS * FRAME)
+ if seed_atlas.size != expected_seed_size:
+ raise ValueError(f"seed atlas must be {expected_seed_size}, got {seed_atlas.size}: {seed_path}")
- atlas = seed_atlas.copy()
+ atlas = Image.new("RGBA", (COLS * FRAME, ROWS * FRAME), (0, 0, 0, 0))
+ atlas.alpha_composite(seed_atlas, (0, 0))
for index, (name, filename) in SOURCE_FRAMES.items():
tile = source_icon(SOURCE_DIR / filename)
@@ -149,10 +161,11 @@ def micro_atlas_frame(atlas: Image.Image, index: int) -> Image.Image:
def write_contact_sheet(seed_atlas: Image.Image, atlas: Image.Image, micro_atlas: Image.Image) -> None:
card_width = 500
card_height = 220
- sheet = Image.new("RGB", (card_width * 4, 40 + card_height * 2), (8, 14, 20))
- draw = ImageDraw.Draw(sheet)
- draw.text((14, 13), "Battle UI V2 action/item icons - before, HQ, runtime sizes", fill=(232, 220, 183))
labels = list(SOURCE_FRAMES.items())
+ sheet_rows = math.ceil(len(labels) / 4)
+ sheet = Image.new("RGB", (card_width * 4, 40 + card_height * sheet_rows), (8, 14, 20))
+ draw = ImageDraw.Draw(sheet)
+ draw.text((14, 13), "Battle UI V2 action/item/signature/status icons - before, HQ, runtime sizes", fill=(232, 220, 183))
runtime_sizes = (14, 20, 30, 34, 52, 70)
for position, (index, (name, _filename)) in enumerate(labels):
diff --git a/scripts/build-equipment-icons.py b/scripts/build-equipment-icons.py
new file mode 100644
index 0000000..106a7a0
--- /dev/null
+++ b/scripts/build-equipment-icons.py
@@ -0,0 +1,61 @@
+from __future__ import annotations
+
+import argparse
+from pathlib import Path
+
+from PIL import Image
+
+
+ROOT = Path(__file__).resolve().parents[1]
+DEFAULT_SOURCE_DIR = ROOT / "tmp" / "imagegen"
+OUTPUT_ROOT = ROOT / "src" / "assets" / "images" / "ui" / "equipment-icons"
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Build centered 128px and 32px equipment icons from transparent source art."
+ )
+ parser.add_argument("ids", nargs="+", help="Equipment ids whose -alpha.png source should be built.")
+ parser.add_argument("--source-dir", type=Path, default=DEFAULT_SOURCE_DIR)
+ return parser.parse_args()
+
+
+def fit_icon(source: Image.Image, size: int) -> Image.Image:
+ rgba = source.convert("RGBA")
+ bounds = rgba.getchannel("A").getbbox()
+ if bounds is None:
+ raise ValueError("source image has no visible pixels")
+
+ cropped = rgba.crop(bounds)
+ inner_size = max(1, round(size * 0.88))
+ scale = min(inner_size / cropped.width, inner_size / cropped.height)
+ resized_size = (
+ max(1, round(cropped.width * scale)),
+ max(1, round(cropped.height * scale)),
+ )
+ resized = cropped.resize(resized_size, Image.Resampling.LANCZOS)
+ canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
+ offset = ((size - resized.width) // 2, (size - resized.height) // 2)
+ canvas.alpha_composite(resized, offset)
+ return canvas
+
+
+def main() -> None:
+ args = parse_args()
+ for size in (128, 32):
+ (OUTPUT_ROOT / str(size)).mkdir(parents=True, exist_ok=True)
+
+ for item_id in args.ids:
+ source_path = args.source_dir / f"{item_id}-alpha.png"
+ if not source_path.exists():
+ raise FileNotFoundError(f"missing transparent source: {source_path}")
+
+ with Image.open(source_path) as source:
+ for size in (128, 32):
+ output_path = OUTPUT_ROOT / str(size) / f"{item_id}.png"
+ fit_icon(source, size).save(output_path, optimize=True)
+ print(f"Wrote {output_path.relative_to(ROOT)}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/measure-performance.mjs b/scripts/measure-performance.mjs
index e1c75a7..0c88665 100644
--- a/scripts/measure-performance.mjs
+++ b/scripts/measure-performance.mjs
@@ -16,13 +16,15 @@ const legacyUiScale = 1.5;
const budgets = {
titleReadyMs: readBudget('PERF_BUDGET_TITLE_READY_MS', 30000),
storyReadyFromNewGameMs: readBudget('PERF_BUDGET_STORY_READY_MS', 30000),
- firstBattleReadyFromStoryMs: readBudget('PERF_BUDGET_BATTLE_READY_MS', 60000),
- firstBattlePlayableFromStoryMs: readBudget('PERF_BUDGET_BATTLE_PLAYABLE_MS', 70000),
+ firstBattleReadyFromStoryMs: readBudget('PERF_BUDGET_BATTLE_READY_MS', 6000),
+ firstBattlePlayableFromStoryMs: readBudget('PERF_BUDGET_BATTLE_PLAYABLE_MS', 25000),
firstLoadEncodedKB: readBudget('PERF_BUDGET_FIRST_LOAD_KB', 10000),
storyTransitionEncodedKB: readBudget('PERF_BUDGET_STORY_TRANSITION_KB', 15000),
+ battleEntryTransitionEncodedKB: readBudget('PERF_BUDGET_BATTLE_ENTRY_KB', 45000),
battleTransitionEncodedKB: readBudget('PERF_BUDGET_BATTLE_TRANSITION_KB', 110000),
storyBattleTransitionEncodedKB: readBudget('PERF_BUDGET_STORY_BATTLE_TRANSITION_KB', 125000),
storyActionSheetRequests: 0,
+ battleEntryActionSheetRequests: 0,
largestJsKB: readBudget('PERF_BUDGET_LARGEST_JS_KB', 2500)
};
@@ -97,26 +99,52 @@ try {
const storyMs = Date.now() - storyStartedAt;
const storyEntries = await resourceEntries(page);
- const battleStartedAt = Date.now();
- await advanceStoryUntilBattle(page, 'first-battle-zhuo-commandery');
+ const battleTransitionStart = await advanceStoryUntilBattle(page, 'first-battle-zhuo-commandery');
+ const battleStartedAt = battleTransitionStart.startedAt;
const battleReadyState = await waitForBattleReady(page, 'first-battle-zhuo-commandery');
- await page.waitForLoadState('networkidle', { timeout: 120000 });
- const battleMs = Date.now() - battleStartedAt;
- const battleEntries = await resourceEntries(page);
+ const battleReadyMs = Date.now() - battleStartedAt;
+ const battleReadyEntries = await resourceEntries(page);
- let firstBattlePlayableMs = battleMs;
+ let firstBattlePlayableMs = battleReadyMs;
+ let battlePlayableState = battleReadyState;
if (battleReadyState.phase === 'deployment') {
- const playableStartedAt = Date.now();
await clickBattleDeploymentStart(page);
- await waitForBattlePlayable(page, 'first-battle-zhuo-commandery');
- firstBattlePlayableMs += Date.now() - playableStartedAt;
+ battlePlayableState = await waitForBattlePlayable(page, 'first-battle-zhuo-commandery');
+ firstBattlePlayableMs = Date.now() - battleStartedAt;
}
+ assert(
+ battleReadyState.combatAssets?.baseReady === true && battleReadyState.combatAssets?.actionReady === false,
+ `Expected deployment to open from base sheets before action sheets: ${JSON.stringify(battleReadyState)}`
+ );
+ assert(
+ battlePlayableState.combatAssets?.status === 'ready' || battlePlayableState.combatAssets?.status === 'degraded',
+ `Expected combat assets to settle before battle play: ${JSON.stringify(battlePlayableState)}`
+ );
+ if (battlePlayableState.combatAssets?.status === 'ready') {
+ assert(
+ battlePlayableState.combatAssets.actionReady === true && battlePlayableState.combatAssets.portraitReady === true,
+ `Expected all deferred combat assets before battle play: ${JSON.stringify(battlePlayableState)}`
+ );
+ }
+ await page.waitForLoadState('networkidle', { timeout: 120000 });
+ const battleEntries = await resourceEntries(page);
+ const combatAssetStallFallback = measureRenderer === 'webgl'
+ ? {
+ action: await verifyCombatAssetStallFallback(browser, targetUrl, 'action'),
+ portrait: await verifyCombatAssetStallFallback(browser, targetUrl, 'portrait')
+ }
+ : null;
const titleNames = new Set(titleEntries.map((entry) => entry.name));
- const storyNames = new Set(storyEntries.map((entry) => entry.name));
+ const preBattleNames = new Set(battleTransitionStart.resourceEntries.map((entry) => entry.name));
+ const battleReadyNames = new Set(battleReadyEntries.map((entry) => entry.name));
const storyOnly = storyEntries.filter((entry) => !titleNames.has(entry.name));
- const battleOnly = battleEntries.filter((entry) => !storyNames.has(entry.name));
+ const battleEntryOnly = battleReadyEntries.filter((entry) => !preBattleNames.has(entry.name));
+ const battleOnly = battleEntries.filter((entry) => !preBattleNames.has(entry.name));
+ const battleDeferredOnly = battleOnly.filter((entry) => !battleReadyNames.has(entry.name));
const storyTransition = segmentStats(storyOnly);
+ const battleEntryTransition = segmentStats(battleEntryOnly);
+ const battleDeferredTransition = segmentStats(battleDeferredOnly);
const battleTransition = segmentStats(battleOnly);
const report = {
targetUrl,
@@ -127,17 +155,26 @@ try {
timingsMs: {
titleReady: titleMs,
storyReadyFromNewGame: storyMs,
- firstBattleReadyFromStory: battleMs,
+ firstBattleReadyFromStory: battleReadyMs,
firstBattlePlayableFromStory: firstBattlePlayableMs
},
build: buildStats(),
firstLoad: segmentStats(titleEntries),
storyTransition,
+ battleEntryTransition,
+ battleDeferredTransition,
battleTransition,
assetPolicy: {
storyActionSheetRequests: actionSheetFiles(storyOnly),
+ battleEntryActionSheetRequests: actionSheetFiles(battleEntryOnly),
+ battleDeferredActionSheetRequests: actionSheetFiles(battleDeferredOnly),
battleActionSheetRequests: actionSheetFiles(battleOnly)
- }
+ },
+ battleAssetStreaming: {
+ entry: battleReadyState.combatAssets,
+ playable: battlePlayableState.combatAssets
+ },
+ combatAssetStallFallback
};
report.budget = evaluateBudgets(report, budgets);
@@ -147,6 +184,8 @@ try {
timingsMs: report.timingsMs,
firstLoad: report.firstLoad.summary,
storyTransition: report.storyTransition.summary,
+ battleEntryTransition: report.battleEntryTransition.summary,
+ battleDeferredTransition: report.battleDeferredTransition.summary,
battleTransition: report.battleTransition.summary,
largestJs: report.build.javascript[0] ?? null,
budget: report.budget.summary
@@ -283,21 +322,36 @@ async function waitForStoryReady(page) {
async function advanceStoryUntilBattle(page, battleId) {
for (let i = 0; i < 48; i += 1) {
- const ready = await page.evaluate((expectedBattleId) => {
+ const state = await page.evaluate((expectedBattleId) => {
try {
- const state = window.__HEROS_DEBUG__?.battle();
- return state?.scene === 'BattleScene' && state?.battleId === expectedBattleId;
+ const battle = window.__HEROS_DEBUG__?.battle();
+ const story = window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.();
+ return {
+ battleReady: battle?.scene === 'BattleScene' && battle?.battleId === expectedBattleId,
+ storyReady: story?.ready === true,
+ storyLastPage: story?.isLastPage === true,
+ storyTargetsBattle: story?.nextScene === 'BattleScene',
+ currentPageReady: story?.assetStreaming?.currentPageReady === true
+ };
} catch {
- return false;
+ return {};
}
}, battleId);
- if (ready) {
- return;
+ if (state.battleReady) {
+ throw new Error('Battle became ready before the transition start could be measured.');
+ }
+ if (state.storyReady && state.storyLastPage && state.storyTargetsBattle && state.currentPageReady) {
+ await page.waitForTimeout(600);
+ const entries = await resourceEntries(page);
+ const startedAt = Date.now();
+ await page.keyboard.press('Space');
+ return { startedAt, resourceEntries: entries };
}
await page.keyboard.press('Space');
await page.waitForTimeout(220);
}
+ throw new Error(`Story did not reach the battle transition for ${battleId}.`);
}
async function waitForBattleReady(page, battleId) {
@@ -309,9 +363,10 @@ async function waitForBattleReady(page, battleId) {
state?.battleId === expectedBattleId &&
(state?.phase === 'deployment' || state?.phase === 'idle') &&
state?.mapBackgroundReady === true &&
+ state?.combatAssets?.baseReady === true &&
state?.resultVisible === false
)
- ? { phase: state.phase }
+ ? { phase: state.phase, combatAssets: state.combatAssets }
: false;
} catch {
return false;
@@ -320,7 +375,7 @@ async function waitForBattleReady(page, battleId) {
}
async function waitForBattlePlayable(page, battleId) {
- await page.waitForFunction((expectedBattleId) => {
+ return page.waitForFunction((expectedBattleId) => {
try {
const state = window.__HEROS_DEBUG__?.battle();
return (
@@ -328,13 +383,130 @@ async function waitForBattlePlayable(page, battleId) {
state?.battleId === expectedBattleId &&
state?.phase === 'idle' &&
state?.mapBackgroundReady === true &&
+ (state?.combatAssets?.status === 'ready' || state?.combatAssets?.status === 'degraded') &&
state?.triggeredBattleEvents?.includes('opening') &&
state?.resultVisible === false
- );
+ )
+ ? { phase: state.phase, combatAssets: state.combatAssets }
+ : false;
} catch {
return false;
}
- }, battleId, { timeout: 30000 });
+ }, battleId, { timeout: 30000 }).then((handle) => handle.jsonValue());
+}
+
+async function verifyCombatAssetStallFallback(browserInstance, baseUrl, kind) {
+ const context = await browserInstance.newContext(desktopBrowserContextOptions);
+ const pageErrors = [];
+ let stalledUrl = null;
+ const stallReleaseMs = 1800;
+ const watchdogMs = 450;
+
+ try {
+ await context.addInitScript(() => window.localStorage.clear());
+ const page = await context.newPage();
+ page.on('pageerror', (error) => pageErrors.push(error.message));
+ await page.route('**/*', async (route) => {
+ const url = route.request().url();
+ const fileName = decodeURIComponent(url.split('/').pop()?.split('?')[0] ?? '');
+ const shouldStall = kind === 'action'
+ ? /^unit-.*-actions-[^.]+\.png$/i.test(fileName)
+ : /^(?:liu-bei|guan-yu|zhang-fei)-[^.]+\.webp$/i.test(fileName);
+ const isSelectedStall = shouldStall && (!stalledUrl || stalledUrl === url);
+ if (isSelectedStall) {
+ stalledUrl ??= url;
+ await delay(stallReleaseMs);
+ await route.abort('timedout');
+ return;
+ }
+ await route.continue();
+ });
+
+ const battleUrl = new URL(baseUrl);
+ battleUrl.searchParams.set('debug', '1');
+ battleUrl.searchParams.set('renderer', 'webgl');
+ battleUrl.searchParams.set('debugBattle', 'first-battle-zhuo-commandery');
+ battleUrl.searchParams.set('debugCombatAssetWatchdogMs', String(watchdogMs));
+ battleUrl.searchParams.set('assetStallQa', kind);
+ await page.goto(battleUrl.toString(), { waitUntil: 'domcontentloaded' });
+ const entry = await waitForBattleReady(page, 'first-battle-zhuo-commandery');
+ assert(
+ entry.phase === 'deployment' && entry.combatAssets?.baseReady === true,
+ `Expected ${kind} stall QA to reach deployment from base sheets: ${JSON.stringify(entry)}`
+ );
+
+ await clickBattleDeploymentStart(page);
+ const playable = await waitForBattlePlayable(page, 'first-battle-zhuo-commandery');
+ const settled = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.combatAssets ?? null);
+ assert(stalledUrl, `Expected ${kind} stall QA to intercept one deferred asset request.`);
+ assert(
+ playable.combatAssets?.status === 'degraded' &&
+ settled?.pendingDeploymentConfirmation === false &&
+ settled?.settlementCount === 1,
+ `Expected ${kind} stall watchdog to settle once and resume pending combat: ${JSON.stringify({ playable, settled, stalledUrl })}`
+ );
+
+ const fallback = await page.evaluate((assetKind) => {
+ const battle = window.__HEROS_DEBUG__?.battle();
+ if (!battle) {
+ return null;
+ }
+ if (assetKind === 'action') {
+ const missing = new Set(battle.combatAssets?.missingActionKeys ?? []);
+ const unitTextures = (battle.units ?? []).map((unit) => ({
+ id: unit.id,
+ textureBase: unit.textureBase,
+ actionTexture: unit.actionTexture
+ }));
+ return {
+ missingKeys: [...missing],
+ fallbackUnitIds: unitTextures
+ .filter((unit) => missing.has(unit.textureBase) && unit.actionTexture === unit.textureBase)
+ .map((unit) => unit.id),
+ unitTextures
+ };
+ }
+ return {
+ missingKeys: [...(battle.combatAssets?.missingPortraitKeys ?? [])],
+ fallbackUnitIds: (battle.units ?? [])
+ .filter((unit) => unit.combatPortraitKey && !unit.combatPortraitReady)
+ .map((unit) => unit.id)
+ };
+ }, kind);
+ assert(
+ fallback?.missingKeys?.length > 0 && fallback?.fallbackUnitIds?.length > 0,
+ `Expected ${kind} stall QA to expose a concrete fallback: ${JSON.stringify(fallback)}`
+ );
+
+ await page.waitForTimeout(stallReleaseMs + 350);
+ const afterLateFailure = await page.evaluate(() => {
+ const battle = window.__HEROS_DEBUG__?.battle();
+ return {
+ phase: battle?.phase ?? null,
+ status: battle?.combatAssets?.status ?? null,
+ pending: battle?.combatAssets?.pendingDeploymentConfirmation ?? null,
+ settlementCount: battle?.combatAssets?.settlementCount ?? null
+ };
+ });
+ assert(
+ afterLateFailure.phase === 'idle' &&
+ afterLateFailure.status === 'degraded' &&
+ afterLateFailure.pending === false &&
+ afterLateFailure.settlementCount === 1,
+ `Expected late ${kind} completion/error callbacks to remain inert: ${JSON.stringify(afterLateFailure)}`
+ );
+ assert(pageErrors.length === 0, `Expected no page errors during ${kind} stall fallback: ${JSON.stringify(pageErrors)}`);
+
+ return {
+ status: afterLateFailure.status,
+ settlementCount: afterLateFailure.settlementCount,
+ missingKeyCount: fallback.missingKeys.length,
+ fallbackUnitIds: fallback.fallbackUnitIds,
+ stalledFile: decodeURIComponent(stalledUrl.split('/').pop()?.split('?')[0] ?? '')
+ };
+ } finally {
+ await context.close();
+ }
}
async function resourceEntries(page) {
@@ -430,9 +602,11 @@ function evaluateBudgets(report, budgetValues) {
['firstBattlePlayableFromStoryMs', report.timingsMs.firstBattlePlayableFromStory, budgetValues.firstBattlePlayableFromStoryMs],
['firstLoadEncodedKB', report.firstLoad.summary.encodedKB, budgetValues.firstLoadEncodedKB],
['storyTransitionEncodedKB', report.storyTransition.summary.encodedKB, budgetValues.storyTransitionEncodedKB],
+ ['battleEntryTransitionEncodedKB', report.battleEntryTransition.summary.encodedKB, budgetValues.battleEntryTransitionEncodedKB],
['battleTransitionEncodedKB', report.battleTransition.summary.encodedKB, budgetValues.battleTransitionEncodedKB],
['storyBattleTransitionEncodedKB', storyBattleTransitionEncodedKB, budgetValues.storyBattleTransitionEncodedKB],
['storyActionSheetRequests', report.assetPolicy.storyActionSheetRequests.length, budgetValues.storyActionSheetRequests],
+ ['battleEntryActionSheetRequests', report.assetPolicy.battleEntryActionSheetRequests.length, budgetValues.battleEntryActionSheetRequests],
['largestJsKB', largestJs, budgetValues.largestJsKB]
];
const failures = checks
diff --git a/scripts/verify-campaign-recruit-data.mjs b/scripts/verify-campaign-recruit-data.mjs
index 3558dc1..a19c6f6 100644
--- a/scripts/verify-campaign-recruit-data.mjs
+++ b/scripts/verify-campaign-recruit-data.mjs
@@ -44,6 +44,7 @@ try {
validateReachability(errors, recruitTemplates, recruitRewardRefs);
validateUniqueRecruitRewards(errors, recruitRewardRefs);
validateSortieRecruitAvailability(errors, scenarioEntries);
+ validateEarlyJianYongChoice(errors, battleScenarios);
if (errors.length) {
console.error(`Campaign recruit data verification failed with ${errors.length} issue(s):`);
@@ -214,6 +215,58 @@ function validateSortieRecruitAvailability(errors, scenarioEntries) {
});
}
+function validateEarlyJianYongChoice(errors, battleScenarios) {
+ const firstBattleId = 'first-battle-zhuo-commandery';
+ const earlyChoiceBattleIds = [
+ 'second-battle-yellow-turban-pursuit',
+ 'third-battle-guangzong-road',
+ 'fourth-battle-guangzong-camp',
+ 'fifth-battle-sishui-vanguard',
+ 'sixth-battle-jieqiao-relief',
+ 'seventh-battle-xuzhou-rescue'
+ ];
+ const foundingTrio = ['liu-bei', 'guan-yu', 'zhang-fei'];
+ const earlyRoster = [...foundingTrio, 'jian-yong'];
+
+ const firstScenario = battleScenarios[firstBattleId];
+ if (!firstScenario?.campaignReward?.recruits?.includes('jian-yong')) {
+ errors.push(`${firstBattleId}: first victory must recruit jian-yong`);
+ }
+ const firstVictoryCopy = firstScenario?.victoryPages?.map((page) => `${page.text ?? ''} ${page.cutscene?.title ?? ''} ${page.cutscene?.subtitle ?? ''}`).join(' ') ?? '';
+ if (!firstVictoryCopy.includes('간옹') || !firstVictoryCopy.includes('최대 3명')) {
+ errors.push(`${firstBattleId}: victory story must explain Jian Yong's arrival and the four-officer/three-slot choice`);
+ }
+
+ earlyChoiceBattleIds.forEach((battleId) => {
+ const scenario = battleScenarios[battleId];
+ if (!scenario) {
+ errors.push(`${battleId}: missing early-choice battle scenario`);
+ return;
+ }
+ const allyIds = new Set(scenario.units.filter((unit) => unit.faction === 'ally').map((unit) => unit.id));
+ earlyRoster.forEach((unitId) => {
+ if (!allyIds.has(unitId)) {
+ errors.push(`${battleId}: early 4-of-3 roster is missing ${unitId}`);
+ }
+ });
+ if (scenario.sortie?.sortieLimit !== 3) {
+ errors.push(`${battleId}: early formation must keep sortieLimit 3, got ${scenario.sortie?.sortieLimit}`);
+ }
+ const recommendedIds = new Set((scenario.sortie?.recommendedUnits ?? []).map((entry) => entry.unitId));
+ if (recommendedIds.size !== foundingTrio.length || foundingTrio.some((unitId) => !recommendedIds.has(unitId))) {
+ errors.push(`${battleId}: recommended formation must remain the founding trio`);
+ }
+ if (!scenario.bonds.some((bond) => bond.id === 'liu-bei__jian-yong')) {
+ errors.push(`${battleId}: missing liu-bei__jian-yong resonance`);
+ }
+ });
+
+ const seventhRecruits = battleScenarios['seventh-battle-xuzhou-rescue']?.campaignReward?.recruits ?? [];
+ if (seventhRecruits.includes('jian-yong') || !seventhRecruits.includes('mi-zhu')) {
+ errors.push('seventh-battle-xuzhou-rescue: must recruit only the new Xuzhou officer Mi Zhu, not Jian Yong again');
+ }
+}
+
function assertNonEmptyString(errors, value, context) {
if (typeof value !== 'string' || value.trim().length === 0) {
errors.push(`${context} must be a non-empty string`);
diff --git a/scripts/verify-campaign-save-normalization.mjs b/scripts/verify-campaign-save-normalization.mjs
index ecc4630..c1b8a00 100644
--- a/scripts/verify-campaign-save-normalization.mjs
+++ b/scripts/verify-campaign-save-normalization.mjs
@@ -644,7 +644,7 @@ try {
const crossBattleResonance = Object.entries(battleScenarios)
.filter(([battleId]) => battleId !== 'first-battle-zhuo-commandery')
.flatMap(([battleId, scenario]) => scenario.bonds.map((bond) => ({ battleId, bond })))
- .find(({ bond }) => bond.level >= 30 && !firstBattleBondIds.has(bond.id));
+ .find(({ bond }) => bond.level >= 30 && bond.id !== 'liu-bei__jian-yong' && !firstBattleBondIds.has(bond.id));
assert(crossBattleResonance, 'Expected campaign scenarios to expose a later-battle-only eligible bond fixture.');
const crossBattleResonanceFixture = {
...coreResonanceFixture,
@@ -693,10 +693,10 @@ try {
);
const reloadedScenarioOnlyResonance = loadCampaignState();
assert(
- normalizedScenarioOnlyResonance.bonds.length === 0 &&
+ !normalizedScenarioOnlyResonance.bonds.some((bond) => bond.id === crossBattleResonance.bond.id) &&
normalizedScenarioOnlyResonance.sortieResonanceSelection?.bondId === crossBattleResonance.bond.id &&
selectedScenarioOnlyResonance.sortieResonanceSelection?.battleId === crossBattleResonance.battleId &&
- reloadedScenarioOnlyResonance.bonds.length === 0 &&
+ !reloadedScenarioOnlyResonance.bonds.some((bond) => bond.id === crossBattleResonance.bond.id) &&
reloadedScenarioOnlyResonance.sortieResonanceSelection?.bondId === crossBattleResonance.bond.id,
`Expected a scenario-owned eligible bond to remain selectable and reload safely before campaign progress contains it: ${JSON.stringify({ normalizedScenarioOnlyResonance, selectedScenarioOnlyResonance, reloadedScenarioOnlyResonance })}`
);
@@ -848,6 +848,87 @@ try {
completedCampVisits: [],
createdAt: '2026-07-03T09:00:00.000Z'
};
+ setFirstBattleReport(firstBattleReport);
+ const preJianYongSave = JSON.parse(storage.get(campaignStorageKey));
+ preJianYongSave.roster = preJianYongSave.roster.filter((unit) => unit.id !== 'jian-yong');
+ preJianYongSave.bonds = preJianYongSave.bonds.filter((bond) => bond.id !== 'liu-bei__jian-yong');
+ preJianYongSave.firstBattleReport.bonds = preJianYongSave.firstBattleReport.bonds.filter((bond) => bond.id !== 'liu-bei__jian-yong');
+ preJianYongSave.firstBattleReport.campaignRewards.recruits = preJianYongSave.firstBattleReport.campaignRewards.recruits.filter((recruit) => recruit.unitId !== 'jian-yong');
+ preJianYongSave.battleHistory[firstScenario.id].bonds = preJianYongSave.battleHistory[firstScenario.id].bonds.filter((bond) => bond.id !== 'liu-bei__jian-yong');
+ preJianYongSave.battleHistory[firstScenario.id].campaignRewards.recruits = preJianYongSave.battleHistory[firstScenario.id].campaignRewards.recruits.filter((recruit) => recruit.unitId !== 'jian-yong');
+ storage.set(campaignStorageKey, JSON.stringify(preJianYongSave));
+ const repairedEarlyChoiceSave = loadCampaignState();
+ const persistedEarlyChoiceSave = saveCampaignState(repairedEarlyChoiceSave);
+ assert(
+ persistedEarlyChoiceSave.roster.filter((unit) => unit.id === 'jian-yong').length === 1 &&
+ persistedEarlyChoiceSave.bonds.filter((bond) => bond.id === 'liu-bei__jian-yong').length === 1 &&
+ persistedEarlyChoiceSave.firstBattleReport?.bonds.filter((bond) => bond.id === 'liu-bei__jian-yong').length === 1 &&
+ persistedEarlyChoiceSave.firstBattleReport?.campaignRewards?.recruits.filter((recruit) => recruit.unitId === 'jian-yong').length === 1 &&
+ persistedEarlyChoiceSave.battleHistory[firstScenario.id]?.bonds.filter((bond) => bond.id === 'liu-bei__jian-yong').length === 1 &&
+ persistedEarlyChoiceSave.battleHistory[firstScenario.id]?.campaignRewards?.recruits.filter((recruit) => recruit.unitId === 'jian-yong').length === 1,
+ `Expected pre-Jian-Yong first-victory saves to backfill one roster member, reward, and resonance everywhere: ${JSON.stringify(persistedEarlyChoiceSave)}`
+ );
+
+ resetCampaignState();
+ const preservedGlobalBondCount = 157;
+ const preservedGlobalBonds = Array.from({ length: preservedGlobalBondCount }, (_, index) => ({
+ id: `legacy-global-bond-${index}`,
+ unitIds: ['liu-bei', 'guan-yu'],
+ title: `Legacy global bond ${index}`,
+ level: 1,
+ exp: index,
+ description: `Late-campaign global bond fixture ${index}`,
+ battleExp: 0
+ }));
+ const rewardRecruitLimit = 128;
+ const preservedRewardRecruits = Array.from({ length: rewardRecruitLimit }, (_, index) => ({
+ unitId: `legacy-reward-recruit-${index}`,
+ name: `Legacy reward recruit ${index}`
+ }));
+ const largePreJianYongSave = JSON.parse(JSON.stringify(preJianYongSave));
+ largePreJianYongSave.bonds = preservedGlobalBonds;
+ largePreJianYongSave.firstBattleReport.bonds = preservedGlobalBonds.slice(0, 96);
+ largePreJianYongSave.battleHistory[firstScenario.id].bonds = preservedGlobalBonds.slice(0, 96);
+ largePreJianYongSave.firstBattleReport.campaignRewards.recruits = preservedRewardRecruits;
+ largePreJianYongSave.battleHistory[firstScenario.id].campaignRewards.recruits = preservedRewardRecruits;
+ const originalFirstScenarioUnitCount = firstScenario.units.length;
+ firstScenario.units.push(...preservedRewardRecruits.map((recruit) => ({ id: recruit.unitId, faction: 'ally' })));
+ let repairedLargeEarlyChoiceSave;
+ try {
+ storage.set(campaignStorageKey, JSON.stringify(largePreJianYongSave));
+ repairedLargeEarlyChoiceSave = saveCampaignState(loadCampaignState());
+ } finally {
+ firstScenario.units.splice(originalFirstScenarioUnitCount);
+ }
+ const repairedLargeGlobalBondIds = new Set(repairedLargeEarlyChoiceSave.bonds.map((bond) => bond.id));
+ assert(
+ repairedLargeEarlyChoiceSave.bonds.length === preservedGlobalBondCount + 1 &&
+ preservedGlobalBonds.every((bond) => repairedLargeGlobalBondIds.has(bond.id)) &&
+ repairedLargeEarlyChoiceSave.bonds.filter((bond) => bond.id === 'liu-bei__jian-yong').length === 1,
+ `Expected all ${preservedGlobalBondCount} late-campaign global bonds to survive Jian Yong backfill: ${JSON.stringify(repairedLargeEarlyChoiceSave.bonds)}`
+ );
+ assert(
+ repairedLargeEarlyChoiceSave.firstBattleReport?.bonds.length === 96 &&
+ repairedLargeEarlyChoiceSave.firstBattleReport.bonds.some((bond) => bond.id === 'liu-bei__jian-yong') &&
+ repairedLargeEarlyChoiceSave.battleHistory[firstScenario.id]?.bonds.length === 96 &&
+ repairedLargeEarlyChoiceSave.battleHistory[firstScenario.id].bonds.some((bond) => bond.id === 'liu-bei__jian-yong'),
+ `Expected report and settlement bond snapshots to retain their 96-entry cap while adding Jian Yong: ${JSON.stringify({ report: repairedLargeEarlyChoiceSave.firstBattleReport?.bonds, settlement: repairedLargeEarlyChoiceSave.battleHistory[firstScenario.id]?.bonds })}`
+ );
+ const repairedReportRecruits = repairedLargeEarlyChoiceSave.firstBattleReport?.campaignRewards?.recruits ?? [];
+ const repairedSettlementRecruits = repairedLargeEarlyChoiceSave.battleHistory[firstScenario.id]?.campaignRewards?.recruits ?? [];
+ const preservedRewardRecruitIds = new Set(preservedRewardRecruits.slice(0, rewardRecruitLimit - 1).map((recruit) => recruit.unitId));
+ assert(
+ repairedReportRecruits.length === rewardRecruitLimit &&
+ repairedSettlementRecruits.length === rewardRecruitLimit &&
+ repairedReportRecruits.filter((recruit) => recruit.unitId === 'jian-yong').length === 1 &&
+ repairedSettlementRecruits.filter((recruit) => recruit.unitId === 'jian-yong').length === 1 &&
+ [...preservedRewardRecruitIds].every((unitId) => repairedReportRecruits.some((recruit) => recruit.unitId === unitId)) &&
+ [...preservedRewardRecruitIds].every((unitId) => repairedSettlementRecruits.some((recruit) => recruit.unitId === unitId)) &&
+ !repairedReportRecruits.some((recruit) => recruit.unitId === preservedRewardRecruits[rewardRecruitLimit - 1].unitId) &&
+ !repairedSettlementRecruits.some((recruit) => recruit.unitId === preservedRewardRecruits[rewardRecruitLimit - 1].unitId),
+ `Expected 128-entry reward recruit snapshots to preserve 127 valid entries and append required Jian Yong without exceeding the schema cap: ${JSON.stringify({ report: repairedReportRecruits, settlement: repairedSettlementRecruits })}`
+ );
+ resetCampaignState();
const performanceUnit = alliedFirstBattleUnits.find((unit) => unit.id === 'liu-bei') ?? alliedFirstBattleUnits[0];
const recommendedButNotDeployedUnit = alliedFirstBattleUnits.find((unit) => unit.id !== performanceUnit?.id);
assert(performanceUnit, 'Expected an allied first-battle unit for sortie performance clone checks');
@@ -971,10 +1052,10 @@ try {
);
const individualizedReserveBond = individualizedReserveTraining.bonds.find((bond) => bond.id === 'reserve-drill-bond');
assert(
- individualizedReserveAward?.focusId === 'bond-practice' &&
+ individualizedReserveAward?.focusId === 'bond-practice' &&
individualizedReserveAward.expGained === 5 &&
individualizedReserveAward.equipmentExpGained === 1 &&
- individualizedReserveAward.bondExpGained === 4 &&
+ individualizedReserveAward.bondExpGained === 8 &&
individualizedReserveBond?.exp === 4,
`Expected per-officer reserve drill assignments to override the global focus and apply bond growth during battle settlement: ${JSON.stringify(individualizedReserveTraining)}`
);
@@ -1754,8 +1835,10 @@ try {
);
const staleReportRefs = loadCampaignState();
assert(
- staleReportRefs.firstBattleReport?.bonds.length === 1 &&
- staleReportRefs.firstBattleReport.bonds[0].id === 'liu-bei__guan-yu' &&
+ staleReportRefs.firstBattleReport?.bonds.length === 2 &&
+ staleReportRefs.firstBattleReport.bonds.some((bond) => bond.id === 'liu-bei__guan-yu') &&
+ staleReportRefs.firstBattleReport.bonds.some((bond) => bond.id === 'liu-bei__jian-yong') &&
+ !staleReportRefs.firstBattleReport.bonds.some((bond) => bond.id === 'liu-bei__ghost') &&
staleReportRefs.firstBattleReport.mvp === undefined &&
JSON.stringify(staleReportRefs.firstBattleReport.sortiePerformance?.selectedUnitIds) === JSON.stringify(['liu-bei']) &&
JSON.stringify(staleReportRefs.firstBattleReport.sortiePerformance?.formationAssignments) === JSON.stringify({ 'liu-bei': 'front' }) &&
@@ -2015,10 +2098,11 @@ try {
malformedReport.firstBattleReport.campaignRewards?.supplies[0] === 'Bean' &&
malformedReport.firstBattleReport.campaignRewards?.equipment.length === 1 &&
malformedReport.firstBattleReport.campaignRewards?.reputation.length === 1 &&
- malformedReport.firstBattleReport.campaignRewards?.recruits.length === 1 &&
+ malformedReport.firstBattleReport.campaignRewards?.recruits.length === 2 &&
malformedReport.firstBattleReport.campaignRewards?.recruits[0].unitId === 'guan-yu' &&
malformedReport.firstBattleReport.campaignRewards?.recruits[0].name === 'Guan Yu' &&
malformedReport.firstBattleReport.campaignRewards?.recruits.filter((recruit) => recruit.unitId === 'guan-yu').length === 1 &&
+ malformedReport.firstBattleReport.campaignRewards?.recruits.filter((recruit) => recruit.unitId === 'jian-yong').length === 1 &&
malformedReport.firstBattleReport.campaignRewards?.unlocks.length === 1 &&
malformedReport.firstBattleReport.campaignRewards?.unlocks[0].battleId === 'second-battle-yellow-turban-pursuit' &&
malformedReport.firstBattleReport.campaignRewards?.unlocks[0].title === 'Next' &&
@@ -2259,8 +2343,9 @@ try {
assert(
malformedHistory.battleHistory['first-battle-zhuo-commandery']?.itemRewards.length === 1 &&
malformedHistory.battleHistory['first-battle-zhuo-commandery']?.campaignRewards?.equipment[0] === 'Iron Sword' &&
- malformedHistory.battleHistory['first-battle-zhuo-commandery']?.campaignRewards?.recruits.length === 1 &&
+ malformedHistory.battleHistory['first-battle-zhuo-commandery']?.campaignRewards?.recruits.length === 2 &&
malformedHistory.battleHistory['first-battle-zhuo-commandery']?.campaignRewards?.recruits[0].unitId === 'guan-yu' &&
+ malformedHistory.battleHistory['first-battle-zhuo-commandery']?.campaignRewards?.recruits.some((recruit) => recruit.unitId === 'jian-yong') &&
malformedHistory.battleHistory['first-battle-zhuo-commandery']?.campaignRewards?.unlocks.length === 1 &&
malformedHistory.battleHistory['first-battle-zhuo-commandery']?.campaignRewards?.unlocks[0].battleId === 'second-battle-yellow-turban-pursuit' &&
malformedHistory.battleHistory['first-battle-zhuo-commandery']?.campaignRewards?.note === undefined,
@@ -2283,10 +2368,11 @@ try {
malformedHistory.battleHistory['first-battle-zhuo-commandery']?.units[0].equipment.weapon.itemId === 'training-sword' &&
malformedHistory.battleHistory['first-battle-zhuo-commandery']?.units[0].equipment.armor.itemId === 'cloth-armor' &&
malformedHistory.battleHistory['first-battle-zhuo-commandery']?.units[0].equipment.accessory.itemId === 'grain-pouch' &&
- malformedHistory.battleHistory['first-battle-zhuo-commandery']?.bonds.length === 1 &&
+ malformedHistory.battleHistory['first-battle-zhuo-commandery']?.bonds.length === 2 &&
malformedHistory.battleHistory['first-battle-zhuo-commandery']?.bonds[0].level === 1 &&
malformedHistory.battleHistory['first-battle-zhuo-commandery']?.bonds[0].exp === 99 &&
malformedHistory.battleHistory['first-battle-zhuo-commandery']?.bonds[0].battleExp === 4 &&
+ malformedHistory.battleHistory['first-battle-zhuo-commandery']?.bonds.some((bond) => bond.id === 'liu-bei__jian-yong') &&
malformedHistory.battleHistory['first-battle-zhuo-commandery']?.reserveTraining?.length === 1 &&
malformedHistory.battleHistory['first-battle-zhuo-commandery']?.reserveTraining?.[0].focusId === 'balanced',
`Expected nested battle history progress arrays to filter invalid entries and normalize numbers: ${JSON.stringify(malformedHistory)}`
diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs
index 8952c78..9ea17e6 100644
--- a/scripts/verify-flow.mjs
+++ b/scripts/verify-flow.mjs
@@ -94,6 +94,49 @@ async function clickBattleDeploymentStart(page) {
await page.mouse.click(point.x, point.y);
}
+async function clickSceneBounds(page, sceneKey, bounds, options) {
+ const point = await page.evaluate(({ requestedSceneKey, logicalBounds }) => {
+ const scene = window.__HEROS_GAME__?.scene.getScene(requestedSceneKey);
+ const canvas = document.querySelector('canvas');
+ const canvasBounds = canvas?.getBoundingClientRect();
+ if (!scene || !canvas || !canvasBounds || !logicalBounds) {
+ return null;
+ }
+ return {
+ x: canvasBounds.left + (logicalBounds.x + logicalBounds.width / 2) * canvasBounds.width / scene.scale.width,
+ y: canvasBounds.top + (logicalBounds.y + logicalBounds.height / 2) * canvasBounds.height / scene.scale.height
+ };
+ }, { requestedSceneKey: sceneKey, logicalBounds: bounds });
+ if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) {
+ throw new Error(`Expected clickable battle bounds: ${JSON.stringify(bounds)}`);
+ }
+ await page.mouse.click(point.x, point.y, options);
+}
+
+async function clickBattleBounds(page, bounds, options) {
+ await clickSceneBounds(page, 'BattleScene', bounds, options);
+}
+
+function boundsOverlap(left, right) {
+ if (!left || !right) {
+ return false;
+ }
+ return !(
+ left.x + left.width <= right.x ||
+ right.x + right.width <= left.x ||
+ left.y + left.height <= right.y ||
+ right.y + right.height <= left.y
+ );
+}
+
+function hasExactUnitIds(actual, expected) {
+ return (
+ Array.isArray(actual) &&
+ actual.length === expected.length &&
+ expected.every((unitId) => actual.includes(unitId))
+ );
+}
+
async function openBattleMapMenu(page) {
const point = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
@@ -263,9 +306,9 @@ try {
const page = await browser.newPage(desktopBrowserContextOptions);
installLegacyUiClickScaling(page);
- await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
+ await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 90000 });
await page.evaluate(() => window.localStorage.clear());
- await page.reload({ waitUntil: 'domcontentloaded' });
+ await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
await page.waitForFunction(() => document.querySelector('canvas') !== null, undefined, { timeout: 90000 });
await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined, undefined, { timeout: 90000 });
await page.waitForFunction(() => window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 });
@@ -315,6 +358,258 @@ try {
await startDeploymentIfNeeded(page, 'first-battle-zhuo-commandery');
+ await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial?.active === true, undefined, { timeout: 15000 });
+ const tutorialStart = await page.evaluate(() => {
+ const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
+ const state = window.__HEROS_DEBUG__?.battle();
+ const tutorial = state?.firstBattleTutorial;
+ const canvas = document.querySelector('canvas');
+ const path = tutorial?.path;
+ const tileBounds = path && scene
+ ? [path.from, path.moveTile, path.targetTile].map((tile) => ({
+ x: scene.tileTopLeftX(tile.x),
+ y: scene.tileTopLeftY(tile.y),
+ width: scene.layout.tileSize,
+ height: scene.layout.tileSize
+ }))
+ : [];
+ return {
+ tutorial,
+ tileBounds,
+ viewport: { width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio },
+ canvas: { width: canvas?.width ?? 0, height: canvas?.height ?? 0 },
+ wrongAllyBounds: (() => {
+ const wrong = state?.units?.find((unit) => unit.faction === 'ally' && unit.id !== path?.unitId && unit.hp > 0);
+ return wrong && scene
+ ? { x: scene.tileTopLeftX(wrong.x), y: scene.tileTopLeftY(wrong.y), width: scene.layout.tileSize, height: scene.layout.tileSize }
+ : null;
+ })()
+ };
+ });
+ if (
+ tutorialStart.viewport.width !== 1920 ||
+ tutorialStart.viewport.height !== 1080 ||
+ tutorialStart.viewport.dpr !== 1 ||
+ tutorialStart.canvas.width !== 1920 ||
+ tutorialStart.canvas.height !== 1080
+ ) {
+ throw new Error(`First-battle tutorial must run at 1920x1080 DPR1: ${JSON.stringify(tutorialStart)}`);
+ }
+ if (
+ tutorialStart.tutorial?.step !== 'select-unit' ||
+ tutorialStart.tutorial.stepNumber !== 1 ||
+ tutorialStart.tutorial.totalSteps !== 4 ||
+ !tutorialStart.tutorial.path?.unitId ||
+ !tutorialStart.tutorial.path?.targetId ||
+ !tutorialStart.tutorial.cardBounds ||
+ !tutorialStart.tutorial.highlightBounds ||
+ !tutorialStart.tutorial.skipBounds ||
+ tutorialStart.tileBounds.some((bounds) => boundsOverlap(tutorialStart.tutorial.cardBounds, bounds)) ||
+ boundsOverlap(tutorialStart.tutorial.cardBounds, tutorialStart.tutorial.highlightBounds)
+ ) {
+ throw new Error(`Expected unobstructed 1/4 tutorial card, path, highlight, and skip bounds: ${JSON.stringify(tutorialStart)}`);
+ }
+ if (!tutorialStart.wrongAllyBounds) {
+ throw new Error(`Expected a non-recommended ally for tutorial wrong-click regression: ${JSON.stringify(tutorialStart)}`);
+ }
+
+ await clickBattleBounds(page, tutorialStart.wrongAllyBounds);
+ await page.waitForFunction(() => {
+ const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial;
+ return tutorial?.step === 'select-unit' && tutorial?.feedback?.length > 0;
+ });
+ let tutorialState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial);
+ await clickBattleBounds(page, tutorialState.highlightBounds);
+ await page.waitForFunction(() => {
+ const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial;
+ return tutorial?.step === 'move' && tutorial?.feedback === '';
+ });
+
+ const wrongMoveBounds = await page.evaluate(() => {
+ const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
+ const path = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial?.path;
+ return scene && path
+ ? { x: scene.tileTopLeftX(path.from.x), y: scene.tileTopLeftY(path.from.y), width: scene.layout.tileSize, height: scene.layout.tileSize }
+ : null;
+ });
+ await clickBattleBounds(page, wrongMoveBounds);
+ await page.waitForFunction(() => {
+ const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial;
+ return tutorial?.step === 'move' && tutorial?.feedback?.length > 0;
+ });
+ tutorialState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial);
+ await clickBattleBounds(page, tutorialState.highlightBounds);
+ await page.waitForFunction(() => {
+ const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial;
+ return tutorial?.step === 'attack-command' && tutorial?.feedback === '';
+ });
+
+ let attackStep = await page.evaluate(() => {
+ const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
+ const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial;
+ const waitBounds = scene?.commandButtons?.find((button) => button.command === 'wait')?.background?.getBounds?.();
+ return {
+ tutorial,
+ waitBounds: waitBounds ? { x: waitBounds.x, y: waitBounds.y, width: waitBounds.width, height: waitBounds.height } : null
+ };
+ });
+ if (
+ !attackStep.waitBounds ||
+ !attackStep.tutorial?.highlightBounds ||
+ boundsOverlap(attackStep.tutorial.cardBounds, attackStep.tutorial.highlightBounds)
+ ) {
+ throw new Error(`Expected unobstructed attack command highlight and a wrong-command target: ${JSON.stringify(attackStep)}`);
+ }
+ await clickBattleBounds(page, attackStep.tutorial.highlightBounds, { button: 'right' });
+ await page.waitForFunction(() => {
+ const battle = window.__HEROS_DEBUG__?.battle();
+ return battle?.firstBattleTutorial?.step === 'move' && battle?.phase === 'moving';
+ });
+ await page.waitForFunction(() => {
+ const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
+ const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial;
+ const unitId = tutorial?.path?.unitId;
+ const highlight = tutorial?.highlightBounds;
+ const hitZone = unitId ? scene?.unitViews?.get?.(unitId)?.hitZone : null;
+ if (!highlight || !hitZone?.active) {
+ return true;
+ }
+ return !hitZone.getBounds().contains(
+ highlight.x + highlight.width / 2,
+ highlight.y + highlight.height / 2
+ );
+ });
+ tutorialState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial);
+ await clickBattleBounds(page, tutorialState.highlightBounds);
+ await page.waitForFunction(() => {
+ const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial;
+ return tutorial?.step === 'attack-command' && tutorial?.feedback === '';
+ });
+ attackStep = await page.evaluate(() => {
+ const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
+ const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial;
+ const waitBounds = scene?.commandButtons?.find((button) => button.command === 'wait')?.background?.getBounds?.();
+ return {
+ tutorial,
+ waitBounds: waitBounds ? { x: waitBounds.x, y: waitBounds.y, width: waitBounds.width, height: waitBounds.height } : null
+ };
+ });
+ await clickBattleBounds(page, attackStep.waitBounds);
+ await page.waitForFunction(() => {
+ const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial;
+ return tutorial?.step === 'attack-command' && tutorial?.feedback?.length > 0;
+ });
+ tutorialState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial);
+ await clickBattleBounds(page, tutorialState.highlightBounds);
+ await page.waitForFunction(() => {
+ const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial;
+ return tutorial?.step === 'target-preview' && tutorial?.feedback === '';
+ });
+ tutorialState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial);
+ await clickBattleBounds(page, tutorialState.highlightBounds, { button: 'right' });
+ await page.waitForFunction(() => {
+ const battle = window.__HEROS_DEBUG__?.battle();
+ return battle?.firstBattleTutorial?.step === 'attack-command' && battle?.phase === 'command';
+ });
+ tutorialState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial);
+ await clickBattleBounds(page, tutorialState.highlightBounds);
+ await page.waitForFunction(() => {
+ const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial;
+ return tutorial?.step === 'target-preview' && tutorial?.feedback === '';
+ });
+
+ const emptyTutorialTileBounds = await page.evaluate(() => {
+ const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
+ const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial;
+ if (!scene || !tutorial?.cardBounds) {
+ return null;
+ }
+ for (let row = 0; row < scene.layout.visibleRows; row += 1) {
+ for (let column = 0; column < scene.layout.visibleColumns; column += 1) {
+ const x = scene.cameraTileX + column;
+ const y = scene.cameraTileY + row;
+ if (!scene.isInBounds(x, y) || scene.isOccupied(x, y)) {
+ continue;
+ }
+ const bounds = {
+ x: scene.tileTopLeftX(x),
+ y: scene.tileTopLeftY(y),
+ width: scene.layout.tileSize,
+ height: scene.layout.tileSize
+ };
+ const card = tutorial.cardBounds;
+ const overlaps = !(
+ bounds.x + bounds.width <= card.x ||
+ card.x + card.width <= bounds.x ||
+ bounds.y + bounds.height <= card.y ||
+ card.y + card.height <= bounds.y
+ );
+ if (!overlaps) {
+ return bounds;
+ }
+ }
+ }
+ return null;
+ });
+ await clickBattleBounds(page, emptyTutorialTileBounds);
+ await page.waitForFunction(() => {
+ const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial;
+ return tutorial?.step === 'target-preview' && tutorial?.targetPreviewLocked === false && tutorial?.feedback?.length > 0;
+ });
+
+ tutorialState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial);
+ if (boundsOverlap(tutorialState.cardBounds, tutorialState.highlightBounds)) {
+ throw new Error(`Tutorial card obscured the recommended target: ${JSON.stringify(tutorialState)}`);
+ }
+ await clickBattleBounds(page, tutorialState.highlightBounds);
+ await page.waitForFunction(() => {
+ const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial;
+ return tutorial?.step === 'target-preview' && tutorial?.targetPreviewLocked === true;
+ });
+ tutorialState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial);
+ await clickBattleBounds(page, tutorialState.highlightBounds);
+ await page.waitForFunction(() => {
+ const battle = window.__HEROS_DEBUG__?.battle();
+ const campaign = JSON.parse(window.localStorage.getItem('heros-web:campaign-state') ?? '{}');
+ const slot = JSON.parse(window.localStorage.getItem('heros-web:campaign-state:slot-1') ?? '{}');
+ return (
+ battle?.firstBattleTutorial?.active === false &&
+ battle?.firstBattleTutorial?.completed === true &&
+ campaign.completedTutorialIds?.includes('first-battle-basic-controls') &&
+ slot.completedTutorialIds?.includes('first-battle-basic-controls')
+ );
+ }, undefined, { timeout: 30000 });
+ await page.waitForFunction((expectedUnitId) => {
+ const battle = window.__HEROS_DEBUG__?.battle();
+ return battle?.phase !== 'targeting' && battle?.selectedUnitId === expectedUnitId;
+ }, tutorialState.path.unitId, { timeout: 30000 });
+ await page.waitForTimeout(500);
+
+ await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
+ await page.waitForFunction(() => {
+ const activeScenes = window.__HEROS_DEBUG__?.activeScenes?.() ?? [];
+ return window.__HEROS_GAME__ && activeScenes.includes('TitleScene');
+ }, undefined, { timeout: 90000 });
+ await page.evaluate(() => {
+ window.__HEROS_GAME__?.scene.getScene('TitleScene')?.continueGame?.(1);
+ });
+ await page.waitForFunction(() => {
+ const state = window.__HEROS_DEBUG__?.battle();
+ const activeScenes = window.__HEROS_DEBUG__?.activeScenes?.() ?? [];
+ return activeScenes.includes('BattleScene') && state?.battleId === 'first-battle-zhuo-commandery' && ['deployment', 'idle'].includes(state?.phase) && state?.mapBackgroundReady;
+ }, undefined, { timeout: 90000 });
+ await startDeploymentIfNeeded(page, 'first-battle-zhuo-commandery');
+ await page.waitForTimeout(4300);
+ const tutorialReentryState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
+ if (
+ tutorialReentryState?.firstBattleTutorial?.active !== false ||
+ tutorialReentryState.firstBattleTutorial.completed !== true ||
+ tutorialReentryState.firstBattleTutorial.cardBounds !== null ||
+ tutorialReentryState.battleLog.some((line) => line.includes('첫 행동 ·'))
+ ) {
+ throw new Error(`Completed tutorial should not reappear or duplicate the legacy one-line guide: ${JSON.stringify(tutorialReentryState)}`);
+ }
+
await page.keyboard.press('F9');
await page.waitForTimeout(100);
await page.screenshot({ path: 'dist/verification-battle.png', fullPage: true });
@@ -661,7 +956,7 @@ try {
await page.screenshot({ path: 'dist/verification-battle-result-victory.png', fullPage: true });
await page.mouse.click(738, 642);
- await waitForCampAfterBattleResult(page);
+ await waitForCampAfterBattleResult(page, { preserveArrivalReward: true });
await page.screenshot({ path: 'dist/verification-camp.png', fullPage: true });
const campState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
@@ -675,6 +970,19 @@ try {
) {
throw new Error(`Expected first camp to expose first-battle dialogue set: ${JSON.stringify(campState)}`);
}
+ const arrivalSupplyAction = campState.victoryRewardAcknowledgement?.actions?.find((action) => action.id === 'supplies');
+ if (
+ campState.victoryRewardAcknowledgement?.visible !== true ||
+ !arrivalSupplyAction?.interactive ||
+ !arrivalSupplyAction.bounds
+ ) {
+ throw new Error(`Expected first camp arrival reward modal with an interactive supplies deep link: ${JSON.stringify(campState.victoryRewardAcknowledgement)}`);
+ }
+ await clickSceneBounds(page, 'CampScene', arrivalSupplyAction.bounds);
+ await page.waitForFunction(() => {
+ const camp = window.__HEROS_DEBUG__?.camp();
+ return camp?.victoryRewardAcknowledgement?.visible === false && camp?.activeTab === 'supplies';
+ }, undefined, { timeout: 30000 });
await page.evaluate(() => {
const campScene = window.__HEROS_GAME__?.scene.getScene('CampScene');
@@ -757,6 +1065,93 @@ try {
throw new Error(`Expected sortie preparation overlay from next story button: ${JSON.stringify(campStateWithSortiePrep)}`);
}
assertSortieTacticalRoster(campStateWithSortiePrep, ['liu-bei', 'guan-yu', 'zhang-fei']);
+ const foundingSortieUnitIds = ['liu-bei', 'guan-yu', 'zhang-fei'];
+ const jianYongSortieUnitIds = ['liu-bei', 'zhang-fei', 'jian-yong'];
+ const firstFormationState = await ensureSortiePrepStep(page, 'formation');
+ const firstFormationRosterIds = firstFormationState.sortieRoster?.map((unit) => unit.id) ?? [];
+ const firstFormationJianYong = firstFormationState.sortieRoster?.find((unit) => unit.id === 'jian-yong');
+ if (
+ firstFormationState.sortieRoster?.length !== 4 ||
+ firstFormationState.sortieSelectionLimit !== 3 ||
+ !hasExactUnitIds(firstFormationRosterIds, [...foundingSortieUnitIds, 'jian-yong']) ||
+ !hasExactUnitIds(firstFormationState.selectedSortieUnitIds, foundingSortieUnitIds) ||
+ firstFormationJianYong?.available !== true ||
+ firstFormationJianYong.selected !== false
+ ) {
+ throw new Error(`Expected first victory to expose an available, initially unselected Jian Yong in a 4-of-3 roster: ${JSON.stringify(firstFormationState)}`);
+ }
+
+ let firstFormationOrderState = firstFormationState;
+ if (firstFormationOrderState.sortieOperationOrder?.required && !firstFormationOrderState.sortieOperationOrder.complete) {
+ const eliteOrderCard = firstFormationOrderState.sortieOperationOrder.cards?.find((card) => card.id === 'elite');
+ if (!eliteOrderCard?.cardBounds) {
+ throw new Error(`Expected an interactive first-sortie operation order card: ${JSON.stringify(firstFormationOrderState.sortieOperationOrder)}`);
+ }
+ await clickSceneBounds(page, 'CampScene', eliteOrderCard.cardBounds);
+ await page.waitForFunction(() => {
+ const order = window.__HEROS_DEBUG__?.camp()?.sortieOperationOrder;
+ return order?.selectedId === 'elite' && order?.complete === true;
+ }, undefined, { timeout: 30000 });
+ firstFormationOrderState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
+ }
+ if (firstFormationOrderState.sortieOperationOrder?.open) {
+ const closeBounds = firstFormationOrderState.sortieOperationOrder.closeButtonBounds;
+ if (!closeBounds) {
+ throw new Error(`Expected the first-sortie operation order browser to expose a close button: ${JSON.stringify(firstFormationOrderState.sortieOperationOrder)}`);
+ }
+ await clickSceneBounds(page, 'CampScene', closeBounds);
+ await page.waitForFunction(() => {
+ const camp = window.__HEROS_DEBUG__?.camp();
+ return camp?.sortieOperationOrder?.open === false && camp?.sortieRosterToggleBounds?.length === 4;
+ }, undefined, { timeout: 30000 });
+ }
+
+ await clickSortiePortraitToggle(page, 'guan-yu', false);
+ await clickSortiePortraitToggle(page, 'jian-yong', true);
+ const jianYongSelectionPersistence = await page.evaluate(() => {
+ const camp = window.__HEROS_DEBUG__?.camp();
+ const globalRaw = window.localStorage.getItem('heros-web:campaign-state');
+ const slotRaw = window.localStorage.getItem('heros-web:campaign-state:slot-1');
+ return {
+ camp,
+ globalSave: globalRaw ? JSON.parse(globalRaw) : null,
+ slotSave: slotRaw ? JSON.parse(slotRaw) : null
+ };
+ });
+ const jianYongPreviewIds = jianYongSelectionPersistence.camp?.sortieDeploymentPreview?.map((entry) => entry.unitId) ?? [];
+ if (
+ !hasExactUnitIds(jianYongSelectionPersistence.camp?.selectedSortieUnitIds, jianYongSortieUnitIds) ||
+ !hasExactUnitIds(jianYongSelectionPersistence.camp?.campaign?.selectedSortieUnitIds, jianYongSortieUnitIds) ||
+ !hasExactUnitIds(jianYongSelectionPersistence.globalSave?.selectedSortieUnitIds, jianYongSortieUnitIds) ||
+ !hasExactUnitIds(jianYongSelectionPersistence.slotSave?.selectedSortieUnitIds, jianYongSortieUnitIds) ||
+ !hasExactUnitIds(jianYongPreviewIds, jianYongSortieUnitIds)
+ ) {
+ throw new Error(`Expected actual portrait-card clicks to persist a three-unit sortie with Jian Yong: ${JSON.stringify(jianYongSelectionPersistence)}`);
+ }
+ await page.screenshot({ path: 'dist/verification-first-camp-jian-yong-sortie.png', fullPage: true });
+
+ await clickSortiePortraitToggle(page, 'jian-yong', false);
+ await clickSortiePortraitToggle(page, 'guan-yu', true);
+ const foundingSelectionPersistence = await page.evaluate(() => {
+ const camp = window.__HEROS_DEBUG__?.camp();
+ const globalRaw = window.localStorage.getItem('heros-web:campaign-state');
+ const slotRaw = window.localStorage.getItem('heros-web:campaign-state:slot-1');
+ return {
+ camp,
+ globalSave: globalRaw ? JSON.parse(globalRaw) : null,
+ slotSave: slotRaw ? JSON.parse(slotRaw) : null
+ };
+ });
+ const foundingPreviewIds = foundingSelectionPersistence.camp?.sortieDeploymentPreview?.map((entry) => entry.unitId) ?? [];
+ if (
+ !hasExactUnitIds(foundingSelectionPersistence.camp?.selectedSortieUnitIds, foundingSortieUnitIds) ||
+ !hasExactUnitIds(foundingSelectionPersistence.camp?.campaign?.selectedSortieUnitIds, foundingSortieUnitIds) ||
+ !hasExactUnitIds(foundingSelectionPersistence.globalSave?.selectedSortieUnitIds, foundingSortieUnitIds) ||
+ !hasExactUnitIds(foundingSelectionPersistence.slotSave?.selectedSortieUnitIds, foundingSortieUnitIds) ||
+ !hasExactUnitIds(foundingPreviewIds, foundingSortieUnitIds)
+ ) {
+ throw new Error(`Expected actual portrait-card clicks to restore and persist the founding trio: ${JSON.stringify(foundingSelectionPersistence)}`);
+ }
await page.screenshot({ path: 'dist/verification-camp-sortie.png', fullPage: true });
await page.keyboard.press('Escape');
@@ -9905,9 +10300,42 @@ async function waitForCampReady(page, options = {}) {
(!expected.step || campState?.campaign?.step === expected.step)
);
}, options, { timeout: 90000 });
+ if (!options.preserveArrivalReward) {
+ await dismissCampArrivalRewardIfVisible(page);
+ }
}
-async function waitForCampAfterBattleResult(page) {
+async function dismissCampArrivalRewardIfVisible(page) {
+ const acknowledgement = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement ?? null);
+ if (!acknowledgement?.visible) {
+ return;
+ }
+ const closeAction = acknowledgement.actions?.find((action) => action.id === 'close');
+ if (!closeAction?.interactive || !closeAction.bounds) {
+ throw new Error(`Expected an interactive close action on the camp arrival reward modal: ${JSON.stringify(acknowledgement)}`);
+ }
+ await clickSceneBounds(page, 'CampScene', closeAction.bounds);
+ try {
+ await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement?.visible === false, undefined, { timeout: 1800 });
+ return;
+ } catch {
+ const afterPointer = await page.evaluate(() => ({
+ activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [],
+ acknowledgement: window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement ?? null
+ }));
+ if (afterPointer.acknowledgement?.visible === false) {
+ return;
+ }
+ await page.keyboard.press('Escape');
+ try {
+ await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement?.visible === false, undefined, { timeout: 30000 });
+ } catch (error) {
+ throw new Error(`Camp arrival reward could not be dismissed by its actual close action or Escape fallback: ${JSON.stringify(afterPointer)}\n${error instanceof Error ? error.message : String(error)}`);
+ }
+ }
+}
+
+async function waitForCampAfterBattleResult(page, options = {}) {
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
return activeScenes.includes('CampScene') || activeScenes.includes('StoryScene');
@@ -9916,14 +10344,14 @@ async function waitForCampAfterBattleResult(page) {
for (let i = 0; i < 48; i += 1) {
const activeScenes = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []);
if (activeScenes.includes('CampScene')) {
- await waitForCampReady(page);
+ await waitForCampReady(page, options);
return;
}
await page.keyboard.press('Space');
await page.waitForTimeout(240);
}
- await waitForCampReady(page);
+ await waitForCampReady(page, options);
}
function delay(ms) {
@@ -10212,6 +10640,37 @@ async function launchSortieFromCurrentStep(page) {
}
}
+async function clickSortiePortraitToggle(page, unitId, expectedSelected) {
+ const state = await ensureSortiePrepStep(page, 'formation');
+ const card = state?.sortiePortraitRosterView?.cardBounds?.find((entry) => entry.unitId === unitId);
+ const firstSliceToggle = state?.firstSortiePrep
+ ? state.sortieRosterToggleBounds?.find((entry) => entry.unitId === unitId)
+ : null;
+ const toggleBounds = state?.sortiePortraitRosterView?.enabled === true
+ ? card?.toggleButtonBounds
+ : firstSliceToggle;
+ if (!toggleBounds) {
+ throw new Error(`Cannot click sortie toggle for ${unitId}: ${JSON.stringify({
+ step: state?.sortiePrepStep,
+ firstSortiePrep: state?.firstSortiePrep,
+ portraitRosterView: state?.sortiePortraitRosterView,
+ firstSliceToggles: state?.sortieRosterToggleBounds
+ })}`);
+ }
+
+ await clickSceneBounds(page, 'CampScene', toggleBounds);
+ await page.waitForFunction(({ targetUnitId, selected }) => {
+ const camp = window.__HEROS_DEBUG__?.camp();
+ return (
+ camp?.sortieVisible === true &&
+ camp?.sortiePrepStep === 'formation' &&
+ camp?.selectedSortieUnitIds?.includes(targetUnitId) === selected
+ );
+ }, { targetUnitId: unitId, selected: expectedSelected }, { timeout: 30000 });
+
+ return page.evaluate(() => window.__HEROS_DEBUG__?.camp());
+}
+
async function clickSortieRosterUnit(page, unitId) {
await ensureSortiePrepStep(page, 'formation');
let state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs
index f3f3077..5166709 100644
--- a/scripts/verify-release-candidate.mjs
+++ b/scripts/verify-release-candidate.mjs
@@ -48,6 +48,48 @@ async function clickBattleDeploymentStart(page) {
await page.mouse.click(point.x, point.y);
}
+async function waitForBattleDeploymentConfirmation(page) {
+ await page.waitForFunction(() => {
+ const battle = window.__HEROS_DEBUG__?.battle();
+ const combatAssets = battle?.combatAssets;
+ return (
+ battle?.phase === 'idle' &&
+ (combatAssets?.status === 'ready' || combatAssets?.status === 'degraded') &&
+ combatAssets?.pendingDeploymentConfirmation === false &&
+ battle?.battleHud?.deploymentPanel === null
+ );
+ }, undefined, { timeout: 90000 });
+}
+
+async function readEquipmentSwapConfirmControlPoint(page) {
+ await page.waitForFunction(() => {
+ const comparison = window.__HEROS_DEBUG__?.camp()?.equipmentSwapComparison;
+ const bounds = comparison?.confirmButtonBounds;
+ return (
+ comparison?.visible === true &&
+ comparison.confirmInteractive === true &&
+ Number.isFinite(bounds?.x) &&
+ Number.isFinite(bounds?.y) &&
+ Number.isFinite(bounds?.width) &&
+ Number.isFinite(bounds?.height) &&
+ bounds.width > 0 &&
+ bounds.height > 0
+ );
+ }, undefined, { timeout: 30000 });
+ await page.evaluate(() => new Promise((resolve) => {
+ requestAnimationFrame(() => requestAnimationFrame(resolve));
+ }));
+ const comparison = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.equipmentSwapComparison ?? null);
+ assert(
+ comparison?.visible === true &&
+ comparison.confirmInteractive === true &&
+ isFiniteBounds(comparison.confirmButtonBounds),
+ `Expected a stable interactive equipment confirmation control: ${JSON.stringify(comparison)}`
+ );
+ const bounds = comparison.confirmButtonBounds;
+ return { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
+}
+
try {
runCommand('node', ['scripts/verify-static-data.mjs']);
mkdirSync(screenshotDir, { recursive: true });
@@ -2385,24 +2427,28 @@ try {
}, 'first camp');
assert(
firstCampProbe.state?.campRoster?.pageCount === 1 &&
- firstCampProbe.state.campRoster.totalCount === 3 &&
- firstCampProbe.state.campRoster.visibleUnitIds.length === 3 &&
- firstCampProbe.state.campRoster.rowBounds.length === 3 &&
+ firstCampProbe.state.campRoster.totalCount === 4 &&
+ sameJsonValue(
+ [...firstCampProbe.state.campRoster.visibleUnitIds].sort(),
+ ['liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong'].sort()
+ ) &&
+ firstCampProbe.state.campRoster.rowBounds.length === 4 &&
firstCampProbe.state.campRoster.rowBounds.every((row) => row.height >= 100) &&
firstCampProbe.state.campRoster.navigationBounds === null &&
firstCampProbe.state.campRoster.previousEnabled === false &&
firstCampProbe.state.campRoster.nextEnabled === false,
- `Expected the three-officer first camp to retain spacious cards without redundant pagination: ${JSON.stringify(firstCampProbe.state?.campRoster)}`
+ `Expected the four-officer first camp to retain spacious cards without redundant pagination: ${JSON.stringify(firstCampProbe.state?.campRoster)}`
);
assert(
firstCampProbe.state?.nextSortieBattleId === 'second-battle-yellow-turban-pursuit',
`Expected first camp sortie flow to prepare unlocked second battle: ${JSON.stringify(firstCampProbe.state)}`
);
assert(
- ['liu-bei', 'guan-yu', 'zhang-fei'].every((unitId) =>
- firstCampProbe.state?.sortieDeploymentPreview?.some((slot) => slot.unitId === unitId)
+ sameJsonValue(
+ (firstCampProbe.state?.sortieDeploymentPreview ?? []).map((slot) => slot.unitId).sort(),
+ ['liu-bei', 'guan-yu', 'zhang-fei'].sort()
),
- `Expected first camp deployment preview to keep the founding trio assigned: ${JSON.stringify(firstCampProbe.state?.sortieDeploymentPreview)}`
+ `Expected first camp deployment preview to keep exactly the founding trio assigned while Jian Yong remains a selectable reserve: ${JSON.stringify(firstCampProbe.state?.sortieDeploymentPreview)}`
);
assert(firstCampProbe.state?.sortieHasBattle === true, `Expected first camp to expose a playable next battle: ${JSON.stringify(firstCampProbe.state)}`);
assert(
@@ -3050,10 +3096,136 @@ try {
equipmentPaginationFixture.panel.pageCount >= 2 &&
equipmentPaginationFixture.panel.visibleItemIds?.length === equipmentPaginationFixture.panel.pageSize &&
equipmentPaginationFixture.panel.nextEnabled === true &&
+ equipmentPaginationFixture.panel.rows?.length === equipmentPaginationFixture.panel.visibleItemIds.length &&
+ equipmentPaginationFixture.panel.rows.every(
+ (row) => boundsInside(row.bounds, equipmentPaginationFixture.panel.bounds) &&
+ boundsInside(row.actionButtonBounds, row.bounds)
+ ) &&
boundsWithinFhdViewport(equipmentPaginationFixture.panel.bounds) &&
boundsInside(equipmentPaginationFixture.panel.nextButtonBounds, equipmentPaginationFixture.panel.bounds),
`Expected the equipment manager to expose the first of multiple inventory pages: ${JSON.stringify(equipmentPaginationFixture)}`
);
+ const equipmentComparisonCandidate = equipmentPaginationFixture.panel.rows.find((row) => row.canEquip && row.interactive);
+ assert(
+ equipmentComparisonCandidate && boundsWithinFhdViewport(equipmentComparisonCandidate.actionButtonBounds),
+ `Expected a visible, interactive equipment candidate in the FHD inventory page: ${JSON.stringify(equipmentPaginationFixture.panel.rows)}`
+ );
+ const equipmentBeforeComparison = await page.evaluate(() => {
+ const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
+ const unit = scene?.campaign?.roster?.find((candidate) => candidate.id === scene?.selectedUnitId);
+ return unit
+ ? {
+ unitId: unit.id,
+ equipment: structuredClone(unit.equipment),
+ inventory: structuredClone(scene.campaign.inventory)
+ }
+ : null;
+ });
+ await page.mouse.click(
+ equipmentComparisonCandidate.actionButtonBounds.x + equipmentComparisonCandidate.actionButtonBounds.width / 2,
+ equipmentComparisonCandidate.actionButtonBounds.y + equipmentComparisonCandidate.actionButtonBounds.height / 2
+ );
+ await page.waitForFunction(
+ () => window.__HEROS_DEBUG__?.camp()?.equipmentSwapComparison?.visible === true,
+ undefined,
+ { timeout: 30000 }
+ );
+ const equipmentComparison = await page.evaluate(() => {
+ const state = window.__HEROS_DEBUG__?.camp();
+ const unit = state?.campaign?.roster?.find((candidate) => candidate.id === state?.equipmentSwapComparison?.unitId);
+ return {
+ comparison: state?.equipmentSwapComparison ?? null,
+ equipment: unit ? structuredClone(unit.equipment) : null,
+ inventory: structuredClone(state?.campaign?.inventory ?? {})
+ };
+ });
+ assert(
+ equipmentBeforeComparison &&
+ equipmentComparison.comparison?.candidateItemId === equipmentComparisonCandidate.itemId &&
+ equipmentComparison.comparison.currentItemId !== equipmentComparison.comparison.candidateItemId &&
+ equipmentComparison.comparison.confirmInteractive === true &&
+ equipmentComparison.comparison.cancelInteractive === true &&
+ boundsWithinFhdViewport(equipmentComparison.comparison.panelBounds) &&
+ boundsInside(equipmentComparison.comparison.confirmButtonBounds, equipmentComparison.comparison.panelBounds) &&
+ boundsInside(equipmentComparison.comparison.cancelButtonBounds, equipmentComparison.comparison.panelBounds) &&
+ sameJsonValue(equipmentComparison.equipment, equipmentBeforeComparison.equipment) &&
+ sameJsonValue(equipmentComparison.inventory, equipmentBeforeComparison.inventory),
+ `Expected candidate selection to open a non-destructive current/candidate comparison: ${JSON.stringify({ before: equipmentBeforeComparison, after: equipmentComparison })}`
+ );
+ await page.screenshot({ path: `${screenshotDir}/rc-first-camp-equipment-comparison.png`, fullPage: true });
+ await assertCanvasPainted(page, 'first camp equipment comparison');
+
+ await page.mouse.click(
+ equipmentComparison.comparison.cancelButtonBounds.x + equipmentComparison.comparison.cancelButtonBounds.width / 2,
+ equipmentComparison.comparison.cancelButtonBounds.y + equipmentComparison.comparison.cancelButtonBounds.height / 2
+ );
+ await page.waitForFunction(
+ () => window.__HEROS_DEBUG__?.camp()?.equipmentSwapComparison === null,
+ undefined,
+ { timeout: 30000 }
+ );
+ const equipmentAfterCancel = await page.evaluate((unitId) => {
+ const state = window.__HEROS_DEBUG__?.camp();
+ const unit = state?.campaign?.roster?.find((candidate) => candidate.id === unitId);
+ return { equipment: structuredClone(unit?.equipment ?? {}), inventory: structuredClone(state?.campaign?.inventory ?? {}) };
+ }, equipmentBeforeComparison.unitId);
+ assert(
+ sameJsonValue(equipmentAfterCancel.equipment, equipmentBeforeComparison.equipment) &&
+ sameJsonValue(equipmentAfterCancel.inventory, equipmentBeforeComparison.inventory),
+ `Expected cancel to leave equipment and inventory untouched: ${JSON.stringify({ before: equipmentBeforeComparison, after: equipmentAfterCancel })}`
+ );
+
+ await page.mouse.click(
+ equipmentComparisonCandidate.actionButtonBounds.x + equipmentComparisonCandidate.actionButtonBounds.width / 2,
+ equipmentComparisonCandidate.actionButtonBounds.y + equipmentComparisonCandidate.actionButtonBounds.height / 2
+ );
+ const equipmentConfirmPoint = await readEquipmentSwapConfirmControlPoint(page);
+ await page.mouse.click(equipmentConfirmPoint.x, equipmentConfirmPoint.y);
+ await page.waitForFunction(
+ ({ unitId, slot, candidateItemId }) => {
+ const state = window.__HEROS_DEBUG__?.camp();
+ const unit = state?.campaign?.roster?.find((candidate) => candidate.id === unitId);
+ return state?.equipmentSwapComparison === null && unit?.equipment?.[slot]?.itemId === candidateItemId;
+ },
+ {
+ unitId: equipmentComparison.comparison.unitId,
+ slot: equipmentComparison.comparison.slot,
+ candidateItemId: equipmentComparison.comparison.candidateItemId
+ },
+ { timeout: 30000 }
+ );
+ const confirmedEquipmentSwap = await page.evaluate(({ unitId, slot }) => {
+ const state = window.__HEROS_DEBUG__?.camp();
+ return state?.campaign?.roster?.find((candidate) => candidate.id === unitId)?.equipment?.[slot]?.itemId ?? null;
+ }, { unitId: equipmentComparison.comparison.unitId, slot: equipmentComparison.comparison.slot });
+ assert(
+ confirmedEquipmentSwap === equipmentComparison.comparison.candidateItemId,
+ `Expected the explicit confirm pointer to apply the candidate equipment: ${JSON.stringify({ comparison: equipmentComparison.comparison, confirmedEquipmentSwap })}`
+ );
+
+ await page.evaluate(({ unitId, slot, currentItemId }) => {
+ const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
+ scene?.swapUnitEquipment?.(unitId, slot, currentItemId);
+ }, {
+ unitId: equipmentComparison.comparison.unitId,
+ slot: equipmentComparison.comparison.slot,
+ currentItemId: equipmentComparison.comparison.currentItemId
+ });
+ const equipmentRestoreConfirmPoint = await readEquipmentSwapConfirmControlPoint(page);
+ await page.mouse.click(equipmentRestoreConfirmPoint.x, equipmentRestoreConfirmPoint.y);
+ await page.waitForFunction(
+ ({ unitId, slot, currentItemId }) => {
+ const state = window.__HEROS_DEBUG__?.camp();
+ const unit = state?.campaign?.roster?.find((candidate) => candidate.id === unitId);
+ return state?.equipmentSwapComparison === null && unit?.equipment?.[slot]?.itemId === currentItemId;
+ },
+ {
+ unitId: equipmentComparison.comparison.unitId,
+ slot: equipmentComparison.comparison.slot,
+ currentItemId: equipmentComparison.comparison.currentItemId
+ },
+ { timeout: 30000 }
+ );
await page.evaluate(() => new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(resolve));
}));
@@ -3135,20 +3307,27 @@ try {
firstSortieBriefingState?.sortiePrimaryAction?.primary === true &&
firstSortieBriefingState?.sortiePrimaryAction?.interactive === true &&
firstSortieBriefingState?.sortiePrimaryAction?.lineWidth >= 2 &&
+ firstSortieBriefingState?.sortieNextActionGuide?.label?.includes('무장 편성으로 이동') &&
+ boundsWithinFhdViewport(firstSortieBriefingState?.sortieNextActionGuide?.bounds) &&
boundsWithinFhdViewport(firstSortieBriefingState?.sortiePrimaryAction?.bounds),
`Expected the first sortie to open at the staged battlefield briefing: ${JSON.stringify(firstSortieBriefingState)}`
);
await advanceSortiePrepStep(page, 'formation');
await page.screenshot({ path: `${screenshotDir}/rc-first-camp-sortie-formation.png`, fullPage: true });
await assertCanvasPainted(page, 'first camp sortie formation');
- const firstSortieSynergy = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortieSynergyPreview);
+ const firstSortieFormationState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
+ const firstSortieSynergy = firstSortieFormationState?.sortieSynergyPreview;
assert(
- firstSortieSynergy?.activeBondCount === 3 &&
+ firstSortieFormationState?.sortiePrimaryAction?.kind === 'next' &&
+ firstSortieFormationState.sortiePrimaryAction.nextStep === 'loadout' &&
+ firstSortieFormationState?.sortieNextActionGuide?.label?.includes('장비·보급 점검으로 이동') &&
+ boundsWithinFhdViewport(firstSortieFormationState?.sortieNextActionGuide?.bounds) &&
+ firstSortieSynergy?.activeBondCount === 3 &&
firstSortieSynergy?.coveredRoleCount === 3 &&
firstSortieSynergy?.strongestBond?.damageBonus === 9 &&
firstSortieSynergy?.strongestBond?.chainRate === 18 &&
firstSortieSynergy?.firstPursuitTrinityConfigured === true,
- `Expected first sortie to preview three bonds, the strongest effect, and trinity: ${JSON.stringify(firstSortieSynergy)}`
+ `Expected first sortie formation to expose its next action and preview three bonds, the strongest effect, and trinity: ${JSON.stringify(firstSortieFormationState)}`
);
const expectedFirstSortiePursuitPairs = [
@@ -3746,6 +3925,7 @@ try {
const battleKey = 'heros-web:battle:second-battle-yellow-turban-pursuit';
const storageKeys = [campaignCurrentKey, campaignSlotKey, battleKey, `${battleKey}:slot-1`, 'heros-web:first-battle-state'];
const storageSnapshot = new Map(storageKeys.map((key) => [key, window.localStorage.getItem(key)]));
+ const loadDiagnostics = [];
const captureUnits = () => {
const byId = new Map((window.__HEROS_DEBUG__?.battle()?.units ?? []).map((unit) => [unit.id, unit]));
return officerIds.map((id) => {
@@ -3765,6 +3945,16 @@ try {
campaign.sortieFormationAssignments = assignments;
delete campaign.sortieResonanceSelection;
window.localStorage.setItem(campaignSlotKey, JSON.stringify(campaign));
+ const battleRaw = window.localStorage.getItem(`${battleKey}:slot-1`) ?? window.localStorage.getItem(battleKey);
+ const savedBattle = battleRaw ? JSON.parse(battleRaw) : null;
+ loadDiagnostics.push({
+ assignments,
+ persistedAssignments: JSON.parse(window.localStorage.getItem(campaignSlotKey))?.sortieFormationAssignments ?? null,
+ battleSavePresent: Boolean(savedBattle),
+ battleSaveReadable: Boolean(scene.readBattleSaveState?.(1)),
+ tacticalCommand: savedBattle?.tacticalCommand ?? null,
+ sortieResonanceBondId: savedBattle?.sortieResonanceBondId ?? null
+ });
scene.launchSortieResonanceBondId = undefined;
scene.coreResonancePursuitAttempts = 0;
scene.coreResonancePursuitSuccesses = 0;
@@ -3774,6 +3964,10 @@ try {
};
scene.saveBattleState?.(1);
+ const savedBattleRaw = window.localStorage.getItem(`${battleKey}:slot-1`) ?? window.localStorage.getItem(battleKey);
+ const savedBattle = savedBattleRaw ? JSON.parse(savedBattleRaw) : null;
+ const battleSaveReadableAfterSave = Boolean(scene.readBattleSaveState?.(1));
+ const savedBattleBonds = structuredClone(savedBattle?.bonds ?? []);
const savedCampaignRaw = window.localStorage.getItem(campaignSlotKey);
if (!savedCampaignRaw) {
return { error: 'campaign slot save missing' };
@@ -3809,6 +4003,7 @@ try {
const restoredCoreResonance = structuredClone(
window.__HEROS_DEBUG__?.battle()?.coreSortieResonance ?? null
);
+ const reserveBondPreserved = scene.bondStates?.has('liu-bei__jian-yong') ?? false;
storageSnapshot.forEach((value, key) => {
if (value === null) {
window.localStorage.removeItem(key);
@@ -3816,8 +4011,31 @@ try {
window.localStorage.setItem(key, value);
}
});
- return { changed, changedCoreResonance, allReserve, restored, restoredCoreResonance };
+ return {
+ changed,
+ changedCoreResonance,
+ allReserve,
+ restored,
+ restoredCoreResonance,
+ loadDiagnostics,
+ battleSaveReadableAfterSave,
+ savedBattleBonds,
+ reserveBondPreserved
+ };
});
+ assert(
+ roleSealResyncProbe?.battleSaveReadableAfterSave === true &&
+ roleSealResyncProbe.savedBattleBonds.length > 0 &&
+ roleSealResyncProbe.savedBattleBonds.every((bond) => (
+ bond.unitIds.every((unitId) => secondBattleProbe.deployedAllyIds.includes(unitId))
+ )) &&
+ !roleSealResyncProbe.savedBattleBonds.some((bond) => bond.id === 'liu-bei__jian-yong') &&
+ roleSealResyncProbe.reserveBondPreserved === true &&
+ roleSealResyncProbe.loadDiagnostics.every((diagnostic) => (
+ diagnostic.battleSavePresent === true && diagnostic.battleSaveReadable === true
+ )),
+ `Expected a three-officer battle save to exclude reserve bonds and remain readable across formation reloads: ${JSON.stringify(roleSealResyncProbe)}`
+ );
assert(
JSON.stringify(roleSealResyncProbe?.changed) === JSON.stringify([
{ id: 'liu-bei', role: 'front', effect: '전열 방호', seal: '전', sealPresent: true, sealVisible: true },
@@ -6430,12 +6648,26 @@ async function assertLargeRosterHud(browser, url) {
if (scene?.phase === 'deployment') {
scene.confirmPreBattleDeployment?.();
}
+ });
+ await waitForBattleDeploymentConfirmation(page);
+ await page.evaluate(() => {
+ const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
scene?.hideBattleEventBanner?.();
scene?.renderSituationPanel?.();
});
await page.waitForFunction(() => {
- const hud = window.__HEROS_DEBUG__?.battle()?.battleHud;
- return hud?.miniMap?.visible === true && hud?.sidebar?.bounds && hud?.deploymentPanel === null;
+ const battle = window.__HEROS_DEBUG__?.battle();
+ const hud = battle?.battleHud;
+ return (
+ battle?.phase === 'idle' &&
+ hud?.miniMap?.visible === true &&
+ hud?.sidebar?.bounds &&
+ hud?.deploymentPanel === null &&
+ hud?.quickTabs?.visible === true &&
+ hud?.quickTabs?.enabled === true &&
+ hud?.quickTabs?.activeTab === 'situation' &&
+ hud?.quickTabs?.currentView === 'situation'
+ );
}, undefined, { timeout: 30000 });
const situationProbe = await readLargeRosterHudProbe(page);
@@ -6529,10 +6761,25 @@ async function assertLargeRosterHud(browser, url) {
await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
scene?.confirmPreBattleDeployment?.();
+ });
+ await waitForBattleDeploymentConfirmation(page);
+ await page.evaluate(() => {
+ const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
scene?.hideBattleEventBanner?.();
scene?.renderRosterPanel?.('ally', '출전 위치 확정. 행동할 장수를 선택하세요.');
});
- await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.battleHud?.rosterPanel?.pageCount > 1, undefined, { timeout: 30000 });
+ await page.waitForFunction(() => {
+ const battle = window.__HEROS_DEBUG__?.battle();
+ const hud = battle?.battleHud;
+ return (
+ battle?.phase === 'idle' &&
+ hud?.rosterPanel?.pageCount > 1 &&
+ hud?.quickTabs?.visible === true &&
+ hud?.quickTabs?.enabled === true &&
+ hud?.quickTabs?.activeTab === 'roster' &&
+ hud?.quickTabs?.currentView === 'roster'
+ );
+ }, undefined, { timeout: 30000 });
const restartedPage = await readLargeRosterHudProbe(page);
assert(
restartedPage.roster.page === 0,
@@ -6560,6 +6807,25 @@ async function assertWebglBattleQuickTabSmoke(browser, url, battleId) {
return battle?.phase === 'idle' && battle.battleHud?.quickTabs?.enabled === true;
}, undefined, { timeout: 90000 });
+ if (battleId === 'first-battle-zhuo-commandery') {
+ await page.waitForFunction(
+ () => window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial?.active === true,
+ undefined,
+ { timeout: 30000 }
+ );
+ await page.evaluate(() => {
+ window.__HEROS_GAME__?.scene.getScene('BattleScene')?.completeFirstBattleTutorial?.('skipped');
+ });
+ await page.waitForFunction(() => {
+ const battle = window.__HEROS_DEBUG__?.battle();
+ return (
+ battle?.firstBattleTutorial?.active === false &&
+ battle.firstBattleTutorial.completion === 'skipped' &&
+ battle.battleHud?.quickTabs?.enabled === true
+ );
+ }, undefined, { timeout: 30000 });
+ }
+
const rendererProbe = await page.evaluate(() => {
const game = window.__HEROS_GAME__;
const scene = game?.scene.getScene('BattleScene');
@@ -7663,9 +7929,15 @@ async function waitForStoryReady(page) {
}
async function assertFreshStoryAssetStreaming(page, requestedResourceUrls, requestStartIndex) {
+ let stableInitialBackground = null;
for (let sampleIndex = 0; sampleIndex < 8; sampleIndex += 1) {
const probe = await readStoryAssetStreamingProbe(page);
assertStoryAssetStreamingProbe(probe, 0, 'fresh story page 1');
+ stableInitialBackground ??= probe.story.background;
+ assert(
+ probe.story.background === stableInitialBackground,
+ `Expected repeated reads of the same story page to retain one deterministic background: ${JSON.stringify(probe)}`
+ );
assert(
storyStreamingIndexes(probe.story.assetStreaming).every((pageIndex) => pageIndex <= 1),
`Expected fresh story loading to stay within pages 1-2 before input: ${JSON.stringify(probe)}`
@@ -7696,6 +7968,22 @@ async function assertFreshStoryAssetStreaming(page, requestedResourceUrls, reque
`Expected every current cutscene unit base sheet to be loaded: ${JSON.stringify(probe)}`
);
+ const repeatedSortieBackgrounds = new Set();
+ while (probe.story?.pageIndex < probe.story?.totalPages - 1) {
+ const targetPageIndex = probe.story.pageIndex + 1;
+ probe = await advanceOneStoryPage(page, targetPageIndex);
+ assertStoryAssetStreamingProbe(probe, targetPageIndex, `fresh story page ${targetPageIndex + 1}`);
+ if (probe.story.requestedBackground === 'story-sortie') {
+ repeatedSortieBackgrounds.add(probe.story.background);
+ }
+ }
+ assert(
+ repeatedSortieBackgrounds.size >= 2,
+ `Expected repeated sortie story pages to distribute deterministic background variants: ${JSON.stringify([
+ ...repeatedSortieBackgrounds
+ ])}`
+ );
+
const actionRequests = requestedResourceUrls
.slice(requestStartIndex)
.filter((url) => /\/unit-[^/?]+-actions(?:-[^/?]+)?\.png(?:[?#]|$)/i.test(decodeURIComponent(url)));
diff --git a/scripts/verify-story-asset-data.mjs b/scripts/verify-story-asset-data.mjs
index fedd310..12b0edf 100644
--- a/scripts/verify-story-asset-data.mjs
+++ b/scripts/verify-story-asset-data.mjs
@@ -1,3 +1,5 @@
+import { readdirSync } from 'node:fs';
+import { join } from 'node:path';
import { createServer } from 'vite';
const validCutsceneKinds = new Set(['muster', 'operation', 'victory']);
@@ -15,7 +17,8 @@ const server = await createServer({
try {
const scenarioModule = await server.ssrLoadModule('/src/game/data/scenario.ts');
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
- const { storyBackgroundAssets, storyBackgroundKeysFor } = await server.ssrLoadModule('/src/game/data/storyAssets.ts');
+ const { storyBackgroundAssets, storyBackgroundKeyForPage, storyBackgroundKeysFor } =
+ await server.ssrLoadModule('/src/game/data/storyAssets.ts');
const { portraitAssetEntriesForKey, portraitKeyForSpeaker } = await server.ssrLoadModule('/src/game/data/portraitAssets.ts');
const { storyCutsceneActorProfileFor } = await server.ssrLoadModule('/src/game/data/storyCutscenes.ts');
const { hasUnitSheetAsset } = await server.ssrLoadModule('/src/game/data/unitAssets.ts');
@@ -24,6 +27,8 @@ try {
const storySources = collectStorySources(scenarioModule, battleScenarios);
const uniquePageIds = new Set();
const usedBackgroundKeys = new Set();
+ const selectedBackgroundKeys = new Set();
+ const selectedBackgroundUrlsByBase = new Map();
const usedPortraitKeys = new Set();
const usedCutsceneActorIds = new Set();
let pageCount = 0;
@@ -44,9 +49,12 @@ try {
index,
page,
usedBackgroundKeys,
+ selectedBackgroundKeys,
+ selectedBackgroundUrlsByBase,
usedPortraitKeys,
usedCutsceneActorIds,
storyBackgroundAssets,
+ storyBackgroundKeyForPage,
storyBackgroundKeysFor,
portraitAssetEntriesForKey,
portraitKeyForSpeaker,
@@ -59,6 +67,9 @@ try {
});
});
+ validateStoryBackgroundVariantCoverage(errors, selectedBackgroundKeys);
+ validateRepeatedStoryBackgroundDistribution(errors, selectedBackgroundUrlsByBase);
+
if (errors.length) {
console.error(`Story asset data verification failed with ${errors.length} issue(s):`);
errors.slice(0, 100).forEach((error) => console.error(`- ${error}`));
@@ -69,7 +80,8 @@ try {
} else {
console.log(
`Verified ${pageCount} story page references (${uniquePageIds.size} unique pages), ` +
- `${cutsceneCount} cutscenes, ${usedBackgroundKeys.size} backgrounds, ` +
+ `${cutsceneCount} cutscenes, ${usedBackgroundKeys.size} background candidates, ` +
+ `${selectedBackgroundKeys.size} selected backgrounds, ` +
`${usedPortraitKeys.size} portrait keys, and ${usedCutsceneActorIds.size} cutscene actor profiles.`
);
}
@@ -131,7 +143,16 @@ function validateStoryPage(context) {
}
function validateBackground(context, pageContext) {
- const { errors, page, usedBackgroundKeys, storyBackgroundAssets, storyBackgroundKeysFor } = context;
+ const {
+ errors,
+ page,
+ usedBackgroundKeys,
+ selectedBackgroundKeys,
+ selectedBackgroundUrlsByBase,
+ storyBackgroundAssets,
+ storyBackgroundKeyForPage,
+ storyBackgroundKeysFor
+ } = context;
if (!page.background) {
return;
}
@@ -139,8 +160,71 @@ function validateBackground(context, pageContext) {
const keys = storyBackgroundKeysFor(page.background).filter((key) => storyBackgroundAssets[key]);
if (keys.length === 0) {
errors.push(`${pageContext}: missing story background asset "${page.background}"`);
+ return;
}
keys.forEach((key) => usedBackgroundKeys.add(key));
+
+ const selectedKey = storyBackgroundKeyForPage(page.background, page.id);
+ const repeatedSelection = storyBackgroundKeyForPage(page.background, page.id);
+ if (selectedKey !== repeatedSelection) {
+ errors.push(`${pageContext}: story background selection must be deterministic`);
+ }
+ if (!keys.includes(selectedKey)) {
+ errors.push(`${pageContext}: selected background "${selectedKey}" is not a candidate for "${page.background}"`);
+ return;
+ }
+
+ const selectedUrl = storyBackgroundAssets[selectedKey];
+ if (!selectedUrl) {
+ errors.push(`${pageContext}: selected background "${selectedKey}" has no runtime asset URL`);
+ return;
+ }
+
+ selectedBackgroundKeys.add(selectedKey);
+ const backgroundGroup = selectedBackgroundUrlsByBase.get(page.background) ?? {
+ pageCount: 0,
+ candidateUrls: new Set(),
+ selectedUrls: new Set()
+ };
+ backgroundGroup.pageCount += 1;
+ keys.forEach((key) => backgroundGroup.candidateUrls.add(storyBackgroundAssets[key]));
+ backgroundGroup.selectedUrls.add(selectedUrl);
+ selectedBackgroundUrlsByBase.set(page.background, backgroundGroup);
+}
+
+function validateStoryBackgroundVariantCoverage(errors, selectedBackgroundKeys) {
+ const firstVariantNumber = 33;
+ const lastVariantNumber = 134;
+ const expectedVariantCount = lastVariantNumber - firstVariantNumber + 1;
+ const variantKeys = readdirSync(join('src', 'assets', 'images', 'story'), { withFileTypes: true })
+ .filter((entry) => entry.isFile())
+ .map((entry) => entry.name.match(/^(\d+)-(.+)\.png$/i))
+ .filter((match) => match && Number(match[1]) >= firstVariantNumber && Number(match[1]) <= lastVariantNumber)
+ .map((match) => `story-${match[2]}`)
+ .sort();
+
+ if (variantKeys.length !== expectedVariantCount) {
+ errors.push(
+ `story background variants ${firstVariantNumber}-${lastVariantNumber}: expected ${expectedVariantCount} files, ` +
+ `found ${variantKeys.length}`
+ );
+ }
+
+ variantKeys.forEach((key) => {
+ if (!selectedBackgroundKeys.has(key)) {
+ errors.push(`story background variant "${key}" is never selected by a campaign page`);
+ }
+ });
+}
+
+function validateRepeatedStoryBackgroundDistribution(errors, selectedBackgroundUrlsByBase) {
+ selectedBackgroundUrlsByBase.forEach(({ pageCount, candidateUrls, selectedUrls }, baseKey) => {
+ if (pageCount > 1 && candidateUrls.size > 1 && selectedUrls.size < 2) {
+ errors.push(
+ `${baseKey}: ${pageCount} repeated pages should use at least two of ${candidateUrls.size} background variants`
+ );
+ }
+ });
}
function validatePortrait(context, pageContext) {
diff --git a/scripts/verify-visual-asset-data.mjs b/scripts/verify-visual-asset-data.mjs
index d280fa6..f0acac6 100644
--- a/scripts/verify-visual-asset-data.mjs
+++ b/scripts/verify-visual-asset-data.mjs
@@ -1,4 +1,4 @@
-import { readFileSync, statSync } from 'node:fs';
+import { readFileSync, readdirSync, statSync } from 'node:fs';
import { createServer } from 'vite';
const server = await createServer({
@@ -16,7 +16,28 @@ const expectedV2IconFrames = {
fire: 24,
bean: 27,
salve: 28,
- wine: 29
+ wine: 29,
+ confusion: 30,
+ benevolentCommand: 31,
+ azureDragonStrike: 32,
+ changbanRoar: 33,
+ singleRiderRescue: 34,
+ eastWindFire: 35,
+ hundredPacePierce: 36,
+ westernCavalryCharge: 37,
+ qilinStratagem: 38,
+ burn: 39
+};
+
+const expectedDedicatedUsableIcons = {
+ benevolentCommand: 'benevolentCommand',
+ azureDragonStrike: 'azureDragonStrike',
+ changbanRoar: 'changbanRoar',
+ singleRiderRescue: 'singleRiderRescue',
+ eastWindFire: 'eastWindFire',
+ hundredPacePierce: 'hundredPacePierce',
+ westernCavalryCharge: 'westernCavalryCharge',
+ qilinStratagem: 'qilinStratagem'
};
try {
@@ -111,6 +132,9 @@ function validateBattleUiIconAtlas(errors, manifest) {
}
const frameLimit = manifest.columns * manifest.rows;
+ if (frameLimit !== 40) {
+ errors.push(`battle UI icon atlas must contain 40 frames, got ${frameLimit}`);
+ }
Object.entries(manifest.frames).forEach(([key, frame]) => {
assertNonEmptyString(errors, key, 'battle UI icon key');
if (!Number.isInteger(frame) || frame < 0 || frame >= frameLimit) {
@@ -135,8 +159,8 @@ function validateBattleUiIconSources(errors, manifest) {
}
const entries = Object.entries(sources);
- if (entries.length !== 8) {
- errors.push(`battleUiIconManifest.v2Sources must contain 8 source icons, got ${entries.length}`);
+ if (entries.length !== 18) {
+ errors.push(`battleUiIconManifest.v2Sources must contain 18 source icons, got ${entries.length}`);
}
const sourceFrames = new Set();
@@ -190,6 +214,46 @@ function validateBattleUsableIcons(errors, usableCatalog, frames) {
errors.push(`${context} must use the item's dedicated icon key "${id}", got "${usable.iconKey}"`);
}
});
+
+ const dedicatedIconKeys = [];
+ Object.entries(expectedDedicatedUsableIcons).forEach(([usableId, expectedIconKey]) => {
+ const usable = usableCatalog[usableId];
+ if (!usable) {
+ errors.push(`usableCatalog.${usableId} must exist for its signature icon`);
+ return;
+ }
+ if (usable.iconKey !== expectedIconKey) {
+ errors.push(`usableCatalog.${usableId}.iconKey must use dedicated icon "${expectedIconKey}", got "${usable.iconKey}"`);
+ }
+ dedicatedIconKeys.push(usable.iconKey);
+ });
+
+ if (new Set(dedicatedIconKeys).size !== dedicatedIconKeys.length) {
+ errors.push('signature usable icon keys must not be duplicated');
+ }
+
+ const statusIconKeys = ['confusion', 'burn'];
+ const reservedGenericIconKeys = ['shout', 'fire'];
+ const premiumIconKeys = [...dedicatedIconKeys, ...statusIconKeys];
+ if (new Set(premiumIconKeys).size !== premiumIconKeys.length) {
+ errors.push('signature and status icon keys must be unique');
+ }
+ [...statusIconKeys, ...reservedGenericIconKeys].forEach((iconKey) => {
+ if (!(iconKey in frames)) {
+ errors.push(`battle UI icon manifest is missing dedicated status/generic icon "${iconKey}"`);
+ }
+ });
+ if (frames.confusion === frames.shout) {
+ errors.push('confusion status icon frame must be distinct from shout');
+ }
+ if (frames.burn === frames.fire) {
+ errors.push('burn status icon frame must be distinct from fire tactic');
+ }
+
+ const premiumFrames = premiumIconKeys.map((iconKey) => frames[iconKey]).filter(Number.isInteger);
+ if (new Set(premiumFrames).size !== premiumFrames.length) {
+ errors.push('signature and status icon frames must not be duplicated');
+ }
}
function validateItemCatalog(errors, itemCatalog, equipmentSlots) {
@@ -224,6 +288,7 @@ function validateItemCatalog(errors, itemCatalog, equipmentSlots) {
function validateGeneratedEquipmentIcons(errors, itemCatalog) {
const bootScene = readFileSync('src/game/scenes/BootScene.ts', 'utf8');
+ const campScene = readFileSync('src/game/scenes/CampScene.ts', 'utf8');
const generatedItemIds = new Set([...bootScene.matchAll(/createItemIcon\('item-([^']+)'/g)].map((match) => match[1]));
const catalogItemIds = new Set(Object.keys(itemCatalog));
@@ -241,6 +306,107 @@ function validateGeneratedEquipmentIcons(errors, itemCatalog) {
errors.push(`BootScene generates item-${itemId}, but itemCatalog has no matching item`);
}
});
+
+ validateEquipmentIconDirectory(errors, catalogItemIds, 'src/assets/images/ui/equipment-icons/128', 128);
+ validateEquipmentIconDirectory(errors, catalogItemIds, 'src/assets/images/ui/equipment-icons/32', 32);
+
+ const bootPreloadChecks = [
+ {
+ pattern: /import\.meta\.glob\('\.\.\/\.\.\/assets\/images\/ui\/equipment-icons\/128\/\*\.png'/,
+ message: 'BootScene must import the 128px equipment icon glob'
+ },
+ {
+ pattern: /import\.meta\.glob\('\.\.\/\.\.\/assets\/images\/ui\/equipment-icons\/32\/\*\.png'/,
+ message: 'BootScene must import the 32px equipment icon glob'
+ },
+ {
+ pattern: /this\.preloadEquipmentIcons\(equipmentIcon128Modules,\s*''\)/,
+ message: 'BootScene must preload 128px equipment icons as item-${id}'
+ },
+ {
+ pattern: /this\.preloadEquipmentIcons\(equipmentIcon32Modules,\s*'-micro'\)/,
+ message: 'BootScene must preload 32px equipment icons as item-${id}-micro'
+ },
+ {
+ pattern: /this\.load\.image\(`item-\$\{itemId\}\$\{suffix\}`,\s*url\)/,
+ message: 'BootScene equipment icon loader must preserve item-${id}${suffix} texture keys'
+ }
+ ];
+ bootPreloadChecks.forEach(({ pattern, message }) => {
+ if (!pattern.test(bootScene)) {
+ errors.push(message);
+ }
+ });
+
+ const smallCampEquipmentIcons = [
+ ...campScene.matchAll(
+ /const icon = [^\n]*this\.add\.image\([^\n]*, `item-\$\{[^`]+?\}(-micro)?`\)\);[\s\S]{0,180}?icon\.setDisplaySize\(this\.campUiLength\((\d+)\)/g
+ )
+ ];
+ smallCampEquipmentIcons.forEach((match) => {
+ const suffix = match[1] ?? '';
+ const displaySize = Number(match[2]);
+ if (displaySize <= 22 && suffix !== '-micro') {
+ errors.push(`CampScene equipment icon displayed at ${displaySize}px must use the -micro texture`);
+ }
+ });
+ if (smallCampEquipmentIcons.filter((match) => Number(match[2]) <= 22 && match[1] === '-micro').length < 3) {
+ errors.push('CampScene must route its main 22px-or-smaller equipment slots through -micro textures');
+ }
+ if (
+ !/renderSortieEquipmentIcons\([^)]*frameSize = 18, iconSize = 14[^)]*\)[\s\S]{0,900}`item-\$\{item\.id\}-micro`/.test(
+ campScene
+ )
+ ) {
+ errors.push('CampScene sortie equipment strip must use item-${id}-micro at its 14px default size');
+ }
+}
+
+function validateEquipmentIconDirectory(errors, catalogItemIds, directory, expectedSize) {
+ let filenames;
+ try {
+ filenames = readdirSync(directory);
+ } catch (error) {
+ errors.push(`equipment icon directory cannot be read at "${directory}": ${error.message}`);
+ return;
+ }
+
+ const pngFilenames = filenames.filter((filename) => filename.endsWith('.png'));
+ filenames
+ .filter((filename) => !filename.endsWith('.png'))
+ .forEach((filename) => errors.push(`${directory} contains unexpected non-PNG asset "${filename}"`));
+
+ const assetIds = new Set(pngFilenames.map((filename) => filename.slice(0, -4)));
+ Array.from(catalogItemIds)
+ .sort()
+ .forEach((itemId) => {
+ if (!assetIds.has(itemId)) {
+ errors.push(`${directory} is missing ${itemId}.png`);
+ return;
+ }
+ const source = `${directory}/${itemId}.png`;
+ let dimensions;
+ try {
+ dimensions = readPngDimensions(source);
+ } catch (error) {
+ errors.push(`equipment icon cannot be read at "${source}": ${error.message}`);
+ return;
+ }
+ if (dimensions.width !== expectedSize || dimensions.height !== expectedSize) {
+ errors.push(`${source} must be exactly ${expectedSize}x${expectedSize}, got ${dimensions.width}x${dimensions.height}`);
+ }
+ if (dimensions.bitDepth !== 8 || dimensions.colorType !== 6) {
+ errors.push(`${source} must be an 8-bit RGBA PNG, got bitDepth=${dimensions.bitDepth} colorType=${dimensions.colorType}`);
+ }
+ });
+
+ Array.from(assetIds)
+ .sort()
+ .forEach((itemId) => {
+ if (!catalogItemIds.has(itemId)) {
+ errors.push(`${directory} contains extra icon ${itemId}.png with no matching itemCatalog entry`);
+ }
+ });
}
function readPngDimensions(path) {
diff --git a/src/assets/images/ui/battle-ui-icons-micro.png b/src/assets/images/ui/battle-ui-icons-micro.png
index 2bd3b5e..0472e88 100644
Binary files a/src/assets/images/ui/battle-ui-icons-micro.png and b/src/assets/images/ui/battle-ui-icons-micro.png differ
diff --git a/src/assets/images/ui/battle-ui-icons.png b/src/assets/images/ui/battle-ui-icons.png
index 3d0d1d3..73332f8 100644
Binary files a/src/assets/images/ui/battle-ui-icons.png and b/src/assets/images/ui/battle-ui-icons.png differ
diff --git a/src/assets/images/ui/equipment-icons/128/bravery-token.png b/src/assets/images/ui/equipment-icons/128/bravery-token.png
new file mode 100644
index 0000000..eee5a46
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/bravery-token.png differ
diff --git a/src/assets/images/ui/equipment-icons/128/cloth-armor.png b/src/assets/images/ui/equipment-icons/128/cloth-armor.png
new file mode 100644
index 0000000..ecf224d
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/cloth-armor.png differ
diff --git a/src/assets/images/ui/equipment-icons/128/grain-pouch.png b/src/assets/images/ui/equipment-icons/128/grain-pouch.png
new file mode 100644
index 0000000..4fd3517
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/grain-pouch.png differ
diff --git a/src/assets/images/ui/equipment-icons/128/green-dragon-glaive.png b/src/assets/images/ui/equipment-icons/128/green-dragon-glaive.png
new file mode 100644
index 0000000..2b5ac95
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/green-dragon-glaive.png differ
diff --git a/src/assets/images/ui/equipment-icons/128/iron-spear.png b/src/assets/images/ui/equipment-icons/128/iron-spear.png
new file mode 100644
index 0000000..65dc70e
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/iron-spear.png differ
diff --git a/src/assets/images/ui/equipment-icons/128/lamellar-armor.png b/src/assets/images/ui/equipment-icons/128/lamellar-armor.png
new file mode 100644
index 0000000..a69202a
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/lamellar-armor.png differ
diff --git a/src/assets/images/ui/equipment-icons/128/leader-axe.png b/src/assets/images/ui/equipment-icons/128/leader-axe.png
new file mode 100644
index 0000000..fbc3cf9
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/leader-axe.png differ
diff --git a/src/assets/images/ui/equipment-icons/128/mountain-guide-scroll.png b/src/assets/images/ui/equipment-icons/128/mountain-guide-scroll.png
new file mode 100644
index 0000000..4148a78
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/mountain-guide-scroll.png differ
diff --git a/src/assets/images/ui/equipment-icons/128/oath-robe.png b/src/assets/images/ui/equipment-icons/128/oath-robe.png
new file mode 100644
index 0000000..05ef69d
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/oath-robe.png differ
diff --git a/src/assets/images/ui/equipment-icons/128/peach-charm.png b/src/assets/images/ui/equipment-icons/128/peach-charm.png
new file mode 100644
index 0000000..f30c33e
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/peach-charm.png differ
diff --git a/src/assets/images/ui/equipment-icons/128/rebel-vest.png b/src/assets/images/ui/equipment-icons/128/rebel-vest.png
new file mode 100644
index 0000000..7b41b39
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/rebel-vest.png differ
diff --git a/src/assets/images/ui/equipment-icons/128/reinforced-lamellar.png b/src/assets/images/ui/equipment-icons/128/reinforced-lamellar.png
new file mode 100644
index 0000000..ea8528c
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/reinforced-lamellar.png differ
diff --git a/src/assets/images/ui/equipment-icons/128/serpent-spear.png b/src/assets/images/ui/equipment-icons/128/serpent-spear.png
new file mode 100644
index 0000000..65f9b3f
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/serpent-spear.png differ
diff --git a/src/assets/images/ui/equipment-icons/128/short-bow.png b/src/assets/images/ui/equipment-icons/128/short-bow.png
new file mode 100644
index 0000000..25dc2ca
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/short-bow.png differ
diff --git a/src/assets/images/ui/equipment-icons/128/sky-piercer-halberd.png b/src/assets/images/ui/equipment-icons/128/sky-piercer-halberd.png
new file mode 100644
index 0000000..042f0bb
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/sky-piercer-halberd.png differ
diff --git a/src/assets/images/ui/equipment-icons/128/training-sword.png b/src/assets/images/ui/equipment-icons/128/training-sword.png
new file mode 100644
index 0000000..1a00fa2
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/training-sword.png differ
diff --git a/src/assets/images/ui/equipment-icons/128/twin-oath-blades.png b/src/assets/images/ui/equipment-icons/128/twin-oath-blades.png
new file mode 100644
index 0000000..26bf737
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/twin-oath-blades.png differ
diff --git a/src/assets/images/ui/equipment-icons/128/war-manual.png b/src/assets/images/ui/equipment-icons/128/war-manual.png
new file mode 100644
index 0000000..cfbe17f
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/war-manual.png differ
diff --git a/src/assets/images/ui/equipment-icons/128/western-cavalry-spear.png b/src/assets/images/ui/equipment-icons/128/western-cavalry-spear.png
new file mode 100644
index 0000000..011cf71
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/western-cavalry-spear.png differ
diff --git a/src/assets/images/ui/equipment-icons/128/white-feather-fan.png b/src/assets/images/ui/equipment-icons/128/white-feather-fan.png
new file mode 100644
index 0000000..bf5265c
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/white-feather-fan.png differ
diff --git a/src/assets/images/ui/equipment-icons/128/wind-quiver.png b/src/assets/images/ui/equipment-icons/128/wind-quiver.png
new file mode 100644
index 0000000..1d0b3e3
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/wind-quiver.png differ
diff --git a/src/assets/images/ui/equipment-icons/128/yellow-scarf-charm.png b/src/assets/images/ui/equipment-icons/128/yellow-scarf-charm.png
new file mode 100644
index 0000000..8e1f00b
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/yellow-scarf-charm.png differ
diff --git a/src/assets/images/ui/equipment-icons/128/yellow-turban-saber.png b/src/assets/images/ui/equipment-icons/128/yellow-turban-saber.png
new file mode 100644
index 0000000..1a50476
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/yellow-turban-saber.png differ
diff --git a/src/assets/images/ui/equipment-icons/32/bravery-token.png b/src/assets/images/ui/equipment-icons/32/bravery-token.png
new file mode 100644
index 0000000..2f77681
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/bravery-token.png differ
diff --git a/src/assets/images/ui/equipment-icons/32/cloth-armor.png b/src/assets/images/ui/equipment-icons/32/cloth-armor.png
new file mode 100644
index 0000000..14db60e
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/cloth-armor.png differ
diff --git a/src/assets/images/ui/equipment-icons/32/grain-pouch.png b/src/assets/images/ui/equipment-icons/32/grain-pouch.png
new file mode 100644
index 0000000..d618328
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/grain-pouch.png differ
diff --git a/src/assets/images/ui/equipment-icons/32/green-dragon-glaive.png b/src/assets/images/ui/equipment-icons/32/green-dragon-glaive.png
new file mode 100644
index 0000000..1646aa8
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/green-dragon-glaive.png differ
diff --git a/src/assets/images/ui/equipment-icons/32/iron-spear.png b/src/assets/images/ui/equipment-icons/32/iron-spear.png
new file mode 100644
index 0000000..d02e829
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/iron-spear.png differ
diff --git a/src/assets/images/ui/equipment-icons/32/lamellar-armor.png b/src/assets/images/ui/equipment-icons/32/lamellar-armor.png
new file mode 100644
index 0000000..962b5e4
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/lamellar-armor.png differ
diff --git a/src/assets/images/ui/equipment-icons/32/leader-axe.png b/src/assets/images/ui/equipment-icons/32/leader-axe.png
new file mode 100644
index 0000000..9a99a24
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/leader-axe.png differ
diff --git a/src/assets/images/ui/equipment-icons/32/mountain-guide-scroll.png b/src/assets/images/ui/equipment-icons/32/mountain-guide-scroll.png
new file mode 100644
index 0000000..feb084a
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/mountain-guide-scroll.png differ
diff --git a/src/assets/images/ui/equipment-icons/32/oath-robe.png b/src/assets/images/ui/equipment-icons/32/oath-robe.png
new file mode 100644
index 0000000..f2c046f
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/oath-robe.png differ
diff --git a/src/assets/images/ui/equipment-icons/32/peach-charm.png b/src/assets/images/ui/equipment-icons/32/peach-charm.png
new file mode 100644
index 0000000..36b6560
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/peach-charm.png differ
diff --git a/src/assets/images/ui/equipment-icons/32/rebel-vest.png b/src/assets/images/ui/equipment-icons/32/rebel-vest.png
new file mode 100644
index 0000000..876e5fd
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/rebel-vest.png differ
diff --git a/src/assets/images/ui/equipment-icons/32/reinforced-lamellar.png b/src/assets/images/ui/equipment-icons/32/reinforced-lamellar.png
new file mode 100644
index 0000000..e99c095
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/reinforced-lamellar.png differ
diff --git a/src/assets/images/ui/equipment-icons/32/serpent-spear.png b/src/assets/images/ui/equipment-icons/32/serpent-spear.png
new file mode 100644
index 0000000..94b0c95
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/serpent-spear.png differ
diff --git a/src/assets/images/ui/equipment-icons/32/short-bow.png b/src/assets/images/ui/equipment-icons/32/short-bow.png
new file mode 100644
index 0000000..3450d0b
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/short-bow.png differ
diff --git a/src/assets/images/ui/equipment-icons/32/sky-piercer-halberd.png b/src/assets/images/ui/equipment-icons/32/sky-piercer-halberd.png
new file mode 100644
index 0000000..0c68d0f
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/sky-piercer-halberd.png differ
diff --git a/src/assets/images/ui/equipment-icons/32/training-sword.png b/src/assets/images/ui/equipment-icons/32/training-sword.png
new file mode 100644
index 0000000..b8b828e
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/training-sword.png differ
diff --git a/src/assets/images/ui/equipment-icons/32/twin-oath-blades.png b/src/assets/images/ui/equipment-icons/32/twin-oath-blades.png
new file mode 100644
index 0000000..1c5819d
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/twin-oath-blades.png differ
diff --git a/src/assets/images/ui/equipment-icons/32/war-manual.png b/src/assets/images/ui/equipment-icons/32/war-manual.png
new file mode 100644
index 0000000..b07b3f4
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/war-manual.png differ
diff --git a/src/assets/images/ui/equipment-icons/32/western-cavalry-spear.png b/src/assets/images/ui/equipment-icons/32/western-cavalry-spear.png
new file mode 100644
index 0000000..0bdacc2
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/western-cavalry-spear.png differ
diff --git a/src/assets/images/ui/equipment-icons/32/white-feather-fan.png b/src/assets/images/ui/equipment-icons/32/white-feather-fan.png
new file mode 100644
index 0000000..ea95436
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/white-feather-fan.png differ
diff --git a/src/assets/images/ui/equipment-icons/32/wind-quiver.png b/src/assets/images/ui/equipment-icons/32/wind-quiver.png
new file mode 100644
index 0000000..7646144
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/wind-quiver.png differ
diff --git a/src/assets/images/ui/equipment-icons/32/yellow-scarf-charm.png b/src/assets/images/ui/equipment-icons/32/yellow-scarf-charm.png
new file mode 100644
index 0000000..7c066bc
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/yellow-scarf-charm.png differ
diff --git a/src/assets/images/ui/equipment-icons/32/yellow-turban-saber.png b/src/assets/images/ui/equipment-icons/32/yellow-turban-saber.png
new file mode 100644
index 0000000..6236c63
Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/yellow-turban-saber.png differ
diff --git a/src/assets/images/ui/icon-sources/battle-ui-v2/azure-dragon-strike-v2.png b/src/assets/images/ui/icon-sources/battle-ui-v2/azure-dragon-strike-v2.png
new file mode 100644
index 0000000..68c0640
Binary files /dev/null and b/src/assets/images/ui/icon-sources/battle-ui-v2/azure-dragon-strike-v2.png differ
diff --git a/src/assets/images/ui/icon-sources/battle-ui-v2/benevolent-command-v2.png b/src/assets/images/ui/icon-sources/battle-ui-v2/benevolent-command-v2.png
new file mode 100644
index 0000000..ed44b92
Binary files /dev/null and b/src/assets/images/ui/icon-sources/battle-ui-v2/benevolent-command-v2.png differ
diff --git a/src/assets/images/ui/icon-sources/battle-ui-v2/burn-v2.png b/src/assets/images/ui/icon-sources/battle-ui-v2/burn-v2.png
new file mode 100644
index 0000000..59b835b
Binary files /dev/null and b/src/assets/images/ui/icon-sources/battle-ui-v2/burn-v2.png differ
diff --git a/src/assets/images/ui/icon-sources/battle-ui-v2/changban-roar-v2.png b/src/assets/images/ui/icon-sources/battle-ui-v2/changban-roar-v2.png
new file mode 100644
index 0000000..a30c751
Binary files /dev/null and b/src/assets/images/ui/icon-sources/battle-ui-v2/changban-roar-v2.png differ
diff --git a/src/assets/images/ui/icon-sources/battle-ui-v2/confusion-v2.png b/src/assets/images/ui/icon-sources/battle-ui-v2/confusion-v2.png
new file mode 100644
index 0000000..a897446
Binary files /dev/null and b/src/assets/images/ui/icon-sources/battle-ui-v2/confusion-v2.png differ
diff --git a/src/assets/images/ui/icon-sources/battle-ui-v2/east-wind-fire-v2.png b/src/assets/images/ui/icon-sources/battle-ui-v2/east-wind-fire-v2.png
new file mode 100644
index 0000000..b7d5840
Binary files /dev/null and b/src/assets/images/ui/icon-sources/battle-ui-v2/east-wind-fire-v2.png differ
diff --git a/src/assets/images/ui/icon-sources/battle-ui-v2/hundred-pace-pierce-v2.png b/src/assets/images/ui/icon-sources/battle-ui-v2/hundred-pace-pierce-v2.png
new file mode 100644
index 0000000..e903824
Binary files /dev/null and b/src/assets/images/ui/icon-sources/battle-ui-v2/hundred-pace-pierce-v2.png differ
diff --git a/src/assets/images/ui/icon-sources/battle-ui-v2/qilin-stratagem-v2.png b/src/assets/images/ui/icon-sources/battle-ui-v2/qilin-stratagem-v2.png
new file mode 100644
index 0000000..47a00cd
Binary files /dev/null and b/src/assets/images/ui/icon-sources/battle-ui-v2/qilin-stratagem-v2.png differ
diff --git a/src/assets/images/ui/icon-sources/battle-ui-v2/single-rider-rescue-v2.png b/src/assets/images/ui/icon-sources/battle-ui-v2/single-rider-rescue-v2.png
new file mode 100644
index 0000000..ca423a4
Binary files /dev/null and b/src/assets/images/ui/icon-sources/battle-ui-v2/single-rider-rescue-v2.png differ
diff --git a/src/assets/images/ui/icon-sources/battle-ui-v2/western-cavalry-charge-v2.png b/src/assets/images/ui/icon-sources/battle-ui-v2/western-cavalry-charge-v2.png
new file mode 100644
index 0000000..a9f50bf
Binary files /dev/null and b/src/assets/images/ui/icon-sources/battle-ui-v2/western-cavalry-charge-v2.png differ
diff --git a/src/game/data/battleUiIcons.ts b/src/game/data/battleUiIcons.ts
index ec2ac54..8023b22 100644
--- a/src/game/data/battleUiIcons.ts
+++ b/src/game/data/battleUiIcons.ts
@@ -37,11 +37,20 @@ export const battleUiIconFrames = {
focus: 23,
fire: 24,
shout: 25,
- confusion: 25,
failure: 26,
bean: 27,
salve: 28,
- wine: 29
+ wine: 29,
+ confusion: 30,
+ benevolentCommand: 31,
+ azureDragonStrike: 32,
+ changbanRoar: 33,
+ singleRiderRescue: 34,
+ eastWindFire: 35,
+ hundredPacePierce: 36,
+ westernCavalryCharge: 37,
+ qilinStratagem: 38,
+ burn: 39
} as const;
export type BattleUiIconKey = keyof typeof battleUiIconFrames;
@@ -54,7 +63,17 @@ export const battleUiIconV2Sources = {
fire: { frame: battleUiIconFrames.fire, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/fire-v2.png' },
bean: { frame: battleUiIconFrames.bean, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/bean-v2.png' },
salve: { frame: battleUiIconFrames.salve, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/salve-v2.png' },
- wine: { frame: battleUiIconFrames.wine, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/wine-v2.png' }
+ wine: { frame: battleUiIconFrames.wine, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/wine-v2.png' },
+ confusion: { frame: battleUiIconFrames.confusion, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/confusion-v2.png' },
+ benevolentCommand: { frame: battleUiIconFrames.benevolentCommand, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/benevolent-command-v2.png' },
+ azureDragonStrike: { frame: battleUiIconFrames.azureDragonStrike, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/azure-dragon-strike-v2.png' },
+ changbanRoar: { frame: battleUiIconFrames.changbanRoar, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/changban-roar-v2.png' },
+ singleRiderRescue: { frame: battleUiIconFrames.singleRiderRescue, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/single-rider-rescue-v2.png' },
+ eastWindFire: { frame: battleUiIconFrames.eastWindFire, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/east-wind-fire-v2.png' },
+ hundredPacePierce: { frame: battleUiIconFrames.hundredPacePierce, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/hundred-pace-pierce-v2.png' },
+ westernCavalryCharge: { frame: battleUiIconFrames.westernCavalryCharge, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/western-cavalry-charge-v2.png' },
+ qilinStratagem: { frame: battleUiIconFrames.qilinStratagem, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/qilin-stratagem-v2.png' },
+ burn: { frame: battleUiIconFrames.burn, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/burn-v2.png' }
} as const satisfies Partial>;
export const battleUiIconManifest = {
@@ -68,7 +87,7 @@ export const battleUiIconManifest = {
microFrameHeight: battleUiIconMicroFrameSize,
microMaxDisplaySize: battleUiIconMicroMaxDisplaySize,
columns: 5,
- rows: 6,
+ rows: 8,
frames: battleUiIconFrames,
v2Sources: battleUiIconV2Sources
} as const;
diff --git a/src/game/data/battleUsables.ts b/src/game/data/battleUsables.ts
index 9b4d8d1..d28ac99 100644
--- a/src/game/data/battleUsables.ts
+++ b/src/game/data/battleUsables.ts
@@ -97,7 +97,7 @@ export const usableCatalog: Record = {
},
benevolentCommand: {
id: 'benevolentCommand',
- iconKey: 'focus',
+ iconKey: 'benevolentCommand',
command: 'strategy',
name: '인덕의 호령',
target: 'ally',
@@ -114,7 +114,7 @@ export const usableCatalog: Record = {
},
azureDragonStrike: {
id: 'azureDragonStrike',
- iconKey: 'strategy',
+ iconKey: 'azureDragonStrike',
command: 'strategy',
name: '청룡일섬',
target: 'enemy',
@@ -129,7 +129,7 @@ export const usableCatalog: Record = {
},
changbanRoar: {
id: 'changbanRoar',
- iconKey: 'confusion',
+ iconKey: 'changbanRoar',
command: 'strategy',
name: '장판교 대갈',
target: 'enemy',
@@ -147,7 +147,7 @@ export const usableCatalog: Record = {
},
singleRiderRescue: {
id: 'singleRiderRescue',
- iconKey: 'heal',
+ iconKey: 'singleRiderRescue',
command: 'strategy',
name: '단기구원',
target: 'ally',
@@ -160,7 +160,7 @@ export const usableCatalog: Record = {
},
eastWindFire: {
id: 'eastWindFire',
- iconKey: 'fire',
+ iconKey: 'eastWindFire',
command: 'strategy',
name: '동남풍 화계',
target: 'enemy',
@@ -178,7 +178,7 @@ export const usableCatalog: Record = {
},
hundredPacePierce: {
id: 'hundredPacePierce',
- iconKey: 'strategy',
+ iconKey: 'hundredPacePierce',
command: 'strategy',
name: '백보천양',
target: 'enemy',
@@ -193,7 +193,7 @@ export const usableCatalog: Record = {
},
westernCavalryCharge: {
id: 'westernCavalryCharge',
- iconKey: 'confusion',
+ iconKey: 'westernCavalryCharge',
command: 'strategy',
name: '서량철기',
target: 'enemy',
@@ -211,7 +211,7 @@ export const usableCatalog: Record = {
},
qilinStratagem: {
id: 'qilinStratagem',
- iconKey: 'confusion',
+ iconKey: 'qilinStratagem',
command: 'strategy',
name: '기린지계',
target: 'enemy',
diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts
index 57f464a..1ec0c3b 100644
--- a/src/game/data/battles.ts
+++ b/src/game/data/battles.ts
@@ -530,10 +530,10 @@ export const firstBattleScenario: BattleScenarioDefinition = {
supplies: ['콩 1', '탁주 1'],
equipment: ['연습검 1'],
reputation: ['의용군 명성 +1'],
- recruits: ['guan-yu', 'zhang-fei'],
+ recruits: ['guan-yu', 'zhang-fei', 'jian-yong'],
unlockBattleId: 'second-battle-yellow-turban-pursuit',
unlockLabel: '황건 잔당 추격 개방',
- note: '도원 형제의 의용군이 정식으로 출전 가능한 부대로 정리됩니다.'
+ note: '간옹이 합류합니다. 다음 전투부터 유비·관우·장비·간옹 4명 중 최대 3명을 골라 편성하며, 추천 편성은 세 형제입니다.'
},
victoryPages: firstBattleVictoryPages,
nextCampScene: 'CampScene'
@@ -1181,10 +1181,10 @@ export const seventhBattleScenario: BattleScenarioDefinition = {
supplies: ['콩 2', '상처약 2', '탁주 1'],
equipment: ['병법서 1'],
reputation: ['서주 민심 +1'],
- recruits: ['jian-yong', 'mi-zhu'],
+ recruits: ['mi-zhu'],
unlockBattleId: 'eighth-battle-xiaopei-supply-road',
unlockLabel: '소패 보급로 방위전 개방',
- note: '서주 구원으로 간옹과 미축이 합류합니다. 다음 전투부터 책사/보급관 선택지가 출전 준비에 표시됩니다.'
+ note: '서주 구원으로 미축이 합류합니다. 탁현부터 함께한 간옹의 책략과 미축의 보급을 조합하는 선택지가 출전 준비에 추가됩니다.'
},
victoryPages: seventhBattleVictoryPages,
nextCampScene: 'CampScene'
@@ -1197,8 +1197,8 @@ export const eighthBattleScenario: BattleScenarioDefinition = {
defeatConditionLabel: '유비 퇴각',
openingObjectiveLines: [
'여포군의 척후와 습격병이 소패 보급로의 창고를 노리고 있습니다. 고순을 격파하면 서주의 첫 방위선을 지킬 수 있습니다.',
- '간옹은 병법서와 책략으로 길목 압박을 늦추고, 미축은 창고 주변 보급과 회복을 맡기 좋습니다. 마을과 창고 지형을 중심으로 전열을 잡으십시오.',
- '35턴 이내 승리하면 서주 보급망과 새 합류 무장의 명성이 크게 오릅니다.'
+ '탁현부터 함께한 간옹은 병법서와 책략으로 길목 압박을 늦추고, 새로 합류한 미축은 창고 주변 보급과 회복을 맡기 좋습니다. 마을과 창고 지형을 중심으로 전열을 잡으십시오.',
+ '35턴 이내 승리하면 서주 보급망과 미축의 군영 명성이 크게 오릅니다.'
],
tacticalGuide: {
summary: '소패 보급로 교두보를 먼저 안정시키고, 관우가 길목을 버티는 동안 간옹과 미축이 후방에서 회복과 책략 보조를 이어갑니다.',
@@ -1243,7 +1243,7 @@ export const eighthBattleScenario: BattleScenarioDefinition = {
{ role: 'flank', unitId: 'zhang-fei', x: 2, y: 18, label: '남쪽 압박' },
{ role: 'support', unitId: 'liu-bei', x: 2, y: 17, label: '후방 지휘' }
],
- note: '소패 보급로는 새로 합류한 간옹과 미축을 시험하기 좋은 전장입니다. 관우가 길목을 막고, 간옹은 책략, 미축은 보급을 맡는 전열이 기본입니다.'
+ note: '소패 보급로는 기존 책사 간옹과 새 보급관 미축의 조합을 시험하기 좋은 전장입니다. 관우가 길목을 막고, 간옹은 책략, 미축은 보급을 맡는 전열이 기본입니다.'
},
map: eighthBattleMap,
units: eighthBattleUnits,
diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts
index 6139cc8..e4f3736 100644
--- a/src/game/data/scenario.ts
+++ b/src/game/data/scenario.ts
@@ -364,15 +364,16 @@ const firstBattleStoryOverrides: Record> = {
'first-victory-next': {
chapter: '다음 길',
speaker: '전황',
- text: '세 형제의 이름은 탁현 일대에 퍼지기 시작했다. 이제 황건 잔당의 퇴로를 끊어야 한다.',
+ text: '탁현에서부터 유비를 도와 온 간옹이 의용군의 군정과 책략을 맡기로 했다. 다음 전투부터 유비·관우·장비·간옹 네 명 중 최대 세 명을 골라 전장에 맞는 편성을 꾸릴 수 있다.',
cutscene: {
kind: 'operation',
- title: '다음 전투 해금',
- subtitle: '황건 잔당 추격으로 이어집니다.',
+ title: '간옹 합류 · 첫 편성 선택',
+ subtitle: '4명 중 최대 3명을 골라 황건 잔당 추격에 나섭니다.',
actors: [
- { unitId: 'liu-bei', x: 0.25, y: 0.54, size: 108, direction: 'east', label: '유비' },
- { unitId: 'guan-yu', x: 0.34, y: 0.53, size: 112, direction: 'east', label: '관우' },
- { unitId: 'zhang-fei', x: 0.43, y: 0.54, size: 112, direction: 'east', label: '장비' }
+ { unitId: 'liu-bei', x: 0.22, y: 0.54, size: 108, direction: 'east', label: '유비' },
+ { unitId: 'guan-yu', x: 0.32, y: 0.53, size: 112, direction: 'east', label: '관우' },
+ { unitId: 'zhang-fei', x: 0.42, y: 0.54, size: 112, direction: 'east', label: '장비' },
+ { unitId: 'jian-yong', x: 0.52, y: 0.55, size: 106, direction: 'west', label: '간옹' }
],
markers: [
{ x: 0.24, y: 0.68, label: '탁현', side: 'ally' },
@@ -381,10 +382,11 @@ const firstBattleStoryOverrides: Record> = {
],
briefing: {
title: '정비 후 출전',
- lines: ['보급품 배정', '장비 교체', '출전 무장 선택']
+ lines: ['신규 무장 간옹 확인', '4명 중 최대 3명 편성', '추천 편성은 유비·관우·장비']
},
rewards: [
- { label: '군영에서 보상 확인', tone: 'reward' },
+ { label: '신규 무장: 간옹', tone: 'reward' },
+ { label: '편성: 4명 중 최대 3명', tone: 'bond' },
{ label: '다음: 황건 잔당 추격', tone: 'next' }
]
}
@@ -841,7 +843,7 @@ export const seventhBattleVictoryPages: StoryPage[] = [
bgm: 'militia-theme',
chapter: '서주의 문',
background: 'story-xuzhou',
- text: '조조군 선봉이 물러서자 서주 성문 앞 피난민들의 숨통이 트였다. 도겸은 유비의 군기를 맞이하며 깊이 고개를 숙였다.'
+ text: '조조군 선봉이 물러서자 서주 성문 앞 피난민들의 숨통이 트였다. 도겸은 유비의 군기를 맞이하며 깊이 고개를 숙였고, 미축은 보급 장부를 들고 유비군에 합류했다.'
},
{
id: 'seventh-tao-qian-entrusts',
@@ -860,7 +862,7 @@ export const eighthBattleIntroPages: StoryPage[] = [
bgm: 'militia-theme',
chapter: '서주의 새 군영',
background: 'story-xuzhou',
- text: '도겸은 병든 몸을 일으켜 서주의 장부와 군량 창고를 유비에게 넘겼다. 간옹과 미축은 백성의 청원과 보급로를 정리하며 새 군영의 기초를 세웠다.'
+ text: '도겸은 병든 몸을 일으켜 서주의 장부와 군량 창고를 유비에게 넘겼다. 오래 함께해 온 간옹은 백성의 청원을 정리하고, 새로 합류한 미축은 보급로와 창고를 맡아 새 군영의 기초를 세웠다.'
},
{
id: 'eighth-lu-bu-shadow',
@@ -885,7 +887,7 @@ export const eighthBattleIntroPages: StoryPage[] = [
chapter: '첫 방위전',
background: 'story-xuzhou',
speaker: '간옹',
- text: '현덕, 이제 함께 갈 장수를 고르는 일도 전술이 되었네. 칼이 필요한 곳과 말이 필요한 곳, 그리고 말보다 장부가 필요한 곳을 구분해야 하네.'
+ text: '현덕, 탁현에서 시작한 편성 선택이 이제 더 넓어졌네. 칼이 필요한 곳과 책략이 필요한 곳, 그리고 말보다 장부가 필요한 곳을 구분해 미축까지 알맞게 배치해야 하네.'
}
];
@@ -5144,7 +5146,7 @@ export const firstBattleUnits: UnitData[] = [
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 5,
- y: 13
+ y: 14
},
{
id: 'rebel-b',
@@ -5358,15 +5360,44 @@ export const firstBattleUnits: UnitData[] = [
}
];
+// 간옹은 첫 승리 직후 합류하므로 2전 데이터가 평가되기 전에 공용 템플릿을 선언한다.
+// 후반 서주 합류 배열도 이 템플릿을 재사용해 같은 무장이 두 번 정의되지 않게 한다.
+export const jianYongRecruitUnit: UnitData = {
+ id: 'jian-yong',
+ name: '간옹',
+ faction: 'ally',
+ className: '책사',
+ classKey: 'strategist',
+ level: 4,
+ exp: 12,
+ hp: 26,
+ maxHp: 26,
+ attack: 7,
+ move: 4,
+ stats: { might: 42, intelligence: 78, leadership: 66, agility: 64, luck: 72 },
+ equipment: {
+ weapon: { itemId: 'training-sword', level: 1, exp: 8 },
+ armor: { itemId: 'cloth-armor', level: 1, exp: 6 },
+ accessory: { itemId: 'war-manual', level: 1, exp: 0 }
+ },
+ x: 1,
+ y: 17
+};
+
+const earlyCampaignAllies: UnitData[] = [
+ ...firstBattleUnits.filter((unit) => unit.faction === 'ally'),
+ jianYongRecruitUnit
+];
+
const secondBattleAllyPositions: Record = {
'liu-bei': { x: 2, y: 15 },
'guan-yu': { x: 3, y: 15 },
- 'zhang-fei': { x: 2, y: 16 }
+ 'zhang-fei': { x: 2, y: 16 },
+ 'jian-yong': { x: 1, y: 16 }
};
export const secondBattleUnits: UnitData[] = [
- ...firstBattleUnits
- .filter((unit) => unit.faction === 'ally')
+ ...earlyCampaignAllies
.map((unit) => placeScenarioUnit(unit, secondBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })),
{
id: 'pursuit-raider-a',
@@ -5604,12 +5635,12 @@ export const secondBattleUnits: UnitData[] = [
const thirdBattleAllyPositions: Record = {
'liu-bei': { x: 2, y: 16 },
'guan-yu': { x: 3, y: 16 },
- 'zhang-fei': { x: 2, y: 17 }
+ 'zhang-fei': { x: 2, y: 17 },
+ 'jian-yong': { x: 1, y: 17 }
};
export const thirdBattleUnits: UnitData[] = [
- ...firstBattleUnits
- .filter((unit) => unit.faction === 'ally')
+ ...earlyCampaignAllies
.map((unit) => placeScenarioUnit(unit, thirdBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })),
{
id: 'guangzong-scout-a',
@@ -5868,12 +5899,12 @@ export const thirdBattleUnits: UnitData[] = [
const fourthBattleAllyPositions: Record = {
'liu-bei': { x: 2, y: 16 },
'guan-yu': { x: 3, y: 16 },
- 'zhang-fei': { x: 2, y: 17 }
+ 'zhang-fei': { x: 2, y: 17 },
+ 'jian-yong': { x: 1, y: 17 }
};
export const fourthBattleUnits: UnitData[] = [
- ...firstBattleUnits
- .filter((unit) => unit.faction === 'ally')
+ ...earlyCampaignAllies
.map((unit) => placeScenarioUnit(unit, fourthBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })),
{
id: 'guangzong-main-vanguard-a',
@@ -6132,12 +6163,12 @@ export const fourthBattleUnits: UnitData[] = [
const fifthBattleAllyPositions: Record = {
'liu-bei': { x: 2, y: 16 },
'guan-yu': { x: 3, y: 16 },
- 'zhang-fei': { x: 2, y: 17 }
+ 'zhang-fei': { x: 2, y: 17 },
+ 'jian-yong': { x: 1, y: 17 }
};
export const fifthBattleUnits: UnitData[] = [
- ...firstBattleUnits
- .filter((unit) => unit.faction === 'ally')
+ ...earlyCampaignAllies
.map((unit) => placeScenarioUnit(unit, fifthBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })),
{
id: 'sishui-vanguard-a',
@@ -6375,12 +6406,12 @@ export const fifthBattleUnits: UnitData[] = [
const sixthBattleAllyPositions: Record = {
'liu-bei': { x: 2, y: 15 },
'guan-yu': { x: 3, y: 15 },
- 'zhang-fei': { x: 2, y: 16 }
+ 'zhang-fei': { x: 2, y: 16 },
+ 'jian-yong': { x: 1, y: 16 }
};
export const sixthBattleUnits: UnitData[] = [
- ...firstBattleUnits
- .filter((unit) => unit.faction === 'ally')
+ ...earlyCampaignAllies
.map((unit) => placeScenarioUnit(unit, sixthBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })),
{
id: 'jieqiao-skirmisher-a',
@@ -6639,12 +6670,12 @@ export const sixthBattleUnits: UnitData[] = [
const seventhBattleAllyPositions: Record = {
'liu-bei': { x: 2, y: 15 },
'guan-yu': { x: 3, y: 15 },
- 'zhang-fei': { x: 2, y: 16 }
+ 'zhang-fei': { x: 2, y: 16 },
+ 'jian-yong': { x: 1, y: 16 }
};
export const seventhBattleUnits: UnitData[] = [
- ...firstBattleUnits
- .filter((unit) => unit.faction === 'ally')
+ ...earlyCampaignAllies
.map((unit) => placeScenarioUnit(unit, seventhBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })),
{
id: 'xuzhou-raider-a',
@@ -6901,27 +6932,7 @@ export const seventhBattleUnits: UnitData[] = [
];
export const xuzhouRecruitUnits: UnitData[] = [
- {
- id: 'jian-yong',
- name: '간옹',
- faction: 'ally',
- className: '책사',
- classKey: 'strategist',
- level: 4,
- exp: 12,
- hp: 26,
- maxHp: 26,
- attack: 7,
- move: 4,
- stats: { might: 42, intelligence: 78, leadership: 66, agility: 64, luck: 72 },
- equipment: {
- weapon: { itemId: 'training-sword', level: 1, exp: 8 },
- armor: { itemId: 'cloth-armor', level: 1, exp: 6 },
- accessory: { itemId: 'war-manual', level: 1, exp: 0 }
- },
- x: 3,
- y: 17
- },
+ jianYongRecruitUnit,
{
id: 'mi-zhu',
name: '미축',
@@ -19141,15 +19152,17 @@ export const firstBattleBonds: BattleBond[] = [
}
];
+export const jianYongRecruitBond: BattleBond = {
+ id: 'liu-bei__jian-yong',
+ unitIds: ['liu-bei', 'jian-yong'],
+ title: '오랜 벗',
+ level: 48,
+ exp: 0,
+ description: '간옹은 유비가 흔들릴 때 현실적인 말로 길을 정리해 주는 오랜 벗이다.'
+};
+
export const xuzhouRecruitBonds: BattleBond[] = [
- {
- id: 'liu-bei__jian-yong',
- unitIds: ['liu-bei', 'jian-yong'],
- title: '오랜 벗',
- level: 48,
- exp: 0,
- description: '간옹은 유비가 흔들릴 때 현실적인 말로 길을 정리해 주는 오랜 벗이다.'
- },
+ jianYongRecruitBond,
{
id: 'liu-bei__mi-zhu',
unitIds: ['liu-bei', 'mi-zhu'],
@@ -19781,12 +19794,12 @@ export const jingRearCrisisBonds: BattleBond[] = [
}
];
-export const secondBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
-export const thirdBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
-export const fourthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
-export const fifthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
-export const sixthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
-export const seventhBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
+export const secondBattleBonds: BattleBond[] = [...firstBattleBonds, jianYongRecruitBond].map(cloneBattleBondForScenario);
+export const thirdBattleBonds: BattleBond[] = [...firstBattleBonds, jianYongRecruitBond].map(cloneBattleBondForScenario);
+export const fourthBattleBonds: BattleBond[] = [...firstBattleBonds, jianYongRecruitBond].map(cloneBattleBondForScenario);
+export const fifthBattleBonds: BattleBond[] = [...firstBattleBonds, jianYongRecruitBond].map(cloneBattleBondForScenario);
+export const sixthBattleBonds: BattleBond[] = [...firstBattleBonds, jianYongRecruitBond].map(cloneBattleBondForScenario);
+export const seventhBattleBonds: BattleBond[] = [...firstBattleBonds, jianYongRecruitBond].map(cloneBattleBondForScenario);
export const eighthBattleBonds: BattleBond[] = [...firstBattleBonds, ...xuzhouRecruitBonds].map(cloneBattleBondForScenario);
export const ninthBattleBonds: BattleBond[] = eighthBattleBonds.map(cloneBattleBondForScenario);
export const tenthBattleBonds: BattleBond[] = ninthBattleBonds.map(cloneBattleBondForScenario);
diff --git a/src/game/data/storyAssets.ts b/src/game/data/storyAssets.ts
index 599be1d..cbe59c8 100644
--- a/src/game/data/storyAssets.ts
+++ b/src/game/data/storyAssets.ts
@@ -26,6 +26,35 @@ const storyAssetAliases: Record = {
'title-taoyuan': 'story-taoyuan-oath-title'
};
+// A handful of later background variants use a scene name that is related to,
+// but not prefixed by, the background alias referenced by scenario data. Keep
+// those relationships explicit so page planning can still discover them
+// without making the runtime depend on file-system order.
+const storyAssetCandidateAliases: Record = {
+ 'story-chengdu-surrender': ['story-chengdu-outer-gate-surrender'],
+ 'story-yiling-fire-attack': ['story-yiling-fire-forest-corridor']
+};
+
+// Hashing provides a stable default. These sparse assignments cover single-page
+// families and hash collisions so every purpose-built 33-134 scene variant is
+// actually shown somewhere in the campaign; the static verifier guards that
+// coverage as story pages evolve.
+const storyBackgroundPageAssignments: Record = {
+ 'yellow-turban-chaos': 'story-chaos-market-panic',
+ 'militia-rises': 'story-militia-training-yard',
+ 'fifth-liu-bei-choice': 'story-anti-dong-hulao-pass',
+ 'thirty-third-chengdu-encircled': 'story-chengdu-surrender-court',
+ 'thirty-third-final-plan': 'story-chengdu-outer-gate-surrender',
+ 'thirty-third-sortie': 'story-chengdu-surrender-hall',
+ 'forty-sixth-camps-stretch': 'story-yiling-fire-attack-river-ashes',
+ 'forty-sixth-lu-xun-fire': 'story-yiling-fire-attack-riverbank',
+ 'forty-sixth-liu-bei-orders': 'story-yiling-fire-forest-corridor',
+ 'forty-seventh-southern-unrest': 'story-nanzhong-forest-council',
+ 'fifty-sixth-tianshui-rumor': 'story-tianshui-front-market-command',
+ 'sixty-second-renewed-qishan-council': 'story-qishan-renewed-frozen-scout-line',
+ 'sixty-third-zhanghe-rear-raid': 'story-lucheng-pursuit-ravine-signal'
+};
+
function storyKeyFromPath(path: string) {
const fileName = path.split('/').pop() ?? path;
const slug = fileName.replace(/\.png$/i, '').replace(/^\d+-/, '');
@@ -47,9 +76,34 @@ Object.entries(storyAssetAliases).forEach(([alias, target]) => {
export function storyBackgroundKeysFor(baseKey: string) {
const prefix = `${baseKey}-`;
- const keys = Object.keys(storyBackgroundAssets)
+ const prefixedKeys = Object.keys(storyBackgroundAssets)
.filter((key) => key === baseKey || key.startsWith(prefix))
.sort();
+ const candidateAliases = storyAssetCandidateAliases[baseKey] ?? [];
+ const keys = [...prefixedKeys, ...candidateAliases].filter((key) => Boolean(storyBackgroundAssets[key]));
+
return Array.from(new Set(keys));
}
+
+export function storyBackgroundKeyForPage(baseKey: string, pageId: string) {
+ const keys = storyBackgroundKeysFor(baseKey);
+ if (keys.length === 0) {
+ return baseKey;
+ }
+
+ const assignedKey = storyBackgroundPageAssignments[pageId];
+ if (assignedKey && keys.includes(assignedKey)) {
+ return assignedKey;
+ }
+
+ return keys[stableStoryHash(pageId) % keys.length];
+}
+
+function stableStoryHash(value: string) {
+ let hash = 0;
+ for (let index = 0; index < value.length; index += 1) {
+ hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
+ }
+ return hash;
+}
diff --git a/src/game/data/storyCutscenes.ts b/src/game/data/storyCutscenes.ts
index e20133a..d0accde 100644
--- a/src/game/data/storyCutscenes.ts
+++ b/src/game/data/storyCutscenes.ts
@@ -75,6 +75,14 @@ const storyCutsceneActorProfiles: Record = {
portraitKey: 'zhangFei',
color: 0xd46a4c
},
+ 'jian-yong': {
+ unitId: 'jian-yong',
+ name: '간옹',
+ roleLabel: '군정·책략 보좌',
+ unitTextureKey: 'unit-shu-strategist',
+ portraitKey: 'jian-yong',
+ color: 0x7ea6b8
+ },
'rebel-leader': {
unitId: 'rebel-leader',
name: '황건 두령',
diff --git a/src/game/data/unitAssets.ts b/src/game/data/unitAssets.ts
index 581c197..865a114 100644
--- a/src/game/data/unitAssets.ts
+++ b/src/game/data/unitAssets.ts
@@ -21,6 +21,20 @@ type UnitSheetAsset = {
actionUrl: string;
};
+export type UnitActionSheetLoadResult = {
+ readyKeys: string[];
+ missingKeys: string[];
+ failedKeys: string[];
+ timedOutKeys: string[];
+ timedOut: boolean;
+};
+
+export type UnitActionSheetLoadOptions = {
+ watchdogMs?: number;
+};
+
+const defaultUnitActionSheetWatchdogMs = 15_000;
+
const unitSheetModules = import.meta.glob('../../assets/images/units/unit-*.png', {
eager: true,
import: 'default'
@@ -116,38 +130,100 @@ export function loadUnitBaseSheets(scene: Phaser.Scene, keys: Iterable,
export function loadUnitSheets(scene: Phaser.Scene, keys: Iterable, onReady: () => void) {
const uniqueKeys = Array.from(new Set(keys));
- if (unitSheetsReady(scene, uniqueKeys)) {
- onReady();
+ loadUnitBaseSheets(scene, uniqueKeys, () => {
+ loadUnitActionSheets(scene, uniqueKeys, () => onReady());
+ });
+}
+
+export function unitActionSheetsReady(scene: Phaser.Scene, keys: Iterable) {
+ return Array.from(keys).every((key) => scene.textures.exists(`${key}-actions`));
+}
+
+export function loadUnitActionSheets(
+ scene: Phaser.Scene,
+ keys: Iterable,
+ onComplete: (result: UnitActionSheetLoadResult) => void,
+ options: UnitActionSheetLoadOptions = {}
+) {
+ const uniqueKeys = Array.from(new Set(keys));
+ if (unitActionSheetsReady(scene, uniqueKeys)) {
+ onComplete({ readyKeys: uniqueKeys, missingKeys: [], failedKeys: [], timedOutKeys: [], timedOut: false });
return;
}
const pendingLoads: Array<{ key: string; url: string }> = [];
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 });
- }
if (!scene.textures.exists(sheet.actionKey) && !pendingUnitTextureKeys.has(sheet.actionKey)) {
pendingUnitTextureKeys.add(sheet.actionKey);
pendingLoads.push({ key: sheet.actionKey, url: sheet.actionUrl });
}
});
- if (pendingLoads.length <= 0) {
- waitForUnitSheets(scene, uniqueKeys, onReady);
- return;
- }
-
+ const actionKeys = new Set(uniqueKeys.map((key) => `${key}-actions`));
+ const failedActionKeys = new Set();
+ const watchdogMs = normalizeUnitSheetWatchdogMs(options.watchdogMs);
+ let settled = false;
+ let pollTimer: ReturnType | undefined;
+ let watchdogTimer: ReturnType | undefined;
const clearPendingLoads = () => {
pendingLoads.forEach(({ key }) => pendingUnitTextureKeys.delete(key));
};
-
- scene.load.once('complete', () => {
+ const cleanup = () => {
+ scene.load.off('complete', handleComplete);
+ scene.load.off('loaderror', handleLoadError);
+ scene.events.off('shutdown', handleShutdown);
clearPendingLoads();
- waitForUnitSheets(scene, uniqueKeys, onReady);
+ if (pollTimer !== undefined) {
+ clearInterval(pollTimer);
+ pollTimer = undefined;
+ }
+ if (watchdogTimer !== undefined) {
+ clearTimeout(watchdogTimer);
+ watchdogTimer = undefined;
+ }
+ };
+ const settle = (notify = true, timedOut = false) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ cleanup();
+ if (notify) {
+ finishUnitActionSheetLoad(scene, uniqueKeys, failedActionKeys, timedOut, onComplete);
+ }
+ };
+ const allRequestsSettled = () => uniqueKeys.every((key) => {
+ const actionKey = `${key}-actions`;
+ return scene.textures.exists(actionKey) || failedActionKeys.has(actionKey);
});
- scene.load.once('loaderror', clearPendingLoads);
+ const handleComplete = () => settle();
+ const handleLoadError = (file: Phaser.Loader.File) => {
+ const key = String(file.key);
+ if (!actionKeys.has(key)) {
+ return;
+ }
+ failedActionKeys.add(key);
+ if (allRequestsSettled()) {
+ settle();
+ }
+ };
+ const handleShutdown = () => settle(false);
+
+ scene.events.once('shutdown', handleShutdown);
+ scene.load.on('complete', handleComplete);
+ scene.load.on('loaderror', handleLoadError);
+ pollTimer = setInterval(() => {
+ if (unitActionSheetsReady(scene, uniqueKeys)) {
+ settle();
+ }
+ }, 50);
+ watchdogTimer = setTimeout(() => settle(true, true), watchdogMs);
+
+ if (pendingLoads.length <= 0) {
+ return;
+ }
+
pendingLoads.forEach(({ key, url }) => {
scene.load.spritesheet(key, url, {
frameWidth: unitSheetFrameSize,
@@ -157,8 +233,10 @@ export function loadUnitSheets(scene: Phaser.Scene, keys: Iterable, onRe
scene.load.start();
}
-function unitSheetsReady(scene: Phaser.Scene, keys: Iterable) {
- return Array.from(keys).every((key) => scene.textures.exists(key) && scene.textures.exists(`${key}-actions`));
+function normalizeUnitSheetWatchdogMs(value: number | undefined) {
+ return Number.isFinite(value)
+ ? Math.max(250, Math.min(defaultUnitActionSheetWatchdogMs, Math.round(value!)))
+ : defaultUnitActionSheetWatchdogMs;
}
function waitForUnitBaseSheetsAttempt(scene: Phaser.Scene, keys: string[], onReady: () => void, attempts = 0) {
@@ -177,18 +255,29 @@ function waitForUnitBaseSheetsAttempt(scene: Phaser.Scene, keys: string[], onRea
scene.time.delayedCall(50, () => waitForUnitBaseSheetsAttempt(scene, keys, onReady, attempts + 1));
}
-function waitForUnitSheets(scene: Phaser.Scene, keys: string[], onReady: () => void, attempts = 0) {
- if (unitSheetsReady(scene, keys)) {
- onReady();
- return;
+function finishUnitActionSheetLoad(
+ scene: Phaser.Scene,
+ keys: string[],
+ failedActionKeys: ReadonlySet,
+ timedOut: boolean,
+ onComplete: (result: UnitActionSheetLoadResult) => void
+) {
+ const missingKeys = keys.filter((key) => !scene.textures.exists(`${key}-actions`));
+ const failedKeys = missingKeys.filter((key) => failedActionKeys.has(`${key}-actions`) || !timedOut);
+ const timedOutKeys = timedOut ? missingKeys.filter((key) => !failedKeys.includes(key)) : [];
+ if (failedKeys.length > 0) {
+ console.warn(`Failed to load unit action sheets; base-sheet fallback will be used: ${failedKeys.join(', ')}`);
}
-
- if (attempts > 200) {
- const missing = keys.filter((key) => !scene.textures.exists(key) || !scene.textures.exists(`${key}-actions`));
- throw new Error(`Timed out waiting for unit sheet textures: ${missing.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(', ')}`);
}
-
- scene.time.delayedCall(50, () => waitForUnitSheets(scene, keys, onReady, attempts + 1));
+ onComplete({
+ readyKeys: keys.filter((key) => !missingKeys.includes(key)),
+ missingKeys,
+ failedKeys,
+ timedOutKeys,
+ timedOut: timedOutKeys.length > 0
+ });
}
export function ensureUnitAnimations(scene: Phaser.Scene, keys: Iterable) {
diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts
index 384e866..25993d8 100644
--- a/src/game/scenes/BattleScene.ts
+++ b/src/game/scenes/BattleScene.ts
@@ -16,7 +16,8 @@ import { getTerrainRule, getUnitClass, type TerrainType, type UnitClassKey } fro
import {
ensureUnitAnimations,
hasUnitSheetAsset,
- loadUnitSheets,
+ loadUnitActionSheets,
+ loadUnitBaseSheets,
unitBaseFramesPerDirection,
unitTextureVariantKeys as unitAssetTextureVariantKeys,
type UnitDirection
@@ -95,7 +96,9 @@ import {
campaignSortiePresetIds,
campaignSaveSlotCount,
cloneCampaignSortieRecommendationSnapshot,
+ completeCampaignTutorial,
getCampaignState,
+ hasCompletedCampaignTutorial,
listCampaignSaveSlots,
loadCampaignState,
markCampaignStep,
@@ -106,6 +109,7 @@ import {
setFirstBattleReport,
type CampaignRewardSnapshot,
type CampaignState,
+ type CampaignTutorialId,
type CampaignSortieItemAssignments,
type CampaignSortieOrderResultSnapshot,
type CampaignSortiePerformanceSnapshot,
@@ -128,6 +132,7 @@ import { startLazyScene } from './lazyScenes';
const battleReferenceWidth = 1280;
const battleReferenceHeight = 720;
const battleFhdUiScale = 1.5;
+const firstBattleTutorialId: CampaignTutorialId = 'first-battle-basic-controls';
const unitTexture: Record = {
'liu-bei': 'unit-liu-bei',
@@ -1350,6 +1355,7 @@ type UnitActionPose = 'attack' | 'strategy' | 'item' | 'hurt' | 'celebrate';
const levelUpCelebrationStepOffsets = [0, -7, -2, -9, 0, -6, -3, -8];
type BattlePhase = 'deployment' | 'idle' | 'moving' | 'command' | 'targeting' | 'animating' | 'resolved';
+type ScenarioCombatAssetStatus = 'idle' | 'loading' | 'ready' | 'degraded';
type BattleCommand = 'attack' | UsableCommand | 'wait';
type DamageCommand = Exclude;
type SpecialDamageMotionKind = 'item' | 'arcane' | 'fire' | 'roar';
@@ -1475,6 +1481,18 @@ type PendingMove = {
intentAfterMove: EnemyIntentSnapshot[];
};
+type FirstBattleTutorialStep = 'select-unit' | 'move' | 'attack-command' | 'target-preview';
+
+type FirstBattleTutorialPath = {
+ unitId: string;
+ from: { x: number; y: number };
+ moveTile: { x: number; y: number };
+ targetId: string;
+ targetTile: { x: number; y: number };
+};
+
+type FirstBattleTutorialCompletion = 'completed' | 'skipped';
+
type AttackIntent = {
attackerId: string;
targetId: string;
@@ -3445,6 +3463,15 @@ export class BattleScene extends Phaser.Scene {
private readonly pendingUiContainerSorts = new WeakSet();
private layout!: BattleLayout;
private phase: BattlePhase = 'idle';
+ private scenarioCombatAssetStatus: ScenarioCombatAssetStatus = 'idle';
+ private scenarioCombatAssetCallbacks: Array<() => void> = [];
+ private scenarioCombatAssetMissingActionKeys: string[] = [];
+ private scenarioCombatAssetMissingPortraitKeys: string[] = [];
+ private scenarioCombatAssetLoadStartedAt?: number;
+ private scenarioCombatAssetLoadCompletedAt?: number;
+ private scenarioCombatAssetLoadGeneration = 0;
+ private scenarioCombatAssetSettlementCount = 0;
+ private pendingDeploymentConfirmation = false;
private turnNumber = 1;
private activeFaction: ActiveFaction = 'ally';
private selectedUnit?: UnitData;
@@ -3602,6 +3629,15 @@ export class BattleScene extends Phaser.Scene {
private pointerFeedbackHintText?: Phaser.GameObjects.Text;
private lastPointerFeedbackKey?: string;
private suppressNextLeftClick = false;
+ private firstBattleTutorialStep?: FirstBattleTutorialStep;
+ private firstBattleTutorialPath?: FirstBattleTutorialPath;
+ private firstBattleTutorialFeedback = '';
+ private firstBattleTutorialTargetPreviewLocked = false;
+ private firstBattleTutorialCompletion?: FirstBattleTutorialCompletion;
+ private firstBattleTutorialObjects: Phaser.GameObjects.GameObject[] = [];
+ private firstBattleTutorialHighlight?: Phaser.GameObjects.Rectangle;
+ private firstBattleTutorialCard?: Phaser.GameObjects.Rectangle;
+ private firstBattleTutorialSkipButton?: Phaser.GameObjects.Rectangle;
private approachPathCostCache = new Map>();
private launchSortieUnitIds: string[] = [];
private launchSortieFormationAssignments: SortieFormationAssignments = {};
@@ -3620,6 +3656,15 @@ export class BattleScene extends Phaser.Scene {
init(data?: BattleSceneData) {
configureBattleScenario(getBattleScenario(data?.battleId));
+ this.scenarioCombatAssetLoadGeneration += 1;
+ this.scenarioCombatAssetStatus = 'idle';
+ this.scenarioCombatAssetCallbacks = [];
+ this.scenarioCombatAssetMissingActionKeys = [];
+ this.scenarioCombatAssetMissingPortraitKeys = [];
+ this.scenarioCombatAssetLoadStartedAt = undefined;
+ this.scenarioCombatAssetLoadCompletedAt = undefined;
+ this.scenarioCombatAssetSettlementCount = 0;
+ this.pendingDeploymentConfirmation = false;
this.rosterTab = 'ally';
this.rosterPage = 0;
this.bondPage = 0;
@@ -3637,6 +3682,15 @@ export class BattleScene extends Phaser.Scene {
this.sidePanelTransitionCompletedRevision = 0;
this.sidePanelTransitionActive = false;
this.sidePanelTransitionLast = undefined;
+ this.firstBattleTutorialStep = undefined;
+ this.firstBattleTutorialPath = undefined;
+ this.firstBattleTutorialFeedback = '';
+ this.firstBattleTutorialTargetPreviewLocked = false;
+ this.firstBattleTutorialCompletion = undefined;
+ this.firstBattleTutorialObjects = [];
+ this.firstBattleTutorialHighlight = undefined;
+ this.firstBattleTutorialCard = undefined;
+ this.firstBattleTutorialSkipButton = undefined;
const campaign = getCampaignState();
const selectedOrderId = campaign.sortieOrderSelection?.battleId === battleScenario.id
? campaign.sortieOrderSelection.orderId
@@ -3681,6 +3735,10 @@ export class BattleScene extends Phaser.Scene {
this.tacticalCommandCutInLast = undefined;
this.bondChainJudgementLast = undefined;
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
+ this.scenarioCombatAssetLoadGeneration += 1;
+ this.scenarioCombatAssetCallbacks = [];
+ this.pendingDeploymentConfirmation = false;
+ this.hideFirstBattleTutorial();
this.hideTacticalCommandCutIn();
this.hideCombatCutIn();
this.mapResultPopupObjects.forEach((object) => object.active && object.destroy());
@@ -3891,8 +3949,12 @@ export class BattleScene extends Phaser.Scene {
this.updateTurnText();
if (this.shouldShowPreBattleDeployment()) {
this.showPreBattleDeployment();
+ this.time.delayedCall(250, () => this.ensureScenarioCombatAssets());
} else {
- this.time.delayedCall(180, () => this.showOpeningBattleEvent());
+ this.time.delayedCall(180, () => {
+ this.showOpeningBattleEvent();
+ this.scheduleFirstBattleTutorial();
+ });
this.renderRosterPanel('ally', '행동할 장수를 선택하세요.');
this.refreshEnemyIntentForecast();
}
@@ -3902,7 +3964,13 @@ export class BattleScene extends Phaser.Scene {
private ensureScenarioAssets(onReady: () => void) {
this.ensureScenarioMapTexture(() =>
loadBattleUiIcons(this, () =>
- this.ensureScenarioUnitTextures(() => this.ensureScenarioCombatPortraits(onReady))
+ this.ensureScenarioUnitBaseTextures(() => {
+ if (this.shouldShowPreBattleDeployment()) {
+ onReady();
+ return;
+ }
+ this.ensureScenarioCombatAssets(onReady);
+ })
)
);
}
@@ -3942,9 +4010,13 @@ export class BattleScene extends Phaser.Scene {
this.load.start();
}
- private ensureScenarioUnitTextures(onReady: () => void) {
- const textureKeys = Array.from(new Set([...battleUnits.map((unit) => this.unitTextureKey(unit)), ...this.debugSpritePreviewKeys()]));
- const texturesReady = textureKeys.every((key) => this.textures.exists(key) && this.textures.exists(`${key}-actions`));
+ private scenarioUnitTextureKeys() {
+ return Array.from(new Set([...battleUnits.map((unit) => this.unitTextureKey(unit)), ...this.debugSpritePreviewKeys()]));
+ }
+
+ private ensureScenarioUnitBaseTextures(onReady: () => void) {
+ const textureKeys = this.scenarioUnitTextureKeys();
+ const texturesReady = textureKeys.every((key) => this.textures.exists(key));
if (texturesReady) {
ensureUnitAnimations(this, textureKeys);
@@ -3967,7 +4039,7 @@ export class BattleScene extends Phaser.Scene {
};
this.load.once('loaderror', handleLoadError);
- loadUnitSheets(this, textureKeys, () => {
+ loadUnitBaseSheets(this, textureKeys, () => {
this.load.off('loaderror', handleLoadError);
loadingText.destroy();
ensureUnitAnimations(this, textureKeys);
@@ -3975,7 +4047,65 @@ export class BattleScene extends Phaser.Scene {
});
}
- private ensureScenarioCombatPortraits(onReady: () => void) {
+ private ensureScenarioCombatAssets(onReady?: () => void) {
+ if (onReady) {
+ this.scenarioCombatAssetCallbacks.push(onReady);
+ }
+ if (this.scenarioCombatAssetStatus === 'ready' || this.scenarioCombatAssetStatus === 'degraded') {
+ this.flushScenarioCombatAssetCallbacks();
+ return;
+ }
+ if (this.scenarioCombatAssetStatus === 'loading') {
+ return;
+ }
+
+ this.scenarioCombatAssetStatus = 'loading';
+ this.scenarioCombatAssetLoadStartedAt = Date.now();
+ this.scenarioCombatAssetLoadCompletedAt = undefined;
+ const generation = this.scenarioCombatAssetLoadGeneration;
+ const watchdogMs = this.scenarioCombatAssetWatchdogMs();
+ const textureKeys = this.scenarioUnitTextureKeys();
+ loadUnitActionSheets(this, textureKeys, (actionResult) => {
+ if (generation !== this.scenarioCombatAssetLoadGeneration) {
+ return;
+ }
+ this.scenarioCombatAssetMissingActionKeys = [...actionResult.missingKeys];
+ this.ensureScenarioCombatPortraits(() => {
+ if (generation !== this.scenarioCombatAssetLoadGeneration) {
+ return;
+ }
+ this.scenarioCombatAssetLoadCompletedAt = Date.now();
+ this.scenarioCombatAssetStatus =
+ actionResult.missingKeys.length > 0 || this.scenarioCombatAssetMissingPortraitKeys.length > 0
+ ? 'degraded'
+ : 'ready';
+ this.scenarioCombatAssetSettlementCount += 1;
+ this.flushScenarioCombatAssetCallbacks();
+ if (this.pendingDeploymentConfirmation && this.phase === 'deployment') {
+ this.pendingDeploymentConfirmation = false;
+ this.confirmPreBattleDeployment();
+ }
+ }, watchdogMs, generation);
+ }, { watchdogMs });
+ }
+
+ private scenarioCombatAssetWatchdogMs() {
+ const fallback = 15_000;
+ if (!this.debugToolsEnabled() || typeof window === 'undefined') {
+ return fallback;
+ }
+ const requested = Number(new URLSearchParams(window.location.search).get('debugCombatAssetWatchdogMs'));
+ return Number.isFinite(requested)
+ ? Phaser.Math.Clamp(Math.round(requested), 250, fallback)
+ : fallback;
+ }
+
+ private flushScenarioCombatAssetCallbacks() {
+ const callbacks = this.scenarioCombatAssetCallbacks.splice(0);
+ callbacks.forEach((callback) => callback());
+ }
+
+ private ensureScenarioCombatPortraits(onReady: () => void, watchdogMs: number, generation: number) {
const portraitEntries = Array.from(
new Map(
battleUnits
@@ -3987,6 +4117,7 @@ export class BattleScene extends Phaser.Scene {
const missingEntries = portraitEntries.filter((entry) => !this.textures.exists(entry.textureKey));
if (missingEntries.length === 0) {
+ this.scenarioCombatAssetMissingPortraitKeys = [];
onReady();
return;
}
@@ -4004,12 +4135,52 @@ export class BattleScene extends Phaser.Scene {
const handleLoadError = () => {
loadingText.setText('일부 장수 초상을 불러오지 못했습니다.');
};
- this.load.on('loaderror', handleLoadError);
- this.load.once('complete', () => {
+ loadingText.setVisible(false);
+ let settled = false;
+ let pollTimer: ReturnType | undefined;
+ let watchdogTimer: ReturnType | undefined;
+ const handleComplete = () => settle();
+ const handleShutdown = () => settle(false);
+ const cleanup = () => {
+ this.load.off('complete', handleComplete);
this.load.off('loaderror', handleLoadError);
- loadingText.destroy();
+ this.events.off(Phaser.Scenes.Events.SHUTDOWN, handleShutdown);
+ if (pollTimer !== undefined) {
+ clearInterval(pollTimer);
+ pollTimer = undefined;
+ }
+ if (watchdogTimer !== undefined) {
+ clearTimeout(watchdogTimer);
+ watchdogTimer = undefined;
+ }
+ };
+ const settle = (notify = true) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ cleanup();
+ if (loadingText.active) {
+ loadingText.destroy();
+ }
+ if (!notify || generation !== this.scenarioCombatAssetLoadGeneration) {
+ return;
+ }
+ this.scenarioCombatAssetMissingPortraitKeys = missingEntries
+ .map((entry) => entry.textureKey)
+ .filter((key) => !this.textures.exists(key));
onReady();
- });
+ };
+
+ this.load.on('loaderror', handleLoadError);
+ this.load.on('complete', handleComplete);
+ this.events.once(Phaser.Scenes.Events.SHUTDOWN, handleShutdown);
+ pollTimer = setInterval(() => {
+ if (missingEntries.every((entry) => this.textures.exists(entry.textureKey))) {
+ settle();
+ }
+ }, 50);
+ watchdogTimer = setTimeout(() => settle(), watchdogMs);
missingEntries.forEach((entry) => this.load.image(entry.textureKey, entry.url));
this.load.start();
}
@@ -5688,7 +5859,7 @@ export class BattleScene extends Phaser.Scene {
const portraitKey = this.combatPortraitKey(actor);
const portrait = portraitKey && this.textures.exists(portraitKey)
? this.fitPortraitToFrame(this.add.image(92, height / 2, portraitKey), 132, 166)
- : this.add.sprite(92, height / 2, this.unitActionTexture(actor), this.unitActionFrameIndex('south', 'strategy'))
+ : this.add.sprite(92, height / 2, this.unitActionTexture(actor), this.unitActionFrame(actor, 'south', 'strategy'))
.setDisplaySize(132, 132);
const iconFrame = this.add.rectangle(202, 42, 54, 54, 0x0a0f14, 0.96);
@@ -5961,11 +6132,15 @@ export class BattleScene extends Phaser.Scene {
idle50.setDepth(depth + 1);
this.applyBaseSpriteBlend(idle50);
+ const actionTextureKey = `${textureKey}-actions`;
+ const actionTextureReady = this.textures.exists(actionTextureKey);
const action = this.add.sprite(
left + 196,
top + 76,
- `${textureKey}-actions`,
- this.unitActionFrameIndex('east', 'attack', this.unitActionImpactFrame('attack'))
+ actionTextureReady ? actionTextureKey : textureKey,
+ actionTextureReady
+ ? this.unitActionFrameIndex('east', 'attack', this.unitActionImpactFrame('attack'))
+ : this.unitFrameIndex('east')
);
action.setDisplaySize(78, 78);
action.setDepth(depth + 1);
@@ -6250,6 +6425,7 @@ export class BattleScene extends Phaser.Scene {
this.positionBondChainReadyMarkers();
this.positionObjectiveMapMarkers();
+ this.positionFirstBattleTutorialHighlight();
battleUnits.forEach((unit) => this.positionUnitView(unit));
this.positionDeploymentOverlayObjects();
this.updateMiniMap();
@@ -6817,6 +6993,31 @@ export class BattleScene extends Phaser.Scene {
return;
}
+ const tutorialPath = this.firstBattleTutorialPath;
+ const tutorialStep = this.firstBattleTutorialStep;
+ const completingTutorialSelect = Boolean(
+ tutorialPath && tutorialStep === 'select-unit' && unit.id === tutorialPath.unitId
+ );
+ if (tutorialPath && tutorialStep === 'select-unit' && unit.id !== tutorialPath.unitId) {
+ const expected = battleUnits.find((candidate) => candidate.id === tutorialPath.unitId);
+ this.showFirstBattleTutorialFeedback(`${unit.name}이 아니라 빛나는 ${expected?.name ?? '아군'}을 먼저 선택하세요.`);
+ return;
+ }
+ if (tutorialPath && tutorialStep === 'move' && unit.id !== tutorialPath.unitId) {
+ this.showFirstBattleTutorialFeedback('선택한 아군의 이동을 먼저 마치세요. 빛나는 이동칸을 클릭하면 됩니다.');
+ return;
+ }
+ if (tutorialPath && tutorialStep === 'attack-command') {
+ this.showFirstBattleTutorialFeedback('다른 부대를 선택하기 전에 열린 명령 카드에서 공격을 선택하세요.');
+ return;
+ }
+ if (tutorialPath && tutorialStep === 'target-preview' && unit.id !== tutorialPath.targetId) {
+ const expected = battleUnits.find((candidate) => candidate.id === tutorialPath.targetId);
+ this.showFirstBattleTutorialFeedback(`${unit.name}은 지금의 권장 대상이 아닙니다. 빛나는 ${expected?.name ?? '적'}을 선택하세요.`);
+ this.renderCurrentTargetingGuideNotice('튜토리얼: 빛나는 적을 선택해 공격 예측을 확인하세요.');
+ return;
+ }
+
this.hideMapMenu();
this.hideSaveSlotPanel();
this.hideTurnEndPrompt();
@@ -6904,6 +7105,10 @@ export class BattleScene extends Phaser.Scene {
this.updateMiniMap();
this.refreshUnitLegibilityStyles();
this.renderUnitDetail(unit, '이동할 파란 칸을 선택하세요.');
+ if (completingTutorialSelect) {
+ this.suppressNextLeftClick = true;
+ this.setFirstBattleTutorialStep('move');
+ }
}
private showMoveRange(unit: UnitData) {
@@ -6942,6 +7147,17 @@ export class BattleScene extends Phaser.Scene {
}
private moveSelectedUnit(x: number, y: number, pointer?: Phaser.Input.Pointer) {
+ if (this.isFirstBattleTutorialActive()) {
+ const path = this.firstBattleTutorialPath!;
+ if (this.firstBattleTutorialStep !== 'move') {
+ this.showFirstBattleTutorialFeedback('현재 빛나는 단계의 조작을 먼저 완료하세요.');
+ return;
+ }
+ if (this.selectedUnit?.id !== path.unitId || x !== path.moveTile.x || y !== path.moveTile.y) {
+ this.showFirstBattleTutorialFeedback('이 칸에서는 권장 적을 공격할 수 없습니다. 빛나는 이동칸을 선택하세요.');
+ return;
+ }
+ }
if (!this.selectedUnit || !this.canMoveTo(this.selectedUnit, x, y)) {
return;
}
@@ -6985,6 +7201,10 @@ export class BattleScene extends Phaser.Scene {
this.showCommandMenu(unit, commandX, commandY);
this.renderUnitDetail(unit, '명령 선택 · 우클릭은 이동 취소');
this.hideTurnEndPrompt();
+ if (this.firstBattleTutorialStep === 'move') {
+ this.suppressNextLeftClick = true;
+ this.setFirstBattleTutorialStep('attack-command');
+ }
}
private showPostMoveTurnEndPrompt(unit: UnitData, commandX = this.tileCenterX(unit.x), commandY = this.tileCenterY(unit.y)) {
@@ -8035,6 +8255,14 @@ export class BattleScene extends Phaser.Scene {
return;
}
+ if (this.scenarioCombatAssetStatus !== 'ready' && this.scenarioCombatAssetStatus !== 'degraded') {
+ this.pendingDeploymentConfirmation = true;
+ this.deploymentNotice = '\uC804\uD22C \uB3D9\uC791\uC744 \uC900\uBE44\uD558\uACE0 \uC788\uC2B5\uB2C8\uB2E4. \uC900\uBE44\uAC00 \uB05D\uB098\uBA74 \uC790\uB3D9\uC73C\uB85C \uC804\uD22C\uB97C \uC2DC\uC791\uD569\uB2C8\uB2E4.';
+ this.renderDeploymentPanel();
+ this.ensureScenarioCombatAssets();
+ return;
+ }
+
this.clearDeploymentObjects();
this.clearPointerFeedback();
this.deploymentSelectedUnitId = undefined;
@@ -8046,6 +8274,7 @@ export class BattleScene extends Phaser.Scene {
this.renderRosterPanel('ally', '출전 위치 확정. 행동할 장수를 선택하세요.');
this.refreshEnemyIntentForecast();
this.showOpeningBattleEvent();
+ this.scheduleFirstBattleTutorial();
soundDirector.playSelect();
}
@@ -8924,6 +9153,9 @@ export class BattleScene extends Phaser.Scene {
}
if (choicePointer.leftButtonDown()) {
this.suppressNextLeftClick = true;
+ if (!this.acceptFirstBattleTutorialCommand(command)) {
+ return;
+ }
this.hideTurnEndPrompt();
if (command === 'strategy' || command === 'item') {
this.showUsableMenu(unit, command, choicePointer.x, choicePointer.y);
@@ -8932,6 +9164,9 @@ export class BattleScene extends Phaser.Scene {
if (command !== 'wait') {
this.beginDamageTargeting(unit, command);
+ if (command === 'attack' && this.phase === 'targeting' && this.firstBattleTutorialStep === 'attack-command') {
+ this.setFirstBattleTutorialStep('target-preview');
+ }
return;
}
@@ -10378,6 +10613,18 @@ export class BattleScene extends Phaser.Scene {
usable?: BattleUsable,
preview?: CombatPreview
) {
+ const tutorialPath = this.firstBattleTutorialPath;
+ const tutorialTargetStep = this.firstBattleTutorialStep === 'target-preview';
+ if (tutorialPath && tutorialTargetStep) {
+ if (action !== 'attack' || usable || attacker.id !== tutorialPath.unitId || target.id !== tutorialPath.targetId) {
+ const expected = battleUnits.find((candidate) => candidate.id === tutorialPath.targetId);
+ this.clearLockedTargetPreview();
+ this.firstBattleTutorialTargetPreviewLocked = false;
+ this.showFirstBattleTutorialFeedback(`${target.name}은 권장 대상이 아닙니다. 빛나는 ${expected?.name ?? '적'}을 선택하세요.`);
+ this.renderCurrentTargetingGuideNotice('튜토리얼: 빛나는 적을 선택해 공격 예측을 확인하세요.');
+ return;
+ }
+ }
if (!this.canUseDamageCommand(attacker, target, action, usable)) {
this.clearLockedTargetPreview();
this.renderCurrentTargetingGuideNotice(this.damageTargetFailureMessage(attacker, target, action, usable));
@@ -10385,11 +10632,19 @@ export class BattleScene extends Phaser.Scene {
}
if (this.isLockedDamageTarget(attacker, target, action, usable)) {
+ if (tutorialTargetStep) {
+ this.completeFirstBattleTutorial('completed');
+ }
void this.tryResolveDamageTarget(attacker, target, action, usable);
return;
}
this.lockDamageTargetPreview(attacker, target, action, usable, preview ?? this.combatPreview(attacker, target, action, usable));
+ if (tutorialTargetStep) {
+ this.firstBattleTutorialTargetPreviewLocked = true;
+ this.firstBattleTutorialFeedback = '';
+ this.renderFirstBattleTutorial();
+ }
}
private chooseSupportTarget(user: UnitData, target: UnitData, usable: BattleUsable, preview?: SupportPreview) {
@@ -14291,7 +14546,7 @@ export class BattleScene extends Phaser.Scene {
let sprite: Phaser.GameObjects.Sprite | undefined;
if (animation.unit) {
sprite = this.trackResultSettlementTransientObject(
- this.add.sprite(spriteX, spriteY + 2, this.unitActionTexture(animation.unit), this.unitActionFrameIndex('south', this.celebrationPose(animation.unit)))
+ this.add.sprite(spriteX, spriteY + 2, this.unitActionTexture(animation.unit), this.unitActionFrame(animation.unit, 'south', this.celebrationPose(animation.unit)))
);
sprite.setDisplaySize(70, 70);
sprite.setDepth(depth + 6);
@@ -14356,7 +14611,7 @@ export class BattleScene extends Phaser.Scene {
}
if (animation.unit) {
- const sprite = this.add.sprite(visualX, 8, this.unitActionTexture(animation.unit), this.unitActionFrameIndex('south', this.celebrationPose(animation.unit)));
+ const sprite = this.add.sprite(visualX, 8, this.unitActionTexture(animation.unit), this.unitActionFrame(animation.unit, 'south', this.celebrationPose(animation.unit)));
sprite.setDisplaySize(82, 82);
container.add(sprite);
void this.playUnitActionFrames(sprite, animation.unit, 'south', this.celebrationPose(animation.unit), 105, 2);
@@ -15626,6 +15881,495 @@ export class BattleScene extends Phaser.Scene {
};
}
+ private scheduleFirstBattleTutorial() {
+ if (
+ battleScenario.id !== 'first-battle-zhuo-commandery' ||
+ hasCompletedCampaignTutorial(firstBattleTutorialId) ||
+ this.firstBattleTutorialStep
+ ) {
+ return;
+ }
+
+ const attemptStart = (attemptsRemaining: number) => {
+ if (
+ this.scene.key !== 'BattleScene' ||
+ this.battleOutcome ||
+ hasCompletedCampaignTutorial(firstBattleTutorialId) ||
+ this.firstBattleTutorialStep
+ ) {
+ return;
+ }
+ if (this.phase === 'idle' && this.activeFaction === 'ally' && this.turnNumber === 1) {
+ this.startFirstBattleTutorial();
+ return;
+ }
+ if (attemptsRemaining > 0) {
+ this.time.delayedCall(180, () => attemptStart(attemptsRemaining - 1));
+ }
+ };
+ attemptStart(12);
+ }
+
+ private startFirstBattleTutorial() {
+ const path = this.createFirstBattleTutorialPath();
+ if (!path) {
+ return;
+ }
+
+ this.firstBattleTutorialPath = path;
+ this.firstBattleTutorialStep = 'select-unit';
+ this.firstBattleTutorialFeedback = '';
+ this.firstBattleTutorialTargetPreviewLocked = false;
+ this.firstBattleTutorialCompletion = undefined;
+ this.ensureFirstBattleTutorialPathVisible();
+ this.renderFirstBattleTutorial();
+ }
+
+ private createFirstBattleTutorialPath(): FirstBattleTutorialPath | undefined {
+ const allyPriority = ['guan-yu', 'zhang-fei', 'liu-bei'];
+ const targetPriority = ['rebel-a'];
+ const allies = battleUnits
+ .filter((unit) => unit.faction === 'ally' && unit.hp > 0 && !this.actedUnitIds.has(unit.id))
+ .sort((left, right) => {
+ const leftRank = allyPriority.indexOf(left.id);
+ const rightRank = allyPriority.indexOf(right.id);
+ return (leftRank < 0 ? allyPriority.length : leftRank) - (rightRank < 0 ? allyPriority.length : rightRank);
+ });
+ const enemies = battleUnits
+ .filter((unit) => unit.faction === 'enemy' && unit.hp > 0)
+ .sort((left, right) => {
+ const leftRank = targetPriority.indexOf(left.id);
+ const rightRank = targetPriority.indexOf(right.id);
+ return (leftRank < 0 ? targetPriority.length : leftRank) - (rightRank < 0 ? targetPriority.length : rightRank);
+ });
+
+ const candidates = allies.flatMap((unit, allyIndex) =>
+ this.reachableTiles(unit)
+ .filter((tile) => tile.x !== unit.x || tile.y !== unit.y)
+ .flatMap((tile) => enemies
+ .filter((target) => this.tileDistanceTo(tile.x, tile.y, target.x, target.y) <= this.attackRange(unit))
+ .map((target, targetIndex) => ({
+ unit,
+ tile,
+ target,
+ score:
+ (target.id === 'rebel-a' ? 10000 : 0) +
+ (unit.id === 'guan-yu' ? 1000 : unit.id === 'zhang-fei' ? 500 : 0) -
+ allyIndex * 20 -
+ targetIndex * 5 +
+ tile.cost
+ })))
+ ).sort((left, right) => right.score - left.score);
+ const selected = candidates[0];
+ if (!selected) {
+ return undefined;
+ }
+
+ return {
+ unitId: selected.unit.id,
+ from: { x: selected.unit.x, y: selected.unit.y },
+ moveTile: { x: selected.tile.x, y: selected.tile.y },
+ targetId: selected.target.id,
+ targetTile: { x: selected.target.x, y: selected.target.y }
+ };
+ }
+
+ private ensureFirstBattleTutorialPathVisible() {
+ const path = this.firstBattleTutorialPath;
+ if (!path) {
+ return;
+ }
+ const minX = Math.min(path.from.x, path.moveTile.x, path.targetTile.x);
+ const maxX = Math.max(path.from.x, path.moveTile.x, path.targetTile.x);
+ const minY = Math.min(path.from.y, path.moveTile.y, path.targetTile.y);
+ const maxY = Math.max(path.from.y, path.moveTile.y, path.targetTile.y);
+ if (
+ minX < this.cameraTileX ||
+ maxX >= this.cameraTileX + this.layout.visibleColumns ||
+ minY < this.cameraTileY ||
+ maxY >= this.cameraTileY + this.layout.visibleRows
+ ) {
+ this.centerCameraOnTile(Math.floor((minX + maxX) / 2), Math.floor((minY + maxY) / 2));
+ }
+ }
+
+ private isFirstBattleTutorialActive() {
+ return Boolean(this.firstBattleTutorialStep && this.firstBattleTutorialPath);
+ }
+
+ private firstBattleTutorialStepNumber() {
+ switch (this.firstBattleTutorialStep) {
+ case 'select-unit':
+ return 1;
+ case 'move':
+ return 2;
+ case 'attack-command':
+ return 3;
+ case 'target-preview':
+ return 4;
+ default:
+ return 0;
+ }
+ }
+
+ private firstBattleTutorialCopy() {
+ const path = this.firstBattleTutorialPath;
+ const unit = path ? battleUnits.find((candidate) => candidate.id === path.unitId) : undefined;
+ const target = path ? battleUnits.find((candidate) => candidate.id === path.targetId) : undefined;
+ switch (this.firstBattleTutorialStep) {
+ case 'select-unit':
+ return {
+ title: '아군 선택',
+ body: `${unit?.name ?? '빛나는 아군'}을 클릭해 행동을 시작하세요.`
+ };
+ case 'move':
+ return {
+ title: '권장 칸으로 이동',
+ body: `${unit?.name ?? '선택한 아군'}을 빛나는 이동 칸으로 전진시키세요.`
+ };
+ case 'attack-command':
+ return {
+ title: '공격 명령',
+ body: '열린 명령 카드에서 공격을 선택하세요.'
+ };
+ case 'target-preview':
+ return this.firstBattleTutorialTargetPreviewLocked
+ ? {
+ title: '공격 확정',
+ body: `피해 예측을 확인했습니다. ${target?.name ?? '같은 대상'}을 한 번 더 클릭해 실행하세요.`
+ }
+ : {
+ title: '공격 대상 확인',
+ body: `${target?.name ?? '빛나는 적'}을 클릭해 피해·명중 예측을 먼저 확인하세요.`
+ };
+ default:
+ return { title: '', body: '' };
+ }
+ }
+
+ private firstBattleTutorialCardPosition(width: number, height: number) {
+ const inset = 18;
+ const candidates = [
+ { left: this.layout.mapX + inset, top: this.layout.mapY + inset },
+ { left: this.layout.mapX + this.layout.mapWidth - width - inset, top: this.layout.mapY + inset },
+ { left: this.layout.mapX + inset, top: this.layout.mapY + this.layout.mapHeight - height - inset },
+ {
+ left: this.layout.mapX + this.layout.mapWidth - width - inset,
+ top: this.layout.mapY + this.layout.mapHeight - height - inset
+ }
+ ];
+ const path = this.firstBattleTutorialPath;
+ const tileBounds = path
+ ? [path.from, path.moveTile, path.targetTile].map((tile) => ({
+ x: this.tileTopLeftX(tile.x) - 10,
+ y: this.tileTopLeftY(tile.y) - 10,
+ width: this.layout.tileSize + 20,
+ height: this.layout.tileSize + 20
+ }))
+ : [];
+ const commandBounds = this.gameObjectCollectionBoundsDebug(this.commandMenuObjects);
+ const openingBounds = this.gameObjectCollectionBoundsDebug(this.battleEventObjects);
+ const obstacles = [
+ ...tileBounds,
+ ...(commandBounds ? [commandBounds] : []),
+ ...(openingBounds ? [openingBounds] : [])
+ ];
+ const pathCenter = path
+ ? {
+ x: (this.tileCenterX(path.from.x) + this.tileCenterX(path.moveTile.x) + this.tileCenterX(path.targetTile.x)) / 3,
+ y: (this.tileCenterY(path.from.y) + this.tileCenterY(path.moveTile.y) + this.tileCenterY(path.targetTile.y)) / 3
+ }
+ : { x: this.layout.mapX, y: this.layout.mapY };
+ const intersects = (
+ left: { x: number; y: number; width: number; height: number },
+ right: { x: number; y: number; width: number; height: number }
+ ) => !(
+ left.x + left.width <= right.x ||
+ right.x + right.width <= left.x ||
+ left.y + left.height <= right.y ||
+ right.y + right.height <= left.y
+ );
+
+ return candidates
+ .map((candidate) => {
+ const bounds = { x: candidate.left, y: candidate.top, width, height };
+ const overlapCount = obstacles.filter((obstacle) => intersects(bounds, obstacle)).length;
+ const centerX = candidate.left + width / 2;
+ const centerY = candidate.top + height / 2;
+ const distanceScore = (centerX - pathCenter.x) ** 2 + (centerY - pathCenter.y) ** 2;
+ return { ...candidate, score: distanceScore - overlapCount * 10_000_000 };
+ })
+ .sort((left, right) => right.score - left.score)[0];
+ }
+
+ private renderFirstBattleTutorial() {
+ if (!this.isFirstBattleTutorialActive()) {
+ this.hideFirstBattleTutorial();
+ return;
+ }
+
+ this.hideFirstBattleTutorial();
+ const stepNumber = this.firstBattleTutorialStepNumber();
+ const copy = this.firstBattleTutorialCopy();
+ const width = Math.min(540, this.layout.mapWidth - 36);
+ const height = 148;
+ const position = this.firstBattleTutorialCardPosition(width, height);
+ const left = position.left;
+ const top = position.top;
+ const depth = 52;
+ const panel = this.trackFirstBattleTutorialObject(this.add.rectangle(left, top, width, height, 0x09131b, 0.98));
+ panel.setName('first-battle-tutorial-card');
+ panel.setOrigin(0);
+ panel.setDepth(depth);
+ panel.setStrokeStyle(2, palette.gold, 0.94);
+ panel.setInteractive();
+ this.firstBattleTutorialCard = panel;
+
+ const stepBadge = this.trackFirstBattleTutorialObject(this.add.rectangle(left + 22, top + 25, 72, 34, 0x5b211d, 0.98));
+ stepBadge.setDepth(depth + 1);
+ stepBadge.setStrokeStyle(1, 0xf0c778, 0.92);
+ const stepText = this.trackFirstBattleTutorialObject(this.add.text(left + 22, top + 25, `${stepNumber}/4`, {
+ fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
+ fontSize: '16px',
+ color: '#fff0bd',
+ fontStyle: '700'
+ }));
+ stepText.setOrigin(0.5);
+ stepText.setDepth(depth + 2);
+
+ const title = this.trackFirstBattleTutorialObject(this.add.text(left + 72, top + 12, copy.title, {
+ fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
+ fontSize: '23px',
+ color: '#f4dfad',
+ fontStyle: '700',
+ stroke: '#05070a',
+ strokeThickness: 3
+ }));
+ title.setDepth(depth + 1);
+ const body = this.trackFirstBattleTutorialObject(this.add.text(left + 22, top + 60, copy.body, {
+ fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
+ fontSize: '16px',
+ color: '#e1e8ef',
+ fontStyle: '700',
+ wordWrap: { width: width - 44, useAdvancedWrap: true }
+ }));
+ body.setDepth(depth + 1);
+ const feedback = this.trackFirstBattleTutorialObject(this.add.text(
+ left + 22,
+ top + 111,
+ this.firstBattleTutorialFeedback || '빛나는 표시를 따라 실제로 조작하세요. · 우클릭/ESC는 이전 단계',
+ {
+ fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
+ fontSize: '12px',
+ color: this.firstBattleTutorialFeedback ? '#ffb08e' : '#9fb0bf',
+ fontStyle: '700',
+ fixedWidth: width - 44
+ }
+ ));
+ feedback.setDepth(depth + 1);
+
+ const skipWidth = 96;
+ const skipHeight = 32;
+ const skipX = left + width - skipWidth - 18;
+ const skipY = top + 16;
+ const skip = this.trackFirstBattleTutorialObject(this.add.rectangle(skipX, skipY, skipWidth, skipHeight, 0x17232e, 0.98));
+ skip.setName('first-battle-tutorial-skip');
+ skip.setOrigin(0);
+ skip.setDepth(depth + 2);
+ skip.setStrokeStyle(1, 0x7f8994, 0.82);
+ skip.setInteractive({ useHandCursor: true });
+ const skipText = this.trackFirstBattleTutorialObject(this.add.text(skipX + skipWidth / 2, skipY + skipHeight / 2, '건너뛰기', {
+ fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
+ fontSize: '13px',
+ color: '#d4dce6',
+ fontStyle: '700'
+ }));
+ skipText.setOrigin(0.5);
+ skipText.setDepth(depth + 3);
+ skipText.setInteractive({ useHandCursor: true });
+ const skipTutorial = () => {
+ this.suppressNextLeftClick = true;
+ soundDirector.playSelect();
+ this.completeFirstBattleTutorial('skipped');
+ };
+ skip.on('pointerdown', skipTutorial);
+ skipText.on('pointerdown', skipTutorial);
+ this.firstBattleTutorialSkipButton = skip;
+ this.renderFirstBattleTutorialHighlight();
+ }
+
+ private renderFirstBattleTutorialHighlight() {
+ const path = this.firstBattleTutorialPath;
+ if (!path || !this.firstBattleTutorialStep) {
+ return;
+ }
+
+ let bounds: { x: number; y: number; width: number; height: number } | undefined;
+ let tile: { x: number; y: number } | undefined;
+ if (this.firstBattleTutorialStep === 'select-unit') {
+ tile = path.from;
+ } else if (this.firstBattleTutorialStep === 'move') {
+ tile = path.moveTile;
+ } else if (this.firstBattleTutorialStep === 'target-preview') {
+ tile = path.targetTile;
+ } else {
+ const attackButton = this.commandButtons.find((button) => button.command === 'attack')?.background;
+ const buttonBounds = attackButton?.active ? attackButton.getBounds() : undefined;
+ if (buttonBounds) {
+ bounds = { x: buttonBounds.x, y: buttonBounds.y, width: buttonBounds.width, height: buttonBounds.height };
+ }
+ }
+
+ if (tile) {
+ bounds = {
+ x: this.tileTopLeftX(tile.x),
+ y: this.tileTopLeftY(tile.y),
+ width: this.layout.tileSize,
+ height: this.layout.tileSize
+ };
+ }
+ if (!bounds) {
+ return;
+ }
+
+ const highlight = this.trackFirstBattleTutorialObject(
+ this.add.rectangle(bounds.x, bounds.y, bounds.width, bounds.height, palette.gold, 0.12)
+ );
+ highlight.setName('first-battle-tutorial-highlight');
+ highlight.setOrigin(0);
+ highlight.setDepth(49);
+ highlight.setStrokeStyle(4, 0xffec9d, 1);
+ if (tile) {
+ highlight.setData('tutorialTileX', tile.x);
+ highlight.setData('tutorialTileY', tile.y);
+ if (this.mapMask) {
+ highlight.setMask(this.mapMask);
+ }
+ highlight.setVisible(this.isTileVisible(tile.x, tile.y));
+ }
+ this.tweens.add({
+ targets: highlight,
+ alpha: 0.34,
+ duration: 540,
+ yoyo: true,
+ repeat: -1,
+ ease: 'Sine.easeInOut'
+ });
+ this.firstBattleTutorialHighlight = highlight;
+ }
+
+ private positionFirstBattleTutorialHighlight() {
+ const highlight = this.firstBattleTutorialHighlight;
+ if (!highlight?.active) {
+ return;
+ }
+ const tileX = highlight.getData('tutorialTileX') as number | undefined;
+ const tileY = highlight.getData('tutorialTileY') as number | undefined;
+ if (tileX === undefined || tileY === undefined) {
+ return;
+ }
+ highlight.setPosition(this.tileTopLeftX(tileX), this.tileTopLeftY(tileY));
+ highlight.setVisible(this.isTileVisible(tileX, tileY));
+ }
+
+ private trackFirstBattleTutorialObject(object: T) {
+ this.firstBattleTutorialObjects.push(object);
+ return object;
+ }
+
+ private hideFirstBattleTutorial() {
+ this.firstBattleTutorialObjects.forEach((object) => {
+ this.tweens?.killTweensOf(object);
+ if (object.active) {
+ object.destroy();
+ }
+ });
+ this.firstBattleTutorialObjects = [];
+ this.firstBattleTutorialHighlight = undefined;
+ this.firstBattleTutorialCard = undefined;
+ this.firstBattleTutorialSkipButton = undefined;
+ }
+
+ private setFirstBattleTutorialStep(step: FirstBattleTutorialStep, feedback = '') {
+ if (!this.firstBattleTutorialPath) {
+ return;
+ }
+ this.firstBattleTutorialStep = step;
+ this.firstBattleTutorialFeedback = feedback;
+ if (step !== 'target-preview') {
+ this.firstBattleTutorialTargetPreviewLocked = false;
+ }
+ this.ensureFirstBattleTutorialPathVisible();
+ this.renderFirstBattleTutorial();
+ }
+
+ private showFirstBattleTutorialFeedback(message: string) {
+ if (!this.isFirstBattleTutorialActive()) {
+ return;
+ }
+ this.firstBattleTutorialFeedback = message;
+ this.renderFirstBattleTutorial();
+ }
+
+ private completeFirstBattleTutorial(completion: FirstBattleTutorialCompletion) {
+ if (!this.isFirstBattleTutorialActive()) {
+ return;
+ }
+ this.firstBattleTutorialCompletion = completion;
+ this.firstBattleTutorialStep = undefined;
+ this.firstBattleTutorialFeedback = '';
+ this.firstBattleTutorialTargetPreviewLocked = false;
+ this.hideFirstBattleTutorial();
+ completeCampaignTutorial(firstBattleTutorialId);
+ }
+
+ private acceptFirstBattleTutorialCommand(command: BattleCommand) {
+ if (!this.isFirstBattleTutorialActive()) {
+ return true;
+ }
+ if (this.firstBattleTutorialStep !== 'attack-command') {
+ this.showFirstBattleTutorialFeedback('지금은 빛나는 단계의 조작을 먼저 완료하세요.');
+ return false;
+ }
+ if (command !== 'attack') {
+ this.showFirstBattleTutorialFeedback(`${commandLabels[command]} 대신 먼저 공격을 선택해 기본 전투 흐름을 익혀 보세요.`);
+ return false;
+ }
+ return true;
+ }
+
+ private handleFirstBattleTutorialUnhandledLeftClick(pointer: Phaser.Input.Pointer) {
+ const path = this.firstBattleTutorialPath;
+ const step = this.firstBattleTutorialStep;
+ if (!path || !step) {
+ return false;
+ }
+ const tile = this.pointerToTile(pointer);
+ if (step === 'select-unit') {
+ if (tile?.x === path.from.x && tile.y === path.from.y) {
+ return false;
+ }
+ this.showFirstBattleTutorialFeedback('먼저 빛나는 아군을 선택해야 이동 범위가 열립니다.');
+ return true;
+ }
+ if (step === 'move') {
+ if (tile?.x === path.moveTile.x && tile.y === path.moveTile.y) {
+ return false;
+ }
+ this.showFirstBattleTutorialFeedback('이 칸은 권장 이동칸이 아닙니다. 빛나는 칸으로 이동해 적을 공격 범위에 넣으세요.');
+ return true;
+ }
+ if (step === 'attack-command') {
+ const inCommandButton = this.commandButtons.some(({ background }) => background.getBounds().contains(pointer.x, pointer.y));
+ if (inCommandButton) {
+ return false;
+ }
+ this.showFirstBattleTutorialFeedback('열린 명령 카드에서 빛나는 공격 버튼을 선택하세요.');
+ return true;
+ }
+ return false;
+ }
+
private showOpeningBattleEvent() {
const guide = battleScenario.tacticalGuide;
const objectiveLines = guide ? [guide.summary, `진군: ${guide.route}`, guide.focus] : battleScenario.openingObjectiveLines;
@@ -16123,6 +16867,11 @@ export class BattleScene extends Phaser.Scene {
return;
}
+ if (this.firstBattleTutorialStep === 'select-unit' || this.firstBattleTutorialStep === 'move') {
+ this.showFirstBattleTutorialFeedback('이 단계에서는 빛나는 표시를 선택하세요. 필요하면 건너뛰기를 사용할 수 있습니다.');
+ return;
+ }
+
if (this.phase === 'deployment') {
this.cancelDeploymentSelection(true);
return;
@@ -16182,6 +16931,10 @@ export class BattleScene extends Phaser.Scene {
return;
}
+ if (this.handleFirstBattleTutorialUnhandledLeftClick(pointer)) {
+ return;
+ }
+
const mapMenuAction = this.mapMenuActionAt(pointer.x, pointer.y);
if (mapMenuAction) {
this.runMapMenuAction(mapMenuAction);
@@ -16224,6 +16977,10 @@ export class BattleScene extends Phaser.Scene {
}
this.clearLockedTargetPreview();
+ if (this.firstBattleTutorialStep === 'target-preview') {
+ this.firstBattleTutorialTargetPreviewLocked = false;
+ this.showFirstBattleTutorialFeedback('빈 칸에는 공격할 대상이 없습니다. 빛나는 적 부대를 선택하세요.');
+ }
const unit = this.selectedUnit;
const usable = this.selectedUsable;
const range = this.actionRange(unit, this.targetingAction, usable);
@@ -16756,6 +17513,7 @@ export class BattleScene extends Phaser.Scene {
}
private createBattleSaveState(): BattleSaveState {
+ const deployedUnitIds = new Set(battleUnits.map((unit) => unit.id));
return {
version: 1,
battleId: battleScenario.id,
@@ -16788,10 +17546,12 @@ export class BattleScene extends Phaser.Scene {
direction: this.unitViews.get(unit.id)?.direction,
equipment: this.cloneEquipment(unit.equipment)
})),
- bonds: Array.from(this.bondStates.values()).map((bond) => ({
- ...bond,
- unitIds: [...bond.unitIds] as [string, string]
- })),
+ bonds: Array.from(this.bondStates.values())
+ .filter((bond) => bond.unitIds.every((unitId) => deployedUnitIds.has(unitId)))
+ .map((bond) => ({
+ ...bond,
+ unitIds: [...bond.unitIds] as [string, string]
+ })),
itemStocks: this.serializeItemStocks(),
battleBuffs: Array.from(this.battleBuffs.values()).map((buff) => ({ ...buff })),
battleStatuses: this.serializeBattleStatuses(),
@@ -16835,16 +17595,15 @@ export class BattleScene extends Phaser.Scene {
this.actedUnitIds = new Set(state.actedUnitIds ?? []);
this.attackIntents = (state.attackIntents ?? []).map((intent) => ({ ...intent }));
this.battleLog = [...(state.battleLog ?? [])];
- this.bondStates = new Map(
- (state.bonds ?? []).map((bond) => [
- bond.id,
- {
- ...bond,
- unitIds: [...bond.unitIds] as [string, string],
- battleExp: bond.battleExp ?? 0
- }
- ])
- );
+ const restoredBondStates = new Map(this.bondStates);
+ (state.bonds ?? []).forEach((bond) => {
+ restoredBondStates.set(bond.id, {
+ ...bond,
+ unitIds: [...bond.unitIds] as [string, string],
+ battleExp: bond.battleExp ?? 0
+ });
+ });
+ this.bondStates = restoredBondStates;
this.itemStocks = this.deserializeItemStocks(state.itemStocks);
this.battleBuffs = new Map((state.battleBuffs ?? []).map((buff) => [buff.unitId, { ...buff }]));
this.battleStatuses = this.deserializeBattleStatuses(state.battleStatuses);
@@ -17943,7 +18702,7 @@ export class BattleScene extends Phaser.Scene {
attackerX,
groundY,
this.unitActionTexture(result.attacker),
- this.unitActionFrameIndex(attackerDirection, this.actionPoseForCommand(result.action))
+ this.unitActionFrame(result.attacker, attackerDirection, this.actionPoseForCommand(result.action))
)
);
attackerSprite.setDepth(depth + 6);
@@ -18581,7 +19340,7 @@ export class BattleScene extends Phaser.Scene {
const supportPose: UnitActionPose = result.usable.command === 'strategy' ? 'strategy' : 'item';
const userSprite = this.trackCombatObject(
- this.add.sprite(left + 160, supportSpriteY, this.unitActionTexture(result.user), this.unitActionFrameIndex('east', supportPose))
+ this.add.sprite(left + 160, supportSpriteY, this.unitActionTexture(result.user), this.unitActionFrame(result.user, 'east', supportPose))
);
userSprite.setDepth(depth + 8);
this.applyCombatSpriteDisplaySize(userSprite, result.user, true);
@@ -19185,7 +19944,7 @@ export class BattleScene extends Phaser.Scene {
}
if (celebrant) {
- const sprite = this.add.sprite(visualX, 8, this.unitActionTexture(celebrant), this.unitActionFrameIndex('south', this.celebrationPose(celebrant)));
+ const sprite = this.add.sprite(visualX, 8, this.unitActionTexture(celebrant), this.unitActionFrame(celebrant, 'south', this.celebrationPose(celebrant)));
sprite.setDisplaySize(82, 82);
const burst = this.add.star(visualX, -12, 8, 12, 34, 0xffdf7b, 0.72);
container.add([burst, sprite]);
@@ -19336,7 +20095,7 @@ export class BattleScene extends Phaser.Scene {
private createGrowthCelebrationSprite(unit: UnitData, x: number, y: number, depth: number) {
const sprite = this.trackCombatObject(
- this.add.sprite(x, y, this.unitActionTexture(unit), this.unitActionFrameIndex('south', this.celebrationPose(unit)))
+ this.add.sprite(x, y, this.unitActionTexture(unit), this.unitActionFrame(unit, 'south', this.celebrationPose(unit)))
);
sprite.setDisplaySize(82, 82);
this.applyActionSpriteBlend(sprite);
@@ -20238,7 +20997,7 @@ export class BattleScene extends Phaser.Scene {
entryX,
groundY,
this.unitActionTexture(partner),
- this.unitActionFrameIndex('east', this.actionPoseForCommand('attack'))
+ this.unitActionFrame(partner, 'east', this.actionPoseForCommand('attack'))
)
);
supporterSprite.setDepth(depth);
@@ -21407,7 +22166,7 @@ export class BattleScene extends Phaser.Scene {
}
private statusEffectIcon(kind: BattleStatusKind): BattleUiIconKey {
- return kind === 'burn' ? 'fire' : 'confusion';
+ return kind === 'burn' ? 'burn' : 'confusion';
}
private statusEffectTone(kind: BattleStatusKind) {
@@ -21544,6 +22303,9 @@ export class BattleScene extends Phaser.Scene {
soundDirector.playSelect();
this.showCommandMenu(unit, view?.sprite.x ?? this.tileCenterX(unit.x), view?.sprite.y ?? this.tileCenterY(unit.y));
this.renderUnitDetail(unit, '대상 선택을 취소했습니다.\n공격, 책략, 도구, 대기 중 하나를 다시 선택하세요.\n우클릭하면 이동 취소로 돌아갑니다.');
+ if (this.firstBattleTutorialStep === 'target-preview') {
+ this.setFirstBattleTutorialStep('attack-command', '대상 선택을 취소해 공격 명령 단계로 돌아왔습니다.');
+ }
return true;
}
@@ -21573,6 +22335,9 @@ export class BattleScene extends Phaser.Scene {
unit,
`이동을 취소했습니다.\n${toX + 1}, ${toY + 1}에서 원래 위치 ${fromX + 1}, ${fromY + 1}로 되돌렸습니다.`
);
+ if (this.firstBattleTutorialStep === 'attack-command') {
+ this.setFirstBattleTutorialStep('move', '이동을 취소해 권장 이동칸 단계로 돌아왔습니다.');
+ }
return true;
}
@@ -22093,12 +22858,18 @@ export class BattleScene extends Phaser.Scene {
return unitFrameRows[direction] * unitActionFramesPerDirection + unitActionColumnOffsets[pose] + safeFrame;
}
+ private unitActionFrame(unit: UnitData, direction: UnitDirection, pose: UnitActionPose, frame = 0) {
+ return this.textures.exists(`${this.unitTextureKey(unit)}-actions`)
+ ? this.unitActionFrameIndex(direction, pose, frame)
+ : this.unitFrameIndex(direction);
+ }
+
private unitActionImpactFrame(pose: UnitActionPose) {
return Math.max(0, Math.floor(unitActionFrameCounts[pose] * 0.55));
}
private setUnitActionFrame(sprite: Phaser.GameObjects.Sprite, unit: UnitData, direction: UnitDirection, pose: UnitActionPose, frame = 0) {
- sprite.setTexture(this.unitActionTexture(unit), this.unitActionFrameIndex(direction, pose, frame));
+ sprite.setTexture(this.unitActionTexture(unit), this.unitActionFrame(unit, direction, pose, frame));
this.applyActionSpriteBlend(sprite);
}
@@ -25447,6 +26218,34 @@ export class BattleScene extends Phaser.Scene {
};
}
+ private firstBattleTutorialDebugState() {
+ const path = this.firstBattleTutorialPath;
+ return {
+ id: firstBattleTutorialId,
+ eligibleBattle: battleScenario.id === 'first-battle-zhuo-commandery',
+ active: this.isFirstBattleTutorialActive(),
+ completed: hasCompletedCampaignTutorial(firstBattleTutorialId),
+ completion: this.firstBattleTutorialCompletion ?? null,
+ step: this.firstBattleTutorialStep ?? null,
+ stepNumber: this.firstBattleTutorialStepNumber(),
+ totalSteps: 4,
+ targetPreviewLocked: this.firstBattleTutorialTargetPreviewLocked,
+ feedback: this.firstBattleTutorialFeedback,
+ path: path
+ ? {
+ unitId: path.unitId,
+ from: { ...path.from },
+ moveTile: { ...path.moveTile },
+ targetId: path.targetId,
+ targetTile: { ...path.targetTile }
+ }
+ : null,
+ cardBounds: this.gameObjectBoundsDebug(this.firstBattleTutorialCard),
+ highlightBounds: this.gameObjectBoundsDebug(this.firstBattleTutorialHighlight),
+ skipBounds: this.gameObjectBoundsDebug(this.firstBattleTutorialSkipButton)
+ };
+ }
+
getDebugState() {
const campaign = getCampaignState();
const effectiveSortieUnitIds = this.effectiveSortieUnitIds(campaign);
@@ -25469,6 +26268,14 @@ export class BattleScene extends Phaser.Scene {
const sortieOrderDebug = this.resultSortieOrderDebugState();
const sortieResonanceDebug = this.coreSortieResonanceDebugState();
const combatCutInDebug = this.combatCutInStageDebugState();
+ const scenarioUnitTextureKeys = this.scenarioUnitTextureKeys();
+ const scenarioCombatPortraitKeys = Array.from(
+ new Set(
+ battleUnits
+ .map((unit) => this.combatPortraitAssetEntry(unit)?.textureKey)
+ .filter((key): key is string => Boolean(key))
+ )
+ );
return {
scene: this.scene.key,
@@ -25487,6 +26294,7 @@ export class BattleScene extends Phaser.Scene {
? cloneCampaignSortieRecommendationSnapshot(this.launchSortieRecommendation)
: null,
battleHud: this.battleHudDebugState(),
+ firstBattleTutorial: this.firstBattleTutorialDebugState(),
combatCutIn: combatCutInDebug,
combatCutInStage: combatCutInDebug,
sortieDoctrine: this.debugSortieDoctrine(),
@@ -25505,6 +26313,23 @@ export class BattleScene extends Phaser.Scene {
turnNumber: this.turnNumber,
activeFaction: this.activeFaction,
phase: this.phase,
+ combatAssets: {
+ status: this.scenarioCombatAssetStatus,
+ deferredDuringDeployment: this.shouldShowPreBattleDeployment(),
+ pendingDeploymentConfirmation: this.pendingDeploymentConfirmation,
+ settlementCount: this.scenarioCombatAssetSettlementCount,
+ watchdogMs: this.scenarioCombatAssetWatchdogMs(),
+ baseReady: scenarioUnitTextureKeys.every((key) => this.textures.exists(key)),
+ actionReady: scenarioUnitTextureKeys.every((key) => this.textures.exists(`${key}-actions`)),
+ portraitReady: scenarioCombatPortraitKeys.every((key) => this.textures.exists(key)),
+ unitTextureKeys: scenarioUnitTextureKeys,
+ missingActionKeys: [...this.scenarioCombatAssetMissingActionKeys],
+ missingPortraitKeys: [...this.scenarioCombatAssetMissingPortraitKeys],
+ loadDurationMs:
+ this.scenarioCombatAssetLoadStartedAt === undefined
+ ? null
+ : (this.scenarioCombatAssetLoadCompletedAt ?? Date.now()) - this.scenarioCombatAssetLoadStartedAt
+ },
battleOutcome: this.battleOutcome ?? null,
mapTextureKey: battleScenario.mapTextureKey,
mapTextureReady: this.textures.exists(battleScenario.mapTextureKey),
diff --git a/src/game/scenes/BootScene.ts b/src/game/scenes/BootScene.ts
index 24d8f53..0b51d38 100644
--- a/src/game/scenes/BootScene.ts
+++ b/src/game/scenes/BootScene.ts
@@ -1,6 +1,16 @@
import Phaser from 'phaser';
import titleBackgroundUrl from '../../assets/images/taoyuan-oath-title.png';
+const equipmentIcon128Modules = import.meta.glob('../../assets/images/ui/equipment-icons/128/*.png', {
+ eager: true,
+ import: 'default'
+}) as Record;
+
+const equipmentIcon32Modules = import.meta.glob('../../assets/images/ui/equipment-icons/32/*.png', {
+ eager: true,
+ import: 'default'
+}) as Record;
+
const itemIconSourceSize = 28;
const itemIconTextureSize = 64;
@@ -11,6 +21,8 @@ export class BootScene extends Phaser.Scene {
preload() {
this.load.image('title-taoyuan', titleBackgroundUrl);
+ this.preloadEquipmentIcons(equipmentIcon128Modules, '');
+ this.preloadEquipmentIcons(equipmentIcon32Modules, '-micro');
}
create() {
@@ -29,6 +41,15 @@ export class BootScene extends Phaser.Scene {
graphics.destroy();
}
+ private preloadEquipmentIcons(modules: Record, suffix: string) {
+ Object.entries(modules).forEach(([path, url]) => {
+ const itemId = path.match(/\/([^/]+)\.png$/)?.[1];
+ if (itemId) {
+ this.load.image(`item-${itemId}${suffix}`, url);
+ }
+ });
+ }
+
private createEquipmentIconTextures() {
this.createItemIcon('item-training-sword', (graphics) => this.drawTrainingSwordIcon(graphics));
this.createItemIcon('item-twin-oath-blades', (graphics) => {
diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts
index 9a44387..970885b 100644
--- a/src/game/scenes/CampScene.ts
+++ b/src/game/scenes/CampScene.ts
@@ -108,6 +108,8 @@ import {
hanzhongMainRecruitUnits,
hanzhongScoutRecruitBonds,
hanzhongScoutRecruitUnits,
+ jianYongRecruitBond,
+ jianYongRecruitUnit,
jiangWeiRecruitBonds,
jiangWeiRecruitUnits,
mengHuoFourthCaptureBonds,
@@ -272,6 +274,20 @@ type CampActionButtonView = {
hovered: boolean;
};
+type EquipmentSwapRequest = {
+ unitId: string;
+ slot: EquipmentSlot;
+ candidateItemId: string;
+ returnToSortie: boolean;
+};
+
+type EquipmentInventoryRowView = {
+ itemId: string;
+ canEquip: boolean;
+ background: Phaser.GameObjects.Rectangle;
+ actionButton: Phaser.GameObjects.Rectangle;
+};
+
type CampRosterBounds = {
x: number;
y: number;
@@ -305,7 +321,7 @@ type SortiePortraitRosterLayout = {
visibleUnitIds: string[];
previousButtonBounds: CampRosterBounds;
nextButtonBounds: CampRosterBounds;
- cardBounds: Array;
+ cardBounds: Array;
previousEnabled: boolean;
nextEnabled: boolean;
};
@@ -11261,6 +11277,12 @@ export class CampScene extends Phaser.Scene {
private equipmentInventoryPreviousButton?: Phaser.GameObjects.Rectangle;
private equipmentInventoryNextButton?: Phaser.GameObjects.Rectangle;
private equipmentInventoryVisibleItemIds: string[] = [];
+ private equipmentInventoryRowViews: EquipmentInventoryRowView[] = [];
+ private equipmentSwapConfirmObjects: Phaser.GameObjects.GameObject[] = [];
+ private pendingEquipmentSwap?: EquipmentSwapRequest;
+ private equipmentSwapConfirmPanel?: Phaser.GameObjects.Rectangle;
+ private equipmentSwapConfirmButton?: Phaser.GameObjects.Rectangle;
+ private equipmentSwapCancelButton?: Phaser.GameObjects.Rectangle;
private suppliesPanelBackground?: Phaser.GameObjects.Rectangle;
private suppliesEquipmentBoxes: Phaser.GameObjects.Rectangle[] = [];
private suppliesTrophyBoxes: Phaser.GameObjects.Rectangle[] = [];
@@ -11292,6 +11314,8 @@ export class CampScene extends Phaser.Scene {
private reportFormationHistoryUndoState?: ReportFormationHistoryUndoState;
private tabButtons: CampTabButtonView[] = [];
private sortiePrimaryActionButton?: CampActionButtonView;
+ private sortieNextActionGuideBackground?: Phaser.GameObjects.Rectangle;
+ private sortieNextActionGuideText?: Phaser.GameObjects.Text;
private visitedTabs = new Set();
private selectedSortieUnitIds: string[] = [];
private sortieFormationAssignments: SortieFormationAssignments = {};
@@ -11327,6 +11351,7 @@ export class CampScene extends Phaser.Scene {
private sortieRosterScroll = 0;
private sortiePortraitRosterPage = 0;
private sortiePortraitRosterLayout?: SortiePortraitRosterLayout;
+ private sortieRosterToggleBounds: Array = [];
private sortiePlanFeedback = '';
private sortiePrepStep: SortiePrepStep = 'briefing';
private terrainCountCache = new Map();
@@ -11361,6 +11386,7 @@ export class CampScene extends Phaser.Scene {
this.sortieRosterScroll = 0;
this.sortiePortraitRosterPage = 0;
this.sortiePortraitRosterLayout = undefined;
+ this.sortieRosterToggleBounds = [];
this.sortiePinnedSwapCandidateUnitId = undefined;
this.sortieSwapUndoState = undefined;
this.sortieFormationPanelMode = 'roster';
@@ -11388,6 +11414,11 @@ export class CampScene extends Phaser.Scene {
this.victoryRewardObjects = [];
this.victoryRewardCards = [];
this.victoryRewardActions = [];
+ this.equipmentSwapConfirmObjects = [];
+ this.pendingEquipmentSwap = undefined;
+ this.equipmentSwapConfirmPanel = undefined;
+ this.equipmentSwapConfirmButton = undefined;
+ this.equipmentSwapCancelButton = undefined;
this.reportFormationReviewObjects = [];
this.reportFormationReviewVisible = false;
this.reportFormationReviewFeedback = '';
@@ -11405,6 +11436,8 @@ export class CampScene extends Phaser.Scene {
this.reportFormationHistoryUndoState = undefined;
this.tabButtons = [];
this.sortiePrimaryActionButton = undefined;
+ this.sortieNextActionGuideBackground = undefined;
+ this.sortieNextActionGuideText = undefined;
this.visitedTabs = new Set(['status']);
this.campaign = getCampaignState();
this.report = this.campaign.firstBattleReport ?? getFirstBattleReport() ?? this.createFallbackReport();
@@ -11652,6 +11685,19 @@ export class CampScene extends Phaser.Scene {
return;
}
+ if (
+ battleId === campBattleIds.first ||
+ battleId === campBattleIds.second ||
+ battleId === campBattleIds.third ||
+ battleId === campBattleIds.fourth ||
+ battleId === campBattleIds.fifth ||
+ battleId === campBattleIds.sixth
+ ) {
+ this.campaign = ensureCampaignRosterUnits([jianYongRecruitUnit], [jianYongRecruitBond]);
+ this.report = this.campaign.firstBattleReport ?? this.report;
+ return;
+ }
+
if (
battleId === campBattleIds.fortieth ||
battleId === campBattleIds.fortyFirst ||
@@ -13150,6 +13196,12 @@ export class CampScene extends Phaser.Scene {
return;
}
+ if (this.equipmentSwapConfirmObjects.length > 0) {
+ soundDirector.playSelect();
+ this.hideEquipmentSwapComparison();
+ return;
+ }
+
if (this.saveSlotConfirmObjects.length > 0) {
soundDirector.playSelect();
this.hideCampSaveConfirm();
@@ -13191,6 +13243,7 @@ export class CampScene extends Phaser.Scene {
if (
this.sortieObjects.length === 0 ||
this.victoryRewardObjects.length > 0 ||
+ this.equipmentSwapConfirmObjects.length > 0 ||
this.saveSlotObjects.length > 0 ||
this.saveSlotConfirmObjects.length > 0 ||
!this.usesStagedSortiePrep()
@@ -13426,6 +13479,17 @@ export class CampScene extends Phaser.Scene {
this.renderFirstSortieLoadoutStep(contentX, contentY, contentWidth, contentHeight, depth + 2, checklist);
}
+ const stepIndex = firstSortiePrepSteps.findIndex((step) => step.id === this.sortiePrepStep);
+ const nextStep = firstSortiePrepSteps[stepIndex + 1]?.id;
+ this.renderSortieNextActionGuide(
+ x + 324,
+ y + height - 66,
+ 548,
+ 28,
+ nextStep,
+ depth + 3
+ );
+
this.addSortieButton('저장', x + 82, y + height - 42, 96, () => {
soundDirector.playSelect();
this.showCampSaveSlotPanel();
@@ -13435,12 +13499,10 @@ export class CampScene extends Phaser.Scene {
this.hideSortiePrep();
}, depth + 3);
- const stepIndex = firstSortiePrepSteps.findIndex((step) => step.id === this.sortiePrepStep);
if (stepIndex > 0) {
const previousStep = firstSortiePrepSteps[stepIndex - 1].id;
this.addSortieButton('이전', x + width - 250, y + height - 42, 104, () => this.setSortiePrepStep(previousStep), depth + 3);
}
- const nextStep = firstSortiePrepSteps[stepIndex + 1]?.id;
const primaryActionLabel = nextStep ? (nextStep === 'formation' ? '편성하기' : '장비·보급') : '출진';
this.addSortieButton(primaryActionLabel, x + width - 134, y + height - 42, 128, () => {
if (nextStep) {
@@ -13768,7 +13830,19 @@ export class CampScene extends Phaser.Scene {
const rowIndex = Math.floor(index / 2);
const cardX = x + 18 + column * (cardWidth + columnGap);
const cardY = cardTop + rowIndex * (cardHeight + rowGap);
- cardBounds.push({ unitId: unit.id, x: cardX, y: cardY, width: cardWidth, height: cardHeight });
+ cardBounds.push({
+ unitId: unit.id,
+ x: cardX,
+ y: cardY,
+ width: cardWidth,
+ height: cardHeight,
+ toggleButtonBounds: {
+ x: cardX + cardWidth - 94,
+ y: cardY + cardHeight - 32,
+ width: 84,
+ height: 24
+ }
+ });
const selected = this.isSortieSelected(unit.id);
const focused = this.sortieFocusedUnitId === unit.id;
const pinnedSwapCandidate = this.sortiePinnedSwapCandidateUnitId === unit.id;
@@ -13926,9 +14000,10 @@ export class CampScene extends Phaser.Scene {
visibleUnitIds: visibleUnits.map((unit) => unit.id),
previousButtonBounds: this.scaleCampUiBounds(previousButtonBounds),
nextButtonBounds: this.scaleCampUiBounds(nextButtonBounds),
- cardBounds: cardBounds.map(({ unitId, ...bounds }) => ({
+ cardBounds: cardBounds.map(({ unitId, toggleButtonBounds, ...bounds }) => ({
unitId,
- ...this.scaleCampUiBounds(bounds)
+ ...this.scaleCampUiBounds(bounds),
+ toggleButtonBounds: this.scaleCampUiBounds(toggleButtonBounds)
})),
previousEnabled,
nextEnabled
@@ -14988,13 +15063,21 @@ export class CampScene extends Phaser.Scene {
this.trackSortie(this.add.text(x + 190, y + 19, '초상을 고른 뒤 역할을 바꾸십시오.', this.textStyle(12, '#9fb0bf'))).setDepth(depth + 1);
this.renderFirstSortieInlineButton('추천 편성', x + width - 134, y + 8, 116, 38, this.canApplyRecommendedSortiePlan(), false, () => this.applyRecommendedSortiePlan(), depth + 1);
- const units = this.sortieRosterDisplayUnits(this.sortieRosterUnits()).slice(0, 3);
- const gap = 12;
- const cardWidth = Math.floor((width - 36 - gap * 2) / 3);
- const cardY = y + 54;
- const cardHeight = height - 70;
+ this.sortieRosterToggleBounds = [];
+ const units = this.sortieRosterDisplayUnits(this.sortieRosterUnits());
+ const compactGrid = units.length > 3;
+ const columns = compactGrid ? 2 : 3;
+ const rows = Math.max(1, Math.ceil(units.length / columns));
+ const columnGap = 12;
+ const rowGap = compactGrid ? 10 : 0;
+ const cardWidth = Math.floor((width - 36 - columnGap * (columns - 1)) / columns);
+ const cardTop = y + 54;
+ const cardHeight = Math.floor((height - 70 - rowGap * (rows - 1)) / rows);
units.forEach((unit, index) => {
- const cardX = x + 18 + index * (cardWidth + gap);
+ const column = index % columns;
+ const rowIndex = Math.floor(index / columns);
+ const cardX = x + 18 + column * (cardWidth + columnGap);
+ const cardY = cardTop + rowIndex * (cardHeight + rowGap);
const selected = this.isSortieSelected(unit.id);
const focused = this.sortieFocusedUnitId === unit.id;
const required = this.isRequiredSortieUnit(unit.id);
@@ -15013,35 +15096,55 @@ export class CampScene extends Phaser.Scene {
this.showSortiePrep();
});
- this.renderFirstSortiePortrait(cardX + cardWidth / 2, cardY + 88, 132, unit, depth + 2, selected ? 1 : 0.5);
- this.trackSortie(this.add.text(cardX + 14, cardY + 162, `${unit.name} Lv${unit.level}`, this.textStyle(20, selected ? '#f2e3bf' : '#87919c', true))).setDepth(depth + 2);
- this.trackSortie(this.add.text(cardX + 14, cardY + 190, `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(12, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2);
- this.drawSortieBar(cardX + 14, cardY + 211, cardWidth - 28, 7, unit.hp / unit.maxHp, selected ? palette.green : 0x53606c, depth + 2);
-
const recommendationText = recommendation
? this.conciseSortieText(recommendation.reason)
: `${this.sortieFormationRoleLabel(role)} 역할로 전장에 합류합니다.`;
- this.trackSortie(
- this.add.text(cardX + 14, cardY + 230, recommendationText, {
- ...this.textStyle(12, recommendation ? '#ffdf7b' : '#9fb0bf', recommendation ? true : false),
- wordWrap: { width: cardWidth - 28, useAdvancedWrap: true },
- lineSpacing: 2
- })
- ).setDepth(depth + 2);
- this.trackSortie(this.add.text(cardX + 14, cardY + 278, this.compactText(this.sortieBondLine(unit), 26), this.textStyle(10, selected ? '#a8ffd0' : '#77818c', true))).setDepth(depth + 2);
- this.trackSortie(this.add.text(cardX + 14, cardY + 294, this.compactText(this.sortieStrategyLine(unit.id), 24), this.textStyle(10, selected ? '#9fc8e8' : '#77818c', true))).setDepth(depth + 2);
+ if (compactGrid) {
+ this.renderFirstSortiePortrait(cardX + 50, cardY + 55, 72, unit, depth + 2, selected ? 1 : 0.5);
+ this.trackSortie(this.add.text(cardX + 96, cardY + 10, `${unit.name} Lv${unit.level}`, this.textStyle(16, selected ? '#f2e3bf' : '#87919c', true))).setDepth(depth + 2);
+ this.trackSortie(this.add.text(cardX + 96, cardY + 36, `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(10, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2);
+ this.drawSortieBar(cardX + 96, cardY + 56, cardWidth - 110, 6, unit.hp / unit.maxHp, selected ? palette.green : 0x53606c, depth + 2);
+ this.trackSortie(this.add.text(cardX + 96, cardY + 68, this.compactText(recommendationText, 28), this.textStyle(9, recommendation ? '#ffdf7b' : '#9fb0bf', Boolean(recommendation)))).setDepth(depth + 2);
+ this.trackSortie(this.add.text(cardX + 14, cardY + 98, this.compactText(this.sortieBondLine(unit), 36), this.textStyle(9, selected ? '#a8ffd0' : '#77818c', true))).setDepth(depth + 2);
+ this.trackSortie(this.add.text(cardX + 14, cardY + 113, this.compactText(this.sortieStrategyLine(unit.id), 34), this.textStyle(9, selected ? '#9fc8e8' : '#77818c', true))).setDepth(depth + 2);
+ } else {
+ this.renderFirstSortiePortrait(cardX + cardWidth / 2, cardY + 88, 132, unit, depth + 2, selected ? 1 : 0.5);
+ this.trackSortie(this.add.text(cardX + 14, cardY + 162, `${unit.name} Lv${unit.level}`, this.textStyle(20, selected ? '#f2e3bf' : '#87919c', true))).setDepth(depth + 2);
+ this.trackSortie(this.add.text(cardX + 14, cardY + 190, `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(12, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2);
+ this.drawSortieBar(cardX + 14, cardY + 211, cardWidth - 28, 7, unit.hp / unit.maxHp, selected ? palette.green : 0x53606c, depth + 2);
+ this.trackSortie(
+ this.add.text(cardX + 14, cardY + 230, recommendationText, {
+ ...this.textStyle(12, recommendation ? '#ffdf7b' : '#9fb0bf', Boolean(recommendation)),
+ wordWrap: { width: cardWidth - 28, useAdvancedWrap: true },
+ lineSpacing: 2
+ })
+ ).setDepth(depth + 2);
+ this.trackSortie(this.add.text(cardX + 14, cardY + 278, this.compactText(this.sortieBondLine(unit), 26), this.textStyle(10, selected ? '#a8ffd0' : '#77818c', true))).setDepth(depth + 2);
+ this.trackSortie(this.add.text(cardX + 14, cardY + 294, this.compactText(this.sortieStrategyLine(unit.id), 24), this.textStyle(10, selected ? '#9fc8e8' : '#77818c', true))).setDepth(depth + 2);
+ }
- this.renderFirstSortieInlineButton(
- required ? '필수 출전' : selected ? '출전 확정' : '대기',
+ const toggleButton = this.renderFirstSortieInlineButton(
+ required ? '필수 출전' : selected ? '출전 해제' : '출전 등록',
cardX + cardWidth - 90,
- cardY + 12,
+ cardY + 10,
78,
30,
- false,
+ !required,
selected,
- () => undefined,
+ () => {
+ this.sortieFocusedUnitId = unit.id;
+ this.toggleSortieUnit(unit.id);
+ },
depth + 3
);
+ const toggleBounds = toggleButton.getBounds();
+ this.sortieRosterToggleBounds.push({
+ unitId: unit.id,
+ x: toggleBounds.x,
+ y: toggleBounds.y,
+ width: toggleBounds.width,
+ height: toggleBounds.height
+ });
const roles = sortieFormationSlotDefinitions.filter((definition) => definition.role !== 'reserve');
const roleGap = 6;
@@ -15050,9 +15153,9 @@ export class CampScene extends Phaser.Scene {
this.renderFirstSortieInlineButton(
definition.label,
cardX + 14 + roleIndex * (roleButtonWidth + roleGap),
- cardY + cardHeight - 52,
+ cardY + cardHeight - (compactGrid ? 38 : 52),
roleButtonWidth,
- 40,
+ compactGrid ? 28 : 40,
selected,
role === definition.role,
() => this.assignFirstSortieRole(unit.id, definition.role),
@@ -15400,6 +15503,7 @@ export class CampScene extends Phaser.Scene {
text.on('pointerout', () => setHovered(false));
text.on('pointerdown', action);
}
+ return bg;
}
private renderSortieHeaderSummary(x: number, y: number, width: number, height: number, depth: number) {
@@ -15512,6 +15616,7 @@ export class CampScene extends Phaser.Scene {
depth: number,
interaction: 'toggle' | 'focus' = 'toggle'
) {
+ this.sortieRosterToggleBounds = [];
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
bg.setOrigin(0);
bg.setDepth(depth);
@@ -15567,6 +15672,16 @@ export class CampScene extends Phaser.Scene {
row.setDepth(depth + 1);
row.setStrokeStyle(1, disabled ? 0x53606c : selected ? palette.green : recommendedReserve ? palette.gold : 0x53606c, disabled ? 0.22 : selected ? 0.7 : recommendedReserve ? 0.54 : 0.36);
row.setInteractive({ useHandCursor: true });
+ const rowBounds = row.getBounds();
+ if (interaction === 'toggle') {
+ this.sortieRosterToggleBounds.push({
+ unitId: unit.id,
+ x: rowBounds.x,
+ y: rowBounds.y,
+ width: rowBounds.width,
+ height: rowBounds.height
+ });
+ }
row.on('wheel', handleRosterWheel);
row.on('pointerover', () => {
this.sortieFocusedUnitId = unit.id;
@@ -16168,7 +16283,7 @@ export class CampScene extends Phaser.Scene {
this.trackSortie(this.add.text(x + 174, rowY + 2, this.compactText(this.itemBonusText(item), 14), this.textStyle(10, '#9fb0bf', true))).setDepth(depth + 1);
this.drawSortieBar(x + 46, rowY + 15, 104, 5, state.exp / equipmentExpToNext(state.level), slot === 'weapon' ? palette.gold : slot === 'armor' ? palette.blue : palette.green, depth + 1);
const canSwap = this.equipmentInventoryEntries(slot).some((entry) => entry.item.id !== item.id);
- this.renderSortiePanelButton(canSwap ? '교체' : '없음', x + width - 58, rowY, 40, 17, canSwap, false, () => this.swapUnitEquipment(unit.id, slot, undefined, true), depth + 1);
+ this.renderSortiePanelButton(canSwap ? '비교' : '없음', x + width - 58, rowY, 40, 17, canSwap, false, () => this.swapUnitEquipment(unit.id, slot, undefined, true), depth + 1);
});
this.renderSortieSupplyAssignment(unit, x + 18, y + 236, width - 36, depth + 1);
@@ -16304,7 +16419,7 @@ export class CampScene extends Phaser.Scene {
const iconFrame = this.trackSortie(this.add.rectangle(x + index * spacing, y, frameSize, frameSize, selected && item.rank === 'treasure' ? 0x2c2630 : 0x0a1017, 0.92));
iconFrame.setStrokeStyle(1, item.rank === 'treasure' ? palette.gold : 0x53606c, selected ? 0.62 : 0.34);
iconFrame.setDepth(depth);
- const icon = this.trackSortie(this.add.image(x + index * spacing, y, `item-${item.id}`));
+ const icon = this.trackSortie(this.add.image(x + index * spacing, y, `item-${item.id}-micro`));
icon.setDisplaySize(this.campUiLength(iconSize), this.campUiLength(iconSize));
icon.setAlpha(selected ? 1 : 0.52);
icon.setDepth(depth + 1);
@@ -19284,6 +19399,34 @@ export class CampScene extends Phaser.Scene {
return view;
}
+ private renderSortieNextActionGuide(
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ nextStep: SortiePrepStep | undefined,
+ depth: number
+ ) {
+ const actionLabel = nextStep === 'formation'
+ ? '무장 편성으로 이동'
+ : nextStep === 'loadout'
+ ? '장비·보급 점검으로 이동'
+ : '필수 조건을 확인하고 출진';
+ const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x151f2a, 0.94));
+ bg.setOrigin(0);
+ bg.setDepth(depth);
+ bg.setStrokeStyle(1, palette.gold, 0.58);
+ const label = this.trackSortie(
+ this.add.text(x + 14, y + 6, `다음 행동 · ${actionLabel}`, this.textStyle(11, '#ffdf7b', true))
+ );
+ label.setDepth(depth + 1);
+ const shortcut = this.trackSortie(this.add.text(x + width - 14, y + 6, 'Enter', this.textStyle(10, '#9fb0bf', true)));
+ shortcut.setOrigin(1, 0);
+ shortcut.setDepth(depth + 1);
+ this.sortieNextActionGuideBackground = bg;
+ this.sortieNextActionGuideText = label;
+ }
+
private startVictoryStory() {
const flow = this.currentSortieFlow();
if (flow.nextBattleId && !this.hasCurrentSortieOrderSelection()) {
@@ -19396,7 +19539,10 @@ export class CampScene extends Phaser.Scene {
this.sortieObjects.forEach((object) => object.destroy());
this.sortieObjects = [];
this.sortiePrimaryActionButton = undefined;
+ this.sortieNextActionGuideBackground = undefined;
+ this.sortieNextActionGuideText = undefined;
this.sortiePortraitRosterLayout = undefined;
+ this.sortieRosterToggleBounds = [];
this.sortieHoveredUnitId = undefined;
if (clearPinnedSwap) {
this.sortiePinnedSwapCandidateUnitId = undefined;
@@ -21749,11 +21895,12 @@ export class CampScene extends Phaser.Scene {
this.equipmentPanelBackground = bg;
this.equipmentInventoryPreviousButton = undefined;
this.equipmentInventoryNextButton = undefined;
+ this.equipmentInventoryRowViews = [];
bg.setOrigin(0);
bg.setStrokeStyle(1, palette.gold, 0.58);
this.track(this.add.text(x + 24, y + 22, '장비 관리', this.textStyle(24, '#f2e3bf', true)));
- this.track(this.add.text(x + 24, y + 58, '선택 장수의 무기·방어구·보조구 효과와 성장도를 비교하고 바로 교체합니다.', this.textStyle(14, '#d4dce6')));
+ this.track(this.add.text(x + 24, y + 58, '후보를 고르면 현재 장비와 수치를 비교합니다. 교체 확정 전에는 장부에 반영되지 않습니다.', this.textStyle(14, '#d4dce6')));
this.renderEquipmentUnitSummary(unit, x + 24, y + 86, width - 48, 48);
@@ -21770,14 +21917,14 @@ export class CampScene extends Phaser.Scene {
this.track(this.add.text(
x + 24,
y + 266,
- `보유 장비 ${inventoryEntries.length}${inventoryPageCount > 1 ? ` · ${this.equipmentInventoryPage + 1}/${inventoryPageCount}` : ''}`,
+ `보유 장비 전체 ${inventoryEntries.length}종${inventoryPageCount > 1 ? ` · ${this.equipmentInventoryPage + 1}/${inventoryPageCount}쪽` : ''}`,
this.textStyle(18, '#f2e3bf', true)
));
if (inventoryPageCount > 1) {
this.equipmentInventoryPreviousButton = this.renderEquipmentInventoryPageButton(
- x + 424,
+ x + 400,
y + 278,
- '‹',
+ '이전',
this.equipmentInventoryPage > 0,
() => {
this.equipmentInventoryPage -= 1;
@@ -21787,7 +21934,7 @@ export class CampScene extends Phaser.Scene {
this.equipmentInventoryNextButton = this.renderEquipmentInventoryPageButton(
x + 462,
y + 278,
- '›',
+ '다음',
this.equipmentInventoryPage < inventoryPageCount - 1,
() => {
this.equipmentInventoryPage += 1;
@@ -21820,9 +21967,9 @@ export class CampScene extends Phaser.Scene {
enabled: boolean,
action: () => void
) {
- const bg = this.track(this.add.rectangle(x, y, 30, 24, enabled ? 0x1a2630 : 0x111820, enabled ? 0.96 : 0.68));
+ const bg = this.track(this.add.rectangle(x, y, 56, 26, enabled ? 0x1a2630 : 0x111820, enabled ? 0.96 : 0.68));
bg.setStrokeStyle(1, enabled ? palette.gold : 0x53606c, enabled ? 0.72 : 0.34);
- const text = this.track(this.add.text(x, y - 1, label, this.textStyle(18, enabled ? '#f2e3bf' : '#6f7882', true)));
+ const text = this.track(this.add.text(x, y - 1, label, this.textStyle(11, enabled ? '#f2e3bf' : '#6f7882', true)));
text.setOrigin(0.5);
if (!enabled) {
return bg;
@@ -21876,7 +22023,7 @@ export class CampScene extends Phaser.Scene {
const iconFrame = this.track(this.add.rectangle(x + 20, y + height / 2, 28, 28, 0x0a1017, 0.92));
iconFrame.setStrokeStyle(1, entry.item.rank === 'treasure' ? palette.gold : 0x53606c, 0.54);
- const icon = this.track(this.add.image(x + 20, y + height / 2, `item-${entry.item.id}`));
+ const icon = this.track(this.add.image(x + 20, y + height / 2, `item-${entry.item.id}-micro`));
icon.setDisplaySize(this.campUiLength(22), this.campUiLength(22));
icon.setAlpha(canEquip ? 1 : 0.52);
@@ -21887,7 +22034,7 @@ export class CampScene extends Phaser.Scene {
const button = this.track(this.add.rectangle(x + width - 34, y + height / 2, 52, 24, canEquip ? 0x1a2630 : 0x121922, canEquip ? 0.96 : 0.62));
button.setStrokeStyle(1, canEquip ? palette.gold : 0x53606c, canEquip ? 0.68 : 0.3);
- const buttonText = this.track(this.add.text(x + width - 34, y + height / 2 - 7, canEquip ? '교체' : '착용', this.textStyle(11, canEquip ? '#f2e3bf' : '#87919c', true)));
+ const buttonText = this.track(this.add.text(x + width - 34, y + height / 2 - 7, canEquip ? '비교' : '착용', this.textStyle(11, canEquip ? '#f2e3bf' : '#87919c', true)));
buttonText.setOrigin(0.5, 0);
if (canEquip) {
@@ -21901,6 +22048,12 @@ export class CampScene extends Phaser.Scene {
buttonText.setInteractive({ useHandCursor: true });
buttonText.on('pointerdown', action);
}
+ this.equipmentInventoryRowViews.push({
+ itemId: entry.item.id,
+ canEquip,
+ background: bg,
+ actionButton: button
+ });
}
private renderEquipmentEffectSummary(unit: UnitData, x: number, y: number, width: number, height: number) {
@@ -21912,7 +22065,7 @@ export class CampScene extends Phaser.Scene {
const equipped = equipmentSlots.map((slot) => ({ slot, state: unit.equipment[slot], item: getItem(unit.equipment[slot].itemId) }));
equipped.forEach((entry, index) => {
const rowY = y + 42 + index * 30;
- const icon = this.track(this.add.image(x + 20, rowY + 8, `item-${entry.item.id}`));
+ const icon = this.track(this.add.image(x + 20, rowY + 8, `item-${entry.item.id}-micro`));
icon.setDisplaySize(this.campUiLength(20), this.campUiLength(20));
this.track(this.add.text(x + 36, rowY, `${equipmentSlotLabels[entry.slot]} Lv${entry.state.level}`, this.textStyle(11, '#d8b15f', true)));
this.track(this.add.text(x + 108, rowY, this.compactText(this.itemBonusText(entry.item), 18), this.textStyle(11, '#d4dce6', true)));
@@ -22058,7 +22211,7 @@ export class CampScene extends Phaser.Scene {
bg.setStrokeStyle(1, entry.item.rank === 'treasure' ? palette.gold : palette.blue, canEquip ? 0.58 : 0.28);
const iconFrame = this.track(this.add.rectangle(x + 18, y + 18, 28, 28, 0x0a1017, 0.92));
iconFrame.setStrokeStyle(1, entry.item.rank === 'treasure' ? palette.gold : 0x53606c, 0.54);
- const icon = this.track(this.add.image(x + 18, y + 18, `item-${entry.item.id}`));
+ const icon = this.track(this.add.image(x + 18, y + 18, `item-${entry.item.id}-micro`));
icon.setDisplaySize(this.campUiLength(21), this.campUiLength(21));
this.track(this.add.text(x + 38, y + 7, this.compactText(`${entry.item.name} x${entry.amount}`, 8), this.textStyle(11, '#f2e3bf', true)));
this.track(this.add.text(x + 38, y + 23, this.compactText(`${equipmentSlotLabels[entry.item.slot]} · ${this.itemBonusText(entry.item)}`, 11), this.textStyle(9, '#9fb0bf', true)));
@@ -22253,7 +22406,7 @@ export class CampScene extends Phaser.Scene {
const canSwap = alternatives.length > 0;
const button = this.track(this.add.rectangle(x + width - 36, y + 16, 54, 22, canSwap ? 0x1a2630 : 0x121922, canSwap ? 0.96 : 0.66));
button.setStrokeStyle(1, canSwap ? palette.gold : 0x53606c, canSwap ? 0.7 : 0.3);
- const buttonText = this.track(this.add.text(x + width - 36, y + 16, canSwap ? '교체' : '없음', this.textStyle(10, canSwap ? '#f2e3bf' : '#7f8994', true)));
+ const buttonText = this.track(this.add.text(x + width - 36, y + 16, canSwap ? '비교' : '없음', this.textStyle(10, canSwap ? '#f2e3bf' : '#7f8994', true)));
buttonText.setOrigin(0.5);
if (canSwap) {
const action = () => this.swapUnitEquipment(unit.id, slot);
@@ -22821,12 +22974,219 @@ export class CampScene extends Phaser.Scene {
return;
}
+ soundDirector.playSelect();
+ this.showEquipmentSwapComparison(
+ {
+ unitId,
+ slot,
+ candidateItemId: nextItem.id,
+ returnToSortie
+ },
+ target,
+ currentItem,
+ nextItem
+ );
+ }
+
+ private showEquipmentSwapComparison(
+ request: EquipmentSwapRequest,
+ target: UnitData,
+ currentItem: ItemDefinition,
+ candidateItem: ItemDefinition
+ ) {
+ this.hideEquipmentSwapComparison();
+ this.pendingEquipmentSwap = request;
+
+ const depth = 92;
+ const width = 636;
+ const height = 304;
+ const x = Math.floor((campLegacyCanvasWidth - width) / 2);
+ const y = Math.floor((campLegacyCanvasHeight - height) / 2);
+ const shade = this.trackEquipmentSwapConfirm(
+ this.add.rectangle(0, 0, campLegacyCanvasWidth, campLegacyCanvasHeight, 0x020406, 0.76)
+ );
+ shade.setOrigin(0);
+ shade.setDepth(depth);
+ shade.setInteractive();
+
+ const panel = this.trackEquipmentSwapConfirm(this.add.rectangle(x, y, width, height, 0x0f1720, 0.99));
+ panel.setOrigin(0);
+ panel.setDepth(depth + 1);
+ panel.setStrokeStyle(3, palette.gold, 0.94);
+ panel.setInteractive();
+ this.equipmentSwapConfirmPanel = panel;
+
+ const title = this.trackEquipmentSwapConfirm(
+ this.add.text(x + 26, y + 20, '장비 교체 비교', this.textStyle(24, '#f2e3bf', true))
+ );
+ title.setDepth(depth + 2);
+ const guide = this.trackEquipmentSwapConfirm(
+ this.add.text(
+ x + 26,
+ y + 53,
+ `${target.name} · ${equipmentSlotLabels[request.slot]} · 아래 수치를 확인한 뒤 확정하십시오.`,
+ this.textStyle(13, '#c8d2dd')
+ )
+ );
+ guide.setDepth(depth + 2);
+
+ const cardWidth = 280;
+ const cardHeight = 116;
+ const cardY = y + 82;
+ this.renderEquipmentSwapComparisonCard(
+ '현재 장비',
+ currentItem,
+ target.equipment[request.slot].level,
+ x + 26,
+ cardY,
+ cardWidth,
+ cardHeight,
+ depth + 2,
+ false
+ );
+ this.renderEquipmentSwapComparisonCard(
+ '교체 후보',
+ candidateItem,
+ target.equipment[request.slot].level,
+ x + width - 26 - cardWidth,
+ cardY,
+ cardWidth,
+ cardHeight,
+ depth + 2,
+ true
+ );
+
+ const delta = this.trackEquipmentSwapConfirm(
+ this.add.rectangle(x + 26, y + 212, width - 52, 34, 0x172a22, 0.92)
+ );
+ delta.setOrigin(0);
+ delta.setDepth(depth + 2);
+ delta.setStrokeStyle(1, palette.green, 0.54);
+ const deltaText = this.trackEquipmentSwapConfirm(
+ this.add.text(
+ x + 42,
+ y + 220,
+ `교체 변화 · ${this.equipmentDeltaText(candidateItem, currentItem)} · 성장도 Lv${target.equipment[request.slot].level} 유지`,
+ this.textStyle(12, '#a8ffd0', true)
+ )
+ );
+ deltaText.setDepth(depth + 3);
+
+ const cancel = this.trackEquipmentSwapConfirm(this.add.rectangle(x + width - 212, y + 274, 116, 36, 0x1a2630, 0.98));
+ cancel.setDepth(depth + 2);
+ cancel.setStrokeStyle(1, palette.blue, 0.82);
+ const cancelText = this.trackEquipmentSwapConfirm(
+ this.add.text(x + width - 212, y + 274, '취소', this.textStyle(14, '#d4dce6', true))
+ );
+ cancelText.setOrigin(0.5);
+ cancelText.setDepth(depth + 3);
+ this.equipmentSwapCancelButton = cancel;
+
+ const confirm = this.trackEquipmentSwapConfirm(this.add.rectangle(x + width - 82, y + 274, 128, 36, 0x4a371d, 0.99));
+ confirm.setDepth(depth + 2);
+ confirm.setStrokeStyle(2, palette.gold, 0.96);
+ const confirmText = this.trackEquipmentSwapConfirm(
+ this.add.text(x + width - 82, y + 274, '교체 확정', this.textStyle(14, '#fff2b8', true))
+ );
+ confirmText.setOrigin(0.5);
+ confirmText.setDepth(depth + 3);
+ this.equipmentSwapConfirmButton = confirm;
+
+ const setCancelHovered = (hovered: boolean) => cancel.setFillStyle(hovered ? 0x283947 : 0x1a2630, 0.99);
+ const setConfirmHovered = (hovered: boolean) => confirm.setFillStyle(hovered ? 0x654c25 : 0x4a371d, 0.99);
+ [cancel, cancelText].forEach((object) => {
+ object.setInteractive({ useHandCursor: true });
+ object.on('pointerover', () => setCancelHovered(true));
+ object.on('pointerout', () => setCancelHovered(false));
+ object.on('pointerdown', () => {
+ soundDirector.playSelect();
+ this.hideEquipmentSwapComparison();
+ });
+ });
+ [confirm, confirmText].forEach((object) => {
+ object.setInteractive({ useHandCursor: true });
+ object.on('pointerover', () => setConfirmHovered(true));
+ object.on('pointerout', () => setConfirmHovered(false));
+ object.on('pointerdown', () => this.confirmEquipmentSwap());
+ });
+ }
+
+ private renderEquipmentSwapComparisonCard(
+ eyebrow: string,
+ item: ItemDefinition,
+ level: number,
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ depth: number,
+ candidate: boolean
+ ) {
+ const bg = this.trackEquipmentSwapConfirm(
+ this.add.rectangle(x, y, width, height, candidate ? 0x1d2b22 : 0x151f2a, 0.96)
+ );
+ bg.setOrigin(0);
+ bg.setDepth(depth);
+ bg.setStrokeStyle(candidate ? 2 : 1, candidate ? palette.gold : palette.blue, candidate ? 0.84 : 0.48);
+ const iconFrame = this.trackEquipmentSwapConfirm(this.add.rectangle(x + 34, y + 62, 54, 54, 0x090f16, 0.96));
+ iconFrame.setDepth(depth + 1);
+ iconFrame.setStrokeStyle(1, item.rank === 'treasure' ? palette.gold : 0x53606c, 0.7);
+ const icon = this.trackEquipmentSwapConfirm(this.add.image(x + 34, y + 62, `item-${item.id}`));
+ icon.setDisplaySize(this.campUiLength(40), this.campUiLength(40));
+ icon.setDepth(depth + 2);
+ const eyebrowText = this.trackEquipmentSwapConfirm(
+ this.add.text(x + 14, y + 10, eyebrow, this.textStyle(11, candidate ? '#ffdf7b' : '#9fb0bf', true))
+ );
+ eyebrowText.setDepth(depth + 1);
+ const name = this.trackEquipmentSwapConfirm(
+ this.add.text(x + 66, y + 32, `${item.name} · Lv${level}`, this.textStyle(17, '#f2e3bf', true))
+ );
+ name.setDepth(depth + 1);
+ const bonus = this.trackEquipmentSwapConfirm(
+ this.add.text(x + 66, y + 60, this.itemBonusText(item), this.textStyle(12, candidate ? '#a8ffd0' : '#d4dce6', true))
+ );
+ bonus.setDepth(depth + 1);
+ const effect = this.trackEquipmentSwapConfirm(
+ this.add.text(x + 66, y + 84, this.compactText(item.effects[0] ?? item.description, 28), this.textStyle(10, '#9fb0bf'))
+ );
+ effect.setDepth(depth + 1);
+ }
+
+ private confirmEquipmentSwap() {
+ const request = this.pendingEquipmentSwap;
+ if (!request) {
+ this.hideEquipmentSwapComparison();
+ return;
+ }
+ this.hideEquipmentSwapComparison();
+ this.applyUnitEquipmentSwap(request);
+ }
+
+ private applyUnitEquipmentSwap(request: EquipmentSwapRequest) {
+ const campaign = this.campaign ?? getCampaignState();
+ const target = campaign.roster.find((unit) => unit.id === request.unitId);
+ if (!target) {
+ this.showCampNotice('장비를 바꿀 장수 장부를 찾지 못했습니다.');
+ return;
+ }
+ const currentState = target.equipment[request.slot];
+ const currentItem = getItem(currentState.itemId);
+ const nextItem = getItem(request.candidateItemId);
+ if (nextItem.slot !== request.slot || nextItem.id === currentItem.id) {
+ this.showCampNotice('장비 상태가 달라졌습니다. 후보를 다시 비교하십시오.');
+ return;
+ }
+ if ((campaign.inventory[nextItem.name] ?? 0) <= 0) {
+ this.showCampNotice(`${nextItem.name} 보유량이 없습니다.`);
+ return;
+ }
+
campaign.inventory[nextItem.name] -= 1;
if (campaign.inventory[nextItem.name] <= 0) {
delete campaign.inventory[nextItem.name];
}
campaign.inventory[currentItem.name] = (campaign.inventory[currentItem.name] ?? 0) + 1;
- target.equipment[slot] = {
+ target.equipment[request.slot] = {
itemId: nextItem.id,
level: currentState.level,
exp: currentState.exp
@@ -22836,14 +23196,23 @@ export class CampScene extends Phaser.Scene {
this.campaign = saveCampaignState(campaign);
this.report = this.campaign.firstBattleReport ?? this.report;
soundDirector.playSelect();
- this.showCampNotice(`${target.name} ${equipmentSlotLabels[slot]} 교체 · ${currentItem.name} → ${nextItem.name} (${this.itemBonusText(nextItem)})`);
- if (returnToSortie || this.sortieObjects.length > 0) {
+ this.showCampNotice(`${target.name} ${equipmentSlotLabels[request.slot]} 교체 · ${currentItem.name} → ${nextItem.name} (${this.itemBonusText(nextItem)})`);
+ if (request.returnToSortie || this.sortieObjects.length > 0) {
this.showSortiePrep();
} else {
this.render();
}
}
+ private hideEquipmentSwapComparison() {
+ this.equipmentSwapConfirmObjects.forEach((object) => object.destroy());
+ this.equipmentSwapConfirmObjects = [];
+ this.pendingEquipmentSwap = undefined;
+ this.equipmentSwapConfirmPanel = undefined;
+ this.equipmentSwapConfirmButton = undefined;
+ this.equipmentSwapCancelButton = undefined;
+ }
+
private nextEquipmentItemForSlot(slot: EquipmentSlot, currentItemId: string) {
return this.equipmentInventoryEntries(slot).find((entry) => entry.item.id !== currentItemId)?.item;
}
@@ -22944,6 +23313,12 @@ export class CampScene extends Phaser.Scene {
return object;
}
+ private trackEquipmentSwapConfirm(object: T) {
+ this.scaleLegacyCampUi(object);
+ this.equipmentSwapConfirmObjects.push(object);
+ return object;
+ }
+
private track(object: T) {
this.scaleLegacyCampUi(object);
this.contentObjects.push(object);
@@ -22965,6 +23340,7 @@ export class CampScene extends Phaser.Scene {
this.equipmentInventoryPreviousButton = undefined;
this.equipmentInventoryNextButton = undefined;
this.equipmentInventoryVisibleItemIds = [];
+ this.equipmentInventoryRowViews = [];
this.suppliesPanelBackground = undefined;
this.suppliesEquipmentBoxes = [];
this.suppliesTrophyBoxes = [];
@@ -23089,6 +23465,15 @@ export class CampScene extends Phaser.Scene {
);
const equipmentInventory = this.equipmentInventoryEntries();
const equipmentInventoryPageCount = Math.max(1, Math.ceil(equipmentInventory.length / equipmentInventoryPageSize));
+ const equipmentSwapTarget = this.pendingEquipmentSwap
+ ? this.campaign?.roster.find((unit) => unit.id === this.pendingEquipmentSwap?.unitId)
+ : undefined;
+ const equipmentSwapCurrentItem = equipmentSwapTarget && this.pendingEquipmentSwap
+ ? getItem(equipmentSwapTarget.equipment[this.pendingEquipmentSwap.slot].itemId)
+ : undefined;
+ const equipmentSwapCandidateItem = this.pendingEquipmentSwap
+ ? getItem(this.pendingEquipmentSwap.candidateItemId)
+ : undefined;
return {
scene: this.scene.key,
victoryRewardAcknowledgement: {
@@ -23107,6 +23492,24 @@ export class CampScene extends Phaser.Scene {
interactive: Boolean(action.button.input?.enabled)
}))
},
+ equipmentSwapComparison: this.pendingEquipmentSwap && equipmentSwapTarget && equipmentSwapCurrentItem && equipmentSwapCandidateItem
+ ? {
+ visible: Boolean(this.equipmentSwapConfirmPanel?.active),
+ unitId: equipmentSwapTarget.id,
+ unitName: equipmentSwapTarget.name,
+ slot: this.pendingEquipmentSwap.slot,
+ currentItemId: equipmentSwapCurrentItem.id,
+ currentItemName: equipmentSwapCurrentItem.name,
+ candidateItemId: equipmentSwapCandidateItem.id,
+ candidateItemName: equipmentSwapCandidateItem.name,
+ deltaText: this.equipmentDeltaText(equipmentSwapCandidateItem, equipmentSwapCurrentItem),
+ panelBounds: this.sortieObjectBoundsDebug(this.equipmentSwapConfirmPanel),
+ confirmButtonBounds: this.sortieObjectBoundsDebug(this.equipmentSwapConfirmButton),
+ cancelButtonBounds: this.sortieObjectBoundsDebug(this.equipmentSwapCancelButton),
+ confirmInteractive: Boolean(this.equipmentSwapConfirmButton?.input?.enabled),
+ cancelInteractive: Boolean(this.equipmentSwapCancelButton?.input?.enabled)
+ }
+ : null,
activeTab: this.activeTab,
campTabs: this.tabButtons.map((button) => ({
id: button.tab,
@@ -23501,6 +23904,12 @@ export class CampScene extends Phaser.Scene {
lineWidth: this.sortiePrimaryActionButton.background.lineWidth
}
: null,
+ sortieNextActionGuide: this.sortieNextActionGuideBackground?.active
+ ? {
+ label: this.sortieNextActionGuideText?.text ?? '',
+ bounds: this.sortieObjectBoundsDebug(this.sortieNextActionGuideBackground)
+ }
+ : null,
sortiePortraitRosterView: {
enabled: stagedSortiePrep && !this.isFirstSortiePrepSlice(),
total: portraitRosterUnits.length,
@@ -23521,8 +23930,12 @@ export class CampScene extends Phaser.Scene {
nextButtonBounds: this.sortiePortraitRosterLayout
? { ...this.sortiePortraitRosterLayout.nextButtonBounds }
: null,
- cardBounds: this.sortiePortraitRosterLayout?.cardBounds.map((bounds) => ({ ...bounds })) ?? []
+ cardBounds: this.sortiePortraitRosterLayout?.cardBounds.map(({ toggleButtonBounds, ...bounds }) => ({
+ ...bounds,
+ toggleButtonBounds: { ...toggleButtonBounds }
+ })) ?? []
},
+ sortieRosterToggleBounds: this.sortieRosterToggleBounds.map((bounds) => ({ ...bounds })),
sortiePortraitRoster: portraitRosterUnits.map((unit) => {
const availability = this.sortieUnitAvailability(unit);
const portraitKey = campaignPortraitKeysByUnitId[unit.id];
@@ -23605,6 +24018,7 @@ export class CampScene extends Phaser.Scene {
selectedUnitId: this.selectedUnitId,
selectedDialogueId: this.selectedDialogueId,
selectedVisitId: this.selectedVisitId,
+ sortieSelectionLimit: this.sortieMaxUnits(),
selectedSortieUnitIds: [...this.selectedSortieUnitIds],
sortieFormationAssignments: { ...this.sortieFormationAssignments },
reserveTrainingAssignments: { ...reserveTrainingAssignments },
@@ -23825,7 +24239,14 @@ export class CampScene extends Phaser.Scene {
previousButtonBounds: this.sortieObjectBoundsDebug(this.equipmentInventoryPreviousButton),
nextButtonBounds: this.sortieObjectBoundsDebug(this.equipmentInventoryNextButton),
previousEnabled: Boolean(this.equipmentInventoryPreviousButton?.input?.enabled),
- nextEnabled: Boolean(this.equipmentInventoryNextButton?.input?.enabled)
+ nextEnabled: Boolean(this.equipmentInventoryNextButton?.input?.enabled),
+ rows: this.equipmentInventoryRowViews.map((row) => ({
+ itemId: row.itemId,
+ canEquip: row.canEquip,
+ bounds: this.sortieObjectBoundsDebug(row.background),
+ actionButtonBounds: this.sortieObjectBoundsDebug(row.actionButton),
+ interactive: Boolean(row.background.input?.enabled && row.actionButton.input?.enabled)
+ }))
}
},
equipmentInventory: equipmentInventory.map((entry) => ({
@@ -23987,7 +24408,11 @@ export class CampScene extends Phaser.Scene {
id: unit.id,
name: unit.name,
hp: unit.hp,
- maxHp: unit.maxHp
+ maxHp: unit.maxHp,
+ equipment: equipmentSlots.reduce((equipment, slot) => {
+ equipment[slot] = { ...unit.equipment[slot] };
+ return equipment;
+ }, {} as UnitData['equipment'])
})),
completedCampDialogues: this.campaign.completedCampDialogues,
completedCampVisits: this.campaign.completedCampVisits
diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts
index 524639e..da555b9 100644
--- a/src/game/scenes/StoryScene.ts
+++ b/src/game/scenes/StoryScene.ts
@@ -8,7 +8,7 @@ import {
type BattleUiIconKey
} from '../data/battleUiIcons';
import { portraitAssetEntriesForKey, portraitKeyForSpeaker, type PortraitAssetEntry } from '../data/portraitAssets';
-import { storyBackgroundAssets, storyBackgroundKeysFor } from '../data/storyAssets';
+import { storyBackgroundAssets, storyBackgroundKeyForPage } from '../data/storyAssets';
import {
ensureUnitAnimations,
loadUnitBaseSheets,
@@ -701,12 +701,8 @@ export class StoryScene extends Phaser.Scene {
}
private pageBackgroundAssetKey(page: StoryPage) {
- const keys = storyBackgroundKeysFor(page.background).filter((key) => Boolean(storyBackgroundAssets[key]));
- if (keys.length <= 1) {
- return keys[0] ?? page.background;
- }
-
- return keys[this.hashString(page.id) % keys.length];
+ const key = storyBackgroundKeyForPage(page.background, page.id);
+ return storyBackgroundAssets[key] ? key : page.background;
}
private applyBackground(page: StoryPage) {
diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts
index 67958b2..82d8874 100644
--- a/src/game/state/campaignState.ts
+++ b/src/game/state/campaignState.ts
@@ -1,7 +1,13 @@
import { equipmentExpToNext, equipmentSlots, itemCatalog, type EquipmentSlot } from '../data/battleItems';
import { unitClasses } from '../data/battleRules';
import { battleScenarios, type BattleScenarioId } from '../data/battles';
-import { campaignRecruitUnits, type BattleBond, type UnitData } from '../data/scenario';
+import {
+ campaignRecruitUnits,
+ jianYongRecruitBond,
+ jianYongRecruitUnit,
+ type BattleBond,
+ type UnitData
+} from '../data/scenario';
import { normalizeSortieFormationAssignments, type SortieFormationAssignments } from '../data/sortieDeployment';
import { coreSortieResonanceMinLevel } from '../data/sortieSynergy';
import {
@@ -438,6 +444,10 @@ export type CampaignSortieOrderHistory = Partial<
Record>>
>;
+export const campaignTutorialIds = ['first-battle-basic-controls'] as const;
+export type CampaignTutorialId = (typeof campaignTutorialIds)[number];
+const campaignTutorialIdSet = new Set(campaignTutorialIds);
+
export type CampaignState = {
version: 1;
updatedAt: string;
@@ -458,6 +468,7 @@ export type CampaignState = {
claimedSortieOrderRewardIds: string[];
reserveTrainingFocus: CampaignReserveTrainingFocusId;
reserveTrainingAssignments: CampaignReserveTrainingAssignments;
+ completedTutorialIds: CampaignTutorialId[];
completedCampDialogues: string[];
completedCampVisits: string[];
acknowledgedVictoryRewardBattleIds: string[];
@@ -663,6 +674,7 @@ export function createInitialCampaignState(): CampaignState {
claimedSortieOrderRewardIds: [],
reserveTrainingFocus: defaultCampaignReserveTrainingFocusId,
reserveTrainingAssignments: {},
+ completedTutorialIds: [],
completedCampDialogues: [],
completedCampVisits: [],
acknowledgedVictoryRewardBattleIds: [],
@@ -749,6 +761,20 @@ export function markCampaignStep(step: CampaignStep) {
return cloneCampaignState(state);
}
+export function hasCompletedCampaignTutorial(id: CampaignTutorialId, state = ensureCampaignState()) {
+ return state.completedTutorialIds.includes(id);
+}
+
+export function completeCampaignTutorial(id: CampaignTutorialId) {
+ const state = ensureCampaignState();
+ if (!state.completedTutorialIds.includes(id)) {
+ state.completedTutorialIds.push(id);
+ state.updatedAt = new Date().toISOString();
+ persistCampaignState(state);
+ }
+ return cloneCampaignState(state);
+}
+
export function setCampaignSortieOrderSelection(battleId: BattleScenarioId, orderId?: CampaignSortiePresetId) {
const state = ensureCampaignState();
if (!(battleId in battleScenarios)) {
@@ -957,6 +983,7 @@ export function setFirstBattleReport(report: FirstBattleReport) {
.filter((unit): unit is UnitData => Boolean(unit));
state.roster = mergeRosterProgress(state.roster, [...alliedReportUnits, ...recruitedUnits]);
state.bonds = reportClone.bonds.map(cloneBondSnapshot);
+ backfillEarlyJianYongRecruit(state);
const reserveTraining = previousSettlement || reportClone.outcome !== 'victory'
? previousSettlement?.reserveTraining ?? []
: applyReserveTrainingProgress(
@@ -1207,6 +1234,89 @@ export function ensureCampaignRosterUnits(units: UnitData[], bonds: BattleBond[]
return cloneCampaignState(state);
}
+const earlyJianYongRecruitBattleId: BattleScenarioId = 'first-battle-zhuo-commandery';
+
+/**
+ * 첫 승리 보상에 간옹이 추가되기 전에 만들어진 저장도 동일한 4인 편성 풀을 갖게 한다.
+ * 반복 호출해도 roster, reward, report, bond에 중복 항목을 만들지 않는다.
+ */
+function backfillEarlyJianYongRecruit(state: CampaignState) {
+ const firstSettlement = state.battleHistory[earlyJianYongRecruitBattleId];
+ const firstReport = state.firstBattleReport?.battleId === earlyJianYongRecruitBattleId
+ ? state.firstBattleReport
+ : undefined;
+ const firstVictoryRecorded = firstSettlement?.outcome === 'victory' || firstReport?.outcome === 'victory';
+
+ if (!firstVictoryRecorded) {
+ return;
+ }
+
+ if (!state.roster.some((unit) => unit.id === jianYongRecruitUnit.id)) {
+ state.roster.push(cloneUnit(jianYongRecruitUnit));
+ }
+ if (!state.bonds.some((bond) => bond.id === jianYongRecruitBond.id)) {
+ // 전역 인연 진행도는 전투 보고서의 96개 스냅샷 제한 대상이 아니다.
+ // 후반 저장을 마이그레이션할 때 기존 인연을 지우지 않고 누락 항목만 추가한다.
+ state.bonds.push(createCampBondSnapshot(jianYongRecruitBond));
+ }
+
+ if (firstReport?.outcome === 'victory') {
+ if (!firstReport.bonds.some((bond) => bond.id === jianYongRecruitBond.id)) {
+ appendRequiredLimitedMigrationEntry(firstReport.bonds, createCampBondSnapshot(jianYongRecruitBond), maxCampaignBattleBondEntries);
+ }
+ firstReport.campaignRewards = backfillEarlyJianYongReward(firstReport.campaignRewards);
+ }
+
+ if (firstSettlement?.outcome === 'victory') {
+ if (!firstSettlement.bonds.some((bond) => bond.id === jianYongRecruitBond.id)) {
+ appendRequiredLimitedMigrationEntry(firstSettlement.bonds, {
+ id: jianYongRecruitBond.id,
+ title: jianYongRecruitBond.title,
+ level: jianYongRecruitBond.level,
+ exp: jianYongRecruitBond.exp,
+ battleExp: 0
+ }, maxCampaignBattleBondEntries);
+ }
+ firstSettlement.campaignRewards = backfillEarlyJianYongReward(firstSettlement.campaignRewards);
+ }
+}
+
+function backfillEarlyJianYongReward(existing?: CampaignRewardSnapshot): CampaignRewardSnapshot {
+ const rewardDefinition = battleScenarios[earlyJianYongRecruitBattleId].campaignReward;
+ const snapshot = cloneCampaignRewardSnapshot(existing, earlyJianYongRecruitBattleId) ?? {
+ supplies: [...(rewardDefinition?.supplies ?? [])],
+ equipment: [...(rewardDefinition?.equipment ?? [])],
+ reputation: [...(rewardDefinition?.reputation ?? [])],
+ recruits: (rewardDefinition?.recruits ?? []).map((unitId) => ({
+ unitId,
+ name: campaignRecruitUnitById.get(unitId)?.name ?? battleScenarios[earlyJianYongRecruitBattleId].units.find((unit) => unit.id === unitId)?.name ?? unitId
+ })),
+ unlocks: rewardDefinition?.unlockBattleId
+ ? [{
+ battleId: rewardDefinition.unlockBattleId,
+ title: rewardDefinition.unlockLabel ?? battleScenarios[rewardDefinition.unlockBattleId].title
+ }]
+ : [],
+ ...(rewardDefinition?.note ? { note: rewardDefinition.note } : {})
+ };
+
+ if (!snapshot.recruits.some((recruit) => recruit.unitId === jianYongRecruitUnit.id)) {
+ appendRequiredLimitedMigrationEntry(
+ snapshot.recruits,
+ { unitId: jianYongRecruitUnit.id, name: jianYongRecruitUnit.name },
+ maxCampaignStringListEntries
+ );
+ }
+ return snapshot;
+}
+
+function appendRequiredLimitedMigrationEntry(entries: T[], entry: T, limit: number) {
+ if (entries.length >= limit) {
+ entries.splice(limit - 1);
+ }
+ entries.push(entry);
+}
+
function ensureCampaignState() {
if (!campaignState) {
campaignState = readStoredCampaignState() ?? createInitialCampaignState();
@@ -1232,9 +1342,14 @@ function normalizeCampaignState(state: Partial): CampaignState {
.filter((bond): bond is CampBondSnapshot => Boolean(bond));
normalized.completedCampDialogues = uniqueStrings(normalized.completedCampDialogues);
normalized.completedCampVisits = uniqueStrings(normalized.completedCampVisits);
+ normalized.completedTutorialIds = uniqueStrings(normalized.completedTutorialIds)
+ .filter((id): id is CampaignTutorialId => campaignTutorialIdSet.has(id));
normalized.acknowledgedVictoryRewardBattleIds = uniqueStrings(normalized.acknowledgedVictoryRewardBattleIds)
.filter((battleId) => battleId in battleScenarios);
normalized.inventory = normalizeInventory(normalized.inventory);
+ normalized.battleHistory = normalizeBattleHistory(normalized.battleHistory);
+ normalized.firstBattleReport = normalizeFirstBattleReport(normalized.firstBattleReport);
+ backfillEarlyJianYongRecruit(normalized);
const rosterUnitIds = normalizedRosterUnitIds(normalized.roster);
const sortieUnitFilter = rosterUnitIds.size > 0 ? rosterUnitIds : undefined;
if (sortieUnitFilter) {
@@ -1279,7 +1394,6 @@ function normalizeCampaignState(state: Partial): CampaignState {
);
normalized.reserveTrainingFocus = normalizeReserveTrainingFocusId(normalized.reserveTrainingFocus);
normalized.reserveTrainingAssignments = normalizeCampaignReserveTrainingAssignments(recordOrEmpty(normalized.reserveTrainingAssignments), sortieUnitFilter);
- normalized.battleHistory = normalizeBattleHistory(normalized.battleHistory);
if (sortieUnitFilter) {
normalized.battleHistory = filterBattleHistoryRosterReferences(
normalized.battleHistory,
@@ -1288,7 +1402,6 @@ function normalizeCampaignState(state: Partial): CampaignState {
);
}
normalized.latestBattleId = normalizeLatestBattleId(normalized.latestBattleId, normalized.battleHistory);
- normalized.firstBattleReport = normalizeFirstBattleReport(normalized.firstBattleReport);
if (normalized.firstBattleReport && sortieUnitFilter) {
normalized.firstBattleReport = filterFirstBattleReportRosterReferences(normalized.firstBattleReport, sortieUnitFilter);
}