340 lines
12 KiB
Python
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)
|