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

+270
View File
@@ -0,0 +1,270 @@
// 预览 `tools/downloader/_out/` 里的 staging:把一个**场景目录**组装成能跑的页面并起服务。
//
// 为什么需要它:`_cache/` 只是**文本**缓存(原始 bundle 文本,引用仍是线上绝对地址、贴图不在里面),
// 不可预览;`_out/` 是真实文件树,但 `serve.mjs` 只会按 URL 逐个取文件——没有索引、也不会画场景。
//
// staging 的形状是「一个场景 = 一个可直接搬走的壁纸目录」(见 tools/downloader/README.md),
// 所以预览的粒度也是场景,不是页面。
//
// 用法:
// node tools/preview.mts # 列出 _out 里有哪些场景可预览
// node tools/preview.mts ys/nico-tea # 页面 = 取它的推荐场景
// node tools/preview.mts ys/nico-tea/scene_main # 点名场景
// node tools/preview.mts hsr/kv45/scene_ava --port 8199
// node tools/preview.mts ys/nico-tea --no-serve # 只组装到 tools/.cache/preview/
//
// 与 tools/checks/verify-scene-player.mts 的关系:那边也有一份「从 scene.json 生成预设」的代码,
// **故意不共用**——门必须独立于被验证对象,共用一份就变成"自己验自己"。
import { createServer } from "node:http";
import { createReadStream, existsSync } from "node:fs";
import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import { spawnSync } from "node:child_process";
import { dirname, extname, join, normalize, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const OUT = join(ROOT, "tools", "downloader", "_out");
const PREVIEW = join(ROOT, "tools", ".cache", "preview");
const args = process.argv.slice(2);
const portFlag = args.indexOf("--port");
const port = portFlag >= 0 ? Number(args[portFlag + 1]) : 8196;
const noServe = args.includes("--no-serve");
const target = args.find((a) => !a.startsWith("--") && !/^\d+$/.test(a));
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".atlas": "text/plain; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".webp": "image/webp",
};
interface Part {
kind: string;
id: string;
order: number;
renderOrder?: number;
position: number[];
scale: number[];
rotation?: number[];
geometrySize?: number[];
geometryCenter?: number[];
animation?: string;
skin?: string;
timeScale?: number;
}
/** 场景目录的判据:有 `preset.template.json`(目录名是转义过的场景 id,不能靠名字认)。 */
const isSceneDir = (dir: string): boolean => existsSync(join(dir, "preset.template.json"));
/** 列出 _out 下所有「游戏/页面/场景」。 */
async function listScenes(): Promise<string[]> {
if (!existsSync(OUT)) return [];
const out: string[] = [];
for (const game of await readdir(OUT, { withFileTypes: true })) {
if (!game.isDirectory()) continue;
for (const page of await readdir(join(OUT, game.name), { withFileTypes: true })) {
if (!page.isDirectory()) continue;
for (const scene of await readdir(join(OUT, game.name, page.name), { withFileTypes: true })) {
if (!scene.isDirectory()) continue;
if (isSceneDir(join(OUT, game.name, page.name, scene.name))) {
out.push(`${game.name}/${page.name}/${scene.name}`);
}
}
}
}
return out.sort();
}
/** 把「游戏/页面」或「游戏/页面/场景」解析成具体的场景目录路径。 */
async function resolveScene(rel: string): Promise<string | null> {
const parts = rel.split("/").filter(Boolean);
if (parts.length >= 3) {
const dir = join(OUT, ...parts);
return isSceneDir(dir) ? dir : null;
}
if (parts.length !== 2) return null;
const pageDir = join(OUT, ...parts);
if (!existsSync(pageDir)) return null;
const scenes = (await readdir(pageDir, { withFileTypes: true }))
.filter((entry) => entry.isDirectory() && isSceneDir(join(pageDir, entry.name)))
.map((entry) => entry.name)
.sort();
if (scenes.length === 0) return null;
let chosen = scenes[0]!;
const pageFile = join(pageDir, "page.json");
if (existsSync(pageFile)) {
const page = JSON.parse(await readFile(pageFile, "utf8")) as { chosenDir?: string; chosenScene?: string };
const wanted = page.chosenDir ?? page.chosenScene;
if (wanted && scenes.includes(wanted)) chosen = wanted;
}
return join(pageDir, chosen);
}
if (!target) {
const scenes = await listScenes();
console.log(`_out 下有 ${scenes.length} 个场景可预览:\n`);
for (const s of scenes) console.log(` node tools/preview.mts ${s}`);
console.log(`\n(「游戏/页面」会取该页的推荐场景;_cache/ 是文本调试缓存,不可预览)`);
process.exit(0);
}
const staged = await resolveScene(target);
if (staged === null) {
console.error(`没有这个场景目录:${target}(用 node tools/preview.mts 看有哪些)`);
process.exit(2);
}
const rel = staged.slice(OUT.length + 1).replace(/\\/g, "/");
// 1) 运行时要有产物:缺了就编一次
if (!existsSync(join(ROOT, "build", "scripts", "scene-controller.js"))) {
console.log("build/scripts/scene-controller.js 不存在,先编译运行时…");
const build = spawnSync(process.execPath, ["node_modules/typescript/bin/tsc", "-p", "tsconfig.runtime.json"], {
cwd: ROOT,
encoding: "utf8",
});
if (build.status !== 0) {
console.error("编译失败:", (build.stdout ?? "") + (build.stderr ?? ""));
process.exit(1);
}
}
// 2) 组装:运行时脚本 + 样式 + staging 资产 + 由 scene.json 生成的预设
const dir = join(PREVIEW, rel.replace(/\//g, "__"));
await rm(dir, { recursive: true, force: true });
await mkdir(join(dir, "scripts"), { recursive: true });
await mkdir(join(dir, "styles"), { recursive: true });
await cp(staged, join(dir, "assets"), { recursive: true });
for (const f of await readdir(join(ROOT, "build", "scripts"))) {
if (f.endsWith(".js")) await cp(join(ROOT, "build", "scripts", f), join(dir, "scripts", f));
}
await cp(join(ROOT, "src", "vendor", "spine-player.js"), join(dir, "scripts", "spine-player.js"));
for (const f of await readdir(join(ROOT, "src", "styles"))) {
await cp(join(ROOT, "src", "styles", f), join(dir, "styles", f));
}
const scene = JSON.parse(await readFile(join(staged, "scene.json"), "utf8")) as {
id: string;
ui?: number[];
camera?: { camera?: { type?: number; fov?: number }; position?: number[] };
parts: Part[];
};
const parts: string[] = [];
for (const part of scene.parts) {
if (part.kind === "solid") continue; // 运行时画不了纯色平面(没有贴图),别写进预设
const fields: string[] = [
`kind: ${JSON.stringify(part.kind)}`,
`id: ${JSON.stringify(part.id)}`,
`order: ${JSON.stringify(part.order)}`,
`position: ${JSON.stringify(part.position)}`,
`scale: ${JSON.stringify(part.scale)}`,
];
if (part.renderOrder) fields.push(`renderOrder: ${JSON.stringify(part.renderOrder)}`);
if (part.rotation && part.rotation.some((v) => Math.abs(v) > 1e-9)) {
fields.push(`rotation: ${JSON.stringify(part.rotation)}`);
}
if (part.geometrySize) {
fields.push(`width: ${JSON.stringify(part.geometrySize[0])}`);
fields.push(`height: ${JSON.stringify(part.geometrySize[1])}`);
}
if (part.geometryCenter) fields.push(`center: ${JSON.stringify(part.geometryCenter)}`);
if (part.animation) fields.push(`animation: ${JSON.stringify(part.animation)}`);
if (part.skin) fields.push(`skin: ${JSON.stringify(part.skin)}`);
if (part.timeScale !== undefined) fields.push(`timeScale: ${JSON.stringify(part.timeScale)}`);
if (part.kind === "spine") {
fields.push(`jsonUrl: asset("../assets/spines/${part.id}/${part.id}.json")`);
fields.push(`atlasUrl: asset("../assets/spines/${part.id}/${part.id}.atlas")`);
} else {
const files = existsSync(join(staged, "scene")) ? await readdir(join(staged, "scene")) : [];
const hit = files.find((f) => f.startsWith(`${part.id}.`));
if (!hit) continue;
fields.push(`image: asset("../assets/scene/${hit}")`);
}
parts.push(` { ${fields.join(", ")} },`);
}
const camera = scene.camera?.camera ?? {};
const cameraLine =
camera.type === undefined
? ""
: ` camera: ${JSON.stringify({ type: camera.type, fov: camera.fov, position: scene.camera?.position })},\n`;
await writeFile(
join(dir, "scripts", "scene-preset.js"),
[
`const base = new URL("./", import.meta.url);`,
`const asset = (path) => new URL(path, base).href;`,
`export default {`,
` id: "preview",`,
` name: ${JSON.stringify(rel)},`,
` game: "preview",`,
` backgroundImage: "",`,
` sceneConfig: {`,
` ui: ${JSON.stringify(scene.ui ?? [2500, 1080])},`,
cameraLine + ` parts: [`,
...parts,
` ],`,
` },`,
` audioChoices: [],`,
` audioOptions: { source: "" },`,
`};`,
``,
].join("\n"),
"utf8",
);
await writeFile(
join(dir, "scripts", "presets.js"),
`import preset from "./scene-preset.js";\nexport const defaultPresetId = "preview";\nexport default Object.fromEntries([["preview", preset]]);\n`,
"utf8",
);
await writeFile(
join(dir, "index.html"),
[
`<!DOCTYPE html>`,
`<html lang="zh"><head><meta charset="UTF-8" /><title>preview ${rel}</title>`,
`<link rel="icon" href="data:," />`,
`<link rel="stylesheet" href="./styles/spine-player.css" />`,
`<link rel="stylesheet" href="./styles/index.css" /></head>`,
`<body><div id="spine-container"></div><audio id="background-music"></audio>`,
`<script src="./scripts/spine-player.js"></script>`,
`<script type="module" src="./scripts/index.js"></script></body></html>`,
``,
].join("\n"),
"utf8",
);
console.log(
`已组装 ${rel} → ${dir}\n 骨架 ${parts.filter((p) => p.includes('"spine"')).length} 件、` +
`平面 ${parts.filter((p) => p.includes('"image"')).length} 件`,
);
if (noServe) process.exit(0);
// 3) 起服务(MIME 要认 .atlas,否则骨架读不到 atlas 文本)
const server = createServer(async (req, res) => {
const pathname = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
const path = normalize(pathname).replace(/^([/\\])+/, "");
const file = resolve(join(dir, path === "" ? "index.html" : path));
if (!file.startsWith(dir)) {
res.writeHead(403).end();
return;
}
try {
const info = await stat(file);
if (info.isDirectory()) {
res.writeHead(404).end();
return;
}
res.writeHead(200, { "content-type": MIME[extname(file).toLowerCase()] ?? "application/octet-stream" });
createReadStream(file).pipe(res);
} catch {
res.writeHead(404).end();
}
});
server.listen(port, "127.0.0.1", () => {
console.log(`\n预览: http://127.0.0.1:${port}/`);
console.log(`调试信息在页面里: window.__sceneDebug(parts / loaded / errors / framing / rotatedSkipped)`);
console.log(`停止: Ctrl+C\n`);
});