villsim/frontend/renderer/map_renderer.py

340 lines
12 KiB
Python

"""Map renderer for the Village Simulation.
Beautiful dark theme with oil fields, temples, and terrain features.
"""
import math
import random
import pygame
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frontend.client import SimulationState
# Color palette - Cyberpunk dark theme
class Colors:
# Background colors
DAY_BG = (28, 35, 42)
NIGHT_BG = (12, 14, 20)
# Terrain
GRASS_LIGHT = (32, 45, 38)
GRASS_DARK = (26, 38, 32)
GRASS_ACCENT = (38, 52, 44)
WATER_SPOT = (25, 45, 65)
WATER_DEEP = (18, 35, 55)
# Grid
GRID_LINE = (45, 55, 60)
GRID_LINE_NIGHT = (25, 30, 38)
# Special locations
OIL_FIELD = (35, 35, 35)
OIL_GLOW = (80, 70, 45)
TEMPLE_GLOW = (100, 80, 140)
# Religion colors
RELIGIONS = {
"solaris": (255, 180, 50), # Golden sun
"aquarius": (50, 150, 220), # Ocean blue
"terranus": (140, 100, 60), # Earth brown
"ignis": (220, 80, 40), # Fire red
"naturis": (80, 180, 80), # Forest green
"atheist": (100, 100, 100), # Gray
}
# Faction colors
FACTIONS = {
"northlands": (100, 150, 200), # Ice blue
"riverfolk": (60, 140, 170), # River teal
"forestkin": (80, 140, 70), # Forest green
"mountaineer": (130, 110, 90), # Mountain brown
"plainsmen": (180, 160, 100), # Plains gold
"neutral": (100, 100, 100), # Gray
}
class MapRenderer:
"""Renders the map/terrain background with special locations."""
def __init__(
self,
screen: pygame.Surface,
map_rect: pygame.Rect,
world_width: int = 30,
world_height: int = 30,
):
self.screen = screen
self.map_rect = map_rect
self.world_width = world_width
self.world_height = world_height
self._cell_width = map_rect.width / world_width
self._cell_height = map_rect.height / world_height
# Animation state
self.animation_tick = 0
# Pre-generate terrain
self._terrain_cache = self._generate_terrain()
# Surface cache for static elements
self._terrain_surface: pygame.Surface | None = None
self._cached_dimensions = (world_width, world_height, map_rect.width, map_rect.height)
def _generate_terrain(self) -> list[list[int]]:
"""Generate terrain variation using noise-like pattern."""
random.seed(42) # Consistent terrain
terrain = []
for y in range(self.world_height):
row = []
for x in range(self.world_width):
# Create organic-looking patterns
noise = (
math.sin(x * 0.3) * math.cos(y * 0.3) +
math.sin(x * 0.7 + y * 0.5) * 0.5
)
if noise > 0.8:
row.append(2) # Water
elif noise > 0.3:
row.append(1) # Dark grass
elif noise < -0.5:
row.append(3) # Accent grass
else:
row.append(0) # Light grass
terrain.append(row)
return terrain
def update_dimensions(self, world_width: int, world_height: int) -> None:
"""Update world dimensions and recalculate cell sizes."""
if world_width != self.world_width or world_height != self.world_height:
self.world_width = world_width
self.world_height = world_height
self._cell_width = self.map_rect.width / world_width
self._cell_height = self.map_rect.height / world_height
self._terrain_cache = self._generate_terrain()
self._terrain_surface = None # Invalidate cache
def grid_to_screen(self, grid_x: int, grid_y: int) -> tuple[int, int]:
"""Convert grid coordinates to screen coordinates (center of cell)."""
screen_x = self.map_rect.left + (grid_x + 0.5) * self._cell_width
screen_y = self.map_rect.top + (grid_y + 0.5) * self._cell_height
return int(screen_x), int(screen_y)
def get_cell_size(self) -> tuple[int, int]:
"""Get the size of a single cell."""
return int(self._cell_width), int(self._cell_height)
def _render_terrain_surface(self, is_night: bool) -> pygame.Surface:
"""Render terrain to a cached surface."""
surface = pygame.Surface((self.map_rect.width, self.map_rect.height))
# Fill background
bg_color = Colors.NIGHT_BG if is_night else Colors.DAY_BG
surface.fill(bg_color)
# Draw terrain cells
for y in range(self.world_height):
for x in range(self.world_width):
cell_rect = pygame.Rect(
x * self._cell_width,
y * self._cell_height,
self._cell_width + 1,
self._cell_height + 1,
)
terrain_type = self._terrain_cache[y][x]
if is_night:
if terrain_type == 2:
color = (15, 25, 40)
elif terrain_type == 1:
color = (18, 25, 22)
elif terrain_type == 3:
color = (22, 30, 26)
else:
color = (20, 28, 24)
else:
if terrain_type == 2:
color = Colors.WATER_SPOT
elif terrain_type == 1:
color = Colors.GRASS_DARK
elif terrain_type == 3:
color = Colors.GRASS_ACCENT
else:
color = Colors.GRASS_LIGHT
pygame.draw.rect(surface, color, cell_rect)
# Draw subtle grid
grid_color = Colors.GRID_LINE_NIGHT if is_night else Colors.GRID_LINE
for x in range(self.world_width + 1):
start_x = x * self._cell_width
pygame.draw.line(
surface,
grid_color,
(start_x, 0),
(start_x, self.map_rect.height),
1,
)
for y in range(self.world_height + 1):
start_y = y * self._cell_height
pygame.draw.line(
surface,
grid_color,
(0, start_y),
(self.map_rect.width, start_y),
1,
)
return surface
def _draw_oil_field(self, oil_field: dict, is_night: bool) -> None:
"""Draw an oil field with pulsing glow effect."""
pos = oil_field.get("position", {"x": 0, "y": 0})
screen_x, screen_y = self.grid_to_screen(pos["x"], pos["y"])
cell_w, cell_h = self.get_cell_size()
radius = min(cell_w, cell_h) // 2 - 2
# Pulsing glow
pulse = 0.7 + 0.3 * math.sin(self.animation_tick * 0.05)
glow_color = tuple(int(c * pulse) for c in Colors.OIL_GLOW)
# Outer glow
for i in range(3, 0, -1):
alpha = int(30 * pulse / i)
glow_surface = pygame.Surface((radius * 4, radius * 4), pygame.SRCALPHA)
pygame.draw.circle(
glow_surface,
(*glow_color, alpha),
(radius * 2, radius * 2),
radius + i * 3,
)
self.screen.blit(
glow_surface,
(screen_x - radius * 2, screen_y - radius * 2),
)
# Oil derrick shape
pygame.draw.circle(self.screen, Colors.OIL_FIELD, (screen_x, screen_y), radius)
pygame.draw.circle(self.screen, glow_color, (screen_x, screen_y), radius, 2)
# Derrick icon (triangle)
points = [
(screen_x, screen_y - radius + 2),
(screen_x - radius // 2, screen_y + radius // 2),
(screen_x + radius // 2, screen_y + radius // 2),
]
pygame.draw.polygon(self.screen, glow_color, points)
pygame.draw.polygon(self.screen, (40, 40, 40), points, 1)
# Oil remaining indicator
oil_remaining = oil_field.get("oil_remaining", 1000)
if oil_remaining < 500:
# Low oil warning
warning_pulse = 0.5 + 0.5 * math.sin(self.animation_tick * 0.1)
warning_color = (int(200 * warning_pulse), int(60 * warning_pulse), 0)
pygame.draw.circle(
self.screen, warning_color,
(screen_x + radius, screen_y - radius),
4,
)
def _draw_temple(self, temple: dict, is_night: bool) -> None:
"""Draw a temple with religion-colored glow."""
pos = temple.get("position", {"x": 0, "y": 0})
religion_type = temple.get("religion_type", "atheist")
screen_x, screen_y = self.grid_to_screen(pos["x"], pos["y"])
cell_w, cell_h = self.get_cell_size()
radius = min(cell_w, cell_h) // 2 - 2
# Get religion color
religion_color = Colors.RELIGIONS.get(religion_type, Colors.RELIGIONS["atheist"])
# Pulsing glow
pulse = 0.6 + 0.4 * math.sin(self.animation_tick * 0.03 + hash(religion_type) % 10)
glow_color = tuple(int(c * pulse) for c in religion_color)
# Outer divine glow
for i in range(4, 0, -1):
alpha = int(40 * pulse / i)
glow_surface = pygame.Surface((radius * 5, radius * 5), pygame.SRCALPHA)
pygame.draw.circle(
glow_surface,
(*glow_color, alpha),
(radius * 2.5, radius * 2.5),
int(radius + i * 4),
)
self.screen.blit(
glow_surface,
(screen_x - radius * 2.5, screen_y - radius * 2.5),
)
# Temple base
pygame.draw.circle(self.screen, (40, 35, 50), (screen_x, screen_y), radius)
pygame.draw.circle(self.screen, glow_color, (screen_x, screen_y), radius, 2)
# Temple icon (cross/star pattern)
half = radius // 2
pygame.draw.line(self.screen, glow_color,
(screen_x, screen_y - half),
(screen_x, screen_y + half), 2)
pygame.draw.line(self.screen, glow_color,
(screen_x - half, screen_y),
(screen_x + half, screen_y), 2)
# Religion initial
font = pygame.font.Font(None, max(10, radius))
initial = religion_type[0].upper() if religion_type else "?"
text = font.render(initial, True, (255, 255, 255))
text_rect = text.get_rect(center=(screen_x, screen_y))
self.screen.blit(text, text_rect)
def draw(self, state: "SimulationState") -> None:
"""Draw the map background with all features."""
self.animation_tick += 1
is_night = state.time_of_day == "night"
# Draw terrain (cached for performance)
current_dims = (self.world_width, self.world_height,
self.map_rect.width, self.map_rect.height)
if self._terrain_surface is None or self._cached_dimensions != current_dims:
self._terrain_surface = self._render_terrain_surface(is_night)
self._cached_dimensions = current_dims
self.screen.blit(self._terrain_surface, self.map_rect.topleft)
# Draw oil fields
for oil_field in state.oil_fields:
self._draw_oil_field(oil_field, is_night)
# Draw temples
for temple in state.temples:
self._draw_temple(temple, is_night)
# Draw border with glow effect
border_color = (50, 55, 70) if not is_night else (35, 40, 55)
pygame.draw.rect(self.screen, border_color, self.map_rect, 2)
# Corner accents
corner_size = 15
accent_color = (80, 100, 130) if not is_night else (60, 75, 100)
corners = [
(self.map_rect.left, self.map_rect.top),
(self.map_rect.right - corner_size, self.map_rect.top),
(self.map_rect.left, self.map_rect.bottom - corner_size),
(self.map_rect.right - corner_size, self.map_rect.bottom - corner_size),
]
for cx, cy in corners:
pygame.draw.rect(self.screen, accent_color,
(cx, cy, corner_size, corner_size), 1)