J. Rogers, SE Ohio
We already have most of the individual parts coded and working separately, now we just need to make everything work together.
Wiring Up Your Existing Components
Current Component Inventory
✅ What You Already Have
| Component | Status | Location |
|---|---|---|
| World Generation | ✅ Working | world_gen.py |
| Town Generator | ✅ Working | Hooked to AI |
| Dungeon Generator | ✅ Working | Hooked to AI |
| Map Viewer | ✅ Working | map_viewer.py |
| Database | ✅ Working | db_manager.py |
| UI Widgets | ✅ Working | widgets.py |
| Image Rendering | ✅ Working | image_strategy.py |
| Heightmap System | ✅ Working | 16-bit PNG storage |
🔧 What Needs Building
| Component | Priority | Purpose |
|---|---|---|
| Campaign Manager | 🔴 Critical | Orchestrates everything |
| Display Manager | 🔴 Critical | Dual window control |
| Navigation System | 🔴 Critical | Node traversal |
| Heightmap Extractor | 🟡 High | Region extraction |
| Building Designer | 🟢 Medium | Interior generation |
| State Synchronization | 🟡 High | DM ↔ Player sync |
Day 1: Core Framework (Morning Session)
Goal: Get the orchestration layer working
Step 1: Create campaign_manager.py
"""
Central orchestrator for the entire campaign system.
Owns all generators, manages state, routes navigation.
"""
from codex_engine.core.db_manager import DBManager
from codex_engine.generators.world_gen import WorldGenerator
from codex_engine.generators.town_gen import TownGenerator # Your existing
from codex_engine.generators.dungeon_gen import DungeonGenerator # Your existing
class CampaignManager:
"""
The brain of the operation. Manages:
- Current campaign state
- Navigation between maps
- Routing to correct generators
- Database operations
"""
def __init__(self, db_manager, theme_manager):
self.db = db_manager
self.theme = theme_manager
# Initialize all generators
self.world_gen = WorldGenerator(theme_manager, db_manager)
self.town_gen = TownGenerator(theme_manager, db_manager)
self.dungeon_gen = DungeonGenerator(theme_manager, db_manager)
# State
self.current_campaign_id = None
self.current_node = None
self.navigation_stack = [] # For back button
def load_campaign(self, campaign_id):
"""Load a campaign and navigate to world map"""
self.current_campaign_id = campaign_id
# Get or create world map node
world_node = self.db.get_node_by_coords(campaign_id, None, 0, 0)
if not world_node:
print("No world map found, generating...")
node_id, metadata = self.world_gen.generate_world_node(campaign_id)
world_node = self.db.get_node_by_coords(campaign_id, None, 0, 0)
self.navigate_to_node(world_node)
def navigate_to_node(self, node):
"""Navigate to any node (world/town/dungeon/room)"""
if self.current_node:
self.navigation_stack.append(self.current_node)
self.current_node = node
print(f"Navigated to: {node['name']} (type: {node['node_type']})")
return node
def navigate_back(self):
"""Go back one level in navigation"""
if self.navigation_stack:
self.current_node = self.navigation_stack.pop()
return self.current_node
return None
def generate_or_load_child(self, parent_node, marker):
"""
Universal child location loader.
Routes to correct generator based on marker type.
"""
# Check if target already exists
if marker.get('target_node_id'):
target = self.db.get_node_by_id(marker['target_node_id'])
if target:
return self.navigate_to_node(target)
# Doesn't exist, generate based on type
marker_type = marker['type']
if marker_type == 'town':
return self._generate_town(parent_node, marker)
elif marker_type == 'dungeon':
return self._generate_dungeon(parent_node, marker)
elif marker_type == 'building':
return self._generate_building(parent_node, marker)
else:
print(f"Unknown marker type: {marker_type}")
return None
def _generate_town(self, parent_node, marker):
"""Generate a town using your existing town generator"""
print(f"Generating town at {marker['coords']}...")
# Extract heightmap region from parent
# (We'll implement this in Step 3)
# Call your existing town generator
node_id, metadata = self.town_gen.generate_town_node(
campaign_id=self.current_campaign_id,
parent_node_id=parent_node['id'],
coords_x=marker['local_x'],
coords_y=marker['local_y'],
town_name=marker.get('name', 'Unnamed Town')
)
# Update marker to point to new node
self.db.update_marker(marker['id'], target_node_id=node_id)
# Load and navigate to it
new_node = self.db.get_node_by_id(node_id)
return self.navigate_to_node(new_node)
def _generate_dungeon(self, parent_node, marker):
"""Generate a dungeon using your existing dungeon generator"""
print(f"Generating dungeon at {marker['coords']}...")
node_id, metadata = self.dungeon_gen.generate_dungeon_node(
campaign_id=self.current_campaign_id,
parent_node_id=parent_node['id'],
coords_x=marker['local_x'],
coords_y=marker['local_y'],
dungeon_name=marker.get('name', 'Unnamed Dungeon'),
theme=marker.get('theme', 'generic')
)
self.db.update_marker(marker['id'], target_node_id=node_id)
new_node = self.db.get_node_by_id(node_id)
return self.navigate_to_node(new_node)
def _generate_building(self, parent_node, marker):
"""Generate building interior (to be implemented)"""
print(f"Building generation not yet implemented")
return None
def get_current_markers(self):
"""Get all markers for current node"""
if not self.current_node:
return []
return self.db.get_markers_for_node(self.current_node['id'])
def create_marker(self, marker_type, local_x, local_y, name, secret=False):
"""Create a new marker on current node"""
if not self.current_node:
return None
marker_id = self.db.create_marker(
node_id=self.current_node['id'],
marker_type=marker_type,
name=name,
local_x=local_x,
local_y=local_y,
secret=secret
)
return self.db.get_marker_by_id(marker_id)
Test it:
# In main.py, test basic navigation
cm = CampaignManager(db, theme)
cm.load_campaign(1)
print(f"Current node: {cm.current_node['name']}")
Day 1: Display Manager (Afternoon Session)
Goal: Get dual window rendering working, save this for stand along project
Step 2: Create display_manager.py
"""
Manages both DM and player display windows.
Handles rendering, state filtering, synchronization.
"""
import pygame
import os
class DisplayManager:
"""
Controls both display windows and their views.
DM window has full access, player window is filtered.
"""
def __init__(self, campaign_manager, theme_manager, dm_screen_size=(1920, 1080)):
self.campaign = campaign_manager
self.theme = theme_manager
# Create DM window (your existing main window)
self.dm_screen = pygame.display.set_mode(dm_screen_size)
pygame.display.set_caption("The Codex Engine - DM View")
# Create player window on second monitor
self.player_screen = None
self.player_enabled = False
# Import your existing MapViewer
from codex_engine.ui.map_viewer import MapViewer
# DM view (full access)
self.dm_view = MapViewer(self.dm_screen, theme_manager)
# Player view (will be created when enabled)
self.player_view = None
# Sync state
self.auto_sync = True # Push changes to player automatically
def enable_player_display(self):
"""
Open player display on second monitor.
Call this when DM clicks "Enable Player Display" button.
"""
if self.player_enabled:
return
# Try to position on second monitor
# This is OS-dependent, might need tweaking
os.environ['SDL_VIDEO_WINDOW_POS'] = "1920,0" # Assumes second monitor is to the right
self.player_screen = pygame.display.set_mode((1920, 1080), pygame.NOFRAME)
pygame.display.set_caption("Campaign Map - Player View")
from codex_engine.ui.map_viewer import MapViewer
self.player_view = MapViewer(self.player_screen, self.theme)
# Copy current DM state to player (filtered)
if self.dm_view.current_node:
self.sync_to_player()
self.player_enabled = True
print("Player display enabled on second monitor")
def disable_player_display(self):
"""Close player display"""
if self.player_screen:
pygame.display.quit()
self.player_screen = None
self.player_view = None
self.player_enabled = False
def sync_to_player(self):
"""
Push current DM view to player display (filtered).
This is where you apply fog of war, hide secret markers, etc.
"""
if not self.player_enabled or not self.player_view:
return
# Get current node from DM view
current_node = self.dm_view.current_node
if not current_node:
return
# Filter the node data for players
filtered_node = self._filter_node_for_players(current_node)
# Set filtered node on player view
self.player_view.set_node(filtered_node)
# Sync camera position (optional - or let player view stay centered)
if self.auto_sync:
self.player_view.cam_x = self.dm_view.cam_x
self.player_view.cam_y = self.dm_view.cam_y
self.player_view.zoom = self.dm_view.zoom
def _filter_node_for_players(self, node):
"""
Remove secret information from node data.
Apply fog of war, hide secret markers, etc.
"""
# Make a copy so we don't modify original
filtered = node.copy()
# TODO: Apply fog of war from database
# fog_state = self.campaign.db.get_fog_of_war(node['id'])
# TODO: Filter metadata to remove DM notes
if 'metadata' in filtered:
metadata = filtered['metadata'].copy()
# Remove any keys that start with 'dm_' or 'secret_'
filtered['metadata'] = {
k: v for k, v in metadata.items()
if not k.startswith('dm_') and not k.startswith('secret_')
}
return filtered
def handle_dm_input(self, event):
"""Route input to DM view"""
self.dm_view.handle_input(event)
# Check if DM navigated to a new node
# If so, sync to player display
if self.auto_sync and self.dm_view.current_node != self.campaign.current_node:
self.sync_to_player()
def update_and_render(self):
"""
Main render loop - draw both windows.
Call this every frame.
"""
# Render DM view
self.dm_view.draw()
pygame.display.flip() # Update DM window
# Render player view if enabled
if self.player_enabled and self.player_view:
self.player_view.draw()
# Need to flip the player display too
# This is tricky with pygame - might need separate display object
Integration challenge: Pygame's pygame.display.set_mode() only supports one window by default. For true dual window, you need to either:
Option A: Use pygame.display.set_mode() twice with separate pygame.init() instances (hacky)
Option B: Use multi-window library like pyglet or moderngl (better, but requires refactoring)
Option C: Use a web-based player view (Pygame for DM, web browser for players on second screen)
For tomorrow, stick with Option A for proof-of-concept:
# Quick dual-window hack for testing
import subprocess
# DM window: Your main pygame app
# Player window: Launch second pygame instance showing same data
# player_process = subprocess.Popen(['python', 'player_display.py'])
Day 2: Navigation Wiring
Goal: Click markers to navigate between maps
Step 3: Wire up marker clicking in map_viewer.py
# Add to MapViewer class
def __init__(self, screen, theme_manager, campaign_manager=None):
# ... existing code ...
self.campaign = campaign_manager # NEW: Reference to campaign manager
self.markers = [] # NEW: Cache of current markers
def set_node(self, node_data):
# ... existing code ...
# NEW: Load markers for this node
if self.campaign:
self.markers = self.campaign.get_current_markers()
def handle_input(self, event):
# ... existing code ...
# NEW: Handle mouse clicks on markers
if event.type == pygame.MOUSEBUTTONDOWN:
if event.button == 1: # Left click
clicked_marker = self._get_marker_at_pos(event.pos)
if clicked_marker:
self._navigate_to_marker(clicked_marker)
def _get_marker_at_pos(self, screen_pos):
"""Check if mouse clicked on a marker"""
# Convert screen coordinates to map coordinates
map_x, map_y = self._screen_to_map_coords(screen_pos)
# Check each marker
for marker in self.markers:
marker_x = marker['local_x']
marker_y = marker['local_y']
# Simple distance check (can improve with hex math later)
distance = ((map_x - marker_x)**2 + (map_y - marker_y)**2)**0.5
if distance < 20: # 20 pixel click radius
return marker
return None
def _screen_to_map_coords(self, screen_pos):
"""Convert screen pixel to map coordinates"""
from codex_engine.config import SCREEN_WIDTH, SCREEN_HEIGHT
sx, sy = screen_pos
center_x = SCREEN_WIDTH // 2
center_y = SCREEN_HEIGHT // 2
map_x = self.cam_x + (sx - center_x) / self.zoom
map_y = self.cam_y + (sy - center_y) / self.zoom
return map_x, map_y
def _navigate_to_marker(self, marker):
"""Tell campaign manager to navigate to this marker"""
if not self.campaign:
return
print(f"Clicked marker: {marker['name']}")
# This triggers generation if needed
new_node = self.campaign.generate_or_load_child(
parent_node=self.current_node,
marker=marker
)
if new_node:
# Load the new node
self.set_node(new_node)
def draw(self):
# ... existing map rendering code ...
# NEW: Draw markers on top of map
self._draw_markers()
def _draw_markers(self):
"""Render marker icons on the map"""
from codex_engine.config import SCREEN_WIDTH, SCREEN_HEIGHT
for marker in self.markers:
# Skip secret markers if this is player view
if marker.get('secret') and not self.show_secrets:
continue
# Convert marker map coords to screen coords
map_x = marker['local_x']
map_y = marker['local_y']
screen_x = (SCREEN_WIDTH // 2) + (map_x - self.cam_x) * self.zoom
screen_y = (SCREEN_HEIGHT // 2) + (map_y - self.cam_y) * self.zoom
# Check if on screen
if 0 <= screen_x <= SCREEN_WIDTH and 0 <= screen_y <= SCREEN_HEIGHT:
# Draw marker icon (simple circle for now)
color = self._get_marker_color(marker['type'])
pygame.draw.circle(self.screen, color, (int(screen_x), int(screen_y)), 8)
# Draw label
if self.zoom > 0.5: # Only show labels when zoomed in
label = self.font_small.render(marker['name'], True, (255, 255, 255))
self.screen.blit(label, (screen_x + 12, screen_y - 10))
def _get_marker_color(self, marker_type):
"""Color code markers by type"""
colors = {
'town': (100, 200, 100), # Green
'dungeon': (200, 100, 100), # Red
'building': (200, 200, 100), # Yellow
'npc': (100, 100, 200), # Blue
'poi': (200, 100, 200), # Purple
}
return colors.get(marker_type, (150, 150, 150)) # Gray default
Day 2: Heightmap Extraction (Afternoon)
Goal: Extract parent terrain for child maps
Step 4: Create terrain_extractor.py
"""
Extracts regions from parent heightmaps and upscales them.
Maintains terrain consistency across zoom levels.
"""
import numpy as np
from PIL import Image
from codex_engine.config import MAPS_DIR
class TerrainExtractor:
"""
Handles extraction of map regions from parent heightmaps.
Used when generating towns/dungeons that need to sit on real terrain.
"""
@staticmethod
def extract_region(parent_map_path, center_x, center_y, radius, target_size):
"""
Extract a square region from parent heightmap and upscale.
Args:
parent_map_path: Path to parent heightmap PNG
center_x, center_y: Center coordinates in parent map
radius: Half-width of extraction in parent pixels
target_size: Output resolution (e.g., 1000x1000)
Returns:
Upscaled heightmap as numpy array (0-1 normalized)
"""
# Load parent heightmap
parent_img = Image.open(parent_map_path)
parent_heightmap = np.array(parent_img, dtype=np.float32) / 65535.0
parent_height, parent_width = parent_heightmap.shape
# Calculate extraction bounds with wrapping
x_start = int(center_x - radius)
x_end = int(center_x + radius)
y_start = int(center_y - radius)
y_end = int(center_y + radius)
# Handle wrapping (toroidal world)
if x_start < 0 or x_end >= parent_width or y_start < 0 or y_end >= parent_height:
# Need to wrap - extract with modulo
extract_width = x_end - x_start
extract_height = y_end - y_start
region = np.zeros((extract_height, extract_width), dtype=np.float32)
for dy in range(extract_height):
for dx in range(extract_width):
src_y = (y_start + dy) % parent_height
src_x = (x_start + dx) % parent_width
region[dy, dx] = parent_heightmap[src_y, src_x]
else:
# Simple slice
region = parent_heightmap[y_start:y_end, x_start:x_end]
# Upscale using bilinear interpolation
region_img = Image.fromarray((region * 65535).astype(np.uint16), mode='I;16')
upscaled_img = region_img.resize((target_size, target_size), Image.BILINEAR)
upscaled = np.array(upscaled_img, dtype=np.float32) / 65535.0
return upscaled
@staticmethod
def save_heightmap(heightmap, filename):
"""Save heightmap as 16-bit PNG"""
uint16_data = (heightmap * 65535).astype(np.uint16)
img = Image.fromarray(uint16_data, mode='I;16')
img.save(MAPS_DIR / filename)
return filename
@staticmethod
def get_terrain_type(heightmap, sea_level_norm):
"""
Analyze heightmap to determine terrain type.
Returns: 'ocean', 'coast', 'plains', 'hills', 'mountains'
"""
avg_height = np.mean(heightmap)
if avg_height < sea_level_norm - 0.1:
return 'ocean'
elif avg_height < sea_level_norm + 0.05:
return 'coast'
elif avg_height < 0.4:
return 'plains'
elif avg_height < 0.7:
return 'hills'
else:
return 'mountains'
Wire it into your generators:
# In town_gen.py
def generate_town_node(self, campaign_id, parent_node_id, coords_x, coords_y, town_name):
# Get parent heightmap
parent_node = self.db.get_node_by_id(parent_node_id)
parent_map_path = MAPS_DIR / parent_node['metadata']['file_path']
# Extract region
from codex_engine.terrain.terrain_extractor import TerrainExtractor
town_heightmap = TerrainExtractor.extract_region(
parent_map_path,
center_x=coords_x,
center_y=coords_y,
radius=100, # 200x200 extraction
target_size=1000 # Upscale to 1000x1000
)
# Determine terrain type for AI context
sea_level_norm = (0 - parent_node['metadata']['real_min']) / \
(parent_node['metadata']['real_max'] - parent_node['metadata']['real_min'])
terrain_type = TerrainExtractor.get_terrain_type(town_heightmap, sea_level_norm)
# Generate town via AI (your existing code)
# Pass terrain_type in prompt for context
town_data = self._generate_via_ai(town_name, terrain_type)
# Save heightmap
import uuid
map_filename = f"{uuid.uuid4()}.png"
TerrainExtractor.save_heightmap(town_heightmap, map_filename)
# Create node
# ... rest of your existing code
Day 3: Building Designer System
Goal: Generate building interiors themed to campaign setting
Step 5: Create building_designer.py
"""
Procedural building interior generator.
Supports multiple themes: fantasy (D&D), sci-fi (Traveller), modern, etc.
"""
import json
from dataclasses import dataclass
from typing import List, Dict
@dataclass
class RoomTemplate:
"""Template for a room type"""
name: str
min_size: tuple # (width, height) in grid squares
max_size: tuple
furniture: List[str]
required_exits: int
theme_specific: Dict # Theme-specific properties
class BuildingDesigner:
"""
Generates building interiors using themed room templates.
"""
def __init__(self, theme='fantasy'):
self.theme = theme
self.room_templates = self._load_room_templates(theme)
def _load_room_templates(self, theme):
"""Load room templates for the given theme"""
if theme == 'fantasy':
return {
'tavern_common_room': RoomTemplate(
name='Common Room',
min_size=(8, 8),
max_size=(15, 15),
furniture=['tables', 'chairs', 'fireplace', 'bar'],
required_exits=2,
theme_specific={
'lighting': 'firelight',
'floor': 'wooden planks',
'atmosphere': 'smoky'
}
),
'bedroom': RoomTemplate(
name='Bedroom',
min_size=(3, 3),
max_size=(6, 6),
furniture=['bed', 'chest', 'nightstand'],
required_exits=1,
theme_specific={
'privacy': True
}
),
'kitchen': RoomTemplate(
name='Kitchen',
min_size=(4, 4),
max_size=(8, 8),
furniture=['stove', 'counter', 'pantry', 'table'],
required_exits=2,
theme_specific={
'fire_hazard': True
}
),
'storage': RoomTemplate(
name='Storage Room',
min_size=(3, 3),
max_size=(6, 6),
furniture=['shelves', 'barrels', 'crates'],
required_exits=1,
theme_specific={}
),
}
elif theme == 'scifi':
return {
'bridge': RoomTemplate(
name='Bridge',
min_size=(10, 8),
max_size=(20, 15),
furniture=['command_chair', 'consoles', 'viewscreen', 'stations'],
required_exits=2,
theme_specific={
'lighting': 'led panels',
'floor': 'metal grating',
'tech_level': 'high'
}
),
'quarters': RoomTemplate(
name='Crew Quarters',
min_size=(3, 3),
max_size=(5, 5),
furniture=['bunk', 'locker', 'terminal'],
required_exits=1,
theme_specific={
'life_support': True
}
),
'engine_room': RoomTemplate(
name='Engine Room',
min_size=(8, 8),
max_size=(15, 12),
furniture=['reactor', 'control_panels', 'cooling_tanks'],
required_exits=2,
theme_specific={
'radiation': True,
'noise_level': 'high'
}
),
}
else:
# Default generic templates
return {}
def generate_building(self, building_type, size='medium'):
"""
Generate a complete building interior.
Args:
building_type: 'inn', 'shop', 'starship', 'warehouse', etc.
size: 'small', 'medium', 'large'
Returns:
Building data structure with rooms, connections, furniture
"""
# Define building type → room type mapping
building_compositions = {
'fantasy': {
'inn': ['tavern_common_room', 'bedroom', 'bedroom', 'bedroom', 'kitchen', 'storage'],
'shop': ['shop_floor', 'storage', 'workshop'],
'mansion': ['foyer', 'dining_room', 'bedroom', 'bedroom', 'kitchen', 'library', 'study'],
'smithy': ['forge', 'workshop', 'storage', 'display'],
},
'scifi': {
'starship': ['bridge', 'quarters', 'quarters', 'engine_room', 'cargo_bay', 'medbay'],
'station': ['command', 'habitation', 'docking', 'maintenance', 'recreation'],
}
}
# Get room list for this building type
if self.theme not in building_compositions:
print(f"Theme {self.theme} not supported")
return None
if building_type not in building_compositions[self.theme]:
print(f"Building type {building_type} not found in theme {self.theme}")
return None
room_types = building_compositions[self.theme][building_type]
# Generate building structure
building = {
'type': building_type,
'theme': self.theme,
'size': size,
'rooms': [],
'connections': []
}
# Generate each room
for i, room_type in enumerate(room_types):
if room_type in self.room_templates:
template = self.room_templates[room_type]
room = self._generate_room(template, room_id=i)
building['rooms'].append(room)
# Connect rooms (simple linear for now)
for i in range(len(building['rooms']) - 1):
building['connections'].append({
'from_room': i,
'to_room': i + 1,
'door_type': 'normal'
})
return building
def _generate_room(self, template, room_id):
"""Generate a single room from template"""
import random
# Random size within template bounds
width = random.randint(template.min_size[0], template.max_size[0])
height = random.randint(template.min_size[1], template.max_size[1])
room = {
'id': room_id,
'name': template.name,
No comments:
Post a Comment