Sam ff43022b3a
Some checks failed
Build Simulation and Test / Run All Tests (push) Failing after 45s
Update screen dimensions and integrate pygame_gui for HUD management
2025-06-18 17:36:24 -05:00

484 lines
21 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
import math
class HUD:
def __init__(self):
self.font = pygame.font.Font("freesansbold.ttf", FONT_SIZE)
self.legend_font = pygame.font.Font("freesansbold.ttf", LEGEND_FONT_SIZE)
self.manager = pygame_gui.UIManager((SCREEN_WIDTH, SCREEN_HEIGHT), "ui/theme.json")
def render_mouse_position(self, screen, camera):
"""Render mouse position in top left."""
mouse_x, mouse_y = camera.get_real_coordinates(*pygame.mouse.get_pos())
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 = (SCREEN_WIDTH - HUD_MARGIN, HUD_MARGIN)
screen.blit(fps_text, fps_rect)
def render_tps(self, screen, actual_tps):
"""Render TPS in bottom right."""
tps_text = self.font.render(f"TPS: {actual_tps}", True, WHITE)
tps_rect = tps_text.get_rect()
tps_rect.bottomright = (SCREEN_WIDTH - HUD_MARGIN, 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, 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 = (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 = 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 = (SCREEN_WIDTH // 2, 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 = (SCREEN_WIDTH - legend_width) // 2
legend_y = 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
# 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 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
viz_x = SCREEN_WIDTH - VIZ_RIGHT_MARGIN # Right side of screen
viz_y = (SCREEN_HEIGHT // 2) - (VIZ_HEIGHT // 2) # Centered vertically
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)
# 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)
# 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)
# Draw network info
info = network.get_structure_info()
info_lines = [
f"Layers: {info['total_layers']}",
f"Neurons: {info['total_neurons']}",
f"Connections: {info['total_connections']}",
f"Network Cost: {info['network_cost']}",
]
for i, line in enumerate(info_lines):
info_text = self.legend_font.render(line, True, WHITE)
info_rect = info_text.get_rect()
info_rect.left = viz_x
info_rect.top = viz_y + VIZ_HEIGHT + INFO_TEXT_TOP_MARGIN + i * INFO_TEXT_LINE_SPACING
screen.blit(info_text, info_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 > SCREEN_WIDTH:
tooltip_x = mouse_x - width - TOOLTIP_X_OFFSET
# Adjust if off bottom edge
if tooltip_y + height > 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):
"""Render sprint debug info: header, TPS, and tick count."""
header = self.font.render("Sprinting...", True, (255, 200, 0))
tps_text = self.font.render(f"TPS: {actual_tps}", True, (255, 255, 255))
ticks_text = self.font.render(f"Ticks: {total_ticks}", True, (255, 255, 255))
y = SCREEN_HEIGHT // 2 - 40
header_rect = header.get_rect(center=(SCREEN_WIDTH // 2, y))
tps_rect = tps_text.get_rect(center=(SCREEN_WIDTH // 2, y + 40))
ticks_rect = ticks_text.get_rect(center=(SCREEN_WIDTH // 2, y + 80))
screen.blit(header, header_rect)
screen.blit(tps_text, tps_rect)
screen.blit(ticks_text, ticks_rect)