[new] web-based frontend

This commit is contained in:
Снесарев Максим 2026-01-19 19:44:38 +03:00
parent 1423fc0dc9
commit 67dc007283
12 changed files with 3712 additions and 170 deletions

View File

@ -121,6 +121,12 @@ class StatisticsSchema(BaseModel):
total_agents_died: int
total_money_in_circulation: int
professions: dict[str, int]
# Wealth inequality metrics
avg_money: float = 0.0
median_money: int = 0
richest_agent: int = 0
poorest_agent: int = 0
gini_coefficient: float = 0.0
class ActionLogSchema(BaseModel):
@ -137,6 +143,18 @@ class TurnLogSchema(BaseModel):
agent_actions: list[ActionLogSchema]
deaths: list[str]
trades: list[dict]
resources_produced: dict[str, int] = {}
resources_consumed: dict[str, int] = {}
resources_spoiled: dict[str, int] = {}
class ResourceStatsSchema(BaseModel):
"""Schema for resource statistics."""
produced: dict[str, int] = {}
consumed: dict[str, int] = {}
spoiled: dict[str, int] = {}
in_inventory: dict[str, int] = {}
in_market: dict[str, int] = {}
class WorldStateResponse(BaseModel):
@ -152,6 +170,7 @@ class WorldStateResponse(BaseModel):
mode: str
is_running: bool
recent_logs: list[TurnLogSchema]
resource_stats: ResourceStatsSchema = ResourceStatsSchema()
# ============== Control Schemas ==============

View File

