Files
SpineWallpaper/tools/lib/drivers.ts
T
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

190 lines
8.6 KiB
TypeScript
Raw 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.
// 调试服注入到 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>`;