[refactor] cleanup unused code

This commit is contained in:
Maxim Snesarev 2026-01-21 01:34:57 +03:00
parent b1b256e520
commit 49aab7ff1c
20 changed files with 82 additions and 3761 deletions

View File

@ -9,14 +9,14 @@ This project simulates a village economy with autonomous AI agents. Each agent h
### Features
- **Agent-based simulation**: Multiple AI agents with different professions
- **GOAP AI system**: Goal-Oriented Action Planning for intelligent agent behavior
- **Vital stats system**: Energy, Hunger, Thirst, and Heat with passive decay
- **Market economy**: Order book system for trading resources
- **Day/Night cycle**: 10 day steps + 1 night step per day
- **Maslow-priority AI**: Agents prioritize survival over economic activities
- **Real-time visualization**: Pygame frontend showing agents and their states
- **Real-time visualization**: Web-based frontend showing agents and their states
- **Agent movement**: Agents visually move to different locations based on their actions
- **Action indicators**: Visual feedback showing what each agent is doing
- **Settings panel**: Adjust simulation parameters with sliders
- **GOAP Debug Panel**: View agent planning and decision-making in real-time
- **Detailed logging**: All simulation steps are logged for analysis
## Architecture
@ -28,11 +28,13 @@ villsim/
│ ├── config.py # Centralized configuration
│ ├── api/ # REST API endpoints
│ ├── core/ # Game logic (engine, world, market, AI, logger)
│ │ └── goap/ # GOAP AI system (planner, actions, goals)
│ └── domain/ # Data models (agent, resources, actions)
├── frontend/ # Pygame visualizer
│ ├── main.py # Entry point
│ ├── client.py # HTTP client
│ └── renderer/ # Drawing components (map, agents, UI, settings)
├── web_frontend/ # Web-based visualizer
│ ├── index.html # Main application
│ ├── goap_debug.html # GOAP debugging view
│ └── src/ # JavaScript modules (scenes, API client)
├── tools/ # Analysis and optimization scripts
├── logs/ # Simulation log files (created on run)
├── docs/design/ # Design documents
├── requirements.txt
@ -79,40 +81,25 @@ The server will start at `http://localhost:8000`. You can access:
- API docs: `http://localhost:8000/docs`
- Health check: `http://localhost:8000/health`
### Start the Frontend Visualizer
### Start the Web Frontend
Open another terminal and run:
Open the web frontend by opening `web_frontend/index.html` in a web browser, or serve it with a local HTTP server:
```bash
python -m frontend.main
cd web_frontend
python -m http.server 8080
```
A Pygame window will open showing the simulation.
Then navigate to `http://localhost:8080` in your browser.
## Controls
| Key | Action |
|-----|--------|
| `SPACE` | Advance one turn (manual mode) |
| `R` | Reset simulation |
| `M` | Toggle between MANUAL and AUTO mode |
| `S` | Open/close settings panel |
| `ESC` | Close settings or quit |
The web frontend provides buttons for:
- **Step**: Advance one turn (manual mode)
- **Auto/Manual**: Toggle between automatic and manual mode
- **Reset**: Reset simulation
Hover over agents to see detailed information.
## Settings Panel
Press `S` to open the settings panel where you can adjust:
- **Agent Stats**: Max values and decay rates for energy, hunger, thirst, heat
- **World Settings**: Grid size, initial agent count, day length
- **Action Costs**: Energy costs for hunting, gathering, etc.
- **Resource Effects**: How much stats are restored by consuming resources
- **Market Settings**: Price adjustment timing and rates
- **Simulation Speed**: Auto-step interval
Changes require clicking "Apply & Restart" to take effect.
Click on agents to see detailed information. Use the GOAP debug panel (`goap_debug.html`) to inspect agent planning.
## Logging
@ -189,12 +176,14 @@ Action indicators above agents show:
- Movement animation when traveling
- Dotted line to destination
### AI Priority System
### AI System (GOAP)
1. **Critical needs** (stat < 20%): Consume, buy, or gather resources
2. **Energy management**: Rest if too tired
3. **Economic activity**: Sell excess inventory, buy needed materials
4. **Routine work**: Perform profession-specific tasks
The simulation uses Goal-Oriented Action Planning (GOAP) for intelligent agent behavior:
1. **Goals**: Agents have weighted goals (Survive, Maintain Heat, Build Wealth, etc.)
2. **Actions**: Agents can perform actions with preconditions and effects
3. **Planning**: A* search finds optimal action sequences to satisfy goals
4. **Personality**: Each agent has unique traits affecting goal weights and decisions
## Development
@ -202,9 +191,10 @@ Action indicators above agents show:
- **Config** (`backend/config.py`): Centralized configuration with dataclasses
- **Domain Layer** (`backend/domain/`): Pure data models
- **Core Layer** (`backend/core/`): Game logic, AI, market, logging
- **Core Layer** (`backend/core/`): Game logic, market, logging
- **GOAP AI** (`backend/core/goap/`): Goal-oriented action planning system
- **API Layer** (`backend/api/`): FastAPI routes and schemas
- **Frontend** (`frontend/`): Pygame visualization client
- **Web Frontend** (`web_frontend/`): Browser-based visualization
### Analyzing Logs
@ -227,7 +217,7 @@ with open("logs/sim_20260118_123456.jsonl") as f:
- Agent reproduction
- Skill progression
- Persistent save/load
- Web-based frontend alternative
- Unity frontend integration
## License

View File

@ -133,14 +133,7 @@ class EconomyConfig:
@dataclass
class AIConfig:
"""Configuration for AI decision-making system.
Controls whether to use GOAP (Goal-Oriented Action Planning) or
the legacy priority-based system.
"""
# Use GOAP-based AI (True) or legacy priority-based AI (False)
use_goap: bool = True
"""Configuration for AI decision-making system (GOAP-based)."""
# Maximum A* iterations for GOAP planner
goap_max_iterations: int = 50

View File

@ -3,7 +3,6 @@
from .world import World, TimeOfDay
from .market import Order, OrderBook
from .engine import GameEngine, SimulationMode
from .ai import AgentAI
from .logger import SimulationLogger, get_simulation_logger
__all__ = [
@ -13,7 +12,6 @@ __all__ = [
"OrderBook",
"GameEngine",
"SimulationMode",
"AgentAI",
"SimulationLogger",
"get_simulation_logger",
]

File diff suppressed because it is too large Load Diff

View File

@ -176,9 +176,6 @@ class GameEngine:
money=agent.money,
)
# Get AI config to determine which system to use
ai_config = get_config().ai
# GOAP AI handles night time automatically
decision = get_ai_decision(
agent,
@ -186,7 +183,6 @@ class GameEngine:
step_in_day=self.world.step_in_day,
day_steps=self.world.config.day_steps,
current_turn=current_turn,
use_goap=ai_config.use_goap,
is_night=self.world.is_night(),
)

View File

@ -1,7 +1,7 @@
"""GOAP-based AI decision system for agents.
This module provides the main interface for GOAP-based decision making.
It replaces the priority-based AgentAI with a goal-oriented planner.
This module provides the main interface for GOAP-based decision making
using Goal-Oriented Action Planning.
"""
from dataclasses import dataclass, field

View File

@ -4,6 +4,7 @@ import os
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from backend.api.routes import router
@ -48,14 +49,8 @@ async def startup_event():
@app.get("/", tags=["root"])
def root():
"""Root endpoint with API information."""
return {
"name": "Village Simulation API",
"version": "1.0.0",
"docs": "/docs",
"web_frontend": "/web/",
"status": "running",
}
"""Root endpoint - redirect to web frontend."""
return RedirectResponse(url="/web/")
@app.get("/health", tags=["health"])
@ -69,12 +64,19 @@ def health_check():
}
# ============== Web Frontend Static Files ==============
# ============== Web Frontend ==============
# Mount static files for web frontend
# Access at http://localhost:8000/web/
@app.get("/web", include_in_schema=False)
def redirect_to_web_frontend():
"""Redirect /web to /web/ for static file serving."""
return RedirectResponse(url="/web/")
# Mount static files for web frontend (access at http://localhost:8000/web/)
if os.path.exists(WEB_FRONTEND_PATH):
app.mount("/web", StaticFiles(directory=WEB_FRONTEND_PATH, html=True), name="web_frontend")
else:
print(f"Warning: Web frontend not found at {WEB_FRONTEND_PATH}")
def main():

View File

