#!/usr/bin/env python3 """ 从 Cocos 工程导出 levels-database.json(权威数据源)。 地图 / 主题:level-prefabs/Level{id}.prefab 上的 LevelMapData(groundJson / borderJson / theme) 实体 spawn / boundary:保留 assets/level-data/levels-database.json 中已有条目(关卡编辑器维护) Unity 主站仅作 ID 对照参考,不参与本导出。 """ from __future__ import annotations import argparse import json import re import sys from datetime import datetime, timezone from pathlib import Path from level_id import LEVEL_ID_BASE, level_db_path, normalize_level_id, prefab_resource_path LEVEL_MAP_TYPE = "d4e5fanuMlNDh8qO0xdbn+K" PREFAB_NAME_RE = re.compile(r"^Level(\d+)\.prefab$", re.I) def parse_prefab_map_data(prefab_path: Path) -> dict | None: try: objs = json.loads(prefab_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as e: print(f"warn: skip {prefab_path.name}: {e}", file=sys.stderr) return None if not isinstance(objs, list): return None for obj in objs: if not isinstance(obj, dict): continue if obj.get("__type__") != LEVEL_MAP_TYPE and "groundJson" not in obj: continue if "groundJson" not in obj and "levelID" not in obj: continue level_id = int(obj.get("levelID") or 0) ground = {} border = {} try: ground = json.loads(obj.get("groundJson") or "{}") except json.JSONDecodeError: pass try: border = json.loads(obj.get("borderJson") or "{}") except json.JSONDecodeError: pass theme = str(obj.get("theme") or "").strip() or None return { "levelID": level_id, "ground": ground if isinstance(ground, dict) else {}, "border": border if isinstance(border, dict) else {}, "theme": theme, } return None def load_existing_db(path: Path) -> dict: if not path.is_file(): return {"levels": {}} try: return json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return {"levels": {}} def merge_entry(level_id: int, map_data: dict | None, prev: dict | None) -> dict: ext_id = normalize_level_id(level_id) out: dict = { "levelID": ext_id, "boundary": (prev or {}).get("boundary") or {"x": 20, "y": 20}, "spawns": list((prev or {}).get("spawns") or []), "cocosPrefab": prefab_resource_path(ext_id), } if prev and prev.get("unityPrefab"): out["unityPrefab"] = prev["unityPrefab"] if map_data: if map_data.get("ground"): out["ground"] = map_data["ground"] if map_data.get("border"): out["border"] = map_data["border"] if map_data.get("theme"): out["theme"] = map_data["theme"] if map_data.get("levelID") and map_data["levelID"] > 0: out["levelID"] = normalize_level_id(map_data["levelID"]) elif prev: for k in ("ground", "border", "theme", "entityTextures"): if prev.get(k) is not None: out[k] = prev[k] return out def export_db(project: Path, output: Path, prefab_dir: Path, level_id: int | None = None) -> dict: existing = load_existing_db(output) prev_levels = existing.get("levels") or {} levels: dict[str, dict] = dict(prev_levels) prefab_files = sorted(prefab_dir.glob("Level*.prefab"), key=lambda p: p.name) if level_id is not None: target = normalize_level_id(level_id) prefab_files = [ p for p in prefab_files if (m := PREFAB_NAME_RE.match(p.name)) and int(m.group(1)) == target ] if not prefab_files and level_id is None: print(f"error: no prefabs in {prefab_dir}", file=sys.stderr) sys.exit(1) if level_id is not None and not prefab_files: print(f"error: prefab Level{normalize_level_id(level_id)} not found in {prefab_dir}", file=sys.stderr) sys.exit(1) for pf in prefab_files: m = PREFAB_NAME_RE.match(pf.name) if not m: continue lid = int(m.group(1)) map_data = parse_prefab_map_data(pf) prev = prev_levels.get(str(lid)) or prev_levels.get(str(normalize_level_id(lid))) levels[str(normalize_level_id(lid))] = merge_entry(lid, map_data, prev) if level_id is None: # 保留仅有 DB 条目、尚无预制体的关卡(编辑器先写 spawns) for key, prev in prev_levels.items(): if key not in levels: try: lid = int(key) except ValueError: continue levels[key] = merge_entry(lid, None, prev) prev_stats = existing.get("stats") or {} total_with_maps = sum( 1 for v in levels.values() if (v.get("ground") or v.get("border")) ) return { "version": 2, "generatedAt": datetime.now(timezone.utc).isoformat(), "source": "Cocos level-prefabs LevelMapData + levels-database spawns", "levelIdBase": LEVEL_ID_BASE, "stats": { "total": len(levels), "withPrefabTilemap": total_with_maps, "withBoundaryRing": prev_stats.get("withBoundaryRing", 0), }, "levels": dict(sorted(levels.items(), key=lambda kv: int(kv[0]))), } def resolve_prefab_dir(project: Path) -> Path: """迁移后预制体在 bundle-level-prefabs/level-prefabs,否则仍在 resources 下。""" bundle = project / "assets/bundle-level-prefabs/level-prefabs" legacy = project / "assets/resources/level-prefabs" if bundle.is_dir(): return bundle return legacy def main(): ap = argparse.ArgumentParser(description="从 Cocos 预制体导出 levels-database.json") ap.add_argument( "--project", default=str(Path(__file__).resolve().parent.parent), help="Cocos 工程根目录", ) ap.add_argument( "--output", default="", help="输出 JSON(默认 assets/level-data/levels-database.json)", ) ap.add_argument( "--prefab-dir", default="", help="关卡预制体目录(默认自动检测 bundle-level-prefabs 或 resources)", ) ap.add_argument( "--level-id", type=int, default=None, help="仅导出/合并指定关卡(增量更新 levels-database.json)", ) args = ap.parse_args() project = Path(args.project) output = Path(args.output) if args.output else level_db_path(project) prefab_dir = Path(args.prefab_dir) if args.prefab_dir else resolve_prefab_dir(project) data = export_db(project, output, prefab_dir, args.level_id) output.parent.mkdir(parents=True, exist_ok=True) output.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") print(f"Exported {data['stats']['total']} levels -> {output}") print(f" with prefab tilemap: {data['stats']['withPrefabTilemap']}") if __name__ == "__main__": main()