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:
1 parent
b8eee05d78
commit
3f11426964
297 files changed
+216627
-1926
No files matched your search
@@ -0,0 +1,372 @@
|
||||
"""页面场景树(``sceneList``)的解析与"part"提取。
|
||||
|
||||
米哈游活动页用自研引擎(three.js 系)描述场景:顶层是一个 ``sceneList`` 数组,每个元素是一个
|
||||
**场景**(``scene_main`` / ``scene_loading`` / ``scene_game`` …),场景里是一棵树。节点有两类
|
||||
我们关心的负载:
|
||||
|
||||
* ``spine:{id:"main_nike"}`` —— 一具骨架(一具骨架 = 一个视觉元件:人物、云、箱子、光效…)
|
||||
* ``geometry:{type:2,config:{...}}`` + ``material:[{uniforms:[{key:"diffuse",value:"main_sky_jpg"}]}]``
|
||||
—— 一块**几何平面**,贴的是场景图。用户口中的 "geometric" 就是这个(Spine 官方没有这个概念)。
|
||||
|
||||
这里不用正则去"抠字段"——那正是旧实现踩过的坑(父节点的 position/scale 会从子孙子树里被误读)。
|
||||
我们把整段字面量解析成对象再走树,字段天然只属于自己那一层。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from . import jslit
|
||||
|
||||
__all__ = ["Scene", "Part", "find_scene_list", "parse_scene", "pick_default_scene"]
|
||||
|
||||
_SCENE_KEY = "sceneList"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Part:
|
||||
"""场景里的一个可绘制件。"""
|
||||
|
||||
kind: str # "spine" | "image"(有贴图的平面) | "solid"(纯色平面,不需要资源)
|
||||
id: str # 骨架逻辑名,或几何平面贴的图名
|
||||
node_path: str
|
||||
order: int # 树序遍历序 = 绘制层级
|
||||
render_order: int
|
||||
position: tuple[float, float, float] # 世界位移(父 scale 已乘进来)
|
||||
scale: tuple[float, float, float] # 世界缩放(逐层连乘)
|
||||
local_position: tuple[float, float, float] | None
|
||||
local_scale: tuple[float, float, float] | None
|
||||
rotation: tuple[float, float, float] | None
|
||||
geometry_type: int | None
|
||||
auto_matrix: bool
|
||||
geometry_size: tuple[float, float] | None = None
|
||||
geometry_center: tuple[float, float] | None = None
|
||||
modifiers: tuple[str, ...] = ()
|
||||
# 页面在这个节点上指定的动画(spine.defaultAnimation);缺省 = 骨架第一个动画。
|
||||
animation: str | None = None
|
||||
skin: str | None = None
|
||||
time_scale: float | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {
|
||||
"kind": self.kind,
|
||||
"id": self.id,
|
||||
"path": self.node_path,
|
||||
"order": self.order,
|
||||
"renderOrder": self.render_order,
|
||||
"position": list(self.position),
|
||||
"scale": list(self.scale),
|
||||
}
|
||||
if self.local_position is not None:
|
||||
out["localPosition"] = list(self.local_position)
|
||||
if self.local_scale is not None:
|
||||
out["localScale"] = list(self.local_scale)
|
||||
if self.rotation is not None:
|
||||
out["rotation"] = list(self.rotation)
|
||||
if self.geometry_type is not None:
|
||||
out["geometryType"] = self.geometry_type
|
||||
if self.geometry_size is not None:
|
||||
out["geometrySize"] = list(self.geometry_size)
|
||||
if self.geometry_center is not None:
|
||||
out["geometryCenter"] = list(self.geometry_center)
|
||||
if self.auto_matrix:
|
||||
out["autoMatrix"] = True
|
||||
if self.modifiers:
|
||||
out["modifiers"] = list(self.modifiers)
|
||||
if self.animation:
|
||||
out["animation"] = self.animation
|
||||
if self.skin:
|
||||
out["skin"] = self.skin
|
||||
if self.time_scale is not None:
|
||||
out["timeScale"] = self.time_scale
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class Scene:
|
||||
"""一个场景及其全部可绘制件。"""
|
||||
|
||||
id: str
|
||||
ui: tuple[int, int] | None
|
||||
camera: dict[str, Any] | None
|
||||
parts: list[Part] = field(default_factory=list)
|
||||
node_count: int = 0
|
||||
max_depth: int = 0
|
||||
rotated_nodes: int = 0 # 带 rotation 的节点数(当前不参与合成,见下)
|
||||
|
||||
@property
|
||||
def spine_ids(self) -> list[str]:
|
||||
seen: list[str] = []
|
||||
for p in self.parts:
|
||||
if p.kind == "spine" and p.id not in seen:
|
||||
seen.append(p.id)
|
||||
return seen
|
||||
|
||||
def as_dict(self, *, page: str | None = None, game: str | None = None) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {
|
||||
"version": 1,
|
||||
"id": self.id,
|
||||
"ui": list(self.ui) if self.ui else None,
|
||||
"camera": self.camera,
|
||||
"parts": [p.as_dict() for p in self.parts],
|
||||
"stats": {
|
||||
"nodes": self.node_count,
|
||||
"maxDepth": self.max_depth,
|
||||
"spines": len(self.spine_ids),
|
||||
"images": sum(1 for p in self.parts if p.kind == "image"),
|
||||
"solids": sum(1 for p in self.parts if p.kind == "solid"),
|
||||
"rotatedNodes": self.rotated_nodes,
|
||||
},
|
||||
}
|
||||
if game:
|
||||
out["game"] = game
|
||||
if page:
|
||||
out["page"] = page
|
||||
return out
|
||||
|
||||
|
||||
def find_scene_list(text: str) -> list[dict[str, Any]]:
|
||||
"""定位并解析 ``sceneList`` 数组;找不到返回空列表。
|
||||
|
||||
两种书写风格:压缩后的 ``sceneList:[`` 与已序列化的 ``"sceneList": [``。
|
||||
"""
|
||||
for pattern in (f'"{_SCENE_KEY}"', f"{_SCENE_KEY}:"):
|
||||
idx = text.find(pattern)
|
||||
if idx < 0:
|
||||
continue
|
||||
start = text.find("[", idx)
|
||||
if start < 0:
|
||||
continue
|
||||
end = jslit.match_literal(text, start)
|
||||
if end < 0:
|
||||
continue
|
||||
data = jslit.loads(text[start : end + 1])
|
||||
if isinstance(data, list) and data:
|
||||
return [s for s in data if isinstance(s, dict)]
|
||||
return []
|
||||
|
||||
|
||||
def _vec3(value: Any) -> tuple[float, float, float] | None:
|
||||
if isinstance(value, (list, tuple)) and len(value) >= 3:
|
||||
try:
|
||||
return (float(value[0]), float(value[1]), float(value[2]))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return (float(value),) * 3
|
||||
return None
|
||||
|
||||
|
||||
def _material_info(node: dict[str, Any]) -> tuple[str | None, bool]:
|
||||
"""返回(diffuse 名, 是否真的用贴图)。
|
||||
|
||||
``defines.USE_TEXTURE == 0`` 的平面是**纯色块**(diffuse 通常写 "DEFAULT"),
|
||||
页面上并不存在对应图片;把它当资源去下载只会抓到一个不存在的 URL。
|
||||
"""
|
||||
diffuse: str | None = None
|
||||
textured = True
|
||||
for material in node.get("material") or []:
|
||||
if not isinstance(material, dict):
|
||||
continue
|
||||
defines = material.get("defines")
|
||||
if isinstance(defines, dict) and defines.get("USE_TEXTURE") == 0:
|
||||
textured = False
|
||||
for uniform in material.get("uniforms") or []:
|
||||
if isinstance(uniform, dict) and uniform.get("key") == "diffuse":
|
||||
value = uniform.get("value")
|
||||
if isinstance(value, str):
|
||||
diffuse = value
|
||||
return diffuse, textured
|
||||
|
||||
|
||||
def _modifiers(node: dict[str, Any]) -> tuple[str, ...]:
|
||||
"""节点的 modifier id 列表(``cacheContainer`` = 渲染到贴图缓冲、``CSS3DObject`` = DOM 叠层)。"""
|
||||
ids: list[str] = []
|
||||
for modifier in node.get("modifier") or []:
|
||||
if isinstance(modifier, dict) and isinstance(modifier.get("id"), str):
|
||||
ids.append(modifier["id"])
|
||||
return tuple(ids)
|
||||
|
||||
|
||||
def mesh_bbox(mesh: Any) -> tuple[tuple[float, float], tuple[float, float]] | None:
|
||||
"""glTF 网格的顶点包围盒 → (尺寸, 相对节点原点的中心)。
|
||||
|
||||
`position.array` 是扁平的 xyz 三元组序列(实测这些网格都是四边形)。中心不为零时,
|
||||
平面并不以节点原点为中心,画的时候要按它偏移。
|
||||
"""
|
||||
if not isinstance(mesh, dict):
|
||||
return None
|
||||
position = ((mesh.get("attributes") or {}).get("position") or {})
|
||||
array = position.get("array")
|
||||
if not isinstance(array, list) or len(array) < 9:
|
||||
return None
|
||||
xs: list[float] = []
|
||||
ys: list[float] = []
|
||||
for index, value in enumerate(array):
|
||||
if not isinstance(value, (int, float)):
|
||||
continue
|
||||
if index % 3 == 0:
|
||||
xs.append(float(value))
|
||||
elif index % 3 == 1:
|
||||
ys.append(float(value))
|
||||
if not xs or not ys:
|
||||
return None
|
||||
return (
|
||||
(max(xs) - min(xs), max(ys) - min(ys)),
|
||||
((min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2),
|
||||
)
|
||||
|
||||
|
||||
def parse_scene(
|
||||
scene: dict[str, Any],
|
||||
geometries: dict[str, Any] | None = None,
|
||||
timeline: dict[str, Any] | None = None,
|
||||
) -> Scene:
|
||||
"""把一棵场景树摊平成 part 列表,并合成世界变换。
|
||||
|
||||
合成语义照抄引擎(three.js 系)的节点语义,与旧项目 v2 侧车一致:
|
||||
world_position = parent_position + parent_scale ⊙ local_position
|
||||
world_scale = parent_scale ⊙ local_scale
|
||||
旋转暂不参与合成(旧项目同样如此)——只在 stats 里报出数量,别假装算了。
|
||||
"""
|
||||
scene_id = str(scene.get("id") or scene.get("name") or "scene")
|
||||
config = scene.get("sceneConfig") or {}
|
||||
ui = None
|
||||
if isinstance(config, dict) and config.get("uiWidth") and config.get("uiHeight"):
|
||||
ui = (int(config["uiWidth"]), int(config["uiHeight"]))
|
||||
|
||||
camera = None
|
||||
cameras = scene.get("camera")
|
||||
if isinstance(cameras, list) and cameras:
|
||||
first = cameras[0]
|
||||
if isinstance(first, dict):
|
||||
camera = {
|
||||
"id": first.get("id"),
|
||||
"position": first.get("position"),
|
||||
"rotation": first.get("rotation"),
|
||||
"camera": first.get("camera"),
|
||||
}
|
||||
|
||||
result = Scene(id=scene_id, ui=ui, camera=camera)
|
||||
order = 0
|
||||
|
||||
def walk(node: Any, path: str, depth: int, parent_pos: tuple[float, float, float], parent_scale: tuple[float, float, float]) -> None:
|
||||
nonlocal order
|
||||
if not isinstance(node, dict):
|
||||
return
|
||||
name = str(node.get("name") or node.get("id") or "?")
|
||||
node_path = f"{path}/{name}"
|
||||
result.node_count += 1
|
||||
result.max_depth = max(result.max_depth, depth)
|
||||
|
||||
# 时间线**按节点名覆盖** position/scale(源码:parsePath → getObjectByName → target[prop] = 值)。
|
||||
# 静态值是入场前的状态,稳定态要用轨道末帧值。
|
||||
overridden_pos = None
|
||||
overridden_scale = None
|
||||
if timeline:
|
||||
tracks = timeline.get(scene_id) or {}
|
||||
overridden_pos = (tracks.get("positions") or {}).get(name)
|
||||
overridden_scale = (tracks.get("scales") or {}).get(name)
|
||||
local_pos = _vec3(overridden_pos) if overridden_pos else _vec3(node.get("position"))
|
||||
local_scale = _vec3(overridden_scale) if overridden_scale else _vec3(node.get("scale"))
|
||||
rotation = _vec3(node.get("rotation"))
|
||||
if rotation and any(abs(v) > 1e-9 for v in rotation):
|
||||
result.rotated_nodes += 1
|
||||
|
||||
world_pos = parent_pos
|
||||
if local_pos:
|
||||
world_pos = tuple(parent_pos[i] + parent_scale[i] * local_pos[i] for i in range(3))
|
||||
world_scale = parent_scale
|
||||
if local_scale:
|
||||
world_scale = tuple(parent_scale[i] * local_scale[i] for i in range(3))
|
||||
|
||||
spine = node.get("spine")
|
||||
spine_id = spine.get("id") if isinstance(spine, dict) else None
|
||||
spine_animation = None
|
||||
spine_skin = None
|
||||
spine_time_scale = None
|
||||
if isinstance(spine, dict):
|
||||
# 页面在这个节点上指定的播放方式——不读它就会去播骨架的第一个动画(常常是入场动画 in)。
|
||||
if isinstance(spine.get("defaultAnimation"), str):
|
||||
spine_animation = spine["defaultAnimation"]
|
||||
if isinstance(spine.get("skin"), str):
|
||||
spine_skin = spine["skin"]
|
||||
if isinstance(spine.get("timeScale"), (int, float)):
|
||||
spine_time_scale = float(spine["timeScale"])
|
||||
diffuse, textured = _material_info(node)
|
||||
modifiers = _modifiers(node)
|
||||
geometry = node.get("geometry")
|
||||
geometry_type = geometry.get("type") if isinstance(geometry, dict) else None
|
||||
geometry_size = None
|
||||
geometry_center = None
|
||||
if isinstance(geometry, dict):
|
||||
config = geometry.get("config")
|
||||
if isinstance(config, dict) and config.get("width") and config.get("height"):
|
||||
geometry_size = (float(config["width"]), float(config["height"]))
|
||||
elif geometry.get("type") == 1 and isinstance(geometry.get("id"), str) and geometries:
|
||||
box = mesh_bbox(geometries.get(geometry["id"]))
|
||||
if box is not None:
|
||||
geometry_size, geometry_center = box
|
||||
|
||||
if isinstance(spine_id, str) and spine_id:
|
||||
result.parts.append(
|
||||
Part(
|
||||
kind="spine",
|
||||
id=spine_id,
|
||||
node_path=node_path,
|
||||
order=order,
|
||||
render_order=int(node.get("renderOrder") or 0),
|
||||
position=world_pos,
|
||||
scale=world_scale,
|
||||
local_position=local_pos,
|
||||
local_scale=local_scale,
|
||||
rotation=rotation,
|
||||
geometry_type=None,
|
||||
auto_matrix=bool(node.get("autoMatrix")),
|
||||
modifiers=modifiers,
|
||||
animation=spine_animation,
|
||||
skin=spine_skin,
|
||||
time_scale=spine_time_scale,
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
elif isinstance(diffuse, str) and diffuse and geometry is not None:
|
||||
result.parts.append(
|
||||
Part(
|
||||
kind="image" if textured and diffuse != "DEFAULT" else "solid",
|
||||
id=diffuse,
|
||||
node_path=node_path,
|
||||
order=order,
|
||||
render_order=int(node.get("renderOrder") or 0),
|
||||
position=world_pos,
|
||||
scale=world_scale,
|
||||
local_position=local_pos,
|
||||
local_scale=local_scale,
|
||||
rotation=rotation,
|
||||
geometry_type=int(geometry_type) if isinstance(geometry_type, int) else None,
|
||||
auto_matrix=bool(node.get("autoMatrix")),
|
||||
geometry_size=geometry_size,
|
||||
geometry_center=geometry_center,
|
||||
modifiers=modifiers,
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
|
||||
for child in node.get("children") or []:
|
||||
walk(child, node_path, depth + 1, world_pos, world_scale)
|
||||
|
||||
for child in scene.get("children") or []:
|
||||
walk(child, scene_id, 1, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0))
|
||||
return result
|
||||
|
||||
|
||||
def pick_default_scene(scenes: list[Scene]) -> Scene | None:
|
||||
"""默认场景 = 骨架最多的那个;并列时取先出现的。
|
||||
|
||||
这与旧项目的规则一致("取 spine 最多的一个场景")。它只是**默认值**:
|
||||
一旦 `sources.yml` 或 `selection.yml` 指定了场景,就以人/记录为准。
|
||||
"""
|
||||
if not scenes:
|
||||
return None
|
||||
return max(scenes, key=lambda s: (len(s.spine_ids), -scenes.index(s)))
|
||||
Reference in new issue
Block a user