@ -31,6 +31,10 @@ class TurnLog:
agent_actions: list[dict] = field(default_factory=list)
deaths: list[str] = field(default_factory=list)
trades: list[dict] = field(default_factory=list)
# Resource tracking for this turn
resources_produced: dict = field(default_factory=dict)
resources_consumed: dict = field(default_factory=dict)
resources_spoiled: dict = field(default_factory=dict)
def to_dict(self) -> dict:
return {
@ -38,6 +42,9 @@ class TurnLog:
"agent_actions": self.agent_actions,
"deaths": self.deaths,
"trades": self.trades,
"resources_produced": self.resources_produced,
"resources_consumed": self.resources_consumed,
"resources_spoiled": self.resources_spoiled,
}
@ -66,6 +73,17 @@ class GameEngine:
self._stop_event = threading.Event()
self.turn_logs: list[TurnLog] = []
self.logger = get_simulation_logger()
# Resource statistics tracking (cumulative)
self.resource_stats = {
"produced": {}, # Total resources produced
"consumed": {}, # Total resources consumed
"spoiled": {}, # Total resources spoiled
"traded": {}, # Total resources traded (bought/sold)
"in_market": {}, # Currently in market
"in_inventory": {}, # Currently in all inventories
}
self._initialized = True
def reset(self, config: Optional[WorldConfig] = None) -> None:
@ -80,6 +98,16 @@ class GameEngine:
self.market = OrderBook()
self.turn_logs = []
# Reset resource statistics
self.resource_stats = {
"produced": {},
"consumed": {},
"spoiled": {},
"traded": {},
"in_market": {},
"in_inventory": {},
}
# Reset and start new logging session
self.logger = reset_simulation_logger()
sim_config = get_config()
@ -183,6 +211,25 @@ class GameEngine:
# Complete agent action with result - this updates the indicator to show what was done
if result:
agent.complete_action(result.success, result.message)
# Log to agent's personal history
agent.log_action(
turn=current_turn,
action_type=decision.action.value,
result=result.message,
success=result.success,
)
# Track resources produced
for res in result.resources_gained:
res_type = res.type.value
turn_log.resources_produced[res_type] = turn_log.resources_produced.get(res_type, 0) + res.quantity
self.resource_stats["produced"][res_type] = self.resource_stats["produced"].get(res_type, 0) + res.quantity
# Track resources consumed
for res in result.resources_consumed:
res_type = res.type.value
turn_log.resources_consumed[res_type] = turn_log.resources_consumed.get(res_type, 0) + res.quantity
self.resource_stats["consumed"][res_type] = self.resource_stats["consumed"].get(res_type, 0) + res.quantity
turn_log.agent_actions.append({
"agent_id": agent.id,
@ -215,6 +262,11 @@ class GameEngine:
# 6. Decay resources in inventories
for agent in self.world.get_living_agents():
expired = agent.decay_inventory(current_turn)
# Track spoiled resources
for res in expired:
res_type = res.type.value
turn_log.resources_spoiled[res_type] = turn_log.resources_spoiled.get(res_type, 0) + res.quantity
self.resource_stats["spoiled"][res_type] = self.resource_stats["spoiled"].get(res_type, 0) + res.quantity
# 7. Mark newly dead agents as corpses (don't remove yet for visualization)
newly_dead = self._mark_dead_agents(current_turn)
@ -297,9 +349,17 @@ class GameEngine:
elif action == ActionType.CONSUME:
if decision.target_resource:
success = agent.consume(decision.target_resource)
consumed_list = []
if success:
consumed_list.append(Resource(
type=decision.target_resource,
quantity=1,
created_turn=self.world.current_turn,
))
return ActionResult(
action_type=action,
success=success,
resources_consumed=consumed_list,
message=f"Consumed {decision.target_resource.value}" if success else "Nothing to consume",
)
return ActionResult(action_type=action, success=False, message="No resource specified")
@ -318,6 +378,7 @@ class GameEngine:
success=True,
energy_spent=abs(config.energy_cost),
heat_gained=fire_heat,
resources_consumed=[Resource(type=ResourceType.WOOD, quantity=1, created_turn=self.world.current_turn)],
message="Built a warm fire",
)
return ActionResult(action_type=action, success=False, message="No wood for fire")
@ -350,6 +411,7 @@ class GameEngine:
)
# Check required materials
resources_consumed = []
if config.requires_resource:
if not agent.has_resource(config.requires_resource, config.requires_quantity):
agent.restore_energy(energy_cost) # Refund energy
@ -359,6 +421,11 @@ class GameEngine:
message=f"Missing required {config.requires_resource.value}",
)
agent.remove_from_inventory(config.requires_resource, config.requires_quantity)
resources_consumed.append(Resource(
type=config.requires_resource,
quantity=config.requires_quantity,
created_turn=self.world.current_turn,
))
# Get relevant skill for this action
skill_name = self._get_skill_for_action(action)
@ -434,6 +501,7 @@ class GameEngine:
success=True,
energy_spent=energy_cost,
resources_gained=resources_gained,
resources_consumed=resources_consumed,
message=message,
)
@ -483,6 +551,10 @@ class GameEngine:
self.world.current_turn,
)
# Track traded resources
res_type = result.resource_type.value
self.resource_stats["traded"][res_type] = self.resource_stats["traded"].get(res_type, 0) + result.quantity
# Deduct money from buyer
agent.money -= result.total_paid
@ -612,6 +684,10 @@ class GameEngine:
self.world.current_turn
)
# Track traded resources
res_type = result.resource_type.value
self.resource_stats["traded"][res_type] = self.resource_stats["traded"].get(res_type, 0) + result.quantity
resource = Resource(
type=result.resource_type,
quantity=result.quantity,
@ -684,6 +760,31 @@ class GameEngine:
"recent_logs": [
log.to_dict() for log in self.turn_logs[-5:]
],
"resource_stats": self._get_resource_stats(),
}
def _get_resource_stats(self) -> dict:
"""Get comprehensive resource statistics."""
# Calculate current inventory totals
in_inventory = {}
for agent in self.world.get_living_agents():
for res in agent.inventory:
res_type = res.type.value
in_inventory[res_type] = in_inventory.get(res_type, 0) + res.quantity
# Calculate current market totals
in_market = {}
for order in self.market.get_active_orders():
res_type = order.resource_type.value
in_market[res_type] = in_market.get(res_type, 0) + order.quantity
return {
"produced": self.resource_stats["produced"].copy(),
"consumed": self.resource_stats["consumed"].copy(),
"spoiled": self.resource_stats["spoiled"].copy(),
"traded": self.resource_stats["traded"].copy(),
"in_inventory": in_inventory,
"in_market": in_market,
}

View File

