Compare commits

...

16 Commits

Author SHA1 Message Date
bb521371fa Food now grows, the chance to graw is dependent on the decay rate. The lower the decay rate, the higher the chance to grow. This helps keep the food from growing exponentially. 2025-06-03 22:26:11 -05:00
4e05a62274 Food now grows, the chance to graw is dependent on the decay rate. The lower the decay rate, the higher the chance to grow. This helps keep the food from growing exponentially. 2025-06-03 22:25:41 -05:00
ae88bf0dd2 Food now grows, the chance to graw is dependent on the decay rate. The lower the decay rate, the higher the chance to grow. This helps keep the food from growing exponentially. 2025-06-03 22:20:37 -05:00
Sam
56415bb07a Added rounding to the object representation of the food object.
All checks were successful
Build Simulation and Test / Run All Tests (push) Successful in 30s
2025-06-03 20:41:20 -05:00
Sam
9a8217a034 Refine zoom limits in simulation interface for improved user experience
All checks were successful
Build Simulation and Test / Run All Tests (push) Successful in 34s
2025-06-03 20:38:50 -05:00
Sam
0202fe59a5 Add legend visibility toggle and pause functionality to simulation
All checks were successful
Build Simulation and Test / Run All Tests (push) Successful in 31s
2025-06-03 20:35:53 -05:00
Sam
9e7ad041b4 Update test runner configuration to use copy link mode during installation to suppress warning
All checks were successful
Build Simulation and Test / Run All Tests (push) Successful in 31s
2025-06-03 20:21:43 -05:00
Sam
55633706a2 Add tests for World class object management and querying functionality
All checks were successful
Build Simulation and Test / Run All Tests (push) Successful in 28s
2025-06-03 20:19:54 -05:00
Sam
dad79942c2 Enhance World initialization with size parameters and adjust food spawning range
All checks were successful
Build Simulation and Test / Run All Tests (push) Successful in 27s
2025-06-03 20:10:22 -05:00
Sam
f0e00d66b6 Add interaction radius visibility toggle and render circles for interactable objects
All checks were successful
Build Simulation and Test / Run All Tests (push) Successful in 29s
2025-06-03 20:03:27 -05:00
Sam
11bd766e62 Decay rate is now accelerated by nearby food.
All checks were successful
Build Simulation and Test / Run All Tests (push) Successful in 33s
2025-06-03 19:55:31 -05:00
Sam
3a4301f7cb Refactor Camera and Entity classes for improved type hinting and documentation using typing and pydantic.
All checks were successful
Build Simulation and Test / Run All Tests (push) Successful in 34s
2025-06-03 19:47:39 -05:00
Sam
9e3c34fd3e Enable food spawning and ensure selected objects remain valid during ticks
All checks were successful
Build Simulation and Test / Run All Tests (push) Successful in 40s
2025-06-03 19:29:37 -05:00
5eb2079da1 food work :3
All checks were successful
Build Simulation and Test / Run All Tests (push) Successful in 2m18s
2025-06-03 19:25:54 -05:00
da355dbc6c Merge branch 'food'
# Conflicts:
#	main.py
#	world/render_objects.py
2025-06-03 18:46:47 -05:00
Sam
b80a5afc4a 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
2025-06-03 18:38:27 -05:00
9 changed files with 1019 additions and 191 deletions

View File

@ -19,10 +19,10 @@ jobs:
run: uv python install
- name: Install the project
run: uv sync --locked --all-extras --dev
run: uv sync --locked --all-extras --dev --link-mode=copy
- name: Run tests
run: uv run pytest tests --junit-xml=pytest-results.xml
run: PYTHONPATH=$(pwd) uv run pytest tests --junit-xml=pytest-results.xml
- name: Upload test results
if: always()
@ -34,4 +34,4 @@ jobs:
- name: Print test summary
if: always()
run: uv run pytest tests --tb=short || true
run: PYTHONPATH=$(pwd) uv run pytest tests --tb=short || true

View File

@ -20,11 +20,3 @@ repos:
# Compile requirements
- id: pip-compile
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

336
main.py
View File

