Test suite modernization

This commit is contained in:
John McCardle 2026-02-09 08:15:18 -05:00
commit 52fdfd0347
141 changed files with 9947 additions and 4665 deletions

View file

@ -0,0 +1,29 @@
"""Interactive Procedural Generation Demo System
An educational, interactive framework for exploring procedural generation
techniques in McRogueFace.
Features:
- 256x256 maps with click-drag pan and scroll-wheel zoom
- Interactive parameter controls (steppers, sliders)
- Layer visibility toggles for masks/overlays
- Step forward/backward through generation stages
- State snapshots for true backward navigation
"""
from .core.demo_base import ProcgenDemoBase, StepDef, LayerDef, StateSnapshot
from .core.parameter import Parameter
from .core.widgets import Stepper, Slider, LayerToggle
from .core.viewport import ViewportController
__all__ = [
'ProcgenDemoBase',
'StepDef',
'LayerDef',
'StateSnapshot',
'Parameter',
'Stepper',
'Slider',
'LayerToggle',
'ViewportController',
]

View file

@ -0,0 +1,18 @@
"""Core framework components for interactive procedural generation demos."""
from .demo_base import ProcgenDemoBase, StepDef, LayerDef, StateSnapshot
from .parameter import Parameter
from .widgets import Stepper, Slider, LayerToggle
from .viewport import ViewportController
__all__ = [
'ProcgenDemoBase',
'StepDef',
'LayerDef',
'StateSnapshot',
'Parameter',
'Stepper',
'Slider',
'LayerToggle',
'ViewportController',
]

View file

