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