villsim/frontend/renderer/settings_renderer.py

566 lines
25 KiB
Python

"""Settings UI renderer with sliders for the Village Simulation.
Includes settings for economy, religion, diplomacy, and oil.
"""
import pygame
from dataclasses import dataclass
from typing import Optional, Callable, Any
class Colors:
"""Color palette for settings UI."""
BG = (15, 17, 23)
PANEL_BG = (22, 26, 35)
PANEL_HEADER = (28, 33, 45)
PANEL_BORDER = (50, 60, 80)
TEXT_PRIMARY = (225, 228, 235)
TEXT_SECONDARY = (140, 150, 165)
TEXT_HIGHLIGHT = (100, 200, 255)
SLIDER_BG = (40, 45, 55)
SLIDER_FILL = (70, 130, 200)
SLIDER_HANDLE = (220, 220, 230)
BUTTON_BG = (50, 90, 150)
BUTTON_HOVER = (70, 110, 170)
BUTTON_TEXT = (255, 255, 255)
SUCCESS = (80, 180, 100)
WARNING = (200, 160, 80)
# Section colors
SECTION_ECONOMY = (100, 200, 255)
SECTION_WORLD = (100, 220, 150)
SECTION_RELIGION = (200, 150, 255)
SECTION_DIPLOMACY = (255, 180, 100)
SECTION_OIL = (180, 160, 100)
@dataclass
class SliderConfig:
"""Configuration for a slider widget."""
name: str
key: str
min_val: float
max_val: float
step: float = 1.0
is_int: bool = True
description: str = ""
section: str = "General"
# Organized slider configs by section
SLIDER_CONFIGS = [
# ═══════════════════════════════════════════════════════════════
# WORLD SETTINGS
# ═══════════════════════════════════════════════════════════════
SliderConfig("World Width", "world.width", 15, 60, 5, True, "Grid width", "World"),
SliderConfig("World Height", "world.height", 15, 60, 5, True, "Grid height", "World"),
SliderConfig("Initial Agents", "world.initial_agents", 10, 200, 10, True, "Starting population", "World"),
SliderConfig("Day Steps", "world.day_steps", 5, 20, 1, True, "Steps per day", "World"),
SliderConfig("Starting Money", "world.starting_money", 50, 300, 25, True, "Initial coins", "World"),
SliderConfig("Inventory Slots", "world.inventory_slots", 6, 20, 2, True, "Max items", "World"),
# ═══════════════════════════════════════════════════════════════
# AGENT STATS
# ═══════════════════════════════════════════════════════════════
SliderConfig("Max Energy", "agent_stats.max_energy", 30, 150, 10, True, "Energy capacity", "Stats"),
SliderConfig("Max Hunger", "agent_stats.max_hunger", 50, 200, 10, True, "Hunger capacity", "Stats"),
SliderConfig("Max Thirst", "agent_stats.max_thirst", 50, 200, 10, True, "Thirst capacity", "Stats"),
SliderConfig("Energy Decay", "agent_stats.energy_decay", 1, 5, 1, True, "Energy lost/turn", "Stats"),
SliderConfig("Hunger Decay", "agent_stats.hunger_decay", 1, 8, 1, True, "Hunger lost/turn", "Stats"),
SliderConfig("Thirst Decay", "agent_stats.thirst_decay", 1, 8, 1, True, "Thirst lost/turn", "Stats"),
SliderConfig("Critical %", "agent_stats.critical_threshold", 0.1, 0.4, 0.05, False, "Survival mode threshold", "Stats"),
# ═══════════════════════════════════════════════════════════════
# ACTIONS
# ═══════════════════════════════════════════════════════════════
SliderConfig("Hunt Energy", "actions.hunt_energy", -15, -3, 1, True, "Energy cost", "Actions"),
SliderConfig("Gather Energy", "actions.gather_energy", -8, -1, 1, True, "Energy cost", "Actions"),
SliderConfig("Hunt Success %", "actions.hunt_success", 0.4, 1.0, 0.1, False, "Success chance", "Actions"),
SliderConfig("Sleep Restore", "actions.sleep_energy", 30, 80, 10, True, "Energy gained", "Actions"),
SliderConfig("Rest Restore", "actions.rest_energy", 5, 25, 5, True, "Energy gained", "Actions"),
# ═══════════════════════════════════════════════════════════════
# RELIGION
# ═══════════════════════════════════════════════════════════════
SliderConfig("Max Faith", "agent_stats.max_faith", 50, 150, 10, True, "Faith capacity", "Religion"),
SliderConfig("Faith Decay", "agent_stats.faith_decay", 0, 5, 1, True, "Faith lost/turn", "Religion"),
SliderConfig("Pray Faith Gain", "actions.pray_faith_gain", 10, 50, 5, True, "Faith from prayer", "Religion"),
SliderConfig("Convert Chance", "actions.preach_convert_chance", 0.05, 0.4, 0.05, False, "Conversion rate", "Religion"),
SliderConfig("Zealot Threshold", "religion.zealot_threshold", 0.6, 0.95, 0.05, False, "Zealot faith %", "Religion"),
SliderConfig("Same Religion Bonus", "religion.same_religion_bonus", 0.0, 0.3, 0.05, False, "Trade bonus", "Religion"),
# ═══════════════════════════════════════════════════════════════
# DIPLOMACY
# ═══════════════════════════════════════════════════════════════
SliderConfig("Num Factions", "diplomacy.num_factions", 2, 8, 1, True, "Active factions", "Diplomacy"),
SliderConfig("Starting Relations", "diplomacy.starting_relations", 30, 70, 5, True, "Initial relation", "Diplomacy"),
SliderConfig("Alliance Threshold", "diplomacy.alliance_threshold", 60, 90, 5, True, "For alliance", "Diplomacy"),
SliderConfig("War Threshold", "diplomacy.war_threshold", 10, 40, 5, True, "For war", "Diplomacy"),
SliderConfig("Relation Decay", "diplomacy.relation_decay", 0, 5, 1, True, "Decay per turn", "Diplomacy"),
SliderConfig("War Exhaustion", "diplomacy.war_exhaustion_rate", 1, 10, 1, True, "Exhaustion/turn", "Diplomacy"),
SliderConfig("Peace Duration", "diplomacy.peace_treaty_duration", 10, 50, 5, True, "Treaty turns", "Diplomacy"),
# ═══════════════════════════════════════════════════════════════
# OIL & RESOURCES
# ═══════════════════════════════════════════════════════════════
SliderConfig("Oil Fields", "world.oil_fields_count", 1, 10, 1, True, "Number of fields", "Oil"),
SliderConfig("Drill Energy", "actions.drill_oil_energy", -20, -5, 1, True, "Drill cost", "Oil"),
SliderConfig("Drill Success %", "actions.drill_oil_success", 0.3, 1.0, 0.1, False, "Success chance", "Oil"),
SliderConfig("Oil Base Price", "economy.oil_base_price", 10, 50, 5, True, "Market price", "Oil"),
SliderConfig("Fuel Base Price", "economy.fuel_base_price", 20, 80, 5, True, "Market price", "Oil"),
SliderConfig("Fuel Heat", "resources.fuel_heat", 20, 60, 5, True, "Heat provided", "Oil"),
# ═══════════════════════════════════════════════════════════════
# MARKET
# ═══════════════════════════════════════════════════════════════
SliderConfig("Discount Turns", "market.turns_before_discount", 5, 30, 5, True, "Before price drop", "Market"),
SliderConfig("Discount Rate %", "market.discount_rate", 0.05, 0.25, 0.05, False, "Per period", "Market"),
SliderConfig("Max Markup", "economy.max_price_markup", 1.5, 4.0, 0.5, False, "Price ceiling", "Market"),
# ═══════════════════════════════════════════════════════════════
# SIMULATION
# ═══════════════════════════════════════════════════════════════
SliderConfig("Auto Step (s)", "auto_step_interval", 0.1, 2.0, 0.1, False, "Seconds/step", "Simulation"),
]
# Section order and colors
SECTION_ORDER = ["World", "Stats", "Actions", "Religion", "Diplomacy", "Oil", "Market", "Simulation"]
SECTION_COLORS = {
"World": Colors.SECTION_WORLD,
"Stats": Colors.SECTION_ECONOMY,
"Actions": Colors.SECTION_ECONOMY,
"Religion": Colors.SECTION_RELIGION,
"Diplomacy": Colors.SECTION_DIPLOMACY,
"Oil": Colors.SECTION_OIL,
"Market": Colors.SECTION_ECONOMY,
"Simulation": Colors.TEXT_SECONDARY,
}
class Slider:
"""A slider widget for adjusting numeric values."""
def __init__(
self,
rect: pygame.Rect,
config: SliderConfig,
font: pygame.font.Font,
small_font: pygame.font.Font,
):
self.rect = rect
self.config = config
self.font = font
self.small_font = small_font
self.value = config.min_val
self.dragging = False
self.hovered = False
def set_value(self, value: float) -> None:
"""Set slider value."""
self.value = max(self.config.min_val, min(self.config.max_val, value))
if self.config.is_int:
self.value = int(round(self.value))
def get_value(self) -> Any:
"""Get current value."""
return int(self.value) if self.config.is_int else round(self.value, 2)
def handle_event(self, event: pygame.event.Event) -> bool:
"""Handle events."""
if event.type == pygame.MOUSEBUTTONDOWN:
if self._slider_area().collidepoint(event.pos):
self.dragging = True
return self._update_from_mouse(event.pos[0])
elif event.type == pygame.MOUSEBUTTONUP:
self.dragging = False
elif event.type == pygame.MOUSEMOTION:
self.hovered = self.rect.collidepoint(event.pos)
if self.dragging:
return self._update_from_mouse(event.pos[0])
return False
def _slider_area(self) -> pygame.Rect:
"""Get slider track area."""
return pygame.Rect(
self.rect.x + 130,
self.rect.y + 12,
self.rect.width - 200,
16,
)
def _update_from_mouse(self, mouse_x: int) -> bool:
"""Update value from mouse."""
slider_area = self._slider_area()
rel_x = mouse_x - slider_area.x
ratio = max(0, min(1, rel_x / slider_area.width))
range_val = self.config.max_val - self.config.min_val
new_value = self.config.min_val + ratio * range_val
if self.config.step > 0:
new_value = round(new_value / self.config.step) * self.config.step
old_value = self.value
self.set_value(new_value)
return abs(old_value - self.value) > 0.001
def draw(self, screen: pygame.Surface, section_color: tuple) -> None:
"""Draw the slider."""
# Hover highlight
if self.hovered:
pygame.draw.rect(screen, (35, 40, 50), self.rect, border_radius=4)
# Label
label = self.small_font.render(self.config.name, True, Colors.TEXT_PRIMARY)
screen.blit(label, (self.rect.x + 8, self.rect.y + 6))
# Slider track
slider_area = self._slider_area()
pygame.draw.rect(screen, Colors.SLIDER_BG, slider_area, border_radius=4)
# Slider fill
ratio = (self.value - self.config.min_val) / (self.config.max_val - self.config.min_val)
fill_width = int(ratio * slider_area.width)
fill_rect = pygame.Rect(slider_area.x, slider_area.y, fill_width, slider_area.height)
pygame.draw.rect(screen, section_color, fill_rect, border_radius=4)
# Handle
handle_x = slider_area.x + fill_width
handle_rect = pygame.Rect(handle_x - 5, slider_area.y - 2, 10, slider_area.height + 4)
pygame.draw.rect(screen, Colors.SLIDER_HANDLE, handle_rect, border_radius=3)
# Value display
value_str = str(self.get_value())
value_text = self.small_font.render(value_str, True, Colors.TEXT_HIGHLIGHT)
screen.blit(value_text, (self.rect.right - 55, self.rect.y + 6))
# Description on hover
if self.hovered and self.config.description:
desc = self.small_font.render(self.config.description, True, Colors.TEXT_SECONDARY)
screen.blit(desc, (self.rect.x + 8, self.rect.y + 24))
class Button:
"""Button widget."""
def __init__(
self,
rect: pygame.Rect,
text: str,
font: pygame.font.Font,
callback: Optional[Callable] = None,
color: tuple = Colors.BUTTON_BG,
):
self.rect = rect
self.text = text
self.font = font
self.callback = callback
self.color = color
self.hovered = False
def handle_event(self, event: pygame.event.Event) -> bool:
"""Handle events."""
if event.type == pygame.MOUSEMOTION:
self.hovered = self.rect.collidepoint(event.pos)
elif event.type == pygame.MOUSEBUTTONDOWN:
if self.rect.collidepoint(event.pos):
if self.callback:
self.callback()
return True
return False
def draw(self, screen: pygame.Surface) -> None:
"""Draw button."""
color = Colors.BUTTON_HOVER if self.hovered else self.color
pygame.draw.rect(screen, color, self.rect, border_radius=6)
pygame.draw.rect(screen, Colors.PANEL_BORDER, self.rect, 1, border_radius=6)
text = self.font.render(self.text, True, Colors.BUTTON_TEXT)
text_rect = text.get_rect(center=self.rect.center)
screen.blit(text, text_rect)
class SettingsRenderer:
"""Settings panel with organized sections and sliders."""
def __init__(self, screen: pygame.Surface):
self.screen = screen
self.font = pygame.font.Font(None, 22)
self.small_font = pygame.font.Font(None, 16)
self.title_font = pygame.font.Font(None, 28)
self.section_font = pygame.font.Font(None, 20)
self.visible = False
self.scroll_offset = 0
self.max_scroll = 0
self.current_section = 0
self.sliders: list[Slider] = []
self.buttons: list[Button] = []
self.section_tabs: list[pygame.Rect] = []
self.config_data: dict = {}
self._create_widgets()
self.status_message = ""
self.status_color = Colors.TEXT_SECONDARY
def _create_widgets(self) -> None:
"""Create widgets."""
screen_w, screen_h = self.screen.get_size()
# Panel dimensions - wider for better readability
panel_width = min(600, screen_w - 100)
panel_height = screen_h - 80
panel_x = (screen_w - panel_width) // 2
panel_y = 40
self.panel_rect = pygame.Rect(panel_x, panel_y, panel_width, panel_height)
# Tab bar for sections
tab_height = 30
self.tab_rect = pygame.Rect(panel_x, panel_y, panel_width, tab_height)
# Content area
content_start_y = panel_y + tab_height + 10
slider_height = 38
# Group sliders by section
self.sliders_by_section: dict[str, list[Slider]] = {s: [] for s in SECTION_ORDER}
slider_width = panel_width - 40
for config in SLIDER_CONFIGS:
rect = pygame.Rect(panel_x + 20, 0, slider_width, slider_height)
slider = Slider(rect, config, self.font, self.small_font)
self.sliders.append(slider)
self.sliders_by_section[config.section].append(slider)
# Calculate positions for current section
self._layout_current_section()
# Buttons at bottom
button_y = panel_y + panel_height - 50
button_width = 120
button_height = 35
button_spacing = 15
buttons_data = [
("Apply & Restart", self._apply_config, Colors.SUCCESS),
("Reset Defaults", self._reset_config, Colors.WARNING),
("Close", self.toggle, Colors.PANEL_BORDER),
]
total_w = len(buttons_data) * button_width + (len(buttons_data) - 1) * button_spacing
start_x = panel_x + (panel_width - total_w) // 2
for i, (text, callback, color) in enumerate(buttons_data):
rect = pygame.Rect(
start_x + i * (button_width + button_spacing),
button_y,
button_width,
button_height,
)
self.buttons.append(Button(rect, text, self.small_font, callback, color))
def _layout_current_section(self) -> None:
"""Layout sliders for current section."""
section = SECTION_ORDER[self.current_section]
sliders = self.sliders_by_section[section]
content_y = self.panel_rect.y + 50
slider_height = 38
for i, slider in enumerate(sliders):
slider.rect.y = content_y + i * slider_height - self.scroll_offset
# Calculate max scroll
total_height = len(sliders) * slider_height
visible_height = self.panel_rect.height - 120
self.max_scroll = max(0, total_height - visible_height)
def toggle(self) -> None:
"""Toggle visibility."""
self.visible = not self.visible
if self.visible:
self.scroll_offset = 0
self._layout_current_section()
def set_config(self, config_data: dict) -> None:
"""Set slider values from config."""
self.config_data = config_data
for slider in self.sliders:
value = self._get_nested_value(config_data, slider.config.key)
if value is not None:
slider.set_value(value)
def get_config(self) -> dict:
"""Get config from sliders."""
result = {}
for slider in self.sliders:
self._set_nested_value(result, slider.config.key, slider.get_value())
return result
def _get_nested_value(self, data: dict, key: str) -> Any:
"""Get nested dict value."""
parts = key.split(".")
current = data
for part in parts:
if isinstance(current, dict) and part in current:
current = current[part]
else:
return None
return current
def _set_nested_value(self, data: dict, key: str, value: Any) -> None:
"""Set nested dict value."""
parts = key.split(".")
current = data
for part in parts[:-1]:
if part not in current:
current[part] = {}
current = current[part]
current[parts[-1]] = value
def _apply_config(self) -> None:
"""Apply config callback."""
self.status_message = "Config applied - restart to see changes"
self.status_color = Colors.SUCCESS
def _reset_config(self) -> None:
"""Reset config callback."""
self.status_message = "Config reset to defaults"
self.status_color = Colors.WARNING
def handle_event(self, event: pygame.event.Event) -> bool:
"""Handle events."""
if not self.visible:
return False
# Tab clicks
if event.type == pygame.MOUSEBUTTONDOWN:
if self.tab_rect.collidepoint(event.pos):
tab_width = self.panel_rect.width // len(SECTION_ORDER)
rel_x = event.pos[0] - self.tab_rect.x
tab_idx = rel_x // tab_width
if 0 <= tab_idx < len(SECTION_ORDER) and tab_idx != self.current_section:
self.current_section = tab_idx
self.scroll_offset = 0
self._layout_current_section()
return True
# Scrolling
if event.type == pygame.MOUSEWHEEL:
self.scroll_offset -= event.y * 30
self.scroll_offset = max(0, min(self.max_scroll, self.scroll_offset))
self._layout_current_section()
return True
# Sliders for current section
section = SECTION_ORDER[self.current_section]
for slider in self.sliders_by_section[section]:
adjusted_rect = slider.rect.copy()
if slider.handle_event(event):
return True
# Buttons
for button in self.buttons:
if button.handle_event(event):
return True
# Consume clicks
if event.type == pygame.MOUSEBUTTONDOWN:
return True
return False
def draw(self) -> None:
"""Draw settings panel."""
if not self.visible:
return
# Dim background
overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 220))
self.screen.blit(overlay, (0, 0))
# Panel
pygame.draw.rect(self.screen, Colors.PANEL_BG, self.panel_rect, border_radius=10)
pygame.draw.rect(self.screen, Colors.PANEL_BORDER, self.panel_rect, 2, border_radius=10)
# Title
title = self.title_font.render("Simulation Settings", True, Colors.TEXT_PRIMARY)
title_rect = title.get_rect(centerx=self.panel_rect.centerx, y=self.panel_rect.y + 8)
self.screen.blit(title, title_rect)
# Section tabs
self._draw_section_tabs()
# Clipping for sliders
clip_rect = pygame.Rect(
self.panel_rect.x + 10,
self.panel_rect.y + 45,
self.panel_rect.width - 20,
self.panel_rect.height - 110,
)
# Draw sliders for current section
section = SECTION_ORDER[self.current_section]
section_color = SECTION_COLORS.get(section, Colors.TEXT_SECONDARY)
for slider in self.sliders_by_section[section]:
if clip_rect.colliderect(slider.rect):
slider.draw(self.screen, section_color)
# Scroll indicator
if self.max_scroll > 0:
scroll_ratio = self.scroll_offset / self.max_scroll
bar_height = max(30, int((clip_rect.height / (clip_rect.height + self.max_scroll)) * clip_rect.height))
bar_y = clip_rect.y + int(scroll_ratio * (clip_rect.height - bar_height))
bar_rect = pygame.Rect(self.panel_rect.right - 12, bar_y, 5, bar_height)
pygame.draw.rect(self.screen, Colors.SLIDER_FILL, bar_rect, border_radius=2)
# Buttons
for button in self.buttons:
button.draw(self.screen)
# Status message
if self.status_message:
status = self.small_font.render(self.status_message, True, self.status_color)
status_rect = status.get_rect(
centerx=self.panel_rect.centerx,
y=self.panel_rect.bottom - 80
)
self.screen.blit(status, status_rect)
def _draw_section_tabs(self) -> None:
"""Draw section tabs."""
tab_width = self.panel_rect.width // len(SECTION_ORDER)
tab_y = self.panel_rect.y + 32
tab_height = 20
for i, section in enumerate(SECTION_ORDER):
tab_x = self.panel_rect.x + i * tab_width
tab_rect = pygame.Rect(tab_x, tab_y, tab_width, tab_height)
color = SECTION_COLORS.get(section, Colors.TEXT_SECONDARY)
if i == self.current_section:
pygame.draw.rect(self.screen, color, tab_rect, border_radius=3)
text_color = Colors.BG
else:
pygame.draw.rect(self.screen, (40, 45, 55), tab_rect, border_radius=3)
text_color = color
# Section name (abbreviated)
name = section[:5] if len(section) > 5 else section
text = self.small_font.render(name, True, text_color)
text_rect = text.get_rect(center=tab_rect.center)
self.screen.blit(text, text_rect)