feat: improve first-session combat and visual assets
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { createServer } from 'vite';
|
||||
|
||||
const server = await createServer({
|
||||
@@ -16,7 +16,28 @@ const expectedV2IconFrames = {
|
||||
fire: 24,
|
||||
bean: 27,
|
||||
salve: 28,
|
||||
wine: 29
|
||||
wine: 29,
|
||||
confusion: 30,
|
||||
benevolentCommand: 31,
|
||||
azureDragonStrike: 32,
|
||||
changbanRoar: 33,
|
||||
singleRiderRescue: 34,
|
||||
eastWindFire: 35,
|
||||
hundredPacePierce: 36,
|
||||
westernCavalryCharge: 37,
|
||||
qilinStratagem: 38,
|
||||
burn: 39
|
||||
};
|
||||
|
||||
const expectedDedicatedUsableIcons = {
|
||||
benevolentCommand: 'benevolentCommand',
|
||||
azureDragonStrike: 'azureDragonStrike',
|
||||
changbanRoar: 'changbanRoar',
|
||||
singleRiderRescue: 'singleRiderRescue',
|
||||
eastWindFire: 'eastWindFire',
|
||||
hundredPacePierce: 'hundredPacePierce',
|
||||
westernCavalryCharge: 'westernCavalryCharge',
|
||||
qilinStratagem: 'qilinStratagem'
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -111,6 +132,9 @@ function validateBattleUiIconAtlas(errors, manifest) {
|
||||
}
|
||||
|
||||
const frameLimit = manifest.columns * manifest.rows;
|
||||
if (frameLimit !== 40) {
|
||||
errors.push(`battle UI icon atlas must contain 40 frames, got ${frameLimit}`);
|
||||
}
|
||||
Object.entries(manifest.frames).forEach(([key, frame]) => {
|
||||
assertNonEmptyString(errors, key, 'battle UI icon key');
|
||||
if (!Number.isInteger(frame) || frame < 0 || frame >= frameLimit) {
|
||||
@@ -135,8 +159,8 @@ function validateBattleUiIconSources(errors, manifest) {
|
||||
}
|
||||
|
||||
const entries = Object.entries(sources);
|
||||
if (entries.length !== 8) {
|
||||
errors.push(`battleUiIconManifest.v2Sources must contain 8 source icons, got ${entries.length}`);
|
||||
if (entries.length !== 18) {
|
||||
errors.push(`battleUiIconManifest.v2Sources must contain 18 source icons, got ${entries.length}`);
|
||||
}
|
||||
|
||||
const sourceFrames = new Set();
|
||||
@@ -190,6 +214,46 @@ function validateBattleUsableIcons(errors, usableCatalog, frames) {
|
||||
errors.push(`${context} must use the item's dedicated icon key "${id}", got "${usable.iconKey}"`);
|
||||
}
|
||||
});
|
||||
|
||||
const dedicatedIconKeys = [];
|
||||
Object.entries(expectedDedicatedUsableIcons).forEach(([usableId, expectedIconKey]) => {
|
||||
const usable = usableCatalog[usableId];
|
||||
if (!usable) {
|
||||
errors.push(`usableCatalog.${usableId} must exist for its signature icon`);
|
||||
return;
|
||||
}
|
||||
if (usable.iconKey !== expectedIconKey) {
|
||||
errors.push(`usableCatalog.${usableId}.iconKey must use dedicated icon "${expectedIconKey}", got "${usable.iconKey}"`);
|
||||
}
|
||||
dedicatedIconKeys.push(usable.iconKey);
|
||||
});
|
||||
|
||||
if (new Set(dedicatedIconKeys).size !== dedicatedIconKeys.length) {
|
||||
errors.push('signature usable icon keys must not be duplicated');
|
||||
}
|
||||
|
||||
const statusIconKeys = ['confusion', 'burn'];
|
||||
const reservedGenericIconKeys = ['shout', 'fire'];
|
||||
const premiumIconKeys = [...dedicatedIconKeys, ...statusIconKeys];
|
||||
if (new Set(premiumIconKeys).size !== premiumIconKeys.length) {
|
||||
errors.push('signature and status icon keys must be unique');
|
||||
}
|
||||
[...statusIconKeys, ...reservedGenericIconKeys].forEach((iconKey) => {
|
||||
if (!(iconKey in frames)) {
|
||||
errors.push(`battle UI icon manifest is missing dedicated status/generic icon "${iconKey}"`);
|
||||
}
|
||||
});
|
||||
if (frames.confusion === frames.shout) {
|
||||
errors.push('confusion status icon frame must be distinct from shout');
|
||||
}
|
||||
if (frames.burn === frames.fire) {
|
||||
errors.push('burn status icon frame must be distinct from fire tactic');
|
||||
}
|
||||
|
||||
const premiumFrames = premiumIconKeys.map((iconKey) => frames[iconKey]).filter(Number.isInteger);
|
||||
if (new Set(premiumFrames).size !== premiumFrames.length) {
|
||||
errors.push('signature and status icon frames must not be duplicated');
|
||||
}
|
||||
}
|
||||
|
||||
function validateItemCatalog(errors, itemCatalog, equipmentSlots) {
|
||||
@@ -224,6 +288,7 @@ function validateItemCatalog(errors, itemCatalog, equipmentSlots) {
|
||||
|
||||
function validateGeneratedEquipmentIcons(errors, itemCatalog) {
|
||||
const bootScene = readFileSync('src/game/scenes/BootScene.ts', 'utf8');
|
||||
const campScene = readFileSync('src/game/scenes/CampScene.ts', 'utf8');
|
||||
const generatedItemIds = new Set([...bootScene.matchAll(/createItemIcon\('item-([^']+)'/g)].map((match) => match[1]));
|
||||
const catalogItemIds = new Set(Object.keys(itemCatalog));
|
||||
|
||||
@@ -241,6 +306,107 @@ function validateGeneratedEquipmentIcons(errors, itemCatalog) {
|
||||
errors.push(`BootScene generates item-${itemId}, but itemCatalog has no matching item`);
|
||||
}
|
||||
});
|
||||
|
||||
validateEquipmentIconDirectory(errors, catalogItemIds, 'src/assets/images/ui/equipment-icons/128', 128);
|
||||
validateEquipmentIconDirectory(errors, catalogItemIds, 'src/assets/images/ui/equipment-icons/32', 32);
|
||||
|
||||
const bootPreloadChecks = [
|
||||
{
|
||||
pattern: /import\.meta\.glob\('\.\.\/\.\.\/assets\/images\/ui\/equipment-icons\/128\/\*\.png'/,
|
||||
message: 'BootScene must import the 128px equipment icon glob'
|
||||
},
|
||||
{
|
||||
pattern: /import\.meta\.glob\('\.\.\/\.\.\/assets\/images\/ui\/equipment-icons\/32\/\*\.png'/,
|
||||
message: 'BootScene must import the 32px equipment icon glob'
|
||||
},
|
||||
{
|
||||
pattern: /this\.preloadEquipmentIcons\(equipmentIcon128Modules,\s*''\)/,
|
||||
message: 'BootScene must preload 128px equipment icons as item-${id}'
|
||||
},
|
||||
{
|
||||
pattern: /this\.preloadEquipmentIcons\(equipmentIcon32Modules,\s*'-micro'\)/,
|
||||
message: 'BootScene must preload 32px equipment icons as item-${id}-micro'
|
||||
},
|
||||
{
|
||||
pattern: /this\.load\.image\(`item-\$\{itemId\}\$\{suffix\}`,\s*url\)/,
|
||||
message: 'BootScene equipment icon loader must preserve item-${id}${suffix} texture keys'
|
||||
}
|
||||
];
|
||||
bootPreloadChecks.forEach(({ pattern, message }) => {
|
||||
if (!pattern.test(bootScene)) {
|
||||
errors.push(message);
|
||||
}
|
||||
});
|
||||
|
||||
const smallCampEquipmentIcons = [
|
||||
...campScene.matchAll(
|
||||
/const icon = [^\n]*this\.add\.image\([^\n]*, `item-\$\{[^`]+?\}(-micro)?`\)\);[\s\S]{0,180}?icon\.setDisplaySize\(this\.campUiLength\((\d+)\)/g
|
||||
)
|
||||
];
|
||||
smallCampEquipmentIcons.forEach((match) => {
|
||||
const suffix = match[1] ?? '';
|
||||
const displaySize = Number(match[2]);
|
||||
if (displaySize <= 22 && suffix !== '-micro') {
|
||||
errors.push(`CampScene equipment icon displayed at ${displaySize}px must use the -micro texture`);
|
||||
}
|
||||
});
|
||||
if (smallCampEquipmentIcons.filter((match) => Number(match[2]) <= 22 && match[1] === '-micro').length < 3) {
|
||||
errors.push('CampScene must route its main 22px-or-smaller equipment slots through -micro textures');
|
||||
}
|
||||
if (
|
||||
!/renderSortieEquipmentIcons\([^)]*frameSize = 18, iconSize = 14[^)]*\)[\s\S]{0,900}`item-\$\{item\.id\}-micro`/.test(
|
||||
campScene
|
||||
)
|
||||
) {
|
||||
errors.push('CampScene sortie equipment strip must use item-${id}-micro at its 14px default size');
|
||||
}
|
||||
}
|
||||
|
||||
function validateEquipmentIconDirectory(errors, catalogItemIds, directory, expectedSize) {
|
||||
let filenames;
|
||||
try {
|
||||
filenames = readdirSync(directory);
|
||||
} catch (error) {
|
||||
errors.push(`equipment icon directory cannot be read at "${directory}": ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pngFilenames = filenames.filter((filename) => filename.endsWith('.png'));
|
||||
filenames
|
||||
.filter((filename) => !filename.endsWith('.png'))
|
||||
.forEach((filename) => errors.push(`${directory} contains unexpected non-PNG asset "${filename}"`));
|
||||
|
||||
const assetIds = new Set(pngFilenames.map((filename) => filename.slice(0, -4)));
|
||||
Array.from(catalogItemIds)
|
||||
.sort()
|
||||
.forEach((itemId) => {
|
||||
if (!assetIds.has(itemId)) {
|
||||
errors.push(`${directory} is missing ${itemId}.png`);
|
||||
return;
|
||||
}
|
||||
const source = `${directory}/${itemId}.png`;
|
||||
let dimensions;
|
||||
try {
|
||||
dimensions = readPngDimensions(source);
|
||||
} catch (error) {
|
||||
errors.push(`equipment icon cannot be read at "${source}": ${error.message}`);
|
||||
return;
|
||||
}
|
||||
if (dimensions.width !== expectedSize || dimensions.height !== expectedSize) {
|
||||
errors.push(`${source} must be exactly ${expectedSize}x${expectedSize}, got ${dimensions.width}x${dimensions.height}`);
|
||||
}
|
||||
if (dimensions.bitDepth !== 8 || dimensions.colorType !== 6) {
|
||||
errors.push(`${source} must be an 8-bit RGBA PNG, got bitDepth=${dimensions.bitDepth} colorType=${dimensions.colorType}`);
|
||||
}
|
||||
});
|
||||
|
||||
Array.from(assetIds)
|
||||
.sort()
|
||||
.forEach((itemId) => {
|
||||
if (!catalogItemIds.has(itemId)) {
|
||||
errors.push(`${directory} contains extra icon ${itemId}.png with no matching itemCatalog entry`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function readPngDimensions(path) {
|
||||
|
||||
Reference in New Issue
Block a user