@ -5,6 +5,7 @@ import random
from world.world import World, Position
from world.render_objects import DebugRenderObject, FoodObject
from world.simulation_interface import Camera
# Initialize Pygame
pygame.init()
@ -24,106 +25,12 @@ GRID_WIDTH = 20 # Number of cells horizontally
GRID_HEIGHT = 15 # Number of cells vertically
CELL_SIZE = 20 # Size of each cell in pixels
DEFAULT_TPS = 20 # 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)
DEFAULT_TPS = 20 # Number of ticks per second for the simulation
FOOD_SPAWNING = True
def draw_grid(screen, camera, showing_grid=True):
# Fill screen with black
# Fill the screen with black
screen.fill(BLACK)
# Calculate effective cell size with zoom
@ -200,13 +107,17 @@ def draw_grid(screen, camera, showing_grid=True):
for start, end in horizontal_lines:
pygame.draw.line(screen, GRAY, start, end)
def main():
screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT), vsync=1)
pygame.display.set_caption("Dynamic Abstraction System Testing")
clock = pygame.time.Clock()
camera = Camera()
camera = Camera(SCREEN_WIDTH, SCREEN_HEIGHT, RENDER_BUFFER)
is_showing_grid = True # Flag to control grid visibility
show_interaction_radius = False # Flag to control interaction radius visibility
showing_legend = False # Flag to control legend visibility
is_paused = False # Flag to control simulation pause state
font = pygame.font.Font("freesansbold.ttf", 16)
@ -215,6 +126,13 @@ def main():
last_tps_time = time.perf_counter() # Tracks the last TPS calculation time
tick_counter = 0 # Counts ticks executed
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("WASD - Move camera")
@ -224,13 +142,16 @@ def main():
print("ESC or close window - Exit")
# Initialize world
world = World()
world = World(CELL_SIZE, (CELL_SIZE * GRID_WIDTH, CELL_SIZE * GRID_HEIGHT))
world.add_object(DebugRenderObject(Position(0,0)))
world.add_object(DebugRenderObject(Position(x=0, y=0)))
world.add_object(DebugRenderObject(Position(x=20, y=0)))
# setting the seed to 67 >_<
# sets seed to 67 >_<
random.seed(67)
world.add_object(FoodObject(Position(x=random.randint(-100, 100), y=random.randint(-100, 100))))
running = True
while running:
deltatime = clock.get_time() / 1000.0 # Convert milliseconds to seconds
@ -241,7 +162,10 @@ def main():
running = False
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
if len(selected_objects) == 0:
running = False
selecting = False
selected_objects = []
if event.key == pygame.K_g:
is_showing_grid = not is_showing_grid
if event.key == pygame.K_UP:
@ -250,43 +174,98 @@ def main():
if event.key == pygame.K_DOWN:
if camera.speed > 350:
camera.speed -= 350
if event.key == pygame.K_i:
show_interaction_radius = not show_interaction_radius
if event.key == pygame.K_l:
showing_legend = not showing_legend
if event.key == pygame.K_SPACE:
is_paused = not is_paused
elif event.type == pygame.MOUSEWHEEL:
camera.handle_zoom(event.y)
elif event.type == pygame.MOUSEBUTTONDOWN:
if event.button == 2: # Middle mouse button
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:
if event.button == 2: # Middle mouse button
if event.button == 2:
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:
camera.pan(event.pos)
if selecting:
select_end = event.pos
# Get pressed keys for smooth movement
keys = pygame.key.get_pressed()
camera.update(keys, deltatime)
if not is_paused:
# Tick logic (runs every tick interval)
current_time = time.perf_counter()
while current_time - last_tick_time >= tick_interval:
last_tick_time += tick_interval
tick_counter += 1
# Add your tick-specific logic here
print("Tick logic executed")
world.tick_all()
total_ticks += 1
# gets every object in the world and returns amount of FoodObjects
objects = world.get_objects()
food = len([obj for obj in objects if isinstance(obj, FoodObject)])
print(f"Food count: {food}")
if food < 10:
for i in range(10 - food):
world.add_object(FoodObject(Position(random.randint(-200, 200), random.randint(-200, 200))))
# if food < 10 and FOOD_SPAWNING == True:
# world.add_object(FoodObject(Position(x=random.randint(-100, 100), y=random.randint(-100, 100))))
# ensure selected objects are still valid or have not changed position, if so, reselect them
selected_objects = [
obj for obj in selected_objects if obj in world.get_objects()
]
world.tick_all()
# Calculate TPS every second
if current_time - last_tps_time >= 1.0:
actual_tps = tick_counter
tick_counter = 0
last_tps_time += 1.0
else:
last_tick_time = time.perf_counter()
last_tps_time = time.perf_counter()
# Get pressed keys for smooth movement
keys = pygame.key.get_pressed()
camera.update(keys, deltatime)
# Draw the reference grid
draw_grid(screen, camera, is_showing_grid)
@ -294,10 +273,53 @@ def main():
# Render everything in the world
world.render_all(camera, screen)
if show_interaction_radius:
for obj in world.get_objects():
obj_x, obj_y = obj.position.get_position()
radius = obj.interaction_radius
if radius > 0 and camera.is_in_view(obj_x, obj_y, margin=radius):
screen_x, screen_y = camera.world_to_screen(obj_x, obj_y)
screen_radius = int(radius * camera.zoom)
if screen_radius > 0:
pygame.draw.circle(
screen,
(255, 0, 0), # Red
(screen_x, screen_y),
screen_radius,
1 # 1 pixel thick
)
# 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
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.topleft = (10, 10)
screen.blit(mouse_text, text_rect)
@ -314,11 +336,87 @@ def main():
tps_rect.bottomright = (SCREEN_WIDTH - 10, SCREEN_HEIGHT - 10)
screen.blit(tps_text, tps_rect)
# Render camera position and speed in bottom left
cam_text = font.render(f"Camera: ({camera.x:.2f}, {camera.y:.2f}), Speed: {camera.speed:.2f}", True, WHITE)
cam_rect = cam_text.get_rect()
cam_rect.bottomleft = (10, SCREEN_HEIGHT - 10)
screen.blit(cam_text, cam_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)}", True, WHITE
)
obj_rect = obj_text.get_rect()
obj_rect.topleft = (10, 30 + i * 20)
screen.blit(obj_text, obj_rect)
i += 1
legend_font = pygame.font.Font("freesansbold.ttf", 14)
keymap_legend = [
("WASD", "Move camera"),
("Mouse wheel", "Zoom in/out"),
("Middle mouse", "Pan camera"),
("R", "Reset camera"),
("G", "Toggle grid"),
("I", "Toggle interaction radius"),
("ESC", "Deselect/Exit"),
("Left click", "Select object(s)"),
("Drag select", "Select multiple objects"),
("Click on object", "Select closest object in range"),
("Up/Down", "Increase/Decrease camera speed"),
("L", "Toggle this legend"),
("Space", "Pause/Resume simulation"),
]
if showing_legend:
# Split into two columns
mid = (len(keymap_legend) + 1) // 2
left_col = keymap_legend[:mid]
right_col = keymap_legend[mid:]
legend_font_height = legend_font.get_height()
column_gap = 40 # Space between columns
# Calculate max width for each column
left_width = max(legend_font.size(f"{k}: {v}")[0] for k, v in left_col)
right_width = max(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 = 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 = 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)
else:
# just show l to toggle legend
legend_text = 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)
if is_paused:
pause_text = 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)
# Update display
pygame.display.flip()