@ -246,6 +246,10 @@ class Agent:
total_trades_completed: int = 0
total_money_earned: int = 0
# Personal action log (recent actions with results)
action_history: list = field(default_factory=list)
MAX_HISTORY_SIZE: int = 20
# Configuration - loaded from config
INVENTORY_SLOTS: int = field(default=-1) # -1 signals to use config value
MOVE_SPEED: float = 0.8 # Grid cells per turn
@ -283,6 +287,19 @@ class Agent:
if action_type in self.actions_performed:
self.actions_performed[action_type] += 1
def log_action(self, turn: int, action_type: str, result: str, success: bool = True) -> None:
"""Add an action to the agent's personal history log."""
entry = {
"turn": turn,
"action": action_type,
"result": result,
"success": success,
}
self.action_history.append(entry)
# Keep only recent history
if len(self.action_history) > self.MAX_HISTORY_SIZE:
self.action_history = self.action_history[-self.MAX_HISTORY_SIZE:]
def record_trade(self, money_earned: int) -> None:
"""Record a completed trade for statistics."""
self.total_trades_completed += 1
@ -511,4 +528,6 @@ class Agent:
"actions_performed": self.actions_performed.copy(),
"total_trades": self.total_trades_completed,
"total_money_earned": self.total_money_earned,
# Personal action history
"action_history": self.action_history.copy(),
}

View File

