villsim/frontend/renderer/agent_renderer.py

521 lines
17 KiB
Python

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