Tiled XML/JSON import support

This commit is contained in:
John McCardle 2026-02-06 21:43:03 -05:00
commit b093e087e1
18 changed files with 3040 additions and 0 deletions

View file

@ -0,0 +1,167 @@
# tiled_analysis.py - Wang set adjacency analysis utility
# Prints adjacency graph, terrain chains, and valid/invalid pair counts
# for exploring tileset Wang transition rules.
#
# Usage:
# cd build && ./mcrogueface --headless --exec ../tests/demo/screens/tiled_analysis.py
import mcrfpy
import sys
from collections import defaultdict
# -- Configuration --------------------------------------------------------
PUNY_BASE = "/home/john/Development/7DRL2026_Liber_Noster_jmccardle/assets_sources/PUNY_WORLD_v1/PUNY_WORLD_v1"
TSX_PATH = PUNY_BASE + "/Tiled/punyworld-overworld-tiles.tsx"
WANG_SET_NAME = "overworld"
def analyze_wang_set(tileset, wang_set_name):
"""Analyze a Wang set and print adjacency information."""
ws = tileset.wang_set(wang_set_name)
T = ws.terrain_enum()
print("=" * 60)
print(f"Wang Set Analysis: {ws.name}")
print(f" Type: {ws.type}")
print(f" Colors: {ws.color_count}")
print("=" * 60)
# List all terrains
terrains = [t for t in T if t != T.NONE]
print(f"\nTerrains ({len(terrains)}):")
for t in terrains:
print(f" {t.value:3d}: {t.name}")
# Test all pairs for valid Wang transitions using 2x2 grids
adjacency = defaultdict(set) # terrain -> set of valid neighbors
valid_pairs = []
invalid_pairs = []
for a in terrains:
for b in terrains:
if b.value <= a.value:
continue
# Create a 2x2 map: columns of A and B
dm = mcrfpy.DiscreteMap((2, 2))
dm.set(0, 0, a)
dm.set(1, 0, b)
dm.set(0, 1, a)
dm.set(1, 1, b)
results = ws.resolve(dm)
has_invalid = any(r == -1 for r in results)
if not has_invalid:
valid_pairs.append((a, b))
adjacency[a.name].add(b.name)
adjacency[b.name].add(a.name)
else:
invalid_pairs.append((a, b))
# Print adjacency graph
print(f"\nAdjacency Graph ({len(valid_pairs)} valid pairs):")
print("-" * 40)
for t in terrains:
neighbors = sorted(adjacency.get(t.name, set()))
if neighbors:
print(f" {t.name}")
for n in neighbors:
print(f" <-> {n}")
else:
print(f" {t.name} (ISOLATED - no valid neighbors)")
# Find terrain chains (connected components)
print(f"\nTerrain Chains (connected components):")
print("-" * 40)
visited = set()
chains = []
def bfs(start):
chain = []
queue = [start]
while queue:
node = queue.pop(0)
if node in visited:
continue
visited.add(node)
chain.append(node)
for neighbor in sorted(adjacency.get(node, set())):
if neighbor not in visited:
queue.append(neighbor)
return chain
for t in terrains:
if t.name not in visited:
chain = bfs(t.name)
if chain:
chains.append(chain)
for i, chain in enumerate(chains):
print(f"\n Chain {i+1}: {len(chain)} terrains")
for name in chain:
neighbors = sorted(adjacency.get(name, set()))
connections = ", ".join(neighbors) if neighbors else "(none)"
print(f" {name} -> [{connections}]")
# Find linear paths within chains
print(f"\nLinear Paths (degree-1 endpoints to degree-1 endpoints):")
print("-" * 40)
for chain in chains:
# Find nodes with degree 1 (endpoints) or degree > 2 (hubs)
endpoints = [n for n in chain if len(adjacency.get(n, set())) == 1]
hubs = [n for n in chain if len(adjacency.get(n, set())) > 2]
if endpoints:
print(f" Endpoints: {', '.join(endpoints)}")
if hubs:
print(f" Hubs: {', '.join(hubs)} (branch points)")
# Trace from each endpoint
for ep in endpoints:
path = [ep]
current = ep
prev = None
while True:
neighbors = adjacency.get(current, set()) - {prev} if prev else adjacency.get(current, set())
if len(neighbors) == 0:
break
if len(neighbors) > 1:
path.append(f"({current} branches)")
break
nxt = list(neighbors)[0]
path.append(nxt)
prev = current
current = nxt
if len(adjacency.get(current, set())) != 2:
break # reached endpoint or hub
print(f" Path: {' -> '.join(path)}")
# Summary statistics
total_possible = len(terrains) * (len(terrains) - 1) // 2
print(f"\nSummary:")
print(f" Total terrain types: {len(terrains)}")
print(f" Valid transitions: {len(valid_pairs)} / {total_possible} "
f"({100*len(valid_pairs)/total_possible:.1f}%)")
print(f" Invalid transitions: {len(invalid_pairs)}")
print(f" Connected components: {len(chains)}")
# Print invalid pairs for reference
if invalid_pairs:
print(f"\nInvalid Pairs ({len(invalid_pairs)}):")
for a, b in invalid_pairs:
print(f" {a.name} X {b.name}")
return valid_pairs, invalid_pairs, chains
def main():
print("Loading tileset...")
tileset = mcrfpy.TileSetFile(TSX_PATH)
print(f" {tileset.name}: {tileset.tile_count} tiles "
f"({tileset.columns} cols, {tileset.tile_width}x{tileset.tile_height}px)")
analyze_wang_set(tileset, WANG_SET_NAME)
print("\nDone!")
if __name__ == "__main__":
main()
sys.exit(0)

