From 8e35556c33aeee1b0da13bb9742896c82ce2ad45 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Thu, 27 Aug 2026 21:51:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(tools):=20materialize=20=E5=AE=9E=E4=BD=93?= =?UTF-8?q?=E5=8C=96=E4=B8=8E=E7=9A=AE=E8=82=A4=E5=90=88=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit junction 变真实拷贝(发布链路不依赖 junction), 框架真源全程只读。 合成先整体校验再写, 不产生半成品; 例外通道把 override meta 的 uuid 改回框架原值以保持 Prefab 引用不断。 修复:Windows UNC 长路径前缀导致 cpSync filter 中 relative() 失败, 规范化 src 路径后正确计算相对位置进行缓存目录过滤。 Co-Authored-By: Claude Opus 5 (1M context) --- .../scripts/lib/materialize.mjs | 79 ++++++++++++ .../scripts/test/materialize.test.mjs | 112 ++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 cocoscreator_projects/scripts/lib/materialize.mjs create mode 100644 cocoscreator_projects/scripts/test/materialize.test.mjs diff --git a/cocoscreator_projects/scripts/lib/materialize.mjs b/cocoscreator_projects/scripts/lib/materialize.mjs new file mode 100644 index 0000000..e874b9f --- /dev/null +++ b/cocoscreator_projects/scripts/lib/materialize.mjs @@ -0,0 +1,79 @@ +import { cpSync, mkdirSync, existsSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs'; +import { join, posix, dirname, relative, sep, resolve } from 'node:path'; +import { listAssets, validateOverrides } from './skin.mjs'; + +/** + * 工程缓存目录:实体化时跳过。 + * **只匹配工程根的直接子目录**——绝不可按目录名在任意层级匹配,否则会连 + * `assets/game/native/` 这类业务目录一起吞掉(与 .gitignore 无锚点模式同一类错误,见 Task 1)。 + */ +const CACHE_DIRS = new Set(['library', 'temp', 'local', 'build', 'native', 'profiles', 'node_modules']); + +export class SkinValidationError extends Error { + constructor(errors) { + super(`皮肤覆盖校验未通过(${errors.length} 处问题)`); + this.name = 'SkinValidationError'; + this.errors = errors; + } +} + +/** + * 把子游戏工程实体化到 destDir: + * `assets/framework` 由 junction 变成对 frameworkSrc 的真实目录拷贝;跳过缓存目录。 + * **frameworkSrc 全程只读**(spec §5.1 ①)。 + */ +export function materialize(gameDir, frameworkSrc, destDir) { + if (existsSync(destDir)) throw new Error(`目标已存在:${destDir}`); + mkdirSync(destDir, { recursive: true }); + + // 1) 拷工程本体,跳过缓存目录与 assets/framework(后者单独从真源拷) + const frameworkLink = join(gameDir, 'assets', 'framework'); + const gameDirNorm = resolve(gameDir); + cpSync(gameDir, destDir, { + recursive: true, + dereference: true, + filter: (src) => { + if (src === frameworkLink) return false; + // 处理 Windows UNC 长路径前缀 (\\?\) 导致 relative() 失败的问题 + const srcNorm = resolve(src.replace(/^\\\\\?\\/, '')); + const rel = relative(gameDirNorm, srcNorm); + if (!rel) return true; // gameDir 自身 + return !CACHE_DIRS.has(rel.split(sep)[0]); // 只看第一段:锚定到工程根 + }, + }); + + // 2) 从真源实体化 framework(不依赖 junction 存在,spec §5.1 ②) + cpSync(frameworkSrc, join(destDir, 'assets', 'framework'), { recursive: true, dereference: true }); +} + +/** + * 把临时工程内的 `assets/game/override/**` 合成到 `assets/framework/ui/**`。 + * 先整体校验,不通过则抛错且**不写任何文件**(避免半成品)。 + */ +export function composeSkin(destDir) { + const frameworkUiDir = join(destDir, 'assets', 'framework', 'ui'); + const overrideDir = join(destDir, 'assets', 'game', 'override'); + + const { matched, errors } = validateOverrides(frameworkUiDir, overrideDir); + if (errors.length) throw new SkinValidationError(errors); + + for (const rel of matched) { + const parts = rel.split(posix.sep); + const from = join(overrideDir, ...parts); + const to = join(frameworkUiDir, ...parts); + mkdirSync(dirname(to), { recursive: true }); + copyFileSync(from, to); + + // 例外通道:override 自带 meta 时,把 uuid 改回框架原值以保持引用 + const ovMeta = `${from}.meta`; + const fwMeta = `${to}.meta`; + if (existsSync(ovMeta) && existsSync(fwMeta)) { + const fwUuid = JSON.parse(readFileSync(fwMeta, 'utf8')).uuid; + const merged = JSON.parse(readFileSync(ovMeta, 'utf8')); + merged.uuid = fwUuid; + writeFileSync(fwMeta, `${JSON.stringify(merged, null, 2)}\n`); + } + } + + return { matched, frameworkTotal: listAssets(frameworkUiDir).length }; +} diff --git a/cocoscreator_projects/scripts/test/materialize.test.mjs b/cocoscreator_projects/scripts/test/materialize.test.mjs new file mode 100644 index 0000000..cadbf20 --- /dev/null +++ b/cocoscreator_projects/scripts/test/materialize.test.mjs @@ -0,0 +1,112 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { join } from 'node:path'; +import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, mkdtempSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { platform } from 'node:process'; +import { materialize, composeSkin, SkinValidationError } from '../lib/materialize.mjs'; + +const LINK_TYPE = platform === 'win32' ? 'junction' : 'dir'; + +function put(dir, rel, content) { + const p = join(dir, ...rel.split('/')); + mkdirSync(join(p, '..'), { recursive: true }); + writeFileSync(p, content); +} +function read(dir, rel) { return readFileSync(join(dir, ...rel.split('/')), 'utf8'); } + +/** 造一个「框架真源 + 挂 junction 的子游戏工程」的临时环境 */ +function scaffold() { + const root = mkdtempSync(join(tmpdir(), 'youle-mat-')); + const frameworkSrc = join(root, 'nexus', 'assets', 'framework'); + mkdirSync(frameworkSrc, { recursive: true }); + put(frameworkSrc, 'ui/atlas-hall/btn.png', 'DEFAULT-BTN'); + put(frameworkSrc, 'ui/atlas-hall/btn.png.meta', '{"uuid":"fw-btn-uuid"}'); + put(frameworkSrc, 'ui/atlas-hall/bg.png', 'DEFAULT-BG'); + put(frameworkSrc, 'core/net.ts', 'export const x=1;'); + + const gameDir = join(root, 'games', 'demo'); + mkdirSync(join(gameDir, 'assets'), { recursive: true }); + writeFileSync(join(gameDir, 'package.json'), JSON.stringify({ name: 'demo', creator: { version: '3.8.8' } })); + symlinkSync(frameworkSrc, join(gameDir, 'assets', 'framework'), LINK_TYPE); + put(gameDir, 'assets/game/theme.ts', 'export const theme={};'); + mkdirSync(join(gameDir, 'library'), { recursive: true }); // 缓存目录,应被跳过 + writeFileSync(join(gameDir, 'library', 'junk'), 'x'); + + return { root, frameworkSrc, gameDir, dest: join(root, 'workspace', 'demo') }; +} +function cleanup(root) { rmSync(root, { recursive: true, force: true }); } + +test('materialize 把 junction 变成真实目录拷贝,并跳过缓存目录', () => { + const s = scaffold(); + try { + materialize(s.gameDir, s.frameworkSrc, s.dest); + assert.equal(read(s.dest, 'assets/framework/ui/atlas-hall/btn.png'), 'DEFAULT-BTN'); + assert.equal(read(s.dest, 'assets/game/theme.ts'), 'export const theme={};'); + assert.ok(!existsSync(join(s.dest, 'library')), 'library 应被跳过'); + } finally { cleanup(s.root); } +}); + +test('materialize 只跳过工程根的缓存目录,不吞 assets 下的同名业务目录', () => { + const s = scaffold(); + try { + put(s.gameDir, 'assets/game/native/bridge.ts', 'BRIDGE'); + materialize(s.gameDir, s.frameworkSrc, s.dest); + assert.equal(read(s.dest, 'assets/game/native/bridge.ts'), 'BRIDGE', 'assets 下的 native 是业务目录,必须保留'); + assert.ok(!existsSync(join(s.dest, 'library')), '工程根的 library 仍应被跳过'); + } finally { cleanup(s.root); } +}); + +test('materialize 后写临时工程不影响框架真源', () => { + const s = scaffold(); + try { + materialize(s.gameDir, s.frameworkSrc, s.dest); + writeFileSync(join(s.dest, 'assets', 'framework', 'ui', 'atlas-hall', 'btn.png'), 'MUTATED'); + assert.equal(read(s.frameworkSrc, 'ui/atlas-hall/btn.png'), 'DEFAULT-BTN', '真源必须只读'); + } finally { cleanup(s.root); } +}); + +test('materialize 拒绝覆盖已存在的目标目录', () => { + const s = scaffold(); + try { + materialize(s.gameDir, s.frameworkSrc, s.dest); + assert.throws(() => materialize(s.gameDir, s.frameworkSrc, s.dest), /已存在/); + } finally { cleanup(s.root); } +}); + +test('composeSkin 用 override 覆盖框架资源内容,保留框架 .meta', () => { + const s = scaffold(); + try { + put(s.gameDir, 'assets/game/override/atlas-hall/btn.png', 'SKINNED-BTN'); + materialize(s.gameDir, s.frameworkSrc, s.dest); + const r = composeSkin(s.dest); + assert.deepEqual(r.matched, ['atlas-hall/btn.png']); + assert.equal(r.frameworkTotal, 2); + assert.equal(read(s.dest, 'assets/framework/ui/atlas-hall/btn.png'), 'SKINNED-BTN'); + assert.equal(read(s.dest, 'assets/framework/ui/atlas-hall/btn.png.meta'), '{"uuid":"fw-btn-uuid"}'); + assert.equal(read(s.dest, 'assets/framework/ui/atlas-hall/bg.png'), 'DEFAULT-BG', '未覆盖的保持默认'); + } finally { cleanup(s.root); } +}); + +test('composeSkin 例外通道:override 自带 meta 时,uuid 被改回框架的值', () => { + const s = scaffold(); + try { + put(s.gameDir, 'assets/game/override/atlas-hall/btn.png', 'SKINNED-BTN'); + put(s.gameDir, 'assets/game/override/atlas-hall/btn.png.meta', '{"uuid":"game-own-uuid","ver":"1.0.0"}'); + materialize(s.gameDir, s.frameworkSrc, s.dest); + composeSkin(s.dest); + const meta = JSON.parse(read(s.dest, 'assets/framework/ui/atlas-hall/btn.png.meta')); + assert.equal(meta.uuid, 'fw-btn-uuid', 'uuid 必须沿用框架的'); + assert.equal(meta.ver, '1.0.0', 'override meta 的其余字段保留'); + } finally { cleanup(s.root); } +}); + +test('composeSkin 校验不过时抛 SkinValidationError 且不写任何文件', () => { + const s = scaffold(); + try { + put(s.gameDir, 'assets/game/override/atlas-hall/typo.png', 'X'); + materialize(s.gameDir, s.frameworkSrc, s.dest); + assert.throws(() => composeSkin(s.dest), SkinValidationError); + assert.equal(read(s.dest, 'assets/framework/ui/atlas-hall/btn.png'), 'DEFAULT-BTN', '失败时不得部分写入'); + } finally { cleanup(s.root); } +});