"""Agent renderer for the Village Simulation.""" import math import pygame from typing import TYPE_CHECKING if TYPE_CHECKING: from frontend.client import SimulationState from frontend.renderer.map_renderer import MapRenderer # Profession colors (villager is the default now) PROFESSION_COLORS = { "villager": (100, 140, 180), # Blue-gray for generic villager "hunter": (180, 80, 80), # Red "gatherer": (80, 160, 80), # Green "woodcutter": (139, 90, 43), # Brown "crafter": (160, 120, 200), # Purple } # Corpse color CORPSE_COLOR = (60, 60, 60) # Dark gray # Status bar colors BAR_COLORS = { "energy": (255, 220, 80), # Yellow "hunger": (220, 140, 80), # Orange "thirst": (80, 160, 220), # Blue "heat": (220, 80, 80), # Red } # Action icons/symbols ACTION_SYMBOLS = { "hunt": "๐Ÿน", "gather": "๐Ÿ‡", "chop_wood": "๐Ÿช“", "get_water": "๐Ÿ’ง", "weave": "๐Ÿงต", "build_fire": "๐Ÿ”ฅ", "trade": "๐Ÿ’ฐ", "rest": "๐Ÿ’ค", "sleep": "๐Ÿ˜ด", "consume": "๐Ÿ–", "dead": "๐Ÿ’€", } # Fallback ASCII symbols for systems without emoji support ACTION_LETTERS = { "hunt": "H", "gather": "G", "chop_wood": "W", "get_water": "~", "weave": "C", "build_fire": "F", "trade": "$", "rest": "R", "sleep": "Z", "consume": "E", "dead": "X", } class AgentRenderer: """Renders agents on the map with movement and action 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, 16) self.action_font = pygame.font.Font(None, 20) # Animation state self.animation_tick = 0 def _get_agent_color(self, agent: dict) -> tuple[int, int, int]: """Get the color for an agent based on state.""" # Corpses are dark gray if agent.get("is_corpse", False) or not agent.get("is_alive", True): return CORPSE_COLOR profession = agent.get("profession", "villager") base_color = PROFESSION_COLORS.get(profession, (100, 140, 180)) if not agent.get("can_act", True): # Slightly dimmed for exhausted agents return tuple(int(c * 0.7) for c in base_color) return base_color def _draw_status_bar( self, x: int, y: int, width: int, height: int, value: int, max_value: int, color: tuple[int, int, int], ) -> None: """Draw a single status bar.""" # Background pygame.draw.rect(self.screen, (40, 40, 40), (x, y, width, height)) # Fill fill_width = int((value / max_value) * width) if max_value > 0 else 0 if fill_width > 0: pygame.draw.rect(self.screen, color, (x, y, fill_width, height)) # Border pygame.draw.rect(self.screen, (80, 80, 80), (x, y, width, height), 1) def _draw_status_bars(self, agent: dict, center_x: int, center_y: int, size: int) -> None: """Draw status bars below the agent.""" stats = agent.get("stats", {}) bar_width = size + 10 bar_height = 3 bar_spacing = 4 start_y = center_y + size // 2 + 4 bars = [ ("energy", stats.get("energy", 0), stats.get("max_energy", 100)), ("hunger", stats.get("hunger", 0), stats.get("max_hunger", 100)), ("thirst", stats.get("thirst", 0), stats.get("max_thirst", 50)), ("heat", stats.get("heat", 0), stats.get("max_heat", 100)), ] for i, (stat_name, value, max_value) in enumerate(bars): bar_y = start_y + i * bar_spacing self._draw_status_bar( center_x - bar_width // 2, bar_y, bar_width, bar_height, value, max_value, BAR_COLORS[stat_name], ) def _draw_action_indicator( self, agent: dict, center_x: int, center_y: int, agent_size: int, ) -> None: """Draw action indicator above the agent.""" current_action = agent.get("current_action", {}) action_type = current_action.get("action_type", "") is_moving = current_action.get("is_moving", False) message = current_action.get("message", "") if not action_type: return # Get action symbol symbol = ACTION_LETTERS.get(action_type, "?") # Draw action bubble above agent bubble_y = center_y - agent_size // 2 - 20 # Animate if moving if is_moving: # Bouncing animation offset = int(3 * math.sin(self.animation_tick * 0.3)) bubble_y += offset # Draw bubble background bubble_width = 22 bubble_height = 18 bubble_rect = pygame.Rect( center_x - bubble_width // 2, bubble_y - bubble_height // 2, bubble_width, bubble_height, ) # Color based on action success/failure if "Failed" in message: bg_color = (120, 60, 60) border_color = (180, 80, 80) elif is_moving: bg_color = (60, 80, 120) border_color = (100, 140, 200) else: bg_color = (50, 70, 50) border_color = (80, 140, 80) pygame.draw.rect(self.screen, bg_color, bubble_rect, border_radius=4) pygame.draw.rect(self.screen, border_color, bubble_rect, 1, border_radius=4) # Draw action letter text = self.action_font.render(symbol, True, (255, 255, 255)) text_rect = text.get_rect(center=(center_x, bubble_y)) self.screen.blit(text, text_rect) # Draw movement trail if moving if is_moving: target_pos = current_action.get("target_position") if target_pos: target_x, target_y = self.map_renderer.grid_to_screen( target_pos.get("x", 0), target_pos.get("y", 0), ) # Draw dotted line to target self._draw_dotted_line( (center_x, center_y), (target_x, target_y), (100, 100, 100), 4, ) def _draw_dotted_line( self, start: tuple[int, int], end: tuple[int, int], color: tuple[int, int, int], dot_spacing: int = 5, ) -> None: """Draw a dotted line between two points.""" dx = end[0] - start[0] dy = end[1] - start[1] distance = max(1, int((dx ** 2 + dy ** 2) ** 0.5)) for i in range(0, distance, dot_spacing * 2): t = i / distance x = int(start[0] + dx * t) y = int(start[1] + dy * t) pygame.draw.circle(self.screen, color, (x, y), 1) def _draw_last_action_result( self, agent: dict, center_x: int, center_y: int, agent_size: int, ) -> None: """Draw the last action result as floating text.""" result = agent.get("last_action_result", "") if not result: return # Truncate long messages if len(result) > 25: result = result[:22] + "..." # Draw text below status bars text_y = center_y + agent_size // 2 + 22 text = self.small_font.render(result, True, (180, 180, 180)) text_rect = text.get_rect(center=(center_x, text_y)) # Background for readability bg_rect = text_rect.inflate(4, 2) pygame.draw.rect(self.screen, (30, 30, 40, 180), bg_rect) self.screen.blit(text, text_rect) def draw(self, state: "SimulationState") -> None: """Draw all agents (including corpses for one turn).""" self.animation_tick += 1 cell_w, cell_h = self.map_renderer.get_cell_size() agent_size = min(cell_w, cell_h) - 8 agent_size = max(10, min(agent_size, 30)) # Clamp size for agent in state.agents: is_corpse = agent.get("is_corpse", False) is_alive = agent.get("is_alive", True) # Get screen position from agent's current position pos = agent.get("position", {"x": 0, "y": 0}) screen_x, screen_y = self.map_renderer.grid_to_screen(pos["x"], pos["y"]) if is_corpse: # Draw corpse with death indicator self._draw_corpse(agent, screen_x, screen_y, agent_size) continue if not is_alive: continue # Draw movement trail/line to target first (behind agent) self._draw_action_indicator(agent, screen_x, screen_y, agent_size) # Draw agent circle color = self._get_agent_color(agent) pygame.draw.circle(self.screen, color, (screen_x, screen_y), agent_size // 2) # Draw border - animated if moving current_action = agent.get("current_action", {}) is_moving = current_action.get("is_moving", False) if is_moving: # Pulsing border when moving pulse = int(127 + 127 * math.sin(self.animation_tick * 0.2)) border_color = (pulse, pulse, 255) elif agent.get("can_act"): border_color = (255, 255, 255) else: border_color = (100, 100, 100) pygame.draw.circle(self.screen, border_color, (screen_x, screen_y), agent_size // 2, 2) # Draw money indicator (small coin icon) money = agent.get("money", 0) if money > 0: coin_x = screen_x + agent_size // 2 - 4 coin_y = screen_y - agent_size // 2 - 4 pygame.draw.circle(self.screen, (255, 215, 0), (coin_x, coin_y), 4) pygame.draw.circle(self.screen, (200, 160, 0), (coin_x, coin_y), 4, 1) # Draw "V" for villager text = self.small_font.render("V", True, (255, 255, 255)) text_rect = text.get_rect(center=(screen_x, screen_y)) self.screen.blit(text, text_rect) # Draw status bars self._draw_status_bars(agent, screen_x, screen_y, agent_size) # Draw last action result self._draw_last_action_result(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 with death reason displayed.""" # Draw corpse circle (dark gray) pygame.draw.circle(self.screen, CORPSE_COLOR, (center_x, center_y), agent_size // 2) # Draw red X border pygame.draw.circle(self.screen, (150, 50, 50), (center_x, center_y), agent_size // 2, 2) # Draw skull symbol text = self.action_font.render("X", True, (180, 80, 80)) text_rect = text.get_rect(center=(center_x, center_y)) self.screen.blit(text, text_rect) # Draw death reason above corpse death_reason = agent.get("death_reason", "unknown") name = agent.get("name", "Unknown") # Death indicator bubble bubble_y = center_y - agent_size // 2 - 20 bubble_text = f"๐Ÿ’€ {death_reason}" text = self.small_font.render(bubble_text, True, (255, 100, 100)) text_rect = text.get_rect(center=(center_x, bubble_y)) # Background for readability bg_rect = text_rect.inflate(8, 4) pygame.draw.rect(self.screen, (40, 20, 20), bg_rect, border_radius=3) pygame.draw.rect(self.screen, (120, 50, 50), bg_rect, 1, border_radius=3) self.screen.blit(text, text_rect) # Draw name below name_y = center_y + agent_size // 2 + 8 name_text = self.small_font.render(name, True, (150, 150, 150)) name_rect = name_text.get_rect(center=(center_x, name_y)) self.screen.blit(name_text, name_rect) def draw_agent_tooltip(self, agent: dict, mouse_pos: tuple[int, int]) -> None: """Draw a tooltip for an agent when hovered.""" # Build tooltip text lines = [ agent.get("name", "Unknown"), f"Profession: {agent.get('profession', '?').capitalize()}", f"Money: {agent.get('money', 0)} coins", "", ] # Current action current_action = agent.get("current_action", {}) action_type = current_action.get("action_type", "") if action_type: action_msg = current_action.get("message", action_type) lines.append(f"Action: {action_msg[:40]}") if current_action.get("is_moving"): lines.append(" (moving to location)") lines.append("") 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', 50)}") lines.append(f" Heat: {stats.get('heat', 0)}/{stats.get('max_heat', 100)}") inventory = agent.get("inventory", []) if inventory: lines.append("") lines.append("Inventory:") for item in inventory[:5]: lines.append(f" {item.get('type', '?')}: {item.get('quantity', 0)}") # Last action result last_result = agent.get("last_action_result", "") if last_result: lines.append("") lines.append(f"Last: {last_result[:35]}") # Calculate tooltip size line_height = 16 max_width = max(self.small_font.size(line)[0] for line in lines) + 20 height = len(lines) * line_height + 10 # Position tooltip near mouse but not off screen x = min(mouse_pos[0] + 15, self.screen.get_width() - max_width - 5) y = min(mouse_pos[1] + 15, self.screen.get_height() - height - 5) # Draw background tooltip_rect = pygame.Rect(x, y, max_width, height) pygame.draw.rect(self.screen, (30, 30, 40), tooltip_rect) pygame.draw.rect(self.screen, (100, 100, 120), tooltip_rect, 1) # Draw text for i, line in enumerate(lines): text = self.small_font.render(line, True, (220, 220, 220)) self.screen.blit(text, (x + 10, y + 5 + i * line_height))