377 lines
15 KiB
JavaScript
377 lines
15 KiB
JavaScript
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
const root = process.cwd();
|
|
const unitDir = join(root, 'src', 'assets', 'images', 'units');
|
|
const distAssetsDir = join(root, 'dist', 'assets');
|
|
const policy = JSON.parse(readFileSync(join(root, 'src', 'game', 'data', 'unitActionAssetPolicy.json'), 'utf8'));
|
|
const MiB = 1024 * 1024;
|
|
const verifyDist = process.argv.includes('--dist');
|
|
if (policy.optimizeAll !== true) {
|
|
throw new Error('Unit asset policy v3 must optimize every base and action sheet.');
|
|
}
|
|
|
|
const optimizedActionKeyList = readdirSync(unitDir)
|
|
.filter((file) => /^unit-.*-actions\.png$/i.test(file))
|
|
.map((file) => file.replace(/-actions\.png$/i, ''))
|
|
.sort();
|
|
const optimizedBaseKeyList = readdirSync(unitDir)
|
|
.filter((file) => /^unit-.*\.png$/i.test(file) && !/-actions\.png$/i.test(file))
|
|
.map((file) => file.replace(/\.png$/i, ''))
|
|
.sort();
|
|
const optimizedActionKeys = new Set(optimizedActionKeyList);
|
|
const optimizedBaseKeys = new Set(optimizedBaseKeyList);
|
|
|
|
if (optimizedActionKeyList.length !== policy.expectedActionAssetCount) {
|
|
throw new Error(`Expected ${policy.expectedActionAssetCount} optimized action assets, found ${optimizedActionKeyList.length}.`);
|
|
}
|
|
if (optimizedBaseKeyList.length !== policy.expectedBaseAssetCount) {
|
|
throw new Error(`Expected ${policy.expectedBaseAssetCount} optimized base assets, found ${optimizedBaseKeyList.length}.`);
|
|
}
|
|
if (optimizedActionKeyList.some((key, index) => key !== optimizedBaseKeyList[index])) {
|
|
throw new Error('Base and action sprite key sets do not match.');
|
|
}
|
|
|
|
const representativeBattles = [
|
|
{
|
|
id: 'first-battle-zhuo-commandery',
|
|
phase: 'early',
|
|
keys: [
|
|
'unit-liu-bei',
|
|
'unit-guan-yu',
|
|
'unit-zhang-fei',
|
|
'unit-rebel',
|
|
'unit-rebel-archer',
|
|
'unit-rebel-cavalry',
|
|
'unit-rebel-leader'
|
|
],
|
|
budget: { actionEncodedMiB: 18, actionDecodedMiB: 150, combinedEncodedMiB: 25, combinedDecodedMiB: 210 }
|
|
},
|
|
{
|
|
id: 'twenty-second-battle-red-cliffs-fire',
|
|
phase: 'middle',
|
|
keys: [
|
|
'unit-liu-bei',
|
|
'unit-guan-yu',
|
|
'unit-zhang-fei',
|
|
'unit-shu-strategist-white',
|
|
'unit-zhao-yun',
|
|
'unit-zhuge-liang',
|
|
'unit-wei-infantry-shield',
|
|
'unit-wei-infantry-veteran',
|
|
'unit-wei-infantry',
|
|
'unit-wei-archer',
|
|
'unit-wei-archer-crossbow',
|
|
'unit-wei-archer-veteran',
|
|
'unit-wei-cavalry',
|
|
'unit-wei-cavalry-elite',
|
|
'unit-wei-cavalry-iron',
|
|
'unit-wei-officer-iron'
|
|
],
|
|
budget: { actionEncodedMiB: 30, actionDecodedMiB: 330, combinedEncodedMiB: 40, combinedDecodedMiB: 475 }
|
|
},
|
|
{
|
|
id: 'forty-sixth-battle-yiling-fire',
|
|
phase: 'late',
|
|
keys: [
|
|
'unit-liu-bei',
|
|
'unit-zhao-yun',
|
|
'unit-zhuge-liang',
|
|
'unit-ma-liang',
|
|
'unit-huang-quan',
|
|
'unit-ma-chao',
|
|
'unit-wang-ping',
|
|
'unit-wu-infantry',
|
|
'unit-wu-infantry-marine',
|
|
'unit-wu-infantry-river',
|
|
'unit-wu-archer-river',
|
|
'unit-wu-archer',
|
|
'unit-wu-archer-naval',
|
|
'unit-wu-cavalry-elite',
|
|
'unit-wu-cavalry-river',
|
|
'unit-wu-cavalry',
|
|
'unit-wu-strategist',
|
|
'unit-wu-strategist-fire',
|
|
'unit-wu-strategist-river',
|
|
'unit-wu-officer',
|
|
'unit-wu-officer-harbor',
|
|
'unit-wu-officer-river'
|
|
],
|
|
budget: { actionEncodedMiB: 35, actionDecodedMiB: 450, combinedEncodedMiB: 50, combinedDecodedMiB: 650 }
|
|
},
|
|
{
|
|
id: 'sixty-sixth-battle-wuzhang-final',
|
|
phase: 'final',
|
|
keys: [
|
|
'unit-zhao-yun',
|
|
'unit-zhuge-liang',
|
|
'unit-li-yan',
|
|
'unit-huang-quan',
|
|
'unit-ma-chao',
|
|
'unit-ma-dai',
|
|
'unit-wang-ping',
|
|
'unit-jiang-wei',
|
|
'unit-wei-infantry',
|
|
'unit-wei-infantry-shield',
|
|
'unit-wei-infantry-veteran',
|
|
'unit-wei-archer-veteran',
|
|
'unit-wei-archer',
|
|
'unit-wei-archer-crossbow',
|
|
'unit-wei-cavalry-elite',
|
|
'unit-wei-cavalry-iron',
|
|
'unit-wei-cavalry',
|
|
'unit-wei-strategist-blue',
|
|
'unit-wei-strategist',
|
|
'unit-wei-strategist-black',
|
|
'unit-wei-officer',
|
|
'unit-sima-yi'
|
|
],
|
|
budget: { actionEncodedMiB: 35, actionDecodedMiB: 450, combinedEncodedMiB: 50, combinedDecodedMiB: 650 }
|
|
}
|
|
];
|
|
|
|
function imageDimensions(path) {
|
|
const buffer = readFileSync(path);
|
|
if (buffer.subarray(1, 4).toString('ascii') === 'PNG') {
|
|
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
|
|
}
|
|
if (buffer.subarray(0, 4).toString('ascii') !== 'RIFF' || buffer.subarray(8, 12).toString('ascii') !== 'WEBP') {
|
|
throw new Error(`Unsupported image format: ${path}`);
|
|
}
|
|
let offset = 12;
|
|
while (offset + 8 <= buffer.length) {
|
|
const chunk = buffer.subarray(offset, offset + 4).toString('ascii');
|
|
const size = buffer.readUInt32LE(offset + 4);
|
|
const payload = offset + 8;
|
|
if (chunk === 'VP8X') {
|
|
return {
|
|
width: 1 + buffer.readUIntLE(payload + 4, 3),
|
|
height: 1 + buffer.readUIntLE(payload + 7, 3)
|
|
};
|
|
}
|
|
if (chunk === 'VP8L') {
|
|
const bits = buffer.readUInt32LE(payload + 1);
|
|
return {
|
|
width: 1 + (bits & 0x3fff),
|
|
height: 1 + ((bits >>> 14) & 0x3fff)
|
|
};
|
|
}
|
|
if (chunk === 'VP8 ') {
|
|
return {
|
|
width: buffer.readUInt16LE(payload + 6) & 0x3fff,
|
|
height: buffer.readUInt16LE(payload + 8) & 0x3fff
|
|
};
|
|
}
|
|
offset = payload + size + (size % 2);
|
|
}
|
|
throw new Error(`Could not read WebP dimensions: ${path}`);
|
|
}
|
|
|
|
function sheetAssetForKey(key, group, preferOptimized = true) {
|
|
const suffix = group === 'action' ? '-actions' : '';
|
|
const optimizedSet = group === 'action' ? optimizedActionKeys : optimizedBaseKeys;
|
|
const useOptimized = preferOptimized && optimizedSet.has(key);
|
|
const extension = useOptimized ? policy.format : 'png';
|
|
const file = `${key}${suffix}.${extension}`;
|
|
const path = join(unitDir, file);
|
|
if (!existsSync(path)) {
|
|
throw new Error(`Missing ${group} asset: ${path}`);
|
|
}
|
|
const dimensions = imageDimensions(path);
|
|
return {
|
|
key,
|
|
group,
|
|
extension,
|
|
file,
|
|
encodedBytes: statSync(path).size,
|
|
decodedBytes: dimensions.width * dimensions.height * 4,
|
|
...dimensions
|
|
};
|
|
}
|
|
|
|
function byteTotals(assets) {
|
|
return {
|
|
encodedBytes: assets.reduce((sum, asset) => sum + asset.encodedBytes, 0),
|
|
decodedBytes: assets.reduce((sum, asset) => sum + asset.decodedBytes, 0)
|
|
};
|
|
}
|
|
|
|
function summarizeBattle(battle) {
|
|
const currentActionAssets = battle.keys.map((key) => sheetAssetForKey(key, 'action'));
|
|
const originalActionAssets = battle.keys.map((key) => sheetAssetForKey(key, 'action', false));
|
|
const currentBaseAssets = battle.keys.map((key) => sheetAssetForKey(key, 'base'));
|
|
const originalBaseAssets = battle.keys.map((key) => sheetAssetForKey(key, 'base', false));
|
|
const currentAction = byteTotals(currentActionAssets);
|
|
const originalAction = byteTotals(originalActionAssets);
|
|
const currentBase = byteTotals(currentBaseAssets);
|
|
const originalBase = byteTotals(originalBaseAssets);
|
|
const combinedEncodedBytes = currentAction.encodedBytes + currentBase.encodedBytes;
|
|
const combinedDecodedBytes = currentAction.decodedBytes + currentBase.decodedBytes;
|
|
const originalCombinedEncodedBytes = originalAction.encodedBytes + originalBase.encodedBytes;
|
|
const originalCombinedDecodedBytes = originalAction.decodedBytes + originalBase.decodedBytes;
|
|
const actionEncodedMiB = currentAction.encodedBytes / MiB;
|
|
const actionDecodedMiB = currentAction.decodedBytes / MiB;
|
|
const combinedEncodedMiB = combinedEncodedBytes / MiB;
|
|
const combinedDecodedMiB = combinedDecodedBytes / MiB;
|
|
const currentAssets = [...currentBaseAssets, ...currentActionAssets];
|
|
const maxTextureEdge = Math.max(...currentAssets.flatMap((asset) => [asset.width, asset.height]));
|
|
const over8kTextureCount = currentAssets.filter((asset) => Math.max(asset.width, asset.height) > 8192).length;
|
|
|
|
if (
|
|
actionEncodedMiB > battle.budget.actionEncodedMiB ||
|
|
actionDecodedMiB > battle.budget.actionDecodedMiB ||
|
|
combinedEncodedMiB > battle.budget.combinedEncodedMiB ||
|
|
combinedDecodedMiB > battle.budget.combinedDecodedMiB
|
|
) {
|
|
throw new Error(
|
|
`${battle.id} unit sheet budget failed: action=${actionEncodedMiB.toFixed(1)}MiB encoded/` +
|
|
`${actionDecodedMiB.toFixed(1)}MiB decoded, combined=${combinedEncodedMiB.toFixed(1)}MiB encoded/` +
|
|
`${combinedDecodedMiB.toFixed(1)}MiB decoded`
|
|
);
|
|
}
|
|
|
|
return {
|
|
id: battle.id,
|
|
phase: battle.phase,
|
|
unitKeyCount: battle.keys.length,
|
|
textureCount: currentAssets.length,
|
|
optimizedTextureCount: currentAssets.filter((asset) => asset.extension === policy.format).length,
|
|
action: {
|
|
encodedMiB: round(actionEncodedMiB),
|
|
encodedReductionPercent: round((1 - currentAction.encodedBytes / originalAction.encodedBytes) * 100),
|
|
decodedMiB: round(actionDecodedMiB),
|
|
decodedReductionPercent: round((1 - currentAction.decodedBytes / originalAction.decodedBytes) * 100)
|
|
},
|
|
base: {
|
|
encodedMiB: round(currentBase.encodedBytes / MiB),
|
|
encodedReductionPercent: round((1 - currentBase.encodedBytes / originalBase.encodedBytes) * 100),
|
|
decodedMiB: round(currentBase.decodedBytes / MiB),
|
|
decodedReductionPercent: round((1 - currentBase.decodedBytes / originalBase.decodedBytes) * 100)
|
|
},
|
|
combined: {
|
|
encodedMiB: round(combinedEncodedMiB),
|
|
encodedReductionPercent: round((1 - combinedEncodedBytes / originalCombinedEncodedBytes) * 100),
|
|
decodedMiB: round(combinedDecodedMiB),
|
|
decodedReductionPercent: round((1 - combinedDecodedBytes / originalCombinedDecodedBytes) * 100)
|
|
},
|
|
maxTextureEdge,
|
|
over8kTextureCount,
|
|
budget: battle.budget
|
|
};
|
|
}
|
|
|
|
function deployedSheetKey(file, group, extension) {
|
|
if (!file.toLowerCase().endsWith(`.${extension}`)) {
|
|
return undefined;
|
|
}
|
|
const stem = file.slice(0, -(extension.length + 1));
|
|
if (group === 'base' && /-actions-/i.test(stem)) {
|
|
return undefined;
|
|
}
|
|
const keys = group === 'action' ? optimizedActionKeyList : optimizedBaseKeyList;
|
|
const suffix = group === 'action' ? '-actions-' : '-';
|
|
return keys
|
|
.filter((key) => stem.startsWith(`${key}${suffix}`) && stem.length > key.length + suffix.length)
|
|
.sort((left, right) => right.length - left.length)[0];
|
|
}
|
|
|
|
function deployedAssetForKey(files, key, group) {
|
|
const webps = files.filter((file) => deployedSheetKey(file, group, 'webp') === key);
|
|
const pngs = files.filter((file) => deployedSheetKey(file, group, 'png') === key);
|
|
if (webps.length !== 1 || pngs.length !== 0) {
|
|
throw new Error(
|
|
`${key} ${group}: expected one deployed WebP and no deployed PNG, got webp=${webps.length}, png=${pngs.length}`
|
|
);
|
|
}
|
|
return { key, group, file: webps[0], bytes: statSync(join(distAssetsDir, webps[0])).size };
|
|
}
|
|
|
|
function deploymentSummary() {
|
|
if (!existsSync(distAssetsDir)) {
|
|
throw new Error(`Missing deployment assets directory: ${distAssetsDir}`);
|
|
}
|
|
const files = readdirSync(distAssetsDir);
|
|
const base = optimizedBaseKeyList.map((key) => deployedAssetForKey(files, key, 'base'));
|
|
const action = optimizedActionKeyList.map((key) => deployedAssetForKey(files, key, 'action'));
|
|
const deployedUnitPngs = files.filter((file) => /^unit-.*\.png$/i.test(file));
|
|
if (deployedUnitPngs.length !== 0) {
|
|
throw new Error(`Expected no deployed unit sheet PNG files, found: ${deployedUnitPngs.join(', ')}`);
|
|
}
|
|
return {
|
|
checked: true,
|
|
baseWebpFiles: base.length,
|
|
actionWebpFiles: action.length,
|
|
totalWebpFiles: base.length + action.length,
|
|
baseMiB: round(base.reduce((sum, asset) => sum + asset.bytes, 0) / MiB),
|
|
actionMiB: round(action.reduce((sum, asset) => sum + asset.bytes, 0) / MiB),
|
|
combinedMiB: round([...base, ...action].reduce((sum, asset) => sum + asset.bytes, 0) / MiB),
|
|
originalPngEmitted: false
|
|
};
|
|
}
|
|
|
|
function optimizedAssetDetails(group, keys, columns, rows) {
|
|
const expectedWidth = columns * policy.optimizedFrameSize;
|
|
const expectedHeight = rows * policy.optimizedFrameSize;
|
|
return keys.map((key) => {
|
|
const current = sheetAssetForKey(key, group);
|
|
const original = sheetAssetForKey(key, group, false);
|
|
if (current.width !== expectedWidth || current.height !== expectedHeight) {
|
|
throw new Error(`${current.file}: expected ${expectedWidth}x${expectedHeight}, got ${current.width}x${current.height}`);
|
|
}
|
|
return {
|
|
key,
|
|
sourceEncodedBytes: original.encodedBytes,
|
|
optimizedEncodedBytes: current.encodedBytes,
|
|
sourceDecodedBytes: original.decodedBytes,
|
|
optimizedDecodedBytes: current.decodedBytes
|
|
};
|
|
});
|
|
}
|
|
|
|
function summarizeOptimizedAssets(details) {
|
|
return {
|
|
count: details.length,
|
|
sourceEncodedMiB: round(details.reduce((sum, asset) => sum + asset.sourceEncodedBytes, 0) / MiB),
|
|
optimizedEncodedMiB: round(details.reduce((sum, asset) => sum + asset.optimizedEncodedBytes, 0) / MiB),
|
|
encodedReductionPercent: round(
|
|
(1 - details.reduce((sum, asset) => sum + asset.optimizedEncodedBytes, 0) /
|
|
details.reduce((sum, asset) => sum + asset.sourceEncodedBytes, 0)) * 100
|
|
),
|
|
sourceDecodedMiB: round(details.reduce((sum, asset) => sum + asset.sourceDecodedBytes, 0) / MiB),
|
|
optimizedDecodedMiB: round(details.reduce((sum, asset) => sum + asset.optimizedDecodedBytes, 0) / MiB),
|
|
decodedReductionPercent: round(
|
|
(1 - details.reduce((sum, asset) => sum + asset.optimizedDecodedBytes, 0) /
|
|
details.reduce((sum, asset) => sum + asset.sourceDecodedBytes, 0)) * 100
|
|
)
|
|
};
|
|
}
|
|
|
|
const optimizedBaseAssets = optimizedAssetDetails('base', optimizedBaseKeyList, policy.baseColumns, policy.baseRows);
|
|
const optimizedActionAssets = optimizedAssetDetails('action', optimizedActionKeyList, policy.actionColumns, policy.actionRows);
|
|
|
|
const report = {
|
|
viewportBaseline: { width: 1920, height: 1080, deviceScaleFactor: 1, zoomPercent: 100 },
|
|
policy: {
|
|
version: policy.version,
|
|
baseFrameCount: policy.baseColumns * policy.baseRows,
|
|
actionFrameCount: policy.actionColumns * policy.actionRows,
|
|
sourceFrameSize: policy.sourceFrameSize,
|
|
optimizedFrameSize: policy.optimizedFrameSize,
|
|
format: policy.lossless ? 'lossless-webp' : policy.format,
|
|
optimizedBaseAssetCount: optimizedBaseKeyList.length,
|
|
optimizedActionAssetCount: optimizedActionKeyList.length
|
|
},
|
|
optimizedAssets: {
|
|
base: summarizeOptimizedAssets(optimizedBaseAssets),
|
|
action: summarizeOptimizedAssets(optimizedActionAssets)
|
|
},
|
|
representativeBattles: representativeBattles.map(summarizeBattle),
|
|
deployment: verifyDist ? deploymentSummary() : { checked: false, hint: 'Run with --dist after a production build.' }
|
|
};
|
|
|
|
console.log(JSON.stringify(report, null, 2));
|
|
|
|
function round(value) {
|
|
return Math.round(value * 10) / 10;
|
|
}
|