villsim/frontend/renderer/ui_renderer.py

646 lines
24 KiB
Python

"""UI renderer for the Village Simulation.
Beautiful dark theme with panels for statistics, factions, religion, and diplomacy.
"""
import math
import pygame
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frontend.client import SimulationState
class Colors:
# Base UI colors - dark cyberpunk theme
BG = (15, 17, 23)
PANEL_BG = (22, 26, 35)
PANEL_HEADER = (28, 33, 45)
PANEL_BORDER = (45, 55, 70)
PANEL_ACCENT = (60, 80, 110)
# Text
TEXT_PRIMARY = (225, 228, 235)
TEXT_SECONDARY = (140, 150, 165)
TEXT_HIGHLIGHT = (100, 200, 255)
TEXT_WARNING = (255, 180, 80)
TEXT_DANGER = (255, 100, 100)
TEXT_SUCCESS = (100, 220, 140)
# Day/Night
DAY_COLOR = (255, 220, 100)
NIGHT_COLOR = (100, 140, 200)
# Faction colors
FACTIONS = {
"northlands": (100, 160, 220),
"riverfolk": (70, 160, 180),
"forestkin": (90, 160, 80),
"mountaineer": (150, 120, 90),
"plainsmen": (200, 180, 100),
"neutral": (120, 120, 120),
}
# Religion colors
RELIGIONS = {
"solaris": (255, 200, 80),
"aquarius": (80, 170, 240),
"terranus": (160, 120, 70),
"ignis": (240, 100, 50),
"naturis": (100, 200, 100),
"atheist": (140, 140, 140),
}
# Scrollbar
SCROLLBAR_BG = (35, 40, 50)
SCROLLBAR_HANDLE = (70, 90, 120)
class UIRenderer:
"""Renders UI elements (HUD, panels, text info)."""
def __init__(
self,
screen: pygame.Surface,
font: pygame.font.Font,
top_panel_height: int = 50,
right_panel_width: int = 280,
bottom_panel_height: int = 60,
):
self.screen = screen
self.font = font
self.top_panel_height = top_panel_height
self.right_panel_width = right_panel_width
self.bottom_panel_height = bottom_panel_height
# Fonts
self.small_font = pygame.font.Font(None, 16)
self.medium_font = pygame.font.Font(None, 20)
self.title_font = pygame.font.Font(None, 24)
self.large_font = pygame.font.Font(None, 28)
# Scrolling state for right panel
self.scroll_offset = 0
self.max_scroll = 0
self.scroll_dragging = False
# Animation
self.animation_tick = 0
def _draw_panel_bg(
self,
rect: pygame.Rect,
title: str = None,
accent_color: tuple = None,
) -> int:
"""Draw a panel background. Returns Y position after header."""
# Main background
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect, border_radius=4)
pygame.draw.rect(self.screen, Colors.PANEL_BORDER, rect, 1, border_radius=4)
y = rect.y + 6
if title:
# Header area
header_rect = pygame.Rect(rect.x, rect.y, rect.width, 24)
pygame.draw.rect(
self.screen, Colors.PANEL_HEADER, header_rect,
border_top_left_radius=4, border_top_right_radius=4,
)
# Accent line
if accent_color:
pygame.draw.line(
self.screen, accent_color,
(rect.x + 2, rect.y + 24),
(rect.x + rect.width - 2, rect.y + 24),
2,
)
# Title
text = self.medium_font.render(title, True, Colors.TEXT_PRIMARY)
self.screen.blit(text, (rect.x + 10, rect.y + 5))
y = rect.y + 30
return y
def _draw_progress_bar(
self,
x: int,
y: int,
width: int,
height: int,
value: float,
max_value: float,
color: tuple,
bg_color: tuple = (35, 40, 50),
show_label: bool = False,
label: str = "",
) -> None:
"""Draw a styled progress bar."""
# Background
pygame.draw.rect(self.screen, bg_color, (x, y, width, height), border_radius=2)
# Fill
if max_value > 0:
ratio = min(1.0, value / max_value)
fill_width = int(ratio * width)
if fill_width > 0:
pygame.draw.rect(
self.screen, color,
(x, y, fill_width, height),
border_radius=2,
)
# Label
if show_label and label:
text = self.small_font.render(label, True, Colors.TEXT_PRIMARY)
text_rect = text.get_rect(midleft=(x + 4, y + height // 2))
self.screen.blit(text, text_rect)
def draw_top_bar(self, state: "SimulationState") -> None:
"""Draw the top information bar."""
self.animation_tick += 1
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 - 1),
(self.screen.get_width(), self.top_panel_height - 1),
)
# Day/Night indicator with animated glow
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"
# Glow effect
glow_alpha = int(100 + 50 * math.sin(self.animation_tick * 0.05))
glow_surface = pygame.Surface((40, 40), pygame.SRCALPHA)
pygame.draw.circle(glow_surface, (*time_color, glow_alpha), (20, 20), 18)
self.screen.blit(glow_surface, (10, 5))
# Time circle
pygame.draw.circle(self.screen, time_color, (30, 25), 12)
pygame.draw.circle(self.screen, Colors.PANEL_BORDER, (30, 25), 12, 1)
# Time/turn info
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, (55, 14))
# Agent count
living = len(state.get_living_agents())
total = len(state.agents)
agent_text = f"Population: {living}/{total}"
color = Colors.TEXT_SUCCESS if living > total * 0.5 else Colors.TEXT_WARNING
if living < total * 0.25:
color = Colors.TEXT_DANGER
text = self.medium_font.render(agent_text, True, color)
self.screen.blit(text, (300, 16))
# Active wars indicator
active_wars = len(state.active_wars)
if active_wars > 0:
war_pulse = 0.5 + 0.5 * math.sin(self.animation_tick * 0.1)
war_color = (int(200 * war_pulse), int(60 * war_pulse), int(60 * war_pulse))
war_text = f"{active_wars} WAR{'S' if active_wars > 1 else ''}"
text = self.medium_font.render(war_text, True, war_color)
self.screen.blit(text, (450, 16))
# Mode and status (right side)
right_x = self.screen.get_width() - 180
mode_color = Colors.TEXT_HIGHLIGHT if state.mode == "auto" else Colors.TEXT_SECONDARY
mode_text = f"Mode: {state.mode.upper()}"
text = self.medium_font.render(mode_text, True, mode_color)
self.screen.blit(text, (right_x, 10))
# Running status
if state.is_running:
status_text = "● RUNNING"
status_color = Colors.TEXT_SUCCESS
else:
status_text = "○ STOPPED"
status_color = Colors.TEXT_SECONDARY
text = self.medium_font.render(status_text, True, status_color)
self.screen.blit(text, (right_x, 28))
def draw_right_panel(self, state: "SimulationState") -> None:
"""Draw the right information panel with scrollable content."""
panel_x = self.screen.get_width() - self.right_panel_width
panel_height = self.screen.get_height() - self.top_panel_height - self.bottom_panel_height
# Main panel background
rect = pygame.Rect(
panel_x, self.top_panel_height,
self.right_panel_width, 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() - self.bottom_panel_height),
)
# Content area with padding
content_x = panel_x + 12
content_width = self.right_panel_width - 24
y = self.top_panel_height + 10
# ═══════════════════════════════════════════════════════════════
# STATISTICS SECTION
# ═══════════════════════════════════════════════════════════════
y = self._draw_stats_section(state, content_x, y, content_width)
y += 15
# ═══════════════════════════════════════════════════════════════
# FACTIONS SECTION
# ═══════════════════════════════════════════════════════════════
y = self._draw_factions_section(state, content_x, y, content_width)
y += 15
# ═══════════════════════════════════════════════════════════════
# RELIGION SECTION
# ═══════════════════════════════════════════════════════════════
y = self._draw_religion_section(state, content_x, y, content_width)
y += 15
# ═══════════════════════════════════════════════════════════════
# DIPLOMACY SECTION
# ═══════════════════════════════════════════════════════════════
y = self._draw_diplomacy_section(state, content_x, y, content_width)
y += 15
# ═══════════════════════════════════════════════════════════════
# MARKET SECTION
# ═══════════════════════════════════════════════════════════════
y = self._draw_market_section(state, content_x, y, content_width)
def _draw_stats_section(
self, state: "SimulationState", x: int, y: int, width: int
) -> int:
"""Draw statistics section."""
# Section header
text = self.title_font.render("📊 Statistics", True, Colors.TEXT_HIGHLIGHT)
self.screen.blit(text, (x, y))
y += 24
stats = state.statistics
# Population bar
living = len(state.get_living_agents())
total = len(state.agents)
pop_color = Colors.TEXT_SUCCESS if living > total * 0.5 else Colors.TEXT_WARNING
pygame.draw.rect(
self.screen, Colors.SCROLLBAR_BG,
(x, y, width, 14), border_radius=2,
)
if total > 0:
ratio = living / total
pygame.draw.rect(
self.screen, pop_color,
(x, y, int(width * ratio), 14), border_radius=2,
)
pop_text = self.small_font.render(f"Pop: {living}/{total}", True, Colors.TEXT_PRIMARY)
self.screen.blit(pop_text, (x + 4, y + 1))
y += 20
# Deaths and money
deaths = stats.get("total_agents_died", 0)
total_money = stats.get("total_money_in_circulation", 0)
text = self.small_font.render(f"Deaths: {deaths}", True, Colors.TEXT_DANGER)
self.screen.blit(text, (x, y))
text = self.small_font.render(f"💰 {total_money}", True, (255, 215, 0))
self.screen.blit(text, (x + width // 2, y))
y += 16
# Average faith
avg_faith = state.get_avg_faith()
text = self.small_font.render(f"Avg Faith: {avg_faith:.0f}%", True, Colors.TEXT_SECONDARY)
self.screen.blit(text, (x, y))
y += 16
return y
def _draw_factions_section(
self, state: "SimulationState", x: int, y: int, width: int
) -> int:
"""Draw factions section with distribution bars."""
# Section header
text = self.title_font.render("⚔ Factions", True, (180, 160, 120))
self.screen.blit(text, (x, y))
y += 22
faction_stats = state.get_faction_stats()
total = sum(faction_stats.values()) or 1
# Sort by count
sorted_factions = sorted(
faction_stats.items(),
key=lambda x: x[1],
reverse=True
)
for faction, count in sorted_factions[:5]: # Top 5
color = Colors.FACTIONS.get(faction, Colors.FACTIONS["neutral"])
ratio = count / total
# Faction bar
bar_width = int(width * 0.6 * ratio * (total / max(1, sorted_factions[0][1])))
bar_width = max(4, min(bar_width, int(width * 0.6)))
pygame.draw.rect(
self.screen, (*color, 180),
(x, y, bar_width, 10), border_radius=2,
)
# Faction name and count
label = f"{faction[:8]}: {count}"
text = self.small_font.render(label, True, color)
self.screen.blit(text, (x + bar_width + 8, y - 1))
y += 14
return y
def _draw_religion_section(
self, state: "SimulationState", x: int, y: int, width: int
) -> int:
"""Draw religion section with distribution."""
# Section header
text = self.title_font.render("✦ Religions", True, (200, 180, 220))
self.screen.blit(text, (x, y))
y += 22
religion_stats = state.get_religion_stats()
total = sum(religion_stats.values()) or 1
# Sort by count
sorted_religions = sorted(
religion_stats.items(),
key=lambda x: x[1],
reverse=True
)
for religion, count in sorted_religions[:5]: # Top 5
color = Colors.RELIGIONS.get(religion, Colors.RELIGIONS["atheist"])
ratio = count / total
# Religion color dot
pygame.draw.circle(self.screen, color, (x + 5, y + 5), 4)
# Religion name, count, and percentage
pct = ratio * 100
label = f"{religion[:8]}: {count} ({pct:.0f}%)"
text = self.small_font.render(label, True, Colors.TEXT_SECONDARY)
self.screen.blit(text, (x + 14, y))
y += 14
return y
def _draw_diplomacy_section(
self, state: "SimulationState", x: int, y: int, width: int
) -> int:
"""Draw diplomacy section with wars and treaties."""
# Section header
text = self.title_font.render("🏛 Diplomacy", True, (120, 180, 200))
self.screen.blit(text, (x, y))
y += 22
# Active wars
active_wars = state.active_wars
if active_wars:
text = self.small_font.render("Active Wars:", True, Colors.TEXT_DANGER)
self.screen.blit(text, (x, y))
y += 14
for war in active_wars[:3]: # Show up to 3 wars
f1 = war.get("faction1", "?")[:6]
f2 = war.get("faction2", "?")[:6]
c1 = Colors.FACTIONS.get(war.get("faction1", "neutral"), (150, 150, 150))
c2 = Colors.FACTIONS.get(war.get("faction2", "neutral"), (150, 150, 150))
# War indicator
pygame.draw.circle(self.screen, c1, (x + 5, y + 5), 4)
text = self.small_font.render("", True, (200, 80, 80))
self.screen.blit(text, (x + 12, y - 1))
pygame.draw.circle(self.screen, c2, (x + 35, y + 5), 4)
war_text = f"{f1} vs {f2}"
text = self.small_font.render(war_text, True, Colors.TEXT_SECONDARY)
self.screen.blit(text, (x + 45, y))
y += 14
else:
text = self.small_font.render("☮ No active wars", True, Colors.TEXT_SUCCESS)
self.screen.blit(text, (x, y))
y += 14
# Peace treaties
peace_treaties = state.peace_treaties
if peace_treaties:
text = self.small_font.render(
f"Peace Treaties: {len(peace_treaties)}",
True, Colors.TEXT_SUCCESS
)
self.screen.blit(text, (x, y))
y += 14
# Recent diplomatic events
recent_events = state.diplomatic_events[:2]
if recent_events:
y += 4
for event in recent_events:
event_type = event.get("type", "unknown")
if event_type == "war_declared":
color = Colors.TEXT_DANGER
icon = ""
elif event_type == "peace_made":
color = Colors.TEXT_SUCCESS
icon = ""
else:
color = Colors.TEXT_SECONDARY
icon = ""
desc = event.get("description", event_type)[:25]
text = self.small_font.render(f"{icon} {desc}", True, color)
self.screen.blit(text, (x, y))
y += 12
return y
def _draw_market_section(
self, state: "SimulationState", x: int, y: int, width: int
) -> int:
"""Draw market section with prices."""
# Section header
text = self.title_font.render("💹 Market", True, (100, 200, 150))
self.screen.blit(text, (x, y))
y += 22
# 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 += 16
# Price summary (show resources with stock)
prices = state.market_prices
shown = 0
for resource, data in sorted(prices.items()):
if shown >= 6: # Limit display
break
total_available = data.get("total_available", 0)
if total_available > 0:
price = data.get("lowest_price", "?")
# Resource color coding
if "oil" in resource.lower() or "fuel" in resource.lower():
res_color = (180, 160, 100)
elif "meat" in resource.lower():
res_color = (200, 120, 100)
elif "water" in resource.lower():
res_color = (100, 160, 200)
else:
res_color = Colors.TEXT_SECONDARY
res_text = f"{resource[:6]}: {total_available}x @ {price}c"
text = self.small_font.render(res_text, True, res_color)
self.screen.blit(text, (x, y))
y += 14
shown += 1
if shown == 0:
text = self.small_font.render("No items for sale", True, Colors.TEXT_SECONDARY)
self.screen.blit(text, (x, y))
y += 14
return y
def draw_bottom_bar(self, state: "SimulationState") -> None:
"""Draw bottom information bar with event log."""
bar_y = self.screen.get_height() - self.bottom_panel_height
rect = pygame.Rect(0, bar_y, self.screen.get_width(), self.bottom_panel_height)
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect)
pygame.draw.line(
self.screen, Colors.PANEL_BORDER,
(0, bar_y), (self.screen.get_width(), bar_y),
)
# Recent events (religious + diplomatic)
x = 15
y = bar_y + 8
text = self.medium_font.render("Recent Events:", True, Colors.TEXT_SECONDARY)
self.screen.blit(text, (x, y))
x += 120
# Show recent religious events
for event in state.religious_events[:2]:
event_type = event.get("type", "")
desc = event.get("description", event_type)[:30]
if event_type == "conversion":
color = Colors.RELIGIONS.get(event.get("to_religion", "atheist"), (150, 150, 150))
elif event_type == "prayer":
color = (180, 160, 220)
else:
color = Colors.TEXT_SECONDARY
text = self.small_font.render(f"{desc}", True, color)
self.screen.blit(text, (x, y))
x += text.get_width() + 20
# Show recent diplomatic events
for event in state.diplomatic_events[:2]:
event_type = event.get("type", "")
desc = event.get("description", event_type)[:30]
if "war" in event_type.lower():
color = Colors.TEXT_DANGER
icon = ""
elif "peace" in event_type.lower():
color = Colors.TEXT_SUCCESS
icon = ""
else:
color = Colors.TEXT_SECONDARY
icon = "🏛"
text = self.small_font.render(f"{icon} {desc}", True, color)
self.screen.blit(text, (x, y))
x += text.get_width() + 20
# If no events, show placeholder
if not state.religious_events and not state.diplomatic_events:
text = self.small_font.render(
"No recent events",
True, Colors.TEXT_SECONDARY
)
self.screen.blit(text, (x, y))
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, 200))
self.screen.blit(overlay, (0, 0))
# Connection box
box_w, box_h = 400, 150
box_x = (self.screen.get_width() - box_w) // 2
box_y = (self.screen.get_height() - box_h) // 2
pygame.draw.rect(
self.screen, Colors.PANEL_BG,
(box_x, box_y, box_w, box_h), border_radius=10,
)
pygame.draw.rect(
self.screen, Colors.PANEL_ACCENT,
(box_x, box_y, box_w, box_h), 2, border_radius=10,
)
# Pulsing dot
pulse = 0.5 + 0.5 * math.sin(self.animation_tick * 0.1)
dot_color = (int(255 * pulse), int(180 * pulse), int(80 * pulse))
pygame.draw.circle(
self.screen, dot_color,
(box_x + 30, box_y + 40), 8,
)
# Text
text = self.large_font.render("Connecting to server...", True, Colors.TEXT_WARNING)
text_rect = text.get_rect(center=(self.screen.get_width() // 2, box_y + 40))
self.screen.blit(text, text_rect)
hint = self.medium_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, box_y + 80))
self.screen.blit(hint, hint_rect)
cmd = self.small_font.render(
"Run: python -m backend.main",
True, Colors.TEXT_HIGHLIGHT
)
cmd_rect = cmd.get_rect(center=(self.screen.get_width() // 2, box_y + 110))
self.screen.blit(cmd, cmd_rect)
def draw(self, state: "SimulationState") -> None:
"""Draw all UI elements."""
self.draw_top_bar(state)
self.draw_right_panel(state)
self.draw_bottom_bar(state)