Files
cocos/tools/bake_cocos_level_prefabs.py
刘宇飞 d393302388 Complete Cocos Creator port with level bundles, themes, and tooling.
Adds level prefabs, theme assets, audio, extensions, and deployment scripts for the Unity WebGL migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 15:30:58 +08:00

501 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
将 levels-database.json 烘焙为 Cocos 关卡预制体(.prefab + .prefab.meta
预制体结构(对齐 Unity LevelN.prefab:
Level{N}
├── Border ← 墙块 Sprite 子节点(纯视觉)
├── Ground ← 地面 Sprite 子节点
└── (挂 LevelMapDataground/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()