@ -0,0 +1,614 @@
"""Base class for interactive procedural generation demos.
Provides the core framework for:
- Step-by-step generation with forward/backward navigation
- State snapshots for true backward navigation
- Parameter management with regeneration on change
- Layer visibility management
- UI layout with control panel
"""
import mcrfpy
from dataclasses import dataclass, field
from typing import List, Dict, Any, Callable, Optional, Tuple
from abc import ABC, abstractmethod
from .parameter import Parameter
from .widgets import Stepper, Slider, LayerToggle
from .viewport import ViewportController
@dataclass
class StepDef:
"""Definition of a generation step.
Attributes:
name: Display name for the step
function: Callable that executes the step
description: Optional longer description/tooltip
"""
name: str
function: Callable
description: str = ""
@dataclass
class LayerDef:
"""Definition of a visualization layer.
Attributes:
name: Internal name (for grid.layers access)
display: Display name in UI
type: 'color' or 'tile'
z_index: Render order (-1 = below entities, 1 = above)
visible: Initial visibility
description: Optional tooltip
"""
name: str
display: str
type: str = "color"
z_index: int = -1
visible: bool = True
description: str = ""
@dataclass
class StateSnapshot:
"""Captured state at a specific step for backward navigation.
Stores HeightMap data as lists for restoration.
"""
step_index: int
heightmaps: Dict[str, List[float]] = field(default_factory=dict)
layer_colors: Dict[str, List[Tuple[int, int, int, int]]] = field(default_factory=dict)
extra_data: Dict[str, Any] = field(default_factory=dict)
class ProcgenDemoBase(ABC):
"""Abstract base class for procedural generation demos.
Subclasses must implement:
- name: Demo display name
- description: Demo description
- define_steps(): Return list of StepDef
- define_parameters(): Return list of Parameter
- define_layers(): Return list of LayerDef
The framework provides:
- Step navigation (forward/backward)
- State snapshot capture and restoration
- Parameter UI widgets
- Layer visibility toggles
- Viewport pan/zoom
"""
# Subclass must set these
name: str = "Unnamed Demo"
description: str = ""
# Default map size - subclasses can override
MAP_SIZE: Tuple[int, int] = (256, 256)
# Layout constants
GRID_WIDTH = 700
GRID_HEIGHT = 525
PANEL_WIDTH = 300
PANEL_X = 720
def __init__(self):
"""Initialize the demo framework."""
# Get definitions from subclass
self.steps = self.define_steps()
self.parameters = {p.name: p for p in self.define_parameters()}
self.layer_defs = self.define_layers()
# State tracking
self.current_step = 0
self.state_history: List[StateSnapshot] = []
self.heightmaps: Dict[str, mcrfpy.HeightMap] = {}
# UI elements
self.scene = None
self.grid = None
self.layers: Dict[str, Any] = {}
self.viewport = None
self.widgets: Dict[str, Any] = {}
# Build the scene
self._build_scene()
# Wire up parameter change handlers
for param in self.parameters.values():
param._on_change = self._on_parameter_change
@abstractmethod
def define_steps(self) -> List[StepDef]:
"""Define the generation steps. Subclass must implement."""
pass
@abstractmethod
def define_parameters(self) -> List[Parameter]:
"""Define configurable parameters. Subclass must implement."""
pass
@abstractmethod
def define_layers(self) -> List[LayerDef]:
"""Define visualization layers. Subclass must implement."""
pass
def _build_scene(self):
"""Build the scene with grid, layers, and control panel."""
self.scene = mcrfpy.Scene(f"procgen_{self.name.lower().replace(' ', '_')}")
ui = self.scene.children
# Background
bg = mcrfpy.Frame(
pos=(0, 0),
size=(1024, 768),
fill_color=mcrfpy.Color(25, 25, 30)
)
ui.append(bg)
# Grid for visualization
self.grid = mcrfpy.Grid(
grid_size=self.MAP_SIZE,
pos=(10, 10),
size=(self.GRID_WIDTH, self.GRID_HEIGHT)
)
ui.append(self.grid)
# Add layers from definitions
for layer_def in self.layer_defs:
if layer_def.type == "color":
layer = mcrfpy.ColorLayer(z_index=layer_def.z_index, grid_size=self.MAP_SIZE)
else:
layer = mcrfpy.TileLayer(z_index=layer_def.z_index, grid_size=self.MAP_SIZE)
self.grid.add_layer(layer)
layer.visible = layer_def.visible
self.layers[layer_def.name] = layer
# Keyboard handler - set BEFORE viewport so viewport can chain to it
self.scene.on_key = self._on_key
# Set up viewport controller (handles scroll wheel via on_click, chains keyboard to us)
self.viewport = ViewportController(
self.grid, self.scene,
on_zoom_change=self._on_zoom_change
)
# Build control panel
self._build_control_panel(ui)
def _build_control_panel(self, ui):
"""Build the right-side control panel."""
panel_y = 10
# Title
title = mcrfpy.Caption(
text=f"Demo: {self.name}",
pos=(self.PANEL_X, panel_y),
font_size=20,
fill_color=mcrfpy.Color(220, 220, 230)
)
ui.append(title)
panel_y += 35
# Separator
sep1 = mcrfpy.Frame(
pos=(self.PANEL_X, panel_y),
size=(self.PANEL_WIDTH, 2),
fill_color=mcrfpy.Color(60, 60, 70)
)
ui.append(sep1)
panel_y += 10
# Step navigation
step_label = mcrfpy.Caption(
text="Step:",
pos=(self.PANEL_X, panel_y),
font_size=14,
fill_color=mcrfpy.Color(150, 150, 160)
)
ui.append(step_label)
panel_y += 20
# Step display and navigation
self._step_display = mcrfpy.Caption(
text=self._format_step_display(),
pos=(self.PANEL_X, panel_y),
font_size=16,
fill_color=mcrfpy.Color(200, 200, 210)
)
ui.append(self._step_display)
# Step nav buttons
btn_prev = mcrfpy.Frame(
pos=(self.PANEL_X + 200, panel_y - 5),
size=(30, 25),
fill_color=mcrfpy.Color(60, 60, 70),
outline=1,
outline_color=mcrfpy.Color(100, 100, 110)
)
prev_label = mcrfpy.Caption(text="<", pos=(10, 3), font_size=14,
fill_color=mcrfpy.Color(200, 200, 210))
btn_prev.children.append(prev_label)
btn_prev.on_click = lambda p, b, a: self._on_step_prev() if b == mcrfpy.MouseButton.LEFT and a == mcrfpy.InputState.RELEASED else None
ui.append(btn_prev)
btn_next = mcrfpy.Frame(
pos=(self.PANEL_X + 235, panel_y - 5),
size=(30, 25),
fill_color=mcrfpy.Color(60, 60, 70),
outline=1,
outline_color=mcrfpy.Color(100, 100, 110)
)
next_label = mcrfpy.Caption(text=">", pos=(10, 3), font_size=14,
fill_color=mcrfpy.Color(200, 200, 210))
btn_next.children.append(next_label)
btn_next.on_click = lambda p, b, a: self._on_step_next() if b == mcrfpy.MouseButton.LEFT and a == mcrfpy.InputState.RELEASED else None
ui.append(btn_next)
panel_y += 30
# Current step name
self._step_name = mcrfpy.Caption(
text="",
pos=(self.PANEL_X, panel_y),
font_size=12,
fill_color=mcrfpy.Color(120, 150, 180)
)
ui.append(self._step_name)
panel_y += 30
# Separator
sep2 = mcrfpy.Frame(
pos=(self.PANEL_X, panel_y),
size=(self.PANEL_WIDTH, 2),
fill_color=mcrfpy.Color(60, 60, 70)
)
ui.append(sep2)
panel_y += 15
# Parameters section
if self.parameters:
param_header = mcrfpy.Caption(
text="Parameters",
pos=(self.PANEL_X, panel_y),
font_size=14,
fill_color=mcrfpy.Color(150, 150, 160)
)
ui.append(param_header)
panel_y += 25
for param in self.parameters.values():
# Parameter label
param_label = mcrfpy.Caption(
text=param.display + ":",
pos=(self.PANEL_X, panel_y),
font_size=12,
fill_color=mcrfpy.Color(180, 180, 190)
)
ui.append(param_label)
panel_y += 20
# Widget based on type
if param.type == 'int':
widget = Stepper(param, pos=(self.PANEL_X, panel_y),
width=180, on_change=self._on_widget_change)
else: # float
widget = Slider(param, pos=(self.PANEL_X, panel_y),
width=200, on_change=self._on_widget_change)
ui.append(widget.frame)
self.widgets[param.name] = widget
panel_y += 35
panel_y += 10
# Separator
sep3 = mcrfpy.Frame(
pos=(self.PANEL_X, panel_y),
size=(self.PANEL_WIDTH, 2),
fill_color=mcrfpy.Color(60, 60, 70)
)
ui.append(sep3)
panel_y += 15
# Layers section
if self.layer_defs:
layer_header = mcrfpy.Caption(
text="Layers",
pos=(self.PANEL_X, panel_y),
font_size=14,
fill_color=mcrfpy.Color(150, 150, 160)
)
ui.append(layer_header)
panel_y += 25
for layer_def in self.layer_defs:
layer = self.layers.get(layer_def.name)
toggle = LayerToggle(
layer_def.display, layer,
pos=(self.PANEL_X, panel_y),
width=180,
initial=layer_def.visible,
on_change=self._on_layer_toggle
)
ui.append(toggle.frame)
self.widgets[f"layer_{layer_def.name}"] = toggle
panel_y += 30
panel_y += 15
# View section
sep4 = mcrfpy.Frame(
pos=(self.PANEL_X, panel_y),
size=(self.PANEL_WIDTH, 2),
fill_color=mcrfpy.Color(60, 60, 70)
)
ui.append(sep4)
panel_y += 15
view_header = mcrfpy.Caption(
text="View",
pos=(self.PANEL_X, panel_y),
font_size=14,
fill_color=mcrfpy.Color(150, 150, 160)
)
ui.append(view_header)
panel_y += 25
self._zoom_display = mcrfpy.Caption(
text="Zoom: 1.00x",
pos=(self.PANEL_X, panel_y),
font_size=12,
fill_color=mcrfpy.Color(180, 180, 190)
)
ui.append(self._zoom_display)
panel_y += 25
# Reset view button
btn_reset = mcrfpy.Frame(
pos=(self.PANEL_X, panel_y),
size=(100, 25),
fill_color=mcrfpy.Color(60, 60, 70),
outline=1,
outline_color=mcrfpy.Color(100, 100, 110)
)
reset_label = mcrfpy.Caption(text="Reset View", pos=(15, 5), font_size=12,
fill_color=mcrfpy.Color(200, 200, 210))
btn_reset.children.append(reset_label)
btn_reset.on_click = lambda p, b, a: self.viewport.reset_view() if b == mcrfpy.MouseButton.LEFT and a == mcrfpy.InputState.RELEASED else None
ui.append(btn_reset)
panel_y += 40
# Instructions at bottom
instructions = [
"Left/Right: Step nav",
"Middle-drag: Pan",
"Scroll: Zoom",
"1-9: Toggle layers",
"R: Reset view",
"Esc: Menu"
]
for instr in instructions:
instr_caption = mcrfpy.Caption(
text=instr,
pos=(self.PANEL_X, panel_y),
font_size=10,
fill_color=mcrfpy.Color(100, 100, 110)
)
ui.append(instr_caption)
panel_y += 15
def _format_step_display(self) -> str:
"""Format step counter display."""
return f"{self.current_step}/{len(self.steps)}"
def _update_step_display(self):
"""Update step navigation display."""
self._step_display.text = self._format_step_display()
if 0 < self.current_step <= len(self.steps):
self._step_name.text = self.steps[self.current_step - 1].name
else:
self._step_name.text = "(not started)"
def _on_zoom_change(self, zoom: float):
"""Handle zoom level change."""
self._zoom_display.text = f"Zoom: {zoom:.2f}x"
def _on_step_prev(self):
"""Go to previous step."""
self.reverse_step()
def _on_step_next(self):
"""Go to next step."""
self.advance_step()
def _on_widget_change(self, param: Parameter):
"""Handle parameter widget change."""
# Parameter already updated, trigger regeneration
self.regenerate_from(param.affects_step)
def _on_parameter_change(self, param: Parameter):
"""Handle direct parameter value change."""
# Update widget display if exists
widget = self.widgets.get(param.name)
if widget:
widget.update_display()
def _on_layer_toggle(self, name: str, visible: bool):
"""Handle layer visibility toggle."""
# Layer visibility already updated by widget
pass
def _on_key(self, key, action):
"""Handle keyboard input."""
# Only process on key press
if action != mcrfpy.InputState.PRESSED:
return
# Check specific keys using enums
if key == mcrfpy.Key.LEFT:
self.reverse_step()
elif key == mcrfpy.Key.RIGHT:
self.advance_step()
elif key == mcrfpy.Key.R:
self.viewport.reset_view()
elif key == mcrfpy.Key.ESCAPE:
self._return_to_menu()
else:
# Number keys for layer toggles - convert to string for parsing
key_str = str(key) if not isinstance(key, str) else key
if key_str.startswith("Key.NUM") or (len(key_str) == 1 and key_str.isdigit()):
try:
num = int(key_str[-1])
if 1 <= num <= len(self.layer_defs):
layer_def = self.layer_defs[num - 1]
toggle = self.widgets.get(f"layer_{layer_def.name}")
if toggle:
toggle.toggle()
except (ValueError, IndexError):
pass
def _return_to_menu(self):
"""Return to demo menu."""
try:
from ..main import show_menu
show_menu()
except ImportError:
pass
# === State Management ===
def capture_state(self) -> StateSnapshot:
"""Capture current state for later restoration."""
snapshot = StateSnapshot(step_index=self.current_step)
# Capture HeightMap data
for name, hmap in self.heightmaps.items():
data = []
w, h = hmap.size
for y in range(h):
for x in range(w):
data.append(hmap[x, y])
snapshot.heightmaps[name] = data
# Capture layer colors
for name, layer in self.layers.items():
if hasattr(layer, 'at'): # ColorLayer
colors = []
w, h = layer.grid_size
for y in range(h):
for x in range(w):
c = layer.at(x, y)
colors.append((c.r, c.g, c.b, c.a))
snapshot.layer_colors[name] = colors
return snapshot
def restore_state(self, snapshot: StateSnapshot):
"""Restore state from snapshot."""
# Restore HeightMap data
for name, data in snapshot.heightmaps.items():
if name in self.heightmaps:
hmap = self.heightmaps[name]
w, h = hmap.size
idx = 0
for y in range(h):
for x in range(w):
hmap[x, y] = data[idx]
idx += 1
# Restore layer colors
for name, colors in snapshot.layer_colors.items():
if name in self.layers:
layer = self.layers[name]
if hasattr(layer, 'set'): # ColorLayer
w, h = layer.grid_size
idx = 0
for y in range(h):
for x in range(w):
r, g, b, a = colors[idx]
layer.set((x, y), mcrfpy.Color(r, g, b, a))
idx += 1
self.current_step = snapshot.step_index
self._update_step_display()
def advance_step(self):
"""Execute the next generation step."""
if self.current_step >= len(self.steps):
return # Already at end
# Capture state before this step
snapshot = self.capture_state()
self.state_history.append(snapshot)
# Execute the step
step = self.steps[self.current_step]
step.function()
self.current_step += 1
self._update_step_display()
def reverse_step(self):
"""Restore to previous step's state."""
if not self.state_history:
return # No history to restore
snapshot = self.state_history.pop()
self.restore_state(snapshot)
def regenerate_from(self, step: int):
"""Re-run generation from a specific step after parameter change."""
# Find the snapshot for the step before target
while self.state_history and self.state_history[-1].step_index >= step:
self.state_history.pop()
# Restore to just before target step
if self.state_history:
snapshot = self.state_history[-1]
self.restore_state(snapshot)
else:
# No history - reset to beginning
self.current_step = 0
self._reset_state()
# Re-run steps up to where we were
target = min(step + 1, len(self.steps))
while self.current_step < target:
self.advance_step()
def _reset_state(self):
"""Reset all state to initial. Override in subclass if needed."""
for hmap in self.heightmaps.values():
hmap.fill(0.0)
for layer in self.layers.values():
if hasattr(layer, 'fill'):
layer.fill(mcrfpy.Color(0, 0, 0, 0))
# === Activation ===
def activate(self):
"""Activate this demo's scene."""
mcrfpy.current_scene = self.scene
self._update_step_display()
def run(self):
"""Activate and run through first step."""
self.activate()
# === Utility Methods for Subclasses ===
def get_param(self, name: str) -> Any:
"""Get current value of a parameter."""
param = self.parameters.get(name)
return param.value if param else None
def create_heightmap(self, name: str, fill: float = 0.0) -> mcrfpy.HeightMap:
"""Create and register a HeightMap."""
hmap = mcrfpy.HeightMap(self.MAP_SIZE, fill=fill)
self.heightmaps[name] = hmap
return hmap
def get_layer(self, name: str):
"""Get a layer by name."""
return self.layers.get(name)

