feat: TS 构建管线、Spine 抓取器与 wallpapers/ 唯一真相来源

把项目从「手写 dist/」改成「wallpapers/ 是唯一真相来源,dist/ 由 pnpm build 生成」,
并补上配套的类型、门禁与抓取器。一次提交落地整条管线,因为拆开会留下不能构建的中间态。

- src/:运行时与模拟器源码(TS,strict),编译到 build/ 再拷进各分发
- tools/:build / dev / check-{syntax,paths,dist},以及抓取器与回归门禁 tools/checks/
  (.scratch/ 下那批一次性脚本移入 tools/checks/ 并入库为长期门禁)
- wallpapers/:七档壁纸的源数据 + README.md(id/音频/预设的完整规范)
- docs/adr/0005-0008:构建管线与分发拓扑、模拟器契约、自包含 sim、每骨架资源布局
- .gitignore:排除 .scratch/ 的参考资料副本(上游 spine 整仓克隆 ~1.2 GB、
  抓取侦查数据 ~680 MB)与调试转储;这些是本地调查材料,补偿会让仓库无法克隆
- 归一化 .gitignore/CONTEXT.md 行尾(工作区 CRLF、索引 LF 造成的整文件假 diff)

同时修掉三档卡住构建的未完工壁纸:
- kv45 的 meta.json 里 id 还是抓取期场景名 scene_main,经 downloader promote 正名为 kv45
- shajin / zhigengniao_juheye 的 meta.json 误用了骨架描述文件(name/spine/animations/pages)、
  且都缺 preset.template.json;现按规范重建:骨架沉到 spines/<名>/(spine-ts 按 atlas 所在
  目录解析贴图页)、补上元数据与单骨架预设,并清掉 zhigengniao 骨架里指向作者机的绝对路径
- 顺带 promote 已在 sources.yml 里的 kv46(月升之前,与兽共舞)

pnpm check 五道门全绿:10 个分发 / 7 档壁纸 / 141 处引用自包含。
This commit is contained in:
Shuery committed 2026-10-02 01:27:02 +08:00
1 parent b8eee05d78
commit 3f11426964
297 files changed
+216627 -1926

No files matched your search

