把项目从「手写 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 处引用自包含。
271 lines
11 KiB
TypeScript
271 lines
11 KiB
TypeScript
// 预览 `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`);
|
||
});
|