McRogueFace/tests/procgen_interactive/demos/town_demo.py

509 lines
18 KiB
Python

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