Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions config.yaml
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
window:
width: 1200
height: 800
height: 1000
panel:
width: 700
height: 700
x: 100
y: 50
chart:
height: 200
grid:
cell_size: 25
mating:
Expand All @@ -14,4 +16,4 @@ mating:
max_range: 5
mutation_chance: 0.05
mutation_multiply_border: 0.2
mutation_addding_border: 0.05
mutation_addding_border: 0.05
183 changes: 183 additions & 0 deletions src/charts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
"""Simple chart registry and rendering utilities."""

from __future__ import annotations

import time
from collections import deque
from dataclasses import dataclass, field
from typing import Callable, Deque, List, Tuple

import pygame

ChartCallback = Callable[[], float]


@dataclass
class Chart:
name: str
value_name: str
callback: ChartCallback
history: Deque[Tuple[float, float]] = field(default_factory=lambda: deque(maxlen=300))

def sample(self, timestamp: float) -> None:
try:
value = float(self.callback())
except Exception:
# On callback failure, skip this sample but keep the chart alive.
return
self.history.append((timestamp, value))

def _value_range(self) -> Tuple[float, float]:
values = [v for _, v in self.history]
if not values:
return (0.0, 1.0)
v_min = min(values)
v_max = max(values)
if abs(v_max - v_min) < 1e-9:
padding = max(1.0, abs(v_max) * 0.1 + 1.0)
return (v_min - padding, v_max + padding)
return (v_min, v_max)

def render(self, surface: pygame.Surface, rect: pygame.Rect, font: pygame.font.Font) -> None:
pygame.draw.rect(surface, (25, 25, 25), rect)
pygame.draw.rect(surface, (80, 80, 80), rect, 1)

title = font.render(self.name, True, (220, 220, 220))
surface.blit(title, (rect.x + 8, rect.y + 4))

label = font.render(self.value_name, True, (180, 180, 180))
surface.blit(label, (rect.x + 8, rect.y + 20))

if len(self.history) < 2:
return

times = [t for t, _ in self.history]
start = times[0]
duration = max(times[-1] - start, 1.0)
v_min, v_max = self._value_range()
scale = max(v_max - v_min, 1e-6)

padding_top = 28
padding_bottom = 12
padding_side = 8
usable_width = max(1, rect.width - padding_side * 2)
usable_height = max(1, rect.height - padding_top - padding_bottom)

points: List[Tuple[int, int]] = []
for timestamp, value in self.history:
x = rect.x + padding_side + int(((timestamp - start) / duration) * usable_width)
normalized = (value - v_min) / scale
y = rect.y + rect.height - padding_bottom - int(normalized * usable_height)
points.append((x, y))

if len(points) >= 2:
pygame.draw.lines(surface, (120, 200, 255), False, points, 2)

latest_value = self.history[-1][1]
latest_text = font.render(f"{latest_value:.1f}", True, (255, 255, 255))
surface.blit(latest_text, (rect.right - latest_text.get_width() - 8, rect.y + 4))


class ChartManager:
def __init__(self, sample_interval: float = 1.0):
self.sample_interval = sample_interval
self._accumulator = 0.0
self._charts: List[Chart] = []
self._font: pygame.font.Font | None = None
self._scroll_x = 0.0
self._scroll_speed = 40.0
self._view_columns = 2.5
self._last_sample_time: float | None = None

def register_chart(self, name: str, value_name: str, callback: ChartCallback) -> Chart:
chart = Chart(name=name, value_name=value_name, callback=callback)
self._charts.append(chart)
return chart

def update(self, dt: float) -> None:
self._accumulator += dt
while self._accumulator >= self.sample_interval:
self._accumulator -= self.sample_interval
self._poll()

def _poll(self) -> None:
timestamp = time.time()
self._last_sample_time = timestamp
for chart in self._charts:
chart.sample(timestamp)

def render(self, surface: pygame.Surface, rect: pygame.Rect) -> None:
if not self._charts:
pygame.draw.rect(surface, (30, 30, 30), rect)
pygame.draw.rect(surface, (80, 80, 80), rect, 1)
return

chart_height = rect.height
chart_width = rect.width / max(self._view_columns, 0.1)
chart_w = max(1, int(chart_width))
total_width = chart_width * len(self._charts)

max_scroll = max(0.0, total_width - rect.width)
self._scroll_x = max(0.0, min(self._scroll_x, max_scroll))

font = self._font or pygame.font.Font(None, 20)
self._font = font

pygame.draw.rect(surface, (20, 20, 20), rect)
pygame.draw.rect(surface, (50, 50, 50), rect, 1)

clip = surface.get_clip()
surface.set_clip(rect)

origin_x = rect.x - self._scroll_x
for i, chart in enumerate(self._charts):
chart_rect = pygame.Rect(
int(origin_x + i * chart_width),
rect.y,
chart_w,
chart_height,
)
if chart_rect.right < rect.x or chart_rect.x > rect.right:
continue
chart.render(surface, chart_rect, font)

surface.set_clip(clip)

def scroll(self, dy: float) -> None:
self._scroll_x = max(0.0, self._scroll_x + dy * self._scroll_speed)


_MANAGER = ChartManager()


def register_chart(name: str, value_name: str, callback: ChartCallback) -> Chart:
"""Registers a chart to be sampled and rendered."""
return _MANAGER.register_chart(name, value_name, callback)


def update(dt: float) -> None:
"""Advances the polling timer."""
_MANAGER.update(dt)


def render(surface: pygame.Surface, rect: pygame.Rect) -> None:
"""Renders all registered charts inside the given rect."""
_MANAGER.render(surface, rect)


