Some checks failed
Build Simulation and Test / Run All Tests (push) Failing after 14m56s
983 lines
44 KiB
Python
983 lines
44 KiB
Python
# 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)
|