[refactor] cleanup unused code
This commit is contained in:
parent
b1b256e520
commit
49aab7ff1c
70
README.md
70
README.md
@ -9,14 +9,14 @@ This project simulates a village economy with autonomous AI agents. Each agent h
|
|||||||
### Features
|
### Features
|
||||||
|
|
||||||
- **Agent-based simulation**: Multiple AI agents with different professions
|
- **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
|
- **Vital stats system**: Energy, Hunger, Thirst, and Heat with passive decay
|
||||||
- **Market economy**: Order book system for trading resources
|
- **Market economy**: Order book system for trading resources
|
||||||
- **Day/Night cycle**: 10 day steps + 1 night step per day
|
- **Day/Night cycle**: 10 day steps + 1 night step per day
|
||||||
- **Maslow-priority AI**: Agents prioritize survival over economic activities
|
- **Real-time visualization**: Web-based frontend showing agents and their states
|
||||||
- **Real-time visualization**: Pygame frontend showing agents and their states
|
|
||||||
- **Agent movement**: Agents visually move to different locations based on their actions
|
- **Agent movement**: Agents visually move to different locations based on their actions
|
||||||
- **Action indicators**: Visual feedback showing what each agent is doing
|
- **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
|
- **Detailed logging**: All simulation steps are logged for analysis
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@ -28,11 +28,13 @@ villsim/
|
|||||||
│ ├── config.py # Centralized configuration
|
│ ├── config.py # Centralized configuration
|
||||||
│ ├── api/ # REST API endpoints
|
│ ├── api/ # REST API endpoints
|
||||||
│ ├── core/ # Game logic (engine, world, market, AI, logger)
|
│ ├── core/ # Game logic (engine, world, market, AI, logger)
|
||||||
|
│ │ └── goap/ # GOAP AI system (planner, actions, goals)
|
||||||
│ └── domain/ # Data models (agent, resources, actions)
|
│ └── domain/ # Data models (agent, resources, actions)
|
||||||
├── frontend/ # Pygame visualizer
|
├── web_frontend/ # Web-based visualizer
|
||||||
│ ├── main.py # Entry point
|
│ ├── index.html # Main application
|
||||||
│ ├── client.py # HTTP client
|
│ ├── goap_debug.html # GOAP debugging view
|
||||||
│ └── renderer/ # Drawing components (map, agents, UI, settings)
|
│ └── src/ # JavaScript modules (scenes, API client)
|
||||||
|
├── tools/ # Analysis and optimization scripts
|
||||||
├── logs/ # Simulation log files (created on run)
|
├── logs/ # Simulation log files (created on run)
|
||||||
├── docs/design/ # Design documents
|
├── docs/design/ # Design documents
|
||||||
├── requirements.txt
|
├── requirements.txt
|
||||||
@ -79,40 +81,25 @@ The server will start at `http://localhost:8000`. You can access:
|
|||||||
- API docs: `http://localhost:8000/docs`
|
- API docs: `http://localhost:8000/docs`
|
||||||
- Health check: `http://localhost:8000/health`
|
- 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
|
```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
|
## Controls
|
||||||
|
|
||||||
| Key | Action |
|
The web frontend provides buttons for:
|
||||||
|-----|--------|
|
- **Step**: Advance one turn (manual mode)
|
||||||
| `SPACE` | Advance one turn (manual mode) |
|
- **Auto/Manual**: Toggle between automatic and manual mode
|
||||||
| `R` | Reset simulation |
|
- **Reset**: Reset simulation
|
||||||
| `M` | Toggle between MANUAL and AUTO mode |
|
|
||||||
| `S` | Open/close settings panel |
|
|
||||||
| `ESC` | Close settings or quit |
|
|
||||||
|
|
||||||
Hover over agents to see detailed information.
|
Click on agents to see detailed information. Use the GOAP debug panel (`goap_debug.html`) to inspect agent planning.
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
@ -189,12 +176,14 @@ Action indicators above agents show:
|
|||||||
- Movement animation when traveling
|
- Movement animation when traveling
|
||||||
- Dotted line to destination
|
- Dotted line to destination
|
||||||
|
|
||||||
### AI Priority System
|
### AI System (GOAP)
|
||||||
|
|
||||||
1. **Critical needs** (stat < 20%): Consume, buy, or gather resources
|
The simulation uses Goal-Oriented Action Planning (GOAP) for intelligent agent behavior:
|
||||||
2. **Energy management**: Rest if too tired
|
|
||||||
3. **Economic activity**: Sell excess inventory, buy needed materials
|
1. **Goals**: Agents have weighted goals (Survive, Maintain Heat, Build Wealth, etc.)
|
||||||
4. **Routine work**: Perform profession-specific tasks
|
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
|
## Development
|
||||||
|
|
||||||
@ -202,9 +191,10 @@ Action indicators above agents show:
|
|||||||
|
|
||||||
- **Config** (`backend/config.py`): Centralized configuration with dataclasses
|
- **Config** (`backend/config.py`): Centralized configuration with dataclasses
|
||||||
- **Domain Layer** (`backend/domain/`): Pure data models
|
- **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
|
- **API Layer** (`backend/api/`): FastAPI routes and schemas
|
||||||
- **Frontend** (`frontend/`): Pygame visualization client
|
- **Web Frontend** (`web_frontend/`): Browser-based visualization
|
||||||
|
|
||||||
### Analyzing Logs
|
### Analyzing Logs
|
||||||
|
|
||||||
@ -227,7 +217,7 @@ with open("logs/sim_20260118_123456.jsonl") as f:
|
|||||||
- Agent reproduction
|
- Agent reproduction
|
||||||
- Skill progression
|
- Skill progression
|
||||||
- Persistent save/load
|
- Persistent save/load
|
||||||
- Web-based frontend alternative
|
- Unity frontend integration
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@ -133,14 +133,7 @@ class EconomyConfig:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AIConfig:
|
class AIConfig:
|
||||||
"""Configuration for AI decision-making system.
|
"""Configuration for AI decision-making system (GOAP-based)."""
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# Maximum A* iterations for GOAP planner
|
# Maximum A* iterations for GOAP planner
|
||||||
goap_max_iterations: int = 50
|
goap_max_iterations: int = 50
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
from .world import World, TimeOfDay
|
from .world import World, TimeOfDay
|
||||||
from .market import Order, OrderBook
|
from .market import Order, OrderBook
|
||||||
from .engine import GameEngine, SimulationMode
|
from .engine import GameEngine, SimulationMode
|
||||||
from .ai import AgentAI
|
|
||||||
from .logger import SimulationLogger, get_simulation_logger
|
from .logger import SimulationLogger, get_simulation_logger
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@ -13,7 +12,6 @@ __all__ = [
|
|||||||
"OrderBook",
|
"OrderBook",
|
||||||
"GameEngine",
|
"GameEngine",
|
||||||
"SimulationMode",
|
"SimulationMode",
|
||||||
"AgentAI",
|
|
||||||
"SimulationLogger",
|
"SimulationLogger",
|
||||||
"get_simulation_logger",
|
"get_simulation_logger",
|
||||||
]
|
]
|
||||||
|
|||||||
1132
backend/core/ai.py
1132
backend/core/ai.py
File diff suppressed because it is too large
Load Diff
@ -176,9 +176,6 @@ class GameEngine:
|
|||||||
money=agent.money,
|
money=agent.money,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get AI config to determine which system to use
|
|
||||||
ai_config = get_config().ai
|
|
||||||
|
|
||||||
# GOAP AI handles night time automatically
|
# GOAP AI handles night time automatically
|
||||||
decision = get_ai_decision(
|
decision = get_ai_decision(
|
||||||
agent,
|
agent,
|
||||||
@ -186,7 +183,6 @@ class GameEngine:
|
|||||||
step_in_day=self.world.step_in_day,
|
step_in_day=self.world.step_in_day,
|
||||||
day_steps=self.world.config.day_steps,
|
day_steps=self.world.config.day_steps,
|
||||||
current_turn=current_turn,
|
current_turn=current_turn,
|
||||||
use_goap=ai_config.use_goap,
|
|
||||||
is_night=self.world.is_night(),
|
is_night=self.world.is_night(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"""GOAP-based AI decision system for agents.
|
"""GOAP-based AI decision system for agents.
|
||||||
|
|
||||||
This module provides the main interface for GOAP-based decision making.
|
This module provides the main interface for GOAP-based decision making
|
||||||
It replaces the priority-based AgentAI with a goal-oriented planner.
|
using Goal-Oriented Action Planning.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import os
|
|||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from backend.api.routes import router
|
from backend.api.routes import router
|
||||||
@ -48,14 +49,8 @@ async def startup_event():
|
|||||||
|
|
||||||
@app.get("/", tags=["root"])
|
@app.get("/", tags=["root"])
|
||||||
def root():
|
def root():
|
||||||
"""Root endpoint with API information."""
|
"""Root endpoint - redirect to web frontend."""
|
||||||
return {
|
return RedirectResponse(url="/web/")
|
||||||
"name": "Village Simulation API",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"docs": "/docs",
|
|
||||||
"web_frontend": "/web/",
|
|
||||||
"status": "running",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health", tags=["health"])
|
@app.get("/health", tags=["health"])
|
||||||
@ -69,12 +64,19 @@ def health_check():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ============== Web Frontend Static Files ==============
|
# ============== Web Frontend ==============
|
||||||
|
|
||||||
# Mount static files for web frontend
|
@app.get("/web", include_in_schema=False)
|
||||||
# Access at http://localhost:8000/web/
|
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):
|
if os.path.exists(WEB_FRONTEND_PATH):
|
||||||
app.mount("/web", StaticFiles(directory=WEB_FRONTEND_PATH, html=True), name="web_frontend")
|
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():
|
def main():
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
"stats_update_interval": 10
|
"stats_update_interval": 10
|
||||||
},
|
},
|
||||||
"ai": {
|
"ai": {
|
||||||
"use_goap": false,
|
|
||||||
"goap_max_iterations": 30,
|
"goap_max_iterations": 30,
|
||||||
"goap_max_plan_depth": 2,
|
"goap_max_plan_depth": 2,
|
||||||
"reactive_fallback": true
|
"reactive_fallback": true
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"ai": {
|
"ai": {
|
||||||
"use_goap": true,
|
|
||||||
"goap_max_iterations": 50,
|
"goap_max_iterations": 50,
|
||||||
"goap_max_plan_depth": 3,
|
"goap_max_plan_depth": 3,
|
||||||
"reactive_fallback": true
|
"reactive_fallback": true
|
||||||
|
|||||||
@ -5,10 +5,10 @@ This document outlines the architecture for the Village Simulation based on [Vil
|
|||||||
## 1. System Overview
|
## 1. System Overview
|
||||||
|
|
||||||
The system consists of two distinct applications communicating via HTTP (REST API):
|
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.
|
1. **Backend (Server)**: Responsible for the entire simulation state, economic logic, AI decision-making (GOAP-based), 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.
|
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.
|
The frontend acts as a **Visualizer**. It does not calculate simulation logic.
|
||||||
|
|
||||||
### 3.1. Structure
|
### 3.1. Structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
frontend/
|
web_frontend/
|
||||||
├── main.py # Pygame Game Loop
|
├── index.html # Main HTML page
|
||||||
├── client.py # Network Client (requests lib)
|
├── goap_debug.html # GOAP debugging view
|
||||||
├── assets/ # Sprites/Fonts
|
├── styles.css # Styling
|
||||||
└── renderer/ # Drawing Logic
|
└── src/
|
||||||
├── map_renderer.py # Draws the grid/terrain
|
├── main.js # Application entry point
|
||||||
├── agent_renderer.py # Draws agents and their status bars
|
├── api.js # Network client (fetch API)
|
||||||
└── ui_renderer.py # Draws text info (Market prices, Day/Night)
|
├── constants.js # Configuration constants
|
||||||
|
└── scenes/ # Game scenes (Phaser.js)
|
||||||
|
├── BootScene.js # Loading scene
|
||||||
|
└── GameScene.js # Main game visualization
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.2. Flow
|
### 3.2. Flow
|
||||||
@ -77,12 +80,11 @@ frontend/
|
|||||||
* Call `GET http://localhost:8000/state`.
|
* Call `GET http://localhost:8000/state`.
|
||||||
* Receive JSON: `{"turn": 5, "time_of_day": "day", "agents": [...], "market": [...]}`.
|
* Receive JSON: `{"turn": 5, "time_of_day": "day", "agents": [...], "market": [...]}`.
|
||||||
2. **Update Step**:
|
2. **Update Step**:
|
||||||
* Parse JSON into local simplified objects.
|
* Parse JSON into JavaScript objects.
|
||||||
3. **Draw Step**:
|
3. **Draw Step**:
|
||||||
* Clear screen.
|
* Update Phaser.js game scene.
|
||||||
* Render Agents at their coordinates.
|
* Render Agents at their coordinates.
|
||||||
* Render UI overlays (e.g., "Day 1, Step 5", "Total Coins: 500").
|
* 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.
|
* Frontend updates the screen.
|
||||||
|
|
||||||
### 4.1. The "God Mode" Problem
|
### 4.1. The "God Mode" Problem
|
||||||
To test the simulation efficiently, the Server will expose a **Simulation Controller**:
|
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 presses `SPACE` in Pygame -> Pygame sends request -> Server updates -> Pygame fetches new state.
|
* **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.
|
* **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
|
## 5. Technology Stack
|
||||||
@ -110,12 +110,13 @@ To test the simulation efficiently, the Server will expose a **Simulation Contro
|
|||||||
* **Language**: Python 3.11+
|
* **Language**: Python 3.11+
|
||||||
* **Backend Framework**: FastAPI (for speed and auto-generated docs).
|
* **Backend Framework**: FastAPI (for speed and auto-generated docs).
|
||||||
* **Data Validation**: Pydantic.
|
* **Data Validation**: Pydantic.
|
||||||
* **Frontend**: Pygame Community Edition (pygame-ce).
|
* **AI System**: GOAP (Goal-Oriented Action Planning).
|
||||||
* **Communication**: HTTP (Requests/Uvicorn).
|
* **Frontend**: HTML/JavaScript with Phaser.js for rendering.
|
||||||
|
* **Communication**: HTTP (Fetch API/Uvicorn).
|
||||||
|
|
||||||
## 6. Future Extensibility (Why this architecture?)
|
## 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`.
|
* **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.
|
* **Database**: Currently state is in-memory (`core/engine.py`). Easy to swap for SQLite/Postgres later by adding a `repository` layer in Backend.
|
||||||
|
|
||||||
|
|||||||
@ -1,2 +0,0 @@
|
|||||||
"""Frontend package for Village Simulation visualization."""
|
|
||||||
|
|
||||||
@ -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)
|
|
||||||
|
|
||||||
324
frontend/main.py
324
frontend/main.py
@ -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()
|
|
||||||
@ -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"]
|
|
||||||
|
|
||||||
@ -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))
|
|
||||||
@ -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)
|
|
||||||
|
|
||||||
@ -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)
|
|
||||||
|
|
||||||
@ -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))
|
|
||||||
|
|
||||||
@ -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)
|
|
||||||
|
|
||||||
@ -5,8 +5,7 @@ fastapi>=0.104.0
|
|||||||
uvicorn[standard]>=0.24.0
|
uvicorn[standard]>=0.24.0
|
||||||
pydantic>=2.5.0
|
pydantic>=2.5.0
|
||||||
|
|
||||||
# Frontend
|
# HTTP client (for web frontend communication)
|
||||||
pygame-ce>=2.4.0
|
|
||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
|
|
||||||
# Tools (balance sheet export/import)
|
# Tools (balance sheet export/import)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user