feat(tools): skin.mjs 资源清单与 override 校验
框架文件树即可覆盖清单(不另建清单文件)。孤儿/尺寸不一致/Spine 缺件 一律报错中止, 不静默跳过。Spine 贴图豁免尺寸校验(坐标在 .atlas 内)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,13 @@ export const HOST_PROJECT = join(ROOT, 'YouleNexus');
|
|||||||
export const FRAMEWORK_SRC = join(HOST_PROJECT, 'assets', 'framework');
|
export const FRAMEWORK_SRC = join(HOST_PROJECT, 'assets', 'framework');
|
||||||
export const GAMES_DIR = join(ROOT, 'games');
|
export const GAMES_DIR = join(ROOT, 'games');
|
||||||
export const SEED_DIR = join(ROOT, 'templates', 'game-seed');
|
export const SEED_DIR = join(ROOT, 'templates', 'game-seed');
|
||||||
|
export const FRAMEWORK_UI_DIR = join(FRAMEWORK_SRC, 'ui');
|
||||||
|
export const BUILD_WORKSPACE = join(ROOT, 'build-workspace');
|
||||||
|
|
||||||
|
// 子游戏的皮肤覆盖目录
|
||||||
|
export function gameOverrideDir(projectDir) {
|
||||||
|
return join(projectDir, 'assets', 'game', 'override');
|
||||||
|
}
|
||||||
|
|
||||||
export function readCreatorVersion(projectDir) {
|
export function readCreatorVersion(projectDir) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { readdirSync, statSync, readFileSync, existsSync } from 'node:fs';
|
||||||
|
import { join, posix, sep, extname, relative } from 'node:path';
|
||||||
|
|
||||||
|
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
|
||||||
|
const SPINE_ROOT = 'spine'; // framework/ui/spine/<资源名>/ 为一套 Spine
|
||||||
|
const SKIPPED = new Set(['.gitkeep']);
|
||||||
|
|
||||||
|
/** 列出目录下所有资源的相对路径(正斜杠),排除 .meta 与 .gitkeep。 */
|
||||||
|
export function listAssets(dir) {
|
||||||
|
if (!existsSync(dir)) return [];
|
||||||
|
const out = [];
|
||||||
|
(function walk(cur) {
|
||||||
|
for (const name of readdirSync(cur)) {
|
||||||
|
const p = join(cur, name);
|
||||||
|
if (statSync(p).isDirectory()) { walk(p); continue; }
|
||||||
|
if (name.endsWith('.meta') || SKIPPED.has(name)) continue;
|
||||||
|
out.push(relative(dir, p).split(sep).join(posix.sep));
|
||||||
|
}
|
||||||
|
})(dir);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读 PNG 宽高;非 PNG 或读取失败返回 null。 */
|
||||||
|
export function readPngSize(file) {
|
||||||
|
try {
|
||||||
|
const b = readFileSync(file);
|
||||||
|
if (b.length < 24 || b.toString('ascii', 1, 4) !== 'PNG') return null;
|
||||||
|
return { width: b.readUInt32BE(16), height: b.readUInt32BE(20) };
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const isImage = (rel) => IMAGE_EXTS.has(extname(rel).toLowerCase());
|
||||||
|
const isSpine = (rel) => rel.split(posix.sep)[0] === SPINE_ROOT;
|
||||||
|
/** Spine 资源的分组键:spine/<资源名> */
|
||||||
|
const spineGroup = (rel) => rel.split(posix.sep).slice(0, 2).join(posix.sep);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 override 是否合法。规则见 spec §3.2。
|
||||||
|
* 返回 { matched: 相对路径[], errors: {path, code, message}[] }。
|
||||||
|
*/
|
||||||
|
export function validateOverrides(frameworkUiDir, overrideDir) {
|
||||||
|
const fwList = listAssets(frameworkUiDir);
|
||||||
|
const fwSet = new Set(fwList);
|
||||||
|
const ovList = listAssets(overrideDir);
|
||||||
|
const ovSet = new Set(ovList);
|
||||||
|
|
||||||
|
const matched = [];
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
// 1) 逐个 override 校验:孤儿 / 尺寸
|
||||||
|
for (const rel of ovList) {
|
||||||
|
if (!fwSet.has(rel)) {
|
||||||
|
const base = rel.slice(0, rel.length - extname(rel).length);
|
||||||
|
const alt = fwList.find((f) => f.slice(0, f.length - extname(f).length) === base);
|
||||||
|
errors.push({
|
||||||
|
path: rel,
|
||||||
|
code: 'orphan',
|
||||||
|
message: alt
|
||||||
|
? `框架里没有 ${rel},但存在同名的 ${alt}——扩展名对不上`
|
||||||
|
: `框架里没有对应资源:${rel}`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spine 贴图豁免尺寸校验(区域坐标在 .atlas 内,整套替换时自洽)
|
||||||
|
const metaPath = join(overrideDir, ...`${rel}.meta`.split(posix.sep));
|
||||||
|
const needSizeCheck =
|
||||||
|
isImage(rel) && !isSpine(rel) && !existsSync(metaPath);
|
||||||
|
if (needSizeCheck) {
|
||||||
|
const a = readPngSize(join(frameworkUiDir, ...rel.split(posix.sep)));
|
||||||
|
const b = readPngSize(join(overrideDir, ...rel.split(posix.sep)));
|
||||||
|
if (a && b && (a.width !== b.width || a.height !== b.height)) {
|
||||||
|
errors.push({
|
||||||
|
path: rel,
|
||||||
|
code: 'size-mismatch',
|
||||||
|
message:
|
||||||
|
`尺寸不一致:框架 ${a.width}x${a.height},override ${b.width}x${b.height}。` +
|
||||||
|
`请改为同尺寸,或连 .meta 一起提供(例外通道)`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matched.push(rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Spine 整套完整性:override 动了某套,就必须整套都在
|
||||||
|
const touchedGroups = new Set(ovList.filter(isSpine).map(spineGroup));
|
||||||
|
for (const g of touchedGroups) {
|
||||||
|
const required = fwList.filter((f) => isSpine(f) && spineGroup(f) === g);
|
||||||
|
const missing = required.filter((f) => !ovSet.has(f));
|
||||||
|
if (missing.length) {
|
||||||
|
errors.push({
|
||||||
|
path: g,
|
||||||
|
code: 'spine-incomplete',
|
||||||
|
message: `Spine 必须整套覆盖,缺少:${missing.join('、')}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { matched, errors };
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { mkdirSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { deflateSync } from 'node:zlib';
|
||||||
|
import { listAssets, readPngSize, validateOverrides } from '../lib/skin.mjs';
|
||||||
|
|
||||||
|
// ---- 测试辅助:零依赖生成指定尺寸的 PNG ----
|
||||||
|
function crc32(buf) {
|
||||||
|
let c = ~0;
|
||||||
|
for (const x of buf) { c ^= x; for (let i = 0; i < 8; i++) c = (c >>> 1) ^ (0xEDB88320 & -(c & 1)); }
|
||||||
|
return ~c >>> 0;
|
||||||
|
}
|
||||||
|
function chunk(type, data) {
|
||||||
|
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
|
||||||
|
const td = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
||||||
|
const c = Buffer.alloc(4); c.writeUInt32BE(crc32(td));
|
||||||
|
return Buffer.concat([len, td, c]);
|
||||||
|
}
|
||||||
|
function makePng(w, h) {
|
||||||
|
const ihdr = Buffer.alloc(13);
|
||||||
|
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
|
||||||
|
const raw = Buffer.alloc(h * (1 + w * 3));
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||||
|
chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
function writeFile(dir, rel, content) {
|
||||||
|
const p = join(dir, ...rel.split('/'));
|
||||||
|
mkdirSync(join(p, '..'), { recursive: true });
|
||||||
|
writeFileSync(p, content);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
function png(dir, rel, w = 64, h = 64) { return writeFile(dir, rel, makePng(w, h)); }
|
||||||
|
|
||||||
|
function makeDirs() {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'youle-skin-'));
|
||||||
|
const fw = join(root, 'ui'); const ov = join(root, 'override');
|
||||||
|
mkdirSync(fw, { recursive: true }); mkdirSync(ov, { recursive: true });
|
||||||
|
return { root, fw, ov };
|
||||||
|
}
|
||||||
|
function cleanup(root) { rmSync(root, { recursive: true, force: true }); }
|
||||||
|
|
||||||
|
// ---- listAssets ----
|
||||||
|
test('listAssets 返回正斜杠相对路径,排除 .meta 与 .gitkeep', () => {
|
||||||
|
const d = makeDirs();
|
||||||
|
try {
|
||||||
|
png(d.fw, 'atlas-hall/btn.png');
|
||||||
|
writeFile(d.fw, 'atlas-hall/btn.png.meta', '{}');
|
||||||
|
writeFile(d.fw, 'audio/.gitkeep', '');
|
||||||
|
writeFile(d.fw, 'audio/click.wav', 'x');
|
||||||
|
assert.deepEqual(listAssets(d.fw).sort(), ['atlas-hall/btn.png', 'audio/click.wav']);
|
||||||
|
} finally { cleanup(d.root); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listAssets 对不存在的目录返回空数组', () => {
|
||||||
|
assert.deepEqual(listAssets('/no/such/dir'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- readPngSize ----
|
||||||
|
test('readPngSize 读出宽高;非 PNG 返回 null', () => {
|
||||||
|
const d = makeDirs();
|
||||||
|
try {
|
||||||
|
const p = png(d.fw, 'a.png', 128, 64);
|
||||||
|
assert.deepEqual(readPngSize(p), { width: 128, height: 64 });
|
||||||
|
const w = writeFile(d.fw, 'b.wav', 'not a png');
|
||||||
|
assert.equal(readPngSize(w), null);
|
||||||
|
} finally { cleanup(d.root); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- validateOverrides:正常命中 ----
|
||||||
|
test('同名同尺寸图片:命中,无错误', () => {
|
||||||
|
const d = makeDirs();
|
||||||
|
try {
|
||||||
|
png(d.fw, 'atlas-hall/btn.png', 64, 64);
|
||||||
|
png(d.ov, 'atlas-hall/btn.png', 64, 64);
|
||||||
|
const r = validateOverrides(d.fw, d.ov);
|
||||||
|
assert.deepEqual(r.errors, []);
|
||||||
|
assert.deepEqual(r.matched, ['atlas-hall/btn.png']);
|
||||||
|
} finally { cleanup(d.root); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('框架有、子游戏未覆盖:不是错误', () => {
|
||||||
|
const d = makeDirs();
|
||||||
|
try {
|
||||||
|
png(d.fw, 'atlas-hall/a.png');
|
||||||
|
png(d.fw, 'atlas-hall/b.png');
|
||||||
|
png(d.ov, 'atlas-hall/a.png');
|
||||||
|
const r = validateOverrides(d.fw, d.ov);
|
||||||
|
assert.deepEqual(r.errors, []);
|
||||||
|
assert.deepEqual(r.matched, ['atlas-hall/a.png']);
|
||||||
|
} finally { cleanup(d.root); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- validateOverrides:孤儿 ----
|
||||||
|
test('override 在框架里找不到对应路径 → orphan', () => {
|
||||||
|
const d = makeDirs();
|
||||||
|
try {
|
||||||
|
png(d.fw, 'atlas-hall/btn.png');
|
||||||
|
png(d.ov, 'atlas-hall/typo.png');
|
||||||
|
const r = validateOverrides(d.fw, d.ov);
|
||||||
|
assert.equal(r.errors.length, 1);
|
||||||
|
assert.equal(r.errors[0].code, 'orphan');
|
||||||
|
assert.equal(r.errors[0].path, 'atlas-hall/typo.png');
|
||||||
|
} finally { cleanup(d.root); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('同名不同扩展名 → orphan,且提示框架里的实际扩展名', () => {
|
||||||
|
const d = makeDirs();
|
||||||
|
try {
|
||||||
|
png(d.fw, 'atlas-hall/btn.png');
|
||||||
|
writeFile(d.ov, 'atlas-hall/btn.jpg', 'x');
|
||||||
|
const r = validateOverrides(d.fw, d.ov);
|
||||||
|
assert.equal(r.errors[0].code, 'orphan');
|
||||||
|
assert.match(r.errors[0].message, /btn\.png/);
|
||||||
|
} finally { cleanup(d.root); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- validateOverrides:尺寸 ----
|
||||||
|
test('图片尺寸不同且未带 meta → size-mismatch', () => {
|
||||||
|
const d = makeDirs();
|
||||||
|
try {
|
||||||
|
png(d.fw, 'atlas-hall/btn.png', 64, 64);
|
||||||
|
png(d.ov, 'atlas-hall/btn.png', 128, 128);
|
||||||
|
const r = validateOverrides(d.fw, d.ov);
|
||||||
|
assert.equal(r.errors.length, 1);
|
||||||
|
assert.equal(r.errors[0].code, 'size-mismatch');
|
||||||
|
assert.match(r.errors[0].message, /64x64/);
|
||||||
|
assert.match(r.errors[0].message, /128x128/);
|
||||||
|
} finally { cleanup(d.root); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('图片尺寸不同但自带 meta(例外通道)→ 通过', () => {
|
||||||
|
const d = makeDirs();
|
||||||
|
try {
|
||||||
|
png(d.fw, 'atlas-hall/btn.png', 64, 64);
|
||||||
|
png(d.ov, 'atlas-hall/btn.png', 128, 128);
|
||||||
|
writeFile(d.ov, 'atlas-hall/btn.png.meta', '{"ver":"1.0.0"}');
|
||||||
|
const r = validateOverrides(d.fw, d.ov);
|
||||||
|
assert.deepEqual(r.errors, []);
|
||||||
|
assert.deepEqual(r.matched, ['atlas-hall/btn.png']);
|
||||||
|
} finally { cleanup(d.root); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('非图片资源(音效)不做尺寸校验', () => {
|
||||||
|
const d = makeDirs();
|
||||||
|
try {
|
||||||
|
writeFile(d.fw, 'audio/click.wav', 'aaaa');
|
||||||
|
writeFile(d.ov, 'audio/click.wav', 'bbbbbbbbbbbb');
|
||||||
|
const r = validateOverrides(d.fw, d.ov);
|
||||||
|
assert.deepEqual(r.errors, []);
|
||||||
|
} finally { cleanup(d.root); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- validateOverrides:Spine ----
|
||||||
|
test('Spine 整套覆盖 → 通过,且贴图豁免尺寸校验', () => {
|
||||||
|
const d = makeDirs();
|
||||||
|
try {
|
||||||
|
writeFile(d.fw, 'spine/hero/hero.json', '{}');
|
||||||
|
writeFile(d.fw, 'spine/hero/hero.atlas', 'atlas');
|
||||||
|
png(d.fw, 'spine/hero/hero.png', 64, 64);
|
||||||
|
writeFile(d.ov, 'spine/hero/hero.json', '{"v":2}');
|
||||||
|
writeFile(d.ov, 'spine/hero/hero.atlas', 'atlas2');
|
||||||
|
png(d.ov, 'spine/hero/hero.png', 256, 256); // 尺寸不同也应通过
|
||||||
|
const r = validateOverrides(d.fw, d.ov);
|
||||||
|
assert.deepEqual(r.errors, []);
|
||||||
|
assert.equal(r.matched.length, 3);
|
||||||
|
} finally { cleanup(d.root); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Spine 缺件 → spine-incomplete,并列出缺少的文件', () => {
|
||||||
|
const d = makeDirs();
|
||||||
|
try {
|
||||||
|
writeFile(d.fw, 'spine/hero/hero.json', '{}');
|
||||||
|
writeFile(d.fw, 'spine/hero/hero.atlas', 'atlas');
|
||||||
|
png(d.fw, 'spine/hero/hero.png', 64, 64);
|
||||||
|
png(d.ov, 'spine/hero/hero.png', 64, 64); // 只换贴图
|
||||||
|
const r = validateOverrides(d.fw, d.ov);
|
||||||
|
assert.equal(r.errors.length, 1);
|
||||||
|
assert.equal(r.errors[0].code, 'spine-incomplete');
|
||||||
|
assert.match(r.errors[0].message, /hero\.json/);
|
||||||
|
assert.match(r.errors[0].message, /hero\.atlas/);
|
||||||
|
} finally { cleanup(d.root); }
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user