// 构建期共享类型:schema 本身即文档。 // // 三层元数据 + 两层运行时配置的关系: // // wallpapers/meta.json ← 「所有壁纸合集」这一档的发布文案 // wallpapers//meta.json ← 游戏显示名 + 该游戏的共享音频 // wallpapers/// // 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 }; } /** * 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///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//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; }