View file

@ -0,0 +1,125 @@
"""Parameter definitions and validation for procedural generation demos.
Parameters define configurable values that affect generation steps.
When a parameter changes, the framework re-runs from the affected step.
"""
from dataclasses import dataclass, field
from typing import Any, Literal, Optional, List, Callable
@dataclass
class Parameter:
"""Definition for a configurable generation parameter.
Attributes:
name: Internal identifier used in code
display: Human-readable label for UI
type: Parameter type - 'int', 'float', or 'choice'
default: Default value
min_val: Minimum value (for numeric types)
max_val: Maximum value (for numeric types)
step: Increment for +/- buttons (for numeric types)
choices: List of valid values (for choice type)
affects_step: Which step index to re-run when this parameter changes
description: Optional tooltip/help text
"""
name: str
display: str
type: Literal['int', 'float', 'choice']
default: Any
min_val: Optional[float] = None
max_val: Optional[float] = None
step: float = 1
choices: Optional[List[Any]] = None
affects_step: int = 0
description: str = ""
# Runtime state
_value: Any = field(default=None, repr=False)
_on_change: Optional[Callable] = field(default=None, repr=False)
def __post_init__(self):
"""Initialize runtime value to default."""
if self._value is None:
self._value = self.default
@property
def value(self) -> Any:
"""Get current parameter value."""
return self._value
@value.setter
def value(self, new_value: Any):
"""Set parameter value with validation and change notification."""
validated = self._validate(new_value)
if validated != self._value:
self._value = validated
if self._on_change:
self._on_change(self)
def _validate(self, value: Any) -> Any:
"""Validate and coerce value to correct type/range."""
if self.type == 'int':
value = int(value)
if self.min_val is not None:
value = max(int(self.min_val), value)
if self.max_val is not None:
value = min(int(self.max_val), value)
elif self.type == 'float':
value = float(value)
if self.min_val is not None:
value = max(self.min_val, value)
if self.max_val is not None:
value = min(self.max_val, value)
elif self.type == 'choice':
if self.choices and value not in self.choices:
value = self.choices[0] if self.choices else self.default
return value
def increment(self):
"""Increase value by step amount."""
if self.type in ('int', 'float'):
self.value = self._value + self.step
elif self.type == 'choice' and self.choices:
idx = self.choices.index(self._value)
if idx < len(self.choices) - 1:
self.value = self.choices[idx + 1]
def decrement(self):
"""Decrease value by step amount."""
if self.type in ('int', 'float'):
self.value = self._value - self.step
elif self.type == 'choice' and self.choices:
idx = self.choices.index(self._value)
if idx > 0:
self.value = self.choices[idx - 1]
def reset(self):
"""Reset to default value."""
self.value = self.default
def format_value(self) -> str:
"""Format value for display."""
if self.type == 'int':
return str(int(self._value))
elif self.type == 'float':
# Show 2 decimal places for floats
return f"{self._value:.2f}"
else:
return str(self._value)
def get_normalized(self) -> float:
"""Get value as 0-1 normalized float (for sliders)."""
if self.type in ('int', 'float') and self.min_val is not None and self.max_val is not None:
if self.max_val == self.min_val:
return 0.5
return (self._value - self.min_val) / (self.max_val - self.min_val)
return 0.5
def set_from_normalized(self, normalized: float):
"""Set value from 0-1 normalized float (from sliders)."""
if self.type in ('int', 'float') and self.min_val is not None and self.max_val is not None:
normalized = max(0.0, min(1.0, normalized))
raw_value = self.min_val + normalized * (self.max_val - self.min_val)
self.value = raw_value

