Test suite modernization
This commit is contained in:
parent
0969f7c2f6
commit
52fdfd0347
141 changed files with 9947 additions and 4665 deletions
8
tests/procgen_interactive/demos/__init__.py
Normal file
8
tests/procgen_interactive/demos/__init__.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
"""Demo implementations for interactive procedural generation."""
|
||||
|
||||
from .cave_demo import CaveDemo
|
||||
from .dungeon_demo import DungeonDemo
|
||||
from .terrain_demo import TerrainDemo
|
||||
from .town_demo import TownDemo
|
||||
|
||||
__all__ = ['CaveDemo', 'DungeonDemo', 'TerrainDemo', 'TownDemo']
|
||||
362
tests/procgen_interactive/demos/cave_demo.py
Normal file
362
tests/procgen_interactive/demos/cave_demo.py
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
"""Cave Generation Demo - Cellular Automata
|
||||
|
||||
Demonstrates cellular automata cave generation with:
|
||||
1. Random noise fill (based on seed + fill_percent)
|
||||
2. Binary threshold application
|
||||
3. Cellular automata smoothing passes
|
||||
4. Flood fill to find connected regions
|
||||
5. Keep largest connected region
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
from typing import List
|
||||
from ..core.demo_base import ProcgenDemoBase, StepDef, LayerDef
|
||||
from ..core.parameter import Parameter
|
||||
|
||||
|
||||
class CaveDemo(ProcgenDemoBase):
|
||||
"""Interactive cellular automata cave generation demo."""
|
||||
|
||||
name = "Cave Generation"
|
||||
description = "Cellular automata cave carving with noise and smoothing"
|
||||
MAP_SIZE = (256, 256)
|
||||
|
||||
def define_steps(self) -> List[StepDef]:
|
||||
"""Define the generation steps."""
|
||||
return [
|
||||
StepDef("Fill with noise", self.step_fill_noise,
|
||||
"Initialize grid with random noise based on seed and fill percentage"),
|
||||
StepDef("Apply threshold", self.step_threshold,
|
||||
"Convert noise to binary wall/floor based on threshold"),
|
||||
StepDef("Automata pass 1", self.step_automata_1,
|
||||
"First cellular automata smoothing pass"),
|
||||
StepDef("Automata pass 2", self.step_automata_2,
|
||||
"Second cellular automata smoothing pass"),
|
||||
StepDef("Automata pass 3", self.step_automata_3,
|
||||
"Third cellular automata smoothing pass"),
|
||||
StepDef("Find regions", self.step_find_regions,
|
||||
"Flood fill to identify connected regions"),
|
||||
StepDef("Keep largest", self.step_keep_largest,
|
||||
"Keep only the largest connected region"),
|
||||
]
|
||||
|
||||
def define_parameters(self) -> List[Parameter]:
|
||||
"""Define configurable parameters."""
|
||||
return [
|
||||
Parameter(
|
||||
name="seed",
|
||||
display="Seed",
|
||||
type="int",
|
||||
default=42,
|
||||
min_val=0,
|
||||
max_val=99999,
|
||||
step=1,
|
||||
affects_step=0,
|
||||
description="Random seed for noise generation"
|
||||
),
|
||||
Parameter(
|
||||
name="fill_percent",
|
||||
display="Fill %",
|
||||
type="float",
|
||||
default=0.45,
|
||||
min_val=0.30,
|
||||
max_val=0.70,
|
||||
step=0.05,
|
||||
affects_step=0,
|
||||
description="Initial noise fill percentage"
|
||||
),
|
||||
Parameter(
|
||||
name="threshold",
|
||||
display="Threshold",
|
||||
type="float",
|
||||
default=0.50,
|
||||
min_val=0.30,
|
||||
max_val=0.70,
|
||||
step=0.05,
|
||||
affects_step=1,
|
||||
description="Wall/floor threshold value"
|
||||
),
|
||||
Parameter(
|
||||
name="wall_rule",
|
||||
display="Wall Rule",
|
||||
type="int",
|
||||
default=5,
|
||||
min_val=3,
|
||||
max_val=7,
|
||||
step=1,
|
||||
affects_step=2,
|
||||
description="Neighbors needed to become wall"
|
||||
),
|
||||
]
|
||||
|
||||
def define_layers(self) -> List[LayerDef]:
|
||||
"""Define visualization layers."""
|
||||
return [
|
||||
LayerDef("final", "Final Cave", "color", z_index=-1, visible=True,
|
||||
description="Final cave result"),
|
||||
LayerDef("raw_noise", "Raw Noise", "color", z_index=0, visible=False,
|
||||
description="Initial random noise"),
|
||||
LayerDef("regions", "Regions", "color", z_index=1, visible=False,
|
||||
description="Connected regions colored by ID"),
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize cave demo with heightmaps."""
|
||||
super().__init__()
|
||||
|
||||
# Create working heightmaps
|
||||
self.hmap_noise = self.create_heightmap("noise", 0.0)
|
||||
self.hmap_binary = self.create_heightmap("binary", 0.0)
|
||||
self.hmap_regions = self.create_heightmap("regions", 0.0)
|
||||
|
||||
# Region tracking
|
||||
self.region_ids = [] # List of (id, size) tuples
|
||||
self.largest_region_id = 0
|
||||
|
||||
# Noise source
|
||||
self.noise = None
|
||||
|
||||
def _apply_colors_to_layer(self, layer, hmap, wall_color, floor_color, alpha=255):
|
||||
"""Apply binary wall/floor colors to a layer based on heightmap."""
|
||||
w, h = self.MAP_SIZE
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
val = hmap[x, y]
|
||||
if val > 0.5:
|
||||
c = mcrfpy.Color(wall_color.r, wall_color.g, wall_color.b, alpha)
|
||||
layer.set((x, y), c)
|
||||
else:
|
||||
c = mcrfpy.Color(floor_color.r, floor_color.g, floor_color.b, alpha)
|
||||
layer.set((x, y), c)
|
||||
|
||||
def _apply_gradient_to_layer(self, layer, hmap, alpha=255):
|
||||
"""Apply gradient visualization to layer."""
|
||||
w, h = self.MAP_SIZE
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
val = hmap[x, y]
|
||||
v = int(val * 255)
|
||||
layer.set((x, y), mcrfpy.Color(v, v, v, alpha))
|
||||
|
||||
# === Step Implementations ===
|
||||
|
||||
def step_fill_noise(self):
|
||||
"""Step 1: Fill with random noise."""
|
||||
seed = self.get_param("seed")
|
||||
fill_pct = self.get_param("fill_percent")
|
||||
|
||||
# Create noise source with seed
|
||||
self.noise = mcrfpy.NoiseSource(
|
||||
dimensions=2,
|
||||
algorithm='simplex',
|
||||
seed=seed
|
||||
)
|
||||
|
||||
# Fill heightmap with noise
|
||||
self.hmap_noise.fill(0.0)
|
||||
self.hmap_noise.add_noise(
|
||||
self.noise,
|
||||
world_size=(50, 50), # Higher frequency for cave-like noise
|
||||
mode='fbm',
|
||||
octaves=1
|
||||
)
|
||||
self.hmap_noise.normalize(0.0, 1.0)
|
||||
|
||||
# Show on raw_noise layer (alpha=128 for overlay)
|
||||
layer = self.get_layer("raw_noise")
|
||||
self._apply_gradient_to_layer(layer, self.hmap_noise, alpha=128)
|
||||
|
||||
# Also show on final layer (full opacity)
|
||||
final = self.get_layer("final")
|
||||
self._apply_gradient_to_layer(final, self.hmap_noise, alpha=255)
|
||||
|
||||
def step_threshold(self):
|
||||
"""Step 2: Apply binary threshold."""
|
||||
threshold = self.get_param("threshold")
|
||||
|
||||
# Copy noise to binary and threshold
|
||||
self.hmap_binary.copy_from(self.hmap_noise)
|
||||
|
||||
# Manual threshold since we want a specific cutoff
|
||||
w, h = self.MAP_SIZE
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
if self.hmap_binary[x, y] >= threshold:
|
||||
self.hmap_binary[x, y] = 1.0 # Wall
|
||||
else:
|
||||
self.hmap_binary[x, y] = 0.0 # Floor
|
||||
|
||||
# Visualize
|
||||
final = self.get_layer("final")
|
||||
wall = mcrfpy.Color(60, 55, 50)
|
||||
floor = mcrfpy.Color(140, 130, 115)
|
||||
self._apply_colors_to_layer(final, self.hmap_binary, wall, floor)
|
||||
|
||||
def _run_automata_pass(self):
|
||||
"""Run one cellular automata pass."""
|
||||
wall_rule = self.get_param("wall_rule")
|
||||
w, h = self.MAP_SIZE
|
||||
|
||||
# Create copy of current state
|
||||
old_data = []
|
||||
for y in range(h):
|
||||
row = []
|
||||
for x in range(w):
|
||||
row.append(self.hmap_binary[x, y])
|
||||
old_data.append(row)
|
||||
|
||||
# Apply rules
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
# Count wall neighbors (including self)
|
||||
walls = 0
|
||||
for dy in range(-1, 2):
|
||||
for dx in range(-1, 2):
|
||||
nx, ny = x + dx, y + dy
|
||||
if 0 <= nx < w and 0 <= ny < h:
|
||||
if old_data[ny][nx] > 0.5:
|
||||
walls += 1
|
||||
else:
|
||||
# Out of bounds counts as wall
|
||||
walls += 1
|
||||
|
||||
# Apply rule: if neighbors >= wall_rule, become wall
|
||||
if walls >= wall_rule:
|
||||
self.hmap_binary[x, y] = 1.0
|
||||
else:
|
||||
self.hmap_binary[x, y] = 0.0
|
||||
|
||||
# Visualize
|
||||
final = self.get_layer("final")
|
||||
wall = mcrfpy.Color(60, 55, 50)
|
||||
floor = mcrfpy.Color(140, 130, 115)
|
||||
self._apply_colors_to_layer(final, self.hmap_binary, wall, floor)
|
||||
|
||||
def step_automata_1(self):
|
||||
"""Step 3: First automata pass."""
|
||||
self._run_automata_pass()
|
||||
|
||||
def step_automata_2(self):
|
||||
"""Step 4: Second automata pass."""
|
||||
self._run_automata_pass()
|
||||
|
||||
def step_automata_3(self):
|
||||
"""Step 5: Third automata pass."""
|
||||
self._run_automata_pass()
|
||||
|
||||
def step_find_regions(self):
|
||||
"""Step 6: Flood fill to find connected floor regions."""
|
||||
w, h = self.MAP_SIZE
|
||||
|
||||
# Reset region data
|
||||
self.hmap_regions.fill(0.0)
|
||||
self.region_ids = []
|
||||
|
||||
# Track visited cells
|
||||
visited = [[False] * w for _ in range(h)]
|
||||
region_id = 0
|
||||
|
||||
# Region colors (for visualization) - alpha=128 for overlay
|
||||
region_colors = [
|
||||
mcrfpy.Color(200, 80, 80, 128),
|
||||
mcrfpy.Color(80, 200, 80, 128),
|
||||
mcrfpy.Color(80, 80, 200, 128),
|
||||
mcrfpy.Color(200, 200, 80, 128),
|
||||
mcrfpy.Color(200, 80, 200, 128),
|
||||
mcrfpy.Color(80, 200, 200, 128),
|
||||
mcrfpy.Color(180, 120, 60, 128),
|
||||
mcrfpy.Color(120, 60, 180, 128),
|
||||
]
|
||||
|
||||
# Find all floor regions
|
||||
for start_y in range(h):
|
||||
for start_x in range(w):
|
||||
if visited[start_y][start_x]:
|
||||
continue
|
||||
if self.hmap_binary[start_x, start_y] > 0.5:
|
||||
# Wall cell
|
||||
visited[start_y][start_x] = True
|
||||
continue
|
||||
|
||||
# Flood fill this region
|
||||
region_id += 1
|
||||
region_size = 0
|
||||
stack = [(start_x, start_y)]
|
||||
|
||||
while stack:
|
||||
x, y = stack.pop()
|
||||
if visited[y][x]:
|
||||
continue
|
||||
if self.hmap_binary[x, y] > 0.5:
|
||||
continue
|
||||
|
||||
visited[y][x] = True
|
||||
self.hmap_regions[x, y] = region_id
|
||||
region_size += 1
|
||||
|
||||
# Add neighbors
|
||||
for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
|
||||
nx, ny = x + dx, y + dy
|
||||
if 0 <= nx < w and 0 <= ny < h and not visited[ny][nx]:
|
||||
stack.append((nx, ny))
|
||||
|
||||
self.region_ids.append((region_id, region_size))
|
||||
|
||||
# Sort by size descending
|
||||
self.region_ids.sort(key=lambda x: x[1], reverse=True)
|
||||
if self.region_ids:
|
||||
self.largest_region_id = self.region_ids[0][0]
|
||||
|
||||
# Visualize regions (alpha=128 for overlay)
|
||||
regions_layer = self.get_layer("regions")
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
rid = int(self.hmap_regions[x, y])
|
||||
if rid > 0:
|
||||
color = region_colors[(rid - 1) % len(region_colors)]
|
||||
regions_layer.set((x, y), color)
|
||||
else:
|
||||
regions_layer.set((x, y), mcrfpy.Color(30, 30, 35, 128))
|
||||
|
||||
# Show region count
|
||||
print(f"Found {len(self.region_ids)} regions")
|
||||
|
||||
def step_keep_largest(self):
|
||||
"""Step 7: Keep only the largest connected region."""
|
||||
if not self.region_ids:
|
||||
return
|
||||
|
||||
w, h = self.MAP_SIZE
|
||||
|
||||
# Fill all non-largest regions with wall
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
rid = int(self.hmap_regions[x, y])
|
||||
if rid == 0 or rid != self.largest_region_id:
|
||||
self.hmap_binary[x, y] = 1.0 # Make wall
|
||||
# else: keep as floor
|
||||
|
||||
# Visualize final result
|
||||
final = self.get_layer("final")
|
||||
wall = mcrfpy.Color(45, 40, 38)
|
||||
floor = mcrfpy.Color(160, 150, 130)
|
||||
self._apply_colors_to_layer(final, self.hmap_binary, wall, floor)
|
||||
|
||||
# Also update regions visualization (alpha=128 for overlay)
|
||||
regions_layer = self.get_layer("regions")
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
if self.hmap_binary[x, y] > 0.5:
|
||||
regions_layer.set((x, y), mcrfpy.Color(30, 30, 35, 128))
|
||||
else:
|
||||
regions_layer.set((x, y), mcrfpy.Color(80, 200, 80, 128))
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the cave demo standalone."""
|
||||
demo = CaveDemo()
|
||||
demo.activate()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
532
tests/procgen_interactive/demos/dungeon_demo.py
Normal file
532
tests/procgen_interactive/demos/dungeon_demo.py
Normal file
|
|
@ -0,0 +1,532 @@
|
|||
"""Dungeon Generation Demo - BSP + Corridors
|
||||
|
||||
Demonstrates BSP dungeon generation with:
|
||||
1. Create BSP and split recursively
|
||||
2. Visualize all BSP partitions (educational)
|
||||
3. Extract leaf nodes as rooms
|
||||
4. Shrink leaves to create room margins
|
||||
5. Build adjacency graph (which rooms neighbor)
|
||||
6. Connect adjacent rooms with corridors
|
||||
7. Composite rooms + corridors
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
from typing import List, Dict, Tuple, Set
|
||||
from ..core.demo_base import ProcgenDemoBase, StepDef, LayerDef
|
||||
from ..core.parameter import Parameter
|
||||
|
||||
|
||||
class DungeonDemo(ProcgenDemoBase):
|
||||
"""Interactive BSP dungeon generation demo."""
|
||||
|
||||
name = "Dungeon (BSP)"
|
||||
description = "Binary Space Partitioning with adjacency-based corridors"
|
||||
MAP_SIZE = (128, 96) # Smaller for better visibility of rooms
|
||||
|
||||
def define_steps(self) -> List[StepDef]:
|
||||
"""Define the generation steps."""
|
||||
return [
|
||||
StepDef("Create BSP tree", self.step_create_bsp,
|
||||
"Initialize BSP and split recursively"),
|
||||
StepDef("Show all partitions", self.step_show_partitions,
|
||||
"Visualize the full BSP tree structure"),
|
||||
StepDef("Extract rooms", self.step_extract_rooms,
|
||||
"Get leaf nodes as potential room spaces"),
|
||||
StepDef("Shrink rooms", self.step_shrink_rooms,
|
||||
"Add margins between rooms"),
|
||||
StepDef("Build adjacency", self.step_build_adjacency,
|
||||
"Find which rooms are neighbors"),
|
||||
StepDef("Dig corridors", self.step_dig_corridors,
|
||||
"Connect adjacent rooms with corridors"),
|
||||
StepDef("Composite", self.step_composite,
|
||||
"Combine rooms and corridors for final dungeon"),
|
||||
]
|
||||
|
||||
def define_parameters(self) -> List[Parameter]:
|
||||
"""Define configurable parameters."""
|
||||
return [
|
||||
Parameter(
|
||||
name="seed",
|
||||
display="Seed",
|
||||
type="int",
|
||||
default=42,
|
||||
min_val=0,
|
||||
max_val=99999,
|
||||
step=1,
|
||||
affects_step=0,
|
||||
description="Random seed for BSP splits"
|
||||
),
|
||||
Parameter(
|
||||
name="depth",
|
||||
display="BSP Depth",
|
||||
type="int",
|
||||
default=4,
|
||||
min_val=2,
|
||||
max_val=6,
|
||||
step=1,
|
||||
affects_step=0,
|
||||
description="BSP recursion depth"
|
||||
),
|
||||
Parameter(
|
||||
name="min_room_w",
|
||||
display="Min Room W",
|
||||
type="int",
|
||||
default=8,
|
||||
min_val=4,
|
||||
max_val=16,
|
||||
step=2,
|
||||
affects_step=0,
|
||||
description="Minimum room width"
|
||||
),
|
||||
Parameter(
|
||||
name="min_room_h",
|
||||
display="Min Room H",
|
||||
type="int",
|
||||
default=6,
|
||||
min_val=4,
|
||||
max_val=12,
|
||||
step=2,
|
||||
affects_step=0,
|
||||
description="Minimum room height"
|
||||
),
|
||||
Parameter(
|
||||
name="shrink",
|
||||
display="Room Shrink",
|
||||
type="int",
|
||||
default=2,
|
||||
min_val=0,
|
||||
max_val=4,
|
||||
step=1,
|
||||
affects_step=3,
|
||||
description="Room inset from leaf bounds"
|
||||
),
|
||||
Parameter(
|
||||
name="corridor_width",
|
||||
display="Corridor W",
|
||||
type="int",
|
||||
default=2,
|
||||
min_val=1,
|
||||
max_val=3,
|
||||
step=1,
|
||||
affects_step=5,
|
||||
description="Corridor thickness"
|
||||
),
|
||||
]
|
||||
|
||||
def define_layers(self) -> List[LayerDef]:
|
||||
"""Define visualization layers."""
|
||||
return [
|
||||
LayerDef("final", "Final Dungeon", "color", z_index=-1, visible=True,
|
||||
description="Combined rooms and corridors"),
|
||||
LayerDef("bsp_tree", "BSP Tree", "color", z_index=0, visible=False,
|
||||
description="All BSP partition boundaries"),
|
||||
LayerDef("rooms", "Rooms Only", "color", z_index=1, visible=False,
|
||||
description="Room areas without corridors"),
|
||||
LayerDef("corridors", "Corridors", "color", z_index=2, visible=False,
|
||||
description="Corridor paths only"),
|
||||
LayerDef("adjacency", "Adjacency", "color", z_index=3, visible=False,
|
||||
description="Lines between adjacent room centers"),
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize dungeon demo."""
|
||||
super().__init__()
|
||||
|
||||
# BSP data
|
||||
self.bsp = None
|
||||
self.leaves = []
|
||||
self.rooms = [] # List of (x, y, w, h) tuples
|
||||
self.room_centers = [] # List of (cx, cy) tuples
|
||||
self.adjacencies = [] # List of (room_idx_1, room_idx_2) pairs
|
||||
|
||||
# HeightMaps for visualization
|
||||
self.hmap_rooms = self.create_heightmap("rooms", 0.0)
|
||||
self.hmap_corridors = self.create_heightmap("corridors", 0.0)
|
||||
|
||||
def _clear_layers(self):
|
||||
"""Clear all visualization layers."""
|
||||
for layer in self.layers.values():
|
||||
layer.fill(mcrfpy.Color(30, 28, 26))
|
||||
|
||||
def _draw_rect(self, layer, x, y, w, h, color, outline_only=False, alpha=None):
|
||||
"""Draw a rectangle on a layer."""
|
||||
map_w, map_h = self.MAP_SIZE
|
||||
# Apply alpha if specified
|
||||
if alpha is not None:
|
||||
color = mcrfpy.Color(color.r, color.g, color.b, alpha)
|
||||
if outline_only:
|
||||
# Draw just the outline
|
||||
for px in range(x, x + w):
|
||||
if 0 <= px < map_w:
|
||||
if 0 <= y < map_h:
|
||||
layer.set((px, y), color)
|
||||
if 0 <= y + h - 1 < map_h:
|
||||
layer.set((px, y + h - 1), color)
|
||||
for py in range(y, y + h):
|
||||
if 0 <= py < map_h:
|
||||
if 0 <= x < map_w:
|
||||
layer.set((x, py), color)
|
||||
if 0 <= x + w - 1 < map_w:
|
||||
layer.set((x + w - 1, py), color)
|
||||
else:
|
||||
# Fill the rectangle
|
||||
for py in range(y, y + h):
|
||||
for px in range(x, x + w):
|
||||
if 0 <= px < map_w and 0 <= py < map_h:
|
||||
layer.set((px, py), color)
|
||||
|
||||
def _draw_line(self, layer, x0, y0, x1, y1, color, width=1, alpha=None):
|
||||
"""Draw a line on a layer using Bresenham's algorithm."""
|
||||
map_w, map_h = self.MAP_SIZE
|
||||
# Apply alpha if specified
|
||||
if alpha is not None:
|
||||
color = mcrfpy.Color(color.r, color.g, color.b, alpha)
|
||||
dx = abs(x1 - x0)
|
||||
dy = abs(y1 - y0)
|
||||
sx = 1 if x0 < x1 else -1
|
||||
sy = 1 if y0 < y1 else -1
|
||||
err = dx - dy
|
||||
|
||||
while True:
|
||||
# Draw width around center point
|
||||
for wo in range(-(width // 2), width // 2 + 1):
|
||||
for ho in range(-(width // 2), width // 2 + 1):
|
||||
px, py = x0 + wo, y0 + ho
|
||||
if 0 <= px < map_w and 0 <= py < map_h:
|
||||
layer.set((px, py), color)
|
||||
|
||||
if x0 == x1 and y0 == y1:
|
||||
break
|
||||
|
||||
e2 = 2 * err
|
||||
if e2 > -dy:
|
||||
err -= dy
|
||||
x0 += sx
|
||||
if e2 < dx:
|
||||
err += dx
|
||||
y0 += sy
|
||||
|
||||
# === Step Implementations ===
|
||||
|
||||
def step_create_bsp(self):
|
||||
"""Step 1: Create and split BSP tree."""
|
||||
seed = self.get_param("seed")
|
||||
depth = self.get_param("depth")
|
||||
min_w = self.get_param("min_room_w")
|
||||
min_h = self.get_param("min_room_h")
|
||||
|
||||
w, h = self.MAP_SIZE
|
||||
|
||||
# Create BSP covering the map (with margin)
|
||||
margin = 2
|
||||
self.bsp = mcrfpy.BSP(
|
||||
pos=(margin, margin),
|
||||
size=(w - margin * 2, h - margin * 2)
|
||||
)
|
||||
|
||||
# Split recursively
|
||||
self.bsp.split_recursive(
|
||||
depth=depth,
|
||||
min_size=(min_w, min_h),
|
||||
seed=seed
|
||||
)
|
||||
|
||||
# Clear and show initial state
|
||||
self._clear_layers()
|
||||
final = self.get_layer("final")
|
||||
final.fill(mcrfpy.Color(30, 28, 26))
|
||||
|
||||
# Draw BSP root bounds
|
||||
bsp_layer = self.get_layer("bsp_tree")
|
||||
bsp_layer.fill(mcrfpy.Color(30, 28, 26))
|
||||
x, y = self.bsp.pos
|
||||
w, h = self.bsp.size
|
||||
self._draw_rect(bsp_layer, x, y, w, h, mcrfpy.Color(80, 80, 100), outline_only=True)
|
||||
|
||||
def step_show_partitions(self):
|
||||
"""Step 2: Visualize all BSP partitions."""
|
||||
bsp_layer = self.get_layer("bsp_tree")
|
||||
bsp_layer.fill(mcrfpy.Color(30, 28, 26, 128)) # alpha=128 for overlay
|
||||
|
||||
# Color palette for different depths (alpha=128 for overlay)
|
||||
depth_colors = [
|
||||
mcrfpy.Color(120, 60, 60, 128),
|
||||
mcrfpy.Color(60, 120, 60, 128),
|
||||
mcrfpy.Color(60, 60, 120, 128),
|
||||
mcrfpy.Color(120, 120, 60, 128),
|
||||
mcrfpy.Color(120, 60, 120, 128),
|
||||
mcrfpy.Color(60, 120, 120, 128),
|
||||
]
|
||||
|
||||
def draw_node(node, depth=0):
|
||||
"""Recursively draw BSP nodes."""
|
||||
x, y = node.pos
|
||||
w, h = node.size
|
||||
color = depth_colors[depth % len(depth_colors)]
|
||||
|
||||
# Draw outline
|
||||
self._draw_rect(bsp_layer, x, y, w, h, color, outline_only=True)
|
||||
|
||||
# Draw children using left/right
|
||||
if node.left:
|
||||
draw_node(node.left, depth + 1)
|
||||
if node.right:
|
||||
draw_node(node.right, depth + 1)
|
||||
|
||||
# Start from root
|
||||
root = self.bsp.root
|
||||
if root:
|
||||
draw_node(root)
|
||||
|
||||
# Also show on final layer
|
||||
final = self.get_layer("final")
|
||||
# Copy bsp_tree to final
|
||||
w, h = self.MAP_SIZE
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
c = bsp_layer.at(x, y)
|
||||
final.set((x, y), c)
|
||||
|
||||
def step_extract_rooms(self):
|
||||
"""Step 3: Extract leaf nodes as rooms."""
|
||||
# Get all leaves
|
||||
self.leaves = list(self.bsp.leaves())
|
||||
self.rooms = []
|
||||
self.room_centers = []
|
||||
|
||||
rooms_layer = self.get_layer("rooms")
|
||||
rooms_layer.fill(mcrfpy.Color(30, 28, 26, 128))
|
||||
|
||||
# Draw each leaf as a room (alpha=128 for overlay)
|
||||
room_colors = [
|
||||
mcrfpy.Color(100, 80, 60, 128),
|
||||
mcrfpy.Color(80, 100, 60, 128),
|
||||
mcrfpy.Color(60, 80, 100, 128),
|
||||
mcrfpy.Color(100, 100, 60, 128),
|
||||
]
|
||||
|
||||
for i, leaf in enumerate(self.leaves):
|
||||
x, y = leaf.pos
|
||||
w, h = leaf.size
|
||||
self.rooms.append((x, y, w, h))
|
||||
self.room_centers.append((x + w // 2, y + h // 2))
|
||||
|
||||
color = room_colors[i % len(room_colors)]
|
||||
self._draw_rect(rooms_layer, x, y, w, h, color)
|
||||
|
||||
# Also show on final
|
||||
final = self.get_layer("final")
|
||||
map_w, map_h = self.MAP_SIZE
|
||||
for y in range(map_h):
|
||||
for x in range(map_w):
|
||||
c = rooms_layer.at(x, y)
|
||||
final.set((x, y), c)
|
||||
|
||||
print(f"Extracted {len(self.rooms)} rooms")
|
||||
|
||||
def step_shrink_rooms(self):
|
||||
"""Step 4: Shrink rooms to add margins."""
|
||||
shrink = self.get_param("shrink")
|
||||
|
||||
rooms_layer = self.get_layer("rooms")
|
||||
rooms_layer.fill(mcrfpy.Color(30, 28, 26, 128))
|
||||
|
||||
# Shrink each room
|
||||
shrunk_rooms = []
|
||||
shrunk_centers = []
|
||||
|
||||
room_color = mcrfpy.Color(120, 100, 80, 128) # alpha=128 for overlay
|
||||
|
||||
for x, y, w, h in self.rooms:
|
||||
# Apply shrink
|
||||
nx = x + shrink
|
||||
ny = y + shrink
|
||||
nw = w - shrink * 2
|
||||
nh = h - shrink * 2
|
||||
|
||||
# Ensure minimum size
|
||||
if nw >= 3 and nh >= 3:
|
||||
shrunk_rooms.append((nx, ny, nw, nh))
|
||||
shrunk_centers.append((nx + nw // 2, ny + nh // 2))
|
||||
self._draw_rect(rooms_layer, nx, ny, nw, nh, room_color)
|
||||
|
||||
# Store in heightmap for later
|
||||
map_w, map_h = self.MAP_SIZE
|
||||
for py in range(ny, ny + nh):
|
||||
for px in range(nx, nx + nw):
|
||||
if 0 <= px < map_w and 0 <= py < map_h:
|
||||
self.hmap_rooms[px, py] = 1.0
|
||||
|
||||
self.rooms = shrunk_rooms
|
||||
self.room_centers = shrunk_centers
|
||||
|
||||
# Update final
|
||||
final = self.get_layer("final")
|
||||
map_w, map_h = self.MAP_SIZE
|
||||
for y in range(map_h):
|
||||
for x in range(map_w):
|
||||
c = rooms_layer.at(x, y)
|
||||
final.set((x, y), c)
|
||||
|
||||
print(f"Shrunk to {len(self.rooms)} valid rooms")
|
||||
|
||||
def step_build_adjacency(self):
|
||||
"""Step 5: Build adjacency graph between rooms."""
|
||||
self.adjacencies = []
|
||||
|
||||
# Simple adjacency: rooms whose bounding boxes are close enough
|
||||
# In a real implementation, use BSP adjacency
|
||||
|
||||
# For each pair of rooms, check if they share an edge
|
||||
for i in range(len(self.rooms)):
|
||||
for j in range(i + 1, len(self.rooms)):
|
||||
r1 = self.rooms[i]
|
||||
r2 = self.rooms[j]
|
||||
|
||||
# Check if rooms are adjacent (share edge or close)
|
||||
if self._rooms_adjacent(r1, r2):
|
||||
self.adjacencies.append((i, j))
|
||||
|
||||
# Visualize adjacency lines (alpha=128 for overlay)
|
||||
adj_layer = self.get_layer("adjacency")
|
||||
adj_layer.fill(mcrfpy.Color(30, 28, 26, 128))
|
||||
|
||||
line_color = mcrfpy.Color(200, 100, 100, 160) # semi-transparent overlay
|
||||
for i, j in self.adjacencies:
|
||||
c1 = self.room_centers[i]
|
||||
c2 = self.room_centers[j]
|
||||
self._draw_line(adj_layer, c1[0], c1[1], c2[0], c2[1], line_color, width=1)
|
||||
|
||||
# Show room centers as dots
|
||||
center_color = mcrfpy.Color(255, 200, 0, 200) # more visible
|
||||
for cx, cy in self.room_centers:
|
||||
for dx in range(-1, 2):
|
||||
for dy in range(-1, 2):
|
||||
px, py = cx + dx, cy + dy
|
||||
map_w, map_h = self.MAP_SIZE
|
||||
if 0 <= px < map_w and 0 <= py < map_h:
|
||||
adj_layer.set((px, py), center_color)
|
||||
|
||||
print(f"Found {len(self.adjacencies)} adjacencies")
|
||||
|
||||
def _rooms_adjacent(self, r1, r2) -> bool:
|
||||
"""Check if two rooms are adjacent."""
|
||||
x1, y1, w1, h1 = r1
|
||||
x2, y2, w2, h2 = r2
|
||||
|
||||
# Horizontal adjacency (side by side)
|
||||
h_gap = max(x1, x2) - min(x1 + w1, x2 + w2)
|
||||
v_overlap = min(y1 + h1, y2 + h2) - max(y1, y2)
|
||||
|
||||
if h_gap <= 4 and v_overlap > 2:
|
||||
return True
|
||||
|
||||
# Vertical adjacency (stacked)
|
||||
v_gap = max(y1, y2) - min(y1 + h1, y2 + h2)
|
||||
h_overlap = min(x1 + w1, x2 + w2) - max(x1, x2)
|
||||
|
||||
if v_gap <= 4 and h_overlap > 2:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def step_dig_corridors(self):
|
||||
"""Step 6: Connect adjacent rooms with corridors."""
|
||||
corridor_width = self.get_param("corridor_width")
|
||||
|
||||
corridors_layer = self.get_layer("corridors")
|
||||
corridors_layer.fill(mcrfpy.Color(30, 28, 26, 128)) # alpha=128 for overlay
|
||||
|
||||
corridor_color = mcrfpy.Color(90, 85, 75, 128) # alpha=128 for overlay
|
||||
|
||||
for i, j in self.adjacencies:
|
||||
c1 = self.room_centers[i]
|
||||
c2 = self.room_centers[j]
|
||||
|
||||
# L-shaped corridor (horizontal then vertical)
|
||||
mid_x = c1[0]
|
||||
mid_y = c2[1]
|
||||
|
||||
# Horizontal segment
|
||||
self._draw_line(corridors_layer, c1[0], c1[1], mid_x, mid_y,
|
||||
corridor_color, width=corridor_width)
|
||||
# Vertical segment
|
||||
self._draw_line(corridors_layer, mid_x, mid_y, c2[0], c2[1],
|
||||
corridor_color, width=corridor_width)
|
||||
|
||||
# Store in heightmap
|
||||
map_w, map_h = self.MAP_SIZE
|
||||
# Mark corridor cells
|
||||
self._mark_line(c1[0], c1[1], mid_x, mid_y, corridor_width)
|
||||
self._mark_line(mid_x, mid_y, c2[0], c2[1], corridor_width)
|
||||
|
||||
# Update final to show rooms + corridors
|
||||
final = self.get_layer("final")
|
||||
rooms_layer = self.get_layer("rooms")
|
||||
map_w, map_h = self.MAP_SIZE
|
||||
for y in range(map_h):
|
||||
for x in range(map_w):
|
||||
room_c = rooms_layer.at(x, y)
|
||||
corr_c = corridors_layer.at(x, y)
|
||||
# Prioritize rooms, then corridors, then background
|
||||
if room_c.r > 50 or room_c.g > 50 or room_c.b > 50:
|
||||
final.set((x, y), room_c)
|
||||
elif corr_c.r > 50 or corr_c.g > 50 or corr_c.b > 50:
|
||||
final.set((x, y), corr_c)
|
||||
else:
|
||||
final.set((x, y), mcrfpy.Color(30, 28, 26))
|
||||
|
||||
def _mark_line(self, x0, y0, x1, y1, width):
|
||||
"""Mark corridor cells in heightmap."""
|
||||
map_w, map_h = self.MAP_SIZE
|
||||
dx = abs(x1 - x0)
|
||||
dy = abs(y1 - y0)
|
||||
sx = 1 if x0 < x1 else -1
|
||||
sy = 1 if y0 < y1 else -1
|
||||
err = dx - dy
|
||||
|
||||
while True:
|
||||
for wo in range(-(width // 2), width // 2 + 1):
|
||||
for ho in range(-(width // 2), width // 2 + 1):
|
||||
px, py = x0 + wo, y0 + ho
|
||||
if 0 <= px < map_w and 0 <= py < map_h:
|
||||
self.hmap_corridors[px, py] = 1.0
|
||||
|
||||
if x0 == x1 and y0 == y1:
|
||||
break
|
||||
|
||||
e2 = 2 * err
|
||||
if e2 > -dy:
|
||||
err -= dy
|
||||
x0 += sx
|
||||
if e2 < dx:
|
||||
err += dx
|
||||
y0 += sy
|
||||
|
||||
def step_composite(self):
|
||||
"""Step 7: Create final composite dungeon."""
|
||||
final = self.get_layer("final")
|
||||
map_w, map_h = self.MAP_SIZE
|
||||
|
||||
wall_color = mcrfpy.Color(40, 38, 35)
|
||||
floor_color = mcrfpy.Color(140, 130, 115)
|
||||
|
||||
for y in range(map_h):
|
||||
for x in range(map_w):
|
||||
is_room = self.hmap_rooms[x, y] > 0.5
|
||||
is_corridor = self.hmap_corridors[x, y] > 0.5
|
||||
|
||||
if is_room or is_corridor:
|
||||
final.set((x, y), floor_color)
|
||||
else:
|
||||
final.set((x, y), wall_color)
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the dungeon demo standalone."""
|
||||
demo = DungeonDemo()
|
||||
demo.activate()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
311
tests/procgen_interactive/demos/terrain_demo.py
Normal file
311
tests/procgen_interactive/demos/terrain_demo.py
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
"""Terrain Generation Demo - Multi-layer Elevation
|
||||
|
||||
Demonstrates terrain generation with:
|
||||
1. Generate base elevation with simplex FBM
|
||||
2. Normalize to 0-1 range
|
||||
3. Apply water level (flatten below threshold)
|
||||
4. Add mountain enhancement (boost peaks)
|
||||
5. Optional erosion simulation
|
||||
6. Apply terrain color ranges (biomes)
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
from typing import List
|
||||
from ..core.demo_base import ProcgenDemoBase, StepDef, LayerDef
|
||||
from ..core.parameter import Parameter
|
||||
|
||||
|
||||
class TerrainDemo(ProcgenDemoBase):
|
||||
"""Interactive multi-layer terrain generation demo."""
|
||||
|
||||
name = "Terrain"
|
||||
description = "Multi-layer elevation with noise and biome coloring"
|
||||
MAP_SIZE = (256, 256)
|
||||
|
||||
# Terrain color ranges (elevation -> color gradient)
|
||||
TERRAIN_COLORS = [
|
||||
(0.00, 0.15, (30, 50, 120), (50, 80, 150)), # Deep water -> Shallow water
|
||||
(0.15, 0.22, (50, 80, 150), (180, 170, 130)), # Shallow water -> Beach
|
||||
(0.22, 0.35, (180, 170, 130), (80, 140, 60)), # Beach -> Grass low
|
||||
(0.35, 0.55, (80, 140, 60), (50, 110, 40)), # Grass low -> Grass high
|
||||
(0.55, 0.70, (50, 110, 40), (100, 90, 70)), # Grass high -> Rock low
|
||||
(0.70, 0.85, (100, 90, 70), (140, 130, 120)), # Rock low -> Rock high
|
||||
(0.85, 1.00, (140, 130, 120), (220, 220, 225)), # Rock high -> Snow
|
||||
]
|
||||
|
||||
def define_steps(self) -> List[StepDef]:
|
||||
"""Define the generation steps."""
|
||||
return [
|
||||
StepDef("Generate base elevation", self.step_base_elevation,
|
||||
"Create initial terrain using simplex FBM noise"),
|
||||
StepDef("Normalize heights", self.step_normalize,
|
||||
"Normalize elevation values to 0-1 range"),
|
||||
StepDef("Apply water level", self.step_water_level,
|
||||
"Flatten terrain below water threshold"),
|
||||
StepDef("Enhance mountains", self.step_mountains,
|
||||
"Boost high elevation areas for dramatic peaks"),
|
||||
StepDef("Apply erosion", self.step_erosion,
|
||||
"Smooth terrain with erosion simulation"),
|
||||
StepDef("Color biomes", self.step_biomes,
|
||||
"Apply biome colors based on elevation"),
|
||||
]
|
||||
|
||||
def define_parameters(self) -> List[Parameter]:
|
||||
"""Define configurable parameters."""
|
||||
return [
|
||||
Parameter(
|
||||
name="seed",
|
||||
display="Seed",
|
||||
type="int",
|
||||
default=42,
|
||||
min_val=0,
|
||||
max_val=99999,
|
||||
step=1,
|
||||
affects_step=0,
|
||||
description="Noise seed"
|
||||
),
|
||||
Parameter(
|
||||
name="octaves",
|
||||
display="Octaves",
|
||||
type="int",
|
||||
default=6,
|
||||
min_val=1,
|
||||
max_val=8,
|
||||
step=1,
|
||||
affects_step=0,
|
||||
description="FBM detail octaves"
|
||||
),
|
||||
Parameter(
|
||||
name="world_size",
|
||||
display="Scale",
|
||||
type="float",
|
||||
default=8.0,
|
||||
min_val=2.0,
|
||||
max_val=20.0,
|
||||
step=1.0,
|
||||
affects_step=0,
|
||||
description="Noise scale (larger = more zoomed out)"
|
||||
),
|
||||
Parameter(
|
||||
name="water_level",
|
||||
display="Water Level",
|
||||
type="float",
|
||||
default=0.20,
|
||||
min_val=0.0,
|
||||
max_val=0.40,
|
||||
step=0.02,
|
||||
affects_step=2,
|
||||
description="Sea level threshold"
|
||||
),
|
||||
Parameter(
|
||||
name="mountain_boost",
|
||||
display="Mt. Boost",
|
||||
type="float",
|
||||
default=0.25,
|
||||
min_val=0.0,
|
||||
max_val=0.50,
|
||||
step=0.05,
|
||||
affects_step=3,
|
||||
description="Mountain height enhancement"
|
||||
),
|
||||
Parameter(
|
||||
name="erosion_passes",
|
||||
display="Erosion",
|
||||
type="int",
|
||||
default=2,
|
||||
min_val=0,
|
||||
max_val=5,
|
||||
step=1,
|
||||
affects_step=4,
|
||||
description="Erosion smoothing passes"
|
||||
),
|
||||
]
|
||||
|
||||
def define_layers(self) -> List[LayerDef]:
|
||||
"""Define visualization layers."""
|
||||
return [
|
||||
LayerDef("colored", "Colored Terrain", "color", z_index=-1, visible=True,
|
||||
description="Final terrain with biome colors"),
|
||||
LayerDef("elevation", "Elevation", "color", z_index=0, visible=False,
|
||||
description="Grayscale height values"),
|
||||
LayerDef("water_mask", "Water Mask", "color", z_index=1, visible=False,
|
||||
description="Binary water regions"),
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize terrain demo."""
|
||||
super().__init__()
|
||||
|
||||
# Create working heightmaps
|
||||
self.hmap_elevation = self.create_heightmap("elevation", 0.0)
|
||||
self.hmap_water = self.create_heightmap("water", 0.0)
|
||||
|
||||
# Noise source
|
||||
self.noise = None
|
||||
|
||||
def _apply_grayscale(self, layer, hmap, alpha=255):
|
||||
"""Apply grayscale visualization to layer."""
|
||||
w, h = self.MAP_SIZE
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
val = hmap[x, y]
|
||||
v = int(max(0, min(255, val * 255)))
|
||||
layer.set((x, y), mcrfpy.Color(v, v, v, alpha))
|
||||
|
||||
def _apply_terrain_colors(self, layer, hmap, alpha=255):
|
||||
"""Apply terrain biome colors based on elevation."""
|
||||
w, h = self.MAP_SIZE
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
val = hmap[x, y]
|
||||
color = self._elevation_to_color(val, alpha)
|
||||
layer.set((x, y), color)
|
||||
|
||||
def _elevation_to_color(self, val, alpha=255):
|
||||
"""Convert elevation value to terrain color."""
|
||||
for low, high, c1, c2 in self.TERRAIN_COLORS:
|
||||
if low <= val <= high:
|
||||
# Interpolate between c1 and c2
|
||||
t = (val - low) / (high - low) if high > low else 0
|
||||
r = int(c1[0] + t * (c2[0] - c1[0]))
|
||||
g = int(c1[1] + t * (c2[1] - c1[1]))
|
||||
b = int(c1[2] + t * (c2[2] - c1[2]))
|
||||
return mcrfpy.Color(r, g, b, alpha)
|
||||
|
||||
# Default for out of range
|
||||
return mcrfpy.Color(128, 128, 128)
|
||||
|
||||
# === Step Implementations ===
|
||||
|
||||
def step_base_elevation(self):
|
||||
"""Step 1: Generate base elevation with FBM noise."""
|
||||
seed = self.get_param("seed")
|
||||
octaves = self.get_param("octaves")
|
||||
world_size = self.get_param("world_size")
|
||||
|
||||
# Create noise source
|
||||
self.noise = mcrfpy.NoiseSource(
|
||||
dimensions=2,
|
||||
algorithm='simplex',
|
||||
seed=seed
|
||||
)
|
||||
|
||||
# Fill with FBM noise
|
||||
self.hmap_elevation.fill(0.0)
|
||||
self.hmap_elevation.add_noise(
|
||||
self.noise,
|
||||
world_size=(world_size, world_size),
|
||||
mode='fbm',
|
||||
octaves=octaves
|
||||
)
|
||||
|
||||
# Show raw noise (elevation layer alpha=128 for overlay)
|
||||
elevation_layer = self.get_layer("elevation")
|
||||
self._apply_grayscale(elevation_layer, self.hmap_elevation, alpha=128)
|
||||
|
||||
# Also on colored layer (full opacity for final)
|
||||
colored_layer = self.get_layer("colored")
|
||||
self._apply_grayscale(colored_layer, self.hmap_elevation, alpha=255)
|
||||
|
||||
def step_normalize(self):
|
||||
"""Step 2: Normalize elevation to 0-1 range."""
|
||||
self.hmap_elevation.normalize(0.0, 1.0)
|
||||
|
||||
# Update visualization
|
||||
elevation_layer = self.get_layer("elevation")
|
||||
self._apply_grayscale(elevation_layer, self.hmap_elevation, alpha=128)
|
||||
|
||||
colored_layer = self.get_layer("colored")
|
||||
self._apply_grayscale(colored_layer, self.hmap_elevation, alpha=255)
|
||||
|
||||
def step_water_level(self):
|
||||
"""Step 3: Flatten terrain below water level."""
|
||||
water_level = self.get_param("water_level")
|
||||
w, h = self.MAP_SIZE
|
||||
|
||||
# Create water mask
|
||||
self.hmap_water.fill(0.0)
|
||||
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
val = self.hmap_elevation[x, y]
|
||||
if val < water_level:
|
||||
# Flatten to water level
|
||||
self.hmap_elevation[x, y] = water_level
|
||||
self.hmap_water[x, y] = 1.0
|
||||
|
||||
# Update water mask layer (alpha=128 for overlay)
|
||||
water_layer = self.get_layer("water_mask")
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
if self.hmap_water[x, y] > 0.5:
|
||||
water_layer.set((x, y), mcrfpy.Color(80, 120, 200, 128))
|
||||
else:
|
||||
water_layer.set((x, y), mcrfpy.Color(30, 30, 35, 128))
|
||||
|
||||
# Update other layers
|
||||
elevation_layer = self.get_layer("elevation")
|
||||
self._apply_grayscale(elevation_layer, self.hmap_elevation, alpha=128)
|
||||
|
||||
colored_layer = self.get_layer("colored")
|
||||
self._apply_grayscale(colored_layer, self.hmap_elevation, alpha=255)
|
||||
|
||||
def step_mountains(self):
|
||||
"""Step 4: Enhance mountain peaks."""
|
||||
mountain_boost = self.get_param("mountain_boost")
|
||||
w, h = self.MAP_SIZE
|
||||
|
||||
if mountain_boost <= 0:
|
||||
return # Skip if no boost
|
||||
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
val = self.hmap_elevation[x, y]
|
||||
# Boost high elevations more than low ones
|
||||
# Using a power curve
|
||||
if val > 0.5:
|
||||
boost = (val - 0.5) * 2 # 0 to 1 for upper half
|
||||
boost = boost * boost * mountain_boost # Squared for sharper peaks
|
||||
self.hmap_elevation[x, y] = min(1.0, val + boost)
|
||||
|
||||
# Re-normalize to ensure 0-1 range
|
||||
self.hmap_elevation.normalize(0.0, 1.0)
|
||||
|
||||
# Update visualization
|
||||
elevation_layer = self.get_layer("elevation")
|
||||
self._apply_grayscale(elevation_layer, self.hmap_elevation, alpha=128)
|
||||
|
||||
colored_layer = self.get_layer("colored")
|
||||
self._apply_grayscale(colored_layer, self.hmap_elevation, alpha=255)
|
||||
|
||||
def step_erosion(self):
|
||||
"""Step 5: Apply erosion/smoothing."""
|
||||
erosion_passes = self.get_param("erosion_passes")
|
||||
|
||||
if erosion_passes <= 0:
|
||||
return # Skip if no erosion
|
||||
|
||||
for _ in range(erosion_passes):
|
||||
self.hmap_elevation.smooth(iterations=1)
|
||||
|
||||
# Update visualization
|
||||
elevation_layer = self.get_layer("elevation")
|
||||
self._apply_grayscale(elevation_layer, self.hmap_elevation, alpha=128)
|
||||
|
||||
colored_layer = self.get_layer("colored")
|
||||
self._apply_grayscale(colored_layer, self.hmap_elevation, alpha=255)
|
||||
|
||||
def step_biomes(self):
|
||||
"""Step 6: Apply biome colors based on elevation."""
|
||||
colored_layer = self.get_layer("colored")
|
||||
self._apply_terrain_colors(colored_layer, self.hmap_elevation, alpha=255)
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the terrain demo standalone."""
|
||||
demo = TerrainDemo()
|
||||
demo.activate()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
509
tests/procgen_interactive/demos/town_demo.py
Normal file
509
tests/procgen_interactive/demos/town_demo.py
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
"""Town Generation Demo - Voronoi Districts + Bezier Roads
|
||||
|
||||
Demonstrates town generation with:
|
||||
1. Generate base terrain elevation
|
||||
2. Add Voronoi districts using HeightMap.add_voronoi()
|
||||
3. Find district centers
|
||||
4. Connect centers with roads using HeightMap.dig_bezier()
|
||||
5. Place building footprints in districts
|
||||
6. Composite: terrain + roads + buildings
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
import random
|
||||
from typing import List, Tuple
|
||||
from ..core.demo_base import ProcgenDemoBase, StepDef, LayerDef
|
||||
from ..core.parameter import Parameter
|
||||
|
||||
|
||||
class TownDemo(ProcgenDemoBase):
|
||||
"""Interactive Voronoi town generation demo."""
|
||||
|
||||
name = "Town"
|
||||
description = "Voronoi districts with Bezier roads and building placement"
|
||||
MAP_SIZE = (128, 96) # Smaller for clearer visualization
|
||||
|
||||
def define_steps(self) -> List[StepDef]:
|
||||
"""Define the generation steps."""
|
||||
return [
|
||||
StepDef("Generate terrain", self.step_terrain,
|
||||
"Create base terrain elevation"),
|
||||
StepDef("Create districts", self.step_districts,
|
||||
"Add Voronoi districts for zoning"),
|
||||
StepDef("Find centers", self.step_find_centers,
|
||||
"Locate district center points"),
|
||||
StepDef("Build roads", self.step_roads,
|
||||
"Connect districts with Bezier roads"),
|
||||
StepDef("Place buildings", self.step_buildings,
|
||||
"Add building footprints in districts"),
|
||||
StepDef("Composite", self.step_composite,
|
||||
"Combine all layers for final town"),
|
||||
]
|
||||
|
||||
def define_parameters(self) -> List[Parameter]:
|
||||
"""Define configurable parameters."""
|
||||
return [
|
||||
Parameter(
|
||||
name="seed",
|
||||
display="Seed",
|
||||
type="int",
|
||||
default=42,
|
||||
min_val=0,
|
||||
max_val=99999,
|
||||
step=1,
|
||||
affects_step=0,
|
||||
description="Random seed for all generation"
|
||||
),
|
||||
Parameter(
|
||||
name="num_districts",
|
||||
display="Districts",
|
||||
type="int",
|
||||
default=12,
|
||||
min_val=5,
|
||||
max_val=25,
|
||||
step=1,
|
||||
affects_step=1,
|
||||
description="Number of Voronoi districts"
|
||||
),
|
||||
Parameter(
|
||||
name="road_width",
|
||||
display="Road Width",
|
||||
type="float",
|
||||
default=2.0,
|
||||
min_val=1.0,
|
||||
max_val=4.0,
|
||||
step=0.5,
|
||||
affects_step=3,
|
||||
description="Bezier road thickness"
|
||||
),
|
||||
Parameter(
|
||||
name="building_density",
|
||||
display="Building %",
|
||||
type="float",
|
||||
default=0.40,
|
||||
min_val=0.20,
|
||||
max_val=0.70,
|
||||
step=0.05,
|
||||
affects_step=4,
|
||||
description="Building coverage density"
|
||||
),
|
||||
Parameter(
|
||||
name="building_min",
|
||||
display="Min Building",
|
||||
type="int",
|
||||
default=3,
|
||||
min_val=2,
|
||||
max_val=5,
|
||||
step=1,
|
||||
affects_step=4,
|
||||
description="Minimum building size"
|
||||
),
|
||||
Parameter(
|
||||
name="building_max",
|
||||
display="Max Building",
|
||||
type="int",
|
||||
default=6,
|
||||
min_val=4,
|
||||
max_val=10,
|
||||
step=1,
|
||||
affects_step=4,
|
||||
description="Maximum building size"
|
||||
),
|
||||
]
|
||||
|
||||
def define_layers(self) -> List[LayerDef]:
|
||||
"""Define visualization layers."""
|
||||
return [
|
||||
LayerDef("final", "Final Town", "color", z_index=-1, visible=True,
|
||||
description="Complete town composite"),
|
||||
LayerDef("districts", "Districts", "color", z_index=0, visible=False,
|
||||
description="Voronoi district regions"),
|
||||
LayerDef("roads", "Roads", "color", z_index=1, visible=False,
|
||||
description="Road network"),
|
||||
LayerDef("buildings", "Buildings", "color", z_index=2, visible=False,
|
||||
description="Building footprints"),
|
||||
LayerDef("control_pts", "Control Points", "color", z_index=3, visible=False,
|
||||
description="Bezier control points (educational)"),
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize town demo."""
|
||||
super().__init__()
|
||||
|
||||
# Working heightmaps
|
||||
self.hmap_terrain = self.create_heightmap("terrain", 0.0)
|
||||
self.hmap_districts = self.create_heightmap("districts", 0.0)
|
||||
self.hmap_roads = self.create_heightmap("roads", 0.0)
|
||||
self.hmap_buildings = self.create_heightmap("buildings", 0.0)
|
||||
|
||||
# District data
|
||||
self.district_points = [] # Voronoi seed points
|
||||
self.district_centers = [] # Calculated centroids
|
||||
self.connections = [] # List of (idx1, idx2) for roads
|
||||
|
||||
# Random state
|
||||
self.rng = None
|
||||
|
||||
def _init_random(self):
|
||||
"""Initialize random generator with seed."""
|
||||
seed = self.get_param("seed")
|
||||
self.rng = random.Random(seed)
|
||||
|
||||
def _get_district_color(self, district_id: int) -> Tuple[int, int, int]:
|
||||
"""Get a color for a district ID."""
|
||||
colors = [
|
||||
(180, 160, 120), # Tan
|
||||
(160, 180, 130), # Sage
|
||||
(170, 150, 140), # Mauve
|
||||
(150, 170, 160), # Seafoam
|
||||
(175, 165, 125), # Sand
|
||||
(165, 175, 135), # Moss
|
||||
(155, 155, 155), # Gray
|
||||
(180, 150, 130), # Terracotta
|
||||
(140, 170, 170), # Teal
|
||||
(170, 160, 150), # Warm gray
|
||||
]
|
||||
return colors[district_id % len(colors)]
|
||||
|
||||
# === Step Implementations ===
|
||||
|
||||
def step_terrain(self):
|
||||
"""Step 1: Generate base terrain."""
|
||||
self._init_random()
|
||||
seed = self.get_param("seed")
|
||||
|
||||
# Create subtle terrain noise
|
||||
noise = mcrfpy.NoiseSource(
|
||||
dimensions=2,
|
||||
algorithm='simplex',
|
||||
seed=seed
|
||||
)
|
||||
|
||||
self.hmap_terrain.fill(0.0)
|
||||
self.hmap_terrain.add_noise(
|
||||
noise,
|
||||
world_size=(15, 15),
|
||||
mode='fbm',
|
||||
octaves=4
|
||||
)
|
||||
self.hmap_terrain.normalize(0.3, 0.7) # Keep in mid range
|
||||
|
||||
# Visualize as subtle green-brown gradient
|
||||
final = self.get_layer("final")
|
||||
w, h = self.MAP_SIZE
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
val = self.hmap_terrain[x, y]
|
||||
# Grass color range
|
||||
r = int(80 + val * 40)
|
||||
g = int(120 + val * 30)
|
||||
b = int(60 + val * 20)
|
||||
final.set((x, y), mcrfpy.Color(r, g, b))
|
||||
|
||||
def step_districts(self):
|
||||
"""Step 2: Create Voronoi districts."""
|
||||
num_districts = self.get_param("num_districts")
|
||||
w, h = self.MAP_SIZE
|
||||
|
||||
# Generate random points for Voronoi seeds
|
||||
margin = 10
|
||||
self.district_points = []
|
||||
for i in range(num_districts):
|
||||
x = self.rng.randint(margin, w - margin)
|
||||
y = self.rng.randint(margin, h - margin)
|
||||
self.district_points.append((x, y))
|
||||
|
||||
# Use add_voronoi to create district values
|
||||
# Each cell gets the ID of its nearest point
|
||||
self.hmap_districts.fill(0.0)
|
||||
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
min_dist = float('inf')
|
||||
nearest_id = 0
|
||||
for i, (px, py) in enumerate(self.district_points):
|
||||
dist = (x - px) ** 2 + (y - py) ** 2
|
||||
if dist < min_dist:
|
||||
min_dist = dist
|
||||
nearest_id = i + 1 # 1-indexed to distinguish from 0
|
||||
self.hmap_districts[x, y] = nearest_id
|
||||
|
||||
# Visualize districts (alpha=128 for overlay)
|
||||
districts_layer = self.get_layer("districts")
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
district_id = int(self.hmap_districts[x, y])
|
||||
if district_id > 0:
|
||||
color = self._get_district_color(district_id - 1)
|
||||
districts_layer.set((x, y), mcrfpy.Color(color[0], color[1], color[2], 128))
|
||||
else:
|
||||
districts_layer.set((x, y), mcrfpy.Color(50, 50, 50, 128))
|
||||
|
||||
# Also show on final
|
||||
final = self.get_layer("final")
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
c = districts_layer.at(x, y)
|
||||
final.set((x, y), c)
|
||||
|
||||
def step_find_centers(self):
|
||||
"""Step 3: Find district center points."""
|
||||
num_districts = self.get_param("num_districts")
|
||||
w, h = self.MAP_SIZE
|
||||
|
||||
# Calculate centroid of each district
|
||||
self.district_centers = []
|
||||
|
||||
for did in range(1, num_districts + 1):
|
||||
sum_x, sum_y, count = 0, 0, 0
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
if int(self.hmap_districts[x, y]) == did:
|
||||
sum_x += x
|
||||
sum_y += y
|
||||
count += 1
|
||||
|
||||
if count > 0:
|
||||
cx = sum_x // count
|
||||
cy = sum_y // count
|
||||
self.district_centers.append((cx, cy))
|
||||
else:
|
||||
# Use the original point if district is empty
|
||||
if did - 1 < len(self.district_points):
|
||||
self.district_centers.append(self.district_points[did - 1])
|
||||
|
||||
# Build connections (minimum spanning tree-like)
|
||||
self.connections = []
|
||||
if len(self.district_centers) > 1:
|
||||
# Simple approach: connect each district to its nearest neighbor
|
||||
# that hasn't been connected yet (Prim's-like)
|
||||
connected = {0} # Start with first district
|
||||
while len(connected) < len(self.district_centers):
|
||||
best_dist = float('inf')
|
||||
best_pair = None
|
||||
|
||||
for i in connected:
|
||||
for j in range(len(self.district_centers)):
|
||||
if j in connected:
|
||||
continue
|
||||
ci = self.district_centers[i]
|
||||
cj = self.district_centers[j]
|
||||
dist = (ci[0] - cj[0]) ** 2 + (ci[1] - cj[1]) ** 2
|
||||
if dist < best_dist:
|
||||
best_dist = dist
|
||||
best_pair = (i, j)
|
||||
|
||||
if best_pair:
|
||||
self.connections.append(best_pair)
|
||||
connected.add(best_pair[1])
|
||||
|
||||
# Add a few extra connections for redundancy
|
||||
for _ in range(min(3, len(self.district_centers) // 4)):
|
||||
i = self.rng.randint(0, len(self.district_centers) - 1)
|
||||
j = self.rng.randint(0, len(self.district_centers) - 1)
|
||||
if i != j and (i, j) not in self.connections and (j, i) not in self.connections:
|
||||
self.connections.append((i, j))
|
||||
|
||||
# Visualize centers and connections (alpha=128 for overlay)
|
||||
control_layer = self.get_layer("control_pts")
|
||||
control_layer.fill(mcrfpy.Color(30, 28, 26, 128))
|
||||
|
||||
# Draw center points
|
||||
for cx, cy in self.district_centers:
|
||||
for dx in range(-2, 3):
|
||||
for dy in range(-2, 3):
|
||||
px, py = cx + dx, cy + dy
|
||||
if 0 <= px < w and 0 <= py < h:
|
||||
control_layer.set((px, py), mcrfpy.Color(255, 200, 0, 200))
|
||||
|
||||
# Draw connection lines
|
||||
for i, j in self.connections:
|
||||
c1 = self.district_centers[i]
|
||||
c2 = self.district_centers[j]
|
||||
self._draw_line(control_layer, c1[0], c1[1], c2[0], c2[1],
|
||||
mcrfpy.Color(200, 100, 100, 160), 1)
|
||||
|
||||
def _draw_line(self, layer, x0, y0, x1, y1, color, width):
|
||||
"""Draw a line on a layer."""
|
||||
w, h = self.MAP_SIZE
|
||||
dx = abs(x1 - x0)
|
||||
dy = abs(y1 - y0)
|
||||
sx = 1 if x0 < x1 else -1
|
||||
sy = 1 if y0 < y1 else -1
|
||||
err = dx - dy
|
||||
|
||||
while True:
|
||||
for wo in range(-(width // 2), width // 2 + 1):
|
||||
for ho in range(-(width // 2), width // 2 + 1):
|
||||
px, py = x0 + wo, y0 + ho
|
||||
if 0 <= px < w and 0 <= py < h:
|
||||
layer.set((px, py), color)
|
||||
|
||||
if x0 == x1 and y0 == y1:
|
||||
break
|
||||
e2 = 2 * err
|
||||
if e2 > -dy:
|
||||
err -= dy
|
||||
x0 += sx
|
||||
if e2 < dx:
|
||||
err += dx
|
||||
y0 += sy
|
||||
|
||||
def step_roads(self):
|
||||
"""Step 4: Build roads between districts."""
|
||||
road_width = self.get_param("road_width")
|
||||
w, h = self.MAP_SIZE
|
||||
|
||||
self.hmap_roads.fill(0.0)
|
||||
roads_layer = self.get_layer("roads")
|
||||
roads_layer.fill(mcrfpy.Color(30, 28, 26, 128)) # alpha=128 for overlay
|
||||
|
||||
road_color = mcrfpy.Color(80, 75, 65, 160) # alpha=160 for better visibility
|
||||
|
||||
for i, j in self.connections:
|
||||
c1 = self.district_centers[i]
|
||||
c2 = self.district_centers[j]
|
||||
|
||||
# Create bezier-like curve by adding a control point
|
||||
mid_x = (c1[0] + c2[0]) // 2
|
||||
mid_y = (c1[1] + c2[1]) // 2
|
||||
|
||||
# Offset the midpoint slightly for curve
|
||||
offset_x = (c2[1] - c1[1]) // 8 # Perpendicular offset
|
||||
offset_y = -(c2[0] - c1[0]) // 8
|
||||
ctrl_x = mid_x + offset_x
|
||||
ctrl_y = mid_y + offset_y
|
||||
|
||||
# Draw quadratic bezier approximation
|
||||
self._draw_bezier(roads_layer, c1, (ctrl_x, ctrl_y), c2,
|
||||
road_color, int(road_width))
|
||||
|
||||
# Also mark in heightmap
|
||||
self._mark_bezier(c1, (ctrl_x, ctrl_y), c2, int(road_width))
|
||||
|
||||
# Update final with roads
|
||||
final = self.get_layer("final")
|
||||
districts_layer = self.get_layer("districts")
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
if self.hmap_roads[x, y] > 0.5:
|
||||
final.set((x, y), road_color)
|
||||
else:
|
||||
c = districts_layer.at(x, y)
|
||||
final.set((x, y), c)
|
||||
|
||||
def _draw_bezier(self, layer, p0, p1, p2, color, width):
|
||||
"""Draw a quadratic bezier curve."""
|
||||
w, h = self.MAP_SIZE
|
||||
# Approximate with line segments
|
||||
steps = 20
|
||||
prev = None
|
||||
for t in range(steps + 1):
|
||||
t = t / steps
|
||||
# Quadratic bezier formula
|
||||
x = int((1-t)**2 * p0[0] + 2*(1-t)*t * p1[0] + t**2 * p2[0])
|
||||
y = int((1-t)**2 * p0[1] + 2*(1-t)*t * p1[1] + t**2 * p2[1])
|
||||
|
||||
if prev:
|
||||
self._draw_line(layer, prev[0], prev[1], x, y, color, width)
|
||||
prev = (x, y)
|
||||
|
||||
def _mark_bezier(self, p0, p1, p2, width):
|
||||
"""Mark bezier curve in roads heightmap."""
|
||||
w, h = self.MAP_SIZE
|
||||
steps = 20
|
||||
for t in range(steps + 1):
|
||||
t = t / steps
|
||||
x = int((1-t)**2 * p0[0] + 2*(1-t)*t * p1[0] + t**2 * p2[0])
|
||||
y = int((1-t)**2 * p0[1] + 2*(1-t)*t * p1[1] + t**2 * p2[1])
|
||||
|
||||
for wo in range(-(width // 2), width // 2 + 1):
|
||||
for ho in range(-(width // 2), width // 2 + 1):
|
||||
px, py = x + wo, y + ho
|
||||
if 0 <= px < w and 0 <= py < h:
|
||||
self.hmap_roads[px, py] = 1.0
|
||||
|
||||
def step_buildings(self):
|
||||
"""Step 5: Place building footprints."""
|
||||
density = self.get_param("building_density")
|
||||
min_size = self.get_param("building_min")
|
||||
max_size = self.get_param("building_max")
|
||||
w, h = self.MAP_SIZE
|
||||
|
||||
self.hmap_buildings.fill(0.0)
|
||||
buildings_layer = self.get_layer("buildings")
|
||||
buildings_layer.fill(mcrfpy.Color(30, 28, 26, 128)) # alpha=128 for overlay
|
||||
|
||||
# Building colors (alpha=160 for better visibility)
|
||||
building_colors = [
|
||||
mcrfpy.Color(140, 120, 100, 160),
|
||||
mcrfpy.Color(130, 130, 120, 160),
|
||||
mcrfpy.Color(150, 130, 110, 160),
|
||||
mcrfpy.Color(120, 120, 130, 160),
|
||||
]
|
||||
|
||||
# Attempt to place buildings
|
||||
attempts = int(w * h * density * 0.1)
|
||||
|
||||
for _ in range(attempts):
|
||||
# Random position
|
||||
bx = self.rng.randint(5, w - max_size - 5)
|
||||
by = self.rng.randint(5, h - max_size - 5)
|
||||
bw = self.rng.randint(min_size, max_size)
|
||||
bh = self.rng.randint(min_size, max_size)
|
||||
|
||||
# Check if location is valid (not on road, not overlapping)
|
||||
valid = True
|
||||
for py in range(by - 1, by + bh + 1):
|
||||
for px in range(bx - 1, bx + bw + 1):
|
||||
if 0 <= px < w and 0 <= py < h:
|
||||
if self.hmap_roads[px, py] > 0.5:
|
||||
valid = False
|
||||
break
|
||||
if self.hmap_buildings[px, py] > 0.5:
|
||||
valid = False
|
||||
break
|
||||
if not valid:
|
||||
break
|
||||
|
||||
if not valid:
|
||||
continue
|
||||
|
||||
# Place building
|
||||
color = self.rng.choice(building_colors)
|
||||
for py in range(by, by + bh):
|
||||
for px in range(bx, bx + bw):
|
||||
if 0 <= px < w and 0 <= py < h:
|
||||
self.hmap_buildings[px, py] = 1.0
|
||||
buildings_layer.set((px, py), color)
|
||||
|
||||
def step_composite(self):
|
||||
"""Step 6: Create final composite."""
|
||||
final = self.get_layer("final")
|
||||
districts_layer = self.get_layer("districts")
|
||||
buildings_layer = self.get_layer("buildings")
|
||||
w, h = self.MAP_SIZE
|
||||
|
||||
road_color = mcrfpy.Color(80, 75, 65)
|
||||
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
# Priority: buildings > roads > districts
|
||||
if self.hmap_buildings[x, y] > 0.5:
|
||||
c = buildings_layer.at(x, y)
|
||||
final.set((x, y), c)
|
||||
elif self.hmap_roads[x, y] > 0.5:
|
||||
final.set((x, y), road_color)
|
||||
else:
|
||||
c = districts_layer.at(x, y)
|
||||
final.set((x, y), c)
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the town demo standalone."""
|
||||
demo = TownDemo()
|
||||
demo.activate()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue