"""Agent renderer for the Village Simulation. Optimized for 100+ agents with faction/religion color coding. """ import math import pygame from typing import TYPE_CHECKING if TYPE_CHECKING: from frontend.client import SimulationState from frontend.renderer.map_renderer import MapRenderer # Faction colors - matches backend FactionType FACTION_COLORS = { "northlands": (100, 160, 220), # Ice blue "riverfolk": (70, 160, 180), # River teal "forestkin": (90, 160, 80), # Forest green "mountaineer": (150, 120, 90), # Mountain brown "plainsmen": (200, 180, 100), # Plains gold "neutral": (120, 120, 120), # Gray } # Religion colors RELIGION_COLORS = { "solaris": (255, 200, 80), # Golden sun "aquarius": (80, 170, 240), # Ocean blue "terranus": (160, 120, 70), # Earth brown "ignis": (240, 100, 50), # Fire red "naturis": (100, 200, 100), # Forest green "atheist": (140, 140, 140), # Gray } # Corpse color CORPSE_COLOR = (50, 50, 55) # Action symbols (simplified for performance) ACTION_SYMBOLS = { "hunt": "⚔", "gather": "◆", "chop_wood": "▲", "get_water": "◎", "weave": "⊕", "build_fire": "◈", "trade": "$", "rest": "○", "sleep": "◐", "consume": "●", "drill_oil": "⛏", "refine": "⚙", "pray": "✦", "preach": "✧", "negotiate": "⚖", "declare_war": "⚔", "make_peace": "☮", "burn_fuel": "◈", "dead": "✖", } # Fallback ASCII ACTION_LETTERS = { "hunt": "H", "gather": "G", "chop_wood": "W", "get_water": "~", "weave": "C", "build_fire": "F", "trade": "$", "rest": "R", "sleep": "Z", "consume": "E", "drill_oil": "O", "refine": "U", "pray": "P", "preach": "!", "negotiate": "N", "declare_war": "!", "make_peace": "+", "burn_fuel": "B", "dead": "X", } class AgentRenderer: """Renders agents on the map with faction/religion indicators.""" def __init__( self, screen: pygame.Surface, map_renderer: "MapRenderer", font: pygame.font.Font, ): self.screen = screen self.map_renderer = map_renderer self.font = font self.small_font = pygame.font.Font(None, 14) self.action_font = pygame.font.Font(None, 16) self.tooltip_font = pygame.font.Font(None, 18) # Animation state self.animation_tick = 0 # Performance: limit detail level based on agent count self.detail_level = 2 # 0=minimal, 1=basic, 2=full def _get_faction_color(self, agent: dict) -> tuple[int, int, int]: """Get agent's faction color.""" # Faction is under diplomacy.faction (not faction.type) diplomacy = agent.get("diplomacy", {}) faction = diplomacy.get("faction", "neutral") return FACTION_COLORS.get(faction, FACTION_COLORS["neutral"]) def _get_religion_color(self, agent: dict) -> tuple[int, int, int]: """Get agent's religion color.""" # Religion type is under religion.religion (not religion.type) religion_data = agent.get("religion", {}) religion = religion_data.get("religion", "atheist") return RELIGION_COLORS.get(religion, RELIGION_COLORS["atheist"]) def _get_agent_color(self, agent: dict) -> tuple[int, int, int]: """Get the main color for an agent (faction-based).""" if agent.get("is_corpse", False) or not agent.get("is_alive", True): return CORPSE_COLOR base_color = self._get_faction_color(agent) if not agent.get("can_act", True): # Dimmed for exhausted agents return tuple(int(c * 0.6) for c in base_color) return base_color def _draw_mini_bar( self, x: int, y: int, width: int, height: int, value: float, max_value: float, color: tuple[int, int, int], ) -> None: """Draw a tiny status bar.""" if max_value <= 0: return # Background pygame.draw.rect(self.screen, (25, 25, 30), (x, y, width, height)) # Fill fill_width = int((value / max_value) * width) if fill_width > 0: # Color gradient based on value ratio = value / max_value if ratio < 0.25: bar_color = (200, 60, 60) # Critical - red elif ratio < 0.5: bar_color = (200, 150, 60) # Low - orange else: bar_color = color pygame.draw.rect(self.screen, bar_color, (x, y, fill_width, height)) def _draw_status_bars( self, agent: dict, center_x: int, center_y: int, size: int ) -> None: """Draw compact status bars below the agent.""" stats = agent.get("stats", {}) bar_width = size + 6 bar_height = 2 bar_spacing = 3 start_y = center_y + size // 2 + 3 bars = [ (stats.get("energy", 0), stats.get("max_energy", 100), (220, 200, 80)), (stats.get("hunger", 0), stats.get("max_hunger", 100), (200, 130, 80)), (stats.get("thirst", 0), stats.get("max_thirst", 100), (80, 160, 200)), ] for i, (value, max_value, color) in enumerate(bars): bar_y = start_y + i * bar_spacing self._draw_mini_bar( center_x - bar_width // 2, bar_y, bar_width, bar_height, value, max_value, color, ) def _draw_action_bubble( self, agent: dict, center_x: int, center_y: int, agent_size: int, ) -> None: """Draw action indicator bubble above agent.""" current_action = agent.get("current_action", {}) action_type = current_action.get("action_type", "") if not action_type: return # Get action symbol symbol = ACTION_LETTERS.get(action_type, "?") # Position above agent bubble_y = center_y - agent_size // 2 - 12 # Animate if moving is_moving = current_action.get("is_moving", False) if is_moving: offset = int(2 * math.sin(self.animation_tick * 0.3)) bubble_y += offset # Draw small bubble bubble_w, bubble_h = 14, 12 bubble_rect = pygame.Rect( center_x - bubble_w // 2, bubble_y - bubble_h // 2, bubble_w, bubble_h, ) # Color based on action type if action_type in ["pray", "preach"]: bg_color = (60, 50, 80) border_color = (120, 100, 160) elif action_type in ["negotiate", "make_peace"]: bg_color = (50, 70, 80) border_color = (100, 160, 180) elif action_type in ["declare_war"]: bg_color = (80, 40, 40) border_color = (180, 80, 80) elif action_type in ["drill_oil", "refine", "burn_fuel"]: bg_color = (60, 55, 40) border_color = (140, 120, 80) elif is_moving: bg_color = (50, 60, 80) border_color = (100, 140, 200) else: bg_color = (40, 55, 45) border_color = (80, 130, 90) pygame.draw.rect(self.screen, bg_color, bubble_rect, border_radius=3) pygame.draw.rect(self.screen, border_color, bubble_rect, 1, border_radius=3) # Draw symbol text = self.action_font.render(symbol, True, (230, 230, 230)) text_rect = text.get_rect(center=(center_x, bubble_y)) self.screen.blit(text, text_rect) def _draw_religion_indicator( self, agent: dict, center_x: int, center_y: int, agent_size: int, ) -> None: """Draw a small religion indicator (faith glow).""" faith = agent.get("faith", 50) religion_color = self._get_religion_color(agent) # Only show for agents with significant faith if faith > 70: # Divine glow effect glow_alpha = int((faith / 100) * 60) glow_surface = pygame.Surface( (agent_size * 2, agent_size * 2), pygame.SRCALPHA ) pygame.draw.circle( glow_surface, (*religion_color, glow_alpha), (agent_size, agent_size), agent_size, ) self.screen.blit( glow_surface, (center_x - agent_size, center_y - agent_size), ) # Small religion dot indicator dot_x = center_x + agent_size // 2 - 2 dot_y = center_y - agent_size // 2 + 2 pygame.draw.circle(self.screen, religion_color, (dot_x, dot_y), 3) pygame.draw.circle(self.screen, (30, 30, 35), (dot_x, dot_y), 3, 1) def _draw_war_indicator(self, agent: dict, center_x: int, center_y: int) -> None: """Draw indicator if agent's faction is at war.""" diplomacy = agent.get("diplomacy", {}) faction = diplomacy.get("faction", "neutral") at_war = agent.get("at_war", False) if at_war: # Red war indicator pulse = 0.5 + 0.5 * math.sin(self.animation_tick * 0.15) war_color = (int(200 * pulse), int(50 * pulse), int(50 * pulse)) pygame.draw.circle( self.screen, war_color, (center_x - 6, center_y - 6), 3, ) def draw(self, state: "SimulationState") -> None: """Draw all agents (optimized for many agents).""" self.animation_tick += 1 cell_w, cell_h = self.map_renderer.get_cell_size() agent_size = min(cell_w, cell_h) - 6 agent_size = max(8, min(agent_size, 24)) # Adjust detail level based on agent count living_count = len(state.get_living_agents()) if living_count > 150: self.detail_level = 0 elif living_count > 80: self.detail_level = 1 else: self.detail_level = 2 # Separate corpses and living agents corpses = [] living = [] for agent in state.agents: if agent.get("is_corpse", False): corpses.append(agent) elif agent.get("is_alive", True): living.append(agent) # Draw corpses first (behind living agents) for agent in corpses: pos = agent.get("position", {"x": 0, "y": 0}) screen_x, screen_y = self.map_renderer.grid_to_screen(pos["x"], pos["y"]) self._draw_corpse(agent, screen_x, screen_y, agent_size) # Draw living agents for agent in living: pos = agent.get("position", {"x": 0, "y": 0}) screen_x, screen_y = self.map_renderer.grid_to_screen(pos["x"], pos["y"]) # Religion glow (full detail only) if self.detail_level >= 2: self._draw_religion_indicator(agent, screen_x, screen_y, agent_size) # Action bubble (basic+ detail) if self.detail_level >= 1: self._draw_action_bubble(agent, screen_x, screen_y, agent_size) # Main agent circle with faction color color = self._get_agent_color(agent) pygame.draw.circle( self.screen, color, (screen_x, screen_y), agent_size // 2, ) # Border - based on state current_action = agent.get("current_action", {}) is_moving = current_action.get("is_moving", False) if is_moving: pulse = int(180 + 75 * math.sin(self.animation_tick * 0.2)) border_color = (pulse, pulse, 255) elif agent.get("can_act"): border_color = (200, 200, 210) else: border_color = (80, 80, 85) pygame.draw.circle( self.screen, border_color, (screen_x, screen_y), agent_size // 2, 1, ) # Money indicator money = agent.get("money", 0) if money > 50: coin_x = screen_x + agent_size // 2 - 2 coin_y = screen_y - agent_size // 2 - 2 pygame.draw.circle(self.screen, (255, 215, 0), (coin_x, coin_y), 3) # War indicator if self.detail_level >= 1: self._draw_war_indicator(agent, screen_x, screen_y) # Status bars (basic+ detail) if self.detail_level >= 1: self._draw_status_bars(agent, screen_x, screen_y, agent_size) def _draw_corpse( self, agent: dict, center_x: int, center_y: int, agent_size: int, ) -> None: """Draw a corpse marker.""" # Simple X marker pygame.draw.circle( self.screen, CORPSE_COLOR, (center_x, center_y), agent_size // 3, ) pygame.draw.circle( self.screen, (100, 50, 50), (center_x, center_y), agent_size // 3, 1, ) # X symbol half = agent_size // 4 pygame.draw.line( self.screen, (120, 60, 60), (center_x - half, center_y - half), (center_x + half, center_y + half), 1, ) pygame.draw.line( self.screen, (120, 60, 60), (center_x + half, center_y - half), (center_x - half, center_y + half), 1, ) def draw_agent_tooltip(self, agent: dict, mouse_pos: tuple[int, int]) -> None: """Draw a detailed tooltip for hovered agent.""" lines = [] # Name and faction name = agent.get("name", "Unknown") diplomacy = agent.get("diplomacy", {}) faction = diplomacy.get("faction", "neutral").title() lines.append(f"{name}") lines.append(f"Faction: {faction}") # Religion and faith religion_data = agent.get("religion", {}) religion = religion_data.get("religion", "atheist").title() faith = religion_data.get("faith", 50) lines.append(f"Religion: {religion} ({faith}% faith)") # Money money = agent.get("money", 0) lines.append(f"Money: {money} coins") lines.append("") # Stats stats = agent.get("stats", {}) lines.append(f"Energy: {stats.get('energy', 0)}/{stats.get('max_energy', 100)}") lines.append(f"Hunger: {stats.get('hunger', 0)}/{stats.get('max_hunger', 100)}") lines.append(f"Thirst: {stats.get('thirst', 0)}/{stats.get('max_thirst', 100)}") lines.append(f"Heat: {stats.get('heat', 0)}/{stats.get('max_heat', 100)}") # Current action current_action = agent.get("current_action", {}) action_type = current_action.get("action_type", "") if action_type: lines.append("") lines.append(f"Action: {action_type.replace('_', ' ').title()}") if current_action.get("is_moving"): lines.append(" (moving)") # Inventory summary inventory = agent.get("inventory", []) if inventory: lines.append("") lines.append("Inventory:") for item in inventory[:4]: item_type = item.get("type", "?") qty = item.get("quantity", 0) lines.append(f" {item_type}: {qty}") if len(inventory) > 4: lines.append(f" ...+{len(inventory) - 4} more") # Calculate size line_height = 16 max_width = max(self.tooltip_font.size(line)[0] for line in lines if line) + 24 height = len(lines) * line_height + 16 # Position x = min(mouse_pos[0] + 15, self.screen.get_width() - max_width - 10) y = min(mouse_pos[1] + 15, self.screen.get_height() - height - 10) # Background with faction color accent tooltip_rect = pygame.Rect(x, y, max_width, height) pygame.draw.rect(self.screen, (25, 28, 35), tooltip_rect, border_radius=6) # Faction color accent bar faction_color = self._get_faction_color(agent) pygame.draw.rect( self.screen, faction_color, (x, y, 4, height), border_top_left_radius=6, border_bottom_left_radius=6, ) pygame.draw.rect( self.screen, (60, 70, 85), tooltip_rect, 1, border_radius=6, ) # Draw text for i, line in enumerate(lines): if not line: continue color = (220, 220, 225) if i == 0 else (170, 175, 185) text = self.tooltip_font.render(line, True, color) self.screen.blit(text, (x + 12, y + 8 + i * line_height))