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:
Shuery committed 2026-10-02 01:27:02 +08:00
1 parent b8eee05d78
commit 3f11426964
297 files changed
+216627 -1926

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
"""一次性迁移:把 `wallpapers/<游戏>/<壁纸>/` 从「按文件类型分组」改成「一具骨架一组」。
旧:
effects/<名>.json ← 骨架数据
images/<名>.atlas images/<名>*.webp ← atlas 与页(扁平)
images/<名>/<名>.atlas + <名>.png ← 另一档用的「一骨架一目录」
images/scene/<图> ← 场景图
images/ava.jpg ← 背景图
新:
spines/<名>/<名>.json | <名>.atlas | <页…>
scene/<图> ← 场景图与背景图
audios/… ← 不动
规则(页的判定用 **atlas 自己声明的页名**,与运行时同一份真相,不做名字启发式):
1. 每个 `<atlas>` 的 stem 建 `spines/<stem>/`,把该 atlas 与它声明的页搬进去;
2. `effects/*.json` → `spines/<stem>/<stem>.json`;
3. `images/` 里剩下的文件(含 `images/scene/*`)→ `scene/`;
4. 按实际搬动结果重写 `preset.template.json` 里所有 `./…` 路径。
用法:
python .scratch/migrate-walls.py # dry-run,只打印
python .scratch/migrate-walls.py --apply # 真搬 + 改预设
"""
from __future__ import annotations
import json
import re
import shutil
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
WALLS = ROOT / "wallpapers"
APPLY = "--apply" in sys.argv
PAGE_EXT = re.compile(r"\.(png|webp|jpg|jpeg)$", re.IGNORECASE)
def atlas_pages(text: str) -> list[str]:
"""取 atlas 声明的页名(非属性行、以图片扩展名结尾的行)。"""
pages = []
for raw in text.splitlines():
line = raw.strip()
if line and ":" not in line and PAGE_EXT.search(line):
pages.append(line)
return pages
def migrate(wall: Path) -> dict[str, str]:
"""搬一档壁纸,返回 {旧相对路径: 新相对路径}(相对壁纸目录,不带 ./)。"""
moves: dict[str, str] = {}
# 1) atlas 及其声明的页
atlases = list((wall / "images").rglob("*.atlas")) + list((wall / "spines").rglob("*.atlas"))
for atlas in atlases:
stem = atlas.stem
dest_dir = wall / "spines" / stem
pages = atlas_pages(atlas.read_text(encoding="utf-8"))
moves[str(atlas.relative_to(wall))] = f"spines/{stem}/{atlas.name}"
for page in pages:
src = atlas.parent / page
if src.exists():
moves[str(src.relative_to(wall))] = f"spines/{stem}/{page}"
# 2) 骨架 json
for js in (wall / "effects").glob("*.json") if (wall / "effects").is_dir() else []:
stem = js.stem
moves[str(js.relative_to(wall))] = f"spines/{stem}/{js.name}"
# 3) images/ 下剩下的(含 images/scene/*)→ scene/
if (wall / "images").is_dir():
for path in sorted((wall / "images").rglob("*")):
if path.is_dir():
continue
rel = str(path.relative_to(wall))
if rel in moves:
continue
moves[rel] = f"scene/{path.name}"
# 4) 重写预设(按映射精确替换 ./ 路径)
preset_path = wall / "preset.template.json"
preset = json.loads(preset_path.read_text(encoding="utf-8"))
replaced: list[str] = []
def rewrite(value: object) -> object:
if isinstance(value, str) and value.startswith("./"):
old = value[2:]
new = moves.get(old)
if new:
replaced.append(f"{value} → ./{new}")
return f"./{new}"
if isinstance(value, dict):
return {k: rewrite(v) for k, v in value.items()}
if isinstance(value, list):
return [rewrite(v) for v in value]
return value
new_preset = rewrite(preset)
print(f"\n== {wall.relative_to(ROOT)}")
for old, new in sorted(moves.items()):
print(f" {old:52} → {new}")
for line in replaced:
print(f" 预设: {line}")
if APPLY:
for old, new in moves.items():
src, dst = wall / old, wall / new
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(src), str(dst))
# 清掉空目录
for empty in ["effects", "images"]:
d = wall / empty
if d.is_dir():
shutil.rmtree(d, ignore_errors=True)
preset_path.write_text(json.dumps(new_preset, ensure_ascii=False, indent="\t") + "\n", encoding="utf-8")
return moves
total = 0
for wall in sorted(p for p in WALLS.rglob("*") if (p / "preset.template.json").is_file()):
total += len(migrate(wall))
print(f"\n{'已搬动' if APPLY else '将搬动'} {total} 个文件;{'(dry-run,加 --apply 才真搬)' if not APPLY else ''}")