"""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)