# ui/hud.py """Handles HUD elements and text overlays.""" import pygame import pygame_gui from config.constants import * from world.base.brain import CellBrain, FlexibleNeuralNetwork from world.objects import DefaultCell from pygame_gui.elements import UIPanel, UIButton, UITextEntryLine, UILabel from ui.tree_widget import TreeWidget import math DARK_GRAY = (40, 40, 40) DARKER_GRAY = (25, 25, 25) # Panel visibility constants SHOW_CONTROL_BAR = True SHOW_INSPECTOR_PANEL = True SHOW_PROPERTIES_PANEL = True SHOW_CONSOLE_PANEL = False class HUD: def __init__(self, ui_manager, screen_width=SCREEN_WIDTH, screen_height=SCREEN_HEIGHT): self.font = pygame.font.Font("freesansbold.ttf", FONT_SIZE) self.legend_font = pygame.font.Font("freesansbold.ttf", LEGEND_FONT_SIZE) self.manager = ui_manager self.screen_width = screen_width self.screen_height = screen_height # Panel size defaults self.control_bar_height = 48 self.inspector_width = 260 self.properties_width = 320 self.console_height = 120 self.splitter_thickness = 6 self.dragging_splitter = None # Simulation control elements self.play_pause_button = None self.step_button = None self.sprint_button = None self.speed_buttons = {} self.custom_tps_entry = None self.tps_label = None # Tree widget for inspector self.tree_widget = None self.world = None # Will be set when world is available self._last_tree_selection = None # Track last selection to avoid unnecessary updates self._create_panels() self._create_simulation_controls() def _create_panels(self): self.panels = [] # Top control bar if SHOW_CONTROL_BAR: self.control_bar = UIPanel( relative_rect=pygame.Rect(0, 0, self.screen_width, self.control_bar_height), manager=self.manager, object_id="#control_bar", ) self.panels.append(self.control_bar) else: self.control_bar = None # Left inspector with tree widget if SHOW_INSPECTOR_PANEL: # Create a container panel for the inspector self.inspector_panel = UIPanel( relative_rect=pygame.Rect( 0, self.control_bar_height if SHOW_CONTROL_BAR else 0, self.inspector_width, self.screen_height - (self.control_bar_height if SHOW_CONTROL_BAR else 0) ), manager=self.manager, object_id="#inspector_panel", ) self.panels.append(self.inspector_panel) # Tree widget will be created when world is available self.tree_widget = None else: self.inspector_panel = None self.tree_widget = None # Right properties if SHOW_PROPERTIES_PANEL: self.properties_panel = UIPanel( relative_rect=pygame.Rect( self.screen_width - self.properties_width, self.control_bar_height if SHOW_CONTROL_BAR else 0, self.properties_width, self.screen_height - (self.control_bar_height if SHOW_CONTROL_BAR else 0) ), manager=self.manager, object_id="#properties_panel", ) self.panels.append(self.properties_panel) else: self.properties_panel = None # Bottom console if SHOW_CONSOLE_PANEL: self.console_panel = UIPanel( relative_rect=pygame.Rect( self.inspector_width if SHOW_INSPECTOR_PANEL else 0, self.screen_height - self.console_height, self.screen_width - (self.inspector_width if SHOW_INSPECTOR_PANEL else 0) - (self.properties_width if SHOW_PROPERTIES_PANEL else 0), self.console_height ), manager=self.manager, object_id="#console_panel", ) self.panels.append(self.console_panel) else: self.console_panel = None self.dragging_splitter = None def initialize_tree_widget(self, world): """Initialize the tree widget when the world is available.""" self.world = world if self.inspector_panel and world: # Create tree widget inside the inspector panel tree_rect = pygame.Rect(0, 0, self.inspector_width, self.inspector_panel.rect.height) self.tree_widget = TreeWidget(tree_rect, self.manager, world) def update_tree_selection(self, selected_objects): """Update tree selection based on world selection.""" if self.tree_widget: # Only update if selection actually changed current_selection = tuple(selected_objects) # Convert to tuple for comparison if self._last_tree_selection != current_selection: self.tree_widget.select_entities(selected_objects) self._last_tree_selection = current_selection def _create_simulation_controls(self): """Create simulation control buttons in the control bar.""" if not self.control_bar: return # Button layout constants button_width = 40 button_height = 32 button_spacing = 8 start_x = 20 start_y = 8 # Play/Pause button self.play_pause_button = UIButton( relative_rect=pygame.Rect(start_x, start_y, button_width, button_height), text='>', manager=self.manager, container=self.control_bar, object_id="#play_pause_button" ) # Step forward button step_x = start_x + button_width + button_spacing self.step_button = UIButton( relative_rect=pygame.Rect(step_x, start_y, button_width, button_height), text='>>', manager=self.manager, container=self.control_bar, object_id="#step_button" ) # Sprint button sprint_x = step_x + button_width + button_spacing + 5 # Extra spacing self.sprint_button = UIButton( relative_rect=pygame.Rect(sprint_x, start_y, button_width + 10, button_height), text='>>|', manager=self.manager, container=self.control_bar, object_id="#sprint_button" ) # Speed control buttons speed_labels = ["0.5x", "1x", "2x", "4x", "8x"] speed_multipliers = [0.5, 1.0, 2.0, 4.0, 8.0] speed_x = sprint_x + button_width + 10 + button_spacing + 10 # Extra spacing for i, (label, multiplier) in enumerate(zip(speed_labels, speed_multipliers)): button_x = speed_x + i * (button_width - 5 + button_spacing) button = UIButton( relative_rect=pygame.Rect(button_x, start_y, button_width - 5, button_height), text=label, manager=self.manager, container=self.control_bar, object_id=f"#speed_{int(multiplier*10)}x_button" ) self.speed_buttons[multiplier] = button # Custom TPS input tps_x = speed_x + len(speed_labels) * (button_width - 5 + button_spacing) + button_spacing self.custom_tps_entry = UITextEntryLine( relative_rect=pygame.Rect(tps_x, start_y + 2, 50, button_height - 4), manager=self.manager, container=self.control_bar, object_id="#custom_tps_entry" ) self.custom_tps_entry.set_text(str(DEFAULT_TPS)) # TPS display label tps_label_x = tps_x + 55 self.tps_label = UILabel( relative_rect=pygame.Rect(tps_label_x, start_y + 4, 80, button_height - 8), text='TPS: 40', manager=self.manager, container=self.control_bar, object_id="#tps_label" ) def get_viewport_rect(self): # Returns the rect for the simulation viewport inspector_width = self.inspector_width if SHOW_INSPECTOR_PANEL and self.inspector_panel else 0 control_bar_height = self.control_bar_height if SHOW_CONTROL_BAR and self.control_bar else 0 properties_width = self.properties_width if SHOW_PROPERTIES_PANEL and self.properties_panel else 0 console_height = self.console_height if SHOW_CONSOLE_PANEL and self.console_panel else 0 x = inspector_width y = control_bar_height w = self.screen_width - inspector_width - properties_width h = self.screen_height - control_bar_height - console_height return pygame.Rect(x, y, w, h) def update_layout(self, window_width, window_height): self.screen_width = window_width self.screen_height = window_height control_bar_height = self.control_bar_height if SHOW_CONTROL_BAR and self.control_bar else 0 # Control bar (top) if self.control_bar: self.control_bar.set_relative_position((0, 0)) self.control_bar.set_dimensions((self.screen_width, self.control_bar_height)) # Inspector panel (left) if self.inspector_panel: self.inspector_panel.set_relative_position((0, control_bar_height)) self.inspector_panel.set_dimensions((self.inspector_width, self.screen_height - control_bar_height)) # Update tree widget size if it exists if self.tree_widget: tree_rect = pygame.Rect(0, 0, self.inspector_width, self.inspector_panel.rect.height) self.tree_widget.rect = tree_rect # Properties panel (right) if self.properties_panel: self.properties_panel.set_relative_position( (self.screen_width - self.properties_width, control_bar_height)) self.properties_panel.set_dimensions((self.properties_width, self.screen_height - control_bar_height)) # Console panel (bottom, spans between inspector and properties) if self.console_panel: inspector_width = self.inspector_width if SHOW_INSPECTOR_PANEL and self.inspector_panel else 0 properties_width = self.properties_width if SHOW_PROPERTIES_PANEL and self.properties_panel else 0 self.console_panel.set_relative_position((inspector_width, self.screen_height - self.console_height)) self.console_panel.set_dimensions( (self.screen_width - inspector_width - properties_width, self.console_height)) # Recreate simulation controls after layout change if hasattr(self, 'play_pause_button'): self._destroy_simulation_controls() self._create_simulation_controls() def _destroy_simulation_controls(self): """Destroy simulation control elements.""" if self.play_pause_button: self.play_pause_button.kill() if self.step_button: self.step_button.kill() if self.sprint_button: self.sprint_button.kill() for button in self.speed_buttons.values(): button.kill() if self.custom_tps_entry: self.custom_tps_entry.kill() if self.tps_label: self.tps_label.kill() self.play_pause_button = None self.step_button = None self.sprint_button = None self.speed_buttons = {} self.custom_tps_entry = None self.tps_label = None def update_simulation_controls(self, simulation_core): """Update simulation control button states and displays based on simulation core state.""" if not self.play_pause_button: return timing_state = simulation_core.timing.state # Update play/pause button if timing_state.is_paused: self.play_pause_button.set_text('>') else: self.play_pause_button.set_text('||') # Update speed button highlights speed_presets = {0.5: "0.5x", 1.0: "1x", 2.0: "2x", 4.0: "4x", 8.0: "8x"} for multiplier, button in self.speed_buttons.items(): if (timing_state.speed_multiplier == multiplier and not timing_state.is_paused and not timing_state.sprint_mode): # Active speed button - make text more prominent button.set_text(f"[{speed_presets[multiplier]}]") else: # Normal button appearance button.set_text(speed_presets[multiplier]) # Update sprint button appearance if timing_state.sprint_mode: self.sprint_button.set_text('⚡') else: self.sprint_button.set_text('⚡') # Update TPS display if self.tps_label: if timing_state.sprint_mode: self.tps_label.set_text(f"TPS: {timing_state.tps:.0f} (Sprint)") else: self.tps_label.set_text(f"TPS: {timing_state.tps:.0f}") # Update custom TPS entry if self.custom_tps_entry and not self.custom_tps_entry.is_focused: self.custom_tps_entry.set_text(str(int(timing_state.tps))) def process_event(self, event): # Check for splitter dragging events first (don't let tree widget block them) if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: mx, my = event.pos # Check if mouse is on a splitter if abs(mx - self.inspector_width) < self.splitter_thickness: # Don't handle this event in the tree widget - it's for the splitter pass elif self.tree_widget and self.inspector_panel: # Handle tree widget events inspector_rect = self.inspector_panel.rect # The inspector_rect should be in absolute screen coordinates inspector_abs_x = inspector_rect.x inspector_abs_y = inspector_rect.y tree_local_pos = (event.pos[0] - inspector_abs_x, event.pos[1] - inspector_abs_y) if (0 <= tree_local_pos[0] < self.tree_widget.rect.width and 0 <= tree_local_pos[1] < self.tree_widget.rect.height): event.pos = tree_local_pos if self.tree_widget.handle_event(event): selected_entities = self.tree_widget.get_selected_entities() return 'tree_selection_changed', selected_entities elif self.tree_widget and self.inspector_panel: # Handle other tree widget events (except MOUSEWHEEL - handled by InputHandler) if event.type == pygame.MOUSEMOTION: # Only handle if not dragging splitter if not self.dragging_splitter and hasattr(event, 'pos'): # For mouse motion, check if mouse is over tree widget inspector_rect = self.inspector_panel.rect # Calculate absolute screen position (need to add control bar height) inspector_abs_x = inspector_rect.x inspector_abs_y = inspector_rect.y tree_local_pos = (event.pos[0] - inspector_abs_x, event.pos[1] - inspector_abs_y) if (0 <= tree_local_pos[0] < self.tree_widget.rect.width and 0 <= tree_local_pos[1] < self.tree_widget.rect.height): event.pos = tree_local_pos if self.tree_widget.handle_event(event): selected_entities = self.tree_widget.get_selected_entities() return 'tree_selection_changed', selected_entities else: # For non-mouse events, try handling them normally if self.tree_widget.handle_event(event): selected_entities = self.tree_widget.get_selected_entities() return 'tree_selection_changed', selected_entities # Handle simulation control button events using ID matching if event.type == pygame_gui.UI_BUTTON_START_PRESS: object_id = str(event.ui_object_id) if '#play_pause_button' in object_id: return 'toggle_pause' elif '#step_button' in object_id: return 'step_forward' elif '#sprint_button' in object_id: return 'toggle_sprint' elif '#speed_5x_button' in object_id: # 0.5x button return 'set_speed', 0.5 elif '#speed_10x_button' in object_id: # 1x button return 'set_speed', 1.0 elif '#speed_20x_button' in object_id: # 2x button return 'set_speed', 2.0 elif '#speed_40x_button' in object_id: # 4x button return 'set_speed', 4.0 elif '#speed_80x_button' in object_id: # 8x button return 'set_speed', 8.0 elif event.type == pygame_gui.UI_TEXT_ENTRY_FINISHED: object_id = str(event.ui_object_id) print(f"Text entry finished: {object_id}, text: {event.text}") if '#custom_tps_entry' in object_id: try: tps = float(event.text) return 'set_custom_tps', tps except ValueError: # Invalid TPS value, reset to current TPS return ('reset_tps_display',) elif event.type == pygame_gui.UI_TEXT_ENTRY_CHANGED: object_id = str(event.ui_object_id) if '#custom_tps_entry' in object_id: print(f"Text entry changed: {object_id}, text: {event.text}") # Handle splitter dragging for resizing panels if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: mx, my = event.pos # Check if mouse is on a splitter (left/right/bottom) if abs(mx - self.inspector_width) < self.splitter_thickness and self.control_bar_height < my < self.screen_height - self.console_height: self.dragging_splitter = "inspector" elif abs(mx - (self.screen_width - self.properties_width)) < self.splitter_thickness and self.control_bar_height < my < self.screen_height - self.console_height: self.dragging_splitter = "properties" elif abs(my - (self.screen_height - self.console_height)) < self.splitter_thickness and self.inspector_width < mx < self.screen_width - self.properties_width: self.dragging_splitter = "console" self.update_layout(self.screen_width, self.screen_height) elif event.type == pygame.MOUSEBUTTONUP and event.button == 1: if self.dragging_splitter is not None: self.dragging_splitter = None return 'viewport_resized' elif event.type == pygame.MOUSEMOTION and self.dragging_splitter: mx, my = event.pos if self.dragging_splitter == "inspector": self.inspector_width = max(100, min(mx, self.screen_width - self.properties_width - 100)) elif self.dragging_splitter == "properties": self.properties_width = max(100, min(self.screen_width - mx, self.screen_width - self.inspector_width - 100)) elif self.dragging_splitter == "console": self.console_height = max(60, min(self.screen_height - my, self.screen_height - self.control_bar_height - 60)) self.update_layout(self.screen_width, self.screen_height) def draw_splitters(self, screen): # Draw draggable splitters for visual feedback indicator_color = (220, 220, 220) indicator_size = 6 # Length of indicator line indicator_gap = 4 # Gap between indicator lines indicator_count = 3 # Number of indicator lines inspector_width = self.inspector_width if SHOW_INSPECTOR_PANEL and self.inspector_panel else 0 properties_width = self.properties_width if SHOW_PROPERTIES_PANEL and self.properties_panel else 0 console_height = self.console_height if SHOW_CONSOLE_PANEL and self.console_panel else 0 control_bar_height = self.control_bar_height if SHOW_CONTROL_BAR and self.control_bar else 0 # Vertical splitter (inspector/properties) # Inspector/properties only if wide enough if inspector_width > 0: x = inspector_width - 2 y1 = control_bar_height y2 = self.screen_height - console_height # Draw indicator (horizontal lines) in the middle mid_y = (y1 + y2) // 2 for i in range(indicator_count): offset = (i - 1) * (indicator_gap + 1) pygame.draw.line( screen, indicator_color, (x - indicator_size // 2, mid_y + offset), (x + indicator_size // 2, mid_y + offset), 2 ) if properties_width > 0: x = self.screen_width - properties_width + 2 y1 = control_bar_height y2 = self.screen_height - console_height mid_y = (y1 + y2) // 2 for i in range(indicator_count): offset = (i - 1) * (indicator_gap + 1) pygame.draw.line( screen, indicator_color, (x - indicator_size // 2, mid_y + offset), (x + indicator_size // 2, mid_y + offset), 2 ) # Horizontal splitter (console) if console_height > 0: y = self.screen_height - console_height + 2 x1 = inspector_width x2 = self.screen_width - properties_width mid_x = (x1 + x2) // 2 for i in range(indicator_count): offset = (i - 1) * (indicator_gap + 1) pygame.draw.line( screen, indicator_color, (mid_x + offset, y - indicator_size // 2), (mid_x + offset, y + indicator_size // 2), 2 ) def render_mouse_position(self, screen, camera, sim_view_rect): """Render mouse position in top left.""" mouse_x, mouse_y = pygame.mouse.get_pos() sim_view_x = mouse_x - sim_view_rect.left sim_view_y = mouse_y - sim_view_rect.top world_x, world_y = camera.get_real_coordinates(sim_view_x, sim_view_y) mouse_text = self.font.render(f"Mouse: ({mouse_x:.2f}, {mouse_y:.2f})", True, WHITE) text_rect = mouse_text.get_rect() text_rect.topleft = (HUD_MARGIN, HUD_MARGIN) screen.blit(mouse_text, text_rect) def render_fps(self, screen, clock): """Render FPS in top right.""" fps_text = self.font.render(f"FPS: {int(clock.get_fps())}", True, WHITE) fps_rect = fps_text.get_rect() fps_rect.topright = (self.screen_width - HUD_MARGIN, HUD_MARGIN) screen.blit(fps_text, fps_rect) def render_tps(self, screen, actual_tps): """Render TPS in bottom right.""" display_tps = round(actual_tps) # Round to nearest whole number tps_text = self.font.render(f"TPS: {display_tps}", True, WHITE) tps_rect = tps_text.get_rect() tps_rect.bottomright = (self.screen_width - HUD_MARGIN, self.screen_height - HUD_MARGIN) screen.blit(tps_text, tps_rect) def render_tick_count(self, screen, total_ticks): """Render total tick count in bottom left.""" tick_text = self.font.render(f"Ticks: {total_ticks}", True, WHITE) tick_rect = tick_text.get_rect() tick_rect.bottomleft = (HUD_MARGIN, self.screen_height - HUD_MARGIN) screen.blit(tick_text, tick_rect) def render_pause_indicator(self, screen, is_paused): """Render pause indicator when paused.""" if is_paused: pause_text = self.font.render("Press 'Space' to unpause", True, WHITE) pause_rect = pause_text.get_rect() pause_rect.center = (self.screen_width // 2, 20) screen.blit(pause_text, pause_rect) def render_selected_objects_info(self, screen, selected_objects): """Render information about selected objects.""" if len(selected_objects) < 1: return max_width = self.screen_width - 20 i = 0 for obj in selected_objects: text = f"Object: {str(obj)}" words = text.split() line = "" line_offset = 0 for word in words: test_line = f"{line} {word}".strip() test_width, _ = self.font.size(test_line) if test_width > max_width and line: obj_text = self.font.render(line, True, WHITE) obj_rect = obj_text.get_rect() obj_rect.topleft = (HUD_MARGIN, 30 + i * LINE_HEIGHT + line_offset) screen.blit(obj_text, obj_rect) line = word line_offset += LINE_HEIGHT else: line = test_line if line: obj_text = self.font.render(line, True, WHITE) obj_rect = obj_text.get_rect() obj_rect.topleft = (HUD_MARGIN, 30 + i * LINE_HEIGHT + line_offset) screen.blit(obj_text, obj_rect) i += 1 def render_legend(self, screen, showing_legend): """Render the controls legend.""" if not showing_legend: legend_text = self.legend_font.render("Press 'L' to show controls", True, WHITE) legend_rect = legend_text.get_rect() legend_rect.center = (self.screen_width // 2, self.screen_height - 20) screen.blit(legend_text, legend_rect) return # Split into two columns mid = (len(KEYMAP_LEGEND) + 1) // 2 left_col = KEYMAP_LEGEND[:mid] right_col = KEYMAP_LEGEND[mid:] legend_font_height = self.legend_font.get_height() column_gap = 40 # Space between columns # Calculate max width for each column left_width = max(self.legend_font.size(f"{k}: {v}")[0] for k, v in left_col) right_width = max(self.legend_font.size(f"{k}: {v}")[0] for k, v in right_col) legend_width = left_width + right_width + column_gap legend_height = max(len(left_col), len(right_col)) * legend_font_height + 10 legend_x = (self.screen_width - legend_width) // 2 legend_y = self.screen_height - legend_height - 10 # Draw left column for i, (key, desc) in enumerate(left_col): text = self.legend_font.render(f"{key}: {desc}", True, WHITE) text_rect = text.get_rect() text_rect.left = legend_x text_rect.top = legend_y + 5 + i * legend_font_height screen.blit(text, text_rect) # Draw right column for i, (key, desc) in enumerate(right_col): text = self.legend_font.render(f"{key}: {desc}", True, WHITE) text_rect = text.get_rect() text_rect.left = legend_x + left_width + column_gap text_rect.top = legend_y + 5 + i * legend_font_height screen.blit(text, text_rect) def render_neural_network_visualization(self, screen, cell: DefaultCell) -> None: """Render neural network visualization. This is fixed to the screen size and is not dependent on zoom or camera position.""" # Visualization layout constants VIZ_WIDTH = 280 # Width of the neural network visualization area VIZ_HEIGHT = 300 # Height of the neural network visualization area VIZ_RIGHT_MARGIN = VIZ_WIDTH + 50 # Distance from right edge of screen to visualization VIZ_BOTTOM_MARGIN = 50 # Distance from the bottom of the screen # Background styling constants BACKGROUND_PADDING = 30 # Padding around the visualization background BACKGROUND_BORDER_WIDTH = 2 # Width of the background border BACKGROUND_COLOR = (30, 30, 30) # Dark gray background color # Title positioning constants TITLE_TOP_MARGIN = 20 # Distance above visualization for title # Neuron appearance constants NEURON_RADIUS = 8 # Radius of neuron circles NEURON_BORDER_WIDTH = 2 # Width of neuron circle borders # Layer spacing constants LAYER_VERTICAL_MARGIN = 30 # Top and bottom margin within visualization for neurons # Connection appearance constants MAX_CONNECTION_THICKNESS = 4 # Maximum thickness for connection lines MIN_CONNECTION_THICKNESS = 1 # Minimum thickness for connection lines # Neuron activation colors NEURON_BASE_INTENSITY = 100 # Base color intensity for neurons NEURON_ACTIVATION_INTENSITY = 155 # Additional intensity based on activation # Text positioning constants ACTIVATION_TEXT_OFFSET = 15 # Distance below neuron for activation value text ACTIVATION_DISPLAY_THRESHOLD = 0.01 # Minimum activation value to display as text ACTIVATION_TEXT_PRECISION = 2 # Decimal places for activation values # Layer label positioning constants LAYER_LABEL_BOTTOM_MARGIN = 15 # Distance below visualization for layer labels # Info text positioning constants INFO_TEXT_TOP_MARGIN = 40 # Distance below visualization for info text INFO_TEXT_LINE_SPACING = 15 # Vertical spacing between info text lines # Activation value clamping ACTIVATION_CLAMP_MIN = -1 # Minimum activation value for visualization ACTIVATION_CLAMP_MAX = 1 # Maximum activation value for visualization # --- Tooltip constants --- TOOLTIP_X_OFFSET = 12 TOOLTIP_Y_OFFSET = 8 TOOLTIP_PADDING_X = 5 TOOLTIP_PADDING_Y = 3 TOOLTIP_BG_COLOR = (40, 40, 40) TOOLTIP_BORDER_COLOR = WHITE TOOLTIP_BORDER_WIDTH = 1 TOOLTIP_MARGIN = 10 TOOLTIP_LINE_SPACING = 0 # No extra spacing between lines if self.properties_width < VIZ_RIGHT_MARGIN + 50: self.properties_width = VIZ_RIGHT_MARGIN + 50 # Ensure properties panel is wide enough for tooltip self.update_layout(self.screen_width, self.screen_height) # Immediately update layout if not hasattr(cell, 'behavioral_model'): return cell_brain: CellBrain = cell.behavioral_model if not hasattr(cell_brain, 'neural_network'): return network: FlexibleNeuralNetwork = cell_brain.neural_network # Calculate visualization position (bottom right) viz_x = self.screen_width - VIZ_RIGHT_MARGIN # Right side of screen viz_y = self.screen_height - VIZ_HEIGHT - VIZ_BOTTOM_MARGIN # Above the bottom margin layer_spacing = VIZ_WIDTH // max(1, len(network.layers) - 1) if len(network.layers) > 1 else VIZ_WIDTH # Draw background background_rect = pygame.Rect(viz_x - BACKGROUND_PADDING, viz_y - BACKGROUND_PADDING, VIZ_WIDTH + 2 * BACKGROUND_PADDING, VIZ_HEIGHT + 2 * BACKGROUND_PADDING) pygame.draw.rect(screen, BACKGROUND_COLOR, background_rect) pygame.draw.rect(screen, WHITE, background_rect, BACKGROUND_BORDER_WIDTH) info = network.get_structure_info() # Title title_text = self.font.render("Neural Network", True, WHITE) title_rect = title_text.get_rect() title_rect.centerx = viz_x + VIZ_WIDTH // 2 title_rect.top = viz_y - TITLE_TOP_MARGIN screen.blit(title_text, title_rect) # Render network cost under the title cost_text = self.font.render(f"Cost: {info['network_cost']}", True, WHITE) cost_rect = cost_text.get_rect() cost_rect.centerx = title_rect.centerx cost_rect.top = title_rect.bottom + 4 # Small gap below the title screen.blit(cost_text, cost_rect) # Get current activations by running a forward pass with current inputs input_values = [cell_brain.inputs[key] for key in cell_brain.input_keys] # Store activations for each layer activations = [input_values] # Input layer # Calculate activations for each layer for layer_idx in range(1, len(network.layers)): layer_activations = [] for neuron in network.layers[layer_idx]: if neuron['type'] == 'input': continue # Calculate weighted sum weighted_sum = neuron.get('bias', 0) for source_layer, source_neuron, weight in neuron.get('connections', []): if source_layer < len(activations) and source_neuron < len(activations[source_layer]): weighted_sum += activations[source_layer][source_neuron] * weight # Apply activation function activation = max(ACTIVATION_CLAMP_MIN, min(ACTIVATION_CLAMP_MAX, weighted_sum)) layer_activations.append(activation) activations.append(layer_activations) # Calculate neuron positions neuron_positions = {} for layer_idx, layer in enumerate(network.layers): layer_neurons = [n for n in layer if n['type'] != 'input' or layer_idx == 0] layer_size = len(layer_neurons) if layer_size == 0: continue # X position based on layer if len(network.layers) == 1: x = viz_x + VIZ_WIDTH // 2 else: x = viz_x + (layer_idx * layer_spacing) # Y positions distributed vertically if layer_size == 1: y_positions = [viz_y + VIZ_HEIGHT // 2] else: y_start = viz_y + LAYER_VERTICAL_MARGIN y_end = viz_y + VIZ_HEIGHT - LAYER_VERTICAL_MARGIN y_positions = [y_start + i * (y_end - y_start) / (layer_size - 1) for i in range(layer_size)] for neuron_idx, neuron in enumerate(layer_neurons): if neuron_idx < len(y_positions): neuron_positions[(layer_idx, neuron_idx)] = (int(x), int(y_positions[neuron_idx])) # Draw connections first (so they appear behind neurons) for layer_idx in range(1, len(network.layers)): for neuron_idx, neuron in enumerate(network.layers[layer_idx]): if neuron['type'] == 'input': continue target_pos = neuron_positions.get((layer_idx, neuron_idx)) if not target_pos: continue for source_layer, source_neuron, weight in neuron.get('connections', []): source_pos = neuron_positions.get((source_layer, source_neuron)) if not source_pos: continue # Get activation value of the source neuron if source_layer < len(activations) and source_neuron < len(activations[source_layer]): activation = activations[source_layer][source_neuron] else: activation = 0.0 # Clamp activation to [-1, 1] activation = max(ACTIVATION_CLAMP_MIN, min(ACTIVATION_CLAMP_MAX, activation)) # Color: interpolate from red (-1) to yellow (0) to green (+1) if activation <= 0: # Red to yellow r = 255 g = int(255 * (activation + 1)) b = 0 else: # Yellow to green r = int(255 * (1 - activation)) g = 255 b = 0 color = (r, g, b) # Thickness: proportional to abs(weight) thickness = max(MIN_CONNECTION_THICKNESS, int(abs(weight) * MAX_CONNECTION_THICKNESS)) pygame.draw.line(screen, color, source_pos, target_pos, thickness) # Draw neurons for layer_idx, layer in enumerate(network.layers): layer_activations = activations[layer_idx] if layer_idx < len(activations) else [] for neuron_idx, neuron in enumerate(layer): if neuron['type'] == 'input' and layer_idx != 0: continue pos = neuron_positions.get((layer_idx, neuron_idx)) if not pos: continue # Get activation value activation = 0 if neuron_idx < len(layer_activations): activation = layer_activations[neuron_idx] # Color based on activation: brightness represents magnitude activation_normalized = max(ACTIVATION_CLAMP_MIN, min(ACTIVATION_CLAMP_MAX, activation)) activation_intensity = int(abs(activation_normalized) * NEURON_ACTIVATION_INTENSITY) if activation_normalized >= 0: # Positive activation: blue tint color = (NEURON_BASE_INTENSITY, NEURON_BASE_INTENSITY, NEURON_BASE_INTENSITY + activation_intensity) else: # Negative activation: red tint color = (NEURON_BASE_INTENSITY + activation_intensity, NEURON_BASE_INTENSITY, NEURON_BASE_INTENSITY) # Draw neuron pygame.draw.circle(screen, color, pos, NEURON_RADIUS) pygame.draw.circle(screen, WHITE, pos, NEURON_RADIUS, NEURON_BORDER_WIDTH) # Draw activation value as text if abs(activation) > ACTIVATION_DISPLAY_THRESHOLD: activation_text = self.legend_font.render(f"{activation:.{ACTIVATION_TEXT_PRECISION}f}", True, WHITE) text_rect = activation_text.get_rect() text_rect.center = (pos[0], pos[1] + NEURON_RADIUS + ACTIVATION_TEXT_OFFSET) screen.blit(activation_text, text_rect) # Draw layer labels num_layers = len(network.layers) for layer_idx in range(num_layers): if layer_idx == 0: label = "Input" elif layer_idx == num_layers - 1: label = "Output" else: label = f"Hidden {layer_idx}" if num_layers > 3 else "Hidden" # Find average x position for this layer x_positions = [pos[0] for (l_idx, n_idx), pos in neuron_positions.items() if l_idx == layer_idx] if x_positions: avg_x = sum(x_positions) // len(x_positions) label_text = self.legend_font.render(label, True, WHITE) label_rect = label_text.get_rect() label_rect.centerx = avg_x label_rect.bottom = viz_y + VIZ_HEIGHT + LAYER_LABEL_BOTTOM_MARGIN screen.blit(label_text, label_rect) # --- Tooltip logic for neuron hover --- mouse_x, mouse_y = pygame.mouse.get_pos() tooltip_text = None for (layer_idx, neuron_idx), pos in neuron_positions.items(): dx = mouse_x - pos[0] dy = mouse_y - pos[1] dist = math.hypot(dx, dy) if dist <= NEURON_RADIUS + 3: neuron = network.layers[layer_idx][neuron_idx] label = None value_str = None # Show input/output name if applicable if neuron['type'] == 'input' and layer_idx == 0: if neuron_idx < len(cell_brain.input_keys): key = cell_brain.input_keys[neuron_idx] label = f"Input: {key}" # Show normalized input value raw_value = cell_brain.inputs.get(key, 0.0) normalized_value = cell_brain._normalize_input(key, raw_value) value_str = f"Value: {normalized_value:.2f}" elif neuron['type'] == 'output': if neuron_idx < len(cell_brain.output_keys): key = cell_brain.output_keys[neuron_idx] label = f"Output: {key}" # Show output value (already actual, not normalized) value = cell_brain.outputs.get(key, 0.0) value_str = f"Value: {value:.2f}" else: # For hidden neurons, show activation value if layer_idx < len(activations) and neuron_idx < len(activations[layer_idx]): value = activations[layer_idx][neuron_idx] value_str = f"Value: {value:.2f}" # Show bias if present bias = neuron.get('bias', None) bias_str = f"Bias: {bias:.2f}" if bias is not None else None # Compose tooltip text tooltip_lines = [] if label: tooltip_lines.append(label) if value_str: tooltip_lines.append(value_str) if bias_str: tooltip_lines.append(bias_str) tooltip_text = "\n".join(tooltip_lines) if tooltip_lines else None break if tooltip_text: lines = tooltip_text.split('\n') tooltip_surfs = [self.legend_font.render(line, True, WHITE) for line in lines] width = max(surf.get_width() for surf in tooltip_surfs) + TOOLTIP_MARGIN height = sum(surf.get_height() for surf in tooltip_surfs) + TOOLTIP_MARGIN # Default position: right and below cursor tooltip_x = mouse_x + TOOLTIP_X_OFFSET tooltip_y = mouse_y + TOOLTIP_Y_OFFSET # Adjust if off right edge if tooltip_x + width > self.screen_width: tooltip_x = mouse_x - width - TOOLTIP_X_OFFSET # Adjust if off bottom edge if tooltip_y + height > self.screen_height: tooltip_y = mouse_y - height - TOOLTIP_Y_OFFSET tooltip_rect = pygame.Rect(tooltip_x, tooltip_y, width, height) pygame.draw.rect(screen, TOOLTIP_BG_COLOR, tooltip_rect) pygame.draw.rect(screen, TOOLTIP_BORDER_COLOR, tooltip_rect, TOOLTIP_BORDER_WIDTH) y = tooltip_rect.top + TOOLTIP_PADDING_Y for surf in tooltip_surfs: screen.blit(surf, (tooltip_rect.left + TOOLTIP_PADDING_X, y)) y += surf.get_height() + TOOLTIP_LINE_SPACING def render_sprint_debug(self, screen, actual_tps, total_ticks, cell_count=None): """Render sprint debug info: header, TPS, and tick count.""" header = self.font.render("Sprinting...", True, (255, 200, 0)) display_tps = round(actual_tps) # Round to nearest whole number tps_text = self.font.render(f"TPS: {display_tps}", True, (255, 255, 255)) ticks_text = self.font.render(f"Ticks: {total_ticks}", True, (255, 255, 255)) cell_text = self.font.render(f"Cells: {cell_count}" if cell_count is not None else "Cells: N/A", True, (255, 255, 255)) y = self.screen_height // 2 - 80 header_rect = header.get_rect(center=(self.screen_width // 2, y)) tps_rect = tps_text.get_rect(center=(self.screen_width // 2, y + 40)) ticks_rect = ticks_text.get_rect(center=(self.screen_width // 2, y + 80)) cell_rect = cell_text.get_rect(center=(self.screen_width // 2, y + 120)) screen.blit(header, header_rect) screen.blit(tps_text, tps_rect) screen.blit(ticks_text, ticks_rect) screen.blit(cell_text, cell_rect) def update_tree_widget(self, time_delta): """Update the tree widget.""" if self.tree_widget: self.tree_widget.update(time_delta) def render_tree_widget(self, screen): """Render the tree widget.""" if self.tree_widget and self.inspector_panel: # Create a surface for the tree widget area tree_surface = screen.subsurface(self.inspector_panel.rect) self.tree_widget.draw(tree_surface)