把项目从「手写 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 处引用自包含。
17 KiB
tools/downloader/ — 米哈游活动页 Spine 抓取器
从 wallpapers/sources.yml 列出的活动页里把 Spine 骨架 + 贴图 + 场景装配信息 抓下来,
落到 staging(_out/)。每个有内容的场景各产出一个「可直接搬走的壁纸目录」——用户挑中的那份
整个移到 wallpapers/<游戏id>/ 就能用(也可以让 promote 代劳)。
它不是构建输入:wallpapers/ 只放最终要发布的资源,抓取脚本与临时下载都留在 tools/。
python -m tools.downloader fetch --page kv45 # 抓一页(结尾自动 verify,有硬伤就非 0 退出)
python -m tools.downloader fetch # 抓 sources.yml 里的全部页面
python -m tools.downloader fetch --interactive # 逐页问:这一页推荐哪个场景(不影响落盘范围)
python -m tools.downloader select --page nico-tea # 只做选择,写 selection.yml
python -m tools.downloader verify # 只自检产物
python -m tools.downloader fetch --offline # 只用 _cache/ + 已落盘的产物,不发任何请求(重跑验证)
python -m tools.downloader promote --page kv45/scene_ava --game hsr --id scene_ava \
--name 挥掷千星的筹码 --title '【崩坏:星穹铁道】挥掷千星的筹码' --description '[b]挥掷千星的筹码[/b]'
node tools/checks/check-downloader.mts # 门:编译 + 夹具绿 + 四处破坏必红
依赖:Python 3.10+ 与 PyYAML(requirements.txt)。不需要浏览器——这些页面免登录即可拿到
全量入口 bundle,骨架数据就在里面。
一、产物形状
tools/downloader/
├── _cache/<页面>/<脚本名> # 抓下来的 bundle 原文(文本调试缓存,可重跑零请求)
├── _out/<游戏>/<页面>/
│ ├── page.json # 页面级溯源:来源 URL / 入口脚本 / 场景清单 / 每个场景目录的摘要
│ └── <场景id>/ # ← 一个场景 = 一个可直接搬走的壁纸目录
│ ├── meta.json # id / name / title / description / game / page / scene / source / audio
│ ├── preset.template.json # 运行时配置(背景图 + sceneConfig.parts)
│ ├── scene.json # 场景侧车:part 列表 + 每个 part 的世界变换
│ ├── spines/<骨架名>/
│ │ ├── <骨架名>.json # 骨架(skeleton.images 已归一化为空串)
│ │ ├── <骨架名>.atlas # 第一行 = 贴图页名,与落盘文件名逐字一致
│ │ ├── <贴图页…> # 与 atlas **同居**(布局 A,见 ADR 0008)
│ │ └── meta.json # spine 版本 / 动画 / 皮肤 / 来源 URL
│ ├── scene/<场景图> # 几何平面(geometry + material.diffuse)贴的图
│ └── audios/ # 音源(当前恒为空:页面的 BGM 还没抓)
└── selection.yml # 机器所有的「哪一页推荐哪个场景」(**入库**)
场景目录名 = 场景 id 转义成合法壁纸 id(只允许 [a-z0-9_-],首字符必须是字母数字):
scene_main / P1 / loading 原样,中文场景 id(back-moon 的 动画预览)折成 scene。
真实 id 不丢——它在同目录的 meta.json 与 scene.json 里。
_cache/ 与 _out/ 在 .gitignore 里;selection.yml 入库。
场景内去重、跨场景不去重
- 场景内按 id 去重:同一个场景里同一具骨架被引用多次只存一份(
ctc_rewards在 back-moon 的scene_content里出现 4 次、gc_win在scene_gacha里 6 次,都只落一份)。scene.json里那些 part 仍然一条不少——只有文件去重。 - 跨场景不去重:
scene_main/与scene_ava/会各存一份用到的骨架。_out/是可随时 重生成的 staging,体积换"每个目录都能单独搬走"。总体积记在page.json的totalBytes里。
usable:不是每个场景目录都能搬走
各页的 scene_ui 是一层场景渲染目标:它的平面用 drawScene modifier 把别的场景渲染成纹理
(diffuse 名就是场景 id),本身没有任何资源文件。这种场景照样落盘("页面上有几个场景"这件事在
产物里是完整的),但它的预设 parts 是空的:
{ "usable": false, "reason": "场景里只有运行时纹理(drawScene / 贴图缓冲),没有可搬走的资源" }
promote 拒绝搬这种目录;page.json 的 sceneDirs[] 里逐条标了 usable。
二、场景选择:只决定"推荐哪个"
fetch 落盘全部有内容的场景(至少有一具骨架或一块贴图平面)。纯色平面组成不了壁纸
(sceneConfig.parts 会是空的),完全没有 part 的场景也没有内容——这两类不落盘,原因记在
page.json 的 skippedScenes 里。
推荐场景由 selection.yml / sources.yml / 默认值算出(优先级写死,避免两份文件打架):
wallpapers/sources.yml里这一页写了scene:/spines:→ 用它(人的意志最高)tools/downloader/selection.yml里有这一页的记录 → 用它(上次交互的结果)- 都没有 → 骨架最多的那个场景(
--interactive时才问人)
默认规则与旧项目的判断一致(get-memory 会推荐 P1、back-moon 会推荐 scene_main)。
推荐值写进 page.json 的 chosenScene / chosenDir,是 promote 不给 --scene 时的默认值。
这份选择不再裁剪产物。 以前它决定"只落哪几个骨架",现在产物是"一个场景一个自包含的 壁纸目录"——砍掉几具骨架会做出一个缺件的坏场景。要精简就在场景目录里的
preset.template.json上改(那是纯数据,构建期才读)。sources.yml的spines:字段同理,只剩记录语义。
三、scene.json 里有什么
页面用自研引擎(three.js 系)描述场景:sceneList 是场景数组,每个场景是一棵树,节点有三类负载:
| kind | 含义 | 需要资源? |
|---|---|---|
spine |
spine:{id:"main_nike"},一具骨架 = 一个视觉元件 |
是(骨架 + atlas + 贴图页) |
image |
geometry + material.uniforms.diffuse,贴图平面 |
是(scene/<名>.<ext>) |
solid |
defines.USE_TEXTURE == 0 的纯色平面(diffuse 常写 DEFAULT) |
否 |
每个 part 带 position / scale(世界变换)与 localPosition / localScale,合成语义照抄引擎:
world_position = parent_position + parent_scale ⊙ local_position
world_scale = parent_scale ⊙ local_scale
旋转暂不参与合成(旧项目同样如此),只在 stats.rotatedNodes 里报出数量——不假装算了。
绘制层级 = 树序遍历序(order)+ 节点自身的 renderOrder。
带 "runtime": true 的 image part 是没有独立文件的平面:drawScene 的场景渲染目标、
cacheContainer 的贴图缓冲,或 diffuse 指向同场景骨架缓存。它们不进预设,也不算缺资源。
用户口中的 "scene / geometric" 就是这里的两类节点:sceneList 的场景容器与 geometry 平面。
Spine 官方没有这两个数据概念(官方 7 种附件类型里没有 geometric;scene 在 spine-webgl 里指的是
SceneRenderer 这个渲染器)。所以"支持场景动画"= 运行期把多具骨架 + 若干贴图平面按这套摆放合成。
四、preset.template.json 怎么生成的
sceneConfig.parts:scene.json里每个spine/imagepart 一条;纯色平面与运行时纹理不写 (运行时画不了它们,写进去只是死配置)。路径一律写成./spines/<名>/…、./scene/<名>.<ext>—— 正斜杠,因为预设会被整体搬走,str(Path)的反斜杠在别的机器上是错的。backgroundImage:构建期它是必填且必须真实存在的。自动挑法 = 场景里面积最大的贴图平面 (它决定document.body的底图与取景用的宽高比,挑到一块小按钮会让整幅画的比例全错); 一块贴图平面都没有时退到第一具骨架的 atlas 第一行声明的贴图页。- 写法与校验在同一个模块
layout.py里(build_preset/missing_preset_paths): 产物形状一旦改,校验规则必须同时改,分成两个文件迟早漂移。写完之后当场把每条路径在磁盘上 核一遍,不留到构建期才炸。
五、页面侧的坑(都踩过,别再踩)
- 描述表的
src表达式不能用一条.+?通吃:kv45 的 bundle 里有个编译后的模板片段写着{src:e.activeIcon,alt:""}}),\{src:(.+?),id:"…",type:"image"\}会从那里一路吃 8.8 万字符 去够后面的,id:"…",type:"image"},把夹在中间的真表项({src:$w,id:"loading_dt1",…})整个吞掉。 现在分成两种形状各匹配各的:_DESCRIPTOR_INLINE(Object.values(Object.assign({…}))[0])与_DESCRIPTOR_SIMPLE(不含,{}的表达式)。 - 资源引用有四种写法,缺一种就会把真资源当"不存在":
X = a.p + "images/x.png"(_ASSET_LITERAL){src:a(38458),id:"x"}:模块直接导出字符串——小图被 webpack 内联成e.exports="data:image/png;base64,…"(_MODULE_STRING)。get-memory 的loading_moutain_a、a01_lizi、start-ndkl 的loading_start_1都栽在这里(少了它们,平面会报"资源表里没有 URL")。{src:$w,id:"x"}:$w="data:image/png;base64,…"(_STRING_ASSIGN,kv45 的loading_dt1)。Object.values(Object.assign({"<源路径>":"data:…"}))[0](_DATA_IN_ASSIGN)。
- 骨架数据一律内联在 bundle 里,6 个页面里
.atlas/.skel的网络请求数是 0。 只有 hsr 的骨架 json 走网络(页面根目录<hash>.json),而且它在 webpack 异步 chunk 里(258.ccb0954b.js)——入口 HTML 根本没列它,得读.u=的 chunk 名映射再排队抓。 这也是 hsr 的scene_ava首次离线抓不到的原因:那两个 json 从没进过_cache/, 得先联网抓一次(见第七节的离线边界)。 - 三种内联家族都要认:A
Object.values(Object.assign({"…/spine/<N>.json":{…}}))[0](JS 对象字面量, 键不带引号,用jslit规范化);B 匿名模块 + 配对表{atlas:fn(id),json:fn(id)}, 骨架是JSON.parse('…')(单引号 JS 字符串,要先按 JS 语义还原);C hsr 的spineSetting。 - 公共路径变量名逐页不同:
n.p/a.p/t.p。写死n.p会让半个页面的资源表全空。 - 同一逻辑名可能有两个候选(引擎的桌面/移动两套预载表)。判据:数组式描述表是基准集,
字典式表是移动端覆盖(引擎里是
desktop() || base.forEach(e => override[e.id] && …)),桌面取基准集。 - atlas 有两种书写风格:
size:498,330与size: 256, 256,解析器两种都要吃。 .atlas的页名要读 atlas 自己声明的(atlas.py解析出来的第一行/页行),不要按<stem>_N猜: 多页是_2.png,也有完全不同的名字。落盘用逻辑名,atlas 第一行不用改。skeleton.images要归一化:作者目录(../images/)搬进分发后一定指错,写空串即可 (页名相对 atlas 所在目录解析),原值记在meta.json的originalImages里。- 跨平台路径:别拿
str(Path)当映射键(Windows 反斜杠 vs 预设里的正斜杠),一律Path.as_posix()。
六、多版本 Spine
meta.json 逐具骨架记录 skeleton.spine 原文。事实基线(详见 .scratch/spine-versions/REPORT.md):
- 版本串是编辑器版本;
4.0-from-4.1-from-4.2-from-4.3.23这类-from-串表示 数据是 4.0 格式(降级导出),运行库只认开头那个major.minor。 - 运行库不校验这个串;spine-ts 4.2 能读 4.0/4.1/4.2 的数据。4.3 数据会被静默丢掉全部约束
(4.3 把约束并进
root.constraints),所以verify对≥4.3的骨架直接报红。 - 目前 7 页共 170+ 具骨架全是 ≤4.2 格式,一套 4.2 运行库就够;真出现 4.3 数据再谈多套 UMD 共存
(官方没有
spine-version属性,只能各包一层别名函数避免window.spine互相覆盖)。 - 重分发运行库要带 Spine Runtimes License Agreement(Exhibit A)+ 版权声明,不得删各文件头。
七、verify 检查什么
逐场景目录核一遍:meta.json / preset.template.json / scene.json 都在;meta.json 的
id 等于目录名、字符集合法、name/title/description 非空、audio.choices 指向的文件存在;
预设里每条 ./… 路径在磁盘上找得到;侧车引用的骨架有目录与文件、atlas 声明的贴图页逐字落盘、
几何平面的图存在;纯色与运行时纹理不计入缺失;骨架版本在运行库可读范围内。
页面级还会抓"旧形状的残留"(页面根的 scene.json / spine/ / scene/)与"不是场景目录的目录"。
退出码非 0 = 有硬伤。
它不评判画面对不对——那是 tools/checks/verify-scene-player.mts 的事(多骨架 + 贴图平面真的合成出来)。
离线能跑到哪一步
fetch --offline 只读 _cache/(bundle 文本)与已经落盘的 _out/。所以:
- 重跑永远是安全的:产物存在就跳过,不发任何请求。
- 首次抓一个"从没抓过的场景"需要联网——它的骨架 json 与贴图页都还没有本地副本。
hsr 的
scene_ava(zhigengniao_juheye/shajin)就是这样:先fetch --page kv45(联网) 一次,之后再--offline就完全绿。缺什么会逐条报出来(不会甩一条 traceback 就走)。
八、离线预览(_out/ 能看,_cache/ 不能)
| 目录 | 内容 | 能否预览 |
|---|---|---|
_cache/<页面>/ |
原始 bundle 文本(引用仍是线上绝对地址、贴图不在里面) | ❌ 按设计就是文本调试缓存 |
_out/<游戏>/<页面>/<场景>/ |
真实文件树(骨架 / atlas / 贴图页 / 场景图 / 侧车 / 预设) | ✅ 可离线预览 |
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/preview.mts 把「构建出的运行时 + staging 的资产 + 由 scene.json 生成的预设」组装成
一个独立目录再起静态服务;页面只读本地文件。页面里的调试面是 window.__sceneDebug
(parts / loaded / errors / framing / rotatedSkipped),排查"少加载了一件"直接看它。
两点说明:
- 要预览"原页面"而不是我们的产物,得走整站镜像(
.scratch/page-mirror/spec.md,尚未实现);_cache/不能满足这个需求——它只有文本,没有资产树,也没有改写引用。 tools/checks/verify-scene-player.mts里另有一份"从 scene.json 生成预设"的代码, 故意不与 preview 共用:门必须独立于被验证对象,共用一份就变成自己验自己。
九、promote:搬进 wallpapers/
fetch 已经产出终态,所以 promote 退化成 拷贝 + 校验 + 写 meta:
python -m tools.downloader promote --page kv45/scene_ava --game hsr --id scene_ava \
--name '挥掷千星的筹码' --title '【崩坏:星穹铁道】挥掷千星的筹码' --description '[b]挥掷千星的筹码[/b]'
--page认三种写法:页面 id(kv45)、页面/场景(kv45/scene_ava)、场景目录的路径。 给页面 id 时用--scene点名,或让它取page.json的chosenScene。--name/--title/--description可选:不给就沿用场景目录meta.json里的 (那里已经是"页面名(场景id)"的形状,够用但通常要改成正式文案)。--cover给逻辑名时覆盖backgroundImage。- 目标目录里的
meta.json的id会被改写成--id——所以壁纸 id 可以跟场景目录名不同, 但目录名与meta.json.id必须一致(构建期会校验)。 - 搬之前会再核一遍预设里的每条路径在目标目录上存在;搬完不用手工改任何路径。
也可以完全不用 promote:整个场景目录拷到 wallpapers/<游戏id>/<壁纸id>/ 就完事
(前提是目录名 = meta.json.id,且那个目录 usable)。两种做法等价,promote 只是顺手改名与校验。
十、还没做的
- 音源:页面 BGM 是另一条链,
audios/现在是空的,meta.json的audio.choices也是空的。 - 预览图:没有抓,也没有生成。
scene_ui这类纯渲染目标场景:落盘但不可搬走(运行时不会drawScene)。