Files
Shuery 3f11426964 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 处引用自包含。
2026-10-02 01:27:02 +08:00

310 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 构建期共享类型:schema 本身即文档。
//
// 三层元数据 + 两层运行时配置的关系:
//
// wallpapers/meta.json ← 「所有壁纸合集」这一档的发布文案
// wallpapers/<gameId>/meta.json ← 游戏显示名 + 该游戏的共享音频
// wallpapers/<gameId>/<wallpaperId>/
// meta.json ← 壁纸的发布文案与音频清单(运行时用不到的)
// preset.template.json ← 壁纸的运行时配置(播放器真正要读的)
//
// build 把后两者合成两份产物:preset.js(运行时)与 project.json(发布)。
import type { ViewportSpec } from "../../src/globals.d.ts";
/** WE 属性的合法类型。build 只生成 combo,其余类型由模板手写。 */
export type PropertyType =
| "bool"
| "combo"
| "slider"
| "textinput"
| "color"
| "file"
| "directory"
| "texture"
| "group"
| "usershortcut"
| "text";
export interface PropertyOption {
label: string;
value: string;
}
export interface PropertyDef {
type: PropertyType;
text?: string;
value?: unknown;
order?: number;
index?: number;
options?: PropertyOption[];
condition?: string;
min?: number;
max?: number;
step?: number;
precision?: number;
fraction?: boolean;
}
/** project.json 的骨架(src/project.template.json)。 */
export interface ProjectTemplate {
contentrating: string;
file: string;
ratingsex: string;
ratingviolence: string;
tags: string[];
type: string;
visibility: string;
general: { properties: Record<string, PropertyDef> };
}
/**
* meta.json 里**手写**的一条音源声明:只有显示名与文件路径。
*
* id 不在这里写——它由 build 自增分配(见 AudioChoice)。以前是手写字符串
* (zaiduheni / xilian / pv37),加一条就得现编一个不重名的 id。
*/
export interface AudioChoiceSpec {
/** 显示名(中文原样保留,是产品的一部分)。 */
name: string;
/** 相对**本壁纸目录**或**合集根**的文件路径(不含 ./ 前缀也可)。 */
file: string;
}
/** meta.json 里的一份音频声明。 */
export interface AudioDecl {
/** 默认音源的位置,**1 起**;省略 = 第一个。 */
default?: number;
choices: AudioChoiceSpec[];
}
/** build 解析后的音源:id 已经分配好,是 WE combo 的 value。 */
export interface AudioChoice {
/**
* 自增 id,**全项目唯一**("1"、"2"、"3"…)。
*
* 为什么必须全局唯一而不是每档壁纸各从 1 开始:`bgm` 下拉会把一个分发里**所有**壁纸的
* 音源平铺进同一个 combo,两档壁纸都叫 "1" 的话,选中的到底是哪一个就无从分辨了。
* 分配顺序固定为 游戏 → (该游戏的共享音频 → 各壁纸的音源),所以同一个音源在任何分发里
* 拿到的 id 都一样。
*/
id: string;
name: string;
file: string;
}
/** 壁纸级元数据(wallpapers/<gameId>/<wallpaperId>/meta.json)。 */
export interface WallpaperMeta {
id: string;
name: string;
title: string;
description: string;
/** 预览图,相对本目录。缺省时 build 省略 project.json 的 preview 并 warning。 */
preview?: string;
/** 该档壁纸可选音源(本壁纸目录内的目录)。 */
audio: AudioDecl;
/** 上传 Workshop 后由 WE 生成;此处缺省即不写字段。 */
workshopid?: string;
workshopurl?: string;
}
/** 游戏级元数据(wallpapers/<gameId>/meta.json)。 */
export interface GameMeta {
id: string;
name: string;
/**
* 游戏合集的创意工坊标题。**必填**:这一层是「游戏合集」文案的唯一来源,
* 不许回落全局 meta——那层是给「全部合集」写的(回落过,见 .scratch/build-pipeline/issues/20)。
*/
title: string;
/** 游戏合集的文案;缺省时 build 用「共 N 档」的模板自动生成。 */
description?: string;
/** 该游戏的共享音频;只在「合集」类分发里被使用。 */
audios: AudioChoiceSpec[];
}
/** 全局元数据(wallpapers/meta.json)= 「所有壁纸合集」这一档。 */
export interface GlobalMeta {
name: string;
title: string;
description: string;
/** 默认预设 id:决定 preset 下拉的第一项与 project.json 的 preset.value。 */
defaultPresetId?: string;
/** 分发级预览图(相对 wallpapers/ 的文件名)。缺省时 project.json 省略 preview。 */
preview?: string;
workshopid?: string;
workshopurl?: string;
}
/** 壁纸级运行时配置(preset.template.json)。只放播放器真正要读的东西。 */
export interface PresetTemplate {
/** 背景图,相对本目录。 */
backgroundImage: string;
/**
* 单骨架路径的配置。与 `sceneConfig` **二选一**:
* 两个都写或都不写,构建期直接报错——不留静默优先级,歧义会烂在产物里。
*/
spineConfig?: {
jsonUrl: string;
atlasUrl: string;
animation?: string;
viewport?: ViewportSpec;
/** "author" = 保留作者原始取景,不跟随背景缩放。 */
framing?: string;
[key: string]: unknown;
};
/**
* 场景路径:N 具骨架 + M 块贴图平面,按抓取期 `scene.json` 的世界变换与绘制层级合成。
* 这些字段由抓取器的 `scene.json` 抄来(构建期烘焙),运行时不再读侧车。
*/
sceneConfig?: {
/** 页面场景的 UI 尺寸,仅作退化取景的兜底。 */
ui?: number[];
/**
* 页面相机(抓取期从场景树抄来)。
* `type: 1` = 透视:按 fov 与相机 z 做 billboard 投影(z 是真实景深,不能忽略);
* `type: 2` = 正交:各 part 直接按原坐标画(z 全为 0 的那些页面)。
*/
camera?: { type?: number; fov?: number; position?: number[] };
/** 页面是 y 向上、spine 渲染是 y 向下:默认把 y 取反;实测颠倒时设 false。 */
flipY?: boolean;
/** 时间线的整场位移(页面单位),从真页面量出来;见 .scratch/scene-player/perspective.md。 */
timelineOffset?: number[];
parts: {
kind: "spine" | "image" | "solid";
id: string;
order: number;
renderOrder?: number;
position: number[];
scale: number[];
image?: string;
jsonUrl?: string;
atlasUrl?: string;
animation?: string;
width?: number;
height?: number;
/** 网格中心相对节点原点的偏移(页面坐标系);四边形不以原点为中心时用它。 */
center?: number[];
/** 节点旋转(弧度)。非零 = 被倾斜的 3D 面片,运行时当前**不画**它。 */
rotation?: number[];
/** 页面指定的皮肤(`spine.skin`);`animation` 字段本来就在上面。 */
skin?: string;
timeScale?: number;
color?: number;
}[];
};
[key: string]: unknown;
}
/** 内存里的一份壁纸(已读盘、已校验)。 */
export interface Wallpaper {
id: string;
gameId: string;
/** 相对仓库根的源目录,如 wallpapers/hsr/xilian。 */
srcRel: string;
meta: WallpaperMeta;
preset: PresetTemplate;
game: GameMeta;
/**
* meta.json 里的原始音频声明(id 还没分配)。
* `defaultIndex` 已经是 **0 起**的数组下标,把"1 起"的换算收在 vault 里一处。
*/
audioDecl: { defaultIndex: number; choices: AudioChoiceSpec[] };
/** 该档壁纸的音频清单:id 已由 build 分配,文件路径已解析成相对**分发内壁纸目录**的形式。 */
audioChoices: AudioChoice[];
}
/** 内存里的一份游戏。 */
export interface Game {
id: string;
meta: GameMeta;
/** 相对仓库根的源目录,如 wallpapers/hsr。 */
srcRel: string;
wallpapers: Wallpaper[];
/** meta.audios 分配 id 之后的结果(读盘时还没有 id,见 readVault)。 */
sharedAudio: AudioChoice[];
}
/**
* 分发类型:**单档**与**合集**两种并列。
*
* 「游戏合集」与「全部合集」都是合集,区别只在 `CollectionScope`——它们不是第三种类型。
* (术语见 CONTEXT.md:单档 / 合集。)
*/
export type ReleaseType = "single" | "collection";
/** 合集的收档范围:一个游戏,还是所有游戏。只有 `type === "collection"` 时有意义。 */
export type CollectionScope = "game" | "all";
/** 一次构建里的一个分发目标。 */
export interface Release {
type: ReleaseType;
/** 合集的收档范围(单档分发没有它)。 */
scope?: CollectionScope;
/** 稳定的分发 id:单档用壁纸 id,游戏合集用游戏 id,全部合集用 "all"。 */
id: string;
/** 目录名(ASCII,带类型前缀,避免"合集"与"单档"混淆)。 */
dir: string;
/** 该分发包含的壁纸(single 恰含一个)。 */
wallpapers: Wallpaper[];
/** 展示名,写进 dist-map.json 供 dev 索引页使用。 */
displayName: string;
/** 该分发的发布文案(单档分发直接取壁纸自己的 meta)。 */
meta: { title: string; description: string; preview?: string; workshopid?: string; workshopurl?: string };
/** 上面那份 meta 所在的目录(相对仓库根):preview 指向的文件从它旁边取。 */
metaRel: string;
/**
* 该分发"合集根共享音频"清单(相对合集根的文件名)。
* 单档分发恒为空——非合集分发不出现共享音频。
*/
sharedAudio: AudioChoice[];
}
/** dist-map.json:分发清单,也是 dev 服与校验脚本的唯一索引。 */
export interface DistMap {
version: string;
builtAt: string;
releases: {
dir: string;
type: ReleaseType;
/** 合集的收档范围(单档分发省略)。 */
scope?: CollectionScope;
id: string;
displayName: string;
/** 该分发里每档壁纸的 id 与其在目录内的相对路径。 */
wallpapers: { id: string; gameId: string; dir: string; name: string }[];
/** 该分发的默认预设 id(写进 scripts/presets.js)。 */
defaultPresetId: string;
/** 发布产物字节数(**不含** sim/)。 */
bytes: number;
/** 自包含包 sim/ 的字节数;没跑 --sim 时不存在。 */
simBytes?: number;
}[];
}
export interface BuildOptions {
singles: string[];
collect: "all" | string[] | null;
/**
* `pnpm build single`(不给 id)= 只编全部单档,不编合集。
*
* 与"什么都不选"(= 全编)必须区分开:单档子命令的语义是"范围=单档",
* 而不是"没指定所以全都要"。用一个显式标记而不是靠 singles 为空来判断。
*/
onlySingles?: boolean;
/**
* `pnpm build collect`(不给参数)= 全部合集:每个游戏的合集 + 「全部壁纸合集」。
*
* 与 `collect: "all"` 区分开:后者是 `--collect all`,只编「全部壁纸合集」一项。
* 这两个语义确实容易混,但都是既有行为,改动其中一个会悄悄改变发布范围。
*/
allCollections?: boolean;
strict: boolean;
clean: boolean;
dryRun: boolean;
/** 额外产出双击可看的自包含包(<分发根>/sim/index.html)。 */
standalone?: boolean;
/** 自包含包里是否内联音频。 */
embedAudio?: boolean;
}