@ -7,7 +7,6 @@
"stats_update_interval": 10
},
"ai": {
"use_goap": false,
"goap_max_iterations": 30,
"goap_max_plan_depth": 2,
"reactive_fallback": true

View File

@ -1,6 +1,5 @@
{
"ai": {
"use_goap": true,
"goap_max_iterations": 50,
"goap_max_plan_depth": 3,
"reactive_fallback": true

View File

@ -5,10 +5,10 @@ This document outlines the architecture for the Village Simulation based on [Vil
## 1. System Overview
The system consists of two distinct applications communicating via HTTP (REST API):
1. **Backend (Server)**: Responsible for the entire simulation state, economic logic, AI decision-making, and turn management.
2. **Frontend (Client)**: A "dumb" terminal using **Pygame** that queries the current state to render it and sends user commands (if any) to the server.
1. **Backend (Server)**: Responsible for the entire simulation state, economic logic, AI decision-making (GOAP-based), and turn management.
2. **Frontend (Client)**: A web-based frontend (HTML/JavaScript) that queries the current state to render it and sends user commands to the server.
This separation allows replacing the Pygame frontend with Web (React/Vue) or Unity in the future without changing the backend logic.
This separation allows replacing the web frontend with other technologies (React/Vue, Unity, etc.) without changing the backend logic.
---
@ -54,21 +54,24 @@ backend/
---
## 3. Frontend Architecture (Pygame)
## 3. Frontend Architecture (Web)
The frontend acts as a **Visualizer**. It does not calculate simulation logic.
### 3.1. Structure
```text
frontend/
├── main.py # Pygame Game Loop
├── client.py # Network Client (requests lib)
├── assets/ # Sprites/Fonts
└── renderer/ # Drawing Logic
├── map_renderer.py # Draws the grid/terrain
├── agent_renderer.py # Draws agents and their status bars
└── ui_renderer.py # Draws text info (Market prices, Day/Night)
web_frontend/
├── index.html # Main HTML page
├── goap_debug.html # GOAP debugging view
├── styles.css # Styling
└── src/
├── main.js # Application entry point
├── api.js # Network client (fetch API)
├── constants.js # Configuration constants
└── scenes/ # Game scenes (Phaser.js)
├── BootScene.js # Loading scene
└── GameScene.js # Main game visualization
```
### 3.2. Flow
@ -77,12 +80,11 @@ frontend/
* Call `GET http://localhost:8000/state`.
* Receive JSON: `{"turn": 5, "time_of_day": "day", "agents": [...], "market": [...]}`.
2. **Update Step**:
* Parse JSON into local simplified objects.
* Parse JSON into JavaScript objects.
3. **Draw Step**:
* Clear screen.
* Update Phaser.js game scene.
* Render Agents at their coordinates.
* Render UI overlays (e.g., "Day 1, Step 5", "Total Coins: 500").
* `pygame.display.flip()`.
---
@ -97,12 +99,10 @@ Since the simulation involves AI agents acting autonomously, the Frontend is pri
* Frontend updates the screen.
### 4.1. The "God Mode" Problem
To test the simulation efficiently, the Server will expose a **Simulation Controller**:
* **Manual Mode**: The server waits for a `POST /next_step` call to advance. The User presses `SPACE` in Pygame -> Pygame sends request -> Server updates -> Pygame fetches new state.
To test the simulation efficiently, the Server exposes a **Simulation Controller**:
* **Manual Mode**: The server waits for a `POST /next_step` call to advance. The User clicks the advance button in the web frontend -> Frontend sends request -> Server updates -> Frontend fetches new state.
* **Auto Mode**: Server runs a background thread updating every N seconds. Frontend just polls.
*Recommended for MVP: Manual Mode (Spacebar to advance turn).*
---
## 5. Technology Stack
@ -110,12 +110,13 @@ To test the simulation efficiently, the Server will expose a **Simulation Contro
* **Language**: Python 3.11+
* **Backend Framework**: FastAPI (for speed and auto-generated docs).
* **Data Validation**: Pydantic.
* **Frontend**: Pygame Community Edition (pygame-ce).
* **Communication**: HTTP (Requests/Uvicorn).
* **AI System**: GOAP (Goal-Oriented Action Planning).
* **Frontend**: HTML/JavaScript with Phaser.js for rendering.
* **Communication**: HTTP (Fetch API/Uvicorn).
## 6. Future Extensibility (Why this architecture?)
* **Switch to Web**: Replace `frontend/` folder with a React app. The React app simply calls the same `GET /state` endpoint.
* **Switch to React/Vue**: Replace `web_frontend/` folder with a React app. The React app simply calls the same `GET /state` endpoint.
* **Switch to Unity**: Unity `UnityWebRequest` calls `GET /state`.
* **Database**: Currently state is in-memory (`core/engine.py`). Easy to swap for SQLite/Postgres later by adding a `repository` layer in Backend.

View File

@ -1,2 +0,0 @@
"""Frontend package for Village Simulation visualization."""

View File

@ -1,180 +0,0 @@
"""HTTP client for communicating with the Village Simulation backend."""
import time
from dataclasses import dataclass
from typing import Optional, Any
import requests
from requests.exceptions import RequestException
@dataclass
class SimulationState:
"""Parsed simulation state from the API."""
turn: int
day: int
step_in_day: int
time_of_day: str
world_width: int
world_height: int
agents: list[dict]
market_orders: list[dict]
market_prices: dict
statistics: dict
mode: str
is_running: bool
recent_logs: list[dict]
@classmethod
def from_api_response(cls, data: dict) -> "SimulationState":
"""Create from API response data."""
return cls(
turn=data.get("turn", 0),
day=data.get("day", 1),
step_in_day=data.get("step_in_day", 0),
time_of_day=data.get("time_of_day", "day"),
world_width=data.get("world_size", {}).get("width", 20),
world_height=data.get("world_size", {}).get("height", 20),
agents=data.get("agents", []),
market_orders=data.get("market", {}).get("orders", []),
market_prices=data.get("market", {}).get("prices", {}),
statistics=data.get("statistics", {}),
mode=data.get("mode", "manual"),
is_running=data.get("is_running", False),
recent_logs=data.get("recent_logs", []),
)
def get_living_agents(self) -> list[dict]:
"""Get only living agents."""
return [a for a in self.agents if a.get("is_alive", False)]
class SimulationClient:
"""HTTP client for the Village Simulation backend."""
def __init__(self, base_url: str = "http://localhost:8000"):
self.base_url = base_url.rstrip("/")
self.api_url = f"{self.base_url}/api"
self.session = requests.Session()
self.last_state: Optional[SimulationState] = None
self.connected = False
self._retry_count = 0
self._max_retries = 3
def _request(
self,
method: str,
endpoint: str,
json: Optional[dict] = None,
timeout: float = 5.0,
) -> Optional[dict]:
"""Make an HTTP request to the API."""
url = f"{self.api_url}{endpoint}"
try:
response = self.session.request(
method=method,
url=url,
json=json,
timeout=timeout,
)
response.raise_for_status()
self.connected = True
self._retry_count = 0
return response.json()
except RequestException as e:
self._retry_count += 1
if self._retry_count >= self._max_retries:
self.connected = False
return None
def check_connection(self) -> bool:
"""Check if the backend is reachable."""
try:
response = self.session.get(
f"{self.base_url}/health",
timeout=2.0,
)
self.connected = response.status_code == 200
return self.connected
except RequestException:
self.connected = False
return False
def get_state(self) -> Optional[SimulationState]:
"""Fetch the current simulation state."""
data = self._request("GET", "/state")
if data:
self.last_state = SimulationState.from_api_response(data)
return self.last_state
return self.last_state # Return cached state if request failed
def advance_turn(self) -> bool:
"""Advance the simulation by one step."""
result = self._request("POST", "/control/next_step")
return result is not None and result.get("success", False)
def set_mode(self, mode: str) -> bool:
"""Set the simulation mode ('manual' or 'auto')."""
result = self._request("POST", "/control/mode", json={"mode": mode})
return result is not None and result.get("success", False)
def initialize(
self,
num_agents: int = 8,
world_width: int = 20,
world_height: int = 20,
) -> bool:
"""Initialize or reset the simulation."""
result = self._request("POST", "/control/initialize", json={
"num_agents": num_agents,
"world_width": world_width,
"world_height": world_height,
})
return result is not None and result.get("success", False)
def get_status(self) -> Optional[dict]:
"""Get simulation status."""
return self._request("GET", "/control/status")
def get_agents(self) -> Optional[list[dict]]:
"""Get all agents."""
result = self._request("GET", "/agents")
if result:
return result.get("agents", [])
return None
def get_market_orders(self) -> Optional[list[dict]]:
"""Get all market orders."""
result = self._request("GET", "/market/orders")
if result:
return result.get("orders", [])
return None
def get_market_prices(self) -> Optional[dict]:
"""Get market prices."""
return self._request("GET", "/market/prices")
def wait_for_connection(self, timeout: float = 30.0) -> bool:
"""Wait for backend connection with timeout."""
start = time.time()
while time.time() - start < timeout:
if self.check_connection():
return True
time.sleep(0.5)
return False
def get_config(self) -> Optional[dict]:
"""Get current simulation configuration."""
return self._request("GET", "/config")
def update_config(self, config_data: dict) -> bool:
"""Update simulation configuration."""
result = self._request("POST", "/config", json=config_data)
return result is not None and result.get("success", False)
def reset_config(self) -> bool:
"""Reset configuration to defaults."""
result = self._request("POST", "/config/reset")
return result is not None and result.get("success", False)

View File

@ -1,324 +0,0 @@
"""Main Pygame application for the Village Simulation frontend."""
import sys
import pygame
from frontend.client import SimulationClient, SimulationState
from frontend.renderer.map_renderer import MapRenderer
from frontend.renderer.agent_renderer import AgentRenderer
from frontend.renderer.ui_renderer import UIRenderer
from frontend.renderer.settings_renderer import SettingsRenderer
from frontend.renderer.stats_renderer import StatsRenderer
# Window configuration
WINDOW_WIDTH = 1200
WINDOW_HEIGHT = 800
WINDOW_TITLE = "Village Economy Simulation"
FPS = 30
# Layout configuration
TOP_PANEL_HEIGHT = 50
RIGHT_PANEL_WIDTH = 200
class VillageSimulationApp:
"""Main application class for the Village Simulation frontend."""
def __init__(self, server_url: str = "http://localhost:8000"):
# Initialize Pygame
pygame.init()
pygame.font.init()
# Create window
self.screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT))
pygame.display.set_caption(WINDOW_TITLE)
# Clock for FPS control
self.clock = pygame.time.Clock()
# Fonts
self.font = pygame.font.Font(None, 24)
# Network client
self.client = SimulationClient(server_url)
# Calculate map area
self.map_rect = pygame.Rect(
0,
TOP_PANEL_HEIGHT,
WINDOW_WIDTH - RIGHT_PANEL_WIDTH,
WINDOW_HEIGHT - TOP_PANEL_HEIGHT,
)
# Initialize renderers
self.map_renderer = MapRenderer(self.screen, self.map_rect)
self.agent_renderer = AgentRenderer(self.screen, self.map_renderer, self.font)
self.ui_renderer = UIRenderer(self.screen, self.font)
self.settings_renderer = SettingsRenderer(self.screen)
self.stats_renderer = StatsRenderer(self.screen)
# State
self.state: SimulationState | None = None
self.running = True
self.hovered_agent: dict | None = None
self._last_turn: int = -1 # Track turn changes for stats update
# Polling interval (ms)
self.last_poll_time = 0
self.poll_interval = 100 # Poll every 100ms for smoother updates
# Setup settings callbacks
self._setup_settings_callbacks()
def _setup_settings_callbacks(self) -> None:
"""Set up callbacks for the settings panel."""
# Override the apply and reset callbacks
original_apply = self.settings_renderer._apply_config
original_reset = self.settings_renderer._reset_config
def apply_config():
config = self.settings_renderer.get_config()
if self.client.update_config(config):
# Restart simulation with new config
if self.client.initialize():
self.state = self.client.get_state()
self.settings_renderer.status_message = "Config applied & simulation restarted!"
self.settings_renderer.status_color = (80, 180, 100)
else:
self.settings_renderer.status_message = "Config saved but restart failed"
self.settings_renderer.status_color = (200, 160, 80)
else:
self.settings_renderer.status_message = "Failed to apply config"
self.settings_renderer.status_color = (200, 80, 80)
def reset_config():
if self.client.reset_config():
# Reload config from server
config = self.client.get_config()
if config:
self.settings_renderer.set_config(config)
self.settings_renderer.status_message = "Config reset to defaults"
self.settings_renderer.status_color = (200, 160, 80)
else:
self.settings_renderer.status_message = "Failed to reset config"
self.settings_renderer.status_color = (200, 80, 80)
self.settings_renderer._apply_config = apply_config
self.settings_renderer._reset_config = reset_config
def _load_config(self) -> None:
"""Load configuration from server into settings panel."""
config = self.client.get_config()
if config:
self.settings_renderer.set_config(config)
def handle_events(self) -> None:
"""Handle Pygame events."""
for event in pygame.event.get():
if event.type == pygame.QUIT:
self.running = False
# Let stats panel handle events first if visible
if self.stats_renderer.handle_event(event):
continue
# Let settings panel handle events first if visible
if self.settings_renderer.handle_event(event):
continue
if event.type == pygame.KEYDOWN:
self._handle_keydown(event)
elif event.type == pygame.MOUSEMOTION:
self._handle_mouse_motion(event)
def _handle_keydown(self, event: pygame.event.Event) -> None:
"""Handle keyboard input."""
if event.key == pygame.K_ESCAPE:
if self.stats_renderer.visible:
self.stats_renderer.toggle()
elif self.settings_renderer.visible:
self.settings_renderer.toggle()
else:
self.running = False
elif event.key == pygame.K_SPACE:
# Advance one turn
if self.client.connected and not self.settings_renderer.visible and not self.stats_renderer.visible:
if self.client.advance_turn():
# Immediately fetch new state
self.state = self.client.get_state()
elif event.key == pygame.K_r:
# Reset simulation
if self.client.connected and not self.settings_renderer.visible and not self.stats_renderer.visible:
if self.client.initialize():
self.state = self.client.get_state()
self.stats_renderer.clear_history()
self._last_turn = -1
elif event.key == pygame.K_m:
# Toggle mode
if self.client.connected and self.state and not self.settings_renderer.visible and not self.stats_renderer.visible:
new_mode = "auto" if self.state.mode == "manual" else "manual"
if self.client.set_mode(new_mode):
self.state = self.client.get_state()
elif event.key == pygame.K_g:
# Toggle statistics/graphs panel
if not self.settings_renderer.visible:
self.stats_renderer.toggle()
elif event.key == pygame.K_s:
# Toggle settings panel
if not self.stats_renderer.visible:
if not self.settings_renderer.visible:
self._load_config()
self.settings_renderer.toggle()
def _handle_mouse_motion(self, event: pygame.event.Event) -> None:
"""Handle mouse motion for agent hover detection."""
if not self.state or self.settings_renderer.visible:
self.hovered_agent = None
return
mouse_pos = event.pos
self.hovered_agent = None
# Check if mouse is in map area
if not self.map_rect.collidepoint(mouse_pos):
return
# Check each agent
for agent in self.state.agents:
if not agent.get("is_alive", False):
continue
pos = agent.get("position", {"x": 0, "y": 0})
screen_x, screen_y = self.map_renderer.grid_to_screen(pos["x"], pos["y"])
# Check if mouse is near agent
dx = mouse_pos[0] - screen_x
dy = mouse_pos[1] - screen_y
distance = (dx * dx + dy * dy) ** 0.5
cell_w, cell_h = self.map_renderer.get_cell_size()
agent_radius = min(cell_w, cell_h) / 2
if distance < agent_radius + 5:
self.hovered_agent = agent
break
def update(self) -> None:
"""Update game state by polling the server."""
current_time = pygame.time.get_ticks()
# Check if we need to poll
if current_time - self.last_poll_time >= self.poll_interval:
self.last_poll_time = current_time
if not self.client.connected:
self.client.check_connection()
if self.client.connected:
new_state = self.client.get_state()
if new_state:
# Update map dimensions if changed
if (
new_state.world_width != self.map_renderer.world_width or
new_state.world_height != self.map_renderer.world_height
):
self.map_renderer.update_dimensions(
new_state.world_width,
new_state.world_height,
)
self.state = new_state
# Update stats history when turn changes
if new_state.turn != self._last_turn:
self.stats_renderer.update_history(new_state)
self._last_turn = new_state.turn
def draw(self) -> None:
"""Draw all elements."""
# Clear screen
self.screen.fill((30, 35, 45))
if self.state:
# Draw map
self.map_renderer.draw(self.state)
# Draw agents
self.agent_renderer.draw(self.state)
# Draw UI
self.ui_renderer.draw(self.state)
# Draw agent tooltip if hovering
if self.hovered_agent and not self.settings_renderer.visible:
mouse_pos = pygame.mouse.get_pos()
self.agent_renderer.draw_agent_tooltip(self.hovered_agent, mouse_pos)
# Draw connection status overlay if disconnected
if not self.client.connected:
self.ui_renderer.draw_connection_status(self.client.connected)
# Draw settings panel if visible
self.settings_renderer.draw()
# Draw stats panel if visible
self.stats_renderer.draw(self.state)
# Draw hints at bottom
if not self.settings_renderer.visible and not self.stats_renderer.visible:
hint_font = pygame.font.Font(None, 18)
hint = hint_font.render("S: Settings | G: Statistics & Graphs", True, (100, 100, 120))
self.screen.blit(hint, (5, self.screen.get_height() - 20))
# Update display
pygame.display.flip()
def run(self) -> None:
"""Main game loop."""
print("Starting Village Simulation Frontend...")
print("Connecting to backend at http://localhost:8000...")
# Try to connect initially
if not self.client.check_connection():
print("Backend not available. Will retry in the main loop.")
else:
print("Connected!")
self.state = self.client.get_state()
print("\nControls:")
print(" SPACE - Advance turn")
print(" R - Reset simulation")
print(" M - Toggle auto/manual mode")
print(" S - Open settings")
print(" G - Open statistics & graphs")
print(" ESC - Close panel / Quit")
print()
while self.running:
self.handle_events()
self.update()
self.draw()
self.clock.tick(FPS)
pygame.quit()
def main():
"""Entry point for the frontend application."""
# Get server URL from command line if provided
server_url = "http://localhost:8000"
if len(sys.argv) > 1:
server_url = sys.argv[1]
app = VillageSimulationApp(server_url)
app.run()
if __name__ == "__main__":
main()

View File

@ -1,9 +0,0 @@
"""Renderer components for the Village Simulation frontend."""
from .map_renderer import MapRenderer
from .agent_renderer import AgentRenderer
from .ui_renderer import UIRenderer
from .settings_renderer import SettingsRenderer
__all__ = ["MapRenderer", "AgentRenderer", "UIRenderer", "SettingsRenderer"]

View File

@ -1,430 +0,0 @@
"""Agent renderer for the Village Simulation."""
import math
import pygame
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frontend.client import SimulationState
from frontend.renderer.map_renderer import MapRenderer
# Profession colors (villager is the default now)
PROFESSION_COLORS = {
"villager": (100, 140, 180), # Blue-gray for generic villager
"hunter": (180, 80, 80), # Red
"gatherer": (80, 160, 80), # Green
"woodcutter": (139, 90, 43), # Brown
"crafter": (160, 120, 200), # Purple
}
# Corpse color
CORPSE_COLOR = (60, 60, 60) # Dark gray
# Status bar colors
BAR_COLORS = {
"energy": (255, 220, 80), # Yellow
"hunger": (220, 140, 80), # Orange
"thirst": (80, 160, 220), # Blue
"heat": (220, 80, 80), # Red
}
# Action icons/symbols
ACTION_SYMBOLS = {
"hunt": "🏹",
"gather": "🍇",
"chop_wood": "🪓",
"get_water": "💧",
"weave": "🧵",
"build_fire": "🔥",
"trade": "💰",
"rest": "💤",
"sleep": "😴",
"consume": "🍖",
"dead": "💀",
}
# Fallback ASCII symbols for systems without emoji support
ACTION_LETTERS = {
"hunt": "H",
"gather": "G",
"chop_wood": "W",
"get_water": "~",
"weave": "C",
"build_fire": "F",
"trade": "$",
"rest": "R",
"sleep": "Z",
"consume": "E",
"dead": "X",
}
class AgentRenderer:
"""Renders agents on the map with movement and action indicators."""
def __init__(
self,
screen: pygame.Surface,
map_renderer: "MapRenderer",
font: pygame.font.Font,
):
self.screen = screen
self.map_renderer = map_renderer
self.font = font
self.small_font = pygame.font.Font(None, 16)
self.action_font = pygame.font.Font(None, 20)
# Animation state
self.animation_tick = 0
def _get_agent_color(self, agent: dict) -> tuple[int, int, int]:
"""Get the color for an agent based on state."""
# Corpses are dark gray
if agent.get("is_corpse", False) or not agent.get("is_alive", True):
return CORPSE_COLOR
profession = agent.get("profession", "villager")
base_color = PROFESSION_COLORS.get(profession, (100, 140, 180))
if not agent.get("can_act", True):
# Slightly dimmed for exhausted agents
return tuple(int(c * 0.7) for c in base_color)
return base_color
def _draw_status_bar(
self,
x: int,
y: int,
width: int,
height: int,
value: int,
max_value: int,
color: tuple[int, int, int],
) -> None:
"""Draw a single status bar."""
# Background
pygame.draw.rect(self.screen, (40, 40, 40), (x, y, width, height))
# Fill
fill_width = int((value / max_value) * width) if max_value > 0 else 0
if fill_width > 0:
pygame.draw.rect(self.screen, color, (x, y, fill_width, height))
# Border
pygame.draw.rect(self.screen, (80, 80, 80), (x, y, width, height), 1)
def _draw_status_bars(self, agent: dict, center_x: int, center_y: int, size: int) -> None:
"""Draw status bars below the agent."""
stats = agent.get("stats", {})
bar_width = size + 10
bar_height = 3
bar_spacing = 4
start_y = center_y + size // 2 + 4
bars = [
("energy", stats.get("energy", 0), stats.get("max_energy", 100)),
("hunger", stats.get("hunger", 0), stats.get("max_hunger", 100)),
("thirst", stats.get("thirst", 0), stats.get("max_thirst", 50)),
("heat", stats.get("heat", 0), stats.get("max_heat", 100)),
]
for i, (stat_name, value, max_value) in enumerate(bars):
bar_y = start_y + i * bar_spacing
self._draw_status_bar(
center_x - bar_width // 2,
bar_y,
bar_width,
bar_height,
value,
max_value,
BAR_COLORS[stat_name],
)
def _draw_action_indicator(
self,
agent: dict,
center_x: int,
center_y: int,
agent_size: int,
) -> None:
"""Draw action indicator above the agent."""
current_action = agent.get("current_action", {})
action_type = current_action.get("action_type", "")
is_moving = current_action.get("is_moving", False)
message = current_action.get("message", "")
if not action_type:
return
# Get action symbol
symbol = ACTION_LETTERS.get(action_type, "?")
# Draw action bubble above agent
bubble_y = center_y - agent_size // 2 - 20
# Animate if moving
if is_moving:
# Bouncing animation
offset = int(3 * math.sin(self.animation_tick * 0.3))
bubble_y += offset
# Draw bubble background
bubble_width = 22
bubble_height = 18
bubble_rect = pygame.Rect(
center_x - bubble_width // 2,
bubble_y - bubble_height // 2,
bubble_width,
bubble_height,
)
# Color based on action success/failure
if "Failed" in message:
bg_color = (120, 60, 60)
border_color = (180, 80, 80)
elif is_moving:
bg_color = (60, 80, 120)
border_color = (100, 140, 200)
else:
bg_color = (50, 70, 50)
border_color = (80, 140, 80)
pygame.draw.rect(self.screen, bg_color, bubble_rect, border_radius=4)
pygame.draw.rect(self.screen, border_color, bubble_rect, 1, border_radius=4)
# Draw action letter
text = self.action_font.render(symbol, True, (255, 255, 255))
text_rect = text.get_rect(center=(center_x, bubble_y))
self.screen.blit(text, text_rect)
# Draw movement trail if moving
if is_moving:
target_pos = current_action.get("target_position")
if target_pos:
target_x, target_y = self.map_renderer.grid_to_screen(
target_pos.get("x", 0),
target_pos.get("y", 0),
)
# Draw dotted line to target
self._draw_dotted_line(
(center_x, center_y),
(target_x, target_y),
(100, 100, 100),
4,
)
def _draw_dotted_line(
self,
start: tuple[int, int],
end: tuple[int, int],
color: tuple[int, int, int],
dot_spacing: int = 5,
) -> None:
"""Draw a dotted line between two points."""
dx = end[0] - start[0]
dy = end[1] - start[1]
distance = max(1, int((dx ** 2 + dy ** 2) ** 0.5))
for i in range(0, distance, dot_spacing * 2):
t = i / distance
x = int(start[0] + dx * t)
y = int(start[1] + dy * t)
pygame.draw.circle(self.screen, color, (x, y), 1)
def _draw_last_action_result(
self,
agent: dict,
center_x: int,
center_y: int,
agent_size: int,
) -> None:
"""Draw the last action result as floating text."""
result = agent.get("last_action_result", "")
if not result:
return
# Truncate long messages
if len(result) > 25:
result = result[:22] + "..."
# Draw text below status bars
text_y = center_y + agent_size // 2 + 22
text = self.small_font.render(result, True, (180, 180, 180))
text_rect = text.get_rect(center=(center_x, text_y))
# Background for readability
bg_rect = text_rect.inflate(4, 2)
pygame.draw.rect(self.screen, (30, 30, 40, 180), bg_rect)
self.screen.blit(text, text_rect)
def draw(self, state: "SimulationState") -> None:
"""Draw all agents (including corpses for one turn)."""
self.animation_tick += 1
cell_w, cell_h = self.map_renderer.get_cell_size()
agent_size = min(cell_w, cell_h) - 8
agent_size = max(10, min(agent_size, 30)) # Clamp size
for agent in state.agents:
is_corpse = agent.get("is_corpse", False)
is_alive = agent.get("is_alive", True)
# Get screen position from agent's current position
pos = agent.get("position", {"x": 0, "y": 0})
screen_x, screen_y = self.map_renderer.grid_to_screen(pos["x"], pos["y"])
if is_corpse:
# Draw corpse with death indicator
self._draw_corpse(agent, screen_x, screen_y, agent_size)
continue
if not is_alive:
continue
# Draw movement trail/line to target first (behind agent)
self._draw_action_indicator(agent, screen_x, screen_y, agent_size)
# Draw agent circle
color = self._get_agent_color(agent)
pygame.draw.circle(self.screen, color, (screen_x, screen_y), agent_size // 2)
# Draw border - animated if moving
current_action = agent.get("current_action", {})
is_moving = current_action.get("is_moving", False)
if is_moving:
# Pulsing border when moving
pulse = int(127 + 127 * math.sin(self.animation_tick * 0.2))
border_color = (pulse, pulse, 255)
elif agent.get("can_act"):
border_color = (255, 255, 255)
else:
border_color = (100, 100, 100)
pygame.draw.circle(self.screen, border_color, (screen_x, screen_y), agent_size // 2, 2)
# Draw money indicator (small coin icon)
money = agent.get("money", 0)
if money > 0:
coin_x = screen_x + agent_size // 2 - 4
coin_y = screen_y - agent_size // 2 - 4
pygame.draw.circle(self.screen, (255, 215, 0), (coin_x, coin_y), 4)
pygame.draw.circle(self.screen, (200, 160, 0), (coin_x, coin_y), 4, 1)
# Draw "V" for villager
text = self.small_font.render("V", True, (255, 255, 255))
text_rect = text.get_rect(center=(screen_x, screen_y))
self.screen.blit(text, text_rect)
# Draw status bars
self._draw_status_bars(agent, screen_x, screen_y, agent_size)
# Draw last action result
self._draw_last_action_result(agent, screen_x, screen_y, agent_size)
def _draw_corpse(
self,
agent: dict,
center_x: int,
center_y: int,
agent_size: int,
) -> None:
"""Draw a corpse with death reason displayed."""
# Draw corpse circle (dark gray)
pygame.draw.circle(self.screen, CORPSE_COLOR, (center_x, center_y), agent_size // 2)
# Draw red X border
pygame.draw.circle(self.screen, (150, 50, 50), (center_x, center_y), agent_size // 2, 2)
# Draw skull symbol
text = self.action_font.render("X", True, (180, 80, 80))
text_rect = text.get_rect(center=(center_x, center_y))
self.screen.blit(text, text_rect)
# Draw death reason above corpse
death_reason = agent.get("death_reason", "unknown")
name = agent.get("name", "Unknown")
# Death indicator bubble
bubble_y = center_y - agent_size // 2 - 20
bubble_text = f"💀 {death_reason}"
text = self.small_font.render(bubble_text, True, (255, 100, 100))
text_rect = text.get_rect(center=(center_x, bubble_y))
# Background for readability
bg_rect = text_rect.inflate(8, 4)
pygame.draw.rect(self.screen, (40, 20, 20), bg_rect, border_radius=3)
pygame.draw.rect(self.screen, (120, 50, 50), bg_rect, 1, border_radius=3)
self.screen.blit(text, text_rect)
# Draw name below
name_y = center_y + agent_size // 2 + 8
name_text = self.small_font.render(name, True, (150, 150, 150))
name_rect = name_text.get_rect(center=(center_x, name_y))
self.screen.blit(name_text, name_rect)
def draw_agent_tooltip(self, agent: dict, mouse_pos: tuple[int, int]) -> None:
"""Draw a tooltip for an agent when hovered."""
# Build tooltip text
lines = [
agent.get("name", "Unknown"),
f"Profession: {agent.get('profession', '?').capitalize()}",
f"Money: {agent.get('money', 0)} coins",
"",
]
# Current action
current_action = agent.get("current_action", {})
action_type = current_action.get("action_type", "")
if action_type:
action_msg = current_action.get("message", action_type)
lines.append(f"Action: {action_msg[:40]}")
if current_action.get("is_moving"):
lines.append(" (moving to location)")
lines.append("")
lines.append("Stats:")
stats = agent.get("stats", {})
lines.append(f" Energy: {stats.get('energy', 0)}/{stats.get('max_energy', 100)}")
lines.append(f" Hunger: {stats.get('hunger', 0)}/{stats.get('max_hunger', 100)}")
lines.append(f" Thirst: {stats.get('thirst', 0)}/{stats.get('max_thirst', 50)}")
lines.append(f" Heat: {stats.get('heat', 0)}/{stats.get('max_heat', 100)}")
inventory = agent.get("inventory", [])
if inventory:
lines.append("")
lines.append("Inventory:")
for item in inventory[:5]:
lines.append(f" {item.get('type', '?')}: {item.get('quantity', 0)}")
# Last action result
last_result = agent.get("last_action_result", "")
if last_result:
lines.append("")
lines.append(f"Last: {last_result[:35]}")
# Calculate tooltip size
line_height = 16
max_width = max(self.small_font.size(line)[0] for line in lines) + 20
height = len(lines) * line_height + 10
# Position tooltip near mouse but not off screen
x = min(mouse_pos[0] + 15, self.screen.get_width() - max_width - 5)
y = min(mouse_pos[1] + 15, self.screen.get_height() - height - 5)
# Draw background
tooltip_rect = pygame.Rect(x, y, max_width, height)
pygame.draw.rect(self.screen, (30, 30, 40), tooltip_rect)
pygame.draw.rect(self.screen, (100, 100, 120), tooltip_rect, 1)
# Draw text
for i, line in enumerate(lines):
text = self.small_font.render(line, True, (220, 220, 220))
self.screen.blit(text, (x + 10, y + 5 + i * line_height))

View File

@ -1,146 +0,0 @@
"""Map renderer for the Village Simulation."""
import pygame
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frontend.client import SimulationState
# Color palette
class Colors:
# Background colors
DAY_BG = (180, 200, 160) # Soft green for day
NIGHT_BG = (40, 45, 60) # Dark blue for night
GRID_LINE = (120, 140, 110) # Subtle grid lines
GRID_LINE_NIGHT = (60, 65, 80)
# Terrain features (for visual variety)
GRASS_LIGHT = (160, 190, 140)
GRASS_DARK = (140, 170, 120)
WATER_SPOT = (100, 140, 180)
class MapRenderer:
"""Renders the map/terrain background."""
def __init__(
self,
screen: pygame.Surface,
map_rect: pygame.Rect,
world_width: int = 20,
world_height: int = 20,
):
self.screen = screen
self.map_rect = map_rect
self.world_width = world_width
self.world_height = world_height
self._cell_width = map_rect.width / world_width
self._cell_height = map_rect.height / world_height
# Pre-generate some terrain variation
self._terrain_cache = self._generate_terrain()
def _generate_terrain(self) -> list[list[int]]:
"""Generate simple terrain variation (0 = light, 1 = dark, 2 = water)."""
import random
terrain = []
for y in range(self.world_height):
row = []
for x in range(self.world_width):
# Simple pattern: mostly grass with occasional water spots
if random.random() < 0.05:
row.append(2) # Water spot
elif (x + y) % 3 == 0:
row.append(1) # Dark grass
else:
row.append(0) # Light grass
terrain.append(row)
return terrain
def update_dimensions(self, world_width: int, world_height: int) -> None:
"""Update world dimensions and recalculate cell sizes."""
if world_width != self.world_width or world_height != self.world_height:
self.world_width = world_width
self.world_height = world_height
self._cell_width = self.map_rect.width / world_width
self._cell_height = self.map_rect.height / world_height
self._terrain_cache = self._generate_terrain()
def grid_to_screen(self, grid_x: int, grid_y: int) -> tuple[int, int]:
"""Convert grid coordinates to screen coordinates (center of cell)."""
screen_x = self.map_rect.left + (grid_x + 0.5) * self._cell_width
screen_y = self.map_rect.top + (grid_y + 0.5) * self._cell_height
return int(screen_x), int(screen_y)
def get_cell_size(self) -> tuple[int, int]:
"""Get the size of a single cell."""
return int(self._cell_width), int(self._cell_height)
def draw(self, state: "SimulationState") -> None:
"""Draw the map background."""
is_night = state.time_of_day == "night"
# Fill background
bg_color = Colors.NIGHT_BG if is_night else Colors.DAY_BG
pygame.draw.rect(self.screen, bg_color, self.map_rect)
# Draw terrain cells
for y in range(self.world_height):
for x in range(self.world_width):
cell_rect = pygame.Rect(
self.map_rect.left + x * self._cell_width,
self.map_rect.top + y * self._cell_height,
self._cell_width + 1, # +1 to avoid gaps
self._cell_height + 1,
)
terrain_type = self._terrain_cache[y][x]
if is_night:
# Darker colors at night
if terrain_type == 2:
color = (60, 80, 110)
elif terrain_type == 1:
color = (35, 40, 55)
else:
color = (45, 50, 65)
else:
if terrain_type == 2:
color = Colors.WATER_SPOT
elif terrain_type == 1:
color = Colors.GRASS_DARK
else:
color = Colors.GRASS_LIGHT
pygame.draw.rect(self.screen, color, cell_rect)
# Draw grid lines
grid_color = Colors.GRID_LINE_NIGHT if is_night else Colors.GRID_LINE
# Vertical lines
for x in range(self.world_width + 1):
start_x = self.map_rect.left + x * self._cell_width
pygame.draw.line(
self.screen,
grid_color,
(start_x, self.map_rect.top),
(start_x, self.map_rect.bottom),
1,
)
# Horizontal lines
for y in range(self.world_height + 1):
start_y = self.map_rect.top + y * self._cell_height
pygame.draw.line(
self.screen,
grid_color,
(self.map_rect.left, start_y),
(self.map_rect.right, start_y),
1,
)
# Draw border
border_color = (80, 90, 70) if not is_night else (80, 85, 100)
pygame.draw.rect(self.screen, border_color, self.map_rect, 2)

View File

@ -1,448 +0,0 @@
"""Settings UI renderer with sliders for the Village Simulation."""
import pygame
from dataclasses import dataclass
from typing import Optional, Callable, Any
class Colors:
"""Color palette for settings UI."""
BG = (25, 28, 35)
PANEL_BG = (35, 40, 50)
PANEL_BORDER = (70, 80, 95)
TEXT_PRIMARY = (230, 230, 235)
TEXT_SECONDARY = (160, 165, 175)
TEXT_HIGHLIGHT = (100, 180, 255)
SLIDER_BG = (50, 55, 65)
SLIDER_FILL = (80, 140, 200)
SLIDER_HANDLE = (220, 220, 230)
BUTTON_BG = (60, 100, 160)
BUTTON_HOVER = (80, 120, 180)
BUTTON_TEXT = (255, 255, 255)
SUCCESS = (80, 180, 100)
WARNING = (200, 160, 80)
@dataclass
class SliderConfig:
"""Configuration for a slider widget."""
name: str
key: str # Dot-separated path like "agent_stats.max_energy"
min_val: float
max_val: float
step: float = 1.0
is_int: bool = True
description: str = ""
# Define all configurable parameters with sliders
SLIDER_CONFIGS = [
# Agent Stats Section
SliderConfig("Max Energy", "agent_stats.max_energy", 50, 200, 10, True, "Maximum energy capacity"),
SliderConfig("Max Hunger", "agent_stats.max_hunger", 50, 200, 10, True, "Maximum hunger capacity"),
SliderConfig("Max Thirst", "agent_stats.max_thirst", 25, 100, 5, True, "Maximum thirst capacity"),
SliderConfig("Max Heat", "agent_stats.max_heat", 50, 200, 10, True, "Maximum heat capacity"),
SliderConfig("Energy Decay", "agent_stats.energy_decay", 1, 10, 1, True, "Energy lost per turn"),
SliderConfig("Hunger Decay", "agent_stats.hunger_decay", 1, 10, 1, True, "Hunger lost per turn"),
SliderConfig("Thirst Decay", "agent_stats.thirst_decay", 1, 10, 1, True, "Thirst lost per turn"),
SliderConfig("Heat Decay", "agent_stats.heat_decay", 1, 10, 1, True, "Heat lost per turn"),
SliderConfig("Critical %", "agent_stats.critical_threshold", 0.1, 0.5, 0.05, False, "Threshold for survival mode"),
# World Section
SliderConfig("World Width", "world.width", 10, 50, 5, True, "World grid width"),
SliderConfig("World Height", "world.height", 10, 50, 5, True, "World grid height"),
SliderConfig("Initial Agents", "world.initial_agents", 2, 20, 1, True, "Starting agent count"),
SliderConfig("Day Steps", "world.day_steps", 5, 20, 1, True, "Steps per day"),
SliderConfig("Inventory Slots", "world.inventory_slots", 5, 20, 1, True, "Agent inventory size"),
SliderConfig("Starting Money", "world.starting_money", 50, 500, 50, True, "Initial coins per agent"),
# Actions Section
SliderConfig("Hunt Energy Cost", "actions.hunt_energy", -30, -5, 5, True, "Energy spent hunting"),
SliderConfig("Gather Energy Cost", "actions.gather_energy", -20, -1, 1, True, "Energy spent gathering"),
SliderConfig("Hunt Success %", "actions.hunt_success", 0.3, 1.0, 0.1, False, "Hunting success chance"),
SliderConfig("Sleep Restore", "actions.sleep_energy", 30, 100, 10, True, "Energy restored by sleep"),
SliderConfig("Rest Restore", "actions.rest_energy", 5, 30, 5, True, "Energy restored by rest"),
# Resources Section
SliderConfig("Meat Decay", "resources.meat_decay", 2, 20, 1, True, "Turns until meat spoils"),
SliderConfig("Berries Decay", "resources.berries_decay", 10, 50, 5, True, "Turns until berries spoil"),
SliderConfig("Meat Hunger +", "resources.meat_hunger", 10, 60, 5, True, "Hunger restored by meat"),
SliderConfig("Water Thirst +", "resources.water_thirst", 20, 60, 5, True, "Thirst restored by water"),
# Market Section
SliderConfig("Discount Turns", "market.turns_before_discount", 1, 10, 1, True, "Turns before price drop"),
SliderConfig("Discount Rate %", "market.discount_rate", 0.05, 0.30, 0.05, False, "Price reduction per period"),
# Simulation Section
SliderConfig("Auto Step (s)", "auto_step_interval", 0.2, 3.0, 0.2, False, "Seconds between auto steps"),
]
class Slider:
"""A slider widget for adjusting numeric values."""
def __init__(
self,
rect: pygame.Rect,
config: SliderConfig,
font: pygame.font.Font,
small_font: pygame.font.Font,
):
self.rect = rect
self.config = config
self.font = font
self.small_font = small_font
self.value = config.min_val
self.dragging = False
self.hovered = False
def set_value(self, value: float) -> None:
"""Set the slider value."""
self.value = max(self.config.min_val, min(self.config.max_val, value))
if self.config.is_int:
self.value = int(round(self.value))
def get_value(self) -> Any:
"""Get the current value."""
return int(self.value) if self.config.is_int else round(self.value, 2)
def handle_event(self, event: pygame.event.Event) -> bool:
"""Handle input events. Returns True if value changed."""
if event.type == pygame.MOUSEBUTTONDOWN:
if self._slider_area().collidepoint(event.pos):
self.dragging = True
return self._update_from_mouse(event.pos[0])
elif event.type == pygame.MOUSEBUTTONUP:
self.dragging = False
elif event.type == pygame.MOUSEMOTION:
self.hovered = self.rect.collidepoint(event.pos)
if self.dragging:
return self._update_from_mouse(event.pos[0])
return False
def _slider_area(self) -> pygame.Rect:
"""Get the actual slider track area."""
return pygame.Rect(
self.rect.x + 120, # Leave space for label
self.rect.y + 15,
self.rect.width - 180, # Leave space for value display
20,
)
def _update_from_mouse(self, mouse_x: int) -> bool:
"""Update value based on mouse position."""
slider_area = self._slider_area()
# Calculate position as 0-1
rel_x = mouse_x - slider_area.x
ratio = max(0, min(1, rel_x / slider_area.width))
# Calculate value
range_val = self.config.max_val - self.config.min_val
new_value = self.config.min_val + ratio * range_val
# Apply step
if self.config.step > 0:
new_value = round(new_value / self.config.step) * self.config.step
old_value = self.value
self.set_value(new_value)
return abs(old_value - self.value) > 0.001
def draw(self, screen: pygame.Surface) -> None:
"""Draw the slider."""
# Background
if self.hovered:
pygame.draw.rect(screen, (45, 50, 60), self.rect)
# Label
label = self.small_font.render(self.config.name, True, Colors.TEXT_PRIMARY)
screen.blit(label, (self.rect.x + 5, self.rect.y + 5))
# Slider track
slider_area = self._slider_area()
pygame.draw.rect(screen, Colors.SLIDER_BG, slider_area, border_radius=3)
# Slider fill
ratio = (self.value - self.config.min_val) / (self.config.max_val - self.config.min_val)
fill_width = int(ratio * slider_area.width)
fill_rect = pygame.Rect(slider_area.x, slider_area.y, fill_width, slider_area.height)
pygame.draw.rect(screen, Colors.SLIDER_FILL, fill_rect, border_radius=3)
# Handle
handle_x = slider_area.x + fill_width
handle_rect = pygame.Rect(handle_x - 4, slider_area.y - 2, 8, slider_area.height + 4)
pygame.draw.rect(screen, Colors.SLIDER_HANDLE, handle_rect, border_radius=2)
# Value display
value_str = str(self.get_value())
value_text = self.small_font.render(value_str, True, Colors.TEXT_HIGHLIGHT)
value_x = self.rect.right - 50
screen.blit(value_text, (value_x, self.rect.y + 5))
# Description on hover
if self.hovered and self.config.description:
desc = self.small_font.render(self.config.description, True, Colors.TEXT_SECONDARY)
screen.blit(desc, (self.rect.x + 5, self.rect.y + 25))
class Button:
"""A simple button widget."""
def __init__(
self,
rect: pygame.Rect,
text: str,
font: pygame.font.Font,
callback: Optional[Callable] = None,
color: tuple = Colors.BUTTON_BG,
):
self.rect = rect
self.text = text
self.font = font
self.callback = callback
self.color = color
self.hovered = False
def handle_event(self, event: pygame.event.Event) -> bool:
"""Handle input events. Returns True if clicked."""
if event.type == pygame.MOUSEMOTION:
self.hovered = self.rect.collidepoint(event.pos)
elif event.type == pygame.MOUSEBUTTONDOWN:
if self.rect.collidepoint(event.pos):
if self.callback:
self.callback()
return True
return False
def draw(self, screen: pygame.Surface) -> None:
"""Draw the button."""
color = Colors.BUTTON_HOVER if self.hovered else self.color
pygame.draw.rect(screen, color, self.rect, border_radius=5)
pygame.draw.rect(screen, Colors.PANEL_BORDER, self.rect, 1, border_radius=5)
text = self.font.render(self.text, True, Colors.BUTTON_TEXT)
text_rect = text.get_rect(center=self.rect.center)
screen.blit(text, text_rect)
class SettingsRenderer:
"""Renders the settings UI panel with sliders."""
def __init__(self, screen: pygame.Surface):
self.screen = screen
self.font = pygame.font.Font(None, 24)
self.small_font = pygame.font.Font(None, 18)
self.title_font = pygame.font.Font(None, 32)
self.visible = False
self.scroll_offset = 0
self.max_scroll = 0
# Create sliders
self.sliders: list[Slider] = []
self.buttons: list[Button] = []
self.config_data: dict = {}
self._create_widgets()
self.status_message = ""
self.status_color = Colors.TEXT_SECONDARY
def _create_widgets(self) -> None:
"""Create slider widgets."""
panel_width = 400
slider_height = 45
start_y = 80
panel_x = (self.screen.get_width() - panel_width) // 2
for i, config in enumerate(SLIDER_CONFIGS):
rect = pygame.Rect(
panel_x + 10,
start_y + i * slider_height,
panel_width - 20,
slider_height,
)
slider = Slider(rect, config, self.font, self.small_font)
self.sliders.append(slider)
# Calculate max scroll
total_height = len(SLIDER_CONFIGS) * slider_height + 150
visible_height = self.screen.get_height() - 150
self.max_scroll = max(0, total_height - visible_height)
# Create buttons at the bottom
button_y = self.screen.get_height() - 60
button_width = 100
button_height = 35
buttons_data = [
("Apply & Restart", self._apply_config, Colors.SUCCESS),
("Reset Defaults", self._reset_config, Colors.WARNING),
("Close", self.toggle, Colors.PANEL_BORDER),
]
total_button_width = len(buttons_data) * button_width + (len(buttons_data) - 1) * 10
start_x = (self.screen.get_width() - total_button_width) // 2
for i, (text, callback, color) in enumerate(buttons_data):
rect = pygame.Rect(
start_x + i * (button_width + 10),
button_y,
button_width,
button_height,
)
self.buttons.append(Button(rect, text, self.small_font, callback, color))
def toggle(self) -> None:
"""Toggle settings visibility."""
self.visible = not self.visible
if self.visible:
self.scroll_offset = 0
def set_config(self, config_data: dict) -> None:
"""Set slider values from config data."""
self.config_data = config_data
for slider in self.sliders:
value = self._get_nested_value(config_data, slider.config.key)
if value is not None:
slider.set_value(value)
def get_config(self) -> dict:
"""Get current config from slider values."""
result = {}
for slider in self.sliders:
self._set_nested_value(result, slider.config.key, slider.get_value())
return result
def _get_nested_value(self, data: dict, key: str) -> Any:
"""Get a value from nested dict using dot notation."""
parts = key.split(".")
current = data
for part in parts:
if isinstance(current, dict) and part in current:
current = current[part]
else:
return None
return current
def _set_nested_value(self, data: dict, key: str, value: Any) -> None:
"""Set a value in nested dict using dot notation."""
parts = key.split(".")
current = data
for part in parts[:-1]:
if part not in current:
current[part] = {}
current = current[part]
current[parts[-1]] = value
def _apply_config(self) -> None:
"""Apply configuration callback (to be set externally)."""
self.status_message = "Config applied - restart to see changes"
self.status_color = Colors.SUCCESS
def _reset_config(self) -> None:
"""Reset configuration callback (to be set externally)."""
self.status_message = "Config reset to defaults"
self.status_color = Colors.WARNING
def handle_event(self, event: pygame.event.Event) -> bool:
"""Handle input events. Returns True if event was consumed."""
if not self.visible:
return False
# Handle scrolling
if event.type == pygame.MOUSEWHEEL:
self.scroll_offset -= event.y * 30
self.scroll_offset = max(0, min(self.max_scroll, self.scroll_offset))
return True
# Handle sliders
for slider in self.sliders:
# Adjust slider position for scroll
original_y = slider.rect.y
slider.rect.y -= self.scroll_offset
if slider.handle_event(event):
slider.rect.y = original_y
return True
slider.rect.y = original_y
# Handle buttons
for button in self.buttons:
if button.handle_event(event):
return True
# Consume all clicks when settings are visible
if event.type == pygame.MOUSEBUTTONDOWN:
return True
return False
def draw(self) -> None:
"""Draw the settings panel."""
if not self.visible:
return
# Dim background
overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 200))
self.screen.blit(overlay, (0, 0))
# Panel background
panel_width = 420
panel_height = self.screen.get_height() - 40
panel_x = (self.screen.get_width() - panel_width) // 2
panel_rect = pygame.Rect(panel_x, 20, panel_width, panel_height)
pygame.draw.rect(self.screen, Colors.PANEL_BG, panel_rect, border_radius=10)
pygame.draw.rect(self.screen, Colors.PANEL_BORDER, panel_rect, 2, border_radius=10)
# Title
title = self.title_font.render("Simulation Settings", True, Colors.TEXT_PRIMARY)
title_rect = title.get_rect(centerx=self.screen.get_width() // 2, y=35)
self.screen.blit(title, title_rect)
# Create clipping region for scrollable area
clip_rect = pygame.Rect(panel_x, 70, panel_width, panel_height - 130)
# Draw sliders with scroll offset
for slider in self.sliders:
# Adjust position for scroll
adjusted_rect = slider.rect.copy()
adjusted_rect.y -= self.scroll_offset
# Only draw if visible
if clip_rect.colliderect(adjusted_rect):
# Temporarily move slider for drawing
original_y = slider.rect.y
slider.rect.y = adjusted_rect.y
slider.draw(self.screen)
slider.rect.y = original_y
# Draw scroll indicator
if self.max_scroll > 0:
scroll_ratio = self.scroll_offset / self.max_scroll
scroll_height = max(30, int((clip_rect.height / (clip_rect.height + self.max_scroll)) * clip_rect.height))
scroll_y = clip_rect.y + int(scroll_ratio * (clip_rect.height - scroll_height))
scroll_rect = pygame.Rect(panel_rect.right - 8, scroll_y, 4, scroll_height)
pygame.draw.rect(self.screen, Colors.SLIDER_FILL, scroll_rect, border_radius=2)
# Draw buttons
for button in self.buttons:
button.draw(self.screen)
# Status message
if self.status_message:
status = self.small_font.render(self.status_message, True, self.status_color)
status_rect = status.get_rect(centerx=self.screen.get_width() // 2, y=self.screen.get_height() - 90)
self.screen.blit(status, status_rect)

View File

@ -1,770 +0,0 @@
"""Real-time statistics and charts renderer for the Village Simulation.
Uses matplotlib to render charts to pygame surfaces for a seamless visualization experience.
"""
import io
from dataclasses import dataclass, field
from collections import deque
from typing import TYPE_CHECKING, Optional
import pygame
import matplotlib
matplotlib.use('Agg') # Use non-interactive backend for pygame integration
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
from matplotlib.figure import Figure
import numpy as np
if TYPE_CHECKING:
from frontend.client import SimulationState
# Color scheme - dark cyberpunk inspired
class ChartColors:
"""Color palette for charts - dark theme with neon accents."""
BG = '#1a1d26'
PANEL = '#252a38'
GRID = '#2f3545'
TEXT = '#e0e0e8'
TEXT_DIM = '#7a7e8c'
# Neon accents for data series
CYAN = '#00d4ff'
MAGENTA = '#ff0099'
LIME = '#39ff14'
ORANGE = '#ff6600'
PURPLE = '#9d4edd'
YELLOW = '#ffcc00'
TEAL = '#00ffa3'
PINK = '#ff1493'
# Series colors for different resources/categories
SERIES = [CYAN, MAGENTA, LIME, ORANGE, PURPLE, YELLOW, TEAL, PINK]
class UIColors:
"""Color palette for pygame UI elements."""
BG = (26, 29, 38)
PANEL_BG = (37, 42, 56)
PANEL_BORDER = (70, 80, 100)
TEXT_PRIMARY = (224, 224, 232)
TEXT_SECONDARY = (122, 126, 140)
TEXT_HIGHLIGHT = (0, 212, 255)
TAB_ACTIVE = (0, 212, 255)
TAB_INACTIVE = (55, 60, 75)
TAB_HOVER = (75, 85, 110)
@dataclass
class HistoryData:
"""Stores historical simulation data for charting."""
max_history: int = 200
# Time series data
turns: deque = field(default_factory=lambda: deque(maxlen=200))
population: deque = field(default_factory=lambda: deque(maxlen=200))
deaths_cumulative: deque = field(default_factory=lambda: deque(maxlen=200))
# Money/Wealth data
total_money: deque = field(default_factory=lambda: deque(maxlen=200))
avg_wealth: deque = field(default_factory=lambda: deque(maxlen=200))
gini_coefficient: deque = field(default_factory=lambda: deque(maxlen=200))
# Price history per resource
prices: dict = field(default_factory=dict) # resource -> deque of prices
# Trade statistics
trade_volume: deque = field(default_factory=lambda: deque(maxlen=200))
# Profession counts over time
professions: dict = field(default_factory=dict) # profession -> deque of counts
def clear(self) -> None:
"""Clear all history data."""
self.turns.clear()
self.population.clear()
self.deaths_cumulative.clear()
self.total_money.clear()
self.avg_wealth.clear()
self.gini_coefficient.clear()
self.prices.clear()
self.trade_volume.clear()
self.professions.clear()
def update(self, state: "SimulationState") -> None:
"""Update history with new state data."""
turn = state.turn
# Avoid duplicate entries for the same turn
if self.turns and self.turns[-1] == turn:
return
self.turns.append(turn)
# Population
living = len([a for a in state.agents if a.get("is_alive", False)])
self.population.append(living)
self.deaths_cumulative.append(state.statistics.get("total_agents_died", 0))
# Wealth data
stats = state.statistics
self.total_money.append(stats.get("total_money_in_circulation", 0))
self.avg_wealth.append(stats.get("avg_money", 0))
self.gini_coefficient.append(stats.get("gini_coefficient", 0))
# Price history from market
for resource, data in state.market_prices.items():
if resource not in self.prices:
self.prices[resource] = deque(maxlen=self.max_history)
# Track lowest price (current market rate)
lowest = data.get("lowest_price")
avg = data.get("avg_sale_price")
# Use lowest price if available, else avg sale price
price = lowest if lowest is not None else avg
self.prices[resource].append(price)
# Trade volume (from recent trades in market orders)
trades = len(state.market_orders) # Active orders as proxy
self.trade_volume.append(trades)
# Profession distribution
professions = stats.get("professions", {})
for prof, count in professions.items():
if prof not in self.professions:
self.professions[prof] = deque(maxlen=self.max_history)
self.professions[prof].append(count)
# Pad missing professions with 0
for prof in self.professions:
if prof not in professions:
self.professions[prof].append(0)
class ChartRenderer:
"""Renders matplotlib charts to pygame surfaces."""
def __init__(self, width: int, height: int):
self.width = width
self.height = height
self.dpi = 100
# Configure matplotlib style
plt.style.use('dark_background')
plt.rcParams.update({
'figure.facecolor': ChartColors.BG,
'axes.facecolor': ChartColors.PANEL,
'axes.edgecolor': ChartColors.GRID,
'axes.labelcolor': ChartColors.TEXT,
'text.color': ChartColors.TEXT,
'xtick.color': ChartColors.TEXT_DIM,
'ytick.color': ChartColors.TEXT_DIM,
'grid.color': ChartColors.GRID,
'grid.alpha': 0.3,
'legend.facecolor': ChartColors.PANEL,
'legend.edgecolor': ChartColors.GRID,
'font.size': 9,
'axes.titlesize': 11,
'axes.titleweight': 'bold',
})
def _fig_to_surface(self, fig: Figure) -> pygame.Surface:
"""Convert a matplotlib figure to a pygame surface."""
buf = io.BytesIO()
fig.savefig(buf, format='png', dpi=self.dpi,
facecolor=ChartColors.BG, edgecolor='none',
bbox_inches='tight', pad_inches=0.1)
buf.seek(0)
surface = pygame.image.load(buf, 'png')
buf.close()
plt.close(fig)
return surface
def render_price_history(self, history: HistoryData, width: int, height: int) -> pygame.Surface:
"""Render price history chart for all resources."""
fig, ax = plt.subplots(figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi)
turns = list(history.turns) if history.turns else [0]
has_data = False
for i, (resource, prices) in enumerate(history.prices.items()):
if prices and any(p is not None for p in prices):
color = ChartColors.SERIES[i % len(ChartColors.SERIES)]
# Filter out None values
valid_prices = [p if p is not None else 0 for p in prices]
# Align with turns
min_len = min(len(turns), len(valid_prices))
ax.plot(list(turns)[-min_len:], valid_prices[-min_len:],
color=color, linewidth=1.5, label=resource.capitalize(), alpha=0.9)
has_data = True
ax.set_title('Market Prices', color=ChartColors.CYAN)
ax.set_xlabel('Turn')
ax.set_ylabel('Price (coins)')
ax.grid(True, alpha=0.2)
if has_data:
ax.legend(loc='upper left', fontsize=8, framealpha=0.8)
ax.set_ylim(bottom=0)
ax.yaxis.set_major_locator(mticker.MaxNLocator(integer=True))
fig.tight_layout()
return self._fig_to_surface(fig)
def render_population(self, history: HistoryData, width: int, height: int) -> pygame.Surface:
"""Render population over time chart."""
fig, ax = plt.subplots(figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi)
turns = list(history.turns) if history.turns else [0]
population = list(history.population) if history.population else [0]
deaths = list(history.deaths_cumulative) if history.deaths_cumulative else [0]
min_len = min(len(turns), len(population))
# Population line
ax.fill_between(turns[-min_len:], population[-min_len:],
alpha=0.3, color=ChartColors.CYAN)
ax.plot(turns[-min_len:], population[-min_len:],
color=ChartColors.CYAN, linewidth=2, label='Living')
# Deaths line
if deaths:
ax.plot(turns[-min_len:], deaths[-min_len:],
color=ChartColors.MAGENTA, linewidth=1.5, linestyle='--',
label='Total Deaths', alpha=0.8)
ax.set_title('Population Over Time', color=ChartColors.LIME)
ax.set_xlabel('Turn')
ax.set_ylabel('Count')
ax.grid(True, alpha=0.2)
ax.legend(loc='upper right', fontsize=8)
ax.set_ylim(bottom=0)
ax.yaxis.set_major_locator(mticker.MaxNLocator(integer=True))
fig.tight_layout()
return self._fig_to_surface(fig)
def render_wealth_distribution(self, state: "SimulationState", width: int, height: int) -> pygame.Surface:
"""Render current wealth distribution as a bar chart."""
fig, ax = plt.subplots(figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi)
# Get agent wealth data
agents = [a for a in state.agents if a.get("is_alive", False)]
if not agents:
ax.text(0.5, 0.5, 'No living agents', ha='center', va='center',
color=ChartColors.TEXT_DIM, fontsize=12)
ax.set_title('Wealth Distribution', color=ChartColors.ORANGE)
fig.tight_layout()
return self._fig_to_surface(fig)
# Sort by wealth
agents_sorted = sorted(agents, key=lambda a: a.get("money", 0), reverse=True)
names = [a.get("name", "?")[:8] for a in agents_sorted]
wealth = [a.get("money", 0) for a in agents_sorted]
# Create gradient colors based on wealth ranking
colors = []
for i in range(len(agents_sorted)):
ratio = i / max(1, len(agents_sorted) - 1)
# Gradient from cyan (rich) to magenta (poor)
r = int(0 + ratio * 255)
g = int(212 - ratio * 212)
b = int(255 - ratio * 102)
colors.append(f'#{r:02x}{g:02x}{b:02x}')
bars = ax.barh(range(len(agents_sorted)), wealth, color=colors, alpha=0.85)
ax.set_yticks(range(len(agents_sorted)))
ax.set_yticklabels(names, fontsize=7)
ax.invert_yaxis() # Rich at top
# Add value labels
for bar, val in zip(bars, wealth):
ax.text(bar.get_width() + 1, bar.get_y() + bar.get_height()/2,
f'{val}', va='center', fontsize=7, color=ChartColors.TEXT_DIM)
ax.set_title('Wealth Distribution', color=ChartColors.ORANGE)
ax.set_xlabel('Coins')
ax.grid(True, alpha=0.2, axis='x')
fig.tight_layout()
return self._fig_to_surface(fig)
def render_wealth_over_time(self, history: HistoryData, width: int, height: int) -> pygame.Surface:
"""Render wealth metrics over time (total money, avg, gini)."""
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(width/self.dpi, height/self.dpi),
dpi=self.dpi, height_ratios=[2, 1])
turns = list(history.turns) if history.turns else [0]
total = list(history.total_money) if history.total_money else [0]
avg = list(history.avg_wealth) if history.avg_wealth else [0]
gini = list(history.gini_coefficient) if history.gini_coefficient else [0]
min_len = min(len(turns), len(total), len(avg))
# Total and average wealth
ax1.plot(turns[-min_len:], total[-min_len:],
color=ChartColors.CYAN, linewidth=2, label='Total Money')
ax1.fill_between(turns[-min_len:], total[-min_len:],
alpha=0.2, color=ChartColors.CYAN)
ax1_twin = ax1.twinx()
ax1_twin.plot(turns[-min_len:], avg[-min_len:],
color=ChartColors.LIME, linewidth=1.5, linestyle='--', label='Avg Wealth')
ax1_twin.set_ylabel('Avg Wealth', color=ChartColors.LIME)
ax1_twin.tick_params(axis='y', labelcolor=ChartColors.LIME)
ax1.set_title('Money in Circulation', color=ChartColors.YELLOW)
ax1.set_ylabel('Total Money', color=ChartColors.CYAN)
ax1.tick_params(axis='y', labelcolor=ChartColors.CYAN)
ax1.grid(True, alpha=0.2)
ax1.set_ylim(bottom=0)
# Gini coefficient (inequality)
min_len_gini = min(len(turns), len(gini))
ax2.fill_between(turns[-min_len_gini:], gini[-min_len_gini:],
alpha=0.4, color=ChartColors.MAGENTA)
ax2.plot(turns[-min_len_gini:], gini[-min_len_gini:],
color=ChartColors.MAGENTA, linewidth=1.5)
ax2.set_xlabel('Turn')
ax2.set_ylabel('Gini')
ax2.set_title('Inequality Index', color=ChartColors.MAGENTA, fontsize=9)
ax2.set_ylim(0, 1)
ax2.grid(True, alpha=0.2)
# Add reference lines for gini
ax2.axhline(y=0.4, color=ChartColors.YELLOW, linestyle=':', alpha=0.5, linewidth=1)
ax2.text(turns[-1] if turns else 0, 0.42, 'Moderate', fontsize=7,
color=ChartColors.YELLOW, alpha=0.7)
fig.tight_layout()
return self._fig_to_surface(fig)
def render_professions(self, state: "SimulationState", history: HistoryData,
width: int, height: int) -> pygame.Surface:
"""Render profession distribution as pie chart and area chart."""
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi)
# Current profession pie chart
professions = state.statistics.get("professions", {})
if professions:
labels = list(professions.keys())
sizes = list(professions.values())
colors = [ChartColors.SERIES[i % len(ChartColors.SERIES)] for i in range(len(labels))]
wedges, texts, autotexts = ax1.pie(
sizes, labels=labels, colors=colors, autopct='%1.0f%%',
startangle=90, pctdistance=0.75,
textprops={'fontsize': 8, 'color': ChartColors.TEXT}
)
for autotext in autotexts:
autotext.set_color(ChartColors.BG)
autotext.set_fontweight('bold')
ax1.set_title('Current Distribution', color=ChartColors.PURPLE, fontsize=10)
else:
ax1.text(0.5, 0.5, 'No data', ha='center', va='center', color=ChartColors.TEXT_DIM)
ax1.set_title('Current Distribution', color=ChartColors.PURPLE)
# Profession history as stacked area
turns = list(history.turns) if history.turns else [0]
if history.professions and turns:
profs_list = list(history.professions.keys())
data = []
for prof in profs_list:
prof_data = list(history.professions[prof])
# Pad to match turns length
while len(prof_data) < len(turns):
prof_data.insert(0, 0)
data.append(prof_data[-len(turns):])
colors = [ChartColors.SERIES[i % len(ChartColors.SERIES)] for i in range(len(profs_list))]
ax2.stackplot(turns, *data, labels=profs_list, colors=colors, alpha=0.8)
ax2.legend(loc='upper left', fontsize=7, framealpha=0.8)
ax2.set_xlabel('Turn')
ax2.set_ylabel('Count')
ax2.set_title('Over Time', color=ChartColors.PURPLE, fontsize=10)
ax2.grid(True, alpha=0.2)
fig.tight_layout()
return self._fig_to_surface(fig)
def render_market_activity(self, state: "SimulationState", history: HistoryData,
width: int, height: int) -> pygame.Surface:
"""Render market activity - orders by resource, supply/demand."""
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi)
# Current market orders by resource type
prices = state.market_prices
resources = []
quantities = []
colors = []
for i, (resource, data) in enumerate(prices.items()):
qty = data.get("total_available", 0)
if qty > 0:
resources.append(resource.capitalize())
quantities.append(qty)
colors.append(ChartColors.SERIES[i % len(ChartColors.SERIES)])
if resources:
bars = ax1.bar(resources, quantities, color=colors, alpha=0.85)
ax1.set_ylabel('Available')
for bar, val in zip(bars, quantities):
ax1.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.3,
str(val), ha='center', fontsize=8, color=ChartColors.TEXT)
else:
ax1.text(0.5, 0.5, 'No orders', ha='center', va='center', color=ChartColors.TEXT_DIM)
ax1.set_title('Market Supply', color=ChartColors.TEAL, fontsize=10)
ax1.tick_params(axis='x', rotation=45, labelsize=7)
ax1.grid(True, alpha=0.2, axis='y')
# Supply/Demand scores
resources_sd = []
supply_scores = []
demand_scores = []
for resource, data in prices.items():
resources_sd.append(resource[:6])
supply_scores.append(data.get("supply_score", 0.5))
demand_scores.append(data.get("demand_score", 0.5))
if resources_sd:
x = np.arange(len(resources_sd))
width_bar = 0.35
ax2.bar(x - width_bar/2, supply_scores, width_bar, label='Supply',
color=ChartColors.CYAN, alpha=0.8)
ax2.bar(x + width_bar/2, demand_scores, width_bar, label='Demand',
color=ChartColors.MAGENTA, alpha=0.8)
ax2.set_xticks(x)
ax2.set_xticklabels(resources_sd, fontsize=7, rotation=45)
ax2.set_ylabel('Score')
ax2.legend(fontsize=7)
ax2.set_ylim(0, 1.2)
ax2.set_title('Supply/Demand', color=ChartColors.TEAL, fontsize=10)
ax2.grid(True, alpha=0.2, axis='y')
fig.tight_layout()
return self._fig_to_surface(fig)
def render_agent_stats(self, state: "SimulationState", width: int, height: int) -> pygame.Surface:
"""Render aggregate agent statistics - energy, hunger, thirst distributions."""
fig, axes = plt.subplots(2, 2, figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi)
agents = [a for a in state.agents if a.get("is_alive", False)]
if not agents:
for ax in axes.flat:
ax.text(0.5, 0.5, 'No agents', ha='center', va='center', color=ChartColors.TEXT_DIM)
fig.suptitle('Agent Statistics', color=ChartColors.CYAN)
fig.tight_layout()
return self._fig_to_surface(fig)
# Extract stats
energies = [a.get("stats", {}).get("energy", 0) for a in agents]
hungers = [a.get("stats", {}).get("hunger", 0) for a in agents]
thirsts = [a.get("stats", {}).get("thirst", 0) for a in agents]
heats = [a.get("stats", {}).get("heat", 0) for a in agents]
max_energy = agents[0].get("stats", {}).get("max_energy", 100)
max_hunger = agents[0].get("stats", {}).get("max_hunger", 100)
max_thirst = agents[0].get("stats", {}).get("max_thirst", 100)
max_heat = agents[0].get("stats", {}).get("max_heat", 100)
stats_data = [
(energies, max_energy, 'Energy', ChartColors.LIME),
(hungers, max_hunger, 'Hunger', ChartColors.ORANGE),
(thirsts, max_thirst, 'Thirst', ChartColors.CYAN),
(heats, max_heat, 'Heat', ChartColors.MAGENTA),
]
for ax, (values, max_val, name, color) in zip(axes.flat, stats_data):
# Histogram
bins = np.linspace(0, max_val, 11)
ax.hist(values, bins=bins, color=color, alpha=0.7, edgecolor=ChartColors.PANEL)
# Mean line
mean_val = np.mean(values)
ax.axvline(x=mean_val, color=ChartColors.TEXT, linestyle='--',
linewidth=1.5, label=f'Avg: {mean_val:.0f}')
# Critical threshold
critical = max_val * 0.25
ax.axvline(x=critical, color=ChartColors.MAGENTA, linestyle=':',
linewidth=1, alpha=0.7)
ax.set_title(name, color=color, fontsize=9)
ax.set_xlim(0, max_val)
ax.legend(fontsize=7, loc='upper right')
ax.grid(True, alpha=0.2)
fig.suptitle('Agent Statistics Distribution', color=ChartColors.CYAN, fontsize=11)
fig.tight_layout()
return self._fig_to_surface(fig)
class StatsRenderer:
"""Main statistics panel with tabs and charts."""
TABS = [
("Prices", "price_history"),
("Wealth", "wealth"),
("Population", "population"),
("Professions", "professions"),
("Market", "market"),
("Agent Stats", "agent_stats"),
]
def __init__(self, screen: pygame.Surface):
self.screen = screen
self.visible = False
self.font = pygame.font.Font(None, 24)
self.small_font = pygame.font.Font(None, 18)
self.title_font = pygame.font.Font(None, 32)
self.current_tab = 0
self.tab_hovered = -1
# History data
self.history = HistoryData()
# Chart renderer
self.chart_renderer: Optional[ChartRenderer] = None
# Cached chart surfaces
self._chart_cache: dict[str, pygame.Surface] = {}
self._cache_turn: int = -1
# Layout
self._calculate_layout()
def _calculate_layout(self) -> None:
"""Calculate panel layout based on screen size."""
screen_w, screen_h = self.screen.get_size()
# Panel takes most of the screen with some margin
margin = 30
self.panel_rect = pygame.Rect(
margin, margin,
screen_w - margin * 2,
screen_h - margin * 2
)
# Tab bar
self.tab_height = 40
self.tab_rect = pygame.Rect(
self.panel_rect.x,
self.panel_rect.y,
self.panel_rect.width,
self.tab_height
)
# Chart area
self.chart_rect = pygame.Rect(
self.panel_rect.x + 10,
self.panel_rect.y + self.tab_height + 10,
self.panel_rect.width - 20,
self.panel_rect.height - self.tab_height - 20
)
# Initialize chart renderer with chart area size
self.chart_renderer = ChartRenderer(
self.chart_rect.width,
self.chart_rect.height
)
# Calculate tab widths
self.tab_width = self.panel_rect.width // len(self.TABS)
def toggle(self) -> None:
"""Toggle visibility of the stats panel."""
self.visible = not self.visible
if self.visible:
self._invalidate_cache()
def update_history(self, state: "SimulationState") -> None:
"""Update history data with new state."""
if state:
self.history.update(state)
def clear_history(self) -> None:
"""Clear all history data (e.g., on simulation reset)."""
self.history.clear()
self._invalidate_cache()
def _invalidate_cache(self) -> None:
"""Invalidate chart cache to force re-render."""
self._chart_cache.clear()
self._cache_turn = -1
def handle_event(self, event: pygame.event.Event) -> bool:
"""Handle input events. Returns True if event was consumed."""
if not self.visible:
return False
if event.type == pygame.MOUSEMOTION:
self._handle_mouse_motion(event.pos)
return True
elif event.type == pygame.MOUSEBUTTONDOWN:
if self._handle_click(event.pos):
return True
# Consume clicks when visible
return True
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
self.toggle()
return True
elif event.key == pygame.K_LEFT:
self.current_tab = (self.current_tab - 1) % len(self.TABS)
self._invalidate_cache()
return True
elif event.key == pygame.K_RIGHT:
self.current_tab = (self.current_tab + 1) % len(self.TABS)
self._invalidate_cache()
return True
return False
def _handle_mouse_motion(self, pos: tuple[int, int]) -> None:
"""Handle mouse motion for tab hover effects."""
self.tab_hovered = -1
if self.tab_rect.collidepoint(pos):
rel_x = pos[0] - self.tab_rect.x
tab_idx = rel_x // self.tab_width
if 0 <= tab_idx < len(self.TABS):
self.tab_hovered = tab_idx
def _handle_click(self, pos: tuple[int, int]) -> bool:
"""Handle mouse click. Returns True if click was on a tab."""
if self.tab_rect.collidepoint(pos):
rel_x = pos[0] - self.tab_rect.x
tab_idx = rel_x // self.tab_width
if 0 <= tab_idx < len(self.TABS) and tab_idx != self.current_tab:
self.current_tab = tab_idx
self._invalidate_cache()
return True
return False
def _render_chart(self, state: "SimulationState") -> pygame.Surface:
"""Render the current tab's chart."""
tab_name, tab_key = self.TABS[self.current_tab]
# Check cache
current_turn = state.turn if state else 0
if tab_key in self._chart_cache and self._cache_turn == current_turn:
return self._chart_cache[tab_key]
# Render chart based on current tab
width = self.chart_rect.width
height = self.chart_rect.height
if tab_key == "price_history":
surface = self.chart_renderer.render_price_history(self.history, width, height)
elif tab_key == "wealth":
# Split into two charts
half_height = height // 2
dist_surface = self.chart_renderer.render_wealth_distribution(state, width, half_height)
time_surface = self.chart_renderer.render_wealth_over_time(self.history, width, half_height)
surface = pygame.Surface((width, height))
surface.fill(UIColors.BG)
surface.blit(dist_surface, (0, 0))
surface.blit(time_surface, (0, half_height))
elif tab_key == "population":
surface = self.chart_renderer.render_population(self.history, width, height)
elif tab_key == "professions":
surface = self.chart_renderer.render_professions(state, self.history, width, height)
elif tab_key == "market":
surface = self.chart_renderer.render_market_activity(state, self.history, width, height)
elif tab_key == "agent_stats":
surface = self.chart_renderer.render_agent_stats(state, width, height)
else:
# Fallback empty surface
surface = pygame.Surface((width, height))
surface.fill(UIColors.BG)
# Cache the result
self._chart_cache[tab_key] = surface
self._cache_turn = current_turn
return surface
def draw(self, state: "SimulationState") -> None:
"""Draw the statistics panel."""
if not self.visible:
return
# Dim background
overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 220))
self.screen.blit(overlay, (0, 0))
# Panel background
pygame.draw.rect(self.screen, UIColors.PANEL_BG, self.panel_rect, border_radius=12)
pygame.draw.rect(self.screen, UIColors.PANEL_BORDER, self.panel_rect, 2, border_radius=12)
# Draw tabs
self._draw_tabs()
# Draw chart
if state:
chart_surface = self._render_chart(state)
self.screen.blit(chart_surface, self.chart_rect.topleft)
# Draw close hint
hint = self.small_font.render("Press G or ESC to close | ← → to switch tabs",
True, UIColors.TEXT_SECONDARY)
hint_rect = hint.get_rect(centerx=self.panel_rect.centerx,
y=self.panel_rect.bottom - 25)
self.screen.blit(hint, hint_rect)
def _draw_tabs(self) -> None:
"""Draw the tab bar."""
for i, (tab_name, _) in enumerate(self.TABS):
tab_x = self.tab_rect.x + i * self.tab_width
tab_rect = pygame.Rect(tab_x, self.tab_rect.y, self.tab_width, self.tab_height)
# Tab background
if i == self.current_tab:
color = UIColors.TAB_ACTIVE
elif i == self.tab_hovered:
color = UIColors.TAB_HOVER
else:
color = UIColors.TAB_INACTIVE
# Draw tab with rounded top corners
tab_surface = pygame.Surface((self.tab_width, self.tab_height), pygame.SRCALPHA)
pygame.draw.rect(tab_surface, color, (0, 0, self.tab_width, self.tab_height),
border_top_left_radius=8, border_top_right_radius=8)
if i == self.current_tab:
# Active tab - solid color
tab_surface.set_alpha(255)
else:
tab_surface.set_alpha(180)
self.screen.blit(tab_surface, (tab_x, self.tab_rect.y))
# Tab text
text_color = UIColors.BG if i == self.current_tab else UIColors.TEXT_PRIMARY
text = self.small_font.render(tab_name, True, text_color)
text_rect = text.get_rect(center=tab_rect.center)
self.screen.blit(text, text_rect)
# Tab border
if i != self.current_tab:
pygame.draw.line(self.screen, UIColors.PANEL_BORDER,
(tab_x + self.tab_width - 1, self.tab_rect.y + 5),
(tab_x + self.tab_width - 1, self.tab_rect.y + self.tab_height - 5))

