449 lines
17 KiB
Python
449 lines
17 KiB
Python
"""Settings UI renderer with sliders for the Village Simulation."""
|
|
|
|
import pygame
|
|
from dataclasses import dataclass
|
|
from typing import Optional, Callable, Any
|
|
|
|
|
|
class Colors:
|
|
"""Color palette for settings UI."""
|
|
BG = (25, 28, 35)
|
|
PANEL_BG = (35, 40, 50)
|
|
PANEL_BORDER = (70, 80, 95)
|
|
TEXT_PRIMARY = (230, 230, 235)
|
|
TEXT_SECONDARY = (160, 165, 175)
|
|
TEXT_HIGHLIGHT = (100, 180, 255)
|
|
SLIDER_BG = (50, 55, 65)
|
|
SLIDER_FILL = (80, 140, 200)
|
|
SLIDER_HANDLE = (220, 220, 230)
|
|
BUTTON_BG = (60, 100, 160)
|
|
BUTTON_HOVER = (80, 120, 180)
|
|
BUTTON_TEXT = (255, 255, 255)
|
|
SUCCESS = (80, 180, 100)
|
|
WARNING = (200, 160, 80)
|
|
|
|
|
|
@dataclass
|
|
class SliderConfig:
|
|
"""Configuration for a slider widget."""
|
|
name: str
|
|
key: str # Dot-separated path like "agent_stats.max_energy"
|
|
min_val: float
|
|
max_val: float
|
|
step: float = 1.0
|
|
is_int: bool = True
|
|
description: str = ""
|
|
|
|
|
|
# Define all configurable parameters with sliders
|
|
SLIDER_CONFIGS = [
|
|
# Agent Stats Section
|
|
SliderConfig("Max Energy", "agent_stats.max_energy", 50, 200, 10, True, "Maximum energy capacity"),
|
|
SliderConfig("Max Hunger", "agent_stats.max_hunger", 50, 200, 10, True, "Maximum hunger capacity"),
|
|
SliderConfig("Max Thirst", "agent_stats.max_thirst", 25, 100, 5, True, "Maximum thirst capacity"),
|
|
SliderConfig("Max Heat", "agent_stats.max_heat", 50, 200, 10, True, "Maximum heat capacity"),
|
|
SliderConfig("Energy Decay", "agent_stats.energy_decay", 1, 10, 1, True, "Energy lost per turn"),
|
|
SliderConfig("Hunger Decay", "agent_stats.hunger_decay", 1, 10, 1, True, "Hunger lost per turn"),
|
|
SliderConfig("Thirst Decay", "agent_stats.thirst_decay", 1, 10, 1, True, "Thirst lost per turn"),
|
|
SliderConfig("Heat Decay", "agent_stats.heat_decay", 1, 10, 1, True, "Heat lost per turn"),
|
|
SliderConfig("Critical %", "agent_stats.critical_threshold", 0.1, 0.5, 0.05, False, "Threshold for survival mode"),
|
|
|
|
# World Section
|
|
SliderConfig("World Width", "world.width", 10, 50, 5, True, "World grid width"),
|
|
SliderConfig("World Height", "world.height", 10, 50, 5, True, "World grid height"),
|
|
SliderConfig("Initial Agents", "world.initial_agents", 2, 20, 1, True, "Starting agent count"),
|
|
SliderConfig("Day Steps", "world.day_steps", 5, 20, 1, True, "Steps per day"),
|
|
SliderConfig("Inventory Slots", "world.inventory_slots", 5, 20, 1, True, "Agent inventory size"),
|
|
SliderConfig("Starting Money", "world.starting_money", 50, 500, 50, True, "Initial coins per agent"),
|
|
|
|
# Actions Section
|
|
SliderConfig("Hunt Energy Cost", "actions.hunt_energy", -30, -5, 5, True, "Energy spent hunting"),
|
|
SliderConfig("Gather Energy Cost", "actions.gather_energy", -20, -1, 1, True, "Energy spent gathering"),
|
|
SliderConfig("Hunt Success %", "actions.hunt_success", 0.3, 1.0, 0.1, False, "Hunting success chance"),
|
|
SliderConfig("Sleep Restore", "actions.sleep_energy", 30, 100, 10, True, "Energy restored by sleep"),
|
|
SliderConfig("Rest Restore", "actions.rest_energy", 5, 30, 5, True, "Energy restored by rest"),
|
|
|
|
# Resources Section
|
|
SliderConfig("Meat Decay", "resources.meat_decay", 2, 20, 1, True, "Turns until meat spoils"),
|
|
SliderConfig("Berries Decay", "resources.berries_decay", 10, 50, 5, True, "Turns until berries spoil"),
|
|
SliderConfig("Meat Hunger +", "resources.meat_hunger", 10, 60, 5, True, "Hunger restored by meat"),
|
|
SliderConfig("Water Thirst +", "resources.water_thirst", 20, 60, 5, True, "Thirst restored by water"),
|
|
|
|
# Market Section
|
|
SliderConfig("Discount Turns", "market.turns_before_discount", 1, 10, 1, True, "Turns before price drop"),
|
|
SliderConfig("Discount Rate %", "market.discount_rate", 0.05, 0.30, 0.05, False, "Price reduction per period"),
|
|
|
|
# Simulation Section
|
|
SliderConfig("Auto Step (s)", "auto_step_interval", 0.2, 3.0, 0.2, False, "Seconds between auto steps"),
|
|
]
|
|
|
|
|
|
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 the 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 the 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 input events. Returns True if value changed."""
|
|
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 the actual slider track area."""
|
|
return pygame.Rect(
|
|
self.rect.x + 120, # Leave space for label
|
|
self.rect.y + 15,
|
|
self.rect.width - 180, # Leave space for value display
|
|
20,
|
|
)
|
|
|
|
def _update_from_mouse(self, mouse_x: int) -> bool:
|
|
"""Update value based on mouse position."""
|
|
slider_area = self._slider_area()
|
|
|
|
# Calculate position as 0-1
|
|
rel_x = mouse_x - slider_area.x
|
|
ratio = max(0, min(1, rel_x / slider_area.width))
|
|
|
|
# Calculate value
|
|
range_val = self.config.max_val - self.config.min_val
|
|
new_value = self.config.min_val + ratio * range_val
|
|
|
|
# Apply step
|
|
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) -> None:
|
|
"""Draw the slider."""
|
|
# Background
|
|
if self.hovered:
|
|
pygame.draw.rect(screen, (45, 50, 60), self.rect)
|
|
|
|
# Label
|
|
label = self.small_font.render(self.config.name, True, Colors.TEXT_PRIMARY)
|
|
screen.blit(label, (self.rect.x + 5, self.rect.y + 5))
|
|
|
|
# Slider track
|
|
slider_area = self._slider_area()
|
|
pygame.draw.rect(screen, Colors.SLIDER_BG, slider_area, border_radius=3)
|
|
|
|
# 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, Colors.SLIDER_FILL, fill_rect, border_radius=3)
|
|
|
|
# Handle
|
|
handle_x = slider_area.x + fill_width
|
|
handle_rect = pygame.Rect(handle_x - 4, slider_area.y - 2, 8, slider_area.height + 4)
|
|
pygame.draw.rect(screen, Colors.SLIDER_HANDLE, handle_rect, border_radius=2)
|
|
|
|
# Value display
|
|
value_str = str(self.get_value())
|
|
value_text = self.small_font.render(value_str, True, Colors.TEXT_HIGHLIGHT)
|
|
value_x = self.rect.right - 50
|
|
screen.blit(value_text, (value_x, self.rect.y + 5))
|
|
|
|
# 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 + 5, self.rect.y + 25))
|
|
|
|
|
|
class Button:
|
|
"""A simple 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 input events. Returns True if clicked."""
|
|
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 the button."""
|
|
color = Colors.BUTTON_HOVER if self.hovered else self.color
|
|
pygame.draw.rect(screen, color, self.rect, border_radius=5)
|
|
pygame.draw.rect(screen, Colors.PANEL_BORDER, self.rect, 1, border_radius=5)
|
|
|
|
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:
|
|
"""Renders the settings UI panel with sliders."""
|
|
|
|
def __init__(self, screen: pygame.Surface):
|
|
self.screen = screen
|
|
self.font = pygame.font.Font(None, 24)
|
|
self.small_font = pygame.font.Font(None, 18)
|
|
self.title_font = pygame.font.Font(None, 32)
|
|
|
|
self.visible = False
|
|
self.scroll_offset = 0
|
|
self.max_scroll = 0
|
|
|
|
# Create sliders
|
|
self.sliders: list[Slider] = []
|
|
self.buttons: list[Button] = []
|
|
self.config_data: dict = {}
|
|
|
|
self._create_widgets()
|
|
self.status_message = ""
|
|
self.status_color = Colors.TEXT_SECONDARY
|
|
|
|
def _create_widgets(self) -> None:
|
|
"""Create slider widgets."""
|
|
panel_width = 400
|
|
slider_height = 45
|
|
start_y = 80
|
|
|
|
panel_x = (self.screen.get_width() - panel_width) // 2
|
|
|
|
for i, config in enumerate(SLIDER_CONFIGS):
|
|
rect = pygame.Rect(
|
|
panel_x + 10,
|
|
start_y + i * slider_height,
|
|
panel_width - 20,
|
|
slider_height,
|
|
)
|
|
slider = Slider(rect, config, self.font, self.small_font)
|
|
self.sliders.append(slider)
|
|
|
|
# Calculate max scroll
|
|
total_height = len(SLIDER_CONFIGS) * slider_height + 150
|
|
visible_height = self.screen.get_height() - 150
|
|
self.max_scroll = max(0, total_height - visible_height)
|
|
|
|
# Create buttons at the bottom
|
|
button_y = self.screen.get_height() - 60
|
|
button_width = 100
|
|
button_height = 35
|
|
|
|
buttons_data = [
|
|
("Apply & Restart", self._apply_config, Colors.SUCCESS),
|
|
("Reset Defaults", self._reset_config, Colors.WARNING),
|
|
("Close", self.toggle, Colors.PANEL_BORDER),
|
|
]
|
|
|
|
total_button_width = len(buttons_data) * button_width + (len(buttons_data) - 1) * 10
|
|
start_x = (self.screen.get_width() - total_button_width) // 2
|
|
|
|
for i, (text, callback, color) in enumerate(buttons_data):
|
|
rect = pygame.Rect(
|
|
start_x + i * (button_width + 10),
|
|
button_y,
|
|
button_width,
|
|
button_height,
|
|
)
|
|
self.buttons.append(Button(rect, text, self.small_font, callback, color))
|
|
|
|
def toggle(self) -> None:
|
|
"""Toggle settings visibility."""
|
|
self.visible = not self.visible
|
|
if self.visible:
|
|
self.scroll_offset = 0
|
|
|
|
def set_config(self, config_data: dict) -> None:
|
|
"""Set slider values from config data."""
|
|
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 current config from slider values."""
|
|
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 a value from nested dict using dot notation."""
|
|
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 a value in nested dict using dot notation."""
|
|
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 configuration callback (to be set externally)."""
|
|
self.status_message = "Config applied - restart to see changes"
|
|
self.status_color = Colors.SUCCESS
|
|
|
|
def _reset_config(self) -> None:
|
|
"""Reset configuration callback (to be set externally)."""
|
|
self.status_message = "Config reset to defaults"
|
|
self.status_color = Colors.WARNING
|
|
|
|
def handle_event(self, event: pygame.event.Event) -> bool:
|
|
"""Handle input events. Returns True if event was consumed."""
|
|
if not self.visible:
|
|
return False
|
|
|
|
# Handle scrolling
|
|
if event.type == pygame.MOUSEWHEEL:
|
|
self.scroll_offset -= event.y * 30
|
|
self.scroll_offset = max(0, min(self.max_scroll, self.scroll_offset))
|
|
return True
|
|
|
|
# Handle sliders
|
|
for slider in self.sliders:
|
|
# Adjust slider position for scroll
|
|
original_y = slider.rect.y
|
|
slider.rect.y -= self.scroll_offset
|
|
|
|
if slider.handle_event(event):
|
|
slider.rect.y = original_y
|
|
return True
|
|
|
|
slider.rect.y = original_y
|
|
|
|
# Handle buttons
|
|
for button in self.buttons:
|
|
if button.handle_event(event):
|
|
return True
|
|
|
|
# Consume all clicks when settings are visible
|
|
if event.type == pygame.MOUSEBUTTONDOWN:
|
|
return True
|
|
|
|
return False
|
|
|
|
def draw(self) -> None:
|
|
"""Draw the settings panel."""
|
|
if not self.visible:
|
|
return
|
|
|
|
# Dim background
|
|
overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA)
|
|
overlay.fill((0, 0, 0, 200))
|
|
self.screen.blit(overlay, (0, 0))
|
|
|
|
# Panel background
|
|
panel_width = 420
|
|
panel_height = self.screen.get_height() - 40
|
|
panel_x = (self.screen.get_width() - panel_width) // 2
|
|
panel_rect = pygame.Rect(panel_x, 20, panel_width, panel_height)
|
|
pygame.draw.rect(self.screen, Colors.PANEL_BG, panel_rect, border_radius=10)
|
|
pygame.draw.rect(self.screen, Colors.PANEL_BORDER, 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.screen.get_width() // 2, y=35)
|
|
self.screen.blit(title, title_rect)
|
|
|
|
# Create clipping region for scrollable area
|
|
clip_rect = pygame.Rect(panel_x, 70, panel_width, panel_height - 130)
|
|
|
|
# Draw sliders with scroll offset
|
|
for slider in self.sliders:
|
|
# Adjust position for scroll
|
|
adjusted_rect = slider.rect.copy()
|
|
adjusted_rect.y -= self.scroll_offset
|
|
|
|
# Only draw if visible
|
|
if clip_rect.colliderect(adjusted_rect):
|
|
# Temporarily move slider for drawing
|
|
original_y = slider.rect.y
|
|
slider.rect.y = adjusted_rect.y
|
|
slider.draw(self.screen)
|
|
slider.rect.y = original_y
|
|
|
|
# Draw scroll indicator
|
|
if self.max_scroll > 0:
|
|
scroll_ratio = self.scroll_offset / self.max_scroll
|
|
scroll_height = max(30, int((clip_rect.height / (clip_rect.height + self.max_scroll)) * clip_rect.height))
|
|
scroll_y = clip_rect.y + int(scroll_ratio * (clip_rect.height - scroll_height))
|
|
scroll_rect = pygame.Rect(panel_rect.right - 8, scroll_y, 4, scroll_height)
|
|
pygame.draw.rect(self.screen, Colors.SLIDER_FILL, scroll_rect, border_radius=2)
|
|
|
|
# Draw 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.screen.get_width() // 2, y=self.screen.get_height() - 90)
|
|
self.screen.blit(status, status_rect)
|
|
|