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:
1 parent
b8eee05d78
commit
3f11426964
297 files changed
+216627
-1926
No files matched your search
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user