把项目从「手写 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 处引用自包含。
154 lines
7.8 KiB
TypeScript
154 lines
7.8 KiB
TypeScript
// 验收 `pnpm build --sim`:把产出的自包含包用 file:// 直接打开,确认
|
||
// ① 骨架真的画出来了(canvas 出现且有非空像素)
|
||
// ② 0 网络请求失败、0 异常、0 控制台报错
|
||
// ③ 模拟器面板在,且明确标着"模拟环境"
|
||
// ④ 音频能播(data: URL 内联)
|
||
// ⑤ 全程没有任何 http(s) 请求("自包含"的硬定义)
|
||
//
|
||
// 用法:node tools/checks/verify-sim-page.mts [分发目录名]
|
||
import { withPage, sleep } from "../cdp.mjs";
|
||
import { existsSync, statSync } from "node:fs";
|
||
import { resolve } from "node:path";
|
||
|
||
const dirName = process.argv[2] ?? "single-hsr-kv37";
|
||
const pageAbs = resolve("dist/releases", dirName, "sim/index.html");
|
||
if (!existsSync(pageAbs)) {
|
||
console.error(`找不到 ${pageAbs}(先跑 node tools/build.ts --single kv37 --sim)`);
|
||
process.exit(1);
|
||
}
|
||
const fileUrl = "file:///" + pageAbs.replace(/\\/g, "/");
|
||
|
||
let failures = 0;
|
||
const check = (ok: unknown, label: string, detail?: unknown) => {
|
||
if (!ok) failures++;
|
||
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
|
||
};
|
||
|
||
console.log(`自包含包:${pageAbs}`);
|
||
console.log(`体积:${(statSync(pageAbs).size / 1024 / 1024).toFixed(1)} MB\n`);
|
||
|
||
await withPage({ width: 1920, height: 1080 }, async ({ send, evaluate, consoleLines, exceptions }) => {
|
||
const rawSend = send;
|
||
|
||
await rawSend("Page.navigate", { url: fileUrl });
|
||
const deadline = Date.now() + 45000;
|
||
let ready = false;
|
||
while (Date.now() < deadline) {
|
||
const canvasCount = await evaluate('document.querySelectorAll("#spine-container canvas").length');
|
||
if (canvasCount > 0) {
|
||
ready = true;
|
||
break;
|
||
}
|
||
await sleep(250);
|
||
}
|
||
await sleep(2500);
|
||
|
||
// ⑤ "自包含"的硬定义:这个文档取过的资源里不能有任何 http(s)。
|
||
// 用页面侧的 Resource Timing,不需要 CDP 的事件通道。
|
||
const externalRequests = await evaluate(`(function () {
|
||
return performance.getEntriesByType("resource")
|
||
.map(function (e) { return e.name; })
|
||
.filter(function (n) { return /^https?:/i.test(n); });
|
||
})()`);
|
||
check(
|
||
Array.isArray(externalRequests) && externalRequests.length === 0,
|
||
"全程没有任何 http(s) 请求(自包含的硬定义)",
|
||
Array.isArray(externalRequests) && externalRequests.length > 0 ? externalRequests.slice(0, 3).join(", ") : "无",
|
||
);
|
||
|
||
const state = await evaluate(`(function () {
|
||
var canvas = document.querySelector("#spine-container canvas");
|
||
var out = {
|
||
canvas: document.querySelectorAll("#spine-container canvas").length,
|
||
canvasSize: canvas ? canvas.clientWidth + "x" + canvas.clientHeight : null,
|
||
bg: (document.body.style.backgroundImage || "").slice(0, 24),
|
||
bgIsData: /^url\\(["']?data:/.test(document.body.style.backgroundImage || ""),
|
||
sim: !!window.__weSim,
|
||
banner: document.getElementById("wesim") ? document.querySelector("#wesim .banner").textContent.trim() : null,
|
||
// 自包含页在 <分发根>/sim/ 下,预览图必须已被内联成 data: URL——
|
||
// file:// 下 ../preview.gif 是跨目录,读不到。
|
||
previewSrc: (function () { var i = document.querySelector("#wesim .preview img"); return i ? i.getAttribute("src").slice(0, 5) : null; })(),
|
||
previewNatural: (function () { var i = document.querySelector("#wesim .preview img"); return i ? i.naturalWidth + "x" + i.naturalHeight : null; })(),
|
||
modules: window.__weModules ? Object.keys(window.__weModules).length : 0,
|
||
assetKeys: window.__simAssets ? Object.keys(window.__simAssets).length : 0,
|
||
audioSrcIsData: false,
|
||
audioDuration: null
|
||
};
|
||
var a = document.getElementById("background-music");
|
||
if (a) {
|
||
out.audioSrcIsData = /^data:/.test(a.src || "");
|
||
out.audioDuration = isFinite(a.duration) ? a.duration : null;
|
||
out.audioError = a.error ? a.error.code : null;
|
||
}
|
||
return out;
|
||
})()`);
|
||
|
||
console.log("=== 渲染 ===");
|
||
check(ready, "出现 Spine 画布", `canvas=${state.canvas}`);
|
||
check(state.canvas === 1, "只有一张 canvas", `canvas=${state.canvas}`);
|
||
check(state.canvasSize === "1920x1080", "画布尺寸等于窗口", state.canvasSize);
|
||
|
||
// 画布真的画了东西:跨帧采样非透明比例。
|
||
//
|
||
// 两个坑叠在一起,先前两次都判成了"空白":
|
||
// ① 页面里有两张 canvas——spine 自己建的那张(承载渲染)和一张 300×150 的默认尺寸空画布。
|
||
// 按 `#spine-container canvas` 取到的是后者,尺寸就不对。
|
||
// ② 播放器用 `preserveDrawingBuffer: false`(默认),**帧外**读像素拿到的永远是已清空的缓冲。
|
||
// 只读一次必然是 0%,与画没画无关。必须在 rAF 里连续采样、取历史最大值。
|
||
await evaluate(`(function () {
|
||
var list = [].slice.call(document.querySelectorAll("#spine-container canvas"));
|
||
var canvas = list.sort(function (a, b) { return b.clientWidth * b.clientHeight - a.clientWidth * a.clientHeight; })[0];
|
||
if (!canvas) { window.__px = { error: "没有 canvas" }; return; }
|
||
var gl = canvas.getContext("webgl2") || canvas.getContext("webgl");
|
||
if (!gl) { window.__px = { error: "拿不到 WebGL 上下文" }; return; }
|
||
window.__px = { best: 0, frames: 0, w: canvas.width, h: canvas.height, count: list.length };
|
||
var buf = new Uint8Array(canvas.width * canvas.height * 4);
|
||
var tick = function () {
|
||
gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, buf);
|
||
var n = 0;
|
||
for (var i = 3; i < buf.length; i += 4) if (buf[i] > 8) n++;
|
||
var ratio = n / (canvas.width * canvas.height);
|
||
if (ratio > window.__px.best) window.__px.best = ratio;
|
||
window.__px.frames++;
|
||
if (window.__px.frames < 90) requestAnimationFrame(tick);
|
||
};
|
||
requestAnimationFrame(tick);
|
||
})()`);
|
||
await sleep(2500);
|
||
const pixels = await evaluate("window.__px");
|
||
check(
|
||
pixels !== null && pixels.best !== undefined && pixels.best > 0.02,
|
||
"画布上有实际像素(不是空白)",
|
||
pixels === null
|
||
? "没有 canvas"
|
||
: pixels.error
|
||
? pixels.error
|
||
: `非透明占比 ${((pixels.best ?? 0) * 100).toFixed(1)}%(${pixels.w}x${pixels.h},采样 ${pixels.frames} 帧)`,
|
||
);
|
||
|
||
console.log("\n=== 自包含性 ===");
|
||
check(state.bgIsData, "背景图是 data: URL(没有外部文件依赖)", state.bg);
|
||
check(state.assetKeys > 0, "内联资源表已建立", `${state.assetKeys} 个 key`);
|
||
check(state.modules >= 9, "模块注册表已建立", `${state.modules} 个模块`);
|
||
check(state.audioSrcIsData, "音频是 data: URL(内联)");
|
||
check(state.audioDuration !== null && state.audioDuration > 60, "音频可解码", state.audioDuration ? `${state.audioDuration.toFixed(0)}s` : `error=${state.audioError}`);
|
||
check(pageAbs.length > 0 && !/^https?:/.test(state.bg), "没有指向 http(s) 的资源");
|
||
|
||
console.log("\n=== 模拟器 ===");
|
||
check(state.sim, "window.__weSim 存在");
|
||
check(/模拟环境/.test(state.banner ?? ""), "面板标着「模拟环境」", state.banner);
|
||
// 自包含包里的预览图:必须是内联的 data: URL,而且要真的解码出来(坏图也会是 data:)。
|
||
if (state.previewSrc !== null) {
|
||
check(state.previewSrc === "data:", "预览图已内联成 data: URL", state.previewSrc);
|
||
check(state.previewNatural === "160x160", "内联的预览图能解码", state.previewNatural);
|
||
}
|
||
|
||
console.log("\n=== 干净度 ===");
|
||
check(exceptions.length === 0, "0 未捕获异常", exceptions.slice(0, 3).join(" | ") || "无");
|
||
const noisy = consoleLines.filter((l) => /error|failed|blocked|CORS/i.test(l));
|
||
check(noisy.length === 0, "0 控制台报错", noisy.slice(0, 3).join(" | ") || "干净");
|
||
});
|
||
|
||
console.log(failures === 0 ? "\n自包含包可用:双击即可打开,无需 pnpm dev,也无需任何 http 服务" : `\n自包含包验收失败:${failures} 处`);
|
||
process.exit(failures === 0 ? 0 : 1);
|