#!/usr/bin/env python3 """ 将 levels-database.json 烘焙为 Cocos 关卡预制体(.prefab + .prefab.meta)。 预制体结构(对齐 Unity LevelN.prefab): Level{N} ├── Border ← 墙块 Sprite 子节点(纯视觉) ├── Ground ← 地面 Sprite 子节点 └── (挂 LevelMapData:ground/border 逻辑 JSON) 用法: python3 tools/bake_cocos_level_prefabs.py \\ --db assets/level-data/levels-database.json \\ --out-dir assets/bundle-level-prefabs/level-prefabs \\ --limit 0 """ from __future__ import annotations import argparse import json import random import re import string import uuid from pathlib import Path from level_id import LEVEL_ID_BASE, touch_database LAYER_DEFAULT = 1073741824 LAYER_UI_2D = 33554432 # 编辑器 2D 视图可见(对齐 SpriteSplash.prefab) CELL = 100 def resolve_level_map_data_type() -> str: """从 Creator 编译产物读取 LevelMapData 的 __type__(_RF.push 第二参数)。""" chunk = Path("temp/programming/packer-driver/targets/preview/chunks/bb/bbf6a3729c922dc4638933a67da37eeb23e90aee.js") if chunk.is_file(): text = chunk.read_text(encoding="utf-8", errors="ignore") m = re.search(r'_RF\.push\(\{\},\s*"([^"]+)",\s*"LevelMapData"', text) if m: return m.group(1) return "d4e5fanuMlNDh8qO0xdbn+K" LEVEL_MAP_DATA_TYPE = resolve_level_map_data_type() GRID_SNAP_HELPER_TYPE = "739b2brZKNN16CEy+hU2Yo2" DEFAULT_SPRITE_UUID = "5625da25-9915-416f-be60-c6decb355672@f9941" TILE_W = 101 TILE_H = 80 # Unity Texture/*.png.meta spritePivot TILE_PIVOTS: dict[str, tuple[float, float]] = { "Baseblock": (0.5, 0.92), "JumpBlock": (0.5, 0.77), "WallBlock": (0.5, 0.67), "kuai11": (0.5, 1.01), } def tile_pivot(tile_name: str) -> tuple[float, float]: return TILE_PIVOTS.get(tile_name, (0.5, 0.92)) def tile_draw_size(width: int, height: int) -> tuple[float, float]: """宽度贴满格子 100px(与 TileSizes.getTileDrawSize 一致)。""" scale = CELL / width return width * scale, height * scale def theme_from_cfg(cfg: dict, override: str) -> str: if override: return override t = cfg.get("theme") if isinstance(t, str) and t: return t return "silu" def ground_sprite_path(theme: str, tile_name: str) -> str: key = tile_name if tile_name in ("Baseblock", "JumpBlock") else "Baseblock" return f"textures/{theme}/{key}" def border_sprite_path(theme: str, tile_val) -> str: if tile_val is True or tile_val is None: name = "WallBlock" elif isinstance(tile_val, str): name = tile_val else: name = "WallBlock" return f"textures/{theme}/{name}" def read_sprite_size(project: Path, texture_path: str) -> tuple[int, int]: meta_path = project / "assets/resources" / f"{texture_path}.png.meta" if meta_path.is_file(): try: meta = json.loads(meta_path.read_text(encoding="utf-8")) ud = meta.get("subMetas", {}).get("f9941", {}).get("userData", {}) w, h = ud.get("width"), ud.get("height") if w and h: return int(w), int(h) except Exception: pass return TILE_W, TILE_H def rebuild_index_from_disk(out: Path) -> dict[str, str]: index: dict[str, str] = {} for p in sorted(out.glob("Level*.prefab")): lid = p.stem.replace("Level", "") if lid.isdigit(): index[lid] = f"level-prefabs/Level{lid}" return index def write_prefab_index(project: Path, out: Path, index: dict[str, str]) -> Path: """写入 tools/(非 assets),避免 Cocos 编辑器锁定 _index.json 报 Can not change asset。""" index_path = project / "tools" / "level-prefab-index.json" index_path.parent.mkdir(parents=True, exist_ok=True) index_path.write_text(json.dumps(index, indent=2), encoding="utf-8") return index_path def write_prefab_uuid_index(project: Path, out: Path) -> Path: """写入 assets/level-data/level-prefab-uuids.json,供编辑器预览 assetManager.loadAny(uuid)。""" uuids: dict[str, str] = {} for meta in sorted(out.glob("Level*.prefab.meta")): m = re.match(r"Level(\d+)\.prefab\.meta$", meta.name) if not m: continue try: data = json.loads(meta.read_text(encoding="utf-8")) uid = data.get("uuid") if uid: uuids[m.group(1)] = uid except Exception: continue uuid_path = project / "assets" / "level-data" / "level-prefab-uuids.json" uuid_path.parent.mkdir(parents=True, exist_ok=True) uuid_path.write_text(json.dumps(uuids, indent=2), encoding="utf-8") return uuid_path def read_sprite_uuid(project: Path, texture_path: str) -> str: meta_path = project / "assets/resources" / f"{texture_path}.png.meta" if meta_path.is_file(): try: meta = json.loads(meta_path.read_text(encoding="utf-8")) base = meta.get("uuid") if base: return f"{base}@f9941" except Exception: pass return DEFAULT_SPRITE_UUID def uid() -> str: return str(uuid.uuid4()) def vec3(x: float, y: float, z: float = 0) -> dict: return {"__type__": "cc.Vec3", "x": x, "y": y, "z": z} def quat() -> dict: return {"__type__": "cc.Quat", "x": 0, "y": 0, "z": 0, "w": 1} def cell_pos(x: int, y: int) -> tuple[float, float]: """Unity Tilemap tileAnchor (0.5,0.5):格子中心,CellSize (1,0.5,1)。""" half_w = CELL * 0.5 half_h = CELL * 0.25 return (x - y) * half_w, (x + y) * half_h + half_h def file_id() -> str: chars = string.ascii_letters + string.digits return "".join(random.choices(chars, k=22)) def finalize_prefab_infos(objs: list[dict], root_id: int) -> None: """每个节点必须挂 cc.PrefabInfo,否则编辑器 open prefab 会报 instance null。""" for obj in objs: if obj.get("__type__") != "cc.Node": continue pid = len(objs) objs.append( { "__type__": "cc.PrefabInfo", "root": {"__id__": root_id}, "asset": {"__id__": 0}, "fileId": file_id(), } ) obj["_prefab"] = {"__id__": pid} class Builder: def __init__(self) -> None: self.objs: list[dict] = [] def add(self, obj: dict) -> int: self.objs.append(obj) return len(self.objs) - 1 def comp_prefab_info(self) -> int: return self.add({"__type__": "cc.CompPrefabInfo", "fileId": file_id()}) def node( self, name: str, parent: int | None, children: list[int], components: list[int], x: float, y: float, layer: int = LAYER_UI_2D, ) -> int: return self.add( { "__type__": "cc.Node", "_name": name, "_objFlags": 0, "_parent": {"__id__": parent} if parent is not None else None, "_children": [{"__id__": c} for c in children], "_active": True, "_components": [{"__id__": c} for c in components], "_prefab": None, "_lpos": vec3(x, y, 0), "_lrot": quat(), "_lscale": vec3(1, 1, 1), "_layer": layer, "_euler": vec3(0, 0, 0), "_id": "", } ) def ui_transform(self, node_id: int, anchor: tuple[float, float], size: tuple[int, int]) -> int: cpi = self.comp_prefab_info() ax, ay = anchor tw, th = size ui_id = self.add( { "__type__": "cc.UITransform", "_name": "", "_objFlags": 0, "node": {"__id__": node_id}, "_enabled": True, "__prefab": {"__id__": cpi}, "_priority": 0, "_contentSize": {"__type__": "cc.Size", "width": tw, "height": th}, "_anchorPoint": {"__type__": "cc.Vec2", "x": ax, "y": ay}, "_id": "", } ) return ui_id def sprite_comp(self, node_id: int, sprite_uuid: str, alpha: int = 255) -> int: cpi = self.comp_prefab_info() return self.add( { "__type__": "cc.Sprite", "_name": "", "_objFlags": 0, "node": {"__id__": node_id}, "_enabled": True, "__prefab": {"__id__": cpi}, "_visFlags": 0, "_customMaterial": None, "_srcBlendFactor": 2, "_dstBlendFactor": 4, "_color": { "__type__": "cc.Color", "r": 255, "g": 255, "b": 255, "a": alpha, }, "_spriteFrame": {"__uuid__": sprite_uuid}, "_type": 0, "_fillType": 0, "_sizeMode": 0, "_fillCenter": {"__type__": "cc.Vec2", "x": 0, "y": 0}, "_fillStart": 0, "_fillRange": 0, "_isTrimmedMode": True, "_useGrayscale": False, "_atlas": None, "_id": "", } ) def grid_snap_helper(self, node_id: int) -> int: cpi = self.comp_prefab_info() return self.add( { "__type__": GRID_SNAP_HELPER_TYPE, "_name": "", "_objFlags": 0, "node": {"__id__": node_id}, "_enabled": True, "__prefab": {"__id__": cpi}, "snapEnabled": True, "showGrid": True, "gridRadius": 12, "gridPadding": 2, "highlightOccupied": True, "syncNodeNames": True, "_id": "", } ) def level_map_data(self, node_id: int, level_id: int, ground: dict, border: dict, theme: str) -> int: cpi = self.comp_prefab_info() return self.add( { "__type__": LEVEL_MAP_DATA_TYPE, "_name": "", "_objFlags": 0, "node": {"__id__": node_id}, "_enabled": True, "__prefab": {"__id__": cpi}, "levelID": level_id, "groundJson": json.dumps(ground, ensure_ascii=False, separators=(",", ":")), "borderJson": json.dumps(border, ensure_ascii=False, separators=(",", ":")), "theme": theme, "_id": "", } ) def tile( self, name: str, parent: int, x: int, y: int, sprite_uuid: str, tile_name: str, size: tuple[int, int], ) -> int: px, py = cell_pos(x, y) nid = self.node(name, parent, [], [], px, py, LAYER_UI_2D) draw_w, draw_h = tile_draw_size(size[0], size[1]) ui_id = self.ui_transform(nid, tile_pivot(tile_name), (draw_w, draw_h)) sp_id = self.sprite_comp(nid, sprite_uuid) self.objs[nid]["_components"] = [{"__id__": ui_id}, {"__id__": sp_id}] return nid def build_prefab(level_id: int, cfg: dict, project: Path, theme: str = "") -> list: ground = cfg.get("ground") or {} border = cfg.get("border") or {} theme = theme_from_cfg(cfg, theme) b = Builder() root_id = 1 b.add( { "__type__": "cc.Prefab", "_name": f"Level{level_id}", "_objFlags": 0, "_native": "", "data": {"__id__": root_id}, "optimizationPolicy": 0, "persistent": False, } ) b.add( { "__type__": "cc.Node", "_name": f"Level{level_id}", "_objFlags": 0, "_parent": None, "_children": [], "_active": True, "_components": [], "_prefab": None, "_lpos": vec3(0, 0, 0), "_lrot": quat(), "_lscale": vec3(1, 1, 1), "_layer": LAYER_UI_2D, "_euler": vec3(0, 0, 0), "_id": "", } ) border_tiles: list[int] = [] for key in sorted(border.keys()): xs, ys = key.split(",") x, y = int(xs), int(ys) val = border[key] if val is True or val is None: bname = "WallBlock" elif isinstance(val, str): bname = val else: bname = "WallBlock" spr_wall = read_sprite_uuid(project, border_sprite_path(theme, border[key])) wall_path = border_sprite_path(theme, border[key]) border_tiles.append( b.tile(f"b_{x}_{y}", -1, x, y, spr_wall, bname, read_sprite_size(project, wall_path)) ) ground_tiles: list[int] = [] for key in sorted(ground.keys()): xs, ys = key.split(",") x, y = int(xs), int(ys) tile_name = ground[key] gpath = ground_sprite_path(theme, tile_name) spr = read_sprite_uuid(project, gpath) ground_tiles.append( b.tile(f"g_{x}_{y}", -1, x, y, spr, tile_name, read_sprite_size(project, gpath)) ) border_id = b.node("Border", root_id, border_tiles, [], 0, 0, LAYER_UI_2D) ground_id = b.node("Ground", root_id, ground_tiles, [], 0, 0, LAYER_UI_2D) map_data_id = b.level_map_data(root_id, level_id, ground, border, theme) grid_snap_id = b.grid_snap_helper(root_id) # Unity 顺序:Ground 在下,Border 在上 b.objs[root_id]["_children"] = [{"__id__": ground_id}, {"__id__": border_id}] b.objs[root_id]["_components"] = [{"__id__": map_data_id}, {"__id__": grid_snap_id}] for tid in border_tiles: b.objs[tid]["_parent"] = {"__id__": border_id} for tid in ground_tiles: b.objs[tid]["_parent"] = {"__id__": ground_id} finalize_prefab_infos(b.objs, root_id) return b.objs def write_meta(prefab_path: Path) -> None: meta_path = prefab_path.with_suffix(".prefab.meta") prefab_uuid = uid() if meta_path.is_file(): try: existing = json.loads(meta_path.read_text(encoding="utf-8")) if existing.get("uuid"): prefab_uuid = existing["uuid"] except Exception: pass meta = { "ver": "1.1.50", "importer": "prefab", "imported": True, "uuid": prefab_uuid, "files": [".json"], "subMetas": {}, "userData": {"syncNodeName": prefab_path.stem}, } meta_path.write_text(json.dumps(meta, indent=2), encoding="utf-8") def main(): ap = argparse.ArgumentParser() ap.add_argument("--db", required=True) ap.add_argument("--out-dir", required=True) ap.add_argument("--limit", type=int, default=0) ap.add_argument("--level-id", type=int, default=0, help="只烘焙指定关卡,0=全部") ap.add_argument("--max-level-id", type=int, default=0, help="只烘焙 levelID <= 此值的关卡") ap.add_argument("--rebuild-index", action="store_true", help="仅重建 tools/level-prefab-index.json(不烘焙 prefab)") ap.add_argument("--theme", default="", help="贴图主题 silu/sanxing/snow/…,空则从关卡 theme 字段读取") args = ap.parse_args() project = Path(__file__).resolve().parent.parent db = json.loads(Path(args.db).read_text(encoding="utf-8")) out = Path(args.out_dir) out.mkdir(parents=True, exist_ok=True) levels = db["levels"] ids = sorted(int(k) for k in levels.keys()) if args.rebuild_index and args.level_id == 0 and args.max_level_id == 0 and args.limit == 0: index = rebuild_index_from_disk(out) index_path = write_prefab_index(project, out, index) uuid_path = write_prefab_uuid_index(project, out) print(f"Index rebuilt -> {index_path} ({len(index)} entries), uuids -> {uuid_path}") return if args.level_id > 0: ids = [args.level_id] if str(args.level_id) in levels else [] elif args.max_level_id > 0: ids = [i for i in ids if i <= args.max_level_id] elif args.limit > 0: ids = ids[: args.limit] for lid in ids: cfg = levels[str(lid)] p = out / f"Level{lid}.prefab" p.write_text(json.dumps(build_prefab(lid, cfg, project, args.theme), indent=2), encoding="utf-8") write_meta(p) if lid % 100 == 0: print(f" baked Level{lid}") index = rebuild_index_from_disk(out) index_path = write_prefab_index(project, out, index) touch_database(Path(args.db), ids) write_prefab_uuid_index(project, out) print(f"Baked {len(ids)} prefabs -> {out} (index: {len(index)} entries -> {index_path})") if __name__ == "__main__": main()