Verify unit sprite asset sheets
This commit is contained in:
229
scripts/verify-unit-sprite-asset-data.mjs
Normal file
229
scripts/verify-unit-sprite-asset-data.mjs
Normal file
@@ -0,0 +1,229 @@
|
||||
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)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user