"""Convert Unity Tilemap data from SampleScene.unity into LevelTotalData.h. Workflow: 1. Parse the Unity scene YAML (no PyYAML dep — pure text scan, because the file is huge and uses Unity-specific tags). 2. For each named Tilemap GameObject (e.g. "foreground", "background"), extract: - m_TileAssetArray : ordered list of tile asset GUIDs - m_Tiles : list of (x, y, m_TileIndex) 3. Map each tile asset GUID -> a project TileId (uint16_t). 4. Crop to a fixed window (origin + width + height, in Unity cell coords) and emit a C++ uint16_t[] array. Unity Y is up; the C++ array is row-major with row 0 at the TOP, so we flip Y on output. Usage: python tools/unity_to_level.py Edit FOREGROUND_GUID_TO_TILEID below to fill in the foreground mapping. """ from __future__ import annotations import os import re import sys from dataclasses import dataclass, field from typing import Dict, List, Optional, Tuple # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) SCENE_PATH = os.path.join(REPO_ROOT, "SampleScene.unity") OUTPUT_PATH = os.path.join( REPO_ROOT, "src", "Apps", "LightGame", "src", "levels", "LevelTotalData.h" ) # Crop window in Unity cell coordinates. Aligned to the `background` # Tilemap's origin (-43, -24). LevelTotal is 96 wide x 54 tall. CROP_ORIGIN_X = -42 CROP_ORIGIN_Y = -24 CROP_WIDTH = 96 CROP_HEIGHT = 54 # TileId values mirror src/Apps/LightGame/src/engine/TileIds.h class TileId: Empty = 0 Background1 = 1 Background2 = 2 Background3 = 3 Background4 = 4 GroundTopGreen = 5 GroundFillGreen = 6 GroundTopGray = 7 GroundFillGray = 8 GroundTopPurple = 9 GroundFillPurple = 10 Platform = 11 Spike = 12 LightPlatformOff = 13 LightPlatformOn = 14 ShadowPlatformOff = 15 ShadowPlatformOn = 16 DoorClosed = 17 DoorOpen = 18 Coin = 19 Flag = 20 Checkpoint = 21 SpikeCeiling = 22 DecorVine = 23 # background Tilemap: m_TileAssetArray order (verified by user). # Indexes 0..3 in the array correspond to TileId 1..4. BACKGROUND_GUID_TO_TILEID: Dict[str, int] = { "7a416b45749852649890b58c40c94382": TileId.Background1, "3e398b33bfe59e8488e6622639e22680": TileId.Background2, "9c0d8493a349d2d4c9394cd4db6c144c": TileId.Background3, "c311fab5d43fea040b20ddf4109be8d4": TileId.Background4, } # foreground Tilemap: m_TileAssetArray order (16 entries in scene). # >>> FILL THIS IN <<< — map each GUID to the matching TileId.* constant. # Use TileId.Empty (= 0) to drop a tile, or comment a line out to leave # it as Empty (the default). FOREGROUND_GUID_TO_TILEID: Dict[str, int] = { "fc664ce3f6082ba478b8d50ca2dce27e": 12, "47a0287d160d89e449cfb63ece6bc64d": 10, "9c787be8025ad3e4ea41faf8641b6542": 19, "d317ceed21dc2844cbe35d377a237065": 9, "228f030a961526742ac581cd160291f2": 1, "5276bbe304866324fbd94b343f5d5716": 1, "47822c4ae0c151848be71ecddbe3e363": 20, "9dae7bdac3edf7e4996e186ac7ea8d42": 8, "f3c8dcd4899bb144795bf74e61529d57": 6, "430a6d0aec468d94d929e481d5082741": 5, "dcd1ff73844513940941a7906732f790": 7, "8caba5ecd24bd3143831d4f04096921f": 17, "c0ea1f55905049745a90ea7d4772f95f": 22, "0f7b94bca5b66ea4e8bf37ac8b2c1528": 16, "67e0c92da848ffa49b3792338de741ac": 14, "1289993ae67654b48a59c5743728e507": 20, } # Map Unity GameObject name -> tile id mapping table. LAYER_MAPPINGS: Dict[str, Dict[str, int]] = { "foreground": FOREGROUND_GUID_TO_TILEID, "background": BACKGROUND_GUID_TO_TILEID, } # --------------------------------------------------------------------------- # Parser # --------------------------------------------------------------------------- @dataclass class TilemapBlock: name: str asset_guids: List[str] = field(default_factory=list) # by m_TileIndex cells: List[Tuple[int, int, int]] = field(default_factory=list) # x, y, idx # Scene file blocks are separated by lines that start with "--- !u!". DOC_SEP = re.compile(r"^--- !u!(\d+) &(\d+)", re.MULTILINE) def split_documents(text: str) -> List[Tuple[int, int, str]]: """Return a list of (class_id, file_id, body) for each YAML doc.""" matches = list(DOC_SEP.finditer(text)) docs: List[Tuple[int, int, str]] = [] for i, m in enumerate(matches): start = m.end() end = matches[i + 1].start() if i + 1 < len(matches) else len(text) docs.append((int(m.group(1)), int(m.group(2)), text[start:end])) return docs def parse_gameobjects(docs) -> Dict[int, str]: """Map GameObject fileID -> name.""" name_re = re.compile(r"^\s*m_Name:\s*(.+?)\s*$", re.MULTILINE) out: Dict[int, str] = {} for class_id, file_id, body in docs: if class_id != 1: # GameObject continue m = name_re.search(body) if m: out[file_id] = m.group(1) return out GAMEOBJECT_REF_RE = re.compile(r"m_GameObject:\s*\{fileID:\s*(\d+)\}") TILE_FIRST_RE = re.compile( r"-\s*first:\s*\{x:\s*(-?\d+),\s*y:\s*(-?\d+),\s*z:\s*-?\d+\}\s*" r"second:\s*\n" r"\s*serializedVersion:\s*\d+\s*\n" r"\s*m_TileIndex:\s*(-?\d+)" ) ASSET_ENTRY_RE = re.compile( r"-\s*m_RefCount:\s*(-?\d+)\s*\n\s*m_Data:\s*\{fileID:\s*\d+,\s*guid:\s*([0-9a-f]+)," ) def parse_tilemaps(docs, go_names: Dict[int, str]) -> Dict[str, TilemapBlock]: """Find Tilemap docs (class 1839735485) and bucket by parent GO name.""" out: Dict[str, TilemapBlock] = {} for class_id, _file_id, body in docs: if class_id != 1839735485: # Tilemap continue m = GAMEOBJECT_REF_RE.search(body) if not m: continue go_id = int(m.group(1)) name = go_names.get(go_id) if not name: continue # Slice out the m_Tiles ... m_AnimatedTiles section so the cell regex # only sees real tile entries. tiles_start = body.find("m_Tiles:") tiles_end = body.find("m_AnimatedTiles", tiles_start if tiles_start >= 0 else 0) tiles_body = body[tiles_start:tiles_end] if tiles_start >= 0 else "" # Slice the m_TileAssetArray section. asset_start = body.find("m_TileAssetArray:") asset_end = body.find("m_TileSpriteArray", asset_start if asset_start >= 0 else 0) asset_body = body[asset_start:asset_end] if asset_start >= 0 else "" # Collect (refcount, guid) entries in their YAML order. asset_entries: List[Tuple[int, str]] = [] for am in ASSET_ENTRY_RE.finditer(asset_body): asset_entries.append((int(am.group(1)), am.group(2))) # Collect raw cell entries (x, y, raw_idx). raw_cells: List[Tuple[int, int, int]] = [] for tm in TILE_FIRST_RE.finditer(tiles_body): raw_cells.append((int(tm.group(1)), int(tm.group(2)), int(tm.group(3)))) # Unity does NOT renumber m_TileIndex when entries are removed from # m_TileAssetArray, so the YAML index of an entry is not necessarily # equal to the m_TileIndex referencing it. Reconstruct the mapping # by aligning the YAML-order asset entries with the *sorted distinct* # m_TileIndex values that share the same refcount/usage-count. from collections import Counter as _Counter idx_counts = _Counter(c[2] for c in raw_cells) sorted_idxs = sorted(idx_counts.keys()) block = TilemapBlock(name=name) if len(sorted_idxs) == len(asset_entries): # Pair entries in array order with idx values in ascending order. # Sanity-check that refcount matches usage count for each pair. for entry, idx in zip(asset_entries, sorted_idxs): refcount, guid = entry actual = idx_counts[idx] if refcount != actual: print( f" [warn] '{name}' refcount mismatch for guid {guid[:8]}: " f"asset refcount={refcount} but idx={idx} appears {actual} times", file=sys.stderr, ) # Grow asset_guids so [idx] -> guid works directly. while len(block.asset_guids) <= idx: block.asset_guids.append("") block.asset_guids[idx] = guid else: print( f" [warn] '{name}' has {len(asset_entries)} asset entries but " f"{len(sorted_idxs)} distinct m_TileIndex values; falling back " f"to positional mapping (results may be wrong)", file=sys.stderr, ) for entry in asset_entries: block.asset_guids.append(entry[1]) block.cells = raw_cells out[name] = block return out # --------------------------------------------------------------------------- # Conversion # --------------------------------------------------------------------------- def block_to_grid( block: TilemapBlock, guid_to_tile: Dict[str, int], origin_x: int, origin_y: int, width: int, height: int, ) -> List[List[int]]: """Convert a TilemapBlock to a row-major grid (row 0 = top). Cells outside the crop window are dropped. Cells whose tile asset has no mapping default to TileId.Empty (and a warning is printed once per GUID). """ grid = [[0 for _ in range(width)] for _ in range(height)] unmapped: Dict[str, int] = {} for x, y, idx in block.cells: if idx < 0 or idx >= len(block.asset_guids): continue guid = block.asset_guids[idx] tile_id = guid_to_tile.get(guid) if tile_id is None: unmapped[guid] = unmapped.get(guid, 0) + 1 continue if tile_id == 0: continue col = x - origin_x # Unity Y is up; flip so row 0 is the top of the array. row = (origin_y + height - 1) - y if 0 <= col < width and 0 <= row < height: grid[row][col] = tile_id if unmapped: print( f" [warn] {len(unmapped)} unmapped GUID(s) in '{block.name}' " f"(treated as Empty):", file=sys.stderr, ) for guid, count in sorted(unmapped.items(), key=lambda kv: -kv[1]): print(f" {guid} x{count}", file=sys.stderr) return grid # --------------------------------------------------------------------------- # C++ emitter # --------------------------------------------------------------------------- HEADER_TEMPLATE = """#pragma once #include "LevelData.h" #include "tile_atlas.h" namespace LightGame {{ namespace LevelTotal {{ static const int32_t kMapWidth = {width}; static const int32_t kMapHeight = {height}; static const int32_t kTileSize = 32; static const uint16_t tiles[] = {{ {tiles_body} }}; static const uint16_t background_tiles[] = {{ {background_body} }}; static const ObjectSpawn spawns[] = {{ {{ GameObjectType::Player, 69, 29, 0, 0, TriggerAction::None, 0 }}, }}; static const RenderData::Image kAtlas(tile_atlas_pixels, tile_atlas_width, tile_atlas_height); static const LevelData data = {{ tiles, kMapWidth, kMapHeight, kTileSize, &kAtlas, tile_atlas_columns, spawns, 1, nullptr, 0, Math::Vector2Int({spawn_px_x}, {spawn_px_y}), 0, 0, {bounds_max_x}, {bounds_max_y}, background_tiles }}; }} }} """ def format_grid(grid: List[List[int]], indent: str = " ") -> str: rows = [] for row in grid: rows.append(indent + ", ".join(str(v) for v in row) + ",") if rows: rows[-1] = rows[-1].rstrip(",") return "\n".join(rows) def emit_header( fg_grid: List[List[int]], bg_grid: List[List[int]], width: int, height: int, tile_size: int = 32, ) -> str: spawn_tile_x, spawn_tile_y = 69, 29 # preserved from current LevelTotal return HEADER_TEMPLATE.format( width=width, height=height, tiles_body=format_grid(fg_grid), background_body=format_grid(bg_grid), spawn_px_x=spawn_tile_x * tile_size, spawn_px_y=spawn_tile_y * tile_size, bounds_max_x=width * tile_size, bounds_max_y=height * tile_size, ) # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main() -> int: if not os.path.isfile(SCENE_PATH): print(f"scene file not found: {SCENE_PATH}", file=sys.stderr) return 1 with open(SCENE_PATH, "r", encoding="utf-8") as f: text = f.read() print(f"reading {SCENE_PATH} ({len(text)} bytes)") docs = split_documents(text) go_names = parse_gameobjects(docs) tilemaps = parse_tilemaps(docs, go_names) print(f"found Tilemaps: {sorted(tilemaps.keys())}") for name, block in tilemaps.items(): print(f" {name}: {len(block.cells)} cells, {len(block.asset_guids)} tile assets") missing = [n for n in ("foreground", "background") if n not in tilemaps] if missing: print(f"missing Tilemap layers: {missing}", file=sys.stderr) return 1 fg_grid = block_to_grid( tilemaps["foreground"], FOREGROUND_GUID_TO_TILEID, CROP_ORIGIN_X, CROP_ORIGIN_Y, CROP_WIDTH, CROP_HEIGHT, ) bg_grid = block_to_grid( tilemaps["background"], BACKGROUND_GUID_TO_TILEID, CROP_ORIGIN_X, CROP_ORIGIN_Y, CROP_WIDTH, CROP_HEIGHT, ) if not FOREGROUND_GUID_TO_TILEID: print( "\n[note] FOREGROUND_GUID_TO_TILEID is empty — foreground will be all 0.\n" " Edit tools/unity_to_level.py and re-run once you have the mapping.", file=sys.stderr, ) out = emit_header(fg_grid, bg_grid, CROP_WIDTH, CROP_HEIGHT) with open(OUTPUT_PATH, "w", encoding="utf-8", newline="\n") as f: f.write(out) print(f"wrote {OUTPUT_PATH}") return 0 if __name__ == "__main__": sys.exit(main())