View File

@ -1,239 +0,0 @@
"""UI renderer for the Village Simulation."""
import pygame
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from frontend.client import SimulationState
class Colors:
# UI colors
PANEL_BG = (35, 40, 50)
PANEL_BORDER = (70, 80, 95)
TEXT_PRIMARY = (230, 230, 235)
TEXT_SECONDARY = (160, 165, 175)
TEXT_HIGHLIGHT = (100, 180, 255)
TEXT_WARNING = (255, 180, 80)
TEXT_DANGER = (255, 100, 100)
# Day/Night indicator
DAY_COLOR = (255, 220, 100)
NIGHT_COLOR = (100, 120, 180)
class UIRenderer:
"""Renders UI elements (HUD, panels, text info)."""
def __init__(self, screen: pygame.Surface, font: pygame.font.Font):
self.screen = screen
self.font = font
self.small_font = pygame.font.Font(None, 20)
self.title_font = pygame.font.Font(None, 28)
# Panel dimensions
self.top_panel_height = 50
self.right_panel_width = 200
def _draw_panel(self, rect: pygame.Rect, title: Optional[str] = None) -> None:
"""Draw a panel background."""
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect)
pygame.draw.rect(self.screen, Colors.PANEL_BORDER, rect, 1)
if title:
title_text = self.small_font.render(title, True, Colors.TEXT_SECONDARY)
self.screen.blit(title_text, (rect.x + 8, rect.y + 4))
def draw_top_bar(self, state: "SimulationState") -> None:
"""Draw the top information bar."""
rect = pygame.Rect(0, 0, self.screen.get_width(), self.top_panel_height)
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect)
pygame.draw.line(
self.screen,
Colors.PANEL_BORDER,
(0, self.top_panel_height),
(self.screen.get_width(), self.top_panel_height),
)
# Day/Night and Turn info
is_night = state.time_of_day == "night"
time_color = Colors.NIGHT_COLOR if is_night else Colors.DAY_COLOR
time_text = "NIGHT" if is_night else "DAY"
# Draw time indicator circle
pygame.draw.circle(self.screen, time_color, (25, 25), 12)
pygame.draw.circle(self.screen, Colors.PANEL_BORDER, (25, 25), 12, 1)
# Time/day text
info_text = f"{time_text} | Day {state.day}, Step {state.step_in_day} | Turn {state.turn}"
text = self.font.render(info_text, True, Colors.TEXT_PRIMARY)
self.screen.blit(text, (50, 15))
# Mode indicator
mode_color = Colors.TEXT_HIGHLIGHT if state.mode == "auto" else Colors.TEXT_SECONDARY
mode_text = f"Mode: {state.mode.upper()}"
text = self.small_font.render(mode_text, True, mode_color)
self.screen.blit(text, (self.screen.get_width() - 120, 8))
# Running indicator
if state.is_running:
status_text = "RUNNING"
status_color = (100, 200, 100)
else:
status_text = "STOPPED"
status_color = Colors.TEXT_DANGER
text = self.small_font.render(status_text, True, status_color)
self.screen.blit(text, (self.screen.get_width() - 120, 28))
def draw_right_panel(self, state: "SimulationState") -> None:
"""Draw the right information panel."""
panel_x = self.screen.get_width() - self.right_panel_width
rect = pygame.Rect(
panel_x,
self.top_panel_height,
self.right_panel_width,
self.screen.get_height() - self.top_panel_height,
)
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect)
pygame.draw.line(
self.screen,
Colors.PANEL_BORDER,
(panel_x, self.top_panel_height),
(panel_x, self.screen.get_height()),
)
y = self.top_panel_height + 10
# Statistics section
y = self._draw_statistics_section(state, panel_x + 10, y)
# Market section
y = self._draw_market_section(state, panel_x + 10, y + 20)
# Controls help section
self._draw_controls_help(panel_x + 10, self.screen.get_height() - 100)
def _draw_statistics_section(self, state: "SimulationState", x: int, y: int) -> int:
"""Draw the statistics section."""
# Title
title = self.title_font.render("Statistics", True, Colors.TEXT_PRIMARY)
self.screen.blit(title, (x, y))
y += 30
stats = state.statistics
living = len(state.get_living_agents())
# Population
pop_color = Colors.TEXT_PRIMARY if living > 2 else Colors.TEXT_DANGER
text = self.small_font.render(f"Population: {living}", True, pop_color)
self.screen.blit(text, (x, y))
y += 18
# Deaths
deaths = stats.get("total_agents_died", 0)
if deaths > 0:
text = self.small_font.render(f"Deaths: {deaths}", True, Colors.TEXT_WARNING)
self.screen.blit(text, (x, y))
y += 18
# Total money
total_money = stats.get("total_money_in_circulation", 0)
text = self.small_font.render(f"Total Coins: {total_money}", True, Colors.TEXT_SECONDARY)
self.screen.blit(text, (x, y))
y += 18
# Professions
professions = stats.get("professions", {})
if professions:
y += 5
text = self.small_font.render("Professions:", True, Colors.TEXT_SECONDARY)
self.screen.blit(text, (x, y))
y += 16
for prof, count in professions.items():
text = self.small_font.render(f" {prof}: {count}", True, Colors.TEXT_SECONDARY)
self.screen.blit(text, (x, y))
y += 14
return y
def _draw_market_section(self, state: "SimulationState", x: int, y: int) -> int:
"""Draw the market section."""
# Title
title = self.title_font.render("Market", True, Colors.TEXT_PRIMARY)
self.screen.blit(title, (x, y))
y += 30
# Order count
order_count = len(state.market_orders)
text = self.small_font.render(f"Active Orders: {order_count}", True, Colors.TEXT_SECONDARY)
self.screen.blit(text, (x, y))
y += 20
# Price summary for each resource with available stock
prices = state.market_prices
for resource, data in prices.items():
if data.get("total_available", 0) > 0:
price = data.get("lowest_price", "?")
qty = data.get("total_available", 0)
text = self.small_font.render(
f"{resource}: {qty}x @ {price}c",
True,
Colors.TEXT_SECONDARY,
)
self.screen.blit(text, (x, y))
y += 16
return y
def _draw_controls_help(self, x: int, y: int) -> None:
"""Draw controls help at bottom of panel."""
pygame.draw.line(
self.screen,
Colors.PANEL_BORDER,
(x - 5, y - 10),
(self.screen.get_width() - 5, y - 10),
)
title = self.small_font.render("Controls", True, Colors.TEXT_PRIMARY)
self.screen.blit(title, (x, y))
y += 20
controls = [
"SPACE - Next Turn",
"R - Reset Simulation",
"M - Toggle Mode",
"S - Settings",
"ESC - Quit",
]
for control in controls:
text = self.small_font.render(control, True, Colors.TEXT_SECONDARY)
self.screen.blit(text, (x, y))
y += 16
def draw_connection_status(self, connected: bool) -> None:
"""Draw connection status overlay when disconnected."""
if connected:
return
# Semi-transparent overlay
overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 180))
self.screen.blit(overlay, (0, 0))
# Connection message
text = self.title_font.render("Connecting to server...", True, Colors.TEXT_WARNING)
text_rect = text.get_rect(center=(self.screen.get_width() // 2, self.screen.get_height() // 2))
self.screen.blit(text, text_rect)
hint = self.small_font.render("Make sure the backend is running on localhost:8000", True, Colors.TEXT_SECONDARY)
hint_rect = hint.get_rect(center=(self.screen.get_width() // 2, self.screen.get_height() // 2 + 30))
self.screen.blit(hint, hint_rect)
def draw(self, state: "SimulationState") -> None:
"""Draw all UI elements."""
self.draw_top_bar(state)
self.draw_right_panel(state)

View File

@ -5,8 +5,7 @@ fastapi>=0.104.0
uvicorn[standard]>=0.24.0
pydantic>=2.5.0
# Frontend
pygame-ce>=2.4.0
# HTTP client (for web frontend communication)
requests>=2.31.0
# Tools (balance sheet export/import)