Test suite modernization
This commit is contained in:
parent
0969f7c2f6
commit
52fdfd0347
141 changed files with 9947 additions and 4665 deletions
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