Verify visual asset references
This commit is contained in:
@@ -14,6 +14,7 @@
|
|||||||
"verify:battle-data": "node scripts/verify-battle-scenario-data.mjs",
|
"verify:battle-data": "node scripts/verify-battle-scenario-data.mjs",
|
||||||
"verify:campaign-data": "node scripts/verify-campaign-flow-data.mjs",
|
"verify:campaign-data": "node scripts/verify-campaign-flow-data.mjs",
|
||||||
"verify:story-assets": "node scripts/verify-story-asset-data.mjs",
|
"verify:story-assets": "node scripts/verify-story-asset-data.mjs",
|
||||||
|
"verify:visual-assets": "node scripts/verify-visual-asset-data.mjs",
|
||||||
"verify:flow": "node scripts/verify-flow.mjs",
|
"verify:flow": "node scripts/verify-flow.mjs",
|
||||||
"verify:save-flow": "node scripts/verify-save-retry-flow.mjs",
|
"verify:save-flow": "node scripts/verify-save-retry-flow.mjs",
|
||||||
"verify:release": "node scripts/verify-release-candidate.mjs",
|
"verify:release": "node scripts/verify-release-candidate.mjs",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ try {
|
|||||||
runCommand('node', ['scripts/verify-campaign-flow-data.mjs']);
|
runCommand('node', ['scripts/verify-campaign-flow-data.mjs']);
|
||||||
runCommand('node', ['scripts/verify-battle-scenario-data.mjs']);
|
runCommand('node', ['scripts/verify-battle-scenario-data.mjs']);
|
||||||
runCommand('node', ['scripts/verify-story-asset-data.mjs']);
|
runCommand('node', ['scripts/verify-story-asset-data.mjs']);
|
||||||
|
runCommand('node', ['scripts/verify-visual-asset-data.mjs']);
|
||||||
mkdirSync(screenshotDir, { recursive: true });
|
mkdirSync(screenshotDir, { recursive: true });
|
||||||
serverProcess = await ensurePreviewServer(targetUrl);
|
serverProcess = await ensurePreviewServer(targetUrl);
|
||||||
|
|
||||||
|
|||||||
146
scripts/verify-visual-asset-data.mjs
Normal file
146
scripts/verify-visual-asset-data.mjs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { readFileSync, statSync } from 'node:fs';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
|
||||||
|
const server = await createServer({
|
||||||
|
logLevel: 'error',
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
appType: 'custom'
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { battleUiIconManifest } = await server.ssrLoadModule('/src/game/data/battleUiIcons.ts');
|
||||||
|
const { itemCatalog, equipmentSlots } = await server.ssrLoadModule('/src/game/data/battleItems.ts');
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
validateBattleUiIconAtlas(errors, battleUiIconManifest);
|
||||||
|
validateItemCatalog(errors, itemCatalog, equipmentSlots);
|
||||||
|
validateGeneratedEquipmentIcons(errors, itemCatalog);
|
||||||
|
|
||||||
|
if (errors.length) {
|
||||||
|
console.error(`Visual asset data 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 battle UI atlas ${battleUiIconManifest.columns}x${battleUiIconManifest.rows}, ` +
|
||||||
|
`${Object.keys(battleUiIconManifest.frames).length} icon keys, and ${Object.keys(itemCatalog).length} generated equipment icons.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBattleUiIconAtlas(errors, manifest) {
|
||||||
|
assertNonEmptyString(errors, manifest.source, 'battleUiIconManifest.source');
|
||||||
|
assertNonEmptyString(errors, manifest.textureKey, 'battleUiIconManifest.textureKey');
|
||||||
|
assertPositiveInteger(errors, manifest.frameWidth, 'battleUiIconManifest.frameWidth');
|
||||||
|
assertPositiveInteger(errors, manifest.frameHeight, 'battleUiIconManifest.frameHeight');
|
||||||
|
assertPositiveInteger(errors, manifest.columns, 'battleUiIconManifest.columns');
|
||||||
|
assertPositiveInteger(errors, manifest.rows, 'battleUiIconManifest.rows');
|
||||||
|
|
||||||
|
let dimensions;
|
||||||
|
try {
|
||||||
|
dimensions = readPngDimensions(manifest.source);
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(`battle UI icon atlas cannot be read at "${manifest.source}": ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedWidth = manifest.columns * manifest.frameWidth;
|
||||||
|
const expectedHeight = manifest.rows * manifest.frameHeight;
|
||||||
|
if (dimensions.width !== expectedWidth || dimensions.height !== expectedHeight) {
|
||||||
|
errors.push(`battle UI icon atlas is ${dimensions.width}x${dimensions.height}, expected ${expectedWidth}x${expectedHeight}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameLimit = manifest.columns * manifest.rows;
|
||||||
|
Object.entries(manifest.frames).forEach(([key, frame]) => {
|
||||||
|
assertNonEmptyString(errors, key, 'battle UI icon key');
|
||||||
|
if (!Number.isInteger(frame) || frame < 0 || frame >= frameLimit) {
|
||||||
|
errors.push(`battle UI icon "${key}" frame ${frame} is outside atlas frame range 0..${frameLimit - 1}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateItemCatalog(errors, itemCatalog, equipmentSlots) {
|
||||||
|
const validSlots = new Set(equipmentSlots);
|
||||||
|
const validRanks = new Set(['common', 'treasure']);
|
||||||
|
|
||||||
|
Object.entries(itemCatalog).forEach(([id, item]) => {
|
||||||
|
const context = `itemCatalog.${id}`;
|
||||||
|
if (item.id !== id) {
|
||||||
|
errors.push(`${context}: item id does not match catalog key`);
|
||||||
|
}
|
||||||
|
assertNonEmptyString(errors, item.name, `${context}.name`);
|
||||||
|
assertNonEmptyString(errors, item.description, `${context}.description`);
|
||||||
|
if (!validSlots.has(item.slot)) {
|
||||||
|
errors.push(`${context}: unknown equipment slot "${item.slot}"`);
|
||||||
|
}
|
||||||
|
if (!validRanks.has(item.rank)) {
|
||||||
|
errors.push(`${context}: unknown equipment rank "${item.rank}"`);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(item.effects) || item.effects.length === 0) {
|
||||||
|
errors.push(`${context}: effects must not be empty`);
|
||||||
|
} else {
|
||||||
|
item.effects.forEach((effect, index) => assertNonEmptyString(errors, effect, `${context}.effects[${index}]`));
|
||||||
|
}
|
||||||
|
['attackBonus', 'defenseBonus', 'strategyBonus'].forEach((field) => {
|
||||||
|
if (item[field] !== undefined && (!Number.isInteger(item[field]) || item[field] < 0)) {
|
||||||
|
errors.push(`${context}.${field} must be a non-negative integer`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateGeneratedEquipmentIcons(errors, itemCatalog) {
|
||||||
|
const bootScene = readFileSync('src/game/scenes/BootScene.ts', 'utf8');
|
||||||
|
const generatedItemIds = new Set([...bootScene.matchAll(/createItemIcon\('item-([^']+)'/g)].map((match) => match[1]));
|
||||||
|
const catalogItemIds = new Set(Object.keys(itemCatalog));
|
||||||
|
|
||||||
|
Array.from(catalogItemIds)
|
||||||
|
.sort()
|
||||||
|
.forEach((itemId) => {
|
||||||
|
if (!generatedItemIds.has(itemId)) {
|
||||||
|
errors.push(`BootScene does not generate an equipment icon texture for item-${itemId}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Array.from(generatedItemIds)
|
||||||
|
.sort()
|
||||||
|
.forEach((itemId) => {
|
||||||
|
if (!catalogItemIds.has(itemId)) {
|
||||||
|
errors.push(`BootScene generates item-${itemId}, but itemCatalog has no matching item`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPositiveInteger(errors, value, context) {
|
||||||
|
if (!Number.isInteger(value) || value <= 0) {
|
||||||
|
errors.push(`${context} must be a positive integer`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNonEmptyString(errors, value, context) {
|
||||||
|
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||||
|
errors.push(`${context} must be a non-empty string`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,9 @@ export class BootScene extends Phaser.Scene {
|
|||||||
});
|
});
|
||||||
this.createItemIcon('item-green-dragon-glaive', (graphics) => this.drawPolearmIcon(graphics, 0x5fbf8f, 0xd8b15f));
|
this.createItemIcon('item-green-dragon-glaive', (graphics) => this.drawPolearmIcon(graphics, 0x5fbf8f, 0xd8b15f));
|
||||||
this.createItemIcon('item-serpent-spear', (graphics) => this.drawSpearIcon(graphics, 0xe0e8ef, 0xb86b55));
|
this.createItemIcon('item-serpent-spear', (graphics) => this.drawSpearIcon(graphics, 0xe0e8ef, 0xb86b55));
|
||||||
|
this.createItemIcon('item-iron-spear', (graphics) => this.drawSpearIcon(graphics, 0xd8dfe6, 0x6cc5ff));
|
||||||
this.createItemIcon('item-sky-piercer-halberd', (graphics) => this.drawPolearmIcon(graphics, 0xd8dfe6, 0xd95f4f));
|
this.createItemIcon('item-sky-piercer-halberd', (graphics) => this.drawPolearmIcon(graphics, 0xd8dfe6, 0xd95f4f));
|
||||||
|
this.createItemIcon('item-western-cavalry-spear', (graphics) => this.drawSpearIcon(graphics, 0xe6d3a6, 0xb86b55));
|
||||||
this.createItemIcon('item-white-feather-fan', (graphics) => this.drawFanIcon(graphics, 0xf3f0df, 0x6e8eb8));
|
this.createItemIcon('item-white-feather-fan', (graphics) => this.drawFanIcon(graphics, 0xf3f0df, 0x6e8eb8));
|
||||||
this.createItemIcon('item-yellow-turban-saber', (graphics) => this.drawSaberIcon(graphics, 0xd8b15f, 0x9c6b2f));
|
this.createItemIcon('item-yellow-turban-saber', (graphics) => this.drawSaberIcon(graphics, 0xd8b15f, 0x9c6b2f));
|
||||||
this.createItemIcon('item-short-bow', (graphics) => this.drawBowIcon(graphics, 0xc58a4c, 0xe8dfca));
|
this.createItemIcon('item-short-bow', (graphics) => this.drawBowIcon(graphics, 0xc58a4c, 0xe8dfca));
|
||||||
@@ -48,6 +50,7 @@ export class BootScene extends Phaser.Scene {
|
|||||||
|
|
||||||
this.createItemIcon('item-peach-charm', (graphics) => this.drawCharmIcon(graphics, 0xf0a6a4, 0xd8b15f));
|
this.createItemIcon('item-peach-charm', (graphics) => this.drawCharmIcon(graphics, 0xf0a6a4, 0xd8b15f));
|
||||||
this.createItemIcon('item-war-manual', (graphics) => this.drawBookIcon(graphics, 0x5b78a0, 0xe8dfca));
|
this.createItemIcon('item-war-manual', (graphics) => this.drawBookIcon(graphics, 0x5b78a0, 0xe8dfca));
|
||||||
|
this.createItemIcon('item-mountain-guide-scroll', (graphics) => this.drawMapScrollIcon(graphics, 0xcfae72, 0x5f8f65));
|
||||||
this.createItemIcon('item-bravery-token', (graphics) => this.drawTokenIcon(graphics, 0xd8b15f, 0x9b4c3a));
|
this.createItemIcon('item-bravery-token', (graphics) => this.drawTokenIcon(graphics, 0xd8b15f, 0x9b4c3a));
|
||||||
this.createItemIcon('item-grain-pouch', (graphics) => this.drawPouchIcon(graphics, 0xa7794a, 0xe8dfca));
|
this.createItemIcon('item-grain-pouch', (graphics) => this.drawPouchIcon(graphics, 0xa7794a, 0xe8dfca));
|
||||||
this.createItemIcon('item-yellow-scarf-charm', (graphics) => this.drawCharmIcon(graphics, 0xd8b15f, 0x8b6f2a));
|
this.createItemIcon('item-yellow-scarf-charm', (graphics) => this.drawCharmIcon(graphics, 0xd8b15f, 0x8b6f2a));
|
||||||
@@ -244,6 +247,25 @@ export class BootScene extends Phaser.Scene {
|
|||||||
graphics.strokePath();
|
graphics.strokePath();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private drawMapScrollIcon(graphics: Phaser.GameObjects.Graphics, paperColor: number, routeColor: number) {
|
||||||
|
graphics.fillStyle(0x8d5a3a, 1);
|
||||||
|
graphics.fillRoundedRect(5, 5, 5, 18, 2);
|
||||||
|
graphics.fillRoundedRect(18, 5, 5, 18, 2);
|
||||||
|
graphics.fillStyle(paperColor, 1);
|
||||||
|
graphics.fillRoundedRect(8, 6, 12, 16, 2);
|
||||||
|
graphics.lineStyle(1, 0x4e3522, 0.7);
|
||||||
|
graphics.strokeRoundedRect(8, 6, 12, 16, 2);
|
||||||
|
graphics.lineStyle(2, routeColor, 1);
|
||||||
|
graphics.beginPath();
|
||||||
|
graphics.moveTo(10, 18);
|
||||||
|
graphics.lineTo(13, 14);
|
||||||
|
graphics.lineTo(15, 16);
|
||||||
|
graphics.lineTo(18, 10);
|
||||||
|
graphics.strokePath();
|
||||||
|
graphics.fillStyle(0xd8b15f, 1);
|
||||||
|
graphics.fillCircle(18, 10, 2);
|
||||||
|
}
|
||||||
|
|
||||||
private drawTokenIcon(graphics: Phaser.GameObjects.Graphics, baseColor: number, accentColor: number) {
|
private drawTokenIcon(graphics: Phaser.GameObjects.Graphics, baseColor: number, accentColor: number) {
|
||||||
graphics.fillStyle(baseColor, 1);
|
graphics.fillStyle(baseColor, 1);
|
||||||
graphics.fillCircle(14, 14, 9);
|
graphics.fillCircle(14, 14, 9);
|
||||||
|
|||||||
Reference in New Issue
Block a user