"""页面场景树(``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)))