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 }; }