diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ff9a845..b46af42 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,12 +19,4 @@ repos: hooks: # 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 \ No newline at end of file + args: [ pyproject.toml, -o, requirements.txt ] \ No newline at end of file diff --git a/main.py b/main.py index fd06790..461eddf 100644 --- a/main.py +++ b/main.py @@ -3,7 +3,8 @@ import time import sys 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 pygame.init() @@ -16,109 +17,13 @@ DARK_GRAY = (64, 64, 64) GRAY = (128, 128, 128) WHITE = (255, 255, 255) RENDER_BUFFER = 50 -SPEED = 700 # Pixels per second # Grid settings 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 = 200 # 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 = 5 # Amount of ticks per second for the simulation 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: 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 @@ -214,6 +120,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") @@ -225,8 +138,8 @@ def main(): # Initialize world world = World() - world.add_object(DebugRenderObject(Position(0,0))) - world.add_object(FoodObject(Position(100, 0))) + world.add_object(DebugRenderObject(Position(0, 0))) + world.add_object(DebugRenderObject(Position(20, 0))) running = True while running: @@ -238,7 +151,8 @@ def main(): running = False elif event.type == pygame.KEYDOWN: if event.key == pygame.K_ESCAPE: - 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: @@ -252,11 +166,52 @@ def main(): 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() @@ -267,7 +222,9 @@ def main(): while current_time - last_tick_time >= tick_interval: last_tick_time += tick_interval tick_counter += 1 + total_ticks += 1 # Add your tick-specific logic here + print("Tick logic executed") world.tick_all() @@ -283,10 +240,37 @@ def main(): # Render everything in the world 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 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) @@ -303,6 +287,24 @@ def main(): tps_rect.bottomright = (SCREEN_WIDTH - 10, SCREEN_HEIGHT - 10) 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 pygame.display.flip() clock.tick(180) diff --git a/world/render_objects.py b/world/render_objects.py index 84986aa..06f500d 100644 --- a/world/render_objects.py +++ b/world/render_objects.py @@ -1,30 +1,35 @@ -from world.world import Position - +from world.world import Position, BaseEntity import pygame -class DebugRenderObject: - def __init__(self, position: Position): - self.position = position - def tick(self): - pass +class DebugRenderObject(BaseEntity): + 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): 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) - -class FoodObject: - def __init__(self, position: Position): - self.decay = 0 - 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) + pygame.draw.circle( + screen, + (50, 50, min([255, (self.neighbors + 4) * 30])), + camera.world_to_screen(*self.position.get_position()), + self.radius * camera.zoom, + ) + def __repr__(self): + return f"DebugRenderObject({self.position}, neighbors={self.neighbors})" diff --git a/world/simulation_interface.py b/world/simulation_interface.py new file mode 100644 index 0000000..6c9dbf2 --- /dev/null +++ b/world/simulation_interface.py @@ -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) diff --git a/world/world.py b/world/world.py index 97af418..5297448 100644 --- a/world/world.py +++ b/world/world.py @@ -1,18 +1,129 @@ +from collections import defaultdict +from abc import ABC, abstractmethod + + class World: - def __init__(self): - self.objects = [] - pass + def __init__(self, partition_size=10): + self.partition_size = partition_size + 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): - for obj in self.objects: - obj.render(camera, screen) + for obj_list in self.buffers[self.current_buffer].values(): + for obj in obj_list: + obj.render(camera, screen) def tick_all(self): - for obj in self.objects: - obj.tick() + next_buffer = 1 - self.current_buffer + 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): - 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: def __init__(self, x, y): @@ -31,4 +142,3 @@ class Position: def get_position(self): return self.x, self.y -