509 lines
18 KiB
Python
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()
|