IMX6U-Game/tools/unity_to_level.py

423 lines
14 KiB
Python

"""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())