View file

@ -0,0 +1,504 @@
# tiled_demo.py - Visual demo of Tiled integration
# Shows premade maps, Wang auto-tiling, and procgen terrain
#
# Usage:
# Headless: cd build && ./mcrogueface --headless --exec ../tests/demo/screens/tiled_demo.py
# Interactive: cd build && ./mcrogueface --exec ../tests/demo/screens/tiled_demo.py
import mcrfpy
from mcrfpy import automation
import sys
# -- Asset Paths -------------------------------------------------------
PUNY_BASE = "/home/john/Development/7DRL2026_Liber_Noster_jmccardle/assets_sources/PUNY_WORLD_v1/PUNY_WORLD_v1"
TSX_PATH = PUNY_BASE + "/Tiled/punyworld-overworld-tiles.tsx"
# -- Load Shared Assets ------------------------------------------------
print("Loading Puny World tileset...")
tileset = mcrfpy.TileSetFile(TSX_PATH)
texture = tileset.to_texture()
overworld_ws = tileset.wang_set("overworld")
Terrain = overworld_ws.terrain_enum()
print(f" Tileset: {tileset.name}")
print(f" Tiles: {tileset.tile_count} ({tileset.columns} cols, {tileset.tile_width}x{tileset.tile_height}px)")
print(f" Wang set: {overworld_ws.name} ({overworld_ws.type}, {overworld_ws.color_count} colors)")
print(f" Terrain enum members: {[t.name for t in Terrain]}")
# -- Helper: Iterative terrain expansion ----------------------------------
def iterative_terrain(hm, wang_set, width, height, passes):
"""Build a DiscreteMap by iteratively splitting terrains outward from
a valid binary map. Each pass splits one terrain on each end of the
chain, validates with wang_set.resolve(), and reverts invalid cells
to their previous value.
hm: HeightMap (normalized 0-1)
passes: list of (threshold, lo_old, lo_new, hi_old, hi_new) tuples.
Each pass says: cells currently == lo_old with height < threshold
become lo_new; cells currently == hi_old with height >= threshold
become hi_new.
Returns (DiscreteMap, stats_dict).
"""
dm = mcrfpy.DiscreteMap((width, height))
# Pass 0: binary split - everything is one of two terrains
p0 = passes[0]
thresh, lo_terrain, hi_terrain = p0
for y in range(height):
for x in range(width):
if hm.get(x, y) < thresh:
dm.set(x, y, int(lo_terrain))
else:
dm.set(x, y, int(hi_terrain))
# Validate pass 0 and fix any invalid cells (rare edge cases like
# checkerboard patterns at the binary boundary)
results = wang_set.resolve(dm)
inv = sum(1 for r in results if r == -1)
if inv > 0:
# Fix by flipping invalid cells to the other terrain
for y in range(height):
for x in range(width):
if results[y * width + x] == -1:
val = dm.get(x, y)
if val == int(lo_terrain):
dm.set(x, y, int(hi_terrain))
else:
dm.set(x, y, int(lo_terrain))
results2 = wang_set.resolve(dm)
inv = sum(1 for r in results2 if r == -1)
stats = {"pass0_invalid": inv}
# Subsequent passes: split outward
for pi, (thresh, lo_old, lo_new, hi_old, hi_new) in enumerate(passes[1:], 1):
# Save current state so we can revert invalid cells
prev = [dm.get(x, y) for y in range(height) for x in range(width)]
# Track which cells were changed this pass
changed = set()
for y in range(height):
for x in range(width):
val = dm.get(x, y)
h = hm.get(x, y)
if val == int(lo_old) and h < thresh:
dm.set(x, y, int(lo_new))
changed.add((x, y))
elif val == int(hi_old) and h >= thresh:
dm.set(x, y, int(hi_new))
changed.add((x, y))
# Iteratively revert changed cells that cause invalid tiles.
# A changed cell should be reverted if:
# - It is itself invalid, OR
# - It is a neighbor of an invalid UN-changed cell (it broke
# a pre-existing valid cell by being placed next to it)
dirs8 = [(-1,-1),(0,-1),(1,-1),(-1,0),(1,0),(-1,1),(0,1),(1,1)]
total_reverted = 0
for revert_round in range(30):
results = wang_set.resolve(dm)
to_revert = set()
for y in range(height):
for x in range(width):
if results[y * width + x] != -1:
continue
if (x, y) in changed:
# This changed cell is invalid - revert it
to_revert.add((x, y))
else:
# Pre-existing cell is now invalid - revert its
# changed neighbors to restore it
for dx, dy in dirs8:
nx, ny = x+dx, y+dy
if (nx, ny) in changed:
to_revert.add((nx, ny))
if not to_revert:
break
for (x, y) in to_revert:
dm.set(x, y, prev[y * width + x])
changed.discard((x, y))
total_reverted += 1
results_final = wang_set.resolve(dm)
remaining = sum(1 for r in results_final if r == -1)
stats[f"pass{pi}_kept"] = len(changed)
stats[f"pass{pi}_reverted"] = total_reverted
stats[f"pass{pi}_remaining"] = remaining
return dm, stats
# -- 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: Premade Tiled Map
# ======================================================================
print("\nSetting up Screen 1: Premade Map...")
scene1 = mcrfpy.Scene("tiled_premade")
bg1 = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(10, 10, 15))
scene1.children.append(bg1)
title1 = mcrfpy.Caption(text="Premade Tiled Map (50x50, 3 layers)", pos=(20, 10))
title1.fill_color = mcrfpy.Color(255, 255, 255)
scene1.children.append(title1)
# Load samplemap1
tm1 = mcrfpy.TileMapFile(PUNY_BASE + "/Tiled/samplemap1.tmj")
print(f" Map: {tm1.width}x{tm1.height}, layers: {tm1.tile_layer_names}")
grid1 = mcrfpy.Grid(grid_size=(tm1.width, tm1.height),
pos=(20, 50), size=(520, 520), layers=[])
grid1.fill_color = mcrfpy.Color(30, 30, 50)
# Add a tile layer for each map layer, bottom-up z ordering
layer_names_1 = tm1.tile_layer_names
for i, name in enumerate(layer_names_1):
z = -(len(layer_names_1) - i)
layer = mcrfpy.TileLayer(name=name, z_index=z, texture=texture)
grid1.add_layer(layer)
tm1.apply_to_tile_layer(layer, name, tileset_index=0)
print(f" Applied layer '{name}' (z_index={z})")
# Center camera on map center (pixels = tiles * tile_size)
grid1.center = (tm1.width * tileset.tile_width // 2,
tm1.height * tileset.tile_height // 2)
scene1.children.append(grid1)
make_info_panel(scene1, [
f"Tileset: {tileset.name}",
f"Tile size: {tileset.tile_width}x{tileset.tile_height}",
f"Tile count: {tileset.tile_count}",
f"Map size: {tm1.width}x{tm1.height}",
"",
"Layers:",
] + [f" {name}" for name in layer_names_1] + [
"",
"Wang sets:",
f" {overworld_ws.name} ({overworld_ws.type})",
f" pathways (edge)",
])
nav1 = mcrfpy.Caption(text="[1] Premade [2] Procgen [3] Side-by-Side [ESC] Quit", pos=(20, 740))
nav1.fill_color = mcrfpy.Color(120, 120, 150)
scene1.children.append(nav1)
# ======================================================================
# SCREEN 2: Procedural Wang Auto-Tile (2-layer approach)
# ======================================================================
print("\nSetting up Screen 2: Procgen Wang Terrain (2-layer)...")
scene2 = mcrfpy.Scene("tiled_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="Procgen Wang Auto-Tile (60x60, 2 layers)", pos=(20, 10))
title2.fill_color = mcrfpy.Color(255, 255, 255)
scene2.children.append(title2)
W, H = 60, 60
T = Terrain # shorthand
# Generate terrain heightmap using NoiseSource
noise = mcrfpy.NoiseSource(dimensions=2, seed=42)
hm = noise.sample(size=(W, H), mode="fbm", octaves=4, world_size=(4.0, 4.0))
hm.normalize(0.0, 1.0)
# -- Base terrain: iterative expansion from binary map --
# Pass 0: binary split at median -> SEAWATER_LIGHT / SAND
# Pass 1: split outward -> SEAWATER_MEDIUM from LIGHT, GRASS from SAND
# Pass 2: split outward -> SEAWATER_DEEP from MEDIUM, CLIFF from GRASS
base_passes = [
# Pass 0: (threshold, lo_terrain, hi_terrain)
(0.45, T.SEAWATER_LIGHT, T.SAND),
# Pass 1+: (threshold, lo_old, lo_new, hi_old, hi_new)
(0.30, T.SEAWATER_LIGHT, T.SEAWATER_MEDIUM, T.SAND, T.GRASS),
(0.20, T.SEAWATER_MEDIUM, T.SEAWATER_DEEP, T.GRASS, T.CLIFF),
]
base_dm, base_stats = iterative_terrain(hm, overworld_ws, W, H, base_passes)
base_dm.enum_type = T
print(f" Base terrain stats: {base_stats}")
# -- Tree overlay: separate noise, binary TREES/AIR --
tree_noise = mcrfpy.NoiseSource(dimensions=2, seed=999)
tree_hm = tree_noise.sample(size=(W, H), mode="fbm", octaves=3, world_size=(6.0, 6.0))
tree_hm.normalize(0.0, 1.0)
overlay_dm = mcrfpy.DiscreteMap((W, H))
overlay_dm.enum_type = T
for y in range(H):
for x in range(W):
base_val = base_dm.get(x, y)
tree_h = tree_hm.get(x, y)
# Trees only on GRASS, driven by separate noise
if base_val == int(T.GRASS) and tree_h > 0.45:
overlay_dm.set(x, y, int(T.TREES))
else:
overlay_dm.set(x, y, int(T.AIR))
# Validate overlay and revert invalid to AIR
overlay_results = overworld_ws.resolve(overlay_dm)
overlay_reverted = 0
for y in range(H):
for x in range(W):
if overlay_results[y * W + x] == -1:
overlay_dm.set(x, y, int(T.AIR))
overlay_reverted += 1
print(f" Overlay: {overlay_reverted} tree cells reverted to AIR")
# Count terrain distribution
terrain_counts = {}
for t in T:
if t == T.NONE:
continue
c = base_dm.count(int(t))
if c > 0:
terrain_counts[t.name] = c
tree_count = overlay_dm.count(int(T.TREES))
terrain_counts["TREES(overlay)"] = tree_count
print(f" Terrain distribution: {terrain_counts}")
# Create grid with 2 layers and apply Wang auto-tiling
grid2 = mcrfpy.Grid(grid_size=(W, H), pos=(20, 50), size=(520, 520), layers=[])
grid2.fill_color = mcrfpy.Color(30, 30, 50)
base_layer2 = mcrfpy.TileLayer(name="base", z_index=-2, texture=texture)
grid2.add_layer(base_layer2)
overworld_ws.apply(base_dm, base_layer2)
overlay_layer2 = mcrfpy.TileLayer(name="trees", z_index=-1, texture=texture)
grid2.add_layer(overlay_layer2)
overworld_ws.apply(overlay_dm, overlay_layer2)
# Post-process overlay: AIR resolves to an opaque tile, set to -1 (transparent)
for y in range(H):
for x in range(W):
if overlay_dm.get(x, y) == int(T.AIR):
overlay_layer2.set((x, y), -1)
grid2.center = (W * tileset.tile_width // 2, H * tileset.tile_height // 2)
scene2.children.append(grid2)
# Info panel
info_lines = [
"Iterative terrain expansion",
f"Seed: 42 (base), 999 (trees)",
f"Grid: {W}x{H}, 2 layers",
"",
"Base (3 passes):",
]
for name in ["SEAWATER_DEEP", "SEAWATER_MEDIUM", "SEAWATER_LIGHT",
"SAND", "GRASS", "CLIFF"]:
count = terrain_counts.get(name, 0)
info_lines.append(f" {name}: {count}")
info_lines.append("")
info_lines.append("Tree Overlay:")
info_lines.append(f" TREES: {tree_count}")
info_lines.append(f" reverted: {overlay_reverted}")
make_info_panel(scene2, info_lines)
nav2 = mcrfpy.Caption(text="[1] Premade [2] Procgen [3] Side-by-Side [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: Side-by-Side...")
scene3 = mcrfpy.Scene("tiled_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="Premade vs Procedural", pos=(20, 10))
title3.fill_color = mcrfpy.Color(255, 255, 255)
scene3.children.append(title3)
# Left: Premade map (samplemap2, 30x30)
tm2 = mcrfpy.TileMapFile(PUNY_BASE + "/Tiled/samplemap2.tmj")
print(f" Map2: {tm2.width}x{tm2.height}, layers: {tm2.tile_layer_names}")
left_label = mcrfpy.Caption(text="Premade (samplemap2)", pos=(20, 38))
left_label.fill_color = mcrfpy.Color(180, 220, 255)
scene3.children.append(left_label)
grid_left = mcrfpy.Grid(grid_size=(tm2.width, tm2.height),
pos=(20, 60), size=(380, 380), layers=[])
grid_left.fill_color = mcrfpy.Color(30, 30, 50)
for i, name in enumerate(tm2.tile_layer_names):
z = -(len(tm2.tile_layer_names) - i)
layer = mcrfpy.TileLayer(name=name, z_index=z, texture=texture)
grid_left.add_layer(layer)
tm2.apply_to_tile_layer(layer, name, tileset_index=0)
grid_left.center = (tm2.width * tileset.tile_width // 2,
tm2.height * tileset.tile_height // 2)
scene3.children.append(grid_left)
# Right: Procgen island
right_label = mcrfpy.Caption(text="Procgen Island (2-layer Wang)", pos=(420, 38))
right_label.fill_color = mcrfpy.Color(180, 255, 220)
scene3.children.append(right_label)
IW, IH = 30, 30
island_noise = mcrfpy.NoiseSource(dimensions=2, seed=7777)
island_hm = island_noise.sample(size=(IW, IH), mode="fbm", octaves=3, world_size=(3.0, 3.0))
island_hm.normalize(0.0, 1.0)
# Create island shape: attenuate edges with radial gradient
for y in range(IH):
for x in range(IW):
dx = (x - IW / 2.0) / (IW / 2.0)
dy = (y - IH / 2.0) / (IH / 2.0)
dist = (dx * dx + dy * dy) ** 0.5
falloff = max(0.0, 1.0 - dist * 1.2)
h = island_hm.get(x, y) * falloff
island_hm[x, y] = h
island_hm.normalize(0.0, 1.0)
# Iterative base terrain expansion (same technique as Screen 2)
island_passes_def = [
(0.40, T.SEAWATER_LIGHT, T.SAND),
(0.25, T.SEAWATER_LIGHT, T.SEAWATER_MEDIUM, T.SAND, T.GRASS),
(0.15, T.SEAWATER_MEDIUM, T.SEAWATER_DEEP, T.GRASS, T.CLIFF),
]
island_base_dm, island_stats = iterative_terrain(
island_hm, overworld_ws, IW, IH, island_passes_def)
island_base_dm.enum_type = T
print(f" Island base stats: {island_stats}")
# Tree overlay with separate noise
island_tree_noise = mcrfpy.NoiseSource(dimensions=2, seed=8888)
island_tree_hm = island_tree_noise.sample(
size=(IW, IH), mode="fbm", octaves=3, world_size=(4.0, 4.0))
island_tree_hm.normalize(0.0, 1.0)
island_overlay_dm = mcrfpy.DiscreteMap((IW, IH))
island_overlay_dm.enum_type = T
for y in range(IH):
for x in range(IW):
base_val = island_base_dm.get(x, y)
tree_h = island_tree_hm.get(x, y)
if base_val == int(T.GRASS) and tree_h > 0.50:
island_overlay_dm.set(x, y, int(T.TREES))
else:
island_overlay_dm.set(x, y, int(T.AIR))
# Validate overlay
island_ov_results = overworld_ws.resolve(island_overlay_dm)
for y in range(IH):
for x in range(IW):
if island_ov_results[y * IW + x] == -1:
island_overlay_dm.set(x, y, int(T.AIR))
grid_right = mcrfpy.Grid(grid_size=(IW, IH),
pos=(420, 60), size=(380, 380), layers=[])
grid_right.fill_color = mcrfpy.Color(30, 30, 50)
island_base_layer = mcrfpy.TileLayer(name="island_base", z_index=-2, texture=texture)
grid_right.add_layer(island_base_layer)
overworld_ws.apply(island_base_dm, island_base_layer)
island_overlay_layer = mcrfpy.TileLayer(name="island_trees", z_index=-1, texture=texture)
grid_right.add_layer(island_overlay_layer)
overworld_ws.apply(island_overlay_dm, island_overlay_layer)
# Post-process: make AIR cells transparent
for y in range(IH):
for x in range(IW):
if island_overlay_dm.get(x, y) == int(T.AIR):
island_overlay_layer.set((x, y), -1)
grid_right.center = (IW * tileset.tile_width // 2, IH * tileset.tile_height // 2)
scene3.children.append(grid_right)
# Info for both
make_info_panel(scene3, [
"Left: Premade Map",
f" samplemap2.tmj",
f" {tm2.width}x{tm2.height}, {len(tm2.tile_layer_names)} layers",
"",
"Right: Procgen Island",
f" {IW}x{IH}, seed=7777",
" Iterative terrain expansion",
" 2-layer Wang auto-tile",
"",
"Same tileset, same engine",
"Different workflows",
], x=200, y=460, w=400, h=None)
nav3 = mcrfpy.Caption(text="[1] Premade [2] Procgen [3] Side-by-Side [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 = ["premade", "procgen", "compare"]
# Keyboard navigation (all scenes share the same handler)
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 and take screenshots synchronously
is_headless = False
try:
win = mcrfpy.Window.get()
is_headless = "headless" in str(win).lower()
except:
is_headless = True
if is_headless:
# Headless: use step() to advance simulation and take screenshots directly
for i, (sc, name) in enumerate(zip(scenes, scene_names)):
mcrfpy.current_scene = sc
# Step a few frames to let the scene render
for _ in range(3):
mcrfpy.step(0.016)
fname = f"tiled_demo_{name}.png"
automation.screenshot(fname)
print(f" Screenshot: {fname}")
print("\nAll screenshots captured. Done!")
sys.exit(0)
else:
# Interactive: start on screen 1
mcrfpy.current_scene = scene1
print("\nTiled Demo ready!")
print("Press [1] [2] [3] to switch screens, [ESC] to quit")

View file

@ -0,0 +1,153 @@
"""Unit tests for WangSet terrain_enum, resolve, and apply"""
import mcrfpy
import sys
PASS_COUNT = 0
FAIL_COUNT = 0
def check(condition, msg):
global PASS_COUNT, FAIL_COUNT
if condition:
PASS_COUNT += 1
print(f" PASS: {msg}")
else:
FAIL_COUNT += 1
print(f" FAIL: {msg}")
def test_terrain_enum():
"""Test IntEnum generation from WangSet colors"""
print("=== Terrain Enum ===")
ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsx")
ws = ts.wang_set("terrain")
Terrain = ws.terrain_enum()
check(Terrain is not None, "terrain_enum() returns something")
check(hasattr(Terrain, "NONE"), "has NONE member")
check(hasattr(Terrain, "GRASS"), "has GRASS member")
check(hasattr(Terrain, "DIRT"), "has DIRT member")
check(int(Terrain.NONE) == 0, f"NONE = {int(Terrain.NONE)}")
check(int(Terrain.GRASS) == 1, f"GRASS = {int(Terrain.GRASS)}")
check(int(Terrain.DIRT) == 2, f"DIRT = {int(Terrain.DIRT)}")
# Check it's an IntEnum
import enum
check(issubclass(Terrain, enum.IntEnum), "is IntEnum subclass")
return Terrain
def test_enum_with_discrete_map(Terrain):
"""Test that terrain enum is compatible with DiscreteMap"""
print("\n=== Enum + DiscreteMap ===")
dm = mcrfpy.DiscreteMap((4, 4))
dm.enum_type = Terrain
check(dm.enum_type == Terrain, "DiscreteMap accepts terrain enum")
# Set values using enum
dm.set(0, 0, Terrain.GRASS)
dm.set(1, 0, Terrain.DIRT)
val = dm.get(0, 0)
check(int(val) == int(Terrain.GRASS), f"get(0,0) = {val}")
val = dm.get(1, 0)
check(int(val) == int(Terrain.DIRT), f"get(1,0) = {val}")
def test_resolve_uniform():
"""Test resolve with uniform terrain"""
print("\n=== Resolve Uniform ===")
ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsx")
ws = ts.wang_set("terrain")
# All grass (terrain ID 1)
dm = mcrfpy.DiscreteMap((3, 3))
dm.fill(1) # All grass
tiles = ws.resolve(dm)
check(isinstance(tiles, list), f"resolve returns list: {type(tiles)}")
check(len(tiles) == 9, f"resolve length = {len(tiles)}")
# All cells should map to the "all grass corners" tile (id=0)
# wangid [0,1,0,1,0,1,0,1] = tile 0
# Note: border cells will see 0 (NONE) on their outer edges, so may not match
# Center cell (1,1) sees all grass neighbors -> should be tile 0
center = tiles[4] # (1,1) in 3x3
check(center == 0, f"center tile (uniform grass) = {center}")
def test_resolve_mixed():
"""Test resolve with mixed terrain"""
print("\n=== Resolve Mixed ===")
ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsx")
ws = ts.wang_set("terrain")
# Create a 3x3 grid: grass everywhere except center = dirt
dm = mcrfpy.DiscreteMap((3, 3))
dm.fill(1) # All grass
dm.set(1, 1, 2) # Center = dirt
tiles = ws.resolve(dm)
check(len(tiles) == 9, f"resolve length = {len(tiles)}")
# The center cell has grass neighbors and is dirt itself
# Corners depend on the max of surrounding cells
center = tiles[4]
# Center: all 4 corners should be max(dirt, grass neighbors) = 2 (dirt)
# wangid [0,2,0,2,0,2,0,2] = tile 1 (all-dirt)
check(center == 1, f"center (dirt surrounded by grass) = {center}")
def test_resolve_returns_negative_for_unknown():
"""Test that unknown wangid combinations return -1"""
print("\n=== Unknown WangID ===")
ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsx")
ws = ts.wang_set("terrain")
# Use terrain ID 3 which doesn't exist in the wang set
dm = mcrfpy.DiscreteMap((2, 2))
dm.fill(3) # Terrain 3 not in wang set
tiles = ws.resolve(dm)
# All should be -1 since terrain 3 has no matching wangid
all_neg = all(t == -1 for t in tiles)
check(all_neg, f"all tiles = -1 for unknown terrain: {tiles}")
def test_resolve_border_handling():
"""Test that border cells handle out-of-bounds correctly"""
print("\n=== Border Handling ===")
ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsx")
ws = ts.wang_set("terrain")
# 1x1 grid - all neighbors are out-of-bounds (0)
dm = mcrfpy.DiscreteMap((1, 1))
dm.set(0, 0, 1) # Single grass cell
tiles = ws.resolve(dm)
check(len(tiles) == 1, f"1x1 resolve length = {len(tiles)}")
# Corner terrain: max(0, 0, 0, grass) = 1 for each corner -> all grass
# wangid [0,1,0,1,0,1,0,1] = tile 0
check(tiles[0] == 0, f"1x1 grass tile = {tiles[0]}")
def test_wang_set_repr():
"""Test WangSet repr"""
print("\n=== WangSet Repr ===")
ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsx")
ws = ts.wang_set("terrain")
r = repr(ws)
check("WangSet" in r, f"repr contains 'WangSet': {r}")
check("terrain" in r, f"repr contains name: {r}")
check("corner" in r, f"repr contains type: {r}")
def main():
Terrain = test_terrain_enum()
test_enum_with_discrete_map(Terrain)
test_resolve_uniform()
test_resolve_mixed()
test_resolve_returns_negative_for_unknown()
test_resolve_border_handling()
test_wang_set_repr()
print(f"\n{'='*40}")
print(f"Results: {PASS_COUNT} passed, {FAIL_COUNT} failed")
if FAIL_COUNT > 0:
sys.exit(1)
else:
print("ALL TESTS PASSED")
sys.exit(0)
if __name__ == "__main__":
main()