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

+76
View File
@@ -0,0 +1,76 @@
"""Spine atlas 文本的解析与页名规范化。
.atlas 是文本格式:**第一行是贴图页文件名**,接着 `size:` / `filter:` / `format:` / `scale:`
等页面头,之后才是各区域的 `bounds` / `offsets` / `rotate`。换页就是再来一行页文件名。
两种书写风格都要吃(同一批页面里都存在):
size:498,330 ← 紧凑式(nico-tea / zhidong-wonder / kv45)
size: 256, 256 ← 带空格式(get-memory)
抓取期我们只关心两件事:**这具骨架需要哪些贴图页**,以及**页名能不能原样落盘**
(运行时按第一行去请求贴图页,所以文件名必须与第一行逐字一致)。
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
__all__ = ["AtlasInfo", "parse", "page_names", "retarget_pages"]
_PAGE_EXT = re.compile(r"\.(png|webp|jpg|jpeg)$", re.IGNORECASE)
_PAGE_HEAD = re.compile(r"^(size|format|filter|repeat|pma|scale)\s*:", re.IGNORECASE)
_REGION_ATTR = re.compile(
r"^(bounds|offsets|rotate|xy|orig|index|split|pad|width|height)\s*:", re.IGNORECASE
)
@dataclass
class AtlasInfo:
"""一具骨架的 atlas 摘要。"""
pages: list[str] = field(default_factory=list)
regions: list[str] = field(default_factory=list)
region_pages: list[str | None] = field(default_factory=list)
@property
def multi_page(self) -> bool:
return len(self.pages) > 1
def parse(text: str) -> AtlasInfo:
"""解析 atlas 文本。无法识别的行按区域名处理(atlas 里非属性行就是区域名)。"""
info = AtlasInfo()
current: str | None = None
for raw in text.splitlines():
line = raw.strip()
if not line:
current = None
continue
if ":" not in line and _PAGE_EXT.search(line):
current = line
info.pages.append(line)
continue
if _PAGE_HEAD.match(line) or _REGION_ATTR.match(line):
continue
info.regions.append(line)
info.region_pages.append(current)
return info
def page_names(text: str) -> list[str]:
"""只要贴图页名(顺序即 atlas 里声明的顺序)。"""
return parse(text).pages
def retarget_pages(text: str, mapping: dict[str, str]) -> str:
"""把 atlas 里的页名按 mapping 改写(只改整行的页名行,不碰区域名)。"""
out: list[str] = []
for raw in text.splitlines():
line = raw.strip()
if line and ":" not in line and _PAGE_EXT.search(line) and line in mapping:
out.append(mapping[line])
else:
out.append(raw)
tail = "\n" if text.endswith("\n") else ""
return "\n".join(out) + tail