LDtk import support

This commit is contained in:
John McCardle 2026-02-07 11:30:32 -05:00
commit de7778b147
24 changed files with 26203 additions and 0 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -0,0 +1,438 @@
# ldtk_demo.py - Visual demo of LDtk import system
# Shows prebuilt level content and procedural generation via auto-rules
# Uses the official LDtk TopDown example with real sprite art
#
# Usage:
# Headless: cd build && ./mcrogueface --headless --exec ../tests/demo/screens/ldtk_demo.py
# Interactive: cd build && ./mcrogueface --exec ../tests/demo/screens/ldtk_demo.py
import mcrfpy
from mcrfpy import automation
import sys
# -- Asset Paths -------------------------------------------------------
LDTK_PATH = "../tests/demo/ldtk/Typical_TopDown_example.ldtk"
# -- Load Project ------------------------------------------------------
print("Loading LDtk TopDown example...")
proj = mcrfpy.LdtkProject(LDTK_PATH)
ts = proj.tileset("TopDown_by_deepnight")
texture = ts.to_texture()
rs = proj.ruleset("Collisions")
Terrain = rs.terrain_enum()
print(f" Project: v{proj.version}")
print(f" Tileset: {ts.name} ({ts.tile_count} tiles, {ts.tile_width}x{ts.tile_height}px)")
print(f" Ruleset: {rs.name} ({rs.rule_count} rules, {rs.group_count} groups)")
print(f" Terrain values: {[t.name for t in Terrain]}")
print(f" Levels: {proj.level_names}")
# -- Helper: Info Panel -------------------------------------------------
def make_info_panel(scene, lines, x=560, y=60, w=220, h=None):
"""Create a semi-transparent info panel with text lines."""
if h is None:
h = len(lines) * 22 + 20
panel = mcrfpy.Frame(pos=(x, y), size=(w, h),
fill_color=mcrfpy.Color(20, 20, 30, 220),
outline_color=mcrfpy.Color(80, 80, 120),
outline=1.5)
scene.children.append(panel)
for i, text in enumerate(lines):
cap = mcrfpy.Caption(text=text, pos=(10, 10 + i * 22))
cap.fill_color = mcrfpy.Color(200, 200, 220)
panel.children.append(cap)
return panel
# ======================================================================
# SCREEN 1: Prebuilt Level Content (all 3 levels)
# ======================================================================
print("\nSetting up Screen 1: Prebuilt Levels...")
scene1 = mcrfpy.Scene("ldtk_prebuilt")
bg1 = mcrfpy.Frame(pos=(0, 0), size=(1024, 768),
fill_color=mcrfpy.Color(10, 10, 15))
scene1.children.append(bg1)
title1 = mcrfpy.Caption(text="LDtk Prebuilt Levels (auto-layer tiles from editor)",
pos=(20, 10))
title1.fill_color = mcrfpy.Color(255, 255, 255)
scene1.children.append(title1)
# Load all 3 levels side by side
level_grids = []
level_x_offset = 20
for li, lname in enumerate(proj.level_names):
level = proj.level(lname)
# Find the Collisions layer (has the auto_tiles)
for layer_info in level["layers"]:
if layer_info["name"] == "Collisions" and layer_info.get("auto_tiles"):
lw, lh = layer_info["width"], layer_info["height"]
auto_tiles = layer_info["auto_tiles"]
# Label
label = mcrfpy.Caption(
text=f"{lname} ({lw}x{lh})",
pos=(level_x_offset, 38))
label.fill_color = mcrfpy.Color(180, 220, 255)
scene1.children.append(label)
# Create layer with prebuilt tiles
prebuilt_layer = mcrfpy.TileLayer(
name=f"prebuilt_{lname}", texture=texture,
grid_size=(lw, lh))
prebuilt_layer.fill(-1)
for tile in auto_tiles:
x, y = tile["x"], tile["y"]
if 0 <= x < lw and 0 <= y < lh:
prebuilt_layer.set((x, y), tile["tile_id"])
# Determine display size (scale to fit)
max_w = 310 if li < 2 else 310
max_h = 300
scale = min(max_w / (lw * ts.tile_width),
max_h / (lh * ts.tile_height))
disp_w = int(lw * ts.tile_width * scale)
disp_h = int(lh * ts.tile_height * scale)
grid = mcrfpy.Grid(
grid_size=(lw, lh),
pos=(level_x_offset, 60),
size=(disp_w, disp_h),
layers=[prebuilt_layer])
grid.fill_color = mcrfpy.Color(30, 30, 50)
grid.center = (lw * ts.tile_width // 2,
lh * ts.tile_height // 2)
scene1.children.append(grid)
level_grids.append((lname, lw, lh, len(auto_tiles)))
level_x_offset += disp_w + 20
break
# Info panel
info_lines = [
"LDtk Prebuilt Content",
"",
f"Project: TopDown example",
f"Version: {proj.version}",
f"Tileset: {ts.name}",
f" {ts.tile_count} tiles, {ts.tile_width}x{ts.tile_height}px",
"",
"Levels loaded:",
]
for lname, lw, lh, natiles in level_grids:
info_lines.append(f" {lname}: {lw}x{lh}")
info_lines.append(f" auto_tiles: {natiles}")
make_info_panel(scene1, info_lines, x=20, y=400, w=400)
nav1 = mcrfpy.Caption(
text="[1] Prebuilt [2] Procgen [3] Compare [ESC] Quit",
pos=(20, 740))
nav1.fill_color = mcrfpy.Color(120, 120, 150)
scene1.children.append(nav1)
# ======================================================================
# SCREEN 2: Procedural Generation via Auto-Rules
# ======================================================================
print("\nSetting up Screen 2: Procedural Generation...")
scene2 = mcrfpy.Scene("ldtk_procgen")
bg2 = mcrfpy.Frame(pos=(0, 0), size=(1024, 768),
fill_color=mcrfpy.Color(10, 10, 15))
scene2.children.append(bg2)
title2 = mcrfpy.Caption(
text="LDtk Procedural Generation (auto-rules at runtime)",
pos=(20, 10))
title2.fill_color = mcrfpy.Color(255, 255, 255)
scene2.children.append(title2)
# Generate a procedural dungeon using the LDtk Collisions rules
PW, PH = 32, 24
proc_dm = mcrfpy.DiscreteMap((PW, PH), fill=0)
# Fill everything with walls first
for y in range(PH):
for x in range(PW):
proc_dm.set(x, y, int(Terrain.WALLS))
# Carve out rooms as floor (value 0 = empty/floor in this tileset)
rooms = [
(2, 2, 10, 6), # Room 1: top-left
(16, 2, 13, 6), # Room 2: top-right
(2, 12, 8, 10), # Room 3: bottom-left
(14, 11, 14, 11), # Room 4: bottom-right
(10, 8, 6, 5), # Room 5: center
]
for rx, ry, rw, rh in rooms:
for y in range(ry, min(ry + rh, PH)):
for x in range(rx, min(rx + rw, PW)):
proc_dm.set(x, y, 0) # 0 = floor/empty
# Connect rooms with corridors
corridors = [
# Horizontal corridors
(11, 4, 16, 6), # Room 1 -> Room 2
(9, 12, 14, 14), # Room 3 -> Room 4
# Vertical corridors
(5, 7, 7, 12), # Room 1 -> Room 3
(20, 7, 22, 11), # Room 2 -> Room 4
# Center connections
(10, 9, 14, 11), # Center -> Room 4
]
for cx1, cy1, cx2, cy2 in corridors:
for y in range(cy1, min(cy2 + 1, PH)):
for x in range(cx1, min(cx2 + 1, PW)):
proc_dm.set(x, y, 0)
# Apply auto-rules
proc_layer = mcrfpy.TileLayer(
name="procgen", texture=texture, grid_size=(PW, PH))
proc_layer.fill(-1)
rs.apply(proc_dm, proc_layer, seed=42)
# Stats
wall_count = sum(1 for y in range(PH) for x in range(PW)
if proc_dm.get(x, y) == int(Terrain.WALLS))
floor_count = PW * PH - wall_count
resolved = rs.resolve(proc_dm, seed=42)
matched = sum(1 for t in resolved if t >= 0)
unmatched = sum(1 for t in resolved if t == -1)
print(f" Dungeon: {PW}x{PH}")
print(f" Walls: {wall_count}, Floors: {floor_count}")
print(f" Resolved: {matched} matched, {unmatched} unmatched")
# Display grid
disp_w2 = min(520, PW * ts.tile_width)
disp_h2 = min(520, PH * ts.tile_height)
scale2 = min(520 / (PW * ts.tile_width), 520 / (PH * ts.tile_height))
disp_w2 = int(PW * ts.tile_width * scale2)
disp_h2 = int(PH * ts.tile_height * scale2)
grid2 = mcrfpy.Grid(grid_size=(PW, PH),
pos=(20, 60), size=(disp_w2, disp_h2),
layers=[proc_layer])
grid2.fill_color = mcrfpy.Color(30, 30, 50)
grid2.center = (PW * ts.tile_width // 2, PH * ts.tile_height // 2)
scene2.children.append(grid2)
# Info panel
make_info_panel(scene2, [
"Procedural Dungeon",
"",
f"Grid: {PW}x{PH}",
f"Seed: 42",
"",
"Terrain counts:",
f" WALLS: {wall_count}",
f" FLOOR: {floor_count}",
"",
"Resolution:",
f" Matched: {matched}/{PW*PH}",
f" Unmatched: {unmatched}",
"",
f"Rules: {rs.rule_count} total",
f"Groups: {rs.group_count}",
"",
"5 rooms + corridors",
"carved from solid walls",
], x=disp_w2 + 40, y=60, w=260)
nav2 = mcrfpy.Caption(
text="[1] Prebuilt [2] Procgen [3] Compare [ESC] Quit",
pos=(20, 740))
nav2.fill_color = mcrfpy.Color(120, 120, 150)
scene2.children.append(nav2)
# ======================================================================
# SCREEN 3: Side-by-Side Comparison
# ======================================================================
print("\nSetting up Screen 3: Comparison...")
scene3 = mcrfpy.Scene("ldtk_compare")
bg3 = mcrfpy.Frame(pos=(0, 0), size=(1024, 768),
fill_color=mcrfpy.Color(10, 10, 15))
scene3.children.append(bg3)
title3 = mcrfpy.Caption(
text="LDtk: Prebuilt vs Re-resolved (same tileset, same rules)",
pos=(20, 10))
title3.fill_color = mcrfpy.Color(255, 255, 255)
scene3.children.append(title3)
# Use World_Level_1 (16x16, square, compact)
cmp_level = proj.level("World_Level_1")
cmp_layer = None
for layer in cmp_level["layers"]:
if layer["name"] == "Collisions":
cmp_layer = layer
break
cw, ch = cmp_layer["width"], cmp_layer["height"]
cmp_auto_tiles = cmp_layer["auto_tiles"]
cmp_intgrid = cmp_layer["intgrid"]
# Left: Prebuilt tiles from editor
left_label = mcrfpy.Caption(text="Prebuilt (from LDtk editor)", pos=(20, 38))
left_label.fill_color = mcrfpy.Color(180, 220, 255)
scene3.children.append(left_label)
left_layer = mcrfpy.TileLayer(
name="cmp_prebuilt", texture=texture, grid_size=(cw, ch))
left_layer.fill(-1)
for tile in cmp_auto_tiles:
x, y = tile["x"], tile["y"]
if 0 <= x < cw and 0 <= y < ch:
left_layer.set((x, y), tile["tile_id"])
grid_left = mcrfpy.Grid(grid_size=(cw, ch),
pos=(20, 60), size=(350, 350),
layers=[left_layer])
grid_left.fill_color = mcrfpy.Color(30, 30, 50)
grid_left.center = (cw * ts.tile_width // 2, ch * ts.tile_height // 2)
scene3.children.append(grid_left)
# Right: Re-resolved using our engine
right_label = mcrfpy.Caption(
text="Re-resolved (our engine, same IntGrid)", pos=(400, 38))
right_label.fill_color = mcrfpy.Color(180, 255, 220)
scene3.children.append(right_label)
cmp_dm = mcrfpy.DiscreteMap((cw, ch))
for y in range(ch):
for x in range(cw):
cmp_dm.set(x, y, cmp_intgrid[y * cw + x])
right_layer = mcrfpy.TileLayer(
name="cmp_resolved", texture=texture, grid_size=(cw, ch))
right_layer.fill(-1)
rs.apply(cmp_dm, right_layer, seed=42)
grid_right = mcrfpy.Grid(grid_size=(cw, ch),
pos=(400, 60), size=(350, 350),
layers=[right_layer])
grid_right.fill_color = mcrfpy.Color(30, 30, 50)
grid_right.center = (cw * ts.tile_width // 2, ch * ts.tile_height // 2)
scene3.children.append(grid_right)
# Tile comparison stats
cmp_matched = 0
cmp_mismatched = 0
for y in range(ch):
for x in range(cw):
pre = left_layer.at(x, y)
res = right_layer.at(x, y)
if pre == res:
cmp_matched += 1
else:
cmp_mismatched += 1
cmp_total = cw * ch
cmp_pct = (cmp_matched / cmp_total * 100) if cmp_total > 0 else 0
print(f" Comparison Level_1: {cmp_matched}/{cmp_total} match ({cmp_pct:.0f}%)")
# Bottom: Another procgen with different seed
bot_label = mcrfpy.Caption(
text="Procgen (new layout, seed=999)", pos=(20, 430))
bot_label.fill_color = mcrfpy.Color(255, 220, 180)
scene3.children.append(bot_label)
BW, BH = 16, 16
bot_dm = mcrfpy.DiscreteMap((BW, BH), fill=int(Terrain.WALLS))
# Diamond room shape
for y in range(BH):
for x in range(BW):
cx_d = abs(x - BW // 2)
cy_d = abs(y - BH // 2)
if cx_d + cy_d < 6:
bot_dm.set(x, y, 0) # floor
# Add some internal walls (pillars)
for px, py in [(6, 6), (9, 6), (6, 9), (9, 9)]:
bot_dm.set(px, py, int(Terrain.WALLS))
bot_layer = mcrfpy.TileLayer(
name="bot_procgen", texture=texture, grid_size=(BW, BH))
bot_layer.fill(-1)
rs.apply(bot_dm, bot_layer, seed=999)
grid_bot = mcrfpy.Grid(grid_size=(BW, BH),
pos=(20, 460), size=(250, 250),
layers=[bot_layer])
grid_bot.fill_color = mcrfpy.Color(30, 30, 50)
grid_bot.center = (BW * ts.tile_width // 2, BH * ts.tile_height // 2)
scene3.children.append(grid_bot)
# Info
make_info_panel(scene3, [
"Tile-by-Tile Comparison",
f"Level: World_Level_1 ({cw}x{ch})",
"",
f" Matches: {cmp_matched}/{cmp_total}",
f" Mismatches: {cmp_mismatched}/{cmp_total}",
f" Match rate: {cmp_pct:.0f}%",
"",
"Prebuilt has stacked tiles",
"(shadows, outlines, etc.)",
"Our engine picks last match",
"per cell (single layer).",
"",
"Bottom: diamond room +",
"4 pillars, seed=999",
], x=300, y=440, w=340)
nav3 = mcrfpy.Caption(
text="[1] Prebuilt [2] Procgen [3] Compare [ESC] Quit",
pos=(20, 740))
nav3.fill_color = mcrfpy.Color(120, 120, 150)
scene3.children.append(nav3)
# ======================================================================
# Navigation & Screenshots
# ======================================================================
scenes = [scene1, scene2, scene3]
scene_names = ["prebuilt", "procgen", "compare"]
def on_key(key, action):
if action != mcrfpy.InputState.PRESSED:
return
if key == mcrfpy.Key.NUM_1:
mcrfpy.current_scene = scene1
elif key == mcrfpy.Key.NUM_2:
mcrfpy.current_scene = scene2
elif key == mcrfpy.Key.NUM_3:
mcrfpy.current_scene = scene3
elif key == mcrfpy.Key.ESCAPE:
mcrfpy.exit()
for s in scenes:
s.on_key = on_key
# Detect headless mode
is_headless = False
try:
win = mcrfpy.Window.get()
is_headless = "headless" in str(win).lower()
except:
is_headless = True
if is_headless:
for i, (sc, name) in enumerate(zip(scenes, scene_names)):
mcrfpy.current_scene = sc
for _ in range(3):
mcrfpy.step(0.016)
fname = f"ldtk_demo_{name}.png"
automation.screenshot(fname)
print(f" Screenshot: {fname}")
print("\nAll screenshots captured. Done!")
sys.exit(0)
else:
mcrfpy.current_scene = scene1
print("\nLDtk Demo ready!")
print("Press [1] [2] [3] to switch screens, [ESC] to quit")

View file

@ -0,0 +1,183 @@
"""Unit tests for LDtk auto-rule apply (resolve + write to TileLayer)."""
import mcrfpy
import sys
def test_apply_basic():
"""Test applying rules to a TileLayer."""
proj = mcrfpy.LdtkProject("../tests/fixtures/test_project.ldtk")
rs = proj.ruleset("Terrain")
ts = proj.tileset("Test_Tileset")
texture = ts.to_texture()
# Create DiscreteMap
dm = mcrfpy.DiscreteMap((5, 5), fill=0)
for y in range(5):
for x in range(5):
if x == 0 or x == 4 or y == 0 or y == 4:
dm.set(x, y, 1)
elif x == 2 and y == 2:
dm.set(x, y, 3)
else:
dm.set(x, y, 2)
# Create TileLayer and apply
layer = mcrfpy.TileLayer(name="terrain", texture=texture, grid_size=(5, 5))
rs.apply(dm, layer, seed=0)
# Verify some tiles were written
wall_tile = layer.at(0, 0)
assert wall_tile == 0, f"Expected wall tile 0 at (0,0), got {wall_tile}"
floor_tile = layer.at(1, 1)
assert floor_tile >= 0, f"Expected floor tile at (1,1), got {floor_tile}"
# Empty cells (water, value=3) should still be -1 (no rule matches water)
water_tile = layer.at(2, 2)
assert water_tile == -1, f"Expected -1 at water (2,2), got {water_tile}"
print(f" applied: wall={wall_tile}, floor={floor_tile}, water={water_tile}")
def test_apply_preserves_unmatched():
"""Test that unmatched cells retain their original value."""
proj = mcrfpy.LdtkProject("../tests/fixtures/test_project.ldtk")
rs = proj.ruleset("Terrain")
ts = proj.tileset("Test_Tileset")
texture = ts.to_texture()
# Pre-fill layer with a sentinel value
layer = mcrfpy.TileLayer(name="test", texture=texture, grid_size=(3, 3))
layer.fill(99)
# Create empty map - no rules will match
dm = mcrfpy.DiscreteMap((3, 3), fill=0)
rs.apply(dm, layer, seed=0)
# All cells should still be 99 (no rules matched)
for y in range(3):
for x in range(3):
val = layer.at(x, y)
assert val == 99, f"Expected 99 at ({x},{y}), got {val}"
print(" unmatched cells preserved: OK")
def test_apply_type_errors():
"""Test that apply raises TypeError for wrong argument types."""
proj = mcrfpy.LdtkProject("../tests/fixtures/test_project.ldtk")
rs = proj.ruleset("Terrain")
# Wrong first argument type
try:
rs.apply("not_a_dmap", None, seed=0)
assert False, "Should have raised TypeError"
except TypeError:
pass
# Wrong second argument type
dm = mcrfpy.DiscreteMap((3, 3))
try:
rs.apply(dm, "not_a_layer", seed=0)
assert False, "Should have raised TypeError"
except TypeError:
pass
print(" type errors raised correctly: OK")
def test_apply_clipping():
"""Test that apply clips to the smaller of map/layer dimensions."""
proj = mcrfpy.LdtkProject("../tests/fixtures/test_project.ldtk")
rs = proj.ruleset("Terrain")
ts = proj.tileset("Test_Tileset")
texture = ts.to_texture()
# DiscreteMap larger than TileLayer
dm = mcrfpy.DiscreteMap((10, 10), fill=1)
layer = mcrfpy.TileLayer(name="small", texture=texture, grid_size=(3, 3))
layer.fill(-1)
rs.apply(dm, layer, seed=0)
# Layer should have tiles written only within its bounds
for y in range(3):
for x in range(3):
val = layer.at(x, y)
assert val >= 0, f"Expected tile at ({x},{y}), got {val}"
print(" clipping (large map, small layer): OK")
# DiscreteMap smaller than TileLayer
dm2 = mcrfpy.DiscreteMap((2, 2), fill=1)
layer2 = mcrfpy.TileLayer(name="big", texture=texture, grid_size=(5, 5))
layer2.fill(88)
rs.apply(dm2, layer2, seed=0)
# Only (0,0)-(1,1) should be overwritten
for y in range(2):
for x in range(2):
val = layer2.at(x, y)
assert val >= 0, f"Expected tile at ({x},{y}), got {val}"
# (3,3) should still be the fill value
assert layer2.at(3, 3) == 88, f"Expected 88 at (3,3), got {layer2.at(3, 3)}"
print(" clipping (small map, large layer): OK")
def test_resolve_type_error():
"""Test that resolve raises TypeError for wrong argument."""
proj = mcrfpy.LdtkProject("../tests/fixtures/test_project.ldtk")
rs = proj.ruleset("Terrain")
try:
rs.resolve("not_a_dmap", seed=0)
assert False, "Should have raised TypeError"
except TypeError:
pass
print(" resolve TypeError: OK")
def test_precomputed_tiles():
"""Test loading pre-computed auto-layer tiles from a level."""
proj = mcrfpy.LdtkProject("../tests/fixtures/test_project.ldtk")
ts = proj.tileset("Test_Tileset")
texture = ts.to_texture()
level = proj.level("Level_0")
layer_info = level["layers"][0]
# Create TileLayer and write pre-computed tiles
layer = mcrfpy.TileLayer(name="precomp", texture=texture, grid_size=(5, 5))
layer.fill(-1)
for tile in layer_info["auto_tiles"]:
x, y = tile["x"], tile["y"]
if 0 <= x < 5 and 0 <= y < 5:
layer.set((x, y), tile["tile_id"])
# Verify some tiles were written
assert layer.at(0, 0) == 0, f"Expected tile 0 at (0,0), got {layer.at(0, 0)}"
print(f" precomputed tiles loaded: first = {layer.at(0, 0)}")
# Run tests
tests = [
test_apply_basic,
test_apply_preserves_unmatched,
test_apply_type_errors,
test_apply_clipping,
test_resolve_type_error,
test_precomputed_tiles,
]
passed = 0
failed = 0
print("=== LDtk Apply Tests ===")
for test in tests:
name = test.__name__
try:
print(f"[TEST] {name}...")
test()
passed += 1
print(f" PASS")
except Exception as e:
failed += 1
print(f" FAIL: {e}")
print(f"\n=== Results: {passed} passed, {failed} failed ===")
if failed > 0:
sys.exit(1)
sys.exit(0)

View file

@ -0,0 +1,222 @@
"""Unit tests for LDtk project parsing."""
import mcrfpy
import sys
import os
def test_load_project():
"""Test basic project loading."""
proj = mcrfpy.LdtkProject("../tests/fixtures/test_project.ldtk")
assert proj is not None, "Failed to create LdtkProject"
print(f" repr: {repr(proj)}")
return proj
def test_version(proj):
"""Test version property."""
assert proj.version == "1.5.3", f"Expected version '1.5.3', got '{proj.version}'"
print(f" version: {proj.version}")
def test_tileset_names(proj):
"""Test tileset enumeration."""
names = proj.tileset_names
assert isinstance(names, list), f"Expected list, got {type(names)}"
assert len(names) == 1, f"Expected 1 tileset, got {len(names)}"
assert names[0] == "Test_Tileset", f"Expected 'Test_Tileset', got '{names[0]}'"
print(f" tileset_names: {names}")
def test_ruleset_names(proj):
"""Test ruleset enumeration."""
names = proj.ruleset_names
assert isinstance(names, list), f"Expected list, got {type(names)}"
assert len(names) == 1, f"Expected 1 ruleset, got {len(names)}"
assert names[0] == "Terrain", f"Expected 'Terrain', got '{names[0]}'"
print(f" ruleset_names: {names}")
def test_level_names(proj):
"""Test level enumeration."""
names = proj.level_names
assert isinstance(names, list), f"Expected list, got {type(names)}"
assert len(names) == 1, f"Expected 1 level, got {len(names)}"
assert names[0] == "Level_0", f"Expected 'Level_0', got '{names[0]}'"
print(f" level_names: {names}")
def test_enums(proj):
"""Test enum access."""
enums = proj.enums
assert isinstance(enums, list), f"Expected list, got {type(enums)}"
assert len(enums) == 1, f"Expected 1 enum, got {len(enums)}"
assert enums[0]["identifier"] == "TileType"
print(f" enums: {len(enums)} enum(s), first = {enums[0]['identifier']}")
def test_tileset_access(proj):
"""Test tileset retrieval."""
ts = proj.tileset("Test_Tileset")
assert ts is not None, "Failed to get tileset"
print(f" tileset: {repr(ts)}")
assert ts.name == "Test_Tileset", f"Expected 'Test_Tileset', got '{ts.name}'"
assert ts.tile_width == 16, f"Expected tile_width 16, got {ts.tile_width}"
assert ts.tile_height == 16, f"Expected tile_height 16, got {ts.tile_height}"
assert ts.columns == 4, f"Expected columns 4, got {ts.columns}"
assert ts.tile_count == 16, f"Expected tile_count 16, got {ts.tile_count}"
def test_tileset_not_found(proj):
"""Test KeyError for missing tileset."""
try:
proj.tileset("Nonexistent")
assert False, "Should have raised KeyError"
except KeyError:
pass
print(" KeyError raised for missing tileset: OK")
def test_ruleset_access(proj):
"""Test ruleset retrieval."""
rs = proj.ruleset("Terrain")
assert rs is not None, "Failed to get ruleset"
print(f" ruleset: {repr(rs)}")
assert rs.name == "Terrain", f"Expected 'Terrain', got '{rs.name}'"
assert rs.grid_size == 16, f"Expected grid_size 16, got {rs.grid_size}"
assert rs.value_count == 3, f"Expected 3 values, got {rs.value_count}"
assert rs.group_count == 2, f"Expected 2 groups, got {rs.group_count}"
assert rs.rule_count == 3, f"Expected 3 rules, got {rs.rule_count}"
def test_ruleset_values(proj):
"""Test IntGrid value definitions."""
rs = proj.ruleset("Terrain")
values = rs.values
assert len(values) == 3, f"Expected 3 values, got {len(values)}"
assert values[0]["value"] == 1
assert values[0]["name"] == "wall"
assert values[1]["value"] == 2
assert values[1]["name"] == "floor"
assert values[2]["value"] == 3
assert values[2]["name"] == "water"
print(f" values: {values}")
def test_terrain_enum(proj):
"""Test terrain_enum() generation."""
rs = proj.ruleset("Terrain")
Terrain = rs.terrain_enum()
assert Terrain is not None, "Failed to create terrain enum"
assert Terrain.NONE == 0, f"Expected NONE=0, got {Terrain.NONE}"
assert Terrain.WALL == 1, f"Expected WALL=1, got {Terrain.WALL}"
assert Terrain.FLOOR == 2, f"Expected FLOOR=2, got {Terrain.FLOOR}"
assert Terrain.WATER == 3, f"Expected WATER=3, got {Terrain.WATER}"
print(f" terrain enum: {list(Terrain)}")
def test_level_access(proj):
"""Test level data retrieval."""
level = proj.level("Level_0")
assert isinstance(level, dict), f"Expected dict, got {type(level)}"
assert level["name"] == "Level_0"
assert level["width_px"] == 80
assert level["height_px"] == 80
assert level["world_x"] == 0
assert level["world_y"] == 0
print(f" level: {level['name']} ({level['width_px']}x{level['height_px']}px)")
def test_level_layers(proj):
"""Test level layer data."""
level = proj.level("Level_0")
layers = level["layers"]
assert len(layers) == 1, f"Expected 1 layer, got {len(layers)}"
layer = layers[0]
assert layer["name"] == "Terrain"
assert layer["type"] == "IntGrid"
assert layer["width"] == 5
assert layer["height"] == 5
print(f" layer: {layer['name']} ({layer['type']}) {layer['width']}x{layer['height']}")
def test_level_intgrid(proj):
"""Test IntGrid CSV data."""
level = proj.level("Level_0")
layer = level["layers"][0]
intgrid = layer["intgrid"]
assert len(intgrid) == 25, f"Expected 25 cells, got {len(intgrid)}"
# Check corners are walls (1)
assert intgrid[0] == 1, f"Expected wall at (0,0), got {intgrid[0]}"
assert intgrid[4] == 1, f"Expected wall at (4,0), got {intgrid[4]}"
# Check center is water (3)
assert intgrid[12] == 3, f"Expected water at (2,2), got {intgrid[12]}"
# Check floor tiles (2)
assert intgrid[6] == 2, f"Expected floor at (1,1), got {intgrid[6]}"
print(f" intgrid: {intgrid[:5]}... ({len(intgrid)} cells)")
def test_level_auto_tiles(proj):
"""Test pre-computed auto-layer tiles."""
level = proj.level("Level_0")
layer = level["layers"][0]
auto_tiles = layer["auto_tiles"]
assert len(auto_tiles) > 0, f"Expected auto tiles, got {len(auto_tiles)}"
# Check first tile structure
t = auto_tiles[0]
assert "tile_id" in t, f"Missing tile_id in auto tile: {t}"
assert "x" in t, f"Missing x in auto tile: {t}"
assert "y" in t, f"Missing y in auto tile: {t}"
assert "flip" in t, f"Missing flip in auto tile: {t}"
print(f" auto_tiles: {len(auto_tiles)} tiles, first = {auto_tiles[0]}")
def test_level_not_found(proj):
"""Test KeyError for missing level."""
try:
proj.level("Nonexistent")
assert False, "Should have raised KeyError"
except KeyError:
pass
print(" KeyError raised for missing level: OK")
def test_load_nonexistent():
"""Test IOError for missing file."""
try:
mcrfpy.LdtkProject("nonexistent.ldtk")
assert False, "Should have raised IOError"
except IOError:
pass
print(" IOError raised for missing file: OK")
# Run all tests
tests = [
("load_project", None),
("version", None),
("tileset_names", None),
("ruleset_names", None),
("level_names", None),
("enums", None),
("tileset_access", None),
("tileset_not_found", None),
("ruleset_access", None),
("ruleset_values", None),
("terrain_enum", None),
("level_access", None),
("level_layers", None),
("level_intgrid", None),
("level_auto_tiles", None),
("level_not_found", None),
("load_nonexistent", None),
]
passed = 0
failed = 0
proj = None
# First test returns the project
print("=== LDtk Parse Tests ===")
for name, func in tests:
try:
test_fn = globals()[f"test_{name}"]
print(f"[TEST] {name}...")
if name == "load_project":
proj = test_fn()
elif name in ("load_nonexistent",):
test_fn()
else:
test_fn(proj)
passed += 1
print(f" PASS")
except Exception as e:
failed += 1
print(f" FAIL: {e}")
print(f"\n=== Results: {passed} passed, {failed} failed ===")
if failed > 0:
sys.exit(1)
sys.exit(0)

View file

@ -0,0 +1,146 @@
"""Unit tests for LDtk auto-rule resolution."""
import mcrfpy
import sys
def test_basic_resolve():
"""Test resolving a simple IntGrid against auto-rules."""
proj = mcrfpy.LdtkProject("../tests/fixtures/test_project.ldtk")
rs = proj.ruleset("Terrain")
# Create a DiscreteMap matching the test fixture
dm = mcrfpy.DiscreteMap((5, 5), fill=0)
# Fill with the same pattern as test_project.ldtk Level_0:
# 1 1 1 1 1
# 1 2 2 2 1
# 1 2 3 2 1
# 1 2 2 2 1
# 1 1 1 1 1
for y in range(5):
for x in range(5):
if x == 0 or x == 4 or y == 0 or y == 4:
dm.set(x, y, 1) # wall
elif x == 2 and y == 2:
dm.set(x, y, 3) # water
else:
dm.set(x, y, 2) # floor
tiles = rs.resolve(dm, seed=0)
assert isinstance(tiles, list), f"Expected list, got {type(tiles)}"
assert len(tiles) == 25, f"Expected 25 tiles, got {len(tiles)}"
print(f" resolved: {tiles}")
# Wall cells (value=1) should have tile_id 0 (from rule 51 matching pattern center=1)
assert tiles[0] >= 0, f"Expected wall tile at (0,0), got {tiles[0]}"
# Floor cells (value=2) should match floor rule (rule 61, tile_id 2 or 3)
assert tiles[6] >= 0, f"Expected floor tile at (1,1), got {tiles[6]}"
print(" wall and floor cells matched rules: OK")
def test_resolve_with_seed():
"""Test that different seeds produce deterministic but different results for multi-tile rules."""
proj = mcrfpy.LdtkProject("../tests/fixtures/test_project.ldtk")
rs = proj.ruleset("Terrain")
dm = mcrfpy.DiscreteMap((5, 5), fill=2) # All floor
tiles_a = rs.resolve(dm, seed=0)
tiles_b = rs.resolve(dm, seed=0)
tiles_c = rs.resolve(dm, seed=42)
# Same seed = same result
assert tiles_a == tiles_b, "Same seed should produce same result"
print(" deterministic with same seed: OK")
# Different seed may produce different tile picks (floor rule has 2 alternatives)
# Not guaranteed to differ for all cells, but we test determinism
tiles_d = rs.resolve(dm, seed=42)
assert tiles_c == tiles_d, "Same seed should produce same result"
print(" deterministic with different seed: OK")
def test_resolve_empty():
"""Test resolving an all-empty grid (value 0 = empty)."""
proj = mcrfpy.LdtkProject("../tests/fixtures/test_project.ldtk")
rs = proj.ruleset("Terrain")
dm = mcrfpy.DiscreteMap((3, 3), fill=0)
tiles = rs.resolve(dm, seed=0)
assert len(tiles) == 9, f"Expected 9 tiles, got {len(tiles)}"
# All empty - no rules should match (rules match value 1 or 2)
for i, t in enumerate(tiles):
assert t == -1, f"Expected -1 at index {i}, got {t}"
print(" empty grid: all tiles -1: OK")
def test_pattern_negation():
"""Test that negative pattern values work (must NOT match)."""
proj = mcrfpy.LdtkProject("../tests/fixtures/test_project.ldtk")
rs = proj.ruleset("Terrain")
# Rule 52 has pattern: [0, -1, 0, 0, 1, 0, 0, 0, 0]
# Center must be 1 (wall), top neighbor must NOT be 1
# Create a 3x3 grid with wall center and non-wall top
dm = mcrfpy.DiscreteMap((3, 3), fill=0)
dm.set(1, 1, 1) # center = wall
dm.set(1, 0, 2) # top = floor (not wall)
tiles = rs.resolve(dm, seed=0)
# The center cell should match rule 52 (wall with non-wall top)
# Rule 52 gives tile_id 1 (from tileRectsIds [16,0] = column 1, row 0 = tile 1)
center = tiles[4] # (1,1) = index 4 in 3x3
print(f" negation pattern: center tile = {center}")
# It should match either rule 51 (generic wall) or rule 52 (wall with non-wall top)
assert center >= 0, f"Expected match at center, got {center}"
print(" pattern negation test: OK")
def test_resolve_dimensions():
"""Test resolve works with different grid dimensions."""
proj = mcrfpy.LdtkProject("../tests/fixtures/test_project.ldtk")
rs = proj.ruleset("Terrain")
for w, h in [(1, 1), (3, 3), (10, 10), (1, 20), (20, 1)]:
dm = mcrfpy.DiscreteMap((w, h), fill=1)
tiles = rs.resolve(dm, seed=0)
assert len(tiles) == w * h, f"Expected {w*h} tiles for {w}x{h}, got {len(tiles)}"
print(" various dimensions: OK")
def test_break_on_match():
"""Test that breakOnMatch prevents later rules from overwriting."""
proj = mcrfpy.LdtkProject("../tests/fixtures/test_project.ldtk")
rs = proj.ruleset("Terrain")
# Create a grid where rule 51 (generic wall) should match
# Rule 51 has breakOnMatch=true, so rule 52 should not override it
dm = mcrfpy.DiscreteMap((3, 3), fill=1) # All walls
tiles = rs.resolve(dm, seed=0)
# All cells should be tile 0 (from rule 51)
center = tiles[4]
assert center == 0, f"Expected tile 0 from rule 51, got {center}"
print(f" break on match: center = {center}: OK")
# Run tests
tests = [
test_basic_resolve,
test_resolve_with_seed,
test_resolve_empty,
test_pattern_negation,
test_resolve_dimensions,
test_break_on_match,
]
passed = 0
failed = 0
print("=== LDtk Resolve Tests ===")
for test in tests:
name = test.__name__
try:
print(f"[TEST] {name}...")
test()
passed += 1
print(f" PASS")
except Exception as e:
failed += 1
print(f" FAIL: {e}")
print(f"\n=== Results: {passed} passed, {failed} failed ===")
if failed > 0:
sys.exit(1)
sys.exit(0)