View file

@ -0,0 +1,159 @@
"""Viewport controller for pan and zoom on large maps.
Provides click-drag pan (middle mouse button) and scroll-wheel zoom
for navigating 256x256 or larger maps within a smaller viewport.
"""
import mcrfpy
from typing import Optional, Callable
class ViewportController:
"""Click-drag pan and scroll-wheel zoom for Grid.
Features:
- Middle-click drag to pan the viewport
- Scroll wheel to zoom in/out (0.25x to 4.0x range)
- Optional zoom level display callback
Args:
grid: The mcrfpy.Grid to control
scene: The scene for keyboard event chaining
min_zoom: Minimum zoom level (default 0.25)
max_zoom: Maximum zoom level (default 4.0)
zoom_factor: Multiplier per scroll tick (default 1.15)
on_zoom_change: Optional callback(zoom_level) when zoom changes
Note:
Scroll wheel events are delivered via on_click with MouseButton.SCROLL_UP
and MouseButton.SCROLL_DOWN (#231, #232).
"""
def __init__(self, grid, scene,
min_zoom: float = 0.25,
max_zoom: float = 4.0,
zoom_factor: float = 1.15,
on_zoom_change: Optional[Callable] = None):
self.grid = grid
self.scene = scene
self.min_zoom = min_zoom
self.max_zoom = max_zoom
self.zoom_factor = zoom_factor
self.on_zoom_change = on_zoom_change
# Drag state
self.dragging = False
self.drag_start_center = (0, 0)
self.drag_start_mouse = (0, 0)
# Store original handlers to chain
self._original_on_click = getattr(grid, 'on_click', None)
self._original_on_move = getattr(grid, 'on_move', None)
self._original_on_key = getattr(scene, 'on_key', None)
# Bind our handlers
grid.on_click = self._on_click
grid.on_move = self._on_move
scene.on_key = self._on_key
def _on_click(self, pos, button, action):
"""Handle drag start/end with middle mouse button, and scroll wheel zoom."""
# Middle-click for panning
if button == mcrfpy.MouseButton.MIDDLE:
if action == mcrfpy.InputState.PRESSED:
self.dragging = True
self.drag_start_center = (self.grid.center.x, self.grid.center.y)
self.drag_start_mouse = (pos.x, pos.y)
elif action == mcrfpy.InputState.RELEASED:
self.dragging = False
return # Don't chain middle-click to other handlers
# Scroll wheel for zooming (#231, #232 - scroll is now a click event)
if button == mcrfpy.MouseButton.SCROLL_UP:
self._zoom_in()
return
elif button == mcrfpy.MouseButton.SCROLL_DOWN:
self._zoom_out()
return
# Chain to original handler for other buttons
if self._original_on_click:
self._original_on_click(pos, button, action)
def _on_move(self, pos):
"""Update center during drag."""
if self.dragging:
# Calculate mouse movement delta
dx = pos.x - self.drag_start_mouse[0]
dy = pos.y - self.drag_start_mouse[1]
# Move center opposite to mouse movement, scaled by zoom
# When zoomed in (zoom > 1), movement should be smaller
# When zoomed out (zoom < 1), movement should be larger
zoom = self.grid.zoom if hasattr(self.grid, 'zoom') else 1.0
self.grid.center = (
self.drag_start_center[0] - dx / zoom,
self.drag_start_center[1] - dy / zoom
)
else:
# Chain to original handler when not dragging
if self._original_on_move:
self._original_on_move(pos)
def _on_key(self, key, action):
"""Handle keyboard input - chain to original handler.
Note: Scroll wheel zoom is now handled in _on_click via
MouseButton.SCROLL_UP/SCROLL_DOWN (#231, #232).
"""
# Chain to original handler
if self._original_on_key:
self._original_on_key(key, action)
def _zoom_in(self):
"""Increase zoom level."""
current = self.grid.zoom if hasattr(self.grid, 'zoom') else 1.0
new_zoom = min(self.max_zoom, current * self.zoom_factor)
self.grid.zoom = new_zoom
if self.on_zoom_change:
self.on_zoom_change(new_zoom)
def _zoom_out(self):
"""Decrease zoom level."""
current = self.grid.zoom if hasattr(self.grid, 'zoom') else 1.0
new_zoom = max(self.min_zoom, current / self.zoom_factor)
self.grid.zoom = new_zoom
if self.on_zoom_change:
self.on_zoom_change(new_zoom)
def reset_view(self):
"""Reset to default view (zoom=1, centered)."""
self.grid.zoom = 1.0
# Center on map center
grid_size = self.grid.grid_size
cell_w = self.grid.texture.sprite_size[0] if self.grid.texture else 16
cell_h = self.grid.texture.sprite_size[1] if self.grid.texture else 16
self.grid.center = (grid_size[0] * cell_w / 2, grid_size[1] * cell_h / 2)
if self.on_zoom_change:
self.on_zoom_change(1.0)
def center_on_cell(self, cell_x: int, cell_y: int):
"""Center the viewport on a specific cell."""
cell_w = self.grid.texture.sprite_size[0] if self.grid.texture else 16
cell_h = self.grid.texture.sprite_size[1] if self.grid.texture else 16
self.grid.center = (
(cell_x + 0.5) * cell_w,
(cell_y + 0.5) * cell_h
)
@property
def zoom(self) -> float:
"""Get current zoom level."""
return self.grid.zoom if hasattr(self.grid, 'zoom') else 1.0
@zoom.setter
def zoom(self, value: float):
"""Set zoom level within bounds."""
self.grid.zoom = max(self.min_zoom, min(self.max_zoom, value))
if self.on_zoom_change:
self.on_zoom_change(self.grid.zoom)

