Files
heros_web/scripts/verify-unit-sprite-asset-data.mjs

230 lines
7.6 KiB
JavaScript

import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { createServer } from 'vite';
const unitDir = join('src', 'assets', 'images', 'units');
const frameSize = 313;
const rows = 4;
const baseFramesPerDirection = 16;
const actionFramesPerDirection = 36;
const expectedBaseSize = { width: frameSize * baseFramesPerDirection, height: frameSize * rows };
const expectedActionSize = { width: frameSize * actionFramesPerDirection, height: frameSize * rows };
const battleScenePath = 'src/game/scenes/BattleScene.ts';
const nonTextureUnitLiterals = new Set(['unit-defeated']);
const requiredDynamicBaseKeys = [
'unit-rebel',
'unit-rebel-archer',
'unit-rebel-cavalry',
'unit-rebel-leader',
'unit-shu-infantry',
'unit-shu-spearman',
'unit-shu-archer',
'unit-shu-cavalry',
'unit-shu-strategist',
'unit-shu-officer',
'unit-wei-infantry',
'unit-wei-archer',
'unit-wei-cavalry',
'unit-wei-strategist',
'unit-wei-officer',
'unit-wu-infantry',
'unit-wu-archer',
'unit-wu-cavalry',
'unit-wu-strategist',
'unit-wu-officer',
'unit-nanman-infantry',
'unit-nanman-shaman',
'unit-nanman-officer'
];
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true },
appType: 'custom'
});
try {
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
const { hasUnitSheetAsset } = await server.ssrLoadModule('/src/game/data/unitAssets.ts');
const { storyCutsceneActorTextureKeys } = await server.ssrLoadModule('/src/game/data/storyCutscenes.ts');
const scenarioModule = await server.ssrLoadModule('/src/game/data/scenario.ts');
const errors = [];
const { baseKeys, actionKeys } = collectUnitSheetKeys(errors);
validateUnitSheetPairs(errors, baseKeys, actionKeys);
validateUnitSheetDimensions(errors, baseKeys, actionKeys);
validateRequiredDynamicBaseKeys(errors, baseKeys, hasUnitSheetAsset);
validateBattleSceneTextureLiterals(errors, baseKeys, actionKeys, hasUnitSheetAsset);
validateCutsceneTextureKeys(errors, scenarioModule, battleScenarios, storyCutsceneActorTextureKeys, baseKeys);
if (errors.length) {
console.error(`Unit sprite asset verification failed with ${errors.length} issue(s):`);
errors.slice(0, 100).forEach((error) => console.error(`- ${error}`));
if (errors.length > 100) {
console.error(`- ...and ${errors.length - 100} more`);
}
process.exitCode = 1;
} else {
console.log(
`Verified ${baseKeys.size} unit base sheets, ${actionKeys.size} action sheets, ` +
`${Object.keys(battleScenarios).length} battle scenario sprite dependencies, and cutscene texture keys.`
);
}
} finally {
await server.close();
}
function collectUnitSheetKeys(errors) {
const baseKeys = new Set();
const actionKeys = new Set();
readdirSync(unitDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && /^unit-.+\.png$/i.test(entry.name))
.forEach((entry) => {
const key = entry.name.replace(/\.png$/i, '');
if (key.endsWith('-actions')) {
actionKeys.add(key);
} else {
baseKeys.add(key);
}
});
if (baseKeys.size === 0) {
errors.push(`No unit base sprite sheets found in ${unitDir}`);
}
if (actionKeys.size === 0) {
errors.push(`No unit action sprite sheets found in ${unitDir}`);
}
return { baseKeys, actionKeys };
}
function validateUnitSheetPairs(errors, baseKeys, actionKeys) {
Array.from(baseKeys)
.sort()
.forEach((key) => {
if (!actionKeys.has(`${key}-actions`)) {
errors.push(`${key}: missing paired action sheet ${key}-actions.png`);
}
});
Array.from(actionKeys)
.sort()
.forEach((key) => {
const baseKey = key.replace(/-actions$/, '');
if (!baseKeys.has(baseKey)) {
errors.push(`${key}: action sheet has no paired base sheet ${baseKey}.png`);
}
});
}
function validateUnitSheetDimensions(errors, baseKeys, actionKeys) {
Array.from(baseKeys)
.sort()
.forEach((key) => validatePngDimensions(errors, key, join(unitDir, `${key}.png`), expectedBaseSize));
Array.from(actionKeys)
.sort()
.forEach((key) => validatePngDimensions(errors, key, join(unitDir, `${key}.png`), expectedActionSize));
}
function validateRequiredDynamicBaseKeys(errors, baseKeys, hasUnitSheetAsset) {
requiredDynamicBaseKeys.forEach((key) => {
if (!baseKeys.has(key) || !hasUnitSheetAsset(key)) {
errors.push(`dynamic unit texture base "${key}" is required by BattleScene but missing from unit assets`);
}
});
}
function validateBattleSceneTextureLiterals(errors, baseKeys, actionKeys, hasUnitSheetAsset) {
const source = readFileSync(battleScenePath, 'utf8');
const literalKeys = new Set([...source.matchAll(/['"`](unit-[a-z0-9-]+)['"`]/g)].map((match) => match[1]));
literalKeys.forEach((key) => {
if (nonTextureUnitLiterals.has(key)) {
return;
}
if (key.endsWith('-actions')) {
if (!actionKeys.has(key)) {
errors.push(`${battleScenePath}: literal action texture "${key}" has no matching PNG`);
}
return;
}
if (!baseKeys.has(key) || !hasUnitSheetAsset(key)) {
errors.push(`${battleScenePath}: literal unit texture "${key}" has no matching paired unit sheet`);
}
});
}
function validateCutsceneTextureKeys(errors, scenarioModule, battleScenarios, storyCutsceneActorTextureKeys, baseKeys) {
collectStorySources(scenarioModule, battleScenarios).forEach(({ source, pages }) => {
pages.forEach((page, index) => {
storyCutsceneActorTextureKeys(page.cutscene).forEach((key) => {
if (!baseKeys.has(key)) {
errors.push(`${source}[${index}]/${page.id}: cutscene texture "${key}" has no matching base sheet`);
}
});
});
});
}
function collectStorySources(scenarioModule, battleScenarios) {
const sources = [];
const seenArrays = new WeakSet();
Object.entries(scenarioModule)
.filter(([, value]) => isStoryPageList(value))
.sort(([left], [right]) => left.localeCompare(right))
.forEach(([name, pages]) => {
sources.push({ source: name, pages });
seenArrays.add(pages);
});
Object.entries(battleScenarios)
.sort(([left], [right]) => left.localeCompare(right))
.forEach(([battleId, scenario]) => {
if (!isStoryPageList(scenario.victoryPages) || seenArrays.has(scenario.victoryPages)) {
return;
}
sources.push({ source: `${battleId}.victoryPages`, pages: scenario.victoryPages });
seenArrays.add(scenario.victoryPages);
});
return sources;
}
function isStoryPageList(value) {
return Array.isArray(value) && value.length > 0 && value.every(isStoryPage);
}
function isStoryPage(page) {
return Boolean(page && typeof page === 'object' && typeof page.id === 'string' && typeof page.text === 'string');
}
function validatePngDimensions(errors, key, path, expected) {
let dimensions;
try {
dimensions = readPngDimensions(path);
} catch (error) {
errors.push(`${key}: cannot read "${path}": ${error.message}`);
return;
}
if (dimensions.width !== expected.width || dimensions.height !== expected.height) {
errors.push(`${key}: expected ${expected.width}x${expected.height}, got ${dimensions.width}x${dimensions.height}`);
}
}
function readPngDimensions(path) {
const stats = statSync(path);
if (stats.size < 24) {
throw new Error('file is too small to be a PNG');
}
const bytes = readFileSync(path);
const signature = '89504e470d0a1a0a';
if (bytes.subarray(0, 8).toString('hex') !== signature) {
throw new Error('file does not have a PNG signature');
}
return {
width: bytes.readUInt32BE(16),
height: bytes.readUInt32BE(20)
};
}