Files
Shuery 3f11426964 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 处引用自包含。
2026-10-02 01:27:02 +08:00

821 lines
42 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// `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 };
}