@ -1,12 +1,17 @@
"""FastAPI entry point for the Village Simulation backend."""
import os
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from backend.api.routes import router
from backend.core.engine import get_engine
# Path to web frontend
WEB_FRONTEND_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "web_frontend")
# Create FastAPI app
app = FastAPI(
title="Village Simulation API",
@ -48,6 +53,7 @@ def root():
"name": "Village Simulation API",
"version": "1.0.0",
"docs": "/docs",
"web_frontend": "/web/",
"status": "running",
}
@ -63,6 +69,14 @@ def health_check():
}
# ============== Web Frontend Static Files ==============
# Mount static files for web frontend
# Access at http://localhost:8000/web/
if os.path.exists(WEB_FRONTEND_PATH):
app.mount("/web", StaticFiles(directory=WEB_FRONTEND_PATH, html=True), name="web_frontend")
def main():
"""Run the server."""
uvicorn.run(

279
web_frontend/index.html Normal file
View File

@ -0,0 +1,279 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VillSim - Village Economy Simulation</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Crimson+Pro:wght@400;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
<script src="https://cdn.jsdelivr.net/npm/phaser@3.80.1/dist/phaser.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
</head>
<body>
<div id="app">
<header id="header">
<div class="header-left">
<h1 class="title">VillSim</h1>
<span class="subtitle">Village Economy Simulation</span>
</div>
<div class="header-center">
<div class="time-display">
<span id="day-display">Day 1</span>
<span class="separator">·</span>
<span id="time-display">☀️ Day</span>
<span class="separator">·</span>
<span id="turn-display">Turn 0</span>
</div>
</div>
<div class="header-right">
<div class="connection-status" id="connection-status">
<span class="status-dot disconnected"></span>
<span class="status-text">Disconnected</span>
</div>
</div>
</header>
<main id="main-content">
<aside id="left-panel" class="panel">
<div class="panel-section">
<h3 class="section-title">Population</h3>
<div class="stat-grid" id="population-stats">
<div class="stat-item">
<span class="stat-value" id="stat-alive">0</span>
<span class="stat-label">Alive</span>
</div>
<div class="stat-item">
<span class="stat-value" id="stat-dead">0</span>
<span class="stat-label">Dead</span>
</div>
</div>
</div>
<div class="panel-section">
<h3 class="section-title">Professions</h3>
<div class="profession-list" id="profession-list">
<!-- Filled by JS -->
</div>
</div>
<div class="panel-section">
<h3 class="section-title">Economy</h3>
<div class="economy-stats" id="economy-stats">
<div class="economy-item">
<span class="economy-label">Money in Circulation</span>
<span class="economy-value" id="stat-money">0</span>
</div>
</div>
</div>
</aside>
<div id="game-container">
<!-- Phaser canvas will be inserted here -->
</div>
<aside id="right-panel" class="panel">
<div class="panel-section agent-section scrollable-section">
<h3 class="section-title">Selected Agent</h3>
<div id="agent-details" class="agent-details">
<p class="no-selection">Click an agent to view details</p>
</div>
</div>
<div class="panel-section">
<h3 class="section-title">Market Prices</h3>
<div class="market-prices" id="market-prices">
<!-- Filled by JS -->
</div>
</div>
<div class="panel-section">
<h3 class="section-title">Activity Log</h3>
<div class="activity-log" id="activity-log">
<!-- Filled by JS -->
</div>
</div>
</aside>
</main>
<footer id="footer">
<div class="controls">
<button id="btn-initialize" class="btn btn-secondary" title="Reset Simulation">
<span class="btn-icon"></span> Reset
</button>
<button id="btn-step" class="btn btn-primary" title="Advance one turn">
<span class="btn-icon"></span> Step
</button>
<button id="btn-auto" class="btn btn-toggle" title="Toggle auto mode">
<span class="btn-icon"></span> Auto
</button>
<button id="btn-stats" class="btn btn-secondary" title="View Statistics">
<span class="btn-icon">📊</span> Stats
</button>
</div>
<div class="speed-control">
<label for="speed-slider">Speed</label>
<input type="range" id="speed-slider" min="50" max="1000" value="150" step="50">
<span id="speed-display">150ms</span>
</div>
</footer>
<!-- Stats Screen (Full View) -->
<div id="stats-screen" class="stats-screen hidden">
<div class="stats-header">
<div class="stats-header-left">
<h2>📊 Simulation Statistics</h2>
<span class="stats-subtitle">Real-time metrics and charts</span>
</div>
<div class="stats-header-center">
<div class="stats-tabs">
<button class="tab-btn active" data-tab="prices">Prices</button>
<button class="tab-btn" data-tab="wealth">Wealth</button>
<button class="tab-btn" data-tab="population">Population</button>
<button class="tab-btn" data-tab="professions">Professions</button>
<button class="tab-btn" data-tab="resources">Resources</button>
<button class="tab-btn" data-tab="market">Market</button>
<button class="tab-btn" data-tab="agents">Agents</button>
</div>
</div>
<div class="stats-header-right">
<button id="btn-close-stats" class="btn btn-secondary">
<span class="btn-icon"></span> Back to Game
</button>
</div>
</div>
<div class="stats-body">
<!-- Prices Tab -->
<div id="tab-prices" class="tab-panel active">
<div class="chart-wrapper">
<canvas id="chart-prices"></canvas>
</div>
</div>
<!-- Wealth Tab -->
<div id="tab-wealth" class="tab-panel">
<div class="chart-grid three-col">
<div class="chart-wrapper">
<canvas id="chart-wealth-dist"></canvas>
</div>
<div class="chart-wrapper">
<canvas id="chart-wealth-prof"></canvas>
</div>
<div class="chart-wrapper">
<canvas id="chart-wealth-time"></canvas>
</div>
</div>
</div>
<!-- Population Tab -->
<div id="tab-population" class="tab-panel">
<div class="chart-wrapper">
<canvas id="chart-population"></canvas>
</div>
</div>
<!-- Professions Tab -->
<div id="tab-professions" class="tab-panel">
<div class="chart-grid two-col">
<div class="chart-wrapper">
<canvas id="chart-prof-pie"></canvas>
</div>
<div class="chart-wrapper">
<canvas id="chart-prof-time"></canvas>
</div>
</div>
</div>
<!-- Resources Tab -->
<div id="tab-resources" class="tab-panel">
<div class="chart-grid four-col">
<div class="chart-wrapper">
<canvas id="chart-res-produced"></canvas>
</div>
<div class="chart-wrapper">
<canvas id="chart-res-consumed"></canvas>
</div>
<div class="chart-wrapper">
<canvas id="chart-res-spoiled"></canvas>
</div>
<div class="chart-wrapper">
<canvas id="chart-res-stock"></canvas>
</div>
</div>
<div class="chart-grid four-col" style="margin-top: 16px;">
<div class="chart-wrapper">
<canvas id="chart-res-cum-produced"></canvas>
</div>
<div class="chart-wrapper">
<canvas id="chart-res-cum-consumed"></canvas>
</div>
<div class="chart-wrapper">
<canvas id="chart-res-cum-spoiled"></canvas>
</div>
<div class="chart-wrapper">
<canvas id="chart-res-cum-traded"></canvas>
</div>
</div>
</div>
<!-- Market Tab -->
<div id="tab-market" class="tab-panel">
<div class="chart-grid two-col">
<div class="chart-wrapper">
<canvas id="chart-market-supply"></canvas>
</div>
<div class="chart-wrapper">
<canvas id="chart-market-activity"></canvas>
</div>
</div>
</div>
<!-- Agents Tab -->
<div id="tab-agents" class="tab-panel">
<div class="chart-grid four-col">
<div class="chart-wrapper">
<canvas id="chart-stat-energy"></canvas>
</div>
<div class="chart-wrapper">
<canvas id="chart-stat-hunger"></canvas>
</div>
<div class="chart-wrapper">
<canvas id="chart-stat-thirst"></canvas>
</div>
<div class="chart-wrapper">
<canvas id="chart-stat-heat"></canvas>
</div>
</div>
</div>
</div>
<div class="stats-footer">
<div class="stats-summary-bar">
<div class="summary-item">
<span class="summary-label">Turn</span>
<span class="summary-value" id="stats-turn">0</span>
</div>
<div class="summary-item">
<span class="summary-label">Living</span>
<span class="summary-value highlight" id="stats-living">0</span>
</div>
<div class="summary-item">
<span class="summary-label">Deaths</span>
<span class="summary-value danger" id="stats-deaths">0</span>
</div>
<div class="summary-item">
<span class="summary-label">Total Gold</span>
<span class="summary-value gold" id="stats-gold">0</span>
</div>
<div class="summary-item">
<span class="summary-label">Avg Wealth</span>
<span class="summary-value" id="stats-avg-wealth">0</span>
</div>
<div class="summary-item">
<span class="summary-label">Gini Index</span>
<span class="summary-value" id="stats-gini">0.00</span>
</div>
</div>
</div>
</div>
</div>
<!-- Load game modules -->
<script type="module" src="src/main.js"></script>
</body>
</html>

132
web_frontend/src/api.js Normal file
View File

@ -0,0 +1,132 @@
/**
* VillSim API Client
* Handles all communication with the backend simulation server.
*/
// Auto-detect API base from current page location (same origin)
function getApiBase() {
// When served by the backend, use same origin
if (typeof window !== 'undefined') {
return window.location.origin;
}
// Fallback for development
return 'http://localhost:8000';
}
class SimulationAPI {
constructor() {
this.baseUrl = getApiBase();
this.connected = false;
this.lastState = null;
}
async request(endpoint, options = {}) {
const url = `${this.baseUrl}${endpoint}`;
try {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
this.connected = true;
return await response.json();
} catch (error) {
console.error(`API Error (${endpoint}):`, error.message);
this.connected = false;
throw error;
}
}
// Health check
async checkHealth() {
try {
const data = await this.request('/health');
this.connected = data.status === 'healthy';
return this.connected;
} catch {
this.connected = false;
return false;
}
}
// Get full simulation state
async getState() {
const data = await this.request('/api/state');
this.lastState = data;
return data;
}
// Get all agents
async getAgents() {
return await this.request('/api/agents');
}
// Get specific agent
async getAgent(agentId) {
return await this.request(`/api/agents/${agentId}`);
}
// Get market orders
async getMarketOrders() {
return await this.request('/api/market/orders');
}
// Get market prices
async getMarketPrices() {
return await this.request('/api/market/prices');
}
// Control: Initialize simulation
async initialize(numAgents = 8, worldWidth = 20, worldHeight = 20) {
return await this.request('/api/control/initialize', {
method: 'POST',
body: JSON.stringify({
num_agents: numAgents,
world_width: worldWidth,
world_height: worldHeight,
}),
});
}
// Control: Advance one step
async nextStep() {
return await this.request('/api/control/next_step', {
method: 'POST',
});
}
// Control: Set mode (manual/auto)
async setMode(mode) {
return await this.request('/api/control/mode', {
method: 'POST',
body: JSON.stringify({ mode }),
});
}
// Control: Get status
async getStatus() {
return await this.request('/api/control/status');
}
// Config: Get configuration
async getConfig() {
return await this.request('/api/config');
}
// Logs: Get recent logs
async getLogs(limit = 10) {
return await this.request(`/api/logs?limit=${limit}`);
}
}
// Export singleton instance
export const api = new SimulationAPI();
export default api;

View File

@ -0,0 +1,71 @@
/**
* VillSim Constants
* Shared constants for the Phaser game.
*/
// Profession icons and colors
export const PROFESSIONS = {
hunter: { icon: '🏹', color: 0xc45c5c, name: 'Hunter' },
gatherer: { icon: '🌿', color: 0x6bab5e, name: 'Gatherer' },
woodcutter: { icon: '🪓', color: 0xa67c52, name: 'Woodcutter' },
trader: { icon: '💰', color: 0xd4a84b, name: 'Trader' },
crafter: { icon: '🧵', color: 0x8b6fc0, name: 'Crafter' },
villager: { icon: '👤', color: 0x7a8899, name: 'Villager' },
};
// Resource icons and colors
export const RESOURCES = {
meat: { icon: '🥩', color: 0xc45c5c, name: 'Meat' },
berries: { icon: '🫐', color: 0xa855a8, name: 'Berries' },
water: { icon: '💧', color: 0x5a8cc8, name: 'Water' },
wood: { icon: '🪵', color: 0xa67c52, name: 'Wood' },
hide: { icon: '🦴', color: 0x8b7355, name: 'Hide' },
clothes: { icon: '👕', color: 0x6b6560, name: 'Clothes' },
};
// Action icons
export const ACTIONS = {
hunt: { icon: '🏹', verb: 'hunting' },
gather: { icon: '🌿', verb: 'gathering' },
chop_wood: { icon: '🪓', verb: 'chopping wood' },
get_water: { icon: '💧', verb: 'getting water' },
weave: { icon: '🧵', verb: 'weaving' },
build_fire: { icon: '🔥', verb: 'building fire' },
trade: { icon: '💰', verb: 'trading' },
rest: { icon: '💤', verb: 'resting' },
sleep: { icon: '😴', verb: 'sleeping' },
consume: { icon: '🍽️', verb: 'consuming' },
idle: { icon: '⏳', verb: 'idle' },
};
// Time of day
export const TIME_OF_DAY = {
day: { icon: '☀️', name: 'Day' },
night: { icon: '🌙', name: 'Night' },
};
// World zones (approximate x-positions as percentages)
export const WORLD_ZONES = {
river: { start: 0.0, end: 0.15, color: 0x3a6ea5, name: 'River' },
bushes: { start: 0.15, end: 0.35, color: 0x4a7c59, name: 'Berry Bushes' },
village: { start: 0.35, end: 0.65, color: 0x8b7355, name: 'Village' },
forest: { start: 0.65, end: 1.0, color: 0x2d5016, name: 'Forest' },
};
// Colors for stats
export const STAT_COLORS = {
energy: 0xd4a84b,
hunger: 0xc87f5a,
thirst: 0x5a8cc8,
heat: 0xc45c5c,
};
// Game display settings
export const DISPLAY = {
TILE_SIZE: 32,
AGENT_SIZE: 24,
MIN_ZOOM: 0.5,
MAX_ZOOM: 2.0,
DEFAULT_ZOOM: 1.0,
};

73
web_frontend/src/main.js Normal file
View File

@ -0,0 +1,73 @@
/**
* VillSim - Phaser Web Frontend
* Main entry point
*/
import BootScene from './scenes/BootScene.js';
import GameScene from './scenes/GameScene.js';
// Calculate game dimensions based on container
function getGameDimensions() {
const container = document.getElementById('game-container');
if (!container) {
return { width: 800, height: 600 };
}
const rect = container.getBoundingClientRect();
return {
width: Math.floor(rect.width),
height: Math.floor(rect.height),
};
}
// Phaser game configuration
const { width, height } = getGameDimensions();
const config = {
type: Phaser.AUTO,
parent: 'game-container',
width: width,
height: height,
backgroundColor: '#151921',
scene: [BootScene, GameScene],
scale: {
mode: Phaser.Scale.RESIZE,
autoCenter: Phaser.Scale.CENTER_BOTH,
},
render: {
antialias: true,
pixelArt: false,
roundPixels: true,
},
physics: {
default: 'arcade',
arcade: {
gravity: { y: 0 },
debug: false,
},
},
dom: {
createContainer: true,
},
};
// Initialize game when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
console.log('VillSim Web Frontend starting...');
// Create Phaser game
const game = new Phaser.Game(config);
// Handle window resize
window.addEventListener('resize', () => {
const { width, height } = getGameDimensions();
game.scale.resize(width, height);
});
// Store game reference globally for debugging
window.villsimGame = game;
});
// Export for debugging
export { config };

