feat(tools): skin.mjs 资源清单与 override 校验

框架文件树即可覆盖清单(不另建清单文件)。孤儿/尺寸不一致/Spine 缺件
一律报错中止, 不静默跳过。Spine 贴图豁免尺寸校验(坐标在 .atlas 内)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 21:24:30 +08:00
co-authored by Claude Opus 5
parent 967641a7c7
commit 962c79866f
3 changed files with 294 additions and 0 deletions
@@ -8,6 +8,13 @@ export const HOST_PROJECT = join(ROOT, 'YouleNexus');
export const FRAMEWORK_SRC = join(HOST_PROJECT, 'assets', 'framework');
export const GAMES_DIR = join(ROOT, 'games');
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) {
try {
+101
View File
@@ -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 };
}