From db319ea99f74c6341e0e5e71963e1c47e9540c75 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 10 Jul 2026 00:10:31 +0900 Subject: [PATCH] Audit equipment icon quality --- docs/equipment-icon-quality-audit.md | 81 +++++ package.json | 1 + scripts/audit-equipment-icon-quality.mjs | 421 +++++++++++++++++++++++ 3 files changed, 503 insertions(+) create mode 100644 docs/equipment-icon-quality-audit.md create mode 100644 scripts/audit-equipment-icon-quality.mjs diff --git a/docs/equipment-icon-quality-audit.md b/docs/equipment-icon-quality-audit.md new file mode 100644 index 0000000..9a4803a --- /dev/null +++ b/docs/equipment-icon-quality-audit.md @@ -0,0 +1,81 @@ +# Equipment Icon Quality Audit + +Generated by `scripts/audit-equipment-icon-quality.mjs`. + +## Summary + +- 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`, `20px`, `21px`, `22px`, `30px` +- Direct item texture render sites found: 5 + +## Main Findings + +- 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. + +## First Improvement Target + +- Target: weapon spear/polearm family +- Item ids: `green-dragon-glaive`, `serpent-spear`, `sky-piercer-halberd`, `iron-spear`, `western-cavalry-spear` +- Reason: 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. + +## Follow-Up Targets + +- armor silhouettes: `oath-robe`, `reinforced-lamellar`, `cloth-armor`, `lamellar-armor`, `rebel-vest` + - Armor and robes are readable but depend on simple triangular torsos and small color accents. +- accessory charms and tokens: `bravery-token`, `mountain-guide-scroll`, `peach-charm`, `war-manual`, `wind-quiver`, `grain-pouch`, `yellow-scarf-charm` + - Accessories are the smallest UI read and several use compact badge-like shapes. + +## Highest-Risk Shared Silhouette Groups + +| score | signature | slots | ranks | items | +| ---: | --- | --- | --- | --- | +| 15 | `drawSpearIcon` | weapon | common, treasure | `serpent-spear`, `iron-spear`, `western-cavalry-spear` | +| 8 | `drawArmorIcon` | armor | common, treasure | `reinforced-lamellar`, `lamellar-armor` | +| 8 | `drawCharmIcon` | accessory | common, treasure | `peach-charm`, `yellow-scarf-charm` | +| 8 | `drawPolearmIcon` | weapon | treasure | `green-dragon-glaive`, `sky-piercer-halberd` | +| 8 | `drawRobeIcon` | armor | common, treasure | `oath-robe`, `cloth-armor` | + +## Full Item Inventory + +| item id | slot | rank | draw helpers | calls | primitives | colors | concerns | +| --- | --- | --- | --- | ---: | ---: | ---: | --- | +| `green-dragon-glaive` | weapon | treasure | `drawPolearmIcon` | 1 | 11 | 2 | single helper silhouette
low primitive detail | +| `serpent-spear` | weapon | treasure | `drawSpearIcon` | 1 | 7 | 2 | single helper silhouette
low primitive detail | +| `sky-piercer-halberd` | weapon | treasure | `drawPolearmIcon` | 1 | 11 | 2 | single helper silhouette
low primitive detail | +| `twin-oath-blades` | weapon | treasure | `drawSwordIcon`
`drawSwordIcon` | 2 | 32 | 3 | none | +| `white-feather-fan` | weapon | treasure | `drawFanIcon` | 1 | 19 | 2 | single helper silhouette | +| `iron-spear` | weapon | common | `drawSpearIcon` | 1 | 7 | 2 | single helper silhouette
low primitive detail | +| `leader-axe` | weapon | common | `drawAxeIcon` | 1 | 7 | 2 | single helper silhouette
low primitive detail | +| `short-bow` | weapon | common | `drawBowIcon` | 1 | 18 | 2 | single helper silhouette | +| `training-sword` | weapon | common | `drawSwordIcon` | 1 | 16 | 2 | single helper silhouette
low primitive detail | +| `western-cavalry-spear` | weapon | common | `drawSpearIcon` | 1 | 7 | 2 | single helper silhouette
low primitive detail | +| `yellow-turban-saber` | weapon | common | `drawSaberIcon` | 1 | 12 | 2 | single helper silhouette
low primitive detail | +| `oath-robe` | armor | treasure | `drawRobeIcon` | 1 | 7 | 2 | single helper silhouette
low primitive detail | +| `reinforced-lamellar` | armor | treasure | `drawArmorIcon` | 1 | 12 | 2 | single helper silhouette
low primitive detail | +| `cloth-armor` | armor | common | `drawRobeIcon` | 1 | 7 | 2 | single helper silhouette
low primitive detail | +| `lamellar-armor` | armor | common | `drawArmorIcon` | 1 | 12 | 2 | single helper silhouette
low primitive detail | +| `rebel-vest` | armor | common | `drawVestIcon` | 1 | 4 | 2 | single helper silhouette
low primitive detail | +| `bravery-token` | accessory | treasure | `drawTokenIcon` | 1 | 4 | 2 | single helper silhouette
low primitive detail | +| `mountain-guide-scroll` | accessory | treasure | `drawMapScrollIcon` | 1 | 13 | 2 | single helper silhouette
low primitive detail | +| `peach-charm` | accessory | treasure | `drawCharmIcon` | 1 | 4 | 2 | single helper silhouette
low primitive detail | +| `war-manual` | accessory | treasure | `drawBookIcon` | 1 | 7 | 2 | single helper silhouette
low primitive detail | +| `wind-quiver` | accessory | treasure | `drawQuiverIcon` | 1 | 8 | 2 | single helper silhouette
low primitive detail | +| `grain-pouch` | accessory | common | `drawPouchIcon` | 1 | 7 | 2 | single helper silhouette
low primitive detail | +| `yellow-scarf-charm` | accessory | common | `drawCharmIcon` | 1 | 4 | 2 | single helper silhouette
low primitive detail | + +## Recommended Next Batch Definition + +- Scope: replace or enrich the five spear/polearm weapon icons while keeping `item-${itemId}` texture keys stable. +- Success criteria: each item reads as a distinct weapon silhouette at 20px and 30px, with treasure weapons visibly richer than common weapons. +- 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. diff --git a/package.json b/package.json index 78b38b2..b23d71b 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "build": "tsc && vite build", "generate:audio": "node scripts/generate-bgm.mjs", "generate:sfx": "node scripts/generate-sfx.mjs", + "audit:equipment-icons": "node scripts/audit-equipment-icon-quality.mjs", "preview": "vite preview --host 0.0.0.0", "measure:performance": "node scripts/measure-performance.mjs", "verify:performance": "node scripts/measure-performance.mjs", diff --git a/scripts/audit-equipment-icon-quality.mjs b/scripts/audit-equipment-icon-quality.mjs new file mode 100644 index 0000000..0a1b83c --- /dev/null +++ b/scripts/audit-equipment-icon-quality.mjs @@ -0,0 +1,421 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { createServer } from 'vite'; + +const bootScenePath = 'src/game/scenes/BootScene.ts'; +const campScenePath = 'src/game/scenes/CampScene.ts'; +const reportPath = 'docs/equipment-icon-quality-audit.md'; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true }, + appType: 'custom' +}); + +try { + const { itemCatalog, equipmentSlots } = await server.ssrLoadModule('/src/game/data/battleItems.ts'); + const bootSceneSource = readFileSync(bootScenePath, 'utf8'); + const campSceneSource = readFileSync(campScenePath, 'utf8'); + + 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}`); +} 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]); +} + +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))].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) { + const slotOrder = new Map(equipmentSlots.map((slot, index) => [slot, index])); + + return Object.entries(itemCatalog) + .map(([itemId, item]) => { + const icon = iconDefinitions.get(itemId); + const concerns = []; + if (!icon) { + concerns.push('missing generated icon'); + } else { + if (icon.drawCalls.length <= 1) { + concerns.push('single helper silhouette'); + } + 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 + }; + }) + .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) + })) + .sort((left, right) => right.score - left.score || right.rows.length - left.rows.length || left.signature.localeCompare(right.signature)); +} + +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; + } + + return score; +} + +function buildRecommendations(itemRows, groupedRows) { + const weaponPolearmIds = itemRows + .filter((row) => row.slot === 'weapon' && ['drawSpearIcon', 'drawPolearmIcon'].some((name) => row.drawFunctions.includes(name))) + .map((row) => row.itemId); + const armorIds = itemRows.filter((row) => row.slot === 'armor').map((row) => row.itemId); + const accessoryIds = itemRows.filter((row) => row.slot === 'accessory').map((row) => row.itemId); + + return { + firstTargetTitle: 'weapon spear/polearm family', + firstTargetIds: weaponPolearmIds, + firstTargetReason: + '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.', + followUps: [ + { + title: 'armor silhouettes', + itemIds: armorIds, + reason: 'Armor and robes are readable but depend on simple triangular torsos and small color accents.' + }, + { + title: 'accessory charms and tokens', + itemIds: accessoryIds, + reason: 'Accessories are the smallest UI read and several use compact badge-like shapes.' + } + ], + topRiskGroups: groupedRows.slice(0, 5) + }; +} + +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); + + return `${[ + '# Equipment Icon Quality Audit', + '', + 'Generated by `scripts/audit-equipment-icon-quality.mjs`.', + '', + '## Summary', + '', + `- 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}`, + '', + '## Main Findings', + '', + '- 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.', + '', + '## First Improvement Target', + '', + `- Target: ${recommendations.firstTargetTitle}`, + `- Item ids: ${recommendations.firstTargetIds.map((itemId) => `\`${itemId}\``).join(', ')}`, + `- Reason: ${recommendations.firstTargetReason}`, + '', + '## Follow-Up Targets', + '', + ...recommendations.followUps.flatMap((target) => [ + `- ${target.title}: ${target.itemIds.map((itemId) => `\`${itemId}\``).join(', ')}`, + ` - ${target.reason}` + ]), + '', + '## 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(', ')} |` + ), + '', + '## Full Item Inventory', + '', + '| 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} |`), + '', + '## Recommended Next Batch Definition', + '', + '- Scope: replace or enrich the five spear/polearm weapon icons while keeping `item-${itemId}` texture keys stable.', + '- Success criteria: each item reads as a distinct weapon silhouette at 20px and 30px, with treasure weapons visibly richer than common weapons.', + '- 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.', + '' + ].join('\n')}`; +} + +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 formatCounts(counts) { + return [...counts.entries()] + .sort(([left], [right]) => String(left).localeCompare(String(right))) + .map(([key, count]) => `${key} ${count}`) + .join(', '); +}