"""Real-time statistics and charts renderer for the Village Simulation. Uses matplotlib to render charts to pygame surfaces for a seamless visualization experience. """ import io from dataclasses import dataclass, field from collections import deque from typing import TYPE_CHECKING, Optional import pygame import matplotlib matplotlib.use('Agg') # Use non-interactive backend for pygame integration import matplotlib.pyplot as plt import matplotlib.ticker as mticker from matplotlib.figure import Figure import numpy as np if TYPE_CHECKING: from frontend.client import SimulationState # Color scheme - dark cyberpunk inspired class ChartColors: """Color palette for charts - dark theme with neon accents.""" BG = '#1a1d26' PANEL = '#252a38' GRID = '#2f3545' TEXT = '#e0e0e8' TEXT_DIM = '#7a7e8c' # Neon accents for data series CYAN = '#00d4ff' MAGENTA = '#ff0099' LIME = '#39ff14' ORANGE = '#ff6600' PURPLE = '#9d4edd' YELLOW = '#ffcc00' TEAL = '#00ffa3' PINK = '#ff1493' # Series colors for different resources/categories SERIES = [CYAN, MAGENTA, LIME, ORANGE, PURPLE, YELLOW, TEAL, PINK] class UIColors: """Color palette for pygame UI elements.""" BG = (26, 29, 38) PANEL_BG = (37, 42, 56) PANEL_BORDER = (70, 80, 100) TEXT_PRIMARY = (224, 224, 232) TEXT_SECONDARY = (122, 126, 140) TEXT_HIGHLIGHT = (0, 212, 255) TAB_ACTIVE = (0, 212, 255) TAB_INACTIVE = (55, 60, 75) TAB_HOVER = (75, 85, 110) @dataclass class HistoryData: """Stores historical simulation data for charting.""" max_history: int = 200 # Time series data turns: deque = field(default_factory=lambda: deque(maxlen=200)) population: deque = field(default_factory=lambda: deque(maxlen=200)) deaths_cumulative: deque = field(default_factory=lambda: deque(maxlen=200)) # Money/Wealth data total_money: deque = field(default_factory=lambda: deque(maxlen=200)) avg_wealth: deque = field(default_factory=lambda: deque(maxlen=200)) gini_coefficient: deque = field(default_factory=lambda: deque(maxlen=200)) # Price history per resource prices: dict = field(default_factory=dict) # resource -> deque of prices # Trade statistics trade_volume: deque = field(default_factory=lambda: deque(maxlen=200)) # Profession counts over time professions: dict = field(default_factory=dict) # profession -> deque of counts def clear(self) -> None: """Clear all history data.""" self.turns.clear() self.population.clear() self.deaths_cumulative.clear() self.total_money.clear() self.avg_wealth.clear() self.gini_coefficient.clear() self.prices.clear() self.trade_volume.clear() self.professions.clear() def update(self, state: "SimulationState") -> None: """Update history with new state data.""" turn = state.turn # Avoid duplicate entries for the same turn if self.turns and self.turns[-1] == turn: return self.turns.append(turn) # Population living = len([a for a in state.agents if a.get("is_alive", False)]) self.population.append(living) self.deaths_cumulative.append(state.statistics.get("total_agents_died", 0)) # Wealth data stats = state.statistics self.total_money.append(stats.get("total_money_in_circulation", 0)) self.avg_wealth.append(stats.get("avg_money", 0)) self.gini_coefficient.append(stats.get("gini_coefficient", 0)) # Price history from market for resource, data in state.market_prices.items(): if resource not in self.prices: self.prices[resource] = deque(maxlen=self.max_history) # Track lowest price (current market rate) lowest = data.get("lowest_price") avg = data.get("avg_sale_price") # Use lowest price if available, else avg sale price price = lowest if lowest is not None else avg self.prices[resource].append(price) # Trade volume (from recent trades in market orders) trades = len(state.market_orders) # Active orders as proxy self.trade_volume.append(trades) # Profession distribution professions = stats.get("professions", {}) for prof, count in professions.items(): if prof not in self.professions: self.professions[prof] = deque(maxlen=self.max_history) self.professions[prof].append(count) # Pad missing professions with 0 for prof in self.professions: if prof not in professions: self.professions[prof].append(0) class ChartRenderer: """Renders matplotlib charts to pygame surfaces.""" def __init__(self, width: int, height: int): self.width = width self.height = height self.dpi = 100 # Configure matplotlib style plt.style.use('dark_background') plt.rcParams.update({ 'figure.facecolor': ChartColors.BG, 'axes.facecolor': ChartColors.PANEL, 'axes.edgecolor': ChartColors.GRID, 'axes.labelcolor': ChartColors.TEXT, 'text.color': ChartColors.TEXT, 'xtick.color': ChartColors.TEXT_DIM, 'ytick.color': ChartColors.TEXT_DIM, 'grid.color': ChartColors.GRID, 'grid.alpha': 0.3, 'legend.facecolor': ChartColors.PANEL, 'legend.edgecolor': ChartColors.GRID, 'font.size': 9, 'axes.titlesize': 11, 'axes.titleweight': 'bold', }) def _fig_to_surface(self, fig: Figure) -> pygame.Surface: """Convert a matplotlib figure to a pygame surface.""" buf = io.BytesIO() fig.savefig(buf, format='png', dpi=self.dpi, facecolor=ChartColors.BG, edgecolor='none', bbox_inches='tight', pad_inches=0.1) buf.seek(0) surface = pygame.image.load(buf, 'png') buf.close() plt.close(fig) return surface def render_price_history(self, history: HistoryData, width: int, height: int) -> pygame.Surface: """Render price history chart for all resources.""" fig, ax = plt.subplots(figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi) turns = list(history.turns) if history.turns else [0] has_data = False for i, (resource, prices) in enumerate(history.prices.items()): if prices and any(p is not None for p in prices): color = ChartColors.SERIES[i % len(ChartColors.SERIES)] # Filter out None values valid_prices = [p if p is not None else 0 for p in prices] # Align with turns min_len = min(len(turns), len(valid_prices)) ax.plot(list(turns)[-min_len:], valid_prices[-min_len:], color=color, linewidth=1.5, label=resource.capitalize(), alpha=0.9) has_data = True ax.set_title('Market Prices', color=ChartColors.CYAN) ax.set_xlabel('Turn') ax.set_ylabel('Price (coins)') ax.grid(True, alpha=0.2) if has_data: ax.legend(loc='upper left', fontsize=8, framealpha=0.8) ax.set_ylim(bottom=0) ax.yaxis.set_major_locator(mticker.MaxNLocator(integer=True)) fig.tight_layout() return self._fig_to_surface(fig) def render_population(self, history: HistoryData, width: int, height: int) -> pygame.Surface: """Render population over time chart.""" fig, ax = plt.subplots(figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi) turns = list(history.turns) if history.turns else [0] population = list(history.population) if history.population else [0] deaths = list(history.deaths_cumulative) if history.deaths_cumulative else [0] min_len = min(len(turns), len(population)) # Population line ax.fill_between(turns[-min_len:], population[-min_len:], alpha=0.3, color=ChartColors.CYAN) ax.plot(turns[-min_len:], population[-min_len:], color=ChartColors.CYAN, linewidth=2, label='Living') # Deaths line if deaths: ax.plot(turns[-min_len:], deaths[-min_len:], color=ChartColors.MAGENTA, linewidth=1.5, linestyle='--', label='Total Deaths', alpha=0.8) ax.set_title('Population Over Time', color=ChartColors.LIME) ax.set_xlabel('Turn') ax.set_ylabel('Count') ax.grid(True, alpha=0.2) ax.legend(loc='upper right', fontsize=8) ax.set_ylim(bottom=0) ax.yaxis.set_major_locator(mticker.MaxNLocator(integer=True)) fig.tight_layout() return self._fig_to_surface(fig) def render_wealth_distribution(self, state: "SimulationState", width: int, height: int) -> pygame.Surface: """Render current wealth distribution as a bar chart.""" fig, ax = plt.subplots(figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi) # Get agent wealth data agents = [a for a in state.agents if a.get("is_alive", False)] if not agents: ax.text(0.5, 0.5, 'No living agents', ha='center', va='center', color=ChartColors.TEXT_DIM, fontsize=12) ax.set_title('Wealth Distribution', color=ChartColors.ORANGE) fig.tight_layout() return self._fig_to_surface(fig) # Sort by wealth agents_sorted = sorted(agents, key=lambda a: a.get("money", 0), reverse=True) names = [a.get("name", "?")[:8] for a in agents_sorted] wealth = [a.get("money", 0) for a in agents_sorted] # Create gradient colors based on wealth ranking colors = [] for i in range(len(agents_sorted)): ratio = i / max(1, len(agents_sorted) - 1) # Gradient from cyan (rich) to magenta (poor) r = int(0 + ratio * 255) g = int(212 - ratio * 212) b = int(255 - ratio * 102) colors.append(f'#{r:02x}{g:02x}{b:02x}') bars = ax.barh(range(len(agents_sorted)), wealth, color=colors, alpha=0.85) ax.set_yticks(range(len(agents_sorted))) ax.set_yticklabels(names, fontsize=7) ax.invert_yaxis() # Rich at top # Add value labels for bar, val in zip(bars, wealth): ax.text(bar.get_width() + 1, bar.get_y() + bar.get_height()/2, f'{val}', va='center', fontsize=7, color=ChartColors.TEXT_DIM) ax.set_title('Wealth Distribution', color=ChartColors.ORANGE) ax.set_xlabel('Coins') ax.grid(True, alpha=0.2, axis='x') fig.tight_layout() return self._fig_to_surface(fig) def render_wealth_over_time(self, history: HistoryData, width: int, height: int) -> pygame.Surface: """Render wealth metrics over time (total money, avg, gini).""" fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi, height_ratios=[2, 1]) turns = list(history.turns) if history.turns else [0] total = list(history.total_money) if history.total_money else [0] avg = list(history.avg_wealth) if history.avg_wealth else [0] gini = list(history.gini_coefficient) if history.gini_coefficient else [0] min_len = min(len(turns), len(total), len(avg)) # Total and average wealth ax1.plot(turns[-min_len:], total[-min_len:], color=ChartColors.CYAN, linewidth=2, label='Total Money') ax1.fill_between(turns[-min_len:], total[-min_len:], alpha=0.2, color=ChartColors.CYAN) ax1_twin = ax1.twinx() ax1_twin.plot(turns[-min_len:], avg[-min_len:], color=ChartColors.LIME, linewidth=1.5, linestyle='--', label='Avg Wealth') ax1_twin.set_ylabel('Avg Wealth', color=ChartColors.LIME) ax1_twin.tick_params(axis='y', labelcolor=ChartColors.LIME) ax1.set_title('Money in Circulation', color=ChartColors.YELLOW) ax1.set_ylabel('Total Money', color=ChartColors.CYAN) ax1.tick_params(axis='y', labelcolor=ChartColors.CYAN) ax1.grid(True, alpha=0.2) ax1.set_ylim(bottom=0) # Gini coefficient (inequality) min_len_gini = min(len(turns), len(gini)) ax2.fill_between(turns[-min_len_gini:], gini[-min_len_gini:], alpha=0.4, color=ChartColors.MAGENTA) ax2.plot(turns[-min_len_gini:], gini[-min_len_gini:], color=ChartColors.MAGENTA, linewidth=1.5) ax2.set_xlabel('Turn') ax2.set_ylabel('Gini') ax2.set_title('Inequality Index', color=ChartColors.MAGENTA, fontsize=9) ax2.set_ylim(0, 1) ax2.grid(True, alpha=0.2) # Add reference lines for gini ax2.axhline(y=0.4, color=ChartColors.YELLOW, linestyle=':', alpha=0.5, linewidth=1) ax2.text(turns[-1] if turns else 0, 0.42, 'Moderate', fontsize=7, color=ChartColors.YELLOW, alpha=0.7) fig.tight_layout() return self._fig_to_surface(fig) def render_professions(self, state: "SimulationState", history: HistoryData, width: int, height: int) -> pygame.Surface: """Render profession distribution as pie chart and area chart.""" fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi) # Current profession pie chart professions = state.statistics.get("professions", {}) if professions: labels = list(professions.keys()) sizes = list(professions.values()) colors = [ChartColors.SERIES[i % len(ChartColors.SERIES)] for i in range(len(labels))] wedges, texts, autotexts = ax1.pie( sizes, labels=labels, colors=colors, autopct='%1.0f%%', startangle=90, pctdistance=0.75, textprops={'fontsize': 8, 'color': ChartColors.TEXT} ) for autotext in autotexts: autotext.set_color(ChartColors.BG) autotext.set_fontweight('bold') ax1.set_title('Current Distribution', color=ChartColors.PURPLE, fontsize=10) else: ax1.text(0.5, 0.5, 'No data', ha='center', va='center', color=ChartColors.TEXT_DIM) ax1.set_title('Current Distribution', color=ChartColors.PURPLE) # Profession history as stacked area turns = list(history.turns) if history.turns else [0] if history.professions and turns: profs_list = list(history.professions.keys()) data = [] for prof in profs_list: prof_data = list(history.professions[prof]) # Pad to match turns length while len(prof_data) < len(turns): prof_data.insert(0, 0) data.append(prof_data[-len(turns):]) colors = [ChartColors.SERIES[i % len(ChartColors.SERIES)] for i in range(len(profs_list))] ax2.stackplot(turns, *data, labels=profs_list, colors=colors, alpha=0.8) ax2.legend(loc='upper left', fontsize=7, framealpha=0.8) ax2.set_xlabel('Turn') ax2.set_ylabel('Count') ax2.set_title('Over Time', color=ChartColors.PURPLE, fontsize=10) ax2.grid(True, alpha=0.2) fig.tight_layout() return self._fig_to_surface(fig) def render_market_activity(self, state: "SimulationState", history: HistoryData, width: int, height: int) -> pygame.Surface: """Render market activity - orders by resource, supply/demand.""" fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi) # Current market orders by resource type prices = state.market_prices resources = [] quantities = [] colors = [] for i, (resource, data) in enumerate(prices.items()): qty = data.get("total_available", 0) if qty > 0: resources.append(resource.capitalize()) quantities.append(qty) colors.append(ChartColors.SERIES[i % len(ChartColors.SERIES)]) if resources: bars = ax1.bar(resources, quantities, color=colors, alpha=0.85) ax1.set_ylabel('Available') for bar, val in zip(bars, quantities): ax1.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.3, str(val), ha='center', fontsize=8, color=ChartColors.TEXT) else: ax1.text(0.5, 0.5, 'No orders', ha='center', va='center', color=ChartColors.TEXT_DIM) ax1.set_title('Market Supply', color=ChartColors.TEAL, fontsize=10) ax1.tick_params(axis='x', rotation=45, labelsize=7) ax1.grid(True, alpha=0.2, axis='y') # Supply/Demand scores resources_sd = [] supply_scores = [] demand_scores = [] for resource, data in prices.items(): resources_sd.append(resource[:6]) supply_scores.append(data.get("supply_score", 0.5)) demand_scores.append(data.get("demand_score", 0.5)) if resources_sd: x = np.arange(len(resources_sd)) width_bar = 0.35 ax2.bar(x - width_bar/2, supply_scores, width_bar, label='Supply', color=ChartColors.CYAN, alpha=0.8) ax2.bar(x + width_bar/2, demand_scores, width_bar, label='Demand', color=ChartColors.MAGENTA, alpha=0.8) ax2.set_xticks(x) ax2.set_xticklabels(resources_sd, fontsize=7, rotation=45) ax2.set_ylabel('Score') ax2.legend(fontsize=7) ax2.set_ylim(0, 1.2) ax2.set_title('Supply/Demand', color=ChartColors.TEAL, fontsize=10) ax2.grid(True, alpha=0.2, axis='y') fig.tight_layout() return self._fig_to_surface(fig) def render_agent_stats(self, state: "SimulationState", width: int, height: int) -> pygame.Surface: """Render aggregate agent statistics - energy, hunger, thirst distributions.""" fig, axes = plt.subplots(2, 2, figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi) agents = [a for a in state.agents if a.get("is_alive", False)] if not agents: for ax in axes.flat: ax.text(0.5, 0.5, 'No agents', ha='center', va='center', color=ChartColors.TEXT_DIM) fig.suptitle('Agent Statistics', color=ChartColors.CYAN) fig.tight_layout() return self._fig_to_surface(fig) # Extract stats energies = [a.get("stats", {}).get("energy", 0) for a in agents] hungers = [a.get("stats", {}).get("hunger", 0) for a in agents] thirsts = [a.get("stats", {}).get("thirst", 0) for a in agents] heats = [a.get("stats", {}).get("heat", 0) for a in agents] max_energy = agents[0].get("stats", {}).get("max_energy", 100) max_hunger = agents[0].get("stats", {}).get("max_hunger", 100) max_thirst = agents[0].get("stats", {}).get("max_thirst", 100) max_heat = agents[0].get("stats", {}).get("max_heat", 100) stats_data = [ (energies, max_energy, 'Energy', ChartColors.LIME), (hungers, max_hunger, 'Hunger', ChartColors.ORANGE), (thirsts, max_thirst, 'Thirst', ChartColors.CYAN), (heats, max_heat, 'Heat', ChartColors.MAGENTA), ] for ax, (values, max_val, name, color) in zip(axes.flat, stats_data): # Histogram bins = np.linspace(0, max_val, 11) ax.hist(values, bins=bins, color=color, alpha=0.7, edgecolor=ChartColors.PANEL) # Mean line mean_val = np.mean(values) ax.axvline(x=mean_val, color=ChartColors.TEXT, linestyle='--', linewidth=1.5, label=f'Avg: {mean_val:.0f}') # Critical threshold critical = max_val * 0.25 ax.axvline(x=critical, color=ChartColors.MAGENTA, linestyle=':', linewidth=1, alpha=0.7) ax.set_title(name, color=color, fontsize=9) ax.set_xlim(0, max_val) ax.legend(fontsize=7, loc='upper right') ax.grid(True, alpha=0.2) fig.suptitle('Agent Statistics Distribution', color=ChartColors.CYAN, fontsize=11) fig.tight_layout() return self._fig_to_surface(fig) class StatsRenderer: """Main statistics panel with tabs and charts.""" TABS = [ ("Prices", "price_history"), ("Wealth", "wealth"), ("Population", "population"), ("Professions", "professions"), ("Market", "market"), ("Agent Stats", "agent_stats"), ] def __init__(self, screen: pygame.Surface): self.screen = screen self.visible = False self.font = pygame.font.Font(None, 24) self.small_font = pygame.font.Font(None, 18) self.title_font = pygame.font.Font(None, 32) self.current_tab = 0 self.tab_hovered = -1 # History data self.history = HistoryData() # Chart renderer self.chart_renderer: Optional[ChartRenderer] = None # Cached chart surfaces self._chart_cache: dict[str, pygame.Surface] = {} self._cache_turn: int = -1 # Layout self._calculate_layout() def _calculate_layout(self) -> None: """Calculate panel layout based on screen size.""" screen_w, screen_h = self.screen.get_size() # Panel takes most of the screen with some margin margin = 30 self.panel_rect = pygame.Rect( margin, margin, screen_w - margin * 2, screen_h - margin * 2 ) # Tab bar self.tab_height = 40 self.tab_rect = pygame.Rect( self.panel_rect.x, self.panel_rect.y, self.panel_rect.width, self.tab_height ) # Chart area self.chart_rect = pygame.Rect( self.panel_rect.x + 10, self.panel_rect.y + self.tab_height + 10, self.panel_rect.width - 20, self.panel_rect.height - self.tab_height - 20 ) # Initialize chart renderer with chart area size self.chart_renderer = ChartRenderer( self.chart_rect.width, self.chart_rect.height ) # Calculate tab widths self.tab_width = self.panel_rect.width // len(self.TABS) def toggle(self) -> None: """Toggle visibility of the stats panel.""" self.visible = not self.visible if self.visible: self._invalidate_cache() def update_history(self, state: "SimulationState") -> None: """Update history data with new state.""" if state: self.history.update(state) def clear_history(self) -> None: """Clear all history data (e.g., on simulation reset).""" self.history.clear() self._invalidate_cache() def _invalidate_cache(self) -> None: """Invalidate chart cache to force re-render.""" self._chart_cache.clear() self._cache_turn = -1 def handle_event(self, event: pygame.event.Event) -> bool: """Handle input events. Returns True if event was consumed.""" if not self.visible: return False if event.type == pygame.MOUSEMOTION: self._handle_mouse_motion(event.pos) return True elif event.type == pygame.MOUSEBUTTONDOWN: if self._handle_click(event.pos): return True # Consume clicks when visible return True elif event.type == pygame.KEYDOWN: if event.key == pygame.K_ESCAPE: self.toggle() return True elif event.key == pygame.K_LEFT: self.current_tab = (self.current_tab - 1) % len(self.TABS) self._invalidate_cache() return True elif event.key == pygame.K_RIGHT: self.current_tab = (self.current_tab + 1) % len(self.TABS) self._invalidate_cache() return True return False def _handle_mouse_motion(self, pos: tuple[int, int]) -> None: """Handle mouse motion for tab hover effects.""" self.tab_hovered = -1 if self.tab_rect.collidepoint(pos): rel_x = pos[0] - self.tab_rect.x tab_idx = rel_x // self.tab_width if 0 <= tab_idx < len(self.TABS): self.tab_hovered = tab_idx def _handle_click(self, pos: tuple[int, int]) -> bool: """Handle mouse click. Returns True if click was on a tab.""" if self.tab_rect.collidepoint(pos): rel_x = pos[0] - self.tab_rect.x tab_idx = rel_x // self.tab_width if 0 <= tab_idx < len(self.TABS) and tab_idx != self.current_tab: self.current_tab = tab_idx self._invalidate_cache() return True return False def _render_chart(self, state: "SimulationState") -> pygame.Surface: """Render the current tab's chart.""" tab_name, tab_key = self.TABS[self.current_tab] # Check cache current_turn = state.turn if state else 0 if tab_key in self._chart_cache and self._cache_turn == current_turn: return self._chart_cache[tab_key] # Render chart based on current tab width = self.chart_rect.width height = self.chart_rect.height if tab_key == "price_history": surface = self.chart_renderer.render_price_history(self.history, width, height) elif tab_key == "wealth": # Split into two charts half_height = height // 2 dist_surface = self.chart_renderer.render_wealth_distribution(state, width, half_height) time_surface = self.chart_renderer.render_wealth_over_time(self.history, width, half_height) surface = pygame.Surface((width, height)) surface.fill(UIColors.BG) surface.blit(dist_surface, (0, 0)) surface.blit(time_surface, (0, half_height)) elif tab_key == "population": surface = self.chart_renderer.render_population(self.history, width, height) elif tab_key == "professions": surface = self.chart_renderer.render_professions(state, self.history, width, height) elif tab_key == "market": surface = self.chart_renderer.render_market_activity(state, self.history, width, height) elif tab_key == "agent_stats": surface = self.chart_renderer.render_agent_stats(state, width, height) else: # Fallback empty surface surface = pygame.Surface((width, height)) surface.fill(UIColors.BG) # Cache the result self._chart_cache[tab_key] = surface self._cache_turn = current_turn return surface def draw(self, state: "SimulationState") -> None: """Draw the statistics 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 background pygame.draw.rect(self.screen, UIColors.PANEL_BG, self.panel_rect, border_radius=12) pygame.draw.rect(self.screen, UIColors.PANEL_BORDER, self.panel_rect, 2, border_radius=12) # Draw tabs self._draw_tabs() # Draw chart if state: chart_surface = self._render_chart(state) self.screen.blit(chart_surface, self.chart_rect.topleft) # Draw close hint hint = self.small_font.render("Press G or ESC to close | ← → to switch tabs", True, UIColors.TEXT_SECONDARY) hint_rect = hint.get_rect(centerx=self.panel_rect.centerx, y=self.panel_rect.bottom - 25) self.screen.blit(hint, hint_rect) def _draw_tabs(self) -> None: """Draw the tab bar.""" for i, (tab_name, _) in enumerate(self.TABS): tab_x = self.tab_rect.x + i * self.tab_width tab_rect = pygame.Rect(tab_x, self.tab_rect.y, self.tab_width, self.tab_height) # Tab background if i == self.current_tab: color = UIColors.TAB_ACTIVE elif i == self.tab_hovered: color = UIColors.TAB_HOVER else: color = UIColors.TAB_INACTIVE # Draw tab with rounded top corners tab_surface = pygame.Surface((self.tab_width, self.tab_height), pygame.SRCALPHA) pygame.draw.rect(tab_surface, color, (0, 0, self.tab_width, self.tab_height), border_top_left_radius=8, border_top_right_radius=8) if i == self.current_tab: # Active tab - solid color tab_surface.set_alpha(255) else: tab_surface.set_alpha(180) self.screen.blit(tab_surface, (tab_x, self.tab_rect.y)) # Tab text text_color = UIColors.BG if i == self.current_tab else UIColors.TEXT_PRIMARY text = self.small_font.render(tab_name, True, text_color) text_rect = text.get_rect(center=tab_rect.center) self.screen.blit(text, text_rect) # Tab border if i != self.current_tab: pygame.draw.line(self.screen, UIColors.PANEL_BORDER, (tab_x + self.tab_width - 1, self.tab_rect.y + 5), (tab_x + self.tab_width - 1, self.tab_rect.y + self.tab_height - 5))