Added camera stabilization, moved the Camera class into its own file, normalized the diagonal camera vector, added a select and multi-select feature, added an inspect feature, changed the world class to use spatial hashing for better storage, changed the world class to use a double buffer to swap states when ticking, added death and interaction flags, added world class object queries, added an abstract base class for a basic entity that should be inherited by all object classes, added a max_visual_width parameter to said base class for selection clarity that should be set by all new entity/object classes, and added a basic interaction framework with an interaction radius parameter. When an object could interact with it's neighbors in the next tick, it should enable its can_interact flag, and the world will provide a list of interactable objects in that radius through the object's tick function. Also removed ruff format precommit check because it currently doesn't work. Also temporarily removed food class because it's outdated.
All checks were successful
Build Simulation and Test / Run All Tests (push) Successful in 2m21s

This commit is contained in:
Sam 2025-06-03 18:38:27 -05:00
parent 6952e88e61
commit b80a5afc4a
5 changed files with 384 additions and 145 deletions

View File

@ -20,11 +20,3 @@ repos:
# Compile requirements # Compile requirements
- id: pip-compile - id: pip-compile
args: [ pyproject.toml, -o, requirements.txt ] args: [ pyproject.toml, -o, requirements.txt ]
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.11.12
hooks:
# Run the linter.
- id: ruff
# Run the formatter.
- id: ruff-format

208
main.py
View File

