#!/usr/bin/env python3 """ 从 Unity 全量导出关卡到单一 JSON 数据库(方便后期增删查改)。 数据来源: - Assets/Scripts/Core/Levels*.cs → spawns / boundary / levelPath - Assets/Prefabs/Level/LevelN.prefab → Ground / Border Tilemap 用法: python3 tools/export_all_levels.py \\ --unity-root "/path/to/主站" \\ --output assets/level-data/levels-database.json """ from __future__ import annotations import argparse import json import re import sys from datetime import datetime, timezone from pathlib import Path from export_unity_levels import ( LEVEL_RE, BOUND_RE, SPAWN_RE, POS_RE, PATH_RE, PDIR_RE, VDIR_RE, kind_from_path, parse_spawns, infer_theme_from_level, build_border_cache, ) from export_unity_prefab_maps import parse_level_prefab from level_id import LEVEL_ID_BASE, normalize_level_id, prefab_resource_path LEVEL_PATH_RE = re.compile( r'levelPath\s*=\s*"?([^",\s]+Level\d+\.prefab)"?', re.I ) def parse_level_block(chunk: str, lid: int) -> dict: bound = BOUND_RE.search(chunk) bx, by = (10, 10) if bound: bx, by = int(bound.group(1)), int(bound.group(2)) path_m = LEVEL_PATH_RE.search(chunk) level_path = path_m.group(1) if path_m else f"Assets/Prefabs/Level/Level{lid}.prefab" ext_id = normalize_level_id(lid) spawns, spawn_paths = parse_spawns(chunk) return { "levelID": ext_id, "boundary": {"x": bx, "y": by}, "spawns": spawns, "theme": infer_theme_from_level(ext_id, spawn_paths), "unityPrefab": level_path.replace("\\", "/"), "cocosPrefab": prefab_resource_path(ext_id), } def parse_levels_cs_file(path: Path) -> dict[int, dict]: text = path.read_text(encoding="utf-8") levels: dict[int, dict] = {} matches = list(LEVEL_RE.finditer(text)) for i, m in enumerate(matches): lid = int(m.group(1)) start = m.end() end = matches[i + 1].start() if i + 1 < len(matches) else len(text) levels[lid] = parse_level_block(text[start:end], lid) return levels def parse_all_levels_cs(core_dir: Path) -> dict[int, dict]: merged: dict[int, dict] = {} for cs in sorted(core_dir.glob("Levels*.cs")): part = parse_levels_cs_file(cs) for lid, L in part.items(): if lid in merged: print(f"warn: duplicate level id {lid} in {cs.name}, keep first", file=sys.stderr) continue merged[lid] = L return merged def prefab_path_for_level(L: dict, prefab_dir: Path) -> Path | None: rel = L.get("unityPrefab", "") if rel: name = Path(rel).name p = prefab_dir / name if p.is_file(): return p p = prefab_dir / f"Level{L['levelID']}.prefab" return p if p.is_file() else None def merge_prefab_maps(L: dict, prefab_dir: Path) -> None: p = prefab_path_for_level(L, prefab_dir) if not p: bx, by = L["boundary"]["x"], L["boundary"]["y"] ring = build_border_cache({L["levelID"]: {**L, "borderKey": f"{bx},{by}"}}) L["border"] = ring[f"{bx},{by}"] L["_mapSource"] = "boundary_ring" return maps = parse_level_prefab(p) # 空边框也要写回,避免旧 JSON 里的 WallBlock 残留 L["ground"] = maps.get("ground") or {} L["border"] = maps.get("border") or {} L["_mapSource"] = "unity_prefab" def strip_internal(L: dict) -> dict: out = { "levelID": L["levelID"], "boundary": L["boundary"], "spawns": L["spawns"], } if L.get("unityPrefab"): out["unityPrefab"] = L["unityPrefab"] if L.get("cocosPrefab"): out["cocosPrefab"] = L["cocosPrefab"] out["ground"] = L.get("ground") or {} out["border"] = L.get("border") or {} if L.get("theme"): out["theme"] = L["theme"] return out def main(): ap = argparse.ArgumentParser() ap.add_argument("--unity-root", required=True, help="Unity 项目根目录(含 Assets)") ap.add_argument("--output", required=True, help="输出 levels-database.json") ap.add_argument("--merge-into", default="", help="合并到已有 JSON(保留范围外关卡)") ap.add_argument("--min-level-id", type=int, default=0, help="仅导出 levelID >= 此值") ap.add_argument("--max-level-id", type=int, default=0, help="仅导出 levelID <= 此值") ap.add_argument("--limit", type=int, default=0, help="仅导出前 N 个关卡(调试用)") ap.add_argument("--skip-prefab-maps", action="store_true", help="不解析 Unity prefab 瓦片(快,地图由 Cocos 预制体承担)") args = ap.parse_args() unity = Path(args.unity_root) core = unity / "Assets/Scripts/Core" prefab_dir = unity / "Assets/Prefabs/Level" if not core.is_dir(): print(f"Core dir not found: {core}", file=sys.stderr) sys.exit(1) print("Parsing Levels*.cs …") levels = parse_all_levels_cs(core) ids = sorted(levels.keys()) if args.min_level_id > 0: ids = [i for i in ids if i >= args.min_level_id] if args.max_level_id > 0: ids = [i for i in ids if i <= args.max_level_id] if args.limit > 0: ids = ids[: args.limit] levels = {k: levels[k] for k in ids} print(f"Level definitions: {len(levels)} ({ids[0] if ids else '?'}-{ids[-1] if ids else '?'})") from_prefab = 0 from_ring = 0 if args.skip_prefab_maps: print("Skipping prefab Tilemaps (--skip-prefab-maps)") else: print("Merging prefab Tilemaps …") for i, lid in enumerate(ids): if (i + 1) % 200 == 0: print(f" … {i + 1}/{len(ids)}") merge_prefab_maps(levels[lid], prefab_dir) if levels[lid].get("_mapSource") == "unity_prefab": from_prefab += 1 else: from_ring += 1 exported = {str(levels[lid]["levelID"]): strip_internal(levels[lid]) for lid in ids} if args.merge_into: merge_path = Path(args.merge_into) if merge_path.is_file(): print(f"Merging into {merge_path} …") base = json.loads(merge_path.read_text(encoding="utf-8")) base_levels = base.get("levels", {}) base_levels.update(exported) payload = { **base, "version": 2, "generatedAt": datetime.now(timezone.utc).isoformat(), "source": "Unity Levels*.cs + Assets/Prefabs/Level/*.prefab (merged)", "stats": { "total": len(base_levels), "withPrefabTilemap": from_prefab, "withBoundaryRing": from_ring, "exportedRange": len(exported), }, "levelIdBase": LEVEL_ID_BASE, "levels": base_levels, } else: print(f"warn: merge target missing, writing export only: {merge_path}", file=sys.stderr) payload = { "version": 2, "generatedAt": datetime.now(timezone.utc).isoformat(), "source": "Unity Levels*.cs + Assets/Prefabs/Level/*.prefab", "stats": { "total": len(exported), "withPrefabTilemap": from_prefab, "withBoundaryRing": from_ring, }, "levelIdBase": LEVEL_ID_BASE, "levels": exported, } else: payload = { "version": 2, "generatedAt": datetime.now(timezone.utc).isoformat(), "source": "Unity Levels*.cs + Assets/Prefabs/Level/*.prefab", "stats": { "total": len(exported), "withPrefabTilemap": from_prefab, "withBoundaryRing": from_ring, }, "levelIdBase": LEVEL_ID_BASE, "levels": exported, } out = Path(args.output) out.parent.mkdir(parents=True, exist_ok=True) out.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") print(f"Wrote {out} ({out.stat().st_size // 1024} KB)") print(f" prefab tilemap: {from_prefab}, fallback ring: {from_ring}") if __name__ == "__main__": main()