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:
1 parent
b8eee05d78
commit
3f11426964
297 files changed
+216627
-1926
No files matched your search
@@ -0,0 +1,252 @@
|
||||
// 把 wallpapers/ 读成内存里的领域对象,并在读的过程中把所有不变量校验掉。
|
||||
//
|
||||
// 校验失败一律 fail-fast:宁可 build 报错,也不要产出一个"能上传但跑不对"的分发目录。
|
||||
|
||||
import { join } from "node:path";
|
||||
import { abs, isFile, listDirs, readJson } from "./fs.ts";
|
||||
import type {
|
||||
AudioChoice,
|
||||
AudioChoiceSpec,
|
||||
Game,
|
||||
GameMeta,
|
||||
GlobalMeta,
|
||||
Wallpaper,
|
||||
WallpaperMeta,
|
||||
PresetTemplate,
|
||||
} from "./types.ts";
|
||||
|
||||
/** CEF(WE 内置的 Chromium 146)实际能解的音频扩展名白名单。
|
||||
* 实测其 FFmpeg 构建含 vorbis/libopus/flac/mp3/pcm_*,**不含 aac**,所以 m4a 直接拒。 */
|
||||
const AUDIO_EXT = new Set([".flac", ".mp3", ".ogg", ".opus", ".wav"]);
|
||||
|
||||
/** 游戏目录与壁纸目录里的保留名:不允许被当作 id 使用。 */
|
||||
const RESERVED = new Set(["audios", "meta.json", "preview.gif", "preset.template.json"]);
|
||||
|
||||
export class VaultError extends Error {}
|
||||
|
||||
function fail(message: string): never {
|
||||
throw new VaultError(message);
|
||||
}
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) fail(message);
|
||||
}
|
||||
|
||||
/** 音频文件必须是白名单内的扩展名,且不许出现在上级目录(分发时会被搬走)。 */
|
||||
function checkAudioFile(file: string, where: string): string {
|
||||
const normalized = file.replace(/^\.\//, "");
|
||||
assert(!normalized.startsWith("../"), `${where}: 音频 ${file} 不得引用上级目录(build 会把它放到分发目录内部)`);
|
||||
assert(!normalized.includes("\\"), `${where}: 音频路径 ${file} 必须用 / 分隔`);
|
||||
const dot = normalized.lastIndexOf(".");
|
||||
assert(dot > 0, `${where}: 音频 ${file} 没有扩展名`);
|
||||
const ext = normalized.slice(dot).toLowerCase();
|
||||
assert(
|
||||
AUDIO_EXT.has(ext),
|
||||
`${where}: 音频 ${file} 的扩展名 ${ext} 不在 CEF 白名单内(${[...AUDIO_EXT].join(" ")});m4a/aac 在 WE 里解不了`,
|
||||
);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读一份音频声明:只校验显示名与文件,**不校验 id**——id 已经不由人写。
|
||||
* 也接受裸数组(游戏级共享音频就是数组)。
|
||||
*
|
||||
* `default` 是 **1 起**的位置,这里换算成 0 起的 `defaultIndex`:
|
||||
* 换算只做这一处,下游拿到的永远是能直接下标的东西。
|
||||
*/
|
||||
function readAudioDecl(
|
||||
audio: { default?: number; choices?: AudioChoiceSpec[] } | AudioChoiceSpec[] | undefined,
|
||||
where: string,
|
||||
): { defaultIndex: number; choices: AudioChoiceSpec[] } {
|
||||
const normalized = Array.isArray(audio) ? { choices: audio } : audio;
|
||||
if (!normalized) fail(`${where}: 缺少 audio 声明`);
|
||||
assert(Array.isArray(normalized.choices), `${where}: audio.choices 必须是数组`);
|
||||
const choices: AudioChoiceSpec[] = normalized.choices.map((choice, i) => {
|
||||
assert(typeof choice.name === "string" && choice.name.length > 0, `${where}: 第 ${i + 1} 条音源缺少显示名`);
|
||||
return { name: choice.name, file: checkAudioFile(choice.file, where) };
|
||||
});
|
||||
let defaultIndex = 0;
|
||||
if (normalized.default !== undefined) {
|
||||
assert(Number.isInteger(normalized.default), `${where}: audio.default 必须是整数位置(1 起)`);
|
||||
assert(
|
||||
normalized.default >= 1 && normalized.default <= choices.length,
|
||||
`${where}: audio.default ${normalized.default} 超出范围(共 ${choices.length} 条,位置从 1 起)`,
|
||||
);
|
||||
defaultIndex = normalized.default - 1;
|
||||
}
|
||||
return { defaultIndex, choices };
|
||||
}
|
||||
|
||||
/**
|
||||
* 音源 id 的分配器。
|
||||
*
|
||||
* **必须是全项目一个计数器**,不能每档壁纸各从 1 开始:`bgm` 下拉会把一个分发里所有壁纸的
|
||||
* 音源平铺进同一个 combo,两档都叫 "1" 的话选中的到底是哪个就无从分辨。
|
||||
* 分配顺序固定(游戏 → 该游戏的共享音频 → 各壁纸的音源),所以同一个音源在任何分发里
|
||||
* 拿到的 id 都一样。
|
||||
*/
|
||||
function createAudioIdAllocator(): () => string {
|
||||
let next = 0;
|
||||
return () => String((next += 1));
|
||||
}
|
||||
|
||||
/** 读一份壁纸(含其所属游戏与全局元数据)。 */
|
||||
async function readWallpaper(gameId: string, wallpaperId: string, game: GameMeta): Promise<Wallpaper> {
|
||||
const srcRel = `wallpapers/${gameId}/${wallpaperId}`;
|
||||
const srcAbs = abs(srcRel);
|
||||
const where = srcRel;
|
||||
|
||||
const metaPath = join(srcAbs, "meta.json");
|
||||
const presetPath = join(srcAbs, "preset.template.json");
|
||||
assert(await isFile(metaPath), `${where}: 缺少 meta.json`);
|
||||
assert(await isFile(presetPath), `${where}: 缺少 preset.template.json`);
|
||||
|
||||
const meta = await readJson<WallpaperMeta>(metaPath);
|
||||
const preset = await readJson<PresetTemplate>(presetPath);
|
||||
|
||||
assert(meta.id === wallpaperId, `${where}: meta.json 的 id "${meta.id}" 与目录名 "${wallpaperId}" 不一致`);
|
||||
assert(!RESERVED.has(meta.id), `${where}: id "${meta.id}" 是保留名`);
|
||||
assert(/^[a-z0-9][a-z0-9_-]*$/i.test(meta.id), `${where}: id "${meta.id}" 只能用字母数字与 _-(它会进 WE 的 combo value)`);
|
||||
assert(typeof meta.name === "string" && meta.name.length > 0, `${where}: 缺少显示名 name`);
|
||||
assert(typeof meta.title === "string" && meta.title.length > 0, `${where}: 缺少 title(会进 project.json)`);
|
||||
assert(typeof meta.description === "string" && meta.description.length > 0, `${where}: 缺少 description`);
|
||||
|
||||
if (meta.preview !== undefined) {
|
||||
assert(await isFile(join(srcAbs, meta.preview)), `${where}: preview "${meta.preview}" 不存在`);
|
||||
}
|
||||
|
||||
const audioDecl = readAudioDecl(meta.audio, where);
|
||||
|
||||
for (const choice of audioDecl.choices) {
|
||||
assert(await isFile(join(srcAbs, choice.file)), `${where}: 音源 "${choice.name}" 指向的 ${choice.file} 不存在`);
|
||||
}
|
||||
|
||||
// 运行时配置里的资源路径也要存在——这是"运行时才会暴露的拼写错误"的唯一静态防线。
|
||||
const spineConfig = preset.spineConfig;
|
||||
const sceneConfig = preset.sceneConfig;
|
||||
assert(
|
||||
Boolean(spineConfig) !== Boolean(sceneConfig),
|
||||
`${where}: preset.template.json 必须**二选一**地写 spineConfig(单骨架)或 sceneConfig(场景)`,
|
||||
);
|
||||
|
||||
const resourcePaths: [string, string | undefined][] = [["backgroundImage", preset.backgroundImage]];
|
||||
if (spineConfig) {
|
||||
resourcePaths.push(["spineConfig.jsonUrl", spineConfig.jsonUrl], ["spineConfig.atlasUrl", spineConfig.atlasUrl]);
|
||||
}
|
||||
if (sceneConfig) {
|
||||
assert(Array.isArray(sceneConfig.parts) && sceneConfig.parts.length > 0, `${where}: sceneConfig.parts 不能为空`);
|
||||
sceneConfig.parts.forEach((part, index) => {
|
||||
const at = `sceneConfig.parts[${index}](${part.kind} ${part.id})`;
|
||||
assert(
|
||||
part.kind === "spine" || part.kind === "image" || part.kind === "solid",
|
||||
`${where}: ${at} 的 kind 只能是 spine/image/solid`,
|
||||
);
|
||||
assert(typeof part.order === "number", `${where}: ${at} 缺少 order(绘制层级)`);
|
||||
if (part.kind === "spine") {
|
||||
resourcePaths.push([`${at}.jsonUrl`, part.jsonUrl], [`${at}.atlasUrl`, part.atlasUrl]);
|
||||
} else if (part.kind === "image") {
|
||||
resourcePaths.push([`${at}.image`, part.image]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const [key, value] of resourcePaths) {
|
||||
assert(typeof value === "string" && value.length > 0, `${where}: preset.template.json 的 ${key} 缺失`);
|
||||
const relPath = value.replace(/^\.\//, "");
|
||||
assert(await isFile(join(srcAbs, relPath)), `${where}: preset.template.json 的 ${key} 指向的 ${relPath} 不存在`);
|
||||
}
|
||||
|
||||
// audioChoices 先留空:id 要等所有游戏都读完才能按固定顺序分配(见 readVault)。
|
||||
return { id: wallpaperId, gameId, srcRel, meta, preset, game, audioDecl, audioChoices: [] };
|
||||
}
|
||||
|
||||
/** 读一个游戏目录。 */
|
||||
async function readGame(gameId: string): Promise<Game> {
|
||||
const srcRel = `wallpapers/${gameId}`;
|
||||
const meta = await readJson<GameMeta>(join(abs(srcRel), "meta.json"));
|
||||
assert(meta.id === gameId, `${srcRel}: meta.json 的 id "${meta.id}" 与目录名 "${gameId}" 不一致`);
|
||||
assert(/^[a-z0-9][a-z0-9_-]*$/i.test(meta.id), `${srcRel}: 游戏 id 只能用字母数字与 _-`);
|
||||
assert(typeof meta.name === "string" && meta.name.length > 0, `${srcRel}: 缺少显示名 name`);
|
||||
// 游戏合集的文案就来自这一层,**不回落全局 meta**——全局那层是给「全部合集」写的,回落正是
|
||||
// collection-ys 顶着《崩坏:星穹铁道》昔涟的原因(见 .scratch/build-pipeline/issues/20)。
|
||||
assert(typeof meta.title === "string" && meta.title.length > 0, `${srcRel}: 缺少 title(游戏合集的创意工坊标题)`);
|
||||
assert(
|
||||
meta.description === undefined || (typeof meta.description === "string" && meta.description.length > 0),
|
||||
`${srcRel}: description 要么不写,要么非空(缺省时由构建按「共 N 档」生成)`,
|
||||
);
|
||||
|
||||
// 游戏级共享音频:只在「合集」类分发里被使用,允许为空(本仓库当前就是空的)。
|
||||
readAudioDecl(meta.audios ?? [], `${srcRel} 的共享音频`);
|
||||
|
||||
const wallpaperIds = await listDirs(abs(srcRel), ["audios"]);
|
||||
assert(wallpaperIds.length > 0, `${srcRel}: 没有任何壁纸目录`);
|
||||
const wallpapers = [];
|
||||
for (const id of wallpaperIds) wallpapers.push(await readWallpaper(gameId, id, meta));
|
||||
// sharedAudio 同样等 readVault 分配 id。
|
||||
return { id: gameId, meta, srcRel, wallpapers, sharedAudio: [] };
|
||||
}
|
||||
|
||||
export interface Vault {
|
||||
global: GlobalMeta;
|
||||
games: Game[];
|
||||
/** 所有壁纸,按 游戏 → 壁纸 的声明顺序。 */
|
||||
wallpapers: Wallpaper[];
|
||||
}
|
||||
|
||||
/** 读取整棵资源库,并校验全局唯一性。 */
|
||||
export async function readVault(): Promise<Vault> {
|
||||
const global = await readJson<GlobalMeta>(abs("wallpapers/meta.json"));
|
||||
assert(typeof global.name === "string" && global.name.length > 0, "wallpapers/meta.json: 缺少 name");
|
||||
assert(typeof global.title === "string" && global.title.length > 0, "wallpapers/meta.json: 缺少 title");
|
||||
assert(typeof global.description === "string" && global.description.length > 0, "wallpapers/meta.json: 缺少 description");
|
||||
|
||||
const gameIds = await listDirs(abs("wallpapers"), ["audios", "meta.json", "preview.gif"]);
|
||||
assert(gameIds.length > 0, "wallpapers/: 没有任何游戏目录");
|
||||
|
||||
const games: Game[] = [];
|
||||
for (const id of gameIds) games.push(await readGame(id));
|
||||
|
||||
// 全局唯一性:WE 的 combo value 是平铺的,跨游戏撞 id 会让后一档静默覆盖前一档。
|
||||
const byId = new Map<string, string>();
|
||||
const wallpapers: Wallpaper[] = [];
|
||||
for (const game of games) {
|
||||
for (const wallpaper of game.wallpapers) {
|
||||
const previous = byId.get(wallpaper.id);
|
||||
assert(
|
||||
previous === undefined,
|
||||
`壁纸 id "${wallpaper.id}" 在 ${previous} 与 ${wallpaper.srcRel} 中重复。` +
|
||||
`id 必须是全局唯一的 WE combo value(老用户设置靠它,不能自动加前缀绕过),请改其中一个。`,
|
||||
);
|
||||
byId.set(wallpaper.id, wallpaper.srcRel);
|
||||
wallpapers.push(wallpaper);
|
||||
}
|
||||
}
|
||||
|
||||
// 到这里所有游戏都读完了,再统一分配音源 id。
|
||||
//
|
||||
// 顺序固定为「游戏 → 该游戏的共享音频 → 各壁纸的音源」,且遍历的是**整棵资源库**
|
||||
// 而不是本次要构建的分发子集——否则同一个音源在不同分发里会拿到不同的 id,
|
||||
// 用户从合集切到单档时 bgm 选择就失效了。
|
||||
const nextAudioId = createAudioIdAllocator();
|
||||
for (const game of games) {
|
||||
game.sharedAudio = (game.meta.audios ?? []).map((choice) => ({
|
||||
id: nextAudioId(),
|
||||
name: choice.name,
|
||||
file: checkAudioFile(choice.file, `${game.srcRel} (共享音频)`),
|
||||
}));
|
||||
for (const wallpaper of game.wallpapers) {
|
||||
wallpaper.audioChoices = wallpaper.audioDecl.choices.map((choice) => ({
|
||||
id: nextAudioId(),
|
||||
name: choice.name,
|
||||
file: choice.file,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return { global, games, wallpapers };
|
||||
}
|
||||
|
||||
/** 该游戏在合集根共享音频里声明的音源(id 已在 readVault 里分配)。 */
|
||||
export function sharedAudioOf(game: Game): AudioChoice[] {
|
||||
return game.sharedAudio;
|
||||
}
|
||||
Reference in new issue
Block a user