@ -3,7 +3,8 @@ import time
import sys import sys
from world.world import World, Position from world.world import World, Position
from world.render_objects import DebugRenderObject, FoodObject from world.render_objects import DebugRenderObject
from world.simulation_interface import Camera
# Initialize Pygame # Initialize Pygame
pygame.init() pygame.init()
@ -16,109 +17,13 @@ DARK_GRAY = (64, 64, 64)
GRAY = (128, 128, 128) GRAY = (128, 128, 128)
WHITE = (255, 255, 255) WHITE = (255, 255, 255)
RENDER_BUFFER = 50 RENDER_BUFFER = 50
SPEED = 700 # Pixels per second
# Grid settings # Grid settings
GRID_WIDTH = 20 # Number of cells horizontally GRID_WIDTH = 20 # Number of cells horizontally
GRID_HEIGHT = 15 # Number of cells vertically GRID_HEIGHT = 15 # Number of cells vertically
CELL_SIZE = 20 # Size of each cell in pixels CELL_SIZE = 20 # Size of each cell in pixels
DEFAULT_TPS = 200 # Amount of ticks per second for the simulation DEFAULT_TPS = 5 # Amount of ticks per second for the simulation
class Camera:
def __init__(self):
self.x = 0
self.y = 0
self.target_x = 0
self.target_y = 0
self.zoom = 1.0
self.target_zoom = 1.0
self.smoothing = 0.15 # Higher = more responsive, lower = more smooth
self.speed = SPEED
self.zoom_smoothing = 0.10
self.is_panning = False
self.last_mouse_pos = None
def update(self, keys, deltatime):
# Update target position based on input
if keys[pygame.K_w]:
self.target_y -= self.speed * deltatime / self.zoom
if keys[pygame.K_s]:
self.target_y += self.speed * deltatime / self.zoom
if keys[pygame.K_a]:
self.target_x -= self.speed * deltatime / self.zoom
if keys[pygame.K_d]:
self.target_x += self.speed * deltatime / self.zoom
if keys[pygame.K_r]:
self.target_x = 0
self.target_y = 0
# Smooth camera movement with drift
smoothing_factor = 1 - pow(
1 - self.smoothing, deltatime * 60
) # Adjust smoothing based on deltatime
self.x += (self.target_x - self.x) * smoothing_factor
self.y += (self.target_y - self.y) * smoothing_factor
# Smooth zoom
zoom_smoothing_factor = 1 - pow(1 - self.zoom_smoothing, deltatime * 60)
self.zoom += (self.target_zoom - self.zoom) * zoom_smoothing_factor
def handle_zoom(self, zoom_delta):
# Zoom in/out with mouse wheel
zoom_factor = 1.1
if zoom_delta > 0: # Zoom in
self.target_zoom *= zoom_factor
elif zoom_delta < 0: # Zoom out
self.target_zoom /= zoom_factor
# Clamp zoom levels
self.target_zoom = max(0.1, min(5.0, self.target_zoom))
def start_panning(self, mouse_pos):
self.is_panning = True
self.last_mouse_pos = mouse_pos
def stop_panning(self):
self.is_panning = False
self.last_mouse_pos = None
def pan(self, mouse_pos):
if self.is_panning and self.last_mouse_pos:
dx = mouse_pos[0] - self.last_mouse_pos[0]
dy = mouse_pos[1] - self.last_mouse_pos[1]
self.x -= dx / self.zoom
self.y -= dy / self.zoom
self.target_x = self.x # Sync target position with actual position
self.target_y = self.y
self.last_mouse_pos = mouse_pos
def get_real_coordinates(self, screen_x, screen_y):
# Convert screen coordinates to world coordinates
world_x = (screen_x - SCREEN_WIDTH // 2 + self.x * self.zoom) / self.zoom
world_y = (screen_y - SCREEN_HEIGHT // 2 + self.y * self.zoom) / self.zoom
return world_x, world_y
def is_in_view(self, obj_x, obj_y, margin=0):
half_w = (SCREEN_WIDTH + (RENDER_BUFFER * self.zoom)) / (2 * self.zoom)
half_h = (SCREEN_HEIGHT + (RENDER_BUFFER * self.zoom)) / (2 * self.zoom)
cam_left = self.x - half_w
cam_right = self.x + half_w
cam_top = self.y - half_h
cam_bottom = self.y + half_h
return (cam_left - margin <= obj_x <= cam_right + margin and
cam_top - margin <= obj_y <= cam_bottom + margin)
def world_to_screen(self, obj_x, obj_y):
screen_x = (obj_x - self.x) * self.zoom + SCREEN_WIDTH // 2
screen_y = (obj_y - self.y) * self.zoom + SCREEN_HEIGHT // 2
return int(screen_x), int(screen_y)
def get_relative_size(self, world_size):
# Converts a world size (e.g., radius or width/height) to screen pixels
return int(world_size * self.zoom)
def draw_grid(screen, camera, showing_grid=True): def draw_grid(screen, camera, showing_grid=True):
@ -199,11 +104,12 @@ def draw_grid(screen, camera, showing_grid=True):
for start, end in horizontal_lines: for start, end in horizontal_lines:
pygame.draw.line(screen, GRAY, start, end) pygame.draw.line(screen, GRAY, start, end)
def main(): def main():
screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT), vsync=1) screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT), vsync=1)
pygame.display.set_caption("Dynamic Abstraction System Testing") pygame.display.set_caption("Dynamic Abstraction System Testing")
clock = pygame.time.Clock() clock = pygame.time.Clock()
camera = Camera() camera = Camera(SCREEN_WIDTH, SCREEN_HEIGHT, RENDER_BUFFER)
is_showing_grid = True # Flag to control grid visibility is_showing_grid = True # Flag to control grid visibility
@ -214,6 +120,13 @@ def main():
last_tps_time = time.perf_counter() # Tracks the last TPS calculation time last_tps_time = time.perf_counter() # Tracks the last TPS calculation time
tick_counter = 0 # Counts ticks executed tick_counter = 0 # Counts ticks executed
actual_tps = 0 # Stores the calculated TPS actual_tps = 0 # Stores the calculated TPS
total_ticks = 0 # Total ticks executed
# Selection state
selecting = False
select_start = None # (screen_x, screen_y)
select_end = None # (screen_x, screen_y)
selected_objects = []
print("Controls:") print("Controls:")
print("WASD - Move camera") print("WASD - Move camera")
@ -226,7 +139,7 @@ def main():
world = World() world = World()
world.add_object(DebugRenderObject(Position(0, 0))) world.add_object(DebugRenderObject(Position(0, 0)))
world.add_object(FoodObject(Position(100, 0))) world.add_object(DebugRenderObject(Position(20, 0)))
running = True running = True
while running: while running:
@ -238,7 +151,8 @@ def main():
running = False running = False
elif event.type == pygame.KEYDOWN: elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE: if event.key == pygame.K_ESCAPE:
running = False selecting = False
selected_objects = []
if event.key == pygame.K_g: if event.key == pygame.K_g:
is_showing_grid = not is_showing_grid is_showing_grid = not is_showing_grid
if event.key == pygame.K_UP: if event.key == pygame.K_UP:
@ -252,11 +166,52 @@ def main():
elif event.type == pygame.MOUSEBUTTONDOWN: elif event.type == pygame.MOUSEBUTTONDOWN:
if event.button == 2: # Middle mouse button if event.button == 2: # Middle mouse button
camera.start_panning(event.pos) camera.start_panning(event.pos)
elif event.button == 1: # Left mouse button
selecting = True
select_start = event.pos
select_end = event.pos
elif event.type == pygame.MOUSEBUTTONUP: elif event.type == pygame.MOUSEBUTTONUP:
if event.button == 2: # Middle mouse button if event.button == 2:
camera.stop_panning() camera.stop_panning()
elif event.button == 1 and selecting:
selecting = False
# Convert screen to world coordinates
x1, y1 = camera.get_real_coordinates(*select_start)
x2, y2 = camera.get_real_coordinates(*select_end)
# If the selection rectangle is very small, treat as a click
if (
abs(select_start[0] - select_end[0]) < 3
and abs(select_start[1] - select_end[1]) < 3
):
# Single click: select closest object if in range
mouse_world_x, mouse_world_y = camera.get_real_coordinates(
*select_start
)
obj = world.query_closest_object(mouse_world_x, mouse_world_y)
selected_objects = []
if obj:
obj_x, obj_y = obj.position.get_position()
# Calculate distance in world coordinates
dx = obj_x - mouse_world_x
dy = obj_y - mouse_world_y
dist = (dx ** 2 + dy ** 2) ** 0.5
if dist <= obj.max_visual_width / 2:
selected_objects = [obj]
print(f"Clicked: selected {len(selected_objects)} object(s)")
else:
# Drag select: select all in rectangle
min_x, max_x = min(x1, x2), max(x1, x2)
min_y, max_y = min(y1, y2), max(y1, y2)
selected_objects = world.query_objects_in_range(
min_x, min_y, max_x, max_y
)
print(
f"Selected {len(selected_objects)} objects in range: {min_x}, {min_y} to {max_x}, {max_y}"
)
elif event.type == pygame.MOUSEMOTION: elif event.type == pygame.MOUSEMOTION:
camera.pan(event.pos) camera.pan(event.pos)
if selecting:
select_end = event.pos
# Get pressed keys for smooth movement # Get pressed keys for smooth movement
keys = pygame.key.get_pressed() keys = pygame.key.get_pressed()
@ -267,7 +222,9 @@ def main():
while current_time - last_tick_time >= tick_interval: while current_time - last_tick_time >= tick_interval:
last_tick_time += tick_interval last_tick_time += tick_interval
tick_counter += 1 tick_counter += 1
total_ticks += 1
# Add your tick-specific logic here # Add your tick-specific logic here
print("Tick logic executed") print("Tick logic executed")
world.tick_all() world.tick_all()
@ -283,10 +240,37 @@ def main():
# Render everything in the world # Render everything in the world
world.render_all(camera, screen) world.render_all(camera, screen)
# Draw selection rectangle if selecting
if selecting and select_start and select_end:
rect_color = (128, 128, 128, 80) # Gray, semi-transparent
border_color = (80, 80, 90) # Slightly darker gray for border
left = min(select_start[0], select_end[0])
top = min(select_start[1], select_end[1])
width = abs(select_end[0] - select_start[0])
height = abs(select_end[1] - select_start[1])
s = pygame.Surface((width, height), pygame.SRCALPHA)
s.fill(rect_color)
screen.blit(s, (left, top))
# Draw 1-pixel border
pygame.draw.rect(
screen, border_color, pygame.Rect(left, top, width, height), 1
)
# Draw blue outline for selected objects
for obj in selected_objects:
obj_x, obj_y = obj.position.get_position()
width = obj.max_visual_width if hasattr(obj, "max_visual_width") else 10
screen_x, screen_y = camera.world_to_screen(obj_x, obj_y)
size = camera.get_relative_size(width)
rect = pygame.Rect(screen_x - size // 2, screen_y - size // 2, size, size)
pygame.draw.rect(screen, (0, 128, 255), rect, 1) # Blue, 1px wide
# Render mouse position as text in top left of screen # Render mouse position as text in top left of screen
mouse_x, mouse_y = camera.get_real_coordinates(*pygame.mouse.get_pos()) mouse_x, mouse_y = camera.get_real_coordinates(*pygame.mouse.get_pos())
mouse_text = font.render(f"Mouse: ({mouse_x}, {mouse_y})", True, WHITE) mouse_text = font.render(f"Mouse: ({mouse_x:.2f}, {mouse_y:.2f})", True, WHITE)
text_rect = mouse_text.get_rect() text_rect = mouse_text.get_rect()
text_rect.topleft = (10, 10) text_rect.topleft = (10, 10)
screen.blit(mouse_text, text_rect) screen.blit(mouse_text, text_rect)
@ -303,6 +287,24 @@ def main():
tps_rect.bottomright = (SCREEN_WIDTH - 10, SCREEN_HEIGHT - 10) tps_rect.bottomright = (SCREEN_WIDTH - 10, SCREEN_HEIGHT - 10)
screen.blit(tps_text, tps_rect) screen.blit(tps_text, tps_rect)
# Render tick count in bottom left
tick_text = font.render(f"Ticks: {total_ticks}", True, WHITE)
tick_rect = tick_text.get_rect()
tick_rect.bottomleft = (10, SCREEN_HEIGHT - 10)
screen.blit(tick_text, tick_rect)
if len(selected_objects) >= 1:
i = 0
for each in selected_objects:
obj = each
obj_text = font.render(
f"Object: {str(obj)}, Neighbors: {obj.neighbors}", True, WHITE
)
obj_rect = obj_text.get_rect()
obj_rect.topleft = (10, 30 + i * 20)
screen.blit(obj_text, obj_rect)
i += 1
# Update display # Update display
pygame.display.flip() pygame.display.flip()
clock.tick(180) clock.tick(180)

View File

@ -1,30 +1,35 @@
from world.world import Position from world.world import Position, BaseEntity
import pygame import pygame
class DebugRenderObject:
def __init__(self, position: Position):
self.position = position
def tick(self): class DebugRenderObject(BaseEntity):
pass def __init__(self, position: Position, radius=5):
super().__init__(position)
self.neighbors = 0
self.radius = radius
self.max_visual_width = radius * 2
self.interaction_radius = 50
self.flags = {
"death": False,
"can_interact": True,
}
def tick(self, interactable=None):
if interactable is None:
interactable = []
self.neighbors = len(interactable)
return self
def render(self, camera, screen): def render(self, camera, screen):
if camera.is_in_view(*self.position.get_position()): if camera.is_in_view(*self.position.get_position()):
pygame.draw.circle(screen, (255,255,255), camera.world_to_screen(*self.position.get_position()), 15 * camera.zoom) pygame.draw.circle(
screen,
class FoodObject: (50, 50, min([255, (self.neighbors + 4) * 30])),
def __init__(self, position: Position): camera.world_to_screen(*self.position.get_position()),
self.decay = 0 self.radius * camera.zoom,
self.position = position )
def tick(self):
self.decay += 1
if (self.decay > 255):
self.decay = 0
def render(self, camera, screen):
if camera.is_in_view(*self.position.get_position()):
pygame.draw.circle(screen, (255,self.decay,0), camera.world_to_screen(*self.position.get_position()), 5 * camera.zoom)
def __repr__(self):
return f"DebugRenderObject({self.position}, neighbors={self.neighbors})"

View File

@ -0,0 +1,130 @@
import pygame
class Camera:
def __init__(self, screen_width, screen_height, render_buffer=50):
self.x = 0
self.y = 0
self.target_x = 0
self.target_y = 0
self.zoom = 1.0
self.target_zoom = 1.0
self.smoothing = 0.15 # Higher = more responsive, lower = more smooth
self.speed = 700
self.zoom_smoothing = 0.2 # Higher = more responsive, lower = more smooth
self.is_panning = False
self.last_mouse_pos = None
self.screen_width = screen_width
self.screen_height = screen_height
self.render_buffer = (
render_buffer # Buffer for rendering objects outside the screen
)
def update(self, keys, deltatime):
# Determine movement direction
dx = 0
dy = 0
if keys[pygame.K_w]:
dy -= 1
if keys[pygame.K_s]:
dy += 1
if keys[pygame.K_a]:
dx -= 1
if keys[pygame.K_d]:
dx += 1
# Normalize direction
length = (dx ** 2 + dy ** 2) ** 0.5
if length > 0:
dx /= length
dy /= length
# Apply movement
self.target_x += dx * self.speed * deltatime / self.zoom
self.target_y += dy * self.speed * deltatime / self.zoom
if keys[pygame.K_r]:
self.target_x = 0
self.target_y = 0
# Smooth camera movement with drift
smoothing_factor = 1 - pow(1 - self.smoothing, deltatime * 60)
self.x += (self.target_x - self.x) * smoothing_factor
self.y += (self.target_y - self.y) * smoothing_factor
# Snap to target if within threshold
threshold = 0.5
if abs(self.x - self.target_x) < threshold:
self.x = self.target_x
if abs(self.y - self.target_y) < threshold:
self.y = self.target_y
# Smooth zoom
zoom_smoothing_factor = 1 - pow(1 - self.zoom_smoothing, deltatime * 60)
self.zoom += (self.target_zoom - self.zoom) * zoom_smoothing_factor
# Snap zoom to target if within threshold
zoom_threshold = 0.001
if abs(self.zoom - self.target_zoom) < zoom_threshold:
self.zoom = self.target_zoom
def handle_zoom(self, zoom_delta):
# Zoom in/out with mouse wheel
zoom_factor = 1.1
if zoom_delta > 0: # Zoom in
self.target_zoom *= zoom_factor
elif zoom_delta < 0: # Zoom out
self.target_zoom /= zoom_factor
# Clamp zoom levels
self.target_zoom = max(0.1, min(5.0, self.target_zoom))
def start_panning(self, mouse_pos):
self.is_panning = True
self.last_mouse_pos = mouse_pos
def stop_panning(self):
self.is_panning = False
self.last_mouse_pos = None
def pan(self, mouse_pos):
if self.is_panning and self.last_mouse_pos:
dx = mouse_pos[0] - self.last_mouse_pos[0]
dy = mouse_pos[1] - self.last_mouse_pos[1]
self.x -= dx / self.zoom
self.y -= dy / self.zoom
self.target_x = self.x # Sync target position with actual position
self.target_y = self.y
self.last_mouse_pos = mouse_pos
def get_real_coordinates(self, screen_x, screen_y):
# Convert screen coordinates to world coordinates
world_x = (screen_x - self.screen_width // 2 + self.x * self.zoom) / self.zoom
world_y = (screen_y - self.screen_height // 2 + self.y * self.zoom) / self.zoom
return world_x, world_y
def is_in_view(self, obj_x, obj_y, margin=0):
half_w = (self.screen_width + (self.render_buffer * self.zoom)) / (
2 * self.zoom
)
half_h = (self.screen_height + (self.render_buffer * self.zoom)) / (
2 * self.zoom
)
cam_left = self.x - half_w
cam_right = self.x + half_w
cam_top = self.y - half_h
cam_bottom = self.y + half_h
return (
cam_left - margin <= obj_x <= cam_right + margin
and cam_top - margin <= obj_y <= cam_bottom + margin
)
def world_to_screen(self, obj_x, obj_y):
screen_x = (obj_x - self.x) * self.zoom + self.screen_width // 2
screen_y = (obj_y - self.y) * self.zoom + self.screen_height // 2
return int(screen_x), int(screen_y)
def get_relative_size(self, world_size):
# Converts a world size (e.g., radius or width/height) to screen pixels
return int(world_size * self.zoom)

View File

@ -1,18 +1,129 @@
from collections import defaultdict
from abc import ABC, abstractmethod
class World: class World:
def __init__(self): def __init__(self, partition_size=10):
self.objects = [] self.partition_size = partition_size
pass self.buffers = [defaultdict(list), defaultdict(list)]
self.current_buffer = 0
def _hash_position(self, position):
# Map world coordinates to cell coordinates
return int(position.x // self.partition_size), int(
position.y // self.partition_size
)
def render_all(self, camera, screen): def render_all(self, camera, screen):
for obj in self.objects: for obj_list in self.buffers[self.current_buffer].values():
for obj in obj_list:
obj.render(camera, screen) obj.render(camera, screen)
def tick_all(self): def tick_all(self):
for obj in self.objects: next_buffer = 1 - self.current_buffer
obj.tick() self.buffers[next_buffer].clear()
# print all objects in the current buffer
print(
f"Ticking objects in buffer {self.current_buffer}:",
self.buffers[self.current_buffer].values(),
)
for obj_list in self.buffers[self.current_buffer].values():
for obj in obj_list:
if obj.flags["death"]:
continue
if obj.flags["can_interact"]:
interactable = self.query_objects_within_radius(
obj.position.x, obj.position.y, obj.interaction_radius
)
interactable.remove(obj)
print(f"Object {obj} interacting with {len(interactable)} objects.")
new_obj = obj.tick(interactable)
else:
new_obj = obj.tick()
if new_obj is None:
continue
cell = self._hash_position(new_obj.position)
self.buffers[next_buffer][cell].append(new_obj)
self.current_buffer = next_buffer
def add_object(self, new_object): def add_object(self, new_object):
self.objects.append(new_object) cell = self._hash_position(new_object.position)
self.buffers[self.current_buffer][cell].append(new_object)
def query_objects_within_radius(self, x, y, radius):
result = []
cell_x, cell_y = int(x // self.partition_size), int(y // self.partition_size)
cells_to_check = []
r = int((radius // self.partition_size) + 1)
for dx in range(-r, r + 1):
for dy in range(-r, r + 1):
cells_to_check.append((cell_x + dx, cell_y + dy))
for cell in cells_to_check:
for obj in self.buffers[self.current_buffer].get(cell, []):
obj_x, obj_y = obj.position.get_position()
dx = obj_x - x
dy = obj_y - y
if dx * dx + dy * dy <= radius * radius:
result.append(obj)
return result
def query_objects_in_range(self, x1, y1, x2, y2):
result = []
cell_x1, cell_y1 = (
int(x1 // self.partition_size),
int(y1 // self.partition_size),
)
cell_x2, cell_y2 = (
int(x2 // self.partition_size),
int(y2 // self.partition_size),
)
for cell_x in range(cell_x1, cell_x2 + 1):
for cell_y in range(cell_y1, cell_y2 + 1):
for obj in self.buffers[self.current_buffer].get((cell_x, cell_y), []):
obj_x, obj_y = obj.position.get_position()
if x1 <= obj_x <= x2 and y1 <= obj_y <= y2:
result.append(obj)
return result
def query_closest_object(self, x, y):
closest_obj = None
closest_distance = float("inf")
for obj_list in self.buffers[self.current_buffer].values():
for obj in obj_list:
obj_x, obj_y = obj.position.get_position()
dx = obj_x - x
dy = obj_y - y
distance = dx * dx + dy * dy
if distance < closest_distance:
closest_distance = distance
closest_obj = obj
return closest_obj
class BaseEntity(ABC):
def __init__(self, position: "Position"):
self.position = position
self.interaction_radius = 0
self.flags = {
"death": False,
"can_interact": False,
}
self.world_callbacks = {}
self.max_visual_width = 0
@abstractmethod
def tick(self, interactable=None):
return self
@abstractmethod
def render(self, camera, screen):
pass
def flag_for_death(self):
self.flags["death"] = True
class Position: class Position:
def __init__(self, x, y): def __init__(self, x, y):
@ -31,4 +142,3 @@ class Position:
def get_position(self): def get_position(self):
return self.x, self.y return self.x, self.y