feat: upgrade prologue exploration presentation
This commit is contained in:
202
scripts/verify-exploration-character-asset-data.mjs
Normal file
202
scripts/verify-exploration-character-asset-data.mjs
Normal file
@@ -0,0 +1,202 @@
|
||||
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 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-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 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 four 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(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 nine mapped exploration character sheets in ${characterDirectory}.`
|
||||
);
|
||||
|
||||
deployedFiles.forEach((fileName) => {
|
||||
const path = join(characterDirectory, fileName);
|
||||
const dimensions = webpDimensions(path);
|
||||
assert.equal(
|
||||
dimensions.width,
|
||||
expectedWidth,
|
||||
`${path}: expected ${expectedWidth}px width for 16 frames per direction.`
|
||||
);
|
||||
assert.equal(
|
||||
dimensions.height,
|
||||
expectedHeight,
|
||||
`${path}: expected ${expectedHeight}px height for four direction rows.`
|
||||
);
|
||||
});
|
||||
|
||||
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 webpDimensions(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;
|
||||
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') {
|
||||
return {
|
||||
width: bytes.readUIntLE(payloadOffset + 4, 3) + 1,
|
||||
height: bytes.readUIntLE(payloadOffset + 7, 3) + 1
|
||||
};
|
||||
}
|
||||
if (chunkType === 'VP8L') {
|
||||
const bits = bytes.readUInt32LE(payloadOffset + 1);
|
||||
return {
|
||||
width: (bits & 0x3fff) + 1,
|
||||
height: ((bits >>> 14) & 0x3fff) + 1
|
||||
};
|
||||
}
|
||||
if (chunkType === 'VP8 ') {
|
||||
return {
|
||||
width: bytes.readUInt16LE(payloadOffset + 6) & 0x3fff,
|
||||
height: bytes.readUInt16LE(payloadOffset + 8) & 0x3fff
|
||||
};
|
||||
}
|
||||
|
||||
offset = payloadOffset + chunkSize + (chunkSize % 2);
|
||||
}
|
||||
|
||||
throw new Error(`${path}: could not read WebP dimensions.`);
|
||||
}
|
||||
Reference in New Issue
Block a user