Adds level prefabs, theme assets, audio, extensions, and deployment scripts for the Unity WebGL migration. Co-authored-by: Cursor <cursoragent@cursor.com>
501 lines
16 KiB
Python
501 lines
16 KiB
Python
#!/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()
|