884 lines
35 KiB
Python
884 lines
35 KiB
Python
"""Real-time statistics and charts renderer for the Village Simulation.
|
|
|
|
Includes tabs for Economy, Religion, Factions, and Diplomacy.
|
|
Uses matplotlib for beautiful data visualization.
|
|
"""
|
|
|
|
import io
|
|
from dataclasses import dataclass, field
|
|
from collections import deque
|
|
from typing import TYPE_CHECKING, Optional
|
|
|
|
import pygame
|
|
import matplotlib
|
|
matplotlib.use('Agg')
|
|
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
|
|
|
|
|
|
class ChartColors:
|
|
"""Color palette for charts - dark cyberpunk theme."""
|
|
BG = '#0f1117'
|
|
PANEL = '#1a1d26'
|
|
GRID = '#2a2f3d'
|
|
TEXT = '#e0e4eb'
|
|
TEXT_DIM = '#6b7280'
|
|
|
|
# Neon accents
|
|
CYAN = '#00d4ff'
|
|
MAGENTA = '#ff0099'
|
|
LIME = '#39ff14'
|
|
ORANGE = '#ff6600'
|
|
PURPLE = '#9d4edd'
|
|
YELLOW = '#ffcc00'
|
|
TEAL = '#00ffa3'
|
|
PINK = '#ff1493'
|
|
|
|
SERIES = [CYAN, MAGENTA, LIME, ORANGE, PURPLE, YELLOW, TEAL, PINK]
|
|
|
|
# Faction colors (matching backend)
|
|
FACTIONS = {
|
|
"northlands": "#64a0dc",
|
|
"riverfolk": "#46a0b4",
|
|
"forestkin": "#5aa050",
|
|
"mountaineer": "#96785a",
|
|
"plainsmen": "#c8b464",
|
|
"neutral": "#787878",
|
|
}
|
|
|
|
# Religion colors
|
|
RELIGIONS = {
|
|
"solaris": "#ffc850",
|
|
"aquarius": "#50aaf0",
|
|
"terranus": "#a07846",
|
|
"ignis": "#f06432",
|
|
"naturis": "#64c864",
|
|
"atheist": "#8c8c8c",
|
|
}
|
|
|
|
|
|
class UIColors:
|
|
"""Color palette for pygame UI."""
|
|
BG = (15, 17, 23)
|
|
PANEL_BG = (26, 29, 38)
|
|
PANEL_BORDER = (55, 65, 85)
|
|
TEXT_PRIMARY = (224, 228, 235)
|
|
TEXT_SECONDARY = (107, 114, 128)
|
|
TEXT_HIGHLIGHT = (0, 212, 255)
|
|
TAB_ACTIVE = (0, 212, 255)
|
|
TAB_INACTIVE = (45, 50, 65)
|
|
TAB_HOVER = (65, 75, 95)
|
|
|
|
|
|
@dataclass
|
|
class HistoryData:
|
|
"""Stores historical simulation data for charting."""
|
|
max_history: int = 300
|
|
|
|
# Time series
|
|
turns: deque = field(default_factory=lambda: deque(maxlen=300))
|
|
population: deque = field(default_factory=lambda: deque(maxlen=300))
|
|
deaths_cumulative: deque = field(default_factory=lambda: deque(maxlen=300))
|
|
|
|
# Wealth data
|
|
total_money: deque = field(default_factory=lambda: deque(maxlen=300))
|
|
avg_wealth: deque = field(default_factory=lambda: deque(maxlen=300))
|
|
gini_coefficient: deque = field(default_factory=lambda: deque(maxlen=300))
|
|
|
|
# Price history
|
|
prices: dict = field(default_factory=dict)
|
|
|
|
# Trade statistics
|
|
trade_volume: deque = field(default_factory=lambda: deque(maxlen=300))
|
|
|
|
# Faction data
|
|
factions: dict = field(default_factory=dict)
|
|
|
|
# Religion data
|
|
religions: dict = field(default_factory=dict)
|
|
avg_faith: deque = field(default_factory=lambda: deque(maxlen=300))
|
|
|
|
# Diplomacy data
|
|
active_wars: deque = field(default_factory=lambda: deque(maxlen=300))
|
|
peace_treaties: deque = field(default_factory=lambda: deque(maxlen=300))
|
|
|
|
def clear(self) -> None:
|
|
"""Clear all history."""
|
|
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.factions.clear()
|
|
self.religions.clear()
|
|
self.avg_faith.clear()
|
|
self.active_wars.clear()
|
|
self.peace_treaties.clear()
|
|
|
|
def update(self, state: "SimulationState") -> None:
|
|
"""Update history with new state."""
|
|
turn = state.turn
|
|
|
|
if self.turns and self.turns[-1] == turn:
|
|
return
|
|
|
|
self.turns.append(turn)
|
|
|
|
# Population
|
|
living = len(state.get_living_agents())
|
|
self.population.append(living)
|
|
self.deaths_cumulative.append(state.statistics.get("total_agents_died", 0))
|
|
|
|
# Wealth
|
|
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))
|
|
|
|
# Prices
|
|
for resource, data in state.market_prices.items():
|
|
if resource not in self.prices:
|
|
self.prices[resource] = deque(maxlen=self.max_history)
|
|
price = data.get("lowest_price") or data.get("avg_sale_price")
|
|
self.prices[resource].append(price)
|
|
|
|
# Trade volume
|
|
self.trade_volume.append(len(state.market_orders))
|
|
|
|
# Factions
|
|
faction_stats = state.get_faction_stats()
|
|
for faction, count in faction_stats.items():
|
|
if faction not in self.factions:
|
|
self.factions[faction] = deque(maxlen=self.max_history)
|
|
self.factions[faction].append(count)
|
|
for faction in self.factions:
|
|
if faction not in faction_stats:
|
|
self.factions[faction].append(0)
|
|
|
|
# Religions
|
|
religion_stats = state.get_religion_stats()
|
|
for religion, count in religion_stats.items():
|
|
if religion not in self.religions:
|
|
self.religions[religion] = deque(maxlen=self.max_history)
|
|
self.religions[religion].append(count)
|
|
for religion in self.religions:
|
|
if religion not in religion_stats:
|
|
self.religions[religion].append(0)
|
|
|
|
# Faith
|
|
self.avg_faith.append(state.get_avg_faith())
|
|
|
|
# Diplomacy
|
|
self.active_wars.append(len(state.active_wars))
|
|
self.peace_treaties.append(len(state.peace_treaties))
|
|
|
|
|
|
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
|
|
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': 12,
|
|
'axes.titleweight': 'bold',
|
|
})
|
|
|
|
def _fig_to_surface(self, fig: Figure) -> pygame.Surface:
|
|
"""Convert matplotlib figure to 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, w: int, h: int) -> pygame.Surface:
|
|
"""Render price history chart."""
|
|
fig, ax = plt.subplots(figsize=(w/self.dpi, h/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)]
|
|
valid = [p if p is not None else 0 for p in prices]
|
|
min_len = min(len(turns), len(valid))
|
|
ax.plot(
|
|
list(turns)[-min_len:], valid[-min_len:],
|
|
color=color, linewidth=1.5, label=resource.title(), alpha=0.9
|
|
)
|
|
has_data = True
|
|
|
|
ax.set_title('Market Prices Over Time', 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, ncol=2)
|
|
|
|
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, w: int, h: int) -> pygame.Surface:
|
|
"""Render population chart."""
|
|
fig, ax = plt.subplots(figsize=(w/self.dpi, h/self.dpi), dpi=self.dpi)
|
|
|
|
turns = list(history.turns) if history.turns else [0]
|
|
pop = 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(pop))
|
|
|
|
ax.fill_between(turns[-min_len:], pop[-min_len:], alpha=0.3, color=ChartColors.CYAN)
|
|
ax.plot(turns[-min_len:], pop[-min_len:], color=ChartColors.CYAN, linewidth=2, label='Living')
|
|
|
|
if deaths:
|
|
ax.plot(
|
|
turns[-min_len:], deaths[-min_len:],
|
|
color=ChartColors.MAGENTA, linewidth=1.5,
|
|
linestyle='--', label='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", w: int, h: int) -> pygame.Surface:
|
|
"""Render wealth distribution bar chart."""
|
|
fig, ax = plt.subplots(figsize=(w/self.dpi, h/self.dpi), dpi=self.dpi)
|
|
|
|
agents = state.get_living_agents()
|
|
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 and show top 20
|
|
sorted_agents = sorted(agents, key=lambda a: a.get("money", 0), reverse=True)[:20]
|
|
names = [a.get("name", "?")[:8] for a in sorted_agents]
|
|
wealth = [a.get("money", 0) for a in sorted_agents]
|
|
|
|
colors = []
|
|
for i, agent in enumerate(sorted_agents):
|
|
diplomacy = agent.get("diplomacy", {})
|
|
faction = diplomacy.get("faction", "neutral")
|
|
colors.append(ChartColors.FACTIONS.get(faction, "#787878"))
|
|
|
|
bars = ax.barh(range(len(sorted_agents)), wealth, color=colors, alpha=0.85)
|
|
ax.set_yticks(range(len(sorted_agents)))
|
|
ax.set_yticklabels(names, fontsize=7)
|
|
ax.invert_yaxis()
|
|
|
|
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 (Top 20)', 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, w: int, h: int) -> pygame.Surface:
|
|
"""Render wealth metrics over time."""
|
|
fig, (ax1, ax2) = plt.subplots(
|
|
2, 1, figsize=(w/self.dpi, h/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))
|
|
|
|
ax1.plot(turns[-min_len:], total[-min_len:], color=ChartColors.CYAN, linewidth=2, label='Total')
|
|
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')
|
|
ax1_twin.set_ylabel('Avg', color=ChartColors.LIME)
|
|
ax1_twin.tick_params(axis='y', labelcolor=ChartColors.LIME)
|
|
|
|
ax1.set_title('Money Circulation', color=ChartColors.YELLOW)
|
|
ax1.set_ylabel('Total', color=ChartColors.CYAN)
|
|
ax1.tick_params(axis='y', labelcolor=ChartColors.CYAN)
|
|
ax1.grid(True, alpha=0.2)
|
|
ax1.set_ylim(bottom=0)
|
|
|
|
min_gini = min(len(turns), len(gini))
|
|
ax2.fill_between(turns[-min_gini:], gini[-min_gini:], alpha=0.4, color=ChartColors.MAGENTA)
|
|
ax2.plot(turns[-min_gini:], gini[-min_gini:], color=ChartColors.MAGENTA, linewidth=1.5)
|
|
ax2.set_xlabel('Turn')
|
|
ax2.set_ylabel('Gini')
|
|
ax2.set_title('Inequality', color=ChartColors.MAGENTA, fontsize=9)
|
|
ax2.set_ylim(0, 1)
|
|
ax2.axhline(y=0.4, color=ChartColors.YELLOW, linestyle=':', alpha=0.5)
|
|
ax2.grid(True, alpha=0.2)
|
|
|
|
fig.tight_layout()
|
|
return self._fig_to_surface(fig)
|
|
|
|
def render_factions(self, state: "SimulationState", history: HistoryData, w: int, h: int) -> pygame.Surface:
|
|
"""Render faction distribution and history."""
|
|
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(w/self.dpi, h/self.dpi), dpi=self.dpi)
|
|
|
|
# Current pie chart
|
|
faction_stats = state.get_faction_stats()
|
|
if faction_stats:
|
|
labels = list(faction_stats.keys())
|
|
sizes = list(faction_stats.values())
|
|
colors = [ChartColors.FACTIONS.get(f, "#787878") for f in labels]
|
|
|
|
wedges, texts, autotexts = ax1.pie(
|
|
sizes, labels=[l.title() for l in 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('Faction Distribution', color=ChartColors.ORANGE, fontsize=10)
|
|
else:
|
|
ax1.text(0.5, 0.5, 'No data', ha='center', va='center', color=ChartColors.TEXT_DIM)
|
|
|
|
# History stacked area
|
|
turns = list(history.turns) if history.turns else [0]
|
|
if history.factions and turns:
|
|
faction_list = list(history.factions.keys())
|
|
data = []
|
|
for f in faction_list:
|
|
f_data = list(history.factions[f])
|
|
while len(f_data) < len(turns):
|
|
f_data.insert(0, 0)
|
|
data.append(f_data[-len(turns):])
|
|
|
|
colors = [ChartColors.FACTIONS.get(f, "#787878") for f in faction_list]
|
|
ax2.stackplot(turns, *data, labels=[f.title() for f in faction_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('Faction History', color=ChartColors.ORANGE, fontsize=10)
|
|
ax2.grid(True, alpha=0.2)
|
|
fig.tight_layout()
|
|
return self._fig_to_surface(fig)
|
|
|
|
def render_religions(self, state: "SimulationState", history: HistoryData, w: int, h: int) -> pygame.Surface:
|
|
"""Render religion distribution and faith levels."""
|
|
fig, axes = plt.subplots(2, 2, figsize=(w/self.dpi, h/self.dpi), dpi=self.dpi)
|
|
ax1, ax2, ax3, ax4 = axes.flat
|
|
|
|
# Current pie chart
|
|
religion_stats = state.get_religion_stats()
|
|
if religion_stats:
|
|
labels = list(religion_stats.keys())
|
|
sizes = list(religion_stats.values())
|
|
colors = [ChartColors.RELIGIONS.get(r, "#8c8c8c") for r in labels]
|
|
|
|
wedges, texts, autotexts = ax1.pie(
|
|
sizes, labels=[l.title() for l in labels], colors=colors,
|
|
autopct='%1.0f%%', startangle=90, pctdistance=0.75,
|
|
textprops={'fontsize': 7, 'color': ChartColors.TEXT}
|
|
)
|
|
for autotext in autotexts:
|
|
autotext.set_color(ChartColors.BG)
|
|
autotext.set_fontweight('bold')
|
|
autotext.set_fontsize(6)
|
|
ax1.set_title('Religion Distribution', color=ChartColors.PURPLE, fontsize=9)
|
|
|
|
# Religion history
|
|
turns = list(history.turns) if history.turns else [0]
|
|
if history.religions and turns:
|
|
religion_list = list(history.religions.keys())
|
|
data = []
|
|
for r in religion_list:
|
|
r_data = list(history.religions[r])
|
|
while len(r_data) < len(turns):
|
|
r_data.insert(0, 0)
|
|
data.append(r_data[-len(turns):])
|
|
|
|
colors = [ChartColors.RELIGIONS.get(r, "#8c8c8c") for r in religion_list]
|
|
ax2.stackplot(turns, *data, labels=[r.title() for r in religion_list], colors=colors, alpha=0.8)
|
|
ax2.legend(loc='upper left', fontsize=6, framealpha=0.8)
|
|
ax2.set_xlabel('Turn')
|
|
ax2.set_title('Religion History', color=ChartColors.PURPLE, fontsize=9)
|
|
ax2.grid(True, alpha=0.2)
|
|
|
|
# Faith distribution histogram
|
|
agents = state.get_living_agents()
|
|
if agents:
|
|
faiths = [a.get("faith", 50) for a in agents]
|
|
ax3.hist(faiths, bins=20, color=ChartColors.PURPLE, alpha=0.7, edgecolor=ChartColors.PANEL)
|
|
avg_faith = np.mean(faiths)
|
|
ax3.axvline(x=avg_faith, color=ChartColors.YELLOW, linestyle='--', label=f'Avg: {avg_faith:.0f}')
|
|
ax3.legend(fontsize=7)
|
|
ax3.set_title('Faith Distribution', color=ChartColors.PURPLE, fontsize=9)
|
|
ax3.set_xlabel('Faith Level')
|
|
ax3.set_xlim(0, 100)
|
|
ax3.grid(True, alpha=0.2)
|
|
|
|
# Average faith over time
|
|
if history.avg_faith and turns:
|
|
min_len = min(len(turns), len(history.avg_faith))
|
|
faith_data = list(history.avg_faith)[-min_len:]
|
|
ax4.fill_between(list(turns)[-min_len:], faith_data, alpha=0.3, color=ChartColors.PURPLE)
|
|
ax4.plot(list(turns)[-min_len:], faith_data, color=ChartColors.PURPLE, linewidth=2)
|
|
ax4.set_title('Avg Faith Over Time', color=ChartColors.PURPLE, fontsize=9)
|
|
ax4.set_xlabel('Turn')
|
|
ax4.set_ylim(0, 100)
|
|
ax4.grid(True, alpha=0.2)
|
|
|
|
fig.tight_layout()
|
|
return self._fig_to_surface(fig)
|
|
|
|
def render_diplomacy(self, state: "SimulationState", history: HistoryData, w: int, h: int) -> pygame.Surface:
|
|
"""Render diplomacy stats - wars and peace treaties."""
|
|
fig, axes = plt.subplots(2, 2, figsize=(w/self.dpi, h/self.dpi), dpi=self.dpi)
|
|
ax1, ax2, ax3, ax4 = axes.flat
|
|
|
|
turns = list(history.turns) if history.turns else [0]
|
|
|
|
# Wars over time
|
|
if history.active_wars:
|
|
min_len = min(len(turns), len(history.active_wars))
|
|
wars_data = list(history.active_wars)[-min_len:]
|
|
ax1.fill_between(list(turns)[-min_len:], wars_data, alpha=0.4, color=ChartColors.MAGENTA)
|
|
ax1.plot(list(turns)[-min_len:], wars_data, color=ChartColors.MAGENTA, linewidth=2)
|
|
ax1.set_title('Active Wars', color=ChartColors.MAGENTA, fontsize=10)
|
|
ax1.set_xlabel('Turn')
|
|
ax1.set_ylim(bottom=0)
|
|
ax1.yaxis.set_major_locator(mticker.MaxNLocator(integer=True))
|
|
ax1.grid(True, alpha=0.2)
|
|
|
|
# Peace treaties over time
|
|
if history.peace_treaties:
|
|
min_len = min(len(turns), len(history.peace_treaties))
|
|
peace_data = list(history.peace_treaties)[-min_len:]
|
|
ax2.fill_between(list(turns)[-min_len:], peace_data, alpha=0.4, color=ChartColors.LIME)
|
|
ax2.plot(list(turns)[-min_len:], peace_data, color=ChartColors.LIME, linewidth=2)
|
|
ax2.set_title('Peace Treaties', color=ChartColors.LIME, fontsize=10)
|
|
ax2.set_xlabel('Turn')
|
|
ax2.set_ylim(bottom=0)
|
|
ax2.yaxis.set_major_locator(mticker.MaxNLocator(integer=True))
|
|
ax2.grid(True, alpha=0.2)
|
|
|
|
# Current wars list
|
|
active_wars = state.active_wars
|
|
if active_wars:
|
|
war_text = '\n'.join([
|
|
f"{w.get('faction1', '?')[:8]} vs {w.get('faction2', '?')[:8]}"
|
|
for w in active_wars[:6]
|
|
])
|
|
ax3.text(0.5, 0.5, war_text, ha='center', va='center',
|
|
fontsize=9, color=ChartColors.MAGENTA,
|
|
transform=ax3.transAxes, family='monospace')
|
|
else:
|
|
ax3.text(0.5, 0.5, '☮ No Active Wars', ha='center', va='center',
|
|
fontsize=12, color=ChartColors.LIME, transform=ax3.transAxes)
|
|
ax3.set_title('Current Conflicts', color=ChartColors.MAGENTA, fontsize=10)
|
|
ax3.set_xticks([])
|
|
ax3.set_yticks([])
|
|
|
|
# Faction relations heatmap (simplified)
|
|
relations = state.faction_relations
|
|
if relations:
|
|
factions = list(relations.keys())[:5] # Top 5 factions
|
|
n = len(factions)
|
|
matrix = np.zeros((n, n))
|
|
|
|
for i, f1 in enumerate(factions):
|
|
for j, f2 in enumerate(factions):
|
|
if f1 in relations and f2 in relations.get(f1, {}):
|
|
rel_data = relations[f1].get(f2, {})
|
|
if isinstance(rel_data, dict):
|
|
matrix[i, j] = rel_data.get("value", 50)
|
|
else:
|
|
matrix[i, j] = 50
|
|
elif i == j:
|
|
matrix[i, j] = 100
|
|
else:
|
|
matrix[i, j] = 50
|
|
|
|
im = ax4.imshow(matrix, cmap='RdYlGn', vmin=0, vmax=100)
|
|
ax4.set_xticks(range(n))
|
|
ax4.set_yticks(range(n))
|
|
ax4.set_xticklabels([f[:5] for f in factions], fontsize=7, rotation=45)
|
|
ax4.set_yticklabels([f[:5] for f in factions], fontsize=7)
|
|
plt.colorbar(im, ax=ax4, shrink=0.8)
|
|
ax4.set_title('Relations', color=ChartColors.CYAN, fontsize=10)
|
|
|
|
fig.tight_layout()
|
|
return self._fig_to_surface(fig)
|
|
|
|
def render_agent_stats(self, state: "SimulationState", w: int, h: int) -> pygame.Surface:
|
|
"""Render agent stats distribution."""
|
|
fig, axes = plt.subplots(2, 2, figsize=(w/self.dpi, h/self.dpi), dpi=self.dpi)
|
|
|
|
agents = state.get_living_agents()
|
|
|
|
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)
|
|
|
|
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]
|
|
|
|
stats_data = [
|
|
(energies, 100, 'Energy', ChartColors.LIME),
|
|
(hungers, 100, 'Hunger', ChartColors.ORANGE),
|
|
(thirsts, 100, 'Thirst', ChartColors.CYAN),
|
|
(heats, 100, 'Heat', ChartColors.MAGENTA),
|
|
]
|
|
|
|
for ax, (values, max_val, name, color) in zip(axes.flat, stats_data):
|
|
bins = np.linspace(0, max_val, 11)
|
|
ax.hist(values, bins=bins, color=color, alpha=0.7, edgecolor=ChartColors.PANEL)
|
|
|
|
mean_val = np.mean(values)
|
|
ax.axvline(x=mean_val, color=ChartColors.TEXT, linestyle='--', linewidth=1.5, label=f'Avg: {mean_val:.0f}')
|
|
|
|
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', color=ChartColors.CYAN, fontsize=11)
|
|
fig.tight_layout()
|
|
return self._fig_to_surface(fig)
|
|
|
|
def render_market_activity(self, state: "SimulationState", history: HistoryData, w: int, h: int) -> pygame.Surface:
|
|
"""Render market activity charts."""
|
|
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(w/self.dpi, h/self.dpi), dpi=self.dpi)
|
|
|
|
# Market supply
|
|
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.title()[:7])
|
|
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=7, 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')
|
|
|
|
# Trade volume over time
|
|
turns = list(history.turns) if history.turns else [0]
|
|
if history.trade_volume:
|
|
min_len = min(len(turns), len(history.trade_volume))
|
|
volume_data = list(history.trade_volume)[-min_len:]
|
|
ax2.fill_between(list(turns)[-min_len:], volume_data, alpha=0.3, color=ChartColors.TEAL)
|
|
ax2.plot(list(turns)[-min_len:], volume_data, color=ChartColors.TEAL, linewidth=2)
|
|
ax2.set_title('Trade Volume', color=ChartColors.TEAL, fontsize=10)
|
|
ax2.set_xlabel('Turn')
|
|
ax2.grid(True, alpha=0.2)
|
|
|
|
fig.tight_layout()
|
|
return self._fig_to_surface(fig)
|
|
|
|
|
|
class StatsRenderer:
|
|
"""Main statistics panel with tabbed interface."""
|
|
|
|
TABS = [
|
|
("Population", "population"),
|
|
("Prices", "price_history"),
|
|
("Wealth", "wealth"),
|
|
("Factions", "factions"),
|
|
("Religion", "religions"),
|
|
("Diplomacy", "diplomacy"),
|
|
("Market", "market"),
|
|
("Agent Stats", "agent_stats"),
|
|
]
|
|
|
|
def __init__(self, screen: pygame.Surface):
|
|
self.screen = screen
|
|
self.visible = False
|
|
|
|
self.font = pygame.font.Font(None, 22)
|
|
self.small_font = pygame.font.Font(None, 16)
|
|
self.title_font = pygame.font.Font(None, 28)
|
|
|
|
self.current_tab = 0
|
|
self.tab_hovered = -1
|
|
|
|
self.history = HistoryData()
|
|
self.chart_renderer: Optional[ChartRenderer] = None
|
|
|
|
self._chart_cache: dict[str, pygame.Surface] = {}
|
|
self._cache_turn: int = -1
|
|
|
|
self._calculate_layout()
|
|
|
|
def _calculate_layout(self) -> None:
|
|
"""Calculate panel layout."""
|
|
screen_w, screen_h = self.screen.get_size()
|
|
|
|
margin = 40
|
|
self.panel_rect = pygame.Rect(
|
|
margin, margin,
|
|
screen_w - margin * 2,
|
|
screen_h - margin * 2
|
|
)
|
|
|
|
self.tab_height = 36
|
|
self.tab_rect = pygame.Rect(
|
|
self.panel_rect.x,
|
|
self.panel_rect.y,
|
|
self.panel_rect.width,
|
|
self.tab_height
|
|
)
|
|
|
|
self.chart_rect = pygame.Rect(
|
|
self.panel_rect.x + 15,
|
|
self.panel_rect.y + self.tab_height + 15,
|
|
self.panel_rect.width - 30,
|
|
self.panel_rect.height - self.tab_height - 45
|
|
)
|
|
|
|
self.chart_renderer = ChartRenderer(self.chart_rect.width, self.chart_rect.height)
|
|
self.tab_width = self.panel_rect.width // len(self.TABS)
|
|
|
|
def toggle(self) -> None:
|
|
"""Toggle visibility."""
|
|
self.visible = not self.visible
|
|
if self.visible:
|
|
self._invalidate_cache()
|
|
|
|
def update_history(self, state: "SimulationState") -> None:
|
|
"""Update history data."""
|
|
if state:
|
|
self.history.update(state)
|
|
|
|
def clear_history(self) -> None:
|
|
"""Clear history."""
|
|
self.history.clear()
|
|
self._invalidate_cache()
|
|
|
|
def _invalidate_cache(self) -> None:
|
|
"""Invalidate chart cache."""
|
|
self._chart_cache.clear()
|
|
self._cache_turn = -1
|
|
|
|
def handle_event(self, event: pygame.event.Event) -> bool:
|
|
"""Handle events."""
|
|
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
|
|
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."""
|
|
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 click."""
|
|
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 current tab chart."""
|
|
tab_name, tab_key = self.TABS[self.current_tab]
|
|
|
|
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]
|
|
|
|
w, h = self.chart_rect.width, self.chart_rect.height
|
|
|
|
if tab_key == "population":
|
|
surface = self.chart_renderer.render_population(self.history, w, h)
|
|
elif tab_key == "price_history":
|
|
surface = self.chart_renderer.render_price_history(self.history, w, h)
|
|
elif tab_key == "wealth":
|
|
half_h = h // 2
|
|
dist = self.chart_renderer.render_wealth_distribution(state, w, half_h)
|
|
time_chart = self.chart_renderer.render_wealth_over_time(self.history, w, half_h)
|
|
surface = pygame.Surface((w, h))
|
|
surface.fill(UIColors.BG)
|
|
surface.blit(dist, (0, 0))
|
|
surface.blit(time_chart, (0, half_h))
|
|
elif tab_key == "factions":
|
|
surface = self.chart_renderer.render_factions(state, self.history, w, h)
|
|
elif tab_key == "religions":
|
|
surface = self.chart_renderer.render_religions(state, self.history, w, h)
|
|
elif tab_key == "diplomacy":
|
|
surface = self.chart_renderer.render_diplomacy(state, self.history, w, h)
|
|
elif tab_key == "market":
|
|
surface = self.chart_renderer.render_market_activity(state, self.history, w, h)
|
|
elif tab_key == "agent_stats":
|
|
surface = self.chart_renderer.render_agent_stats(state, w, h)
|
|
else:
|
|
surface = pygame.Surface((w, h))
|
|
surface.fill(UIColors.BG)
|
|
|
|
self._chart_cache[tab_key] = surface
|
|
self._cache_turn = current_turn
|
|
return surface
|
|
|
|
def draw(self, state: "SimulationState") -> None:
|
|
"""Draw statistics panel."""
|
|
if not self.visible:
|
|
return
|
|
|
|
# Dim background
|
|
overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA)
|
|
overlay.fill((0, 0, 0, 230))
|
|
self.screen.blit(overlay, (0, 0))
|
|
|
|
# Panel background
|
|
pygame.draw.rect(self.screen, UIColors.PANEL_BG, self.panel_rect, border_radius=10)
|
|
pygame.draw.rect(self.screen, UIColors.PANEL_BORDER, self.panel_rect, 2, border_radius=10)
|
|
|
|
# Tabs
|
|
self._draw_tabs()
|
|
|
|
# Chart
|
|
if state:
|
|
chart = self._render_chart(state)
|
|
self.screen.blit(chart, self.chart_rect.topleft)
|
|
|
|
# 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 - 22)
|
|
self.screen.blit(hint, hint_rect)
|
|
|
|
def _draw_tabs(self) -> None:
|
|
"""Draw 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)
|
|
|
|
if i == self.current_tab:
|
|
color = UIColors.TAB_ACTIVE
|
|
elif i == self.tab_hovered:
|
|
color = UIColors.TAB_HOVER
|
|
else:
|
|
color = UIColors.TAB_INACTIVE
|
|
|
|
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=6, border_top_right_radius=6
|
|
)
|
|
|
|
if i == self.current_tab:
|
|
tab_surface.set_alpha(255)
|
|
else:
|
|
tab_surface.set_alpha(180)
|
|
|
|
self.screen.blit(tab_surface, (tab_x, self.tab_rect.y))
|
|
|
|
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)
|
|
|
|
if i != self.current_tab and i < len(self.TABS) - 1:
|
|
pygame.draw.line(
|
|
self.screen, UIColors.PANEL_BORDER,
|
|
(tab_x + self.tab_width - 1, self.tab_rect.y + 6),
|
|
(tab_x + self.tab_width - 1, self.tab_rect.y + self.tab_height - 6)
|
|
)
|