521 lines
17 KiB
Python
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))
|