Files
heros_web/scripts/verify-exploration-character-asset-data.mjs

246 lines
8.6 KiB
JavaScript

import assert from 'node:assert/strict';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
const frameSize = 192;
const idleFrames = 8;
const walkFrames = 8;
const directionCount = 4;
const expectedWidth = frameSize * (idleFrames + walkFrames);
const expectedHeight = frameSize * directionCount;
const characterDirectory = join('src', 'assets', 'images', 'exploration', 'characters');
const modulePath = join('src', 'game', 'data', 'explorationCharacterAssets.ts');
const villageScenePath = join('src', 'game', 'scenes', 'PrologueVillageScene.ts');
const militiaCampScenePath = join('src', 'game', 'scenes', 'PrologueMilitiaCampScene.ts');
const campVisitScenePath = join('src', 'game', 'scenes', 'CampVisitExplorationScene.ts');
const expectedBaseMappings = {
'unit-liu-bei': 'exploration-liu-bei',
'unit-guan-yu': 'exploration-guan-yu',
'unit-zhang-fei': 'exploration-zhang-fei',
'unit-shu-officer': 'exploration-shu-officer',
'unit-shu-infantry': 'exploration-shu-infantry'
};
const expectedNamedTextureFallbacks = {
'exploration-huang-quan': 'unit-shu-officer',
'exploration-jian-yong': 'unit-shu-officer',
'exploration-mi-zhu': 'unit-shu-officer',
'exploration-zhuge-liang': 'unit-shu-officer',
'exploration-zhuo-recruiting-clerk': 'unit-shu-officer',
'exploration-zhuo-villager': 'unit-shu-infantry',
'exploration-zhuo-quartermaster': 'unit-shu-officer',
'exploration-zou-jing': 'unit-shu-officer'
};
const expectedSceneNpcTextureKeys = [
{
scenePath: villageScenePath,
sceneLabel: 'prologue village',
mappingName: 'villageNpcTextureKeyById',
mappings: {
'recruiting-clerk': 'exploration-zhuo-recruiting-clerk',
'market-villager': 'exploration-zhuo-villager'
}
},
{
scenePath: militiaCampScenePath,
sceneLabel: 'prologue militia camp',
mappingName: 'campNpcTextureKeyById',
mappings: {
quartermaster: 'exploration-zhuo-quartermaster',
'zou-jing': 'exploration-zou-jing',
'nervous-volunteer': 'exploration-shu-infantry'
}
}
];
const expectedFiles = [
...Object.values(expectedBaseMappings),
...Object.keys(expectedNamedTextureFallbacks)
]
.map((textureKey) => `${textureKey}.webp`)
.sort();
assert(existsSync(modulePath), `Missing exploration character asset module: ${modulePath}`);
const moduleSource = readFileSync(modulePath, 'utf8');
assert.match(
moduleSource,
/import\.meta\.glob\(\s*['"]\.\.\/\.\.\/assets\/images\/exploration\/characters\/exploration-\*\.webp['"]/,
'Exploration character sheets must be collected through a static import.meta.glob.'
);
assert.match(
moduleSource,
/export const explorationCharacterFrameSize = 192;/,
'The exploration character frame size must remain 192px.'
);
assert.match(
moduleSource,
/export const explorationCharacterIdleFrameCount = 8;/,
'Each direction must retain eight idle frames.'
);
assert.match(
moduleSource,
/export const explorationCharacterWalkFrameCount = 8;/,
'Each direction must retain eight walk frames.'
);
assert.match(
moduleSource,
/export function ensureExplorationCharacterAnimations\(/,
'The module must expose exploration animation registration.'
);
assert.match(
moduleSource,
/export function explorationCharacterFrameFor\(/,
'The module must expose deterministic direction and motion frame lookup.'
);
assert.match(
moduleSource,
/export function applyExplorationCharacterMotion\(/,
'The module must expose shared reduced-motion-aware animation application.'
);
assert.match(
moduleSource,
/if \(reducedMotion && motion === 'idle'\)\s*\{\s*sprite\.anims\.stop\(\);\s*sprite\.setFrame\(explorationCharacterFrameFor\('idle', direction\)\);/s,
'Reduced motion must freeze only decorative idle loops on the matching direction frame.'
);
assert.match(
moduleSource,
/export function releaseExplorationCharacterTextures\(/,
'The module must expose texture and animation cleanup.'
);
assert.deepEqual(
readStringMap(moduleSource, 'explorationCharacterTextureKeyByUnitTextureKey'),
expectedBaseMappings,
'Exploration character unit-to-sheet mappings must exactly match the five reusable base sheets.'
);
assert.deepEqual(
readStringMap(moduleSource, 'explorationCharacterNamedTextureKeyFallbacks'),
expectedNamedTextureFallbacks,
'Exploration character named-sheet fallbacks must exactly match the dedicated NPC sheets.'
);
expectedSceneNpcTextureKeys.forEach(({ scenePath, sceneLabel, mappingName, mappings }) => {
assert(existsSync(scenePath), `Missing ${sceneLabel} scene module: ${scenePath}`);
assert.deepEqual(
readStringMap(readFileSync(scenePath, 'utf8'), mappingName),
mappings,
`${sceneLabel} must select the expected exploration sheet for each overridden NPC id.`
);
});
assert(existsSync(campVisitScenePath), `Missing first-camp visit exploration scene: ${campVisitScenePath}`);
const campVisitSceneSource = readFileSync(campVisitScenePath, 'utf8');
assert.match(
campVisitSceneSource,
/id:\s*['"]jian-yong['"][\s\S]*?textureKey:\s*['"]exploration-jian-yong['"]/,
'The first-camp visit scene must render Jian Yong with his dedicated exploration sheet.'
);
assert.match(
campVisitSceneSource,
/initialPosition:\s*\{\s*x:\s*definition\.x,\s*y:\s*definition\.y\s*\}/,
'The first-camp visit scene must retain each NPC initial position for no-teleport verification.'
);
assert(
existsSync(characterDirectory),
`Missing exploration character asset directory: ${characterDirectory}`
);
const deployedFiles = readdirSync(characterDirectory)
.filter((fileName) => /^exploration-.*\.webp$/i.test(fileName))
.sort();
assert.deepEqual(
deployedFiles,
expectedFiles,
`Expected exactly the ${expectedFiles.length} mapped exploration character sheets in ${characterDirectory}.`
);
deployedFiles.forEach((fileName) => {
const path = join(characterDirectory, fileName);
const inspection = inspectWebp(path);
assert.equal(
inspection.width,
expectedWidth,
`${path}: expected ${expectedWidth}px width for 16 frames per direction.`
);
assert.equal(
inspection.height,
expectedHeight,
`${path}: expected ${expectedHeight}px height for four direction rows.`
);
assert.equal(
inspection.hasAlpha,
true,
`${path}: exploration character sheets must preserve transparent backgrounds.`
);
});
console.log(
`Verified ${deployedFiles.length} exploration character sheets and ` +
`${expectedSceneNpcTextureKeys.reduce((count, scene) => count + Object.keys(scene.mappings).length, 0)} ` +
`scene NPC mappings: ` +
`${frameSize}px frames, ${directionCount} directions, ${idleFrames} idle + ${walkFrames} walk frames.`
);
function readStringMap(source, mappingName) {
const escapedMappingName = mappingName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const mappingBlock = source.match(
new RegExp(
`(?:export\\s+)?const\\s+${escapedMappingName}\\s*=\\s*\\{([\\s\\S]*?)\\}\\s*as const`
)
);
assert(mappingBlock, `Could not read ${mappingName}.`);
const mappings = {};
for (const entry of mappingBlock[1].matchAll(/(?:['"]([^'"]+)['"]|([A-Za-z0-9_-]+))\s*:\s*['"]([^'"]+)['"]/g)) {
mappings[entry[1] ?? entry[2]] = entry[3];
}
return mappings;
}
function inspectWebp(path) {
const bytes = readFileSync(path);
assert(
bytes.subarray(0, 4).toString('ascii') === 'RIFF' &&
bytes.subarray(8, 12).toString('ascii') === 'WEBP',
`${path}: expected a WebP RIFF container.`
);
let offset = 12;
let dimensions;
let hasAlpha = false;
while (offset + 8 <= bytes.length) {
const chunkType = bytes.subarray(offset, offset + 4).toString('ascii');
const chunkSize = bytes.readUInt32LE(offset + 4);
const payloadOffset = offset + 8;
assert(
payloadOffset + chunkSize <= bytes.length,
`${path}: ${chunkType} chunk exceeds the WebP container.`
);
if (chunkType === 'VP8X') {
dimensions = {
width: bytes.readUIntLE(payloadOffset + 4, 3) + 1,
height: bytes.readUIntLE(payloadOffset + 7, 3) + 1
};
hasAlpha ||= (bytes[payloadOffset] & 0x10) !== 0;
} else if (chunkType === 'ALPH') {
hasAlpha = true;
} else if (chunkType === 'VP8L') {
const bits = bytes.readUInt32LE(payloadOffset + 1);
dimensions ??= {
width: (bits & 0x3fff) + 1,
height: ((bits >>> 14) & 0x3fff) + 1
};
hasAlpha ||= (bits & 0x10000000) !== 0;
} else if (chunkType === 'VP8 ') {
dimensions ??= {
width: bytes.readUInt16LE(payloadOffset + 6) & 0x3fff,
height: bytes.readUInt16LE(payloadOffset + 8) & 0x3fff
};
}
offset = payloadOffset + chunkSize + (chunkSize % 2);
}
assert(dimensions, `${path}: could not read WebP dimensions.`);
return { ...dimensions, hasAlpha };
}