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

+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>`;