View file

@ -0,0 +1,353 @@
"""UI widgets for interactive parameter controls.
Provides reusable widget classes for:
- Stepper: +/- buttons with value display for integers/seeds
- Slider: Draggable track for float ranges
- LayerToggle: Checkbox for layer visibility
"""
import mcrfpy
from typing import Callable, Optional
from .parameter import Parameter
class Stepper:
"""Integer/seed stepper with +/- buttons and value display.
Layout: [-] [ value ] [+]
Args:
parameter: Parameter to control
pos: (x, y) position tuple
width: Total widget width (default 150)
height: Widget height (default 30)
on_change: Optional callback when value changes
"""
def __init__(self, parameter: Parameter, pos: tuple,
width: int = 150, height: int = 30,
on_change: Optional[Callable] = None):
self.parameter = parameter
self.pos = pos
self.width = width
self.height = height
self.on_change = on_change
button_width = height # Square buttons
value_width = width - 2 * button_width - 4
# Container frame
self.frame = mcrfpy.Frame(
pos=pos,
size=(width, height),
fill_color=mcrfpy.Color(40, 40, 45),
outline=1,
outline_color=mcrfpy.Color(80, 80, 90)
)
# Minus button
self.btn_minus = mcrfpy.Frame(
pos=(0, 0),
size=(button_width, height),
fill_color=mcrfpy.Color(60, 60, 70),
outline=1,
outline_color=mcrfpy.Color(100, 100, 110)
)
minus_label = mcrfpy.Caption(
text="-",
pos=(button_width // 2 - 4, height // 2 - 10),
font_size=18,
fill_color=mcrfpy.Color(200, 200, 210)
)
self.btn_minus.children.append(minus_label)
self.btn_minus.on_click = self._on_minus_click
self.btn_minus.on_enter = lambda pos: self._on_btn_hover(self.btn_minus, True)
self.btn_minus.on_exit = lambda pos: self._on_btn_hover(self.btn_minus, False)
self.frame.children.append(self.btn_minus)
# Value display
self.value_caption = mcrfpy.Caption(
text=parameter.format_value(),
pos=(button_width + value_width // 2, height // 2 - 8),
font_size=14,
fill_color=mcrfpy.Color(220, 220, 230)
)
self.frame.children.append(self.value_caption)
# Plus button
self.btn_plus = mcrfpy.Frame(
pos=(width - button_width, 0),
size=(button_width, height),
fill_color=mcrfpy.Color(60, 60, 70),
outline=1,
outline_color=mcrfpy.Color(100, 100, 110)
)
plus_label = mcrfpy.Caption(
text="+",
pos=(button_width // 2 - 4, height // 2 - 10),
font_size=18,
fill_color=mcrfpy.Color(200, 200, 210)
)
self.btn_plus.children.append(plus_label)
self.btn_plus.on_click = self._on_plus_click
self.btn_plus.on_enter = lambda pos: self._on_btn_hover(self.btn_plus, True)
self.btn_plus.on_exit = lambda pos: self._on_btn_hover(self.btn_plus, False)
self.frame.children.append(self.btn_plus)
# Wire up parameter change notification
self.parameter._on_change = self._on_param_change
def _on_btn_hover(self, btn, entered: bool):
"""Handle button hover state."""
if entered:
btn.fill_color = mcrfpy.Color(80, 80, 95)
else:
btn.fill_color = mcrfpy.Color(60, 60, 70)
def _on_minus_click(self, pos, button, action):
"""Handle minus button click."""
if button == mcrfpy.MouseButton.LEFT and action == mcrfpy.InputState.RELEASED:
self.parameter.decrement()
def _on_plus_click(self, pos, button, action):
"""Handle plus button click."""
if button == mcrfpy.MouseButton.LEFT and action == mcrfpy.InputState.RELEASED:
self.parameter.increment()
def _on_param_change(self, param):
"""Handle parameter value change."""
self.value_caption.text = param.format_value()
if self.on_change:
self.on_change(param)
def update_display(self):
"""Force update of displayed value."""
self.value_caption.text = self.parameter.format_value()
class Slider:
"""Draggable slider for float parameter ranges.
Layout: [======o========] value
Args:
parameter: Parameter to control
pos: (x, y) position tuple
width: Total widget width (default 200)
height: Widget height (default 25)
on_change: Optional callback when value changes
"""
def __init__(self, parameter: Parameter, pos: tuple,
width: int = 200, height: int = 25,
on_change: Optional[Callable] = None):
self.parameter = parameter
self.pos = pos
self.width = width
self.height = height
self.on_change = on_change
self.dragging = False
value_display_width = 50
track_width = width - value_display_width - 5
# Container frame
self.frame = mcrfpy.Frame(
pos=pos,
size=(width, height),
fill_color=mcrfpy.Color(40, 40, 45)
)
# Track background
track_height = 8
track_y = (height - track_height) // 2
self.track = mcrfpy.Frame(
pos=(0, track_y),
size=(track_width, track_height),
fill_color=mcrfpy.Color(50, 50, 55),
outline=1,
outline_color=mcrfpy.Color(80, 80, 90)
)
self.track.on_click = self._on_track_click
self.track.on_move = self._on_track_move
self.frame.children.append(self.track)
# Filled portion (left of handle)
self.fill = mcrfpy.Frame(
pos=(0, 0),
size=(int(track_width * parameter.get_normalized()), track_height),
fill_color=mcrfpy.Color(100, 150, 200)
)
self.track.children.append(self.fill)
# Handle
handle_width = 12
handle_pos = int((track_width - handle_width) * parameter.get_normalized())
self.handle = mcrfpy.Frame(
pos=(handle_pos, -3),
size=(handle_width, track_height + 6),
fill_color=mcrfpy.Color(180, 180, 200),
outline=1,
outline_color=mcrfpy.Color(220, 220, 230)
)
self.track.children.append(self.handle)
# Value display
self.value_caption = mcrfpy.Caption(
text=parameter.format_value(),
pos=(track_width + 8, height // 2 - 8),
font_size=12,
fill_color=mcrfpy.Color(180, 180, 190)
)
self.frame.children.append(self.value_caption)
# Wire up parameter change notification
self.parameter._on_change = self._on_param_change
self.track_width = track_width
def _on_track_click(self, pos, button, action):
"""Handle click on track for direct positioning and drag start/end."""
if button == mcrfpy.MouseButton.LEFT:
if action == mcrfpy.InputState.PRESSED:
self.dragging = True
self._update_from_position(pos.x)
elif action == mcrfpy.InputState.RELEASED:
self.dragging = False
def _on_track_move(self, pos):
"""Handle mouse movement for dragging."""
if self.dragging:
self._update_from_position(pos.x)
def _update_from_position(self, x: float):
"""Update parameter value from mouse x position on track."""
normalized = max(0.0, min(1.0, x / self.track_width))
self.parameter.set_from_normalized(normalized)
def _on_param_change(self, param):
"""Handle parameter value change - update visual elements."""
normalized = param.get_normalized()
handle_width = 12
handle_pos = int((self.track_width - handle_width) * normalized)
self.handle.x = handle_pos
self.fill.w = int(self.track_width * normalized)
self.value_caption.text = param.format_value()
if self.on_change:
self.on_change(param)
def update_display(self):
"""Force update of visual elements."""
self._on_param_change(self.parameter)
class LayerToggle:
"""Checkbox toggle for layer visibility.
Layout: [x] Layer Name
Args:
name: Display name for the layer
layer: The ColorLayer or TileLayer to toggle
pos: (x, y) position tuple
width: Total widget width (default 150)
height: Widget height (default 25)
initial: Initial checked state (default True)
on_change: Optional callback when toggled
"""
def __init__(self, name: str, layer, pos: tuple,
width: int = 150, height: int = 25,
initial: bool = True,
on_change: Optional[Callable] = None):
self.name = name
self.layer = layer
self.pos = pos
self.width = width
self.height = height
self.checked = initial
self.on_change = on_change
checkbox_size = height - 4
# Container frame
self.frame = mcrfpy.Frame(
pos=pos,
size=(width, height),
fill_color=mcrfpy.Color(40, 40, 45)
)
self.frame.on_click = self._on_click
self.frame.on_enter = self._on_enter
self.frame.on_exit = self._on_exit
# Checkbox box
self.checkbox = mcrfpy.Frame(
pos=(2, 2),
size=(checkbox_size, checkbox_size),
fill_color=mcrfpy.Color(60, 60, 70) if not initial else mcrfpy.Color(80, 140, 200),
outline=1,
outline_color=mcrfpy.Color(120, 120, 130)
)
self.frame.children.append(self.checkbox)
# Check mark (X)
self.check_mark = mcrfpy.Caption(
text="x" if initial else "",
pos=(checkbox_size // 2 - 4, checkbox_size // 2 - 8),
font_size=14,
fill_color=mcrfpy.Color(255, 255, 255)
)
self.checkbox.children.append(self.check_mark)
# Label
self.label = mcrfpy.Caption(
text=name,
pos=(checkbox_size + 8, height // 2 - 8),
font_size=14,
fill_color=mcrfpy.Color(200, 200, 210) if initial else mcrfpy.Color(120, 120, 130)
)
self.frame.children.append(self.label)
# Apply initial visibility
if layer is not None:
layer.visible = initial
def _on_click(self, pos, button, action):
"""Handle click to toggle."""
if button == mcrfpy.MouseButton.LEFT and action == mcrfpy.InputState.RELEASED:
self.toggle()
def _on_enter(self, pos):
"""Handle mouse enter - highlight."""
self.frame.fill_color = mcrfpy.Color(50, 50, 60)
def _on_exit(self, pos):
"""Handle mouse exit - unhighlight."""
self.frame.fill_color = mcrfpy.Color(40, 40, 45)
def toggle(self):
"""Toggle the checkbox state."""
self.checked = not self.checked
self._update_visual()
if self.layer is not None:
self.layer.visible = self.checked
if self.on_change:
self.on_change(self.name, self.checked)
def set_checked(self, checked: bool):
"""Set checkbox state directly."""
if checked != self.checked:
self.checked = checked
self._update_visual()
if self.layer is not None:
self.layer.visible = checked
def _update_visual(self):
"""Update visual elements based on checked state."""
if self.checked:
self.checkbox.fill_color = mcrfpy.Color(80, 140, 200)
self.check_mark.text = "x"
self.label.fill_color = mcrfpy.Color(200, 200, 210)
else:
self.checkbox.fill_color = mcrfpy.Color(60, 60, 70)
self.check_mark.text = ""
self.label.fill_color = mcrfpy.Color(120, 120, 130)

View 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']

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

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

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

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

View file

@ -0,0 +1,200 @@
"""Interactive Procedural Generation Demo Launcher
Run with: ./mcrogueface ../tests/procgen_interactive/main.py
"""
import mcrfpy
import sys
# Demo classes
from .demos.cave_demo import CaveDemo
from .demos.dungeon_demo import DungeonDemo
from .demos.terrain_demo import TerrainDemo
from .demos.town_demo import TownDemo
class DemoLauncher:
"""Main menu for selecting demos."""
DEMOS = [
("Cave (Cellular Automata)", CaveDemo,
"Cellular automata cave generation with noise, smoothing, and region detection"),
("Dungeon (BSP)", DungeonDemo,
"Binary Space Partitioning with room extraction and corridor connections"),
("Terrain (Multi-layer)", TerrainDemo,
"FBM noise elevation with water level, mountains, erosion, and biomes"),
("Town (Voronoi)", TownDemo,
"Voronoi districts with Bezier roads and building placement"),
]
def __init__(self):
"""Build the menu scene."""
self.scene = mcrfpy.Scene("procgen_menu")
self.current_demo = None
self._build_menu()
def _build_menu(self):
"""Create the menu UI."""
ui = self.scene.children
# Background
bg = mcrfpy.Frame(
pos=(0, 0),
size=(1024, 768),
fill_color=mcrfpy.Color(25, 28, 35)
)
ui.append(bg)
# Title
title = mcrfpy.Caption(
text="Interactive Procedural Generation",
pos=(512, 60),
font_size=32,
fill_color=mcrfpy.Color(220, 220, 230)
)
ui.append(title)
subtitle = mcrfpy.Caption(
text="Educational demos for exploring generation techniques",
pos=(512, 100),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 160)
)
ui.append(subtitle)
# Demo buttons
button_y = 180
button_width = 400
button_height = 80
for i, (name, demo_class, description) in enumerate(self.DEMOS):
# Button frame
btn = mcrfpy.Frame(
pos=(312, button_y),
size=(button_width, button_height),
fill_color=mcrfpy.Color(45, 48, 55),
outline=2,
outline_color=mcrfpy.Color(80, 85, 100)
)
# Demo name
name_caption = mcrfpy.Caption(
text=name,
pos=(20, 15),
font_size=20,
fill_color=mcrfpy.Color(200, 200, 210)
)
btn.children.append(name_caption)
# Description (wrap manually for now)
desc_text = description[:55] + "..." if len(description) > 55 else description
desc_caption = mcrfpy.Caption(
text=desc_text,
pos=(20, 45),
font_size=12,
fill_color=mcrfpy.Color(120, 120, 130)
)
btn.children.append(desc_caption)
# Click handler
demo_idx = i
btn.on_click = lambda p, b, a, idx=demo_idx: self._on_demo_click(idx, b, a)
btn.on_enter = lambda p, btn=btn: self._on_btn_enter(btn)
btn.on_exit = lambda p, btn=btn: self._on_btn_exit(btn)
ui.append(btn)
button_y += button_height + 20
# Instructions
instructions = [
"Click a demo to start exploring procedural generation",
"Each demo shows step-by-step visualization of the algorithm",
"",
"Controls (in demos):",
" Left/Right arrows: Navigate steps",
" Middle-drag: Pan viewport",
" Scroll wheel: Zoom in/out",
" Number keys: Toggle layer visibility",
" R: Reset view",
" Escape: Return to this menu",
]
instr_y = 580
for line in instructions:
cap = mcrfpy.Caption(
text=line,
pos=(312, instr_y),
font_size=12,
fill_color=mcrfpy.Color(100, 100, 110)
)
ui.append(cap)
instr_y += 18
# Keyboard handler
self.scene.on_key = self._on_key
def _on_btn_enter(self, btn):
"""Handle button hover enter."""
btn.fill_color = mcrfpy.Color(55, 60, 70)
btn.outline_color = mcrfpy.Color(100, 120, 180)
def _on_btn_exit(self, btn):
"""Handle button hover exit."""
btn.fill_color = mcrfpy.Color(45, 48, 55)
btn.outline_color = mcrfpy.Color(80, 85, 100)
def _on_demo_click(self, idx, button, action):
"""Handle demo button click."""
if button == mcrfpy.MouseButton.LEFT and action == mcrfpy.InputState.RELEASED:
self._launch_demo(idx)
def _launch_demo(self, idx):
"""Launch a demo by index."""
_, demo_class, _ = self.DEMOS[idx]
self.current_demo = demo_class()
self.current_demo.activate()
def _on_key(self, key, action):
"""Handle keyboard input."""
# Only process on key press
if action != mcrfpy.InputState.PRESSED:
return
# Convert key to string for easier comparison
key_str = str(key) if not isinstance(key, str) else key
# Number keys to launch demos directly
if key_str.startswith("Key.NUM") or (len(key_str) == 1 and key_str.isdigit()):
try:
num = int(key_str[-1])
if 1 <= num <= len(self.DEMOS):
self._launch_demo(num - 1)
except (ValueError, IndexError):
pass
elif key == mcrfpy.Key.ESCAPE:
sys.exit(0)
def show(self):
"""Show the menu."""
mcrfpy.current_scene = self.scene
# Global launcher instance
_launcher = None
def show_menu():
"""Show the demo menu (called from demos to return)."""
global _launcher
if _launcher is None:
_launcher = DemoLauncher()
_launcher.show()
def main():
"""Entry point for the demo system."""
show_menu()
if __name__ == "__main__":
main()