View File

@ -5,6 +5,7 @@ description = "Add your description here"
requires-python = ">=3.11"
dependencies = [
"pre-commit>=4.2.0",
"pydantic>=2.11.5",
"pygame>=2.6.1",
"pytest>=8.3.5",
]

87
tests/test_world.py Normal file
View File

@ -0,0 +1,87 @@
import pytest
from world.world import World, Position, BaseEntity
class DummyEntity(BaseEntity):
def __init__(self, position):
super().__init__(position)
self.ticked = False
self.rendered = False
def tick(self, interactable=None):
self.ticked = True
return self
def render(self, camera, screen):
self.rendered = True
@pytest.fixture
def world():
return World(partition_size=10, world_size=(100, 100))
def test_add_object_and_get_objects(world):
entity = DummyEntity(Position(x=0, y=0))
world.add_object(entity)
assert entity in world.get_objects()
def test_query_objects_within_radius(world):
e1 = DummyEntity(Position(x=0, y=0))
e2 = DummyEntity(Position(x=5, y=0))
e3 = DummyEntity(Position(x=20, y=0))
world.add_object(e1)
world.add_object(e2)
world.add_object(e3)
found = world.query_objects_within_radius(0, 0, 10)
assert e1 in found
assert e2 in found
assert e3 not in found
def test_query_objects_in_range(world):
e1 = DummyEntity(Position(x=1, y=1))
e2 = DummyEntity(Position(x=5, y=5))
e3 = DummyEntity(Position(x=20, y=20))
world.add_object(e1)
world.add_object(e2)
world.add_object(e3)
found = world.query_objects_in_range(0, 0, 10, 10)
assert e1 in found
assert e2 in found
assert e3 not in found
def test_query_closest_object(world):
e1 = DummyEntity(Position(x=0, y=0))
e2 = DummyEntity(Position(x=10, y=0))
world.add_object(e1)
world.add_object(e2)
closest = world.query_closest_object(1, 0)
assert closest == e1
def test_tick_all_removes_dead(world):
e1 = DummyEntity(Position(x=0, y=0))
e2 = DummyEntity(Position(x=10, y=0))
e2.flags["death"] = True
world.add_object(e1)
world.add_object(e2)
world.tick_all()
objs = world.get_objects()
assert e1 in objs
assert e2 not in objs
def test_tick_all_calls_tick(world):
e1 = DummyEntity(Position(x=0, y=0))
world.add_object(e1)
world.tick_all()
assert e1.ticked
def test_add_object_out_of_bounds(world):
entity = DummyEntity(Position(x=1000, y=1000))
with pytest.raises(ValueError):
world.add_object(entity)

