"""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)