界面迁移更新
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { symlinkSync, lstatSync, existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { platform } from 'node:process';
|
||||
|
||||
// Windows 用 junction(免管理员、仅限目录);其它平台用 dir 符号链接
|
||||
const LINK_TYPE = platform === 'win32' ? 'junction' : 'dir';
|
||||
|
||||
export function isLink(p) {
|
||||
try {
|
||||
return lstatSync(p).isSymbolicLink();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 在 linkPath 建一个指向 targetPath 的目录链接。
|
||||
// 返回 'created' | 'exists';目标不存在或 linkPath 已被真实文件占用则抛错。
|
||||
export function ensureJunction(linkPath, targetPath) {
|
||||
const target = resolve(targetPath); // junction 要求绝对目标
|
||||
if (!existsSync(target)) {
|
||||
throw new Error(`junction target 不存在: ${target}`);
|
||||
}
|
||||
if (isLink(linkPath)) return 'exists';
|
||||
if (existsSync(linkPath)) {
|
||||
throw new Error(`路径已存在且非链接,拒绝覆盖: ${linkPath}`);
|
||||
}
|
||||
symlinkSync(target, linkPath, LINK_TYPE);
|
||||
return 'created';
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import { cpSync, mkdirSync, existsSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs';
|
||||
import { join, posix, dirname, relative, sep, resolve, basename } from 'node:path';
|
||||
import { listCoverableAssets, validateOverrides } from './skin.mjs';
|
||||
import { gameOverrideDir, frameworkUiDirOf } from './paths.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 frameworkRel = join('assets', 'framework');
|
||||
const gameDirNorm = resolve(gameDir);
|
||||
cpSync(gameDir, destDir, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
filter: (src) => {
|
||||
// 处理 Windows UNC 长路径前缀 (\\?\):cpSync 传入的 src 在 Windows 上
|
||||
// 每一层都带该前缀,若不归一化,下面任何基于 rel 的比较都会恒假
|
||||
// (包括曾经直接 `src === frameworkLink` 的写法——那是 Critical 1 的成因)。
|
||||
const srcNorm = resolve(src.replace(/^\\\\\?\\/, ''));
|
||||
const rel = relative(gameDirNorm, srcNorm);
|
||||
if (!rel) return true; // gameDir 自身
|
||||
if (rel === frameworkRel) return false; // junction:单独从真源拷贝,不经此路径解引用
|
||||
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) {
|
||||
// 路径推导唯一权威在 paths.mjs(check-skin 用同一套),composeSkin 不再自己拼一份地图
|
||||
const frameworkUiDir = frameworkUiDirOf(destDir);
|
||||
const overrideDir = gameOverrideDir(destDir);
|
||||
const gameName = basename(destDir);
|
||||
|
||||
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;
|
||||
if (!fwUuid) {
|
||||
// 静默通过会写出没有 uuid 的 .meta——Cocos 重新生成 UUID,框架 Prefab 对该
|
||||
// 资源的引用在发布包里断裂,这正是"保留 .meta"整个设计要防的唯一失效模式。
|
||||
throw new SkinValidationError([{
|
||||
path: rel,
|
||||
code: 'meta-missing-uuid',
|
||||
message: `${gameName}: 框架侧 .meta 没有 uuid(${fwMeta}),无法为例外通道保留引用,请先修复框架该文件的 .meta`,
|
||||
}]);
|
||||
}
|
||||
const merged = JSON.parse(readFileSync(ovMeta, 'utf8'));
|
||||
merged.uuid = fwUuid;
|
||||
writeFileSync(fwMeta, `${JSON.stringify(merged, null, 2)}\n`);
|
||||
} else if (existsSync(ovMeta) && !existsSync(fwMeta)) {
|
||||
// 框架侧没有 .meta 时整段合并被跳过,override 自带的 meta 会被无声丢弃——
|
||||
// 同样违反"下游不兜底":必须显式报错,而不是悄悄忽略 override 的意图。
|
||||
throw new SkinValidationError([{
|
||||
path: rel,
|
||||
code: 'meta-orphan',
|
||||
message: `${gameName}: override 提供了 ${rel}.meta,但框架侧没有对应的 .meta(${fwMeta}),无法合并 uuid(例外通道要求框架侧资源本身已有 .meta)`,
|
||||
}]);
|
||||
}
|
||||
}
|
||||
|
||||
return { matched, frameworkTotal: listCoverableAssets(frameworkUiDir).length };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
||||
|
||||
// 本文件位于 <monorepo>/scripts/lib/paths.mjs → 上溯两级为 monorepo 根
|
||||
export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
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 BUILD_WORKSPACE = join(ROOT, 'build-workspace');
|
||||
|
||||
// 子游戏的皮肤覆盖目录
|
||||
export function gameOverrideDir(projectDir) {
|
||||
return join(projectDir, 'assets', 'game', 'override');
|
||||
}
|
||||
|
||||
// 某工程(宿主真源,或实体化后的临时工作区)的 framework/ui 目录。
|
||||
// check-skin 与 build-game(composeSkin) 共用同一份推导,不各自拼路径(唯一权威)。
|
||||
export function frameworkUiDirOf(projectDir) {
|
||||
return join(projectDir, 'assets', 'framework', 'ui');
|
||||
}
|
||||
|
||||
export const FRAMEWORK_UI_DIR = frameworkUiDirOf(HOST_PROJECT);
|
||||
|
||||
export function readCreatorVersion(projectDir) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'));
|
||||
return pkg?.creator?.version ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function listGameProjects(gamesDir = GAMES_DIR) {
|
||||
if (!existsSync(gamesDir)) return [];
|
||||
return readdirSync(gamesDir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => join(gamesDir, d.name))
|
||||
.filter((dir) => existsSync(join(dir, 'package.json')));
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { cpSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
const CACHE_DIRS = ['library', 'temp', 'local', 'build'];
|
||||
|
||||
// 复制种子工程到 destDir,并清理可能残留的缓存目录
|
||||
export function copySeed(seedDir, destDir) {
|
||||
if (existsSync(destDir)) throw new Error(`目标已存在: ${destDir}`);
|
||||
cpSync(seedDir, destDir, { recursive: true });
|
||||
for (const c of CACHE_DIRS) {
|
||||
rmSync(join(destDir, c), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// 修改工程 package.json 的 name 并分配全新 uuid(保留 creator.version 等其它字段)
|
||||
export function setProjectIdentity(projectDir, name) {
|
||||
const pkgPath = join(projectDir, 'package.json');
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
||||
pkg.name = name;
|
||||
pkg.uuid = randomUUID();
|
||||
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
||||
return pkg;
|
||||
}
|
||||
|
||||
// 铺出子游戏私有 game/ 骨架:IGameModule 占位 + theme 占位 + override 目录
|
||||
export function scaffoldGameAssets(projectDir, name) {
|
||||
const gameDir = join(projectDir, 'assets', 'game');
|
||||
mkdirSync(join(gameDir, 'override'), { recursive: true });
|
||||
writeFileSync(join(gameDir, 'override', '.gitkeep'), '');
|
||||
writeFileSync(join(gameDir, 'GameModule.ts'), gameModuleStub(name));
|
||||
writeFileSync(join(gameDir, 'theme.ts'), themeStub(name));
|
||||
}
|
||||
|
||||
function gameModuleStub(name) {
|
||||
return `/**
|
||||
* ${name} 子游戏模块骨架。
|
||||
* TODO(Plan 5): 待 framework/sdk 的 IGameModule / GameContext 接口定稿后实现
|
||||
* (route / onEnter / onReceive / onReconnect 等)。
|
||||
* 协议字段一律参见 docs/protocol/(spec §0.1:只引用、不内联复制)。
|
||||
*/
|
||||
export const GAME_ROUTE = '${name}';
|
||||
`;
|
||||
}
|
||||
|
||||
function themeStub(name) {
|
||||
return `/**
|
||||
* ${name} 主题覆盖骨架。
|
||||
* TODO(Plan 4): 待 framework/ui 的皮肤变量清单定稿后填写主色/字体/图集映射等。
|
||||
*/
|
||||
export const theme = {};
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user