246 lines
8.6 KiB
JavaScript
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 };
|
|
}
|