431 lines
15 KiB
Python
431 lines
15 KiB
Python
"""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))
|