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>
This commit is contained in:
500
tools/bake_cocos_level_prefabs.py
Normal file
500
tools/bake_cocos_level_prefabs.py
Normal file
@@ -0,0 +1,500 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user