View File

@ -0,0 +1,141 @@
/**
* BootScene - Initial loading and setup
*/
import { api } from '../api.js';
export default class BootScene extends Phaser.Scene {
constructor() {
super({ key: 'BootScene' });
}
preload() {
// Create loading graphics
const { width, height } = this.cameras.main;
// Background
this.add.rectangle(width / 2, height / 2, width, height, 0x151921);
// Loading text
this.loadingText = this.add.text(width / 2, height / 2 - 40, 'VillSim', {
fontSize: '48px',
fontFamily: 'Crimson Pro, Georgia, serif',
color: '#d4a84b',
}).setOrigin(0.5);
this.statusText = this.add.text(width / 2, height / 2 + 20, 'Connecting to server...', {
fontSize: '18px',
fontFamily: 'Crimson Pro, Georgia, serif',
color: '#a8a095',
}).setOrigin(0.5);
// Progress bar background
const barWidth = 300;
const barHeight = 8;
this.progressBg = this.add.rectangle(
width / 2, height / 2 + 60,
barWidth, barHeight,
0x242b3d
).setOrigin(0.5);
this.progressBar = this.add.rectangle(
width / 2 - barWidth / 2, height / 2 + 60,
0, barHeight,
0xd4a84b
).setOrigin(0, 0.5);
}
async create() {
// Animate progress bar
this.tweens.add({
targets: this.progressBar,
width: 100,
duration: 500,
ease: 'Power2',
});
// Attempt to connect to server
this.statusText.setText('Connecting to server...');
let connected = false;
let retries = 0;
const maxRetries = 10;
while (!connected && retries < maxRetries) {
try {
connected = await api.checkHealth();
if (connected) {
this.statusText.setText('Connected! Loading simulation...');
this.tweens.add({
targets: this.progressBar,
width: 200,
duration: 300,
ease: 'Power2',
});
} else {
retries++;
this.statusText.setText(`Connecting... (attempt ${retries}/${maxRetries})`);
await this.delay(1000);
}
} catch (error) {
retries++;
this.statusText.setText(`Connection failed. Retrying... (${retries}/${maxRetries})`);
await this.delay(1000);
}
}
if (!connected) {
this.statusText.setText('Could not connect to server. Is the backend running?');
this.statusText.setColor('#c45c5c');
// Add retry button
const retryBtn = this.add.text(
this.cameras.main.width / 2,
this.cameras.main.height / 2 + 100,
'[ Click to Retry ]',
{
fontSize: '16px',
fontFamily: 'Crimson Pro, Georgia, serif',
color: '#d4a84b',
}
).setOrigin(0.5).setInteractive({ useHandCursor: true });
retryBtn.on('pointerup', () => {
this.scene.restart();
});
retryBtn.on('pointerover', () => retryBtn.setColor('#e8e4dc'));
retryBtn.on('pointerout', () => retryBtn.setColor('#d4a84b'));
return;
}
// Load initial state
try {
const state = await api.getState();
this.registry.set('simulationState', state);
this.tweens.add({
targets: this.progressBar,
width: 300,
duration: 300,
ease: 'Power2',
onComplete: () => {
this.statusText.setText('Starting simulation...');
this.time.delayedCall(500, () => {
this.scene.start('GameScene');
});
}
});
} catch (error) {
this.statusText.setText('Error loading simulation state');
this.statusText.setColor('#c45c5c');
console.error('Failed to load state:', error);
}
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
/**
* Scene exports
*/
export { default as BootScene } from './BootScene.js';
export { default as GameScene } from './GameScene.js';

1053
web_frontend/styles.css Normal file

File diff suppressed because it is too large Load Diff