def scroll(dy: float) -> None:
"""Scrolls the chart viewport horizontally (dy is mouse wheel delta)."""
_MANAGER.scroll(dy)


def first_sample_age() -> float:
"""Returns seconds since the oldest datapoint across charts was sampled."""
oldest = None
for chart in _MANAGER._charts:
if chart.history:
timestamp = chart.history[0][0]
if oldest is None or timestamp < oldest:
oldest = timestamp
if oldest is None:
return 0.0
return max(0.0, time.time() - oldest)
4 changes: 3 additions & 1 deletion src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@
GRID_WIDTH = PANEL_WIDTH // CELL_SIZE
GRID_HEIGHT = PANEL_HEIGHT // CELL_SIZE

CHART_AREA_HEIGHT = cfg["chart"]["height"]

MIN_AGE_PERCENT = cfg["mating"]["min_age_percent"]
MIN_ENERGY_LEVEL = cfg["mating"]["min_energy_level"]
MAX_RANGE = cfg["mating"]["max_range"]
MUTATION_CHANCE = cfg["mating"]["mutation_chance"]
MUTATION_MULTIPLY_BORDER = cfg["mating"]["mutation_multiply_border"]
MUTATION_ADDING_BORDER = cfg["mating"]["mutation_addding_border"]
MUTATION_ADDING_BORDER = cfg["mating"]["mutation_addding_border"]
34 changes: 34 additions & 0 deletions src/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,40 @@ def get_agents(self):
def create_agent(self, agent : Agent):
self.agents.append(agent)

def agent_count(self) -> int:
with self.data_lock:
return sum(1 for agent in self.agents if agent.is_alive())

def average_agent_health(self) -> float:
with self.data_lock:
living = [agent for agent in self.agents if agent.is_alive()]
if not living:
return 0.0
return sum(agent.hp for agent in living) / len(living)

def average_agent_energy(self) -> float:
with self.data_lock:
living = [agent for agent in self.agents if agent.is_alive()]
if not living:
return 0.0
return sum(agent.energy for agent in living) / len(living)

def food_sources_with_stock(self) -> int:
with self.data_lock:
return sum(
1
for source in self.food_sources
if not source.is_destroyed and getattr(source, "food_left", 0) >= 1
)

def total_food_stock(self) -> float:
with self.data_lock:
return sum(
max(0.0, float(getattr(source, "food_left", 0)))
for source in self.food_sources
if not source.is_destroyed
)

def _get_agent_area(self, agent: Agent) -> Area:
"""Maps agent pixel position to the underlying terrain cell."""
grid_x = min(self.grid_width - 1, max(0, int(agent.x // self.cell_size)))
Expand Down
61 changes: 61 additions & 0 deletions src/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,32 @@
from area import Area
from food import FoodSource
from ui import UI
import charts

pygame.init()
pygame.display.set_caption("Simulation")
screen = pygame.display.set_mode((config.WINDOW_WIDTH, config.WINDOW_HEIGHT))

clock = pygame.time.Clock()
manager = pygame_gui.UIManager((config.WINDOW_WIDTH, config.WINDOW_HEIGHT))
hint_font = pygame.font.Font(None, 22)


def _format_first_sample_age() -> str:
age = charts.first_sample_age()
if age <= 0:
return ""
if age < 60:
return f"Oldest sample is {int(age)}s old"
minutes = int(age // 60)
seconds = int(age % 60)
return f"Oldest sample is {minutes}m {seconds}s old"


def _chart_hint_text() -> str:
base = "Scroll over the chart area to pan charts"
age_text = _format_first_sample_age()
return f"{base} — {age_text}" if age_text else base
env_ui = UI(
manager=manager,
x=config.PANEL_WIDTH + 2*config.PANEL_X,
Expand All @@ -29,6 +48,40 @@
pixel_height=config.PANEL_HEIGHT
)

chart_rect = pygame.Rect(
config.PANEL_X,
config.PANEL_Y + config.PANEL_HEIGHT + 20,
config.PANEL_WIDTH,
config.CHART_AREA_HEIGHT
)
chart_hint_pos = (chart_rect.x, chart_rect.bottom + 4)

charts.register_chart(
name="Agents over time",
value_name="Agents",
callback=env.agent_count,
)
charts.register_chart(
name="Average health",
value_name="HP",
callback=env.average_agent_health,
)
charts.register_chart(
name="Average energy",
value_name="Energy",
callback=env.average_agent_energy,
)
charts.register_chart(
name="Food sources with stock",
value_name="Sources",
callback=env.food_sources_with_stock,
)
charts.register_chart(
name="Stored food across sources",
value_name="Food",
callback=env.total_food_stock,
)

running = True
while running:
dt = clock.tick(60) / 1000.0
Expand All @@ -52,10 +105,14 @@
env.change_area_at(grid_x, grid_y, env_ui.current_brush)
elif isinstance(env_ui.current_brush, type(FoodSource)):
env.add_manual_food_source(grid_x, grid_y, env_ui.current_brush)
elif event.type == pygame.MOUSEWHEEL:
if chart_rect.collidepoint(mouse_pos):
charts.scroll(-event.y)
manager.process_events(event)
env_ui.process_events(event, env)

manager.update(dt)
charts.update(dt)

screen.fill((20, 20, 20)) # Clear screen

Expand All @@ -66,6 +123,10 @@
cell_size=config.CELL_SIZE
)

charts.render(screen, chart_rect)
hint_surface = hint_font.render(_chart_hint_text(), True, (220, 220, 220))
screen.blit(hint_surface, chart_hint_pos)

manager.draw_ui(screen)

pygame.display.flip()
Expand Down
Loading