+820
View File
@@ -0,0 +1,820 @@
// `pnpm build --sim`:把一档分发再打成一个**双击就能看的自包含包**。
//
// ## 为什么必须单独做一次打包
//
// 发布产物是"给 Wallpaper Engine 的 CEF 用"的:那里是 http 语义,支持 ES module。而"双击打开"
// 走 `file://`,浏览器对它另有一套限制。实测(无头 Edge,与用户双击的行为一致):
//
// file:// 下 结果
// ES module(import / 动态 import) ✗ 被 CORS 拦(origin: null)
// fetch() / XMLHttpRequest() ✗ 同上
// 内联 <script> ✓
// 同目录相对路径的 <img> / <audio> ✓
// 跨目录的 <img> / <audio> ✗ "Not allowed to load local resource"
// data: URL ✓ 到处可用
//
// 于是自包含包要满足三件事:① ES module 合成一个经典脚本;② 资源走 data: URL;③ 不发起任何
// XHR/fetch。第 ② 条顺带解决"跨目录",因为 data: URL 没有目录。
//
// ## 骨架与音频怎么内联
//
// 不去重写 preset 的内容,而是**保留** `asset()` 生成的绝对 URL,再把同一批 URL 映射成 data: URL:
//
// - 骨架 JSON / atlas:spine-player 自带的 `config.rawDataURIs` 就是为这件事设计的通道,
// 它的 downloadText / downloadBinary 会优先查这张表,命中就完全不发请求。
// - 贴图页(atlas 里的 `page.image`):spine-player 用 `pathPrefix + page.name`(pathPrefix 为空,
// 也就是裸文件名)查同一张表,然后交给 <img>,而 <img> 接受 data: URL。
// - 背景图:CSS `url("data:…")`,直接可用。
// - 音频:`<audio src="data:…">`。外链音频要么跨目录被拒、要么得放成一堆散文件而破坏"自包含",
// 所以默认内联;--no-embed-audio 可关掉(包小很多,但音频在 file:// 下不可播,面板会标注)。
//
// ## 产物
//
// `<分发根>/sim/index.html` 一个文件(默认全内联)。它只在 `--sim` 时生成,与发布产物互不干扰。
import { readFile } from "node:fs/promises";
import { mkdirSync, writeFileSync } from "node:fs";
import child_process from "node:child_process";
import { dirname, join } from "node:path";
import { abs, isFile } from "./fs.ts";
import { assetRelIn, audioPathIn, baseName, planAudio, releasePathOf } from "./generate.ts";
import { SIMULATOR_DRIVER } from "./drivers.ts";
import type { Release } from "./types.ts";
/** 运行时模块的依赖序(叶子在前)。手写而非拓扑排序:这份清单是封闭的,写死更好读也更好排错。 */
const RUNTIME_MODULES = [
"viewport-fitter.js",
"preset-controller.js",
"background-controller.js",
"audio-controller.js",
"spine-controller.js",
] as const;
const MIME: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".svg": "image/svg+xml",
".atlas": "text/plain",
".json": "application/json",
".flac": "audio/flac",
".mp3": "audio/mpeg",
".wav": "audio/wav",
".ogg": "audio/ogg",
};
export interface BundleOptions {
/** 内联音频(data: URL)。关掉时音频在 file:// 下不可播,但包体积小得多。 */
embedAudio: boolean;
}
interface ModuleDef {
id: string;
source: string;
}
/**
* 把一份 tsc/生成器产出的 ES module 源码改成"往 window.__weModules 注册"的经典脚本。
*
* import 会被**真的改写**成注册表查表,而不是靠一份手写清单去推断加载顺序:
*
* import Presets, { defaultPresetId } from "./presets.js";
* → const __m_scripts_presets_js = __require("scripts/presets.js");
* const Presets = __m_scripts_presets_js.default;
* const defaultPresetId = __m_scripts_presets_js.defaultPresetId;
*
* 好处是依赖变成**可校验**的:任何没登记进 __weModules 的 id 都会在求值时立刻抛
* `找不到模块`,而不是静默 undefined 到浏览器里才炸。加载顺序 = 注册顺序,与 ES module
* 的求值顺序一致(依赖先注册)。
*
* `moduleDir` 是这份源码"自认为"所在的目录(相对分发根),用来把相对 specifier 解析成
* 绝对模块 id;`known` 是本次打包会注册的 id 清单,解析不出来就直接失败。
*/
function transformModule(id: string, source: string, moduleDir: string, known: Set<string>, requireKnown = true): ModuleDef {
if (/(^|\n)export\s*\{/.test(source)) {
throw new Error(`bundle: ${id} 里出现了不支持的 "export {" 写法,请扩展 transformModule`);
}
const resolveId = (specifier: string): string => {
const target = resolveSpecifier(moduleDir, specifier);
// requireKnown=false 只给测试用:单测要能对着一份"带 import 的源码"验证 export 改写,
// 而那份源码当然不在真实打包清单里。生产路径永远为 true。
if (requireKnown && !known.has(target)) {
throw new Error(
`bundle: ${id} 里 import 了 ${specifier}(解析为 ${target}),但它不在本次打包的模块清单里。` +
`新增运行时模块时要把它加进 buildSimPage 的注册列表。`,
);
}
return target;
};
let out = source.replace(
/(^|\n)\s*import\s+([\s\S]*?)\s+from\s+(['"])([^'"]+)\3\s*;?/g,
(_match: string, lead: string, clause: string, _quote: string, specifier: string) => {
const target = resolveId(specifier);
const varName = `__m_${target.replace(/[^\w]/g, "_")}`;
const lines = [`${lead}const ${varName} = __require(${JSON.stringify(target)});`];
const braces = /\{([^}]*)\}/.exec(clause);
const bare = clause.replace(/\{[^}]*\}/, "").replace(/,/g, " ").trim();
if (bare) lines.push(`const ${bare} = ${varName}.default;`);
if (braces?.[1]) {
for (const name of braces[1].split(",").map((n) => n.trim()).filter(Boolean)) {
lines.push(`const ${name} = ${varName}[${JSON.stringify(name)}];`);
}
}
return lines.join("\n");
},
);
// `import.meta.url` 在经典脚本里是**语法错误**("Cannot use 'import.meta' outside a module"),
// 不是运行时 undefined——所以不能靠 `if (import.meta)` 之类的守卫,必须整段换掉。
//
// 发布产物里 preset.js 用 `new URL("./", import.meta.url)` 让分发目录能整体搬家。
// 自包含包不能照抄这个语义:`import.meta.url` 是**文件** URL,`new URL("./", 文件URL)`
// 会把文件名那一段摘掉(→ 发布根),而自包含页在 `<发布根>/sim/index.html`,
// 它的所有资源都在 `<发布根>/sim/` 下。照抄的结果是预设里写死成
// `file:///<发布根>/spines/kv37/kv37.atlas`——**指向发布根而不是 sim**,
// 而 spine 把「atlasUrl 的父目录」当贴图页基准,于是每一个贴图页都去读一个不存在的路径。
// 症状极难定位:内联表里明明有这张图的 data: URL,图片却还是发 file:// 请求被 CORS 拦。
//
// 所以注入 __simAssetRoot(页内写死为 "./sim/"),把"资源根"与"页面 URL"解耦。
// 见下方 emit 处对 __importMetaUrl 的定义与二次替换。
const usesImportMeta = out.includes("import.meta");
out = out.replace(/\bimport\.meta\.url\b/g, "__importMetaUrl");
// ── export → exports.* 改写 ────────────────────────────────────────────────
//
// 这里**故意不用**"一条正则吃下所有形态"的写法。嵌套可选组会让捕获位置随分支漂移:
// `export function f` 里的 "function" 会落到"名字"位上,而 `export default class C` 又会落到
// 另一处,结果 `exports.function resolveViewport(...)` 这种产物就出来了——语法错、
// 但错得"很像对的",排查起来非常费时。改成两趟按行首扫描,逻辑读得出来、也测得出来。
//
// 前提:tsc 与生成器产出的 export 一律顶格(行首无缩进),这个前提有断言兜底。
//
// **核心约束:本地绑定必须原样活着。** 早先的写法是把 `export const X = 1` 直接改成
// `exports.X = 1`,看着等价,其实把模块内的 `X` 一起删了——同模块别处引用 `X` 就变成
// ReferenceError(真事故:`frameForAspect(..., referenceAspect = REFERENCE_ASPECT)`
// 的默认参数在运行到那一帧时才求值,于是"加载全对、渲染第一帧炸")。
// `export function f` 同理:`exports.f = function f(){}` 里的名字只在该函数体内可见,
// 外层作用域并没有 `f`。
//
// 所以一律**保留声明原文**,把导出推迟到模块体末尾统一赋值。声明都执行完了再赋值,
// const/let 也不会踩 TDZ。
const exportAssignments: string[] = [];
// 声明式:`export [default] [async] function|class 名字`
out = out.replace(
/^export (default )?(async function|function|class) (\w+)/gm,
(_match: string, isDefault: string | undefined, keyword: string, name: string) => {
exportAssignments.push(`exports.${isDefault !== undefined ? "default" : name} = ${name};`);
return `${keyword} ${name}`;
},
);
// 变量声明:`export const|let|var 名字`。` = 1` 那一段原样留着。
// 这里**不用** `(const|let|var)?` 这种可选组:失败时正则引擎会回溯,把已匹配的部分一起丢掉。
out = out.replace(/^export (const|let|var) (\w+)/gm, (_match: string, keyword: string, name: string) => {
exportAssignments.push(`exports.${name} = ${name};`);
return `${keyword} ${name}`;
});
// `export default <表达式>`:只把前缀换成赋值,右边一个字符都不碰。
// 这样 `export default {`(生成的 preset.js)、`export default Presets;` 用同一条规则就够。
// 声明式那两条已经先跑过,这里剩下的只可能是表达式。
out = out.replace(/^export default /gm, `exports.default = `);
const left = /^export\b/m.exec(out);
if (left) throw new Error(`bundle: ${id} 里仍残留未识别的 export 写法:${JSON.stringify(left[0])}`);
// 发布产物里 preset.js 用 `new URL("./", import.meta.url)` 让分发目录能整体搬家。
// 自包含包不能照抄这个语义,而且**有两个不同的"根",必须分开**:
//
// 页面根 —— 自包含页永远在 `<分发根>/sim/index.html`,所以相对页面是 `"../"`。
// 内联表的建键基准与运行时的 resolveAssetUrl 用这个。
// 模块根 —— preset.js 在 `<分发根>/<壁纸id>/preset.js`(合集)或 `<分发根>/preset.js`(单档),
// 它的 `import.meta.url` 基准是**自己所在目录**。相对页面是 `"../<壁纸id>/"`。
//
// 早先把两者都当成"页面根 × 分发深度",单档看不出差别(深度 0),合集就错了:
// preset 把 `./audios/kv37/x.mp3` 解析到 `<releases>/audios/...`(少一层),
// 于是音频退回 file:// 读、报 ERR_FILE_NOT_FOUND。而资源表本身是对的——
// 所以现象是"表和模块都对,只有音频不对",非常容易误判成音频内联逻辑的问题。
const moduleDirPart = moduleDir === "" ? "" : `${moduleDir}/`;
const rebased = out.replaceAll(
`new URL("./", __importMetaUrl)`,
`new URL(${JSON.stringify(`../${moduleDirPart}`)}, document.baseURI)`,
);
// 用了 import.meta.url 但没匹配上"资源根"这条固定写法时保持 document.baseURI——本页共址的
// 资源那样也解析得对。真正的兜底不在这里:`transformModuleForTest` 里有一条断言,
// 要求任何 `new URL("./", import.meta.url)` 都必须被这次替换吃掉,否则测试直接红。
return {
id,
source:
`__weModules[${JSON.stringify(id)}] = (function () {\n` +
` const exports = {};\n` +
(usesImportMeta ? ` const __importMetaUrl = document.baseURI;\n` : "") +
`${indent(rebased)}\n` +
exportAssignments.map((line) => ` ${line}`).join("\n") +
(exportAssignments.length > 0 ? "\n" : "") +
` return exports;\n})();\n`,
};
}
/**
* 仅供测试:把一份 ES module 源码改写成经典脚本的**模块体**(不含包装)。
*
* 导出它是为了让"export 改写"能有一份跑真实代码的单元测试。这条逻辑踩过五次坑,每次的症状
* 都是"语法错得很像对的",靠改一处跑一遍全量构建根本收敛不了。测试里必须调**这一个**函数,
* 不能在测试里复刻一份——复刻的那份永远是对的,真实的那个永远在坏。
*/
export function transformModuleForTest(source: string): string {
return transformModule("__test__", source, "", new Set(), false).source;
}
/** 把 `moduleDir` 下的相对 specifier 解析成分发根的相对模块 id。 */function resolveSpecifier(moduleDir: string, specifier: string): string {
const joined = moduleDir === "" ? specifier : `${moduleDir}/${specifier}`;
const parts: string[] = [];
for (const segment of joined.split("/")) {
if (segment === "" || segment === ".") continue;
if (segment === "..") parts.pop();
else parts.push(segment);
}
return parts.join("/");
}
function indent(text: string): string {
return text
.split("\n")
.map((line) => (line.length > 0 ? " " + line : line))
.join("\n");
}
async function toDataUrl(fileAbs: string): Promise<string> {
const bytes = await readFile(fileAbs);
const ext = fileAbs.slice(fileAbs.lastIndexOf(".")).toLowerCase();
const mime = MIME[ext];
if (!mime) throw new Error(`bundle: 不知道 ${ext} 该用什么 MIME(文件 ${fileAbs})`);
// 刻意不换行:spine-player 用 atob() 解码,atob 不接受 base64 里夹换行。
return `data:${mime};base64,${bytes.toString("base64")}`;
}
export interface SimAssets {
/** 内联资源:规范化后的 key → data: URL。 */
byUrl: Map<string, string>;
/** 按裸文件名登记的 key(spine-player 查贴图页就是这么查的)。 */
byName: Map<string, string>;
/** 内联资源原始字节数。 */
bytes: number;
/** --no-embed-audio 时被跳过的音频。 */
skippedAudio: string[];
/** 登记的资源条数(去重后)。 */
count: number;
}
/**
* 模块 id = **文件自身的路径**(相对分发根,统一不带开头的 `./`),不是 import 里写的 specifier。
*
* 这一点必须搞清楚:`presets.js` 里写的是 `import preset0 from "../hsr/xilian/preset.js"`,
* 而 `index.js` 里写的是 `import Presets from "./presets.js"`。按 specifier 当 id 会得到
* "`../hsr/xilian/preset.js`" 与 "`./presets.js`" 这种互相撞不到一起的键;按文件路径算则
* 两者都归一成 `scripts/...` 或 `hsr/xilian/preset.js`,依赖图才是对的。
*/
function urlKey(path: string): string {
return path.replace(/^(\.\.?\/)+/, "");
}
/**
* 把"相对 preset.js"的路径换算成"相对分发根"的路径。
*
* 音频这条**不需要**再拼壁纸目录:`audioPathIn` 已经写成 `../audios/<壁纸id>/x.mp3`
* 的形式,剥掉开头的 `../` 正好就是分发根相对路径。与资源那条不同——
* 资源的 `assetUrlIn` 是 `./spines/x/x.webp`(相对壁纸目录),所以要多补一段 `<壁纸id>/`。
* 两者基准不同,别合并。
*/
function distRelOf(presetRelative: string): string {
return urlKey(presetRelative);
}
function releaseOutRel(release: Release): string {
return `dist/releases/${release.dir}`;
}
/**
* 收集并内联资源。
*
* 表的键一律是**分发根相对路径**(合集里带 `<壁纸id>/` 这一段)。理由:自包含页在
* `<分发根>/sim/` 下,页面把 `new URL(键, "../")` 解析成绝对 URL,运行时拿到的绝对 URL
* 正好就是这个键解析后的结果。用 preset 相对路径(`../spines/x/x.webp`)建键会丢掉合集里的
* `<壁纸id>/`,症状是"表里有 152 个键、骨架却查不中"——单档分发没有这一段,所以看不出来。
*/
async function collectAssets(release: Release, options: BundleOptions): Promise<SimAssets> {
const byUrl = new Map<string, string>();
const byName = new Map<string, string>();
const skippedAudio: string[] = [];
let bytes = 0;
// 音频落点来自 generate.ts 的 planAudio —— **同一份算法**,两边各写一份必然漂移
const audioPlan = await planAudio(release);
if (audioPlan.collisions.length > 0) {
throw new Error(`bundle: ${release.dir} 的共享音频重名:${audioPlan.collisions.join(";")}`);
}
/**
* 登记一份内联资源。
*
* `bytes` 用来避免同一份文件被多次计入体积统计(贴图页会被登记成"带目录 + 裸名"两个键)。
*/
const seen = new Set<string>();
const add = async (fileAbs: string, url: string, options2: { bytes?: boolean } = {}): Promise<void> => {
const data = await toDataUrl(fileAbs);
const key = urlKey(url);
byUrl.set(key, data);
const name = baseName(key);
if (!byName.has(name)) byName.set(name, data);
if (options2.bytes === false || seen.has(fileAbs)) return;
seen.add(fileAbs);
bytes += (await readFile(fileAbs)).length;
};
for (const wallpaper of release.wallpapers) {
/**
* 资源从**源目录**读,不从 dist 读——`copyWallpaperAssets` 只搬 `spines/`、`scene/`、
* `audios/` 三个目录,而预设里的路径是 `./scene/photo.webp`、`./spines/<名>/<名>.atlas`
* 这种**相对壁纸目录**的形式,要按同一条规则剥掉开头的 `./` 再 join,直接拼会拼错。
* 解析方式与 vault.ts 的校验完全一致。
*/
const dirAbs = abs(wallpaper.srcRel);
const resolveIn = (baseDir: string, relPath: string): string => join(baseDir, relPath.replace(/^\.\//, ""));
const bgRel = wallpaper.preset.backgroundImage;
await add(resolveIn(dirAbs, bgRel), assetRelIn(release, wallpaper, bgRel));
/** 登记一具骨架:json + atlas + atlas 里引用的贴图页。 */
const addSpine = async (jsonRel: string, atlasRel: string): Promise<void> => {
await add(resolveIn(dirAbs, jsonRel), assetRelIn(release, wallpaper, jsonRel));
const atlasAbs = resolveIn(dirAbs, atlasRel);
const atlasRelInRelease = assetRelIn(release, wallpaper, atlasRel);
await add(atlasAbs, atlasRelInRelease);
// atlas 里引用的贴图页:相对 **atlas 自己所在目录**解析,spine-player 也是这么找的。
const atlasDir = dirname(atlasAbs);
const atlasText = await readFile(atlasAbs, "utf8");
// spine-player 的做法是 `atlasUrl 的父目录 + page.name`,所以这里用同一份算法先算出
// atlas 在分发里的目录前缀,再拼页名——与运行时逐字一致,不自己拼目录名。
const atlasPrefix = atlasRelInRelease.slice(0, atlasRelInRelease.lastIndexOf("/") + 1);
for (const line of atlasText.split("\n").map((l) => l.trim())) {
if (!/\.(png|jpe?g|webp)$/i.test(line)) continue;
const pageName = baseName(line);
const pageAbs = join(atlasDir, pageName);
if (!(await isFile(pageAbs))) continue;
await add(pageAbs, atlasPrefix + line.replace(/^\.\//, ""));
// 裸名也登记一份:atlas 里写的就是裸名时,spine-player 在某些路径下会直接拿它查表。
await add(pageAbs, pageName, { bytes: false });
}
};
const singleSpine = wallpaper.preset.spineConfig;
if (singleSpine) await addSpine(singleSpine.jsonUrl, singleSpine.atlasUrl);
// 场景预设:每个 part 的资源都要登记(骨架同单骨架那条路,贴图平面就是一张图)。
const scene = wallpaper.preset.sceneConfig;
if (scene) {
for (const part of scene.parts) {
if (part.kind === "spine" && part.jsonUrl && part.atlasUrl) await addSpine(part.jsonUrl, part.atlasUrl);
else if (part.kind === "image" && part.image) {
await add(resolveIn(dirAbs, part.image), assetRelIn(release, wallpaper, part.image));
}
}
}
// 音频落点来自 generate.ts 的 planAudio —— **同一份算法**,两边各写一份必然漂移。
// audioPathIn 给的是"相对 preset.js"的路径,这里换成"相对分发根"的键。
for (const item of audioPlan.placements) {
if (item.key === "" && release.type !== "single") continue; // 共享音频在下面单独处理
if (!item.from.startsWith(join(abs(wallpaper.srcRel), "audios"))) continue;
if (!options.embedAudio) {
skippedAudio.push(item.destRel);
continue;
}
if (!(await isFile(item.from))) throw new Error(`bundle: ${release.dir} 里找不到音频源 ${item.from}`);
await add(item.from, distRelOf(audioPathIn(release, wallpaper, item.name)));
}
}
// 分发级预览图(project.json 的 preview 指向分发根下的文件名)。
//
// 它**不被 preset.js 引用**,所以不在上面那圈里;但面板要在设置上方显示它。
// 而且自包含页在 `<分发根>/sim/` 下,`../preview.gif` 在 file:// 下是跨目录,
// 读不到——必须内联成 data: URL,和别的资源一个待遇。
if (release.meta.preview) {
const name = release.meta.preview.replace(/\\/g, "/").replace(/^.*\//, "");
const previewAbs = join(abs(release.metaRel), name);
if (await isFile(previewAbs)) await add(previewAbs, name);
}
// 游戏级共享音频:落在合集根 audios/<文件>
const context = release.wallpapers[0];
if (context === undefined) throw new Error(`bundle: ${release.dir} 没有任何壁纸`);
for (const item of audioPlan.placements) {
if (item.key !== "" || release.type === "single") continue;
if (!options.embedAudio) {
skippedAudio.push(item.destRel);
continue;
}
if (!(await isFile(item.from))) throw new Error(`bundle: ${release.dir} 里找不到共享音频 ${item.from}`);
await add(item.from, distRelOf(audioPathIn(release, context, item.name, "shared")));
}
return { byUrl, byName, bytes, skippedAudio, count: byUrl.size };
}
export interface SimBuildResult {
html: string;
assetBytes: number;
skippedAudio: string[];
/** rawDataURIs 的条目数(供构建日志显示)。 */
inlined: number;
}
/**
* 内联资源表。
*
* 两组数据,分工必须分清(这里错了三次,每次症状都不一样):
*
* `rawDataUris`(骨架 JSON / atlas):spine-player 是**查值**,键是它自己解析后的 URL。
* 所以我们要按"以本页为基准解析后的绝对 URL"登记。
* `loadTexture`(贴图页 / 背景图):spine-player 是**查键再当 data: 用**——
* 它先 `rawDataUris[path] || path` 拿到字符串,再赋给 `image.src`,且回调是以**入参**收尾的。
* 所以这里必须把路径真的换成 data: URL,否则就变成"图片从 file:// 读(被 CORS 拦),
* 而回调登记在另一个键上"(第一版就是这么错的)。
*
* 返回值里键表与值数组分开:一张 2 MB 的图会被登记成十几个键(相对/绝对/裸名),
* 值必须去重,否则页面会凭空胖 50%(52 MB → 78 MB,实测)。
*/
function simAssetTable(assets: SimAssets): { keyToIndex: Record<string, number>; values: string[] } {
const keyToIndex: Record<string, number> = {};
const values: string[] = [];
const indexOf = new Map<string, number>();
const add = (key: string, data: string): void => {
if (keyToIndex[key] !== undefined) return;
let index = indexOf.get(data);
if (index === undefined) {
index = values.length;
values.push(data);
indexOf.set(data, index);
}
keyToIndex[key] = index;
};
for (const [key, data] of assets.byUrl) {
add(key, data);
add(`./${key}`, data);
add(`/${key}`, data);
add(baseName(key), data);
}
for (const [name, data] of assets.byName) {
add(name, data);
add(`./${name}`, data);
}
return { keyToIndex, values };
}
/**
* 拼出来的 JS 必须能被 V8 解析——**构建期就检查**,不要等浏览器。
*
* 这里拼的是生成器产出的裸 JS 文本(正则改写 + 字符串模板),tsc 完全不看它们。踩过多次真事故:
* 正则把类方法里的 "export " 前缀改坏、`exports.default` 少了等号、`function` 关键字被吞。
* 全都是"其他检查全绿、只有浏览器白屏",所以这一步是必须的。失败时把出问题的那几行连同
* 完整产物写到 .scratch/sim-failed/ 下——只有行号和"Unexpected token"根本不够定位。
*/
function assertParses(label: string, code: string): void {
const { spawnSync } = child_process;
const child = spawnSync(
process.execPath,
[
"--experimental-vm-modules",
"--no-warnings",
"-e",
`new (require("node:vm").SourceTextModule)(require("node:fs").readFileSync(0, "utf8"));`,
],
{ input: code, encoding: "utf8" },
);
if (child.status === 0) return;
// 产物落盘,方便直接看坏在哪儿
const outDir = ".scratch/sim-failed";
mkdirSync(outDir, { recursive: true });
const file = join(outDir, `${label}.js`);
writeFileSync(file, code, "utf8");
const lines = code.split("\n");
const lineNo = Number(/vm:module\(\d+\):(\d+)/.exec(child.stderr ?? "")?.[1] ?? 0);
const context =
lineNo > 0
? lines
.slice(Math.max(0, lineNo - 4), lineNo + 2)
.map((l, i) => ` ${Math.max(0, lineNo - 3) + i}: ${l}`)
.join("\n")
: "";
const reason = (child.stderr ?? "").split("\n").find((l) => /SyntaxError/.test(l)) ?? "(V8 未给出原因)";
throw new Error(`bundle: ${label} 拼接出来的 JS 无法解析:${reason}\n产物已写入 ${file}\n${context}`);
}
export async function buildSimPage(release: Release, defaultPresetId: string, options: BundleOptions): Promise<SimBuildResult> {
const assets = await collectAssets(release, options);
// 先把所有会被注册的模块 id 列出来,再逐个改写。改写时才能校验 import 都指得到东西。
//
// id 是**文件相对分发根的路径**,与 generatePresetIndex 生成的 import 逐字对应:
// 单档分发 preset.js (presets.js 在 scripts/ 下,写 `../preset.js` → 归一成 preset.js)
// 合集分发 <壁纸id>/preset.js (presets.js 写 `../<壁纸id>/preset.js` → 归一成 <壁纸id>/preset.js)
// 注意合集里**没有** scripts/ 这一段:preset.js 与 scripts/ 平级。写成 `scripts/<id>/preset.js`
// 会让 presets.js 的 import 校验直接失败("不在本次打包的模块清单里")。
const presetIds = release.wallpapers.map((w) => {
const sub = releasePathOf(release, w);
return sub === "" ? "preset.js" : `${sub}/preset.js`;
});
const known = new Set<string>([
...RUNTIME_MODULES.map((name) => `scripts/${name}`),
...presetIds,
"scripts/presets.js",
"scripts/index.js",
"sim",
]);
const modules: ModuleDef[] = [];
for (const name of RUNTIME_MODULES) {
modules.push(transformModule(`scripts/${name}`, await readFile(abs(`build/scripts/${name}`), "utf8"), "scripts", known));
}
for (const [index, wallpaper] of release.wallpapers.entries()) {
const sub = releasePathOf(release, wallpaper);
// 模块体所在目录 = id 的目录部分。合集里是 `<壁纸id>`(与 scripts/ 平级),单档里是分发根("")。
const moduleDir = sub;
modules.push(
transformModule(
presetIds[index] ?? "",
await readFile(abs(join(releaseOutRel(release), sub, "preset.js")), "utf8"),
moduleDir,
known,
),
);
}
modules.push(
transformModule(
"scripts/presets.js",
await readFile(abs(join(releaseOutRel(release), "scripts", "presets.js")), "utf8"),
"scripts",
known,
),
);
modules.push(
transformModule("scripts/index.js", await readFile(abs("build/scripts/index.js"), "utf8"), "scripts", known),
);
modules.push(transformModule("sim", await readFile(abs("build/scripts/wallpaper-engine.js"), "utf8"), "", known));
const simModule = modules[modules.length - 1] as ModuleDef;
// 模拟器单独成段注入:它要排在驱动脚本**之前**、壁纸运行时**之前**(见下面的 <script> 顺序注释)。
// 它的模块包装体是个 IIFE,函数不会自动变成全局,所以要在这里把它挂到 window 上——
// 真实 WE 里并不存在这个全局(模拟器也不该凭空提供别的),它只是**本页内**的接线。
simModule.source +=
`\nif (window.mountWallpaperEngineSimulator === undefined) {\n` +
` window.mountWallpaperEngineSimulator = __weModules["sim"].mountWallpaperEngineSimulator;\n` +
`}\n`;
modules.pop();
const project = JSON.parse(await readFile(abs(join(releaseOutRel(release), "project.json")), "utf8")) as {
general?: { properties?: Record<string, unknown> };
title?: string;
version?: number;
};
const css = await Promise.all(
["spine-player.css", "index.css"].map(async (name) => `/* ${name} */\n${await readFile(abs(`src/styles/${name}`), "utf8")}`),
);
const bootstrap = [
`// ── 模块注册表 ────────────────────────────────────────────────────────────`,
`// 发布产物用的是真正的 ES module;file:// 下浏览器根本不加载它们,所以这里把同一批模块`,
`// 合成经典脚本,用最小注册表补回 import 的语义。`,
`//`,
`// 这段在页面上出现两次(模拟器段之前、运行时段之前):两段脚本都需要注册表,而顺序又必须是`,
`// "注册表 → 模拟器 → 驱动 → 壁纸运行时"。所以做成幂等的,重复执行没有副作用。`,
`//`,
`// 加载顺序 = 注册顺序,与 ES module 的求值顺序一致(依赖先注册)。依赖在这里是**可校验**的:`,
`// 任何没登记过的 id 都会立刻抛错,而不是静默 undefined 到浏览器里才炸。`,
`if (!window.__weModules) window.__weModules = {};`,
`function __require(id) {`,
` var m = window.__weModules[id];`,
` if (!m) throw new Error("[自包含包] 找不到模块 " + id + "(打包清单与源码的 import 不一致)");`,
` return m;`,
`}`,
``,
].join("\n");
const table = simAssetTable(assets);
/**
* 页面资源根:自包含页固定在 `<分发根>/sim/index.html`,资源相对**分发根**寻址,
* 所以永远是 `"../"`——**与合集深度无关**。
*
* 这里踩过一次:写成 `"../".repeat(depth)`,单档(深度 0)与游戏合集(深度 1)碰巧都对,
* 全部合集(深度 2)就变成 `"../../"`,资源根跑到 `dist/releases/` 去了。
* 症状是"表里 152 个键、骨架却查不中"——页面 URL 与分发深度是两件不相干的事。
*
* 模块内的 `import.meta.url` 基准另算(见 transformModule 的 moduleDir 参数)。
*/
const assetRoot = "../";
const script = [
`"use strict";`,
// 必须**先于模块体**定义:模块体里的 `new URL(__simAssetRoot, document.baseURI)` 在导入期就会求值。
`const __simAssetRoot = ${JSON.stringify(assetRoot)};`,
bootstrap,
...modules.map((m) => m.source),
``,
`// ── 内联资源表 & 自包含包装 ───────────────────────────────────────────────`,
`// 这里有两张表,**分工不能混**(混过三次,每次症状都不一样):`,
`// resolve() → 给"把 URL 换成 data: URL"用:贴图页/背景图必须真换,`,
`// spine-player 的 loadTexture 是"查键再当 data: 用"。`,
`// raw() → 给 rawDataURIs 用:骨架 JSON/atlas 是"查值",键是 spine-player`,
`// 解析后的绝对 URL,值就是 data: URL。`,
`// 值数组单独存一份并按下标引用:同一张 2 MB 的图有十几个键别名(相对/绝对/裸名),`,
`// 直接展开会让页面凭空胖 50%(52 MB → 78 MB,实测过)。`,
`(function () {`,
` var VALUES = ${JSON.stringify(table.values)};`,
` var KEYS = ${JSON.stringify(table.keyToIndex)};`,
` // 资源根:与运行时 resolveAssetUrl 用的是**同一个字符串**(见 buildSimPage 的 assetRoot)。`,
` // 两处只要差一层目录,表里就有键而查不中——图片会安静地退回 file:// 再被 CORS 拦。`,
` var assetRoot = __simAssetRoot;`,
` var base = new URL(assetRoot, document.baseURI);`,
` // byRelative 以构建期登记的键为键(形如 ./spines/kv37/kv37.webp),`,
` // byAbsolute 额外补上"以资源根为基准解析后的绝对 URL",因为运行时两种形态都会出现。`,
` var byRelative = {};`,
` var byAbsolute = {};`,
` for (var k in KEYS) {`,
` var data = VALUES[KEYS[k]];`,
` byRelative[k] = data;`,
` byAbsolute[k] = data;`,
` try { byAbsolute[new URL(k, base).href] = data; } catch (e) { /* 畸形相对路径忽略 */ }`,
` }`,
` var strip = function (url) { return String(url).replace(/^(\\.\\.?\\/)+/, ""); };`,
` var raw = function (url) {`,
` if (typeof url !== "string" || url === "") return undefined;`,
` return byAbsolute[url] || byAbsolute["./" + strip(url)] || byAbsolute[strip(url)];`,
` };`,
` var resolve = function (url) {`,
` if (typeof url !== "string" || url === "") return url;`,
` if (url.slice(0, 5) === "data:") return url;`,
` return raw(url) || url;`,
` };`,
` window.__simAssets = byAbsolute;`,
` window.__simSwap = resolve;`,
` window.__simAssetRoot = assetRoot;`,
``,
` // spine-player 用 __export 把 SpinePlayer 定义成**不可配置的 getter**,所以既不能给它赋值、`,
` // 也不能 defineProperty 覆盖(实测 descriptor: configurable=false)。但 window.spine 本身是`,
` // 可写的普通全局,所以换掉整个 spine 对象:代理继承自真的那个,只覆盖 SpinePlayer 一个键。`,
` // 这样 spine-player 内部拿到的还是自己的真实对象,只有页面上 new spine.SpinePlayer(...) 走包装。`,
` var RealSpine = window.spine;`,
` var OriginalSpinePlayer = RealSpine.SpinePlayer;`,
` // 调用方(spine-controller)传进来的 jsonUrl/atlasUrl 是**相对路径**,由 spine-player 自己解析。`,
` // 这里按页面基准算出它们将被解析成什么,再登记到 rawDataURIs 里——键必须与那个结果逐字相同。`,
` var absolute = function (value) {`,
` if (typeof value !== "string" || value === "") return undefined;`,
` try { return new URL(value, base).href; } catch (e) { return undefined; }`,
` };`,
` var WrappedSpinePlayer = function (container, config) {`,
` if (config && typeof config === "object") {`,
` // 整张表先进去,**两种键都要**:`,
` // byRelative —— loadTexture(贴图页/背景图)拿 spine 自己解析出的相对路径当键查;`,
` // byAbsolute —— 骨架 JSON/atlas 是查值,spine 用解析后的绝对 URL 当键查。`,
` // 只并 byRelative 会漏掉贴图页的绝对键;只并 registerRaw 的三个 URL 也会漏——`,
` // 贴图页根本不在那三个 URL 里,它们的绝对键只有 byAbsolute 有。`,
` var merged = {};`,
` for (var j in byRelative) merged[j] = byRelative[j];`,
` for (var j3 in byAbsolute) merged[j3] = byAbsolute[j3];`,
` for (var j2 in config.rawDataURIs || {}) merged[j2] = config.rawDataURIs[j2];`,
` // 骨架 JSON / atlas 是**查值**,键是 spine-player 解析后的绝对 URL,所以要另按绝对 URL 登记。`,
` var registerRaw = function (value) {`,
` var target = absolute(value);`,
` var data = target === undefined ? undefined : raw(value);`,
` if (target !== undefined && data !== undefined) merged[target] = data;`,
` };`,
` registerRaw(config.jsonUrl);`,
` registerRaw(config.atlasUrl);`,
` registerRaw(config.binaryUrl);`,
` config.rawDataURIs = merged;`,
` // 断言:骨架与 atlas 解析后的绝对 URL 必须在表里。`,
` // 少了任意一个,spine 都会安静地退回 XHR,在 file:// 下被 CORS 拦成一片白屏——`,
` // 现场只剩"图片加载失败",看不出是建表基准错了还是查表姿势错了。`,
` for (var checkIdx = 0; checkIdx < 3; checkIdx++) {`,
` var checkValue = [config.jsonUrl, config.atlasUrl, config.binaryUrl][checkIdx];`,
` var checkKey = absolute(checkValue);`,
` if (checkKey !== undefined && merged[checkKey] === undefined) {`,
` throw new Error("[自包含包] rawDataURIs 缺键:" + checkKey);`,
` }`,
` }`,
` // 关键:**不改 config 里的 URL**。改了就会把回调键与 textureUrl 键拆成两个,`,
` // 于是图片去 file:// 读(被 CORS 拦)、回调永远不触发。`,
` if (config.backgroundImage && config.backgroundImage.url) {`,
` config.backgroundImage.url = resolve(config.backgroundImage.url);`,
` }`,
` }`,
` var player = new OriginalSpinePlayer(container, config);`,
` return player;`,
` };`,
` WrappedSpinePlayer.prototype = OriginalSpinePlayer.prototype;`,
` var spineProxy = Object.create(RealSpine);`,
` Object.defineProperty(spineProxy, "SpinePlayer", {`,
` value: WrappedSpinePlayer,`,
` writable: true,`,
` configurable: true,`,
` enumerable: true,`,
` });`,
` window.spine = spineProxy;`,
` if (window.spine.SpinePlayer !== WrappedSpinePlayer) {`,
` throw new Error("[自包含包] 替换 spine.SpinePlayer 失败:spine-player 的全局结构变了");`,
` }`,
`})();`,
].join("\n");
const driver = SIMULATOR_DRIVER({
search: "",
dir: `${release.dir} · 自包含包`,
title: project.title ?? release.dir,
version: String(project.version ?? ""),
defaultPresetId,
// 预览图由 collectAssets 内联成 data: URL,这里只把文件名交给面板(面板自己走 __simSwap)。
preview: release.meta.preview,
// 不写这一条就会被动态 import 到 /release/… 上去——file:// 下必被 CORS 拦。
simulatorMode: "global",
});
// 解析门禁:这两段是拼出来的裸 JS,必须先过 V8 才能写盘。
assertParses("模拟器段", simModule.source);
assertParses("运行时段", script);
/**
* 内联脚本里出现 `</script` 会**提前终止**这个 script 元素,后面的代码就变成页面文本——
* 症状是页面半死不活、控制台报一堆莫名其妙的语法错,而错误位置与真正的原因毫无关系。
*
* 这里选择"构建期报错"而不是"自动转义":转义要区分字符串内/外,改错了会静默改变语义;
* 而当前所有被内联的源码都不含这个串,一旦将来出现,报错比猜更安全。
*
* **只查纯 JS 体**,不查 driver:driver 本身就是一个完整的 `<script>…</script>` 块,
* 它的收尾标签是它自己的,不是需要转义的内容。第一版把 driver 也一起查了,
* 结果干净源码立刻构建失败——守卫自身写错,比没有守卫更糟。
*/
for (const [label, code] of [
["spine-player", await readFile(abs("src/vendor/spine-player.js"), "utf8")],
["模拟器段", simModule.source],
["运行时段", script],
] as const) {
if (/<\/script/i.test(code)) {
throw new Error(`bundle: ${release.dir} 的${label}里含 "</script",内联后会截断页面`);
}
}
const html = `<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${project.title ?? release.displayName}(自包含调试包)</title>
<link rel="icon" href="data:," />
<style>
${css.map(indent).join("\n")}
</style>
</head>
<body>
<div id="spine-container"></div>
<audio id="background-music"></audio>
<!--
脚本顺序是**语义要求**,不是排版问题:
1. spine-player:壁纸运行时要 new spine.SpinePlayer
2. 属性表 + 模拟器 + 驱动:API 必须在壁纸模块注册 wallpaperPropertyListener **之前**就绪,
否则"属性早于模块到达"这条竞态(?__propsAt=dom)复现不出来
3. 壁纸运行时(模块注册表 + 启动):它必须在最后,注册了 listener 才会收到第 2 步的下发
自包含包里没有模块系统,所以这里顺序即一切。
-->
<script>
${indent(await readFile(abs("src/vendor/spine-player.js"), "utf8"))}
</script>
<script>window.__weProperties = ${JSON.stringify(project.general?.properties ?? {}).replace(/</g, "\\u003c")};</script>
<!-- 模拟器(经典脚本版,内联):只提供 WE 的官方 API,绝不自己注册 listener -->
<script>
"use strict";
${indent(bootstrap)}
${indent(simModule.source)}
</script>
${driver}
<!-- 壁纸运行时:与发布产物同一批模块,合成一个经典脚本 -->
<script>
${indent(script)}
</script>
</body>
</html>
`;
return { html, assetBytes: assets.bytes, skippedAudio: assets.skippedAudio, inlined: assets.count };
}
+189
View File
@@ -0,0 +1,189 @@
// 调试服注入到 index.html 里的驱动脚本。
//
// 为什么单独一个文件:调试服(pnpm dev)与对拍工具(tools/serve.mjs)都往页面里注入脚本,
// 两边的注入点与语义必须一致,否则"调试服里好的、对拍里坏"这类分歧会持续存在。
//
// 这里生成的都是**经典脚本**(非 module):它们在 index.html 的 <script type="module"> 之前执行,
// 因此能在壁纸模块注册 window.wallpaperPropertyListener 之前就把钩子准备好。
/**
* 调试服提供模拟器的固定路径(`simulatorMode: "url"` 的默认值)。
*
* 放在调试服自己的命名空间下,而不是某个分发目录里:模拟器默认不进发布产物(ADR 0006 第 1 条),
* 但调试服总是需要它。两边共用一个常量,避免"驱动里写死一个、服务器里写死另一个"再次错位。
*
* **静态托管(GitHub Pages)不能用它**:那是根绝对路径,Pages 会把它解析到域名根,
* 而不是仓库子路径。静态构建必须传 `simulatorUrl: "./scripts/wallpaper-engine.js"`。
*/
export const SIMULATOR_URL = "/simulator/wallpaper-engine.js";
/**
* 静态构建(`pnpm build --with-sim`)里注入的模拟器地址。
*
* 相对路径是**必须**的:GitHub Pages 把站点放在 `/<repo>/` 子路径下,
* 根绝对路径 `/scripts/…` 会 404。相对路径也让同一个分发目录放到任何位置都能用。
*/
export const SIMULATOR_URL_STATIC = "./scripts/wallpaper-engine.js";
export interface SimulatorDriverOptions {
/** 原始查询串(用于把 __props / fps / __paused 等参数交给模拟器)。 */
search: string;
/** 分发目录名。 */
dir: string;
/** project.json 的 title。 */
title: string;
/** project.json 的 version。 */
version: string;
/** 本分发的默认预设 id。 */
defaultPresetId: string;
/** project.json 的 preview 字段(分发根下的文件名)。缺省 = 该分发没有预览图。 */
preview?: string;
/**
* 装载方式:
* "url"(默认) 经典脚本里的动态 import,从调试服自己的 `/simulator/wallpaper-engine.js` 取。
* "global" 直接调 `window.mountWallpaperEngineSimulator`,模拟器已由前面一个
* <script> 内联在同一页里。
*
* 自包含包(`pnpm build --sim`)必须用 "global":file:// 下动态 import 会被 CORS 拦掉。
*
* 为什么 "url" 不指向 `/release/<dir>/scripts/…`:模拟器是**调试期专有**资源,
* 按 ADR 0006 §1 默认不进发布产物,只有 `pnpm build --with-sim` 才会往分发里放一份。
* 而调试服**总是**需要它。指向分发内部会让"没加 --with-sim 的默认构建"下
* 动态 import 404、面板静默消失——正是这里踩过的坑。改由调试服提供,
* 分发目录则永远保持"就是发布产物"的样子。
*/
simulatorMode?: "url" | "global";
/**
* `simulatorMode: "url"` 时从哪儿取模拟器本体。默认 `SIMULATOR_URL`(调试服的绝对路由)。
* 静态构建传 `SIMULATOR_URL_STATIC`。
*/
simulatorUrl?: string;
}
/**
* 模拟器驱动:解析 URL 参数 → 装载模拟器 → 调 mountWallpaperEngineSimulator。
*
* 模拟器本身是 ES module(`pnpm build:sim` 产出 scripts/wallpaper-engine.js)。这里刻意**不**用
* `<script type="module">`:module 脚本会被延迟到解析完成之后执行,那样它就不可能早于壁纸模块
* 就位,而"属性在 load 之前到达"这一时序要求 API 必须在 index.js 注册 listener 之前就绪。
* 用经典脚本里的动态 import 装载,既满足时序,又能让模拟器源码正常使用 import/export。
*/
export function SIMULATOR_DRIVER(options: SimulatorDriverOptions): string {
const config = JSON.stringify({
search: options.search,
dir: options.dir,
title: options.title,
version: options.version,
defaultPresetId: options.defaultPresetId,
preview: options.preview,
});
const load =
options.simulatorMode === "global"
? `var mount = window.mountWallpaperEngineSimulator;
if (typeof mount !== "function") { console.error("[WE 模拟器] 内联的模拟器没有挂上 window.mountWallpaperEngineSimulator"); return; }
mount(createOptions());`
: `var moduleUrl = ${JSON.stringify(options.simulatorUrl ?? SIMULATOR_URL)};
import(moduleUrl).then(function (mod) {
mod.mountWallpaperEngineSimulator(createOptions());
}, function (error) {
console.error("[WE 模拟器] 加载失败:", error);
});`;
return `<script>
(function () {
// 幂等:--with-sim 构建的分发**自带**驱动,调试服又会再注入一次。
// 没有这道闸,模拟器会被 mount 两遍,页面上出现两个面板、属性也下发两次。
if (window.__weSimDriver) return;
window.__weSimDriver = true;
var cfg = ${config};
var q = new URLSearchParams(cfg.search || location.search);
var props = {};
try { props = JSON.parse(q.get("__props") || "{}"); } catch (e) { props = {}; }
// project.json 的默认值作为底,URL 参数覆盖它
var defaults = {};
var defs = window.__weProperties || null;
if (defs) for (var k in defs) if (defs[k] && "value" in defs[k]) defaults[k] = defs[k].value;
var fpsRaw = q.get("fps");
function createOptions() {
var opts = {
releaseName: cfg.title + " · " + cfg.dir,
preview: cfg.preview,
properties: defs || {},
initialProps: Object.assign({}, defaults, props),
initialFps: fpsRaw === null ? 0 : Number(fpsRaw) || 0,
initialPaused: q.get("__paused") === "1",
propsAt: q.get("__propsAt") === "dom" ? "dom" : "load"
};
return opts;
}
function afterMount() {
if (!window.__weSim) return;
window.__weSim.release.version = cfg.version;
window.__weSim.release.dir = cfg.dir;
}
${load}
afterMount();
})();
</script>`;
}
/**
* ?nojs=1 的收尾脚本:剥掉所有 <script> 之后,页面上什么都不剩,连"是否加载成功"都无从判断。
* 这里补一个小小的状态标记,让 cdp/shot 的断言仍然能读到 readyState。
*/
export const NOJS_DRIVER = `<script>
document.title = "TESTSTATE " + JSON.stringify({ mode: "nojs", readyState: document.readyState, scripts: 0 });
</script>`;
/**
* 热更新客户端(只有调试服注入,发布产物里没有)。
*
* 连调试服的 SSE,按消息决定怎么更新:
* building → 左下角出现一个小药丸,说明正在重建(不然保存后有 1 秒左右毫无反馈)
* css → **只换样式表**,不整页重载:保住已经加载好的 Spine 播放器与面板状态
* reload → 整页刷新
* error → 显示错误并且**不刷新**:构建失败时刷新只会把一个半成品页面端上来
*
* 药丸挂在 documentElement 下,理由与模拟器面板相同:body 上会被打 transform/filter
* (翻转、颜色选项),挂 body 里会跟着壁纸一起被镜像、被调色。
*/
export const LIVE_RELOAD_CLIENT = `<script>
(function () {
if (!window.EventSource) return;
var pill = null;
function show(text, tone) {
if (!pill) {
pill = document.createElement("div");
pill.id = "dev-reload-pill";
pill.style.cssText = "position:fixed;left:12px;bottom:12px;z-index:2147483647;" +
"font:12px/1.5 'Segoe UI',system-ui,sans-serif;padding:6px 12px;border-radius:999px;" +
"border:1px solid #3a3a40;box-shadow:0 4px 14px rgba(0,0,0,.5);max-width:62vw;white-space:pre-wrap;";
document.documentElement.append(pill);
}
pill.style.display = "block";
pill.style.background = tone === "error" ? "#5a1f1f" : "#2a2a2e";
pill.style.color = tone === "error" ? "#ffb4b4" : "#e6e6e8";
pill.textContent = text;
}
function hide() { if (pill) pill.style.display = "none"; }
var es = new EventSource("/__dev/events");
es.onmessage = function (event) {
var msg;
try { msg = JSON.parse(event.data); } catch (e) { return; }
if (msg.type === "building") { show("⟳ 重新构建中… " + (msg.steps || []).join(" → ")); return; }
if (msg.type === "error") { show("✗ 构建失败(页面没有刷新)\\n" + msg.message, "error"); return; }
if (msg.type === "css") {
var links = document.querySelectorAll('link[rel="stylesheet"]');
for (var i = 0; i < links.length; i++) {
links[i].href = links[i].href.split("?")[0] + "?t=" + Date.now();
}
hide();
return;
}
if (msg.type === "reload") location.reload();
};
// 调试服重启时连接会断,EventSource 自己会重连,这里不需要做任何事。
es.onerror = function () {};
})();
</script>`;
+129
View File
@@ -0,0 +1,129 @@
// 文件系统与会话基元。刻意不引任何第三方依赖:构建脚本要能在一台只有 Node 的机器上跑起来。
import { createHash } from "node:crypto";
import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { dirname, join, relative, resolve, sep } from "node:path";
/** 仓库根(tools/lib/fs.ts → ../..)。所有路径都相对它解析,脚本可从任意 cwd 调用。 */
export const repoRoot = resolve(import.meta.dirname, "..", "..");
/** 把仓库相对路径解析成绝对路径。 */
export function abs(rel: string): string {
return join(repoRoot, rel);
}
/** 统一成以 / 分隔的仓库相对路径,便于打印与写进 dist-map.json。 */
export function rel(absPath: string): string {
return relative(repoRoot, absPath).split(sep).join("/");
}
export async function readJson<T>(path: string): Promise<T> {
return JSON.parse(await readFile(path, "utf8")) as T;
}
export async function writeJson(path: string, value: unknown): Promise<void> {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, JSON.stringify(value, null, "\t") + "\n", "utf8");
}
export async function writeText(path: string, value: string): Promise<void> {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, value.endsWith("\n") ? value : value + "\n", "utf8");
}
export async function exists(path: string): Promise<boolean> {
return existsSync(path);
}
export async function isDir(path: string): Promise<boolean> {
try {
return (await stat(path)).isDirectory();
} catch {
return false;
}
}
export async function isFile(path: string): Promise<boolean> {
try {
return (await stat(path)).isFile();
} catch {
return false;
}
}
/** 列目录,只返回目录名,跳过以 . 开头的条目与显式排除的名字。 */
export async function listDirs(path: string, exclude: string[] = []): Promise<string[]> {
if (!(await isDir(path))) return [];
const entries = await readdir(path, { withFileTypes: true });
return entries
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && !exclude.includes(e.name))
.map((e) => e.name)
.sort();
}
/**
* 拷贝目录树。
*
* 刻意用真拷贝而不是链接(hardlink / junction):分发目录要能被整体搬到别的机器上传,
* 链接会让"目录可整体搬走"这条不变式在某些工具下失效(见 docs/adr/0005)。
*/
export async function copyTree(src: string, dest: string): Promise<void> {
if (!(await exists(src))) return;
await mkdir(dirname(dest), { recursive: true });
await cp(src, dest, { recursive: true, force: true });
}
/** 拷贝单个文件,自动建父目录。 */
export async function copyFileTo(src: string, dest: string): Promise<void> {
await mkdir(dirname(dest), { recursive: true });
await cp(src, dest, { force: true });
}
/** 目录内所有文件的相对路径(以 / 分隔)。 */
export async function walkFiles(root: string, base = root): Promise<string[]> {
if (!(await isDir(base))) return [];
const out: string[] = [];
for (const entry of await readdir(base, { withFileTypes: true })) {
const full = join(base, entry.name);
if (entry.isDirectory()) out.push(...(await walkFiles(root, full)));
else if (entry.isFile()) out.push(relative(root, full).split(sep).join("/"));
}
return out.sort();
}
/** 目录内所有文件的总字节数。 */
export async function dirBytes(root: string): Promise<number> {
if (!(await isDir(root))) return 0;
let total = 0;
for (const f of await walkFiles(root)) total += (await stat(join(root, f))).size;
return total;
}
export async function sizeOf(path: string): Promise<number> {
try {
return (await stat(path)).size;
} catch {
return 0;
}
}
/** 一个长任务的耗时统计与人性化打印。 */
export function mb(bytes: number): string {
return (bytes / 1024 / 1024).toFixed(1) + " MB";
}
export function sha1(text: string): string {
return createHash("sha1").update(text).digest("hex");
}
/** 清空目录后重建(用于"分发目录绝不残留脏文件"这条保证)。 */
export async function resetDir(path: string): Promise<void> {
await rm(path, { recursive: true, force: true });
await mkdir(path, { recursive: true });
}
/** 单行进度输出,避免几十个分发目录刷屏。 */
export function log(message: string): void {
process.stdout.write(message + "\n");
}
+531
View File
@@ -0,0 +1,531 @@
// 一个新分发目录 = 一个能直接上传 Wallpaper Engine 的作品。
//
// 这一层只负责"合成产物":从 wallpapers/ 的内容算出 preset.js(运行时)与 project.json(发布),
// 并把资源铺进目录结构。校验与去重不在本文件(见 vault.ts 与 build.ts)。
import { join } from "node:path";
import type { AudioChoice, ProjectTemplate, Release, Wallpaper } from "./types.ts";
import { abs, walkFiles } from "./fs.ts";
import { copyFileTo, copyTree, isDir, isFile, writeJson, writeText } from "./fs.ts";
export interface GenerateContext {
template: ProjectTemplate;
version: string;
/** 默认预设 id(来自 wallpapers/meta.json):决定合集分发的 preset 下拉首项与 value。 */
defaultPresetId: string;
/** 分发目录的绝对路径。 */
outDir: string;
/** 往运行时脚本目录里额外放一个可选的 WE 模拟器入口。 */
withSimulator: boolean;
}
/** 该分发里一档壁纸落到哪个子目录(相对分发根)。 */
export function releasePathOf(release: Release, wallpaper: Wallpaper): string {
if (release.scope === "all") return `${wallpaper.gameId}/${wallpaper.id}`;
if (release.scope === "game") return wallpaper.id;
return "";
}
/**
* 一份音频在某分发里的相对路径(相对 preset.js 所在目录)。
*
* 落点键必须与 `planAudio` 算出来的一致:单档分发与共享音频都落在分发根(键为空串),
* 合集分发落在 `audios/<壁纸id>/`。这里刻意不查落点表——查表会让人以为"表是权威",
* 而真正的权威是 planAudio;两者不一致时应当在构建里被发现(见 check:dist)。
*/
export function audioPathIn(release: Release, wallpaper: Wallpaper, file: string, shared = ""): string {
const rootPrefix = release.type === "single" ? "./" : rootPrefixOf(release, wallpaper);
const key = shared !== "" ? shared.replace(/^\/+|\/+$/g, "") : release.type === "single" ? "" : wallpaper.id;
return rootPrefix + "audios/" + (key ? key + "/" : "") + baseName(file);
}
/**
* 从某档壁纸的目录回到**分发根**的相对前缀。
*
* 必须与 build.ts 的 copyWallpaperAssets({skipAudio}) + copyCollectionAudio 的搬移行为严格一致,
* 否则 preset.js 会指向不存在的文件(这条路径算错过一次:多算了一层 `../`)。
*/
export function rootPrefixOf(release: Release, wallpaper: Wallpaper): string {
if (release.type === "single") return "./";
// game 合集里壁纸在 1 层下(../),all 合集里在 2 层下(../../)。
const depth = releasePathOf(release, wallpaper).split("/").filter(Boolean).length;
return "../".repeat(depth);
}
/** 该分发里一档壁纸的目录深度(0 = 单档,1 = 游戏合集,2 = 全部合集)。 */
export function depthOf(release: Release, wallpaper: Wallpaper): number {
return releasePathOf(release, wallpaper).split("/").filter(Boolean).length;
}
/** 一档壁纸目录内的资源(图片/骨架/atlas)在发布产物里的 URL,与 `asset()` 完全同算法。 */
export function assetUrlIn(release: Release, wallpaper: Wallpaper, relInWallpaper: string): string {
return `./${rootPrefixOf(release, wallpaper)}${relInWallpaper.replace(/^\.\//, "")}`;
}
/**
* 同一份资源在**分发根**下的路径(不带 `./`、`../`)。
*
* 与 `assetUrlIn` 的区别是基准:那个以 preset.js 所在目录为基准(`../spines/x/x.webp`),
* 这个以分发根为基准(`kv37/spines/x/x.webp`)。自包含包的资源表以**页面**为基准建键,
* 而页面在 `<分发根>/sim/`,所以要用这个。
*
* 拿 `assetUrlIn` 的结果去建表会丢掉合集里的 `<壁纸id>/` 这一段:
* `./../spines/x/x.webp` 归一化后是 `spines/x/x.webp`,而真实位置是 `kv37/spines/x/x.webp`。
* 单档分发看不出差别(没有这一段),合集里则是"表里 152 个键,骨架却查不中"。
*/
export function assetRelIn(release: Release, wallpaper: Wallpaper, relInWallpaper: string): string {
const sub = releasePathOf(release, wallpaper);
const rel = relInWallpaper.replace(/^\.\//, "");
return sub === "" ? rel : `${sub}/${rel}`;
}
function withDotSlash(path: string): string {
const normalized = path.replace(/\\/g, "/");
return normalized.startsWith("./") || normalized.startsWith("../") ? normalized : "./" + normalized;
}
function baseName(path: string): string {
return path.replace(/\\/g, "/").replace(/^.*\//, "");
}
/**
* 生成一档壁纸的 preset.js。
*
* 关键:每个分发目录里的 preset.js 都是**现算**的——同一档壁纸在三种分发形状下,
* 音频落点与相对深度都不同,所以不能一份源码拷贝到多处:
* 单档分发 图片/骨架/音频全在本目录 → ./audios/<壁纸id>/…
* 游戏合集 图片/骨架在本目录,音频在分发根 → ../audios/<壁纸id>/…
* 全部合集 图片/骨架在本目录,音频在分发根 → ../../audios/<壁纸id>/…
*
* 落点里的 `<壁纸id>/` 一级与 copyCollectionAudio 的分目录一一对应:把不同壁纸的音源分开放,
* 既避免**同名不同曲**的静默冲突(旧的扁平布局会因重名直接报错),也让"这份音频属于谁"一目了然。
* 游戏级共享音频仍在合集根,不进任何壁纸子目录。
*/
export function generatePresetModule(
release: Release,
wallpaper: Wallpaper,
sharedAudio: AudioChoice[],
): string {
const game = wallpaper.game;
if (!game) throw new Error(`内部错误:${wallpaper.srcRel} 没有所属游戏`);
/** 一份音频在该分发里的相对路径(相对 preset.js 所在目录)。 */
const audioPath = (file: string, shared = ""): string => audioPathIn(release, wallpaper, file, shared);
const spineConfig = wallpaper.preset.spineConfig;
const sceneConfig = wallpaper.preset.sceneConfig;
if (spineConfig && sceneConfig) {
throw new Error(`${wallpaper.srcRel}:spineConfig 与 sceneConfig 只能写一个(单骨架 / 场景二选一)`);
}
if (!spineConfig && !sceneConfig) {
throw new Error(`${wallpaper.srcRel}:preset.template.json 里既没有 spineConfig 也没有 sceneConfig`);
}
const lines: string[] = [
`// 由 pnpm build 生成,请勿手工修改。`,
`// 源:${wallpaper.srcRel}/meta.json(音频清单)+ preset.template.json(运行时配置)`,
`//`,
`// 资源路径用 import.meta.url 从本文件位置推导:整个分发目录可以整体搬家而不用改任何一行路径。`,
``,
`const base = new URL("./", import.meta.url);`,
`const asset = (path) => new URL(path, base).href;`,
``,
`export default {`,
` id: ${JSON.stringify(wallpaper.id)},`,
` name: ${JSON.stringify(wallpaper.meta.name)},`,
` game: ${JSON.stringify(game.id)},`,
``,
` backgroundImage: asset(${JSON.stringify(withDotSlash(wallpaper.preset.backgroundImage))}),`,
];
if (spineConfig) {
lines.push(` spineConfig: {`);
for (const [key, value] of Object.entries(spineConfig)) {
const isUrl = key === "jsonUrl" || key === "atlasUrl";
lines.push(
` ${key}: ${isUrl && typeof value === "string" ? `asset(${JSON.stringify(withDotSlash(value))})` : JSON.stringify(value)},`,
);
}
lines.push(` },`);
}
if (sceneConfig) {
lines.push(` sceneConfig: {`);
if (sceneConfig.ui) lines.push(` ui: ${JSON.stringify(sceneConfig.ui)},`);
if (sceneConfig.camera) lines.push(` camera: ${JSON.stringify(sceneConfig.camera)},`);
if (sceneConfig.timelineOffset) lines.push(` timelineOffset: ${JSON.stringify(sceneConfig.timelineOffset)},`);
if (sceneConfig.flipY === false) lines.push(` flipY: false,`);
lines.push(` parts: [`);
for (const part of sceneConfig.parts) {
const fields: string[] = [
`kind: ${JSON.stringify(part.kind)}`,
`id: ${JSON.stringify(part.id)}`,
`order: ${JSON.stringify(part.order ?? 0)}`,
`position: ${JSON.stringify(part.position)}`,
`scale: ${JSON.stringify(part.scale)}`,
];
if (part.renderOrder) fields.push(`renderOrder: ${JSON.stringify(part.renderOrder)}`);
// 三个资源字段都走 asset():与单骨架同一条路径解析规则(相对 preset.js 推导)。
for (const key of ["image", "jsonUrl", "atlasUrl"] as const) {
const value = part[key];
if (typeof value === "string" && value) fields.push(`${key}: asset(${JSON.stringify(withDotSlash(value))})`);
}
if (part.animation) fields.push(`animation: ${JSON.stringify(part.animation)}`);
if (part.skin) fields.push(`skin: ${JSON.stringify(part.skin)}`);
if (part.timeScale !== undefined) fields.push(`timeScale: ${JSON.stringify(part.timeScale)}`);
if (part.width !== undefined) fields.push(`width: ${JSON.stringify(part.width)}`);
if (part.height !== undefined) fields.push(`height: ${JSON.stringify(part.height)}`);
if (part.center !== undefined) fields.push(`center: ${JSON.stringify(part.center)}`);
if (part.rotation !== undefined) fields.push(`rotation: ${JSON.stringify(part.rotation)}`);
if (part.color !== undefined) fields.push(`color: ${JSON.stringify(part.color)}`);
lines.push(` { ${fields.join(", ")} },`);
}
lines.push(` ],`, ` },`);
}
lines.push(``, ` audioChoices: [`);
for (const choice of wallpaper.audioChoices) {
lines.push(
` { id: ${JSON.stringify(choice.id)}, name: ${JSON.stringify(choice.name)}, source: asset(${JSON.stringify(
audioPath(choice.file),
)}) },`,
);
}
for (const choice of sharedAudio) {
lines.push(
` { id: ${JSON.stringify(choice.id)}, name: ${JSON.stringify(choice.name)}, source: asset(${JSON.stringify(
audioPath(choice.file, "shared"),
)}) },`,
);
}
// 默认音源:合集分发里它也随之搬到分发根。
//
// 有一处**刻意的历史不一致**:当前已发布的全部合集里这一行是 asset("./audios/<文件>"),
// 也就是指向壁纸自己目录下并不存在的 audios/。运行时不读它(`index.ts` 用 audioChoices 的
// source 覆盖),所以它一直是死数据。这里改成与 audioChoices 同落点的**正确**路径:
// 死数据不该被继承,且一旦将来有代码读它就立刻是错的。
// 默认音源按 **1 起的位置** 取(meta.json 里不再手写 id):省略 = 第一个。
const defaultChoice = wallpaper.audioChoices[wallpaper.audioDecl.defaultIndex];
// 没有音源时写空串,**不能**写成 `asset("")`:那会解析成 preset.js 自己的 URL,
// 运行时会把它塞进 <audio>.src(实测会去请求一个不存在的音频)。
const defaultSource = defaultChoice ? `asset(${JSON.stringify(withDotSlash(audioPath(defaultChoice.file)))})` : `""`;
lines.push(` ],`, ``, ` audioOptions: { source: ${defaultSource} },`, `};`, ``);
return lines.join("\n");
}
/**
* 生成 scripts/presets.js:预设查表 + 本分发的默认预设 id。
*
* 注意这里生成的是**裸 JS 文本**,编译器管不到它——写错了不会有任何提示,只会在浏览器里
* SyntaxError 白屏。所以格式必须最保守:用 `Object.fromEntries([[id, mod], …])` 这种"数组的数组",
* 别用对象字面量的计算属性名 `[k]: v`(在数组字面量里是非法语法,实测过一次真事故)。
*/
export function generatePresetIndex(release: Release, defaultPresetId: string): string {
const imports: string[] = [];
const entries: string[] = [];
const ordered = [...release.wallpapers].sort((a, b) => {
if (a.id === defaultPresetId) return -1;
if (b.id === defaultPresetId) return 1;
return 0;
});
ordered.forEach((wallpaper, index) => {
const sub = releasePathOf(release, wallpaper);
const local = `preset${index}`;
imports.push(`import ${local} from "../${sub ? sub + "/" : ""}preset.js";`);
entries.push(` [${local}.id, ${local}],`);
});
// 单档分发里 defaultPresetId 必须等于它自己的 id,否则 index.js 会拿表里不存在的 id 去查,整页白屏。
const fallback = ordered.some((w) => w.id === defaultPresetId) ? defaultPresetId : (ordered[0]?.id ?? "");
return [
`// 由 pnpm build 生成,请勿手工修改。`,
`//`,
`// 本分发包含的壁纸:${ordered.map((w) => `${w.id}(${w.meta.name})`).join("、")}`,
`// defaultPresetId 是本分发在 WE 属性缺失时的回落目标。`,
``,
...imports,
``,
`const Presets = Object.fromEntries([`,
...entries,
`]);`,
``,
`export const defaultPresetId = ${JSON.stringify(fallback)};`,
`export default Presets;`,
``,
].join("\n");
}
export interface ProjectJson {
contentrating: string;
description: string;
file: string;
general: { properties: Record<string, unknown> };
preview?: string;
ratingsex: string;
ratingviolence: string;
tags: string[];
title: string;
type: string;
/** WE 的 project.json 里 version 是**数字**(VERSION 文件里是文本,生成时转换)。 */
version: number;
visibility: string;
workshopid?: string;
workshopurl?: string;
}
interface ComboProperty {
options: { label: string; value: string }[];
value: string;
}
/**
* 按固定顺序重建一个属性对象。
*
* 键序不是无所谓的:WE 面板只看字段名,但 project.json 同时是**已发布产物的一部分**,
* 保持与线上逐字节一致的键序,才能让"新旧构建的 diff"只反映真实改动,而不是序列化顺序抖动。
* 顺序取自线上已发布的 project.json。
*/
function orderProperty(source: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const key of ["index", "options", "order", "text", "type", "value"]) {
if (key in source) out[key] = source[key];
}
for (const [key, value] of Object.entries(source)) {
if (!(key in out)) out[key] = value;
}
return out;
}
/**
* 给属性表自动编号:`index` = 0,1,2…,`order` = 100+index,都按**属性表的书写顺序**。
*
* 这两串数字以前是手写在模板里的。代价在加一个属性时才显出来:后面全部要重编号
* (上一轮加 use_custom_audio 手改了 6 处),而且单档分发删掉 preset/bgm 之后会留下空号
* (0,1,4,5,6…)。自动编号同时解决这两件事。
*
* `$order` 是显式钉住:schemecolor 是 WE 的**内置**属性,线上就是 order 0 且**没有** index。
* 钉住的属性不参与 index 序列——这样产物与线上逐字一致,diff 里只剩真实改动。
*/
function numberProperties(properties: Record<string, unknown>): void {
let index = 0;
for (const definition of Object.values(properties)) {
if (definition === null || typeof definition !== "object") continue;
const def = definition as Record<string, unknown>;
const pinned = def.$order;
delete def.$order;
if (typeof pinned === "number") {
def.order = pinned;
delete def.index;
continue;
}
def.index = index;
def.order = 100 + index;
index += 1;
}
}
/** 生成某分发的 project.json。字段顺序对齐已发布作品,便于人工 diff。 */
export function generateProjectJson(release: Release, ctx: GenerateContext): ProjectJson {
const properties: Record<string, unknown> = structuredClone(ctx.template.general.properties) as Record<string, unknown>;
const isCollection = release.wallpapers.length > 1;
// 下拉顺序以**默认预设**开头。这不是美观问题:默认项决定 preset.value,
// 也决定 WE 属性缺失时 show 的是哪一档;线上值必须逐字保持不变。
const ordered = [...release.wallpapers].sort((a, b) => {
if (a.id === ctx.defaultPresetId) return -1;
if (b.id === ctx.defaultPresetId) return 1;
return 0;
});
// bgm:本分发内所有壁纸音源的并集 + 合集根共享音频。运行时只让"属于当前壁纸"的选择生效,
// 其余回落该壁纸默认音源(既有语义,见 .scratch/wallpaper-optimization/issues/05)。
//
// **单档分发也要算它**:一档壁纸照样可以带多首曲子(xilian 就有两首)。
// 以前整块跟着 preset 一起删,结果 single-hsr-xilian 根本没有切换入口。
const bgmOptions: { label: string; value: string }[] = [{ label: "随预设", value: "auto" }];
const seen = new Set<string>(["auto"]);
for (const wallpaper of ordered) {
for (const choice of wallpaper.audioChoices) {
if (seen.has(choice.id)) continue;
seen.add(choice.id);
bgmOptions.push({ label: choice.name, value: choice.id });
}
}
for (const choice of release.sharedAudio) {
if (seen.has(choice.id)) continue;
seen.add(choice.id);
bgmOptions.push({ label: choice.name, value: choice.id });
}
if (isCollection) {
const multiGame = new Set(release.wallpapers.map((w) => w.gameId)).size > 1;
const preset = properties.preset as ComboProperty;
preset.options = ordered.map((wallpaper) => ({
label: multiGame
? `${wallpaper.game?.name ?? wallpaper.gameId} - ${wallpaper.meta.name}`
: wallpaper.meta.name,
value: wallpaper.id,
}));
preset.value = ordered[0]?.id ?? "";
properties.preset = orderProperty(properties.preset as Record<string, unknown>);
} else {
// 单档分发:只剩一档壁纸,"壁纸预设切换"没有意义。整条属性删掉;运行时对缺失属性是安全的。
delete properties.preset;
delete properties.preset_note;
}
// 只剩"随预设"+ 至多一首时这个下拉是句废话(两个选项效果完全一样),删掉。
// 这样 single-hsr-kv37(一首)没有它、single-hsr-xilian(两首)有它。
if (bgmOptions.length <= 2) {
delete properties.bgm;
delete properties.bgm_note;
} else {
(properties.bgm as ComboProperty).options = bgmOptions;
properties.bgm = orderProperty(properties.bgm as Record<string, unknown>);
}
// 编号必须在**删属性之后**:单档分发删掉 preset/bgm 那四行,编号要跟着补上,不留空号。
numberProperties(properties);
// **键序是有意义的**:上传到创意工坊的 project.json 要和线上那份逐字节对齐,
// 否则每次构建都会产生一个"内容相同但文件不同"的 diff,无法判断是真改动还是噪声。
//
// 线上那份的键序是(WE 自己写出来的):
// contentrating, description, file, general, **preview**, ratingsex, ratingviolence,
// tags, title, type, version, visibility, workshopid, workshopurl
// 注意 `preview` 夹在 general 与 ratingsex 之间,而 workshop* 在最后——不是"可选字段统一追加"。
// 先前把三个可选字段都追加到末尾,字段值一个没差、键序却变了;靠逐键比对才发现。
const out: ProjectJson = {
contentrating: ctx.template.contentrating,
description: release.meta.description,
file: ctx.template.file,
general: { properties },
...(release.meta.preview ? { preview: release.meta.preview } : {}),
ratingsex: ctx.template.ratingsex,
ratingviolence: ctx.template.ratingviolence,
tags: ctx.template.tags,
title: release.meta.title,
type: ctx.template.type,
// WE 的 project.json 里 version 是数字;VERSION 文件里是文本,这里必须转回来。
version: Number(ctx.version),
visibility: ctx.template.visibility,
};
// workshopid / workshopurl 只在有值时出现(Q7):没有就整键省略,而不是留空串。
if (release.meta.workshopid) out.workshopid = release.meta.workshopid;
if (release.meta.workshopurl) out.workshopurl = release.meta.workshopurl;
return out;
}
export interface AudioPlacement {
/** 源文件绝对路径。 */
from: string;
/** 落点键:壁纸 id(→ `audios/<键>/<文件>`)或 `""`(→ 合集根 `audios/<文件>`)。 */
key: string;
/** 文件名(保留中文,是产品内容)。 */
name: string;
/** 相对分发根的落点。 */
destRel: string;
}
export interface AudioPlan {
placements: AudioPlacement[];
/** 合集根共享层的重名冲突(扁平一层,重名即歧义)。 */
collisions: string[];
}
/**
* 算出**整个分发**的音频落点。
*
* 这份映射是三件事的唯一真相来源,所以必须是同一个函数:
* ① copyCollectionAudio 按它搬文件;② generatePresetModule 按它拼 preset.js 里的路径;
* ③ `--sim` 的自包含打包按它建内联表。
* 三处各算一遍的症状是"http 下正常、file:// 下白屏"或"静默无声"——最难在开发期发现的那类。
*
* 落点分两层:
* audios/<壁纸id>/<文件> 该壁纸自己的音源(互相隔离,同名不同曲不再冲突)
* audios/<文件> 游戏级共享音频(扁平一层,重名即歧义)
*/
export async function planAudio(release: Release): Promise<AudioPlan> {
const placements: AudioPlacement[] = [];
const collisions: string[] = [];
const seen = new Set<string>();
const push = (from: string, key: string, name: string): void => {
if (seen.has(from)) return;
seen.add(from);
placements.push({ from, key, name, destRel: `audios/${key ? key + "/" : ""}${name}` });
};
const owner = new Map<string, string>();
for (const wallpaper of release.wallpapers) {
const key = release.type === "single" ? "" : wallpaper.id;
for (const choice of wallpaper.audioChoices) {
const name = baseName(choice.file);
push(join(abs(wallpaper.srcRel), "audios", name), key, name);
owner.set(name, wallpaper.srcRel);
}
}
// 游戏级共享音频(wallpapers/<游戏>/audios/):同一首曲子被该游戏多档壁纸共用时放这里。
for (const gameId of [...new Set(release.wallpapers.map((w) => w.gameId))]) {
const shared = abs(`wallpapers/${gameId}/audios`);
if (!(await isDir(shared))) continue;
for (const name of await walkFiles(shared)) {
const previous = owner.get(name);
if (previous !== undefined) {
collisions.push(`${name}:${previous} 与 共享(${gameId})`);
continue;
}
owner.set(name, `共享(${gameId})`);
push(join(shared, name), "", name);
}
}
return { placements, collisions };
}
/** 把一档壁纸的资源铺进分发目录(骨架、贴图、音频、预览图)。
*
* 合集类分发里音频统一放在**分发根的 audios/**(见 copySharedAudio),所以这里跳过 audios/,
* 否则同一首曲子会在分发里出现两份。单档分发没有合集根,音频必须留在壁纸目录内。 */
export async function copyWallpaperAssets(
wallpaper: Wallpaper,
destDir: string,
options: { skipAudio?: boolean } = {},
): Promise<void> {
const from = abs(wallpaper.srcRel);
// 目录名必须与 wallpapers/README.md 的布局一致:一具骨架一组的 `spines/`、
// 场景图与背景的 `scene/`、音频的 `audios/`(这里改一处,所有分发的搬运都跟着走)。
for (const segment of ["spines", "scene", "audios"]) {
if (segment === "audios" && options.skipAudio) continue;
if (await isDir(join(from, segment))) await copyTree(join(from, segment), join(destDir, segment));
}
if (wallpaper.meta.preview && (await isFile(join(from, baseName(wallpaper.meta.preview))))) {
await copyTree(join(from, baseName(wallpaper.meta.preview)), join(destDir, baseName(wallpaper.meta.preview)));
}
}
/**
* 把**整个分发**的音频铺进分发根。落点完全由 planAudio 决定(见它的注释)。
*
* 只有共享层允许重名冲突:那里重名是真的不知道谁是权威,直接报错而不是静默覆盖。
*/
export async function copyCollectionAudio(
release: Release,
outDir: string,
): Promise<{ names: string[]; audioDirs: Map<string, string> }> {
const plan = await planAudio(release);
if (plan.collisions.length > 0) {
throw new Error(
`合集根共享音频文件重名,无法确定权威版本:\n - ${plan.collisions.join("\n - ")}\n` +
`共享层是扁平的一层,重名即歧义。请改名,或把它放进各自壁纸目录(不同壁纸之间不会冲突)。`,
);
}
const audioDirs = new Map<string, string>();
for (const item of plan.placements) {
audioDirs.set(item.name, item.key);
await copyFileTo(item.from, join(outDir, item.destRel));
}
return { names: plan.placements.map((p) => p.name).sort(), audioDirs };
}
export { writeJson, writeText, baseName, withDotSlash };
+309
View File
@@ -0,0 +1,309 @@
// 构建期共享类型:schema 本身即文档。
//
// 三层元数据 + 两层运行时配置的关系:
//
// wallpapers/meta.json ← 「所有壁纸合集」这一档的发布文案
// wallpapers/<gameId>/meta.json ← 游戏显示名 + 该游戏的共享音频
// wallpapers/<gameId>/<wallpaperId>/
// meta.json ← 壁纸的发布文案与音频清单(运行时用不到的)
// preset.template.json ← 壁纸的运行时配置(播放器真正要读的)
//
// build 把后两者合成两份产物:preset.js(运行时)与 project.json(发布)。
import type { ViewportSpec } from "../../src/globals.d.ts";
/** WE 属性的合法类型。build 只生成 combo,其余类型由模板手写。 */
export type PropertyType =
| "bool"
| "combo"
| "slider"
| "textinput"
| "color"
| "file"
| "directory"
| "texture"
| "group"
| "usershortcut"
| "text";
export interface PropertyOption {
label: string;
value: string;
}
export interface PropertyDef {
type: PropertyType;
text?: string;
value?: unknown;
order?: number;
index?: number;
options?: PropertyOption[];
condition?: string;
min?: number;
max?: number;
step?: number;
precision?: number;
fraction?: boolean;
}
/** project.json 的骨架(src/project.template.json)。 */
export interface ProjectTemplate {
contentrating: string;
file: string;
ratingsex: string;
ratingviolence: string;
tags: string[];
type: string;
visibility: string;
general: { properties: Record<string, PropertyDef> };
}
/**
* meta.json 里**手写**的一条音源声明:只有显示名与文件路径。
*
* id 不在这里写——它由 build 自增分配(见 AudioChoice)。以前是手写字符串
* (zaiduheni / xilian / pv37),加一条就得现编一个不重名的 id。
*/
export interface AudioChoiceSpec {
/** 显示名(中文原样保留,是产品的一部分)。 */
name: string;
/** 相对**本壁纸目录**或**合集根**的文件路径(不含 ./ 前缀也可)。 */
file: string;
}
/** meta.json 里的一份音频声明。 */
export interface AudioDecl {
/** 默认音源的位置,**1 起**;省略 = 第一个。 */
default?: number;
choices: AudioChoiceSpec[];
}
/** build 解析后的音源:id 已经分配好,是 WE combo 的 value。 */
export interface AudioChoice {
/**
* 自增 id,**全项目唯一**("1"、"2"、"3"…)。
*
* 为什么必须全局唯一而不是每档壁纸各从 1 开始:`bgm` 下拉会把一个分发里**所有**壁纸的
* 音源平铺进同一个 combo,两档壁纸都叫 "1" 的话,选中的到底是哪一个就无从分辨了。
* 分配顺序固定为 游戏 → (该游戏的共享音频 → 各壁纸的音源),所以同一个音源在任何分发里
* 拿到的 id 都一样。
*/
id: string;
name: string;
file: string;
}
/** 壁纸级元数据(wallpapers/<gameId>/<wallpaperId>/meta.json)。 */
export interface WallpaperMeta {
id: string;
name: string;
title: string;
description: string;
/** 预览图,相对本目录。缺省时 build 省略 project.json 的 preview 并 warning。 */
preview?: string;
/** 该档壁纸可选音源(本壁纸目录内的目录)。 */
audio: AudioDecl;
/** 上传 Workshop 后由 WE 生成;此处缺省即不写字段。 */
workshopid?: string;
workshopurl?: string;
}
/** 游戏级元数据(wallpapers/<gameId>/meta.json)。 */
export interface GameMeta {
id: string;
name: string;
/**
* 游戏合集的创意工坊标题。**必填**:这一层是「游戏合集」文案的唯一来源,
* 不许回落全局 meta——那层是给「全部合集」写的(回落过,见 .scratch/build-pipeline/issues/20)。
*/
title: string;
/** 游戏合集的文案;缺省时 build 用「共 N 档」的模板自动生成。 */
description?: string;
/** 该游戏的共享音频;只在「合集」类分发里被使用。 */
audios: AudioChoiceSpec[];
}
/** 全局元数据(wallpapers/meta.json)= 「所有壁纸合集」这一档。 */
export interface GlobalMeta {
name: string;
title: string;
description: string;
/** 默认预设 id:决定 preset 下拉的第一项与 project.json 的 preset.value。 */
defaultPresetId?: string;
/** 分发级预览图(相对 wallpapers/ 的文件名)。缺省时 project.json 省略 preview。 */
preview?: string;
workshopid?: string;
workshopurl?: string;
}
/** 壁纸级运行时配置(preset.template.json)。只放播放器真正要读的东西。 */
export interface PresetTemplate {
/** 背景图,相对本目录。 */
backgroundImage: string;
/**
* 单骨架路径的配置。与 `sceneConfig` **二选一**:
* 两个都写或都不写,构建期直接报错——不留静默优先级,歧义会烂在产物里。
*/
spineConfig?: {
jsonUrl: string;
atlasUrl: string;
animation?: string;
viewport?: ViewportSpec;
/** "author" = 保留作者原始取景,不跟随背景缩放。 */
framing?: string;
[key: string]: unknown;
};
/**
* 场景路径:N 具骨架 + M 块贴图平面,按抓取期 `scene.json` 的世界变换与绘制层级合成。
* 这些字段由抓取器的 `scene.json` 抄来(构建期烘焙),运行时不再读侧车。
*/
sceneConfig?: {
/** 页面场景的 UI 尺寸,仅作退化取景的兜底。 */
ui?: number[];
/**
* 页面相机(抓取期从场景树抄来)。
* `type: 1` = 透视:按 fov 与相机 z 做 billboard 投影(z 是真实景深,不能忽略);
* `type: 2` = 正交:各 part 直接按原坐标画(z 全为 0 的那些页面)。
*/
camera?: { type?: number; fov?: number; position?: number[] };
/** 页面是 y 向上、spine 渲染是 y 向下:默认把 y 取反;实测颠倒时设 false。 */
flipY?: boolean;
/** 时间线的整场位移(页面单位),从真页面量出来;见 .scratch/scene-player/perspective.md。 */
timelineOffset?: number[];
parts: {
kind: "spine" | "image" | "solid";
id: string;
order: number;
renderOrder?: number;
position: number[];
scale: number[];
image?: string;
jsonUrl?: string;
atlasUrl?: string;
animation?: string;
width?: number;
height?: number;
/** 网格中心相对节点原点的偏移(页面坐标系);四边形不以原点为中心时用它。 */
center?: number[];
/** 节点旋转(弧度)。非零 = 被倾斜的 3D 面片,运行时当前**不画**它。 */
rotation?: number[];
/** 页面指定的皮肤(`spine.skin`);`animation` 字段本来就在上面。 */
skin?: string;
timeScale?: number;
color?: number;
}[];
};
[key: string]: unknown;
}
/** 内存里的一份壁纸(已读盘、已校验)。 */
export interface Wallpaper {
id: string;
gameId: string;
/** 相对仓库根的源目录,如 wallpapers/hsr/xilian。 */
srcRel: string;
meta: WallpaperMeta;
preset: PresetTemplate;
game: GameMeta;
/**
* meta.json 里的原始音频声明(id 还没分配)。
* `defaultIndex` 已经是 **0 起**的数组下标,把"1 起"的换算收在 vault 里一处。
*/
audioDecl: { defaultIndex: number; choices: AudioChoiceSpec[] };
/** 该档壁纸的音频清单:id 已由 build 分配,文件路径已解析成相对**分发内壁纸目录**的形式。 */
audioChoices: AudioChoice[];
}
/** 内存里的一份游戏。 */
export interface Game {
id: string;
meta: GameMeta;
/** 相对仓库根的源目录,如 wallpapers/hsr。 */
srcRel: string;
wallpapers: Wallpaper[];
/** meta.audios 分配 id 之后的结果(读盘时还没有 id,见 readVault)。 */
sharedAudio: AudioChoice[];
}
/**
* 分发类型:**单档**与**合集**两种并列。
*
* 「游戏合集」与「全部合集」都是合集,区别只在 `CollectionScope`——它们不是第三种类型。
* (术语见 CONTEXT.md:单档 / 合集。)
*/
export type ReleaseType = "single" | "collection";
/** 合集的收档范围:一个游戏,还是所有游戏。只有 `type === "collection"` 时有意义。 */
export type CollectionScope = "game" | "all";
/** 一次构建里的一个分发目标。 */
export interface Release {
type: ReleaseType;
/** 合集的收档范围(单档分发没有它)。 */
scope?: CollectionScope;
/** 稳定的分发 id:单档用壁纸 id,游戏合集用游戏 id,全部合集用 "all"。 */
id: string;
/** 目录名(ASCII,带类型前缀,避免"合集"与"单档"混淆)。 */
dir: string;
/** 该分发包含的壁纸(single 恰含一个)。 */
wallpapers: Wallpaper[];
/** 展示名,写进 dist-map.json 供 dev 索引页使用。 */
displayName: string;
/** 该分发的发布文案(单档分发直接取壁纸自己的 meta)。 */
meta: { title: string; description: string; preview?: string; workshopid?: string; workshopurl?: string };
/** 上面那份 meta 所在的目录(相对仓库根):preview 指向的文件从它旁边取。 */
metaRel: string;
/**
* 该分发"合集根共享音频"清单(相对合集根的文件名)。
* 单档分发恒为空——非合集分发不出现共享音频。
*/
sharedAudio: AudioChoice[];
}
/** dist-map.json:分发清单,也是 dev 服与校验脚本的唯一索引。 */
export interface DistMap {
version: string;
builtAt: string;
releases: {
dir: string;
type: ReleaseType;
/** 合集的收档范围(单档分发省略)。 */
scope?: CollectionScope;
id: string;
displayName: string;
/** 该分发里每档壁纸的 id 与其在目录内的相对路径。 */
wallpapers: { id: string; gameId: string; dir: string; name: string }[];
/** 该分发的默认预设 id(写进 scripts/presets.js)。 */
defaultPresetId: string;
/** 发布产物字节数(**不含** sim/)。 */
bytes: number;
/** 自包含包 sim/ 的字节数;没跑 --sim 时不存在。 */
simBytes?: number;
}[];
}
export interface BuildOptions {
singles: string[];
collect: "all" | string[] | null;
/**
* `pnpm build single`(不给 id)= 只编全部单档,不编合集。
*
* 与"什么都不选"(= 全编)必须区分开:单档子命令的语义是"范围=单档",
* 而不是"没指定所以全都要"。用一个显式标记而不是靠 singles 为空来判断。
*/
onlySingles?: boolean;
/**
* `pnpm build collect`(不给参数)= 全部合集:每个游戏的合集 + 「全部壁纸合集」。
*
* 与 `collect: "all"` 区分开:后者是 `--collect all`,只编「全部壁纸合集」一项。
* 这两个语义确实容易混,但都是既有行为,改动其中一个会悄悄改变发布范围。
*/
allCollections?: boolean;
strict: boolean;
clean: boolean;
dryRun: boolean;
/** 额外产出双击可看的自包含包(<分发根>/sim/index.html)。 */
standalone?: boolean;
/** 自包含包里是否内联音频。 */
embedAudio?: boolean;
}
+252
View File
@@ -0,0 +1,252 @@
// 把 wallpapers/ 读成内存里的领域对象,并在读的过程中把所有不变量校验掉。
//
// 校验失败一律 fail-fast:宁可 build 报错,也不要产出一个"能上传但跑不对"的分发目录。
import { join } from "node:path";
import { abs, isFile, listDirs, readJson } from "./fs.ts";
import type {
AudioChoice,
AudioChoiceSpec,
Game,
GameMeta,
GlobalMeta,
Wallpaper,
WallpaperMeta,
PresetTemplate,
} from "./types.ts";
/** CEF(WE 内置的 Chromium 146)实际能解的音频扩展名白名单。
* 实测其 FFmpeg 构建含 vorbis/libopus/flac/mp3/pcm_*,**不含 aac**,所以 m4a 直接拒。 */
const AUDIO_EXT = new Set([".flac", ".mp3", ".ogg", ".opus", ".wav"]);
/** 游戏目录与壁纸目录里的保留名:不允许被当作 id 使用。 */
const RESERVED = new Set(["audios", "meta.json", "preview.gif", "preset.template.json"]);
export class VaultError extends Error {}
function fail(message: string): never {
throw new VaultError(message);
}
function assert(condition: unknown, message: string): asserts condition {
if (!condition) fail(message);
}
/** 音频文件必须是白名单内的扩展名,且不许出现在上级目录(分发时会被搬走)。 */
function checkAudioFile(file: string, where: string): string {
const normalized = file.replace(/^\.\//, "");
assert(!normalized.startsWith("../"), `${where}: 音频 ${file} 不得引用上级目录(build 会把它放到分发目录内部)`);
assert(!normalized.includes("\\"), `${where}: 音频路径 ${file} 必须用 / 分隔`);
const dot = normalized.lastIndexOf(".");
assert(dot > 0, `${where}: 音频 ${file} 没有扩展名`);
const ext = normalized.slice(dot).toLowerCase();
assert(
AUDIO_EXT.has(ext),
`${where}: 音频 ${file} 的扩展名 ${ext} 不在 CEF 白名单内(${[...AUDIO_EXT].join(" ")});m4a/aac 在 WE 里解不了`,
);
return normalized;
}
/**
* 读一份音频声明:只校验显示名与文件,**不校验 id**——id 已经不由人写。
* 也接受裸数组(游戏级共享音频就是数组)。
*
* `default` 是 **1 起**的位置,这里换算成 0 起的 `defaultIndex`:
* 换算只做这一处,下游拿到的永远是能直接下标的东西。
*/
function readAudioDecl(
audio: { default?: number; choices?: AudioChoiceSpec[] } | AudioChoiceSpec[] | undefined,
where: string,
): { defaultIndex: number; choices: AudioChoiceSpec[] } {
const normalized = Array.isArray(audio) ? { choices: audio } : audio;
if (!normalized) fail(`${where}: 缺少 audio 声明`);
assert(Array.isArray(normalized.choices), `${where}: audio.choices 必须是数组`);
const choices: AudioChoiceSpec[] = normalized.choices.map((choice, i) => {
assert(typeof choice.name === "string" && choice.name.length > 0, `${where}: 第 ${i + 1} 条音源缺少显示名`);
return { name: choice.name, file: checkAudioFile(choice.file, where) };
});
let defaultIndex = 0;
if (normalized.default !== undefined) {
assert(Number.isInteger(normalized.default), `${where}: audio.default 必须是整数位置(1 起)`);
assert(
normalized.default >= 1 && normalized.default <= choices.length,
`${where}: audio.default ${normalized.default} 超出范围(共 ${choices.length} 条,位置从 1 起)`,
);
defaultIndex = normalized.default - 1;
}
return { defaultIndex, choices };
}
/**
* 音源 id 的分配器。
*
* **必须是全项目一个计数器**,不能每档壁纸各从 1 开始:`bgm` 下拉会把一个分发里所有壁纸的
* 音源平铺进同一个 combo,两档都叫 "1" 的话选中的到底是哪个就无从分辨。
* 分配顺序固定(游戏 → 该游戏的共享音频 → 各壁纸的音源),所以同一个音源在任何分发里
* 拿到的 id 都一样。
*/
function createAudioIdAllocator(): () => string {
let next = 0;
return () => String((next += 1));
}
/** 读一份壁纸(含其所属游戏与全局元数据)。 */
async function readWallpaper(gameId: string, wallpaperId: string, game: GameMeta): Promise<Wallpaper> {
const srcRel = `wallpapers/${gameId}/${wallpaperId}`;
const srcAbs = abs(srcRel);
const where = srcRel;
const metaPath = join(srcAbs, "meta.json");
const presetPath = join(srcAbs, "preset.template.json");
assert(await isFile(metaPath), `${where}: 缺少 meta.json`);
assert(await isFile(presetPath), `${where}: 缺少 preset.template.json`);
const meta = await readJson<WallpaperMeta>(metaPath);
const preset = await readJson<PresetTemplate>(presetPath);
assert(meta.id === wallpaperId, `${where}: meta.json 的 id "${meta.id}" 与目录名 "${wallpaperId}" 不一致`);
assert(!RESERVED.has(meta.id), `${where}: id "${meta.id}" 是保留名`);
assert(/^[a-z0-9][a-z0-9_-]*$/i.test(meta.id), `${where}: id "${meta.id}" 只能用字母数字与 _-(它会进 WE 的 combo value)`);
assert(typeof meta.name === "string" && meta.name.length > 0, `${where}: 缺少显示名 name`);
assert(typeof meta.title === "string" && meta.title.length > 0, `${where}: 缺少 title(会进 project.json)`);
assert(typeof meta.description === "string" && meta.description.length > 0, `${where}: 缺少 description`);
if (meta.preview !== undefined) {
assert(await isFile(join(srcAbs, meta.preview)), `${where}: preview "${meta.preview}" 不存在`);
}
const audioDecl = readAudioDecl(meta.audio, where);
for (const choice of audioDecl.choices) {
assert(await isFile(join(srcAbs, choice.file)), `${where}: 音源 "${choice.name}" 指向的 ${choice.file} 不存在`);
}
// 运行时配置里的资源路径也要存在——这是"运行时才会暴露的拼写错误"的唯一静态防线。
const spineConfig = preset.spineConfig;
const sceneConfig = preset.sceneConfig;
assert(
Boolean(spineConfig) !== Boolean(sceneConfig),
`${where}: preset.template.json 必须**二选一**地写 spineConfig(单骨架)或 sceneConfig(场景)`,
);
const resourcePaths: [string, string | undefined][] = [["backgroundImage", preset.backgroundImage]];
if (spineConfig) {
resourcePaths.push(["spineConfig.jsonUrl", spineConfig.jsonUrl], ["spineConfig.atlasUrl", spineConfig.atlasUrl]);
}
if (sceneConfig) {
assert(Array.isArray(sceneConfig.parts) && sceneConfig.parts.length > 0, `${where}: sceneConfig.parts 不能为空`);
sceneConfig.parts.forEach((part, index) => {
const at = `sceneConfig.parts[${index}](${part.kind} ${part.id})`;
assert(
part.kind === "spine" || part.kind === "image" || part.kind === "solid",
`${where}: ${at} 的 kind 只能是 spine/image/solid`,
);
assert(typeof part.order === "number", `${where}: ${at} 缺少 order(绘制层级)`);
if (part.kind === "spine") {
resourcePaths.push([`${at}.jsonUrl`, part.jsonUrl], [`${at}.atlasUrl`, part.atlasUrl]);
} else if (part.kind === "image") {
resourcePaths.push([`${at}.image`, part.image]);
}
});
}
for (const [key, value] of resourcePaths) {
assert(typeof value === "string" && value.length > 0, `${where}: preset.template.json 的 ${key} 缺失`);
const relPath = value.replace(/^\.\//, "");
assert(await isFile(join(srcAbs, relPath)), `${where}: preset.template.json 的 ${key} 指向的 ${relPath} 不存在`);
}
// audioChoices 先留空:id 要等所有游戏都读完才能按固定顺序分配(见 readVault)。
return { id: wallpaperId, gameId, srcRel, meta, preset, game, audioDecl, audioChoices: [] };
}
/** 读一个游戏目录。 */
async function readGame(gameId: string): Promise<Game> {
const srcRel = `wallpapers/${gameId}`;
const meta = await readJson<GameMeta>(join(abs(srcRel), "meta.json"));
assert(meta.id === gameId, `${srcRel}: meta.json 的 id "${meta.id}" 与目录名 "${gameId}" 不一致`);
assert(/^[a-z0-9][a-z0-9_-]*$/i.test(meta.id), `${srcRel}: 游戏 id 只能用字母数字与 _-`);
assert(typeof meta.name === "string" && meta.name.length > 0, `${srcRel}: 缺少显示名 name`);
// 游戏合集的文案就来自这一层,**不回落全局 meta**——全局那层是给「全部合集」写的,回落正是
// collection-ys 顶着《崩坏:星穹铁道》昔涟的原因(见 .scratch/build-pipeline/issues/20)。
assert(typeof meta.title === "string" && meta.title.length > 0, `${srcRel}: 缺少 title(游戏合集的创意工坊标题)`);
assert(
meta.description === undefined || (typeof meta.description === "string" && meta.description.length > 0),
`${srcRel}: description 要么不写,要么非空(缺省时由构建按「共 N 档」生成)`,
);
// 游戏级共享音频:只在「合集」类分发里被使用,允许为空(本仓库当前就是空的)。
readAudioDecl(meta.audios ?? [], `${srcRel} 的共享音频`);
const wallpaperIds = await listDirs(abs(srcRel), ["audios"]);
assert(wallpaperIds.length > 0, `${srcRel}: 没有任何壁纸目录`);
const wallpapers = [];
for (const id of wallpaperIds) wallpapers.push(await readWallpaper(gameId, id, meta));
// sharedAudio 同样等 readVault 分配 id。
return { id: gameId, meta, srcRel, wallpapers, sharedAudio: [] };
}
export interface Vault {
global: GlobalMeta;
games: Game[];
/** 所有壁纸,按 游戏 → 壁纸 的声明顺序。 */
wallpapers: Wallpaper[];
}
/** 读取整棵资源库,并校验全局唯一性。 */
export async function readVault(): Promise<Vault> {
const global = await readJson<GlobalMeta>(abs("wallpapers/meta.json"));
assert(typeof global.name === "string" && global.name.length > 0, "wallpapers/meta.json: 缺少 name");
assert(typeof global.title === "string" && global.title.length > 0, "wallpapers/meta.json: 缺少 title");
assert(typeof global.description === "string" && global.description.length > 0, "wallpapers/meta.json: 缺少 description");
const gameIds = await listDirs(abs("wallpapers"), ["audios", "meta.json", "preview.gif"]);
assert(gameIds.length > 0, "wallpapers/: 没有任何游戏目录");
const games: Game[] = [];
for (const id of gameIds) games.push(await readGame(id));
// 全局唯一性:WE 的 combo value 是平铺的,跨游戏撞 id 会让后一档静默覆盖前一档。
const byId = new Map<string, string>();
const wallpapers: Wallpaper[] = [];
for (const game of games) {
for (const wallpaper of game.wallpapers) {
const previous = byId.get(wallpaper.id);
assert(
previous === undefined,
`壁纸 id "${wallpaper.id}" 在 ${previous} 与 ${wallpaper.srcRel} 中重复。` +
`id 必须是全局唯一的 WE combo value(老用户设置靠它,不能自动加前缀绕过),请改其中一个。`,
);
byId.set(wallpaper.id, wallpaper.srcRel);
wallpapers.push(wallpaper);
}
}
// 到这里所有游戏都读完了,再统一分配音源 id。
//
// 顺序固定为「游戏 → 该游戏的共享音频 → 各壁纸的音源」,且遍历的是**整棵资源库**
// 而不是本次要构建的分发子集——否则同一个音源在不同分发里会拿到不同的 id,
// 用户从合集切到单档时 bgm 选择就失效了。
const nextAudioId = createAudioIdAllocator();
for (const game of games) {
game.sharedAudio = (game.meta.audios ?? []).map((choice) => ({
id: nextAudioId(),
name: choice.name,
file: checkAudioFile(choice.file, `${game.srcRel} (共享音频)`),
}));
for (const wallpaper of game.wallpapers) {
wallpaper.audioChoices = wallpaper.audioDecl.choices.map((choice) => ({
id: nextAudioId(),
name: choice.name,
file: choice.file,
}));
}
}
return { global, games, wallpapers };
}
/** 该游戏在合集根共享音频里声明的音源(id 已在 readVault 里分配)。 */
export function sharedAudioOf(game: Game): AudioChoice[] {
return game.sharedAudio;
}