diff --git a/cocoscreator_projects/scripts/lib/paths.mjs b/cocoscreator_projects/scripts/lib/paths.mjs index 721620e..2f8ab21 100644 --- a/cocoscreator_projects/scripts/lib/paths.mjs +++ b/cocoscreator_projects/scripts/lib/paths.mjs @@ -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 { diff --git a/cocoscreator_projects/scripts/lib/skin.mjs b/cocoscreator_projects/scripts/lib/skin.mjs new file mode 100644 index 0000000..b1698a6 --- /dev/null +++ b/cocoscreator_projects/scripts/lib/skin.mjs @@ -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 }; +} diff --git a/cocoscreator_projects/scripts/test/skin.test.mjs b/cocoscreator_projects/scripts/test/skin.test.mjs new file mode 100644 index 0000000..e413bae --- /dev/null +++ b/cocoscreator_projects/scripts/test/skin.test.mjs @@ -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); } +});