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