Update Procedural-Generation wiki: modernize API usage (Scene/Timer/Entity/Grid constructors, remove cell.color, use positional grid.at, add visual layers section)

John McCardle 2026-02-07 22:32:05 +00:00
commit 991b6d95dd

@ -14,11 +14,6 @@ McRogueFace supports procedural content generation through its Grid and Entity s
- `src/scripts/cos_tiles.py` - Wave Function Collapse tile placement - `src/scripts/cos_tiles.py` - Wave Function Collapse tile placement
- `src/UIGrid.cpp` - Grid manipulation API - `src/UIGrid.cpp` - Grid manipulation API
**Related Issues:**
- [#123](../issues/123) - Subgrid system for large worlds
- [#67](../issues/67) - Grid stitching for seamless chunks
- [#55](../issues/55) - Agent simulation demo
--- ---
## Binary Space Partitioning (BSP) ## Binary Space Partitioning (BSP)
@ -43,7 +38,6 @@ class BinaryRoomNode:
def split(self, min_size=8): def split(self, min_size=8):
"""Split this room into two sub-rooms""" """Split this room into two sub-rooms"""
# Choose split direction based on aspect ratio
if self.w > self.h: if self.w > self.h:
direction = "vertical" direction = "vertical"
elif self.h > self.w: elif self.h > self.w:
@ -51,7 +45,6 @@ class BinaryRoomNode:
else: else:
direction = random.choice(["vertical", "horizontal"]) direction = random.choice(["vertical", "horizontal"])
# Calculate split position (30-70%)
split_ratio = random.uniform(0.3, 0.7) split_ratio = random.uniform(0.3, 0.7)
if direction == "vertical" and self.w >= min_size * 2: if direction == "vertical" and self.w >= min_size * 2:
@ -67,7 +60,7 @@ class BinaryRoomNode:
self.w, self.h - split_y) self.w, self.h - split_y)
return True return True
return False # Can't split further return False
def get_leaves(self): def get_leaves(self):
"""Get all leaf rooms (no children)""" """Get all leaf rooms (no children)"""
@ -80,12 +73,12 @@ class BinaryRoomNode:
leaves.extend(self.right.get_leaves()) leaves.extend(self.right.get_leaves())
return leaves return leaves
# Generate dungeon layout
def generate_bsp_dungeon(grid, num_splits=4): def generate_bsp_dungeon(grid, num_splits=4):
"""Generate dungeon layout using BSP"""
w, h = grid.grid_size w, h = grid.grid_size
root = BinaryRoomNode(0, 0, w, h) root = BinaryRoomNode(0, 0, w, h)
# Split recursively
nodes = [root] nodes = [root]
for _ in range(num_splits): for _ in range(num_splits):
new_nodes = [] new_nodes = []
@ -96,12 +89,10 @@ def generate_bsp_dungeon(grid, num_splits=4):
new_nodes.append(node) new_nodes.append(node)
nodes = new_nodes nodes = new_nodes
# Get leaf rooms
rooms = root.get_leaves() rooms = root.get_leaves()
# Carve rooms into grid # Carve rooms into grid
for room in rooms: for room in rooms:
# Add margin
rx = room.x + 2 rx = room.x + 2
ry = room.y + 2 ry = room.y + 2
rw = room.w - 4 rw = room.w - 4
@ -109,10 +100,10 @@ def generate_bsp_dungeon(grid, num_splits=4):
for x in range(rx, rx + rw): for x in range(rx, rx + rw):
for y in range(ry, ry + rh): for y in range(ry, ry + rh):
cell = grid.at((x, y)) cell = grid.at(x, y)
cell.walkable = True cell.walkable = True
cell.transparent = True
cell.tilesprite = 0 # Floor tile cell.tilesprite = 0 # Floor tile
cell.color = (128, 128, 128, 255)
return rooms return rooms
``` ```
@ -126,30 +117,28 @@ After generating rooms, create corridors:
```python ```python
def carve_corridor(grid, x1, y1, x2, y2): def carve_corridor(grid, x1, y1, x2, y2):
"""L-shaped corridor between two points""" """L-shaped corridor between two points"""
# Horizontal first
for x in range(min(x1, x2), max(x1, x2) + 1): for x in range(min(x1, x2), max(x1, x2) + 1):
cell = grid.at((x, y1)) cell = grid.at(x, y1)
cell.walkable = True cell.walkable = True
cell.transparent = True
cell.tilesprite = 0 cell.tilesprite = 0
# Then vertical
for y in range(min(y1, y2), max(y1, y2) + 1): for y in range(min(y1, y2), max(y1, y2) + 1):
cell = grid.at((x2, y)) cell = grid.at(x2, y)
cell.walkable = True cell.walkable = True
cell.transparent = True
cell.tilesprite = 0 cell.tilesprite = 0
def connect_rooms(grid, rooms): def connect_rooms(grid, rooms):
"""Connect all rooms with corridors""" """Connect all rooms with corridors"""
for i in range(len(rooms) - 1): for i in range(len(rooms) - 1):
r1 = rooms[i] r1 = rooms[i]
r2 = rooms[i + 1] r2 = rooms[i + 1]
# Room centers
x1 = r1.x + r1.w // 2 x1 = r1.x + r1.w // 2
y1 = r1.y + r1.h // 2 y1 = r1.y + r1.h // 2
x2 = r2.x + r2.w // 2 x2 = r2.x + r2.w // 2
y2 = r2.y + r2.h // 2 y2 = r2.y + r2.h // 2
carve_corridor(grid, x1, y1, x2, y2) carve_corridor(grid, x1, y1, x2, y2)
``` ```
@ -173,69 +162,35 @@ class TileRule:
"west": west or [] "west": west or []
} }
def can_place(self, grid, x, y):
"""Check if tile can be placed at (x, y)"""
# Check each neighbor
if y > 0: # North
north_tile = grid.at((x, y - 1)).tilesprite
if north_tile not in self.neighbors["north"]:
return False
if y < grid.grid_size[1] - 1: # South
south_tile = grid.at((x, y + 1)).tilesprite
if south_tile not in self.neighbors["south"]:
return False
# ... check east/west similarly
return True
# Define tile rules (example: simple dungeon)
FLOOR = 0
WALL = 1
RULES = [
TileRule(FLOOR, north=[FLOOR, WALL], south=[FLOOR, WALL],
east=[FLOOR, WALL], west=[FLOOR, WALL]),
TileRule(WALL, north=[WALL], south=[WALL],
east=[WALL], west=[WALL])
]
def wfc_generate(grid): def wfc_generate(grid):
"""Generate tiles using Wave Function Collapse""" """Generate tiles using Wave Function Collapse"""
w, h = grid.grid_size w, h = grid.grid_size
# Track cells with multiple possibilities
possibilities = {} possibilities = {}
for x in range(w): for x in range(w):
for y in range(h): for y in range(h):
possibilities[(x, y)] = [0, 1] # Can be floor or wall possibilities[(x, y)] = [0, 1]
# Iteratively collapse
while possibilities: while possibilities:
# Find cell with minimum entropy (fewest possibilities)
min_cell = min(possibilities.keys(), min_cell = min(possibilities.keys(),
key=lambda c: len(possibilities[c])) key=lambda c: len(possibilities[c]))
x, y = min_cell x, y = min_cell
# Choose random tile from possibilities
tile = random.choice(possibilities[min_cell]) tile = random.choice(possibilities[min_cell])
grid.at((x, y)).tilesprite = tile grid.at(x, y).tilesprite = tile
del possibilities[min_cell] del possibilities[min_cell]
# Update neighbor possibilities based on constraints
for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]: for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
nx, ny = x + dx, y + dy nx, ny = x + dx, y + dy
if (nx, ny) in possibilities: if (nx, ny) in possibilities:
# Filter based on placed tile's constraints possibilities[(nx, ny)] = [
# (simplified - real WFC is more complex) t for t in possibilities[(nx, ny)]
possibilities[(nx, ny)] = [t for t in possibilities[(nx, ny)] if can_coexist(tile, t)
if can_coexist(tile, t)] ]
``` ```
See `src/scripts/cos_tiles.py` for the complete WFC implementation with: See `src/scripts/cos_tiles.py` for the complete WFC implementation with 9-cell constraint matching and weighted tile selection.
- 9-cell constraint matching
- Special cardinal direction rules
- Weighted tile selection
- Multi-pass convergence
--- ---
@ -255,19 +210,21 @@ def generate_cave(grid, fill_probability=0.45, iterations=5):
# Initialize with random noise # Initialize with random noise
for x in range(w): for x in range(w):
for y in range(h): for y in range(h):
cell = grid.at(x, y)
if random.random() < fill_probability: if random.random() < fill_probability:
grid.at((x, y)).walkable = False cell.walkable = False
grid.at((x, y)).tilesprite = 1 # Wall cell.transparent = False
cell.tilesprite = 1 # Wall
else: else:
grid.at((x, y)).walkable = True cell.walkable = True
grid.at((x, y)).tilesprite = 0 # Floor cell.transparent = True
cell.tilesprite = 0 # Floor
# Iterate cellular automata rules # Iterate cellular automata rules
for _ in range(iterations): for _ in range(iterations):
next_state = [] next_state = []
for x in range(w): for x in range(w):
for y in range(h): for y in range(h):
# Count wall neighbors
wall_count = 0 wall_count = 0
for dx in [-1, 0, 1]: for dx in [-1, 0, 1]:
for dy in [-1, 0, 1]: for dy in [-1, 0, 1]:
@ -275,21 +232,21 @@ def generate_cave(grid, fill_probability=0.45, iterations=5):
continue continue
nx, ny = x + dx, y + dy nx, ny = x + dx, y + dy
if nx < 0 or nx >= w or ny < 0 or ny >= h: if nx < 0 or nx >= w or ny < 0 or ny >= h:
wall_count += 1 # Out of bounds = wall wall_count += 1
elif not grid.at((nx, ny)).walkable: elif not grid.at(nx, ny).walkable:
wall_count += 1 wall_count += 1
# Apply rule: become wall if 5+ neighbors are walls
is_wall = wall_count >= 5 is_wall = wall_count >= 5
next_state.append((x, y, is_wall)) next_state.append((x, y, is_wall))
# Apply next state
for x, y, is_wall in next_state: for x, y, is_wall in next_state:
grid.at((x, y)).walkable = not is_wall cell = grid.at(x, y)
grid.at((x, y)).tilesprite = 1 if is_wall else 0 cell.walkable = not is_wall
cell.transparent = not is_wall
cell.tilesprite = 1 if is_wall else 0
``` ```
### Smoothing and Refinement ### Region Cleanup
```python ```python
def remove_small_regions(grid, min_size=10): def remove_small_regions(grid, min_size=10):
@ -298,7 +255,6 @@ def remove_small_regions(grid, min_size=10):
visited = set() visited = set()
def flood_fill(x, y): def flood_fill(x, y):
"""Returns size of connected region"""
stack = [(x, y)] stack = [(x, y)]
region = [] region = []
while stack: while stack:
@ -307,79 +263,60 @@ def remove_small_regions(grid, min_size=10):
continue continue
if cx < 0 or cx >= w or cy < 0 or cy >= h: if cx < 0 or cx >= w or cy < 0 or cy >= h:
continue continue
if not grid.at((cx, cy)).walkable: if not grid.at(cx, cy).walkable:
continue continue
visited.add((cx, cy)) visited.add((cx, cy))
region.append((cx, cy)) region.append((cx, cy))
# Add neighbors
stack.extend([(cx-1, cy), (cx+1, cy), (cx, cy-1), (cx, cy+1)]) stack.extend([(cx-1, cy), (cx+1, cy), (cx, cy-1), (cx, cy+1)])
return region return region
# Find all regions
regions = [] regions = []
for x in range(w): for x in range(w):
for y in range(h): for y in range(h):
if (x, y) not in visited and grid.at((x, y)).walkable: if (x, y) not in visited and grid.at(x, y).walkable:
region = flood_fill(x, y) region = flood_fill(x, y)
regions.append(region) regions.append(region)
# Keep only largest region, fill others
if regions: if regions:
largest = max(regions, key=len) largest = max(regions, key=len)
for region in regions: for region in regions:
if len(region) < min_size: if region is not largest and len(region) < min_size:
for x, y in region: for x, y in region:
grid.at((x, y)).walkable = False cell = grid.at(x, y)
grid.at((x, y)).tilesprite = 1 cell.walkable = False
cell.transparent = False
cell.tilesprite = 1
``` ```
--- ---
## Noise-Based Terrain ## Visual Layers for Generated Content
### Perlin/Simplex Noise GridPoint properties (`walkable`, `transparent`, `tilesprite`) handle world state. For visual styling, use layers:
Generate heightmaps for terrain:
```python ```python
# Requires noise library: pip install noise # Create grid with explicit layers
import noise grid = mcrfpy.Grid(grid_size=(50, 50), pos=(0, 0), size=(800, 600), layers=[])
def generate_terrain(grid, scale=0.1, octaves=4): # Background colors layer
"""Generate terrain using Perlin noise""" bg = mcrfpy.ColorLayer(name="background", z_index=-2)
w, h = grid.grid_size grid.add_layer(bg)
bg.fill(mcrfpy.Color(20, 20, 30, 255)) # Dark blue
for x in range(w): # Tile sprites layer (requires a texture)
for y in range(h): # tiles = mcrfpy.TileLayer(name="terrain", z_index=-1, texture=tileset)
# Sample noise # grid.add_layer(tiles)
value = noise.pnoise2(x * scale, y * scale,
octaves=octaves,
persistence=0.5,
lacunarity=2.0,
repeatx=w,
repeaty=h,
base=0)
# Map to tile types (-1 to 1 -> 0 to N) # Fog overlay (above entities)
if value < -0.3: fog = mcrfpy.ColorLayer(name="fog", z_index=1)
tile = 2 # Water grid.add_layer(fog)
walkable = False fog.fill(mcrfpy.Color(0, 0, 0, 255)) # Start fully hidden
elif value < 0.0:
tile = 0 # Grass
walkable = True
elif value < 0.3:
tile = 1 # Forest
walkable = True
else:
tile = 3 # Mountain
walkable = False
cell = grid.at((x, y)) # After generating rooms, color the background
cell.tilesprite = tile for room in rooms:
cell.walkable = walkable for x in range(room.x + 2, room.x + room.w - 2):
for y in range(room.y + 2, room.y + room.h - 2):
bg.set((x, y), mcrfpy.Color(128, 128, 128, 255))
``` ```
--- ---
@ -398,30 +335,28 @@ def place_entities(grid, rooms, entity_density=0.05):
num_entities = int(room_area * entity_density) num_entities = int(room_area * entity_density)
for _ in range(num_entities): for _ in range(num_entities):
# Random walkable position in room x = random.randint(room.x + 2, room.x + room.w - 3)
x = random.randint(room.x, room.x + room.w - 1) y = random.randint(room.y + 2, room.y + room.h - 3)
y = random.randint(room.y, room.y + room.h - 1)
if grid.at((x, y)).walkable: if grid.at(x, y).walkable:
enemy = mcrfpy.Entity( enemy = mcrfpy.Entity(
grid_pos=(x, y), grid_pos=(x, y),
sprite_index=random.randint(10, 15) sprite_index=random.randint(10, 15)
) )
grid.entities.append(enemy) grid.entities.append(enemy)
def place_items(grid, rooms, num_items=10): def place_items(grid, rooms, num_items=10):
"""Place items in random locations""" """Place items in random locations"""
placed = 0 placed = 0
attempts = 0 attempts = 0
max_attempts = num_items * 10
while placed < num_items and attempts < max_attempts: while placed < num_items and attempts < num_items * 10:
# Random room
room = random.choice(rooms) room = random.choice(rooms)
x = random.randint(room.x, room.x + room.w - 1) x = random.randint(room.x + 2, room.x + room.w - 3)
y = random.randint(room.y, room.y + room.h - 1) y = random.randint(room.y + 2, room.y + room.h - 3)
if grid.at((x, y)).walkable: if grid.at(x, y).walkable:
item = mcrfpy.Entity( item = mcrfpy.Entity(
grid_pos=(x, y), grid_pos=(x, y),
sprite_index=random.randint(20, 25) sprite_index=random.randint(20, 25)
@ -443,114 +378,76 @@ For worlds larger than memory allows:
```python ```python
class ChunkManager: class ChunkManager:
"""Generate chunks on-demand""" """Generate chunks on-demand"""
def __init__(self, chunk_size=256): def __init__(self, chunk_size=64):
self.chunk_size = chunk_size self.chunk_size = chunk_size
self.loaded_chunks = {} # (cx, cy) -> Grid self.loaded_chunks = {}
def get_chunk(self, chunk_x, chunk_y): def get_chunk(self, chunk_x, chunk_y):
"""Load or generate chunk"""
key = (chunk_x, chunk_y) key = (chunk_x, chunk_y)
if key not in self.loaded_chunks: if key not in self.loaded_chunks:
self.loaded_chunks[key] = self._generate_chunk(chunk_x, chunk_y) self.loaded_chunks[key] = self._generate_chunk(chunk_x, chunk_y)
return self.loaded_chunks[key] return self.loaded_chunks[key]
def _generate_chunk(self, cx, cy): def _generate_chunk(self, cx, cy):
"""Generate a single chunk"""
grid = mcrfpy.Grid( grid = mcrfpy.Grid(
grid_size=(self.chunk_size, self.chunk_size), grid_size=(self.chunk_size, self.chunk_size),
pos=(cx * self.chunk_size * 16, cy * self.chunk_size * 16), pos=(cx * self.chunk_size * 16, cy * self.chunk_size * 16),
size=(self.chunk_size * 16, self.chunk_size * 16) size=(self.chunk_size * 16, self.chunk_size * 16)
) )
# Use chunk coordinates as seed for deterministic generation
seed = hash((cx, cy)) seed = hash((cx, cy))
random.seed(seed) random.seed(seed)
generate_cave(grid)
# Generate terrain for this chunk
generate_terrain(grid, scale=0.1)
return grid return grid
def unload_distant_chunks(self, player_chunk, radius=2): def unload_distant_chunks(self, player_chunk, radius=2):
"""Unload chunks far from player"""
px, py = player_chunk px, py = player_chunk
to_unload = [] to_unload = [
key for key in self.loaded_chunks
for (cx, cy), chunk in self.loaded_chunks.items(): if abs(key[0] - px) > radius or abs(key[1] - py) > radius
if abs(cx - px) > radius or abs(cy - py) > radius: ]
to_unload.append((cx, cy))
for key in to_unload: for key in to_unload:
del self.loaded_chunks[key] del self.loaded_chunks[key]
``` ```
**Issue #123** and **Issue #67** track official subgrid and chunk stitching support.
--- ---
## Performance Considerations ## Incremental Generation with Timers
### Batch Grid Updates Generate during scene transitions to avoid frame drops:
Set many cells efficiently:
```python ```python
# SLOW: Individual cell updates generation_step = [0]
for x in range(100): rooms = [None]
for y in range(100):
cell = grid.at((x, y))
cell.tilesprite = compute_tile(x, y)
# FASTER: Minimize Python/C++ crossings def start_generation(grid):
tiles = [] """Begin incremental procedural generation"""
for x in range(100): scene = mcrfpy.Scene("loading")
for y in range(100): mcrfpy.current_scene = scene
tiles.append(compute_tile(x, y))
# Then set in batch (when batch API available - see [[Performance-and-Profiling]]) def generate_world(timer, runtime):
for i, (x, y) in enumerate(coords): if generation_step[0] == 0:
grid.at((x, y)).tilesprite = tiles[i] rooms[0] = generate_bsp_dungeon(grid)
``` elif generation_step[0] == 1:
connect_rooms(grid, rooms[0])
elif generation_step[0] == 2:
place_entities(grid, rooms[0])
else:
timer.stop()
game_scene = mcrfpy.Scene("game")
game_scene.children.append(grid)
mcrfpy.current_scene = game_scene
return
### Generation Timing generation_step[0] += 1
Generate during scene transitions or in background: mcrfpy.Timer("gen_step", generate_world, 10)
```python
def start_generation():
"""Begin procedural generation"""
mcrfpy.createScene("loading")
mcrfpy.setScene("loading")
# Start generation timer
mcrfpy.setTimer("generate", generate_world, 10)
def generate_world(ms):
"""Generate incrementally"""
global generation_step
if generation_step == 0:
rooms = generate_bsp_dungeon(grid)
elif generation_step == 1:
connect_rooms(grid, rooms)
elif generation_step == 2:
wfc_generate_tiles(grid)
elif generation_step == 3:
place_entities(grid)
else:
# Done!
mcrfpy.setScene("game")
mcrfpy.delTimer("generate")
return
generation_step += 1
``` ```
--- ---
## Examples and Demos ## Complete Dungeon Generator
### Complete Dungeon Generator
```python ```python
import mcrfpy import mcrfpy
@ -558,7 +455,6 @@ import random
def create_procedural_dungeon(): def create_procedural_dungeon():
"""Complete dungeon generation pipeline""" """Complete dungeon generation pipeline"""
# Create grid
grid = mcrfpy.Grid(grid_size=(100, 100), pos=(0, 0), size=(1024, 768)) grid = mcrfpy.Grid(grid_size=(100, 100), pos=(0, 0), size=(1024, 768))
# 1. Generate layout with BSP # 1. Generate layout with BSP
@ -567,13 +463,7 @@ def create_procedural_dungeon():
# 2. Connect rooms # 2. Connect rooms
connect_rooms(grid, rooms) connect_rooms(grid, rooms)
# 3. Tile placement with WFC # 3. Place player in first room
wfc_generate_tiles(grid)
# 4. Smooth with cellular automata
smooth_edges(grid)
# 5. Place player in first room
first_room = rooms[0] first_room = rooms[0]
player = mcrfpy.Entity( player = mcrfpy.Entity(
grid_pos=(first_room.x + first_room.w // 2, grid_pos=(first_room.x + first_room.w // 2,
@ -582,10 +472,10 @@ def create_procedural_dungeon():
) )
grid.entities.append(player) grid.entities.append(player)
# 6. Place enemies # 4. Place enemies
place_entities(grid, rooms[1:], entity_density=0.05) place_entities(grid, rooms[1:], entity_density=0.05)
# 7. Place exit in last room # 5. Place exit in last room
last_room = rooms[-1] last_room = rooms[-1]
exit_entity = mcrfpy.Entity( exit_entity = mcrfpy.Entity(
grid_pos=(last_room.x + last_room.w // 2, grid_pos=(last_room.x + last_room.w // 2,
@ -596,31 +486,38 @@ def create_procedural_dungeon():
return grid, player return grid, player
# Use it # Use it
mcrfpy.createScene("game") game_scene = mcrfpy.Scene("game")
mcrfpy.setScene("game")
grid, player = create_procedural_dungeon() grid, player = create_procedural_dungeon()
ui = mcrfpy.sceneUI("game") game_scene.children.append(grid)
ui.append(grid) mcrfpy.current_scene = game_scene
``` ```
See `src/scripts/cos_level.py` and `src/scripts/cos_tiles.py` for production examples from "Crypt of Sokoban." See `src/scripts/cos_level.py` and `src/scripts/cos_tiles.py` for production examples.
--- ---
## API Reference ## API Reference
See [`docs/api_reference_dynamic.html`](../src/branch/master/docs/api_reference_dynamic.html) for Grid and Entity APIs. **Grid Construction:**
- `mcrfpy.Grid(grid_size=(w, h), pos=(x, y), size=(pw, ph))` - Create grid
**Key Grid Methods:** **Cell Properties (GridPoint):**
- `grid.at((x, y))` - Get cell for modification - `cell.walkable` - Pathfinding flag (bool)
- `grid.grid_size` - Get dimensions - `cell.transparent` - FOV flag (bool)
- `grid.entities` - Entity collection - `cell.tilesprite` - Tile sprite index (int, legacy)
**Key Cell Properties:** **Visual Layers:**
- `cell.tilesprite` - Tile index - `mcrfpy.ColorLayer(name="...", z_index=N)` - Color overlay layer
- `cell.walkable` - Pathfinding flag - `mcrfpy.TileLayer(name="...", z_index=N, texture=tex)` - Tile sprite layer
- `cell.color` - RGBA tint - `grid.add_layer(layer)` - Attach layer to grid
- `layer.set((x, y), value)` - Set cell value
- `layer.fill(value)` - Fill all cells
**Entity Placement:**
- `mcrfpy.Entity(grid_pos=(x, y), sprite_index=N)` - Create entity
- `grid.entities.append(entity)` - Add to grid
--- ---