112
uv.lock generated
View File

@ -2,6 +2,15 @@ version = 1
revision = 2
requires-python = ">=3.11"
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "cfgv"
version = "3.4.0"
@ -35,6 +44,7 @@ version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "pre-commit" },
{ name = "pydantic" },
{ name = "pygame" },
{ name = "pytest" },
]
@ -47,6 +57,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "pre-commit", specifier = ">=4.2.0" },
{ name = "pydantic", specifier = ">=2.11.5" },
{ name = "pygame", specifier = ">=2.6.1" },
{ name = "pytest", specifier = ">=8.3.5" },
]
@ -133,6 +144,86 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" },
]
[[package]]
name = "pydantic"
version = "2.11.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f0/86/8ce9040065e8f924d642c58e4a344e33163a07f6b57f836d0d734e0ad3fb/pydantic-2.11.5.tar.gz", hash = "sha256:7f853db3d0ce78ce8bbb148c401c2cdd6431b3473c0cdff2755c7690952a7b7a", size = 787102, upload-time = "2025-05-22T21:18:08.761Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/69/831ed22b38ff9b4b64b66569f0e5b7b97cf3638346eb95a2147fdb49ad5f/pydantic-2.11.5-py3-none-any.whl", hash = "sha256:f9c26ba06f9747749ca1e5c94d6a85cb84254577553c8785576fd38fa64dc0f7", size = 444229, upload-time = "2025-05-22T21:18:06.329Z" },
]
[[package]]
name = "pydantic-core"
version = "2.33.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" },
{ url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" },
{ url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" },
{ url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" },
{ url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" },
{ url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" },
{ url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" },
{ url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" },
{ url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" },
{ url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" },
{ url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" },
{ url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" },
{ url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" },
{ url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" },
{ url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" },
{ url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" },
{ url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" },
{ url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" },
{ url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" },
{ url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" },
{ url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" },
{ url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" },
{ url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" },
{ url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" },
{ url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" },
{ url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" },
{ url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" },
{ url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" },
{ url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" },
{ url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" },
{ url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" },
{ url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" },
{ url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" },
{ url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" },
{ url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" },
{ url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" },
{ url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" },
{ url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" },
{ url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" },
{ url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" },
{ url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" },
{ url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" },
{ url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" },
{ url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" },
{ url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" },
{ url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" },
{ url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" },
{ url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" },
{ url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" },
]
[[package]]
name = "pygame"
version = "2.6.1"
@ -237,6 +328,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/44/42/d58086ec20f52d2b0140752ae54b355ea2be2ed46f914231136dd1effcc7/ruff-0.11.12-py3-none-win_arm64.whl", hash = "sha256:65194e37853158d368e333ba282217941029a28ea90913c67e558c611d04daa5", size = 10697770, upload-time = "2025-05-29T13:31:38.009Z" },
]
[[package]]
name = "typing-extensions"
version = "4.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload-time = "2025-06-02T14:52:11.399Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" },
]
[[package]]
name = "virtualenv"
version = "20.31.2"

View File

@ -1,36 +1,173 @@
from world.world import Position
import random, math
from multiprocessing.reduction import duplicate
from world.world import Position, BaseEntity
import pygame
from typing import Optional, List, Any
# returns desired yellow value for food decay
def food_decay_yellow(decay):
class DebugRenderObject(BaseEntity):
"""
Debug object that renders as a circle and counts its neighbors.
"""
def __init__(self, position: Position, radius: int = 5) -> None:
"""
Initializes the debug render object.
:param position: The position of the object.
:param radius: The radius of the rendered circle.
"""
super().__init__(position)
self.neighbors: int = 0
self.radius: int = radius
self.max_visual_width: int = radius * 2
self.interaction_radius: int = 50
self.flags: dict[str, bool] = {
"death": False,
"can_interact": True,
}
def tick(self, interactable: Optional[List[BaseEntity]] = None) -> "DebugRenderObject":
"""
Updates the object, counting the number of interactable neighbors.
:param interactable: List of nearby entities.
:return: Self.
"""
if interactable is None:
interactable = []
self.neighbors = len(interactable)
return self
def render(self, camera: Any, screen: Any) -> None:
"""
Renders the debug object as a circle, color intensity based on neighbors.
:param camera: The camera object for coordinate transformation.
:param screen: The Pygame screen surface.
"""
if camera.is_in_view(*self.position.get_position()):
pygame.draw.circle(
screen,
(50, 50, min([255, (self.neighbors + 4) * 30])),
camera.world_to_screen(*self.position.get_position()),
int(self.radius * camera.zoom),
)
def __repr__(self) -> str:
"""
Returns a string representation of the object.
:return: String representation.
"""
return f"DebugRenderObject({self.position}, neighbors={self.neighbors})"
def food_decay_yellow(decay: int) -> int:
"""
Returns the yellow color component for food decay visualization.
:param decay: The current decay value (0-255).
:return: The yellow component (0-255).
"""
if decay < 128:
return decay
else:
return 255 - decay
class DebugRenderObject:
def __init__(self, position: Position):
self.position = position
def chance_to_grow(decay_rate):
return ((2**(-20*(decay_rate-1)))*12.5)+0.1
def tick(self):
pass
def chance(percent):
return random.random() < percent / 100
def render(self, camera, screen):
class FoodObject(BaseEntity):
"""
Food object that decays over time and is rendered as a colored circle.
"""
def __init__(self, position: Position) -> None:
"""
Initializes the food object.
:param position: The position of the food.
"""
super().__init__(position)
self.max_visual_width: int = 10
self.decay: int = 0
self.decay_rate: int = 1
self.max_decay = 200
self.interaction_radius: int = 50
self.neighbors: int = 0
self.flags: dict[str, bool] = {
"death": False,
"can_interact": True,
}
def tick(self, interactable: Optional[List[BaseEntity]] = None) -> Optional["FoodObject"]:
"""
Updates the food object, increasing decay and flagging for death if decayed.
:param interactable: List of nearby entities (unused).
:return: Self
"""
if interactable is None:
interactable = []
self.neighbors = len(interactable)
if self.neighbors > 0:
self.decay += self.decay_rate * (1 + (self.neighbors / 10))
else:
self.decay += self.decay_rate
if self.decay > self.max_decay:
self.decay = self.max_decay
self.flag_for_death()
grow_chance = chance_to_grow(self.decay_rate * (1 + (self.neighbors / 10)))
# print(grow_chance)
if chance(grow_chance):
# print("Growing")
duplicate_x, duplicate_y = self.position.get_position()
duplicate_x += random.randint(-self.interaction_radius, self.interaction_radius)
duplicate_y += random.randint(-self.interaction_radius, self.interaction_radius)
return [self, FoodObject(Position(x=duplicate_x, y=duplicate_y))]
return self
def normalize_decay_to_color(self) -> int:
"""
Normalizes the decay value to a color component.
:return: Normalized decay value (0-255).
"""
return self.decay / self.max_decay * 255 if self.max_decay > 0 else 0
def render(self, camera: Any, screen: Any) -> None:
"""
Renders the food object as a decaying colored circle.
:param camera: The camera object for coordinate transformation.
:param screen: The Pygame screen surface.
"""
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,
(255 - self.normalize_decay_to_color(), food_decay_yellow(self.normalize_decay_to_color()), 0),
camera.world_to_screen(*self.position.get_position()),
int(5 * camera.zoom)
)
class FoodObject:
def __init__(self, position: Position):
self.decay = 0
self.position = position
def __repr__(self) -> str:
"""
Returns a string representation of the food object.
def tick(self):
self.decay += 1
if self.decay > 255:
self.decay = 0 # eventually will raise a destroy flag
def render(self, camera, screen):
if camera.is_in_view(*self.position.get_position()):
pygame.draw.circle(screen, (255-self.decay,food_decay_yellow(self.decay),0), camera.world_to_screen(*self.position.get_position()), 5 * camera.zoom)
:return: String representation.
"""
return f"FoodObject({self.position}, decay={self.decay:.0f}, decay_rate={self.decay_rate * (1 + (self.neighbors / 10))})"

View File

@ -0,0 +1,183 @@
import pygame
from typing import Optional, Tuple, Sequence
class Camera:
"""
Camera class for handling world-to-screen transformations, panning, and zooming in a 2D simulation.
"""
def __init__(
self,
screen_width: int,
screen_height: int,
render_buffer: int = 50,
) -> None:
"""
Initializes the Camera.
:param screen_width: Width of the screen in pixels.
:param screen_height: Height of the screen in pixels.
:param render_buffer: Buffer for rendering objects outside the screen.
"""
self.x: float = 0
self.y: float = 0
self.target_x: float = 0
self.target_y: float = 0
self.zoom: float = 1.0
self.target_zoom: float = 1.0
self.smoothing: float = 0.15 # Higher = more responsive, lower = more smooth
self.speed: float = 700
self.zoom_smoothing: float = 0.2 # Higher = more responsive, lower = more smooth
self.is_panning: bool = False
self.last_mouse_pos: Optional[Sequence[int]] = None
self.screen_width: int = screen_width
self.screen_height: int = screen_height
self.render_buffer: int = render_buffer
self.min_zoom: float = 50.0 # Maximum zoom level
self.max_zoom: float = 0.01 # Minimum zoom level
def update(self, keys: Sequence[bool], deltatime: float) -> None:
"""
Updates the camera position and zoom based on input and time.
:param keys: Sequence of boolean values representing pressed keys.
:param deltatime: Time elapsed since last update (in seconds).
"""
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
length = (dx ** 2 + dy ** 2) ** 0.5
if length > 0:
dx /= length
dy /= length
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
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
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
zoom_smoothing_factor = 1 - pow(1 - self.zoom_smoothing, deltatime * 60)
self.zoom += (self.target_zoom - self.zoom) * zoom_smoothing_factor
zoom_threshold = 0.001
if abs(self.zoom - self.target_zoom) < zoom_threshold:
self.zoom = self.target_zoom
def handle_zoom(self, zoom_delta: int) -> None:
"""
Adjusts the camera zoom level based on mouse wheel input.
:param zoom_delta: The amount of zoom change (positive for zoom in, negative for zoom out).
"""
zoom_factor = 1.1
if zoom_delta > 0:
self.target_zoom *= zoom_factor
elif zoom_delta < 0:
self.target_zoom /= zoom_factor
self.target_zoom = max(self.max_zoom, min(self.min_zoom, self.target_zoom))
def start_panning(self, mouse_pos: Sequence[int]) -> None:
"""
Begins panning the camera.
:param mouse_pos: The current mouse position as a sequence (x, y).
"""
self.is_panning = True
self.last_mouse_pos = mouse_pos
def stop_panning(self) -> None:
"""
Stops panning the camera.
"""
self.is_panning = False
self.last_mouse_pos = None
def pan(self, mouse_pos: Sequence[int]) -> None:
"""
Pans the camera based on mouse movement.
:param mouse_pos: The current mouse position as a sequence (x, y).
"""
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
self.target_y = self.y
self.last_mouse_pos = mouse_pos
def get_real_coordinates(self, screen_x: int, screen_y: int) -> Tuple[float, float]:
"""
Converts screen coordinates to world coordinates.
:param screen_x: X coordinate on the screen.
:param screen_y: Y coordinate on the screen.
:return: Tuple of (world_x, world_y).
"""
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: float, obj_y: float, margin: float = 0) -> bool:
"""
Checks if a world coordinate is within the camera's view.
:param obj_x: X coordinate in world space.
:param obj_y: Y coordinate in world space.
:param margin: Additional margin to expand the view area.
:return: True if the object is in view, False otherwise.
"""
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: float, obj_y: float) -> Tuple[int, int]:
"""
Converts world coordinates to screen coordinates.
:param obj_x: X coordinate in world space.
:param obj_y: Y coordinate in world space.
:return: Tuple of (screen_x, screen_y) in pixels.
"""
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: float) -> int:
"""
Converts a world size (e.g., radius or width/height) to screen pixels.
:param world_size: Size in world units.
:return: Size in screen pixels.
"""
return int(world_size * self.zoom)

View File

@ -1,37 +1,255 @@
class World:
def __init__(self):
self.objects = []
pass
from collections import defaultdict
from abc import ABC, abstractmethod
from typing import List, Dict, Tuple, Optional, Any, TypeVar, Union
from pydantic import BaseModel, Field
def render_all(self, camera, screen):
for obj in self.objects:
obj.render(camera, screen)
T = TypeVar("T", bound="BaseEntity")
def tick_all(self):
for obj in self.objects:
obj.tick()
def add_object(self, new_object):
self.objects.append(new_object)
class Position(BaseModel):
"""
Represents a 2D position in the world.
"""
x: int = Field(..., description="X coordinate")
y: int = Field(..., description="Y coordinate")
def get_objects(self):
return self.objects
class Position:
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
def __str__(self) -> str:
return f"({self.x}, {self.y})"
def __repr__(self):
def __repr__(self) -> str:
return f"Position({self.x}, {self.y})"
def set_position(self, x, y):
def set_position(self, x: int, y: int) -> None:
"""
Sets the position to the given coordinates.
:param x: New X coordinate.
:param y: New Y coordinate.
"""
self.x = x
self.y = y
def get_position(self):
def get_position(self) -> Tuple[int, int]:
"""
Returns the current position as a tuple.
:return: Tuple of (x, y).
"""
return self.x, self.y
class BaseEntity(ABC):
"""
Abstract base class for all entities in the world.
"""
def __init__(self, position: Position) -> None:
"""
Initializes the entity with a position.
:param position: The position of the entity.
"""
self.position: Position = position
self.interaction_radius: int = 0
self.flags: Dict[str, bool] = {
"death": False,
"can_interact": False,
}
self.world_callbacks: Dict[str, Any] = {}
self.max_visual_width: int = 0
@abstractmethod
def tick(self, interactable: Optional[List["BaseEntity"]] = None) -> Optional["BaseEntity"]:
"""
Updates the entity for a single tick.
:param interactable: List of entities this entity can interact with.
:return: The updated entity or None if it should be removed.
"""
return self
@abstractmethod
def render(self, camera: Any, screen: Any) -> None:
"""
Renders the entity on the screen.
:param camera: The camera object for coordinate transformation.
:param screen: The Pygame screen surface.
"""
pass
def flag_for_death(self) -> None:
"""
Flags the entity for removal from the world.
"""
self.flags["death"] = True
class World:
"""
A world-class that contains and manages all objects in the game using spatial partitioning.
"""
def __init__(self, partition_size: int = 10, world_size: tuple[int, int] = (400, 300)) -> None:
"""
Initializes the world with a partition size.
:param partition_size: The size of each partition cell in the world.
"""
self.partition_size: int = partition_size
self.buffers: List[Dict[Tuple[int, int], List[BaseEntity]]] = [defaultdict(list), defaultdict(list)]
self.world_size: Tuple[int, int] = world_size
self.current_buffer: int = 0
def _hash_position(self, position: Position) -> Tuple[int, int]:
"""
Hashes a position into a cell based on the partition size.
:param position: A Position object representing the position in the world.
:return: Tuple (cell_x, cell_y) representing the cell coordinates.
"""
# Ensure position is within world bounds, considering a center origin
if position.x < -self.world_size[0] / 2 or position.x >= self.world_size[0] / 2 or position.y < - \
self.world_size[1] / 2 or position.y >= self.world_size[1] / 2:
# force position to be within bounds
position.x = max(-self.world_size[0] / 2, min(position.x, self.world_size[0] / 2 - 1))
position.y = max(-self.world_size[1] / 2, min(position.y, self.world_size[1] / 2 - 1))
return int(position.x // self.partition_size), int(position.y // self.partition_size)
def render_all(self, camera: Any, screen: Any) -> None:
"""
Renders all objects in the current buffer.
:param camera: The camera object for coordinate transformation.
:param screen: The Pygame screen surface.
"""
for obj_list in self.buffers[self.current_buffer].values():
for obj in obj_list:
obj.render(camera, screen)
def tick_all(self) -> None:
"""
Advances all objects in the world by one tick, updating their state and handling interactions.
"""
next_buffer: int = 1 - self.current_buffer
self.buffers[next_buffer].clear()
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)
new_obj = obj.tick(interactable)
else:
new_obj = obj.tick()
if new_obj is None:
continue
# reproduction code
if isinstance(new_obj, list):
for item in new_obj:
if isinstance(item, BaseEntity):
cell = self._hash_position(item.position)
self.buffers[next_buffer][cell].append(item)
else:
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: BaseEntity) -> None:
"""
Adds a new object to the world in the appropriate cell.
:param new_object: The object to add.
"""
cell = self._hash_position(new_object.position)
self.buffers[self.current_buffer][cell].append(new_object)
def query_objects_within_radius(self, x: float, y: float, radius: float) -> List[BaseEntity]:
"""
Returns all objects within a given radius of a point.
:param x: X coordinate of the center.
:param y: Y coordinate of the center.
:param radius: Search radius.
:return: List of objects within the radius.
"""
result: List[BaseEntity] = []
cell_x, cell_y = int(x // self.partition_size), int(y // self.partition_size)
cells_to_check: List[Tuple[int, int]] = []
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: float, y1: float, x2: float, y2: float) -> List[BaseEntity]:
"""
Returns all objects within a rectangular range.
:param x1: Minimum X coordinate.
:param y1: Minimum Y coordinate.
:param x2: Maximum X coordinate.
:param y2: Maximum Y coordinate.
:return: List of objects within the rectangle.
"""
result: List[BaseEntity] = []
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: float, y: float) -> Optional[BaseEntity]:
"""
Returns the closest object to a given point.
:param x: X coordinate of the point.
:param y: Y coordinate of the point.
:return: The closest object or None if no objects exist.
"""
closest_obj: Optional[BaseEntity] = None
closest_distance: float = 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
def get_objects(self) -> List[BaseEntity]:
"""
Returns a list of all objects currently in the world.
:return: List of all objects.
"""
all_objects: List[BaseEntity] = []
for obj_list in self.buffers[self.current_buffer].values():
all_objects.extend(obj_list)
return all_objects