跨任务视角发现的问题:Critical1 materialize.mjs 的 junction 排除比较在 Windows 上因长路径前缀恒假、从未生效,且测试夹具的 junction 指向真源自身, 掩盖了此问题;Critical2 composeSkin 例外通道在 uuid 缺失/框架侧无 meta 时 静默失败,违反第二准则;Important3 可覆盖范围误把 theme/ 下的框架 TS 代码 也纳入;Important5 cli-entry 测试漏注册 check-skin/build-game;Important6 composeSkin 与 check-skin 各自拼路径,未共用 paths.mjs 权威推导;Important7 --platform 缺值时静默落到默认平台。逐条修复并补测试,Critical1 用诱饵框架 目录验证过能真正杀掉该 bug(变异推演见 fix report)。 spec §5 step5 的 dist/<name>/ 拷贝按要求标注为本期未实现,不实现。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
131 lines
4.8 KiB
JavaScript
131 lines
4.8 KiB
JavaScript
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']);
|
||
|
||
/**
|
||
* 可被子游戏覆盖的框架资源目录(spec §2 规则 3 枚举的 8 个目录)。
|
||
* `theme/` 不在其中——那是 §4.2 规定的框架 TS 代码位置,不是资源,不可覆盖。
|
||
*/
|
||
const COVERABLE_DIRS = new Set([
|
||
'atlas-common', 'atlas-login', 'atlas-hall', 'atlas-room',
|
||
'standalone', 'spine', 'audio', 'font',
|
||
]);
|
||
|
||
/** 列出目录下所有资源的相对路径(正斜杠),排除 .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;
|
||
}
|
||
|
||
/**
|
||
* 列出框架 UI 目录下**可被覆盖**的资源(白名单目录,见 COVERABLE_DIRS)。
|
||
* `frameworkUiDir` 下还有 `theme/`、`README.md` 等非资源内容,
|
||
* 不属于可覆盖范围,也不计入"框架可覆盖资源共 N 个"的统计。
|
||
*/
|
||
export function listCoverableAssets(frameworkUiDir) {
|
||
return listAssets(frameworkUiDir).filter((rel) => COVERABLE_DIRS.has(rel.split(posix.sep)[0]));
|
||
}
|
||
|
||
/** 读 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 = listCoverableAssets(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) {
|
||
const side = !a ? '框架' : 'override';
|
||
errors.push({
|
||
path: rel,
|
||
code: 'size-unreadable',
|
||
message:
|
||
`框架 UI 图片必须是 PNG(无法读取尺寸:${side})。` +
|
||
`请改用 PNG,或连 .meta 一起提供(例外通道)`,
|
||
});
|
||
continue;
|
||
}
|
||
if (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 };
|
||
}
|