diff --git a/main.py b/main.py index 954a08c..0d23739 100644 --- a/main.py +++ b/main.py @@ -1,10 +1,12 @@ +import math + import pygame import time import sys import random -from world.world import World, Position -from world.objects import DebugRenderObject, FoodObject, TestVelocityObject +from world.world import World, Position, Rotation +from world.objects import DebugRenderObject, FoodObject, TestVelocityObject, DefaultCell from world.simulation_interface import Camera # Initialize Pygame @@ -26,7 +28,7 @@ GRID_HEIGHT = 25 # Number of cells vertically CELL_SIZE = 20 # Size of each cell in pixels DEFAULT_TPS = 20 # Number of ticks per second for the simulation -FOOD_SPAWNING = False +FOOD_SPAWNING = True def draw_grid(screen, camera, showing_grid=True): @@ -107,6 +109,16 @@ def draw_grid(screen, camera, showing_grid=True): for start, end in horizontal_lines: pygame.draw.line(screen, GRAY, start, end) +def setup(world: World): + if FOOD_SPAWNING: + world.add_object(FoodObject(Position(x=random.randint(-100, 100), y=random.randint(-100, 100)))) + + world.add_object(TestVelocityObject(Position(x=random.randint(-100, 100), y=random.randint(-100, 100)))) + + world.add_object(DefaultCell(Position(x=0,y=0), Rotation(angle=0))) + + return world + def main(): screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT), vsync=1) @@ -148,10 +160,7 @@ def main(): # sets seed to 67 >_< random.seed(0) - if FOOD_SPAWNING: - world.add_object(FoodObject(Position(x=random.randint(-100, 100), y=random.randint(-100, 100)))) - - world.add_object(TestVelocityObject(Position(x=random.randint(-100, 100), y=random.randint(-100, 100)))) + world = setup(world) running = True while running: @@ -183,7 +192,7 @@ def main(): if event.key == pygame.K_SPACE: is_paused = not is_paused if event.key == pygame.K_LSHIFT: - tps = DEFAULT_TPS * 2 + tps = DEFAULT_TPS * 4 elif event.type == pygame.KEYUP: if event.key == pygame.K_LSHIFT: tps = DEFAULT_TPS @@ -251,8 +260,6 @@ def main(): objects = world.get_objects() food = len([obj for obj in objects if isinstance(obj, FoodObject)]) - # 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 = [ @@ -298,6 +305,133 @@ def main(): 1 # 1 pixel thick ) + # Draw direction arrow + rotation_angle = obj.rotation.get_rotation() + arrow_length = obj.max_visual_width/2 * camera.zoom # Scale arrow length with zoom + arrow_color = (255, 255, 255) # Green + + # Calculate the arrow's end-point based on rotation angle + end_x = screen_x + arrow_length * math.cos(math.radians(rotation_angle)) + end_y = screen_y + arrow_length * math.sin(math.radians(rotation_angle)) + + # Draw the arrow line + pygame.draw.line(screen, arrow_color, (screen_x, screen_y), (end_x, end_y), 2) + + # Draw a rotated triangle for the arrowhead + tip_size = 3 * camera.zoom # Scale triangle tip size with zoom + left_tip_x = end_x - tip_size * math.cos(math.radians(rotation_angle + 150 + 180)) + left_tip_y = end_y - tip_size * math.sin(math.radians(rotation_angle + 150 + 180)) + right_tip_x = end_x - tip_size * math.cos(math.radians(rotation_angle - 150 + 180)) + right_tip_y = end_y - tip_size * math.sin(math.radians(rotation_angle - 150 + 180)) + + # Draw arrowhead (triangle) for direction + pygame.draw.polygon( + screen, + arrow_color, + [(end_x, end_y), (left_tip_x, left_tip_y), (right_tip_x, right_tip_y)] + ) + + # Draw angular acceleration arrow (if present) + if hasattr(obj, 'angular_acceleration'): + angular_acceleration = obj.angular_acceleration + + # Scale the angular acceleration value for visibility + angular_accel_magnitude = abs( + angular_acceleration) * 50 * camera.zoom # Use absolute magnitude for scaling + + # Determine the perpendicular direction based on the sign of angular_acceleration + angular_direction = rotation_angle + 90 if angular_acceleration >= 0 else rotation_angle - 90 + + # Calculate the end of the angular acceleration vector + angular_acc_end_x = end_x + angular_accel_magnitude * math.cos( + math.radians(angular_direction)) + angular_acc_end_y = end_y + angular_accel_magnitude * math.sin( + math.radians(angular_direction)) + + # Draw the angular acceleration vector as a red line + pygame.draw.line(screen, (52, 134, 235), (end_x, end_y), + (angular_acc_end_x, angular_acc_end_y), 2) + + # Add an arrowhead to the angular acceleration vector + angular_tip_size = 2.5 * camera.zoom + left_angular_tip_x = angular_acc_end_x - angular_tip_size * math.cos( + math.radians(angular_direction + 150 + 180)) + left_angular_tip_y = angular_acc_end_y - angular_tip_size * math.sin( + math.radians(angular_direction + 150 + 180)) + right_angular_tip_x = angular_acc_end_x - angular_tip_size * math.cos( + math.radians(angular_direction - 150 + 180)) + right_angular_tip_y = angular_acc_end_y - angular_tip_size * math.sin( + math.radians(angular_direction - 150 + 180)) + + # Draw arrowhead (triangle) for angular acceleration + pygame.draw.polygon( + screen, + (52, 134, 235), # Red arrowhead + [(angular_acc_end_x, angular_acc_end_y), (left_angular_tip_x, left_angular_tip_y), + (right_angular_tip_x, right_angular_tip_y)] + ) + + # If object has an acceleration attribute, draw a red vector with arrowhead + if hasattr(obj, 'acceleration') and isinstance(obj.acceleration, tuple) and len( + obj.acceleration) == 2: + acc_x, acc_y = obj.acceleration + + # Calculate acceleration magnitude and direction + acc_magnitude = math.sqrt(acc_x ** 2 + acc_y ** 2) + if acc_magnitude > 0: + acc_direction = math.degrees(math.atan2(acc_y, acc_x)) # Get the angle in degrees + + # Calculate scaled acceleration vector's end point + acc_vector_length = acc_magnitude * 1000 * camera.zoom # Scale length with zoom + acc_end_x = screen_x + acc_vector_length * math.cos(math.radians(acc_direction)) + acc_end_y = screen_y + acc_vector_length * math.sin(math.radians(acc_direction)) + + # Draw the acceleration vector as a red line + pygame.draw.line(screen, (255, 0, 0), (screen_x, screen_y), (acc_end_x, acc_end_y), 2) + + # Add arrowhead to acceleration vector + acc_tip_size = 5 * camera.zoom + left_tip_x = acc_end_x - acc_tip_size * math.cos(math.radians(acc_direction + 150 + 180)) + left_tip_y = acc_end_y - acc_tip_size * math.sin(math.radians(acc_direction + 150 + 180)) + right_tip_x = acc_end_x - acc_tip_size * math.cos(math.radians(acc_direction - 150 + 180)) + right_tip_y = acc_end_y - acc_tip_size * math.sin(math.radians(acc_direction - 150 + 180)) + + pygame.draw.polygon( + screen, + (255, 0, 0), # Red arrowhead + [(acc_end_x, acc_end_y), (left_tip_x, left_tip_y), (right_tip_x, right_tip_y)] + ) + + # If object has a velocity attribute, draw a blue vector with arrowhead + if hasattr(obj, 'velocity') and isinstance(obj.velocity, tuple) and len(obj.velocity) == 2: + vel_x, vel_y = obj.velocity + + # Calculate velocity magnitude and direction + vel_magnitude = math.sqrt(vel_x ** 2 + vel_y ** 2) + if vel_magnitude > 0: + vel_direction = math.degrees(math.atan2(vel_y, vel_x)) # Get the angle in degrees + + # Calculate scaled velocity vector's end point + vel_vector_length = vel_magnitude * 50 * camera.zoom # Scale length with zoom + vel_end_x = screen_x + vel_vector_length * math.cos(math.radians(vel_direction)) + vel_end_y = screen_y + vel_vector_length * math.sin(math.radians(vel_direction)) + + # Draw the velocity vector as a blue line + pygame.draw.line(screen, (0, 0, 255), (screen_x, screen_y), (vel_end_x, vel_end_y), 2) + + # Add arrowhead to velocity vector + vel_tip_size = 5 * camera.zoom + left_tip_x = vel_end_x - vel_tip_size * math.cos(math.radians(vel_direction + 150 + 180)) + left_tip_y = vel_end_y - vel_tip_size * math.sin(math.radians(vel_direction + 150 + 180)) + right_tip_x = vel_end_x - vel_tip_size * math.cos(math.radians(vel_direction - 150 + 180)) + right_tip_y = vel_end_y - vel_tip_size * math.sin(math.radians(vel_direction - 150 + 180)) + + pygame.draw.polygon( + screen, + (0, 0, 255), # Blue arrowhead + [(vel_end_x, vel_end_y), (left_tip_x, left_tip_y), (right_tip_x, right_tip_y)] + ) + # Draw selection rectangle if selecting if selecting and select_start and select_end: rect_color = (128, 128, 128, 80) # Gray, semi-transparent @@ -353,14 +487,37 @@ def main(): if len(selected_objects) >= 1: i = 0 + max_width = SCREEN_WIDTH - 20 # Leave some padding from the right edge 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) + text = f"Object: {str(obj)}" + words = text.split() # Split text into words + line = "" + line_height = 20 # Height of each line of text + line_offset = 0 + + for word in words: + test_line = f"{line} {word}".strip() + test_width, _ = font.size(test_line) + + # Check if the line width exceeds the limit + if test_width > max_width and line: + obj_text = font.render(line, True, WHITE) + obj_rect = obj_text.get_rect() + obj_rect.topleft = (10, 30 + i * line_height + line_offset) + screen.blit(obj_text, obj_rect) + line = word # Start a new line + line_offset += line_height + else: + line = test_line + + # Render the last line + if line: + obj_text = font.render(line, True, WHITE) + obj_rect = obj_text.get_rect() + obj_rect.topleft = (10, 30 + i * line_height + line_offset) + screen.blit(obj_text, obj_rect) + i += 1 legend_font = pygame.font.Font("freesansbold.ttf", 14) diff --git a/world/base/brain.py b/world/base/brain.py index af479c9..5bc528a 100644 --- a/world/base/brain.py +++ b/world/base/brain.py @@ -1,7 +1,7 @@ -from world.behavorial import BehavioralModel +from world.behavioral import BehavioralModel -class TestBrain(BehavioralModel): +class CellBrain(BehavioralModel): def __init__(self): super().__init__() # Define input keys @@ -12,13 +12,13 @@ class TestBrain(BehavioralModel): # Define output keys self.outputs = { - 'acceleration': 0.0, # Linear acceleration + 'linear_acceleration': 0.0, # Linear acceleration 'angular_acceleration': 0.0 # Angular acceleration } self.weights = { - 'distance': 1.0, - 'angle': 1.0 + 'distance': 1, + 'angle': 0.5 } def tick(self, input_data) -> dict: @@ -26,16 +26,22 @@ class TestBrain(BehavioralModel): Process inputs and produce corresponding outputs. :param input_data: Dictionary containing 'distance' and 'angle' values - :return: Dictionary with 'acceleration' and 'angular_acceleration' values + :return: Dictionary with 'linear_acceleration' and 'angular_acceleration' values """ # Update internal input state self.inputs['distance'] = input_data.get('distance', 0.0) self.inputs['angle'] = input_data.get('angle', 0.0) # Initialize output dictionary - output_data = { - 'acceleration': 0.0, - 'angular_acceleration': 0.0 - } + output_data = {'linear_acceleration': self.inputs['distance'] * self.weights['distance'], + 'angular_acceleration': self.inputs['angle'] * self.weights['angle']} + + self.outputs = output_data return output_data + + def __repr__(self): + inputs = {key: round(value, 1) for key, value in self.inputs.items()} + outputs = {key: round(value, 1) for key, value in self.outputs.items()} + weights = {key: round(value, 1) for key, value in self.weights.items()} + return f"CellBrain(inputs={inputs}, outputs={outputs}, weights={weights})" diff --git a/world/behavorial.py b/world/behavioral.py similarity index 85% rename from world/behavorial.py rename to world/behavioral.py index 12a6c80..c36f74c 100644 --- a/world/behavorial.py +++ b/world/behavioral.py @@ -24,4 +24,8 @@ class BehavioralModel: """ output_data = {} + for key in self.outputs: + if key not in output_data: + raise KeyError(f"Output key '{key}' not found in output data.") + return output_data diff --git a/world/objects.py b/world/objects.py index 5680c9d..55a923f 100644 --- a/world/objects.py +++ b/world/objects.py @@ -1,8 +1,15 @@ +import math import random -from world.world import Position, BaseEntity +from world.base.brain import CellBrain +from world.behavioral import BehavioralModel +from world.world import Position, BaseEntity, Rotation import pygame -from typing import Optional, List, Any +from typing import Optional, List, Any, Union + +from world.utils import get_distance_between_objects + +from math import atan2, degrees class DebugRenderObject(BaseEntity): @@ -17,7 +24,7 @@ class DebugRenderObject(BaseEntity): :param position: The position of the object. :param radius: The radius of the rendered circle. """ - super().__init__(position) + super().__init__(position, Rotation(angle=0)) self.neighbors: int = 0 self.radius: int = radius self.max_visual_width: int = radius * 2 @@ -93,7 +100,7 @@ class FoodObject(BaseEntity): :param position: The position of the food. """ - super().__init__(position) + super().__init__(position, Rotation(angle=0)) self.max_visual_width: int = 10 self.decay: int = 0 self.decay_rate: int = 1 @@ -105,7 +112,7 @@ class FoodObject(BaseEntity): "can_interact": True, } - def tick(self, interactable: Optional[List[BaseEntity]] = None) -> Optional["FoodObject"]: + def tick(self, interactable: Optional[List[BaseEntity]] = None) -> Union["FoodObject", List["FoodObject"]]: """ Updates the food object, increasing decay and flagging for death if decayed. @@ -183,7 +190,7 @@ class TestVelocityObject(BaseEntity): :param position: The position of the object. """ - super().__init__(position) + super().__init__(position, Rotation(angle=random.randint(0, 360))) self.velocity = (random.uniform(-0.1, 0.5), random.uniform(-0.1, 0.5)) self.max_visual_width: int = 10 self.interaction_radius: int = 50 @@ -237,3 +244,162 @@ class DefaultCell(BaseEntity): """ Cell object """ + def __init__(self, starting_position: Position, starting_rotation: Rotation) -> None: + """ + Initializes the cell. + + :param starting_position: The position of the object. + """ + + super().__init__(starting_position, starting_rotation) + self.drag_coefficient: float = 0.1 + + self.velocity: tuple[int, int] = (0, 0) + self.acceleration: tuple[int, int] = (0, 0) + + self.rotational_velocity: int = 0 + self.angular_acceleration: int = 0 + + self.behavioral_model: CellBrain = CellBrain() + + self.max_visual_width: int = 10 + self.interaction_radius: int = 50 + self.flags: dict[str, bool] = { + "death": False, + "can_interact": True, + } + + + def set_brain(self, behavioral_model: CellBrain) -> None: + self.behavioral_model = behavioral_model + + + def tick(self, interactable: Optional[List[BaseEntity]] = None) -> "DefaultCell": + """ + Updates the cell according to its behavioral model. + + :param interactable: List of nearby entities (unused). + :return: Self. + """ + if interactable is None: + interactable = [] + + # filter interactable objects + food_objects = self.filter_food(interactable) + + # grab the closest food + if len(food_objects) > 0: + food_object = food_objects[0] + else: + food_object = FoodObject(self.position) + + angle_between_food = self.calculate_angle_between_food(self.position.get_position(), self.rotation.get_rotation(), food_object.position.get_position()) + + input_data = { + "distance": get_distance_between_objects(self, food_object), + "angle": angle_between_food, + } + + output_data = self.behavioral_model.tick(input_data) + + # clamp accelerations + output_data["linear_acceleration"] = max(-0.1, min(0.02, output_data["linear_acceleration"])) + output_data["angular_acceleration"] = max(-0.1, min(0.1, output_data["angular_acceleration"])) + + # output acceleration is acceleration along its current rotation. + x_component = output_data["linear_acceleration"] * math.cos(math.radians(self.rotation.get_rotation())) + y_component = output_data["linear_acceleration"] * math.sin(math.radians(self.rotation.get_rotation())) + + self.acceleration = (x_component, y_component) + + # # add drag according to current velocity + # drag_coefficient = 0.3 + # drag_x = -self.velocity[0] * drag_coefficient + # drag_y = -self.velocity[1] * drag_coefficient + # self.acceleration = (self.acceleration[0] + drag_x, self.acceleration[1] + drag_y) + + # tick acceleration + velocity_x = self.velocity[0] + self.acceleration[0] + velocity_y = self.velocity[1] + self.acceleration[1] + self.velocity = (velocity_x, velocity_y) + + # clamp velocity + self.velocity = (max(-0.5, min(0.5, self.velocity[0])), max(-0.5, min(0.5, self.velocity[1]))) + + # tick velocity + x, y = self.position.get_position() + x += self.velocity[0] + y += self.velocity[1] + + self.position.set_position(x, y) + + # tick rotational acceleration + self.angular_acceleration = output_data["angular_acceleration"] + self.rotational_velocity += self.angular_acceleration + + # clamp rotational velocity + self.rotational_velocity = max(-0.5, min(0.5, self.rotational_velocity)) + + # tick rotational velocity + self.rotation.set_rotation(self.rotation.get_rotation() + self.rotational_velocity) + + return self + + @staticmethod + def calculate_angle_between_food(object_position, object_rotation, food_position) -> float: + """ + Calculates the angle between an object's current rotation and the position of the food. + + :param object_position: Tuple of (x, y) for the object's position. + :param object_rotation: Current rotation of the object in degrees. + :param food_position: Tuple of (x, y) for the food's position. + :return: Angle between -180 and 180 degrees. + """ + obj_x, obj_y = object_position + food_x, food_y = food_position + + # Calculate the angle to the food relative to the object + angle_to_food = math.degrees(math.atan2(food_y - obj_y, food_x - obj_x)) + + # Calculate the relative angle to the object's rotation + angle_between = angle_to_food - object_rotation + + # Normalize the angle to be between -180 and 180 degrees + if angle_between > 180: + angle_between -= 360 + elif angle_between < -180: + angle_between += 360 + + return angle_between + + def filter_food(self, input_objects: List[BaseEntity]) -> List[FoodObject]: + """ + Filters the input objects to only include food. Sort output by distance, closest first + """ + food_objects = [] + for obj in input_objects: + if isinstance(obj, FoodObject): + food_objects.append(obj) + food_objects.sort(key=lambda x: get_distance_between_objects(self, x)) + return food_objects + + def render(self, camera: Any, screen: Any) -> None: + """ + Renders the cell as a 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, + (0, 255, 0), + camera.world_to_screen(*self.position.get_position()), + int(5 * camera.zoom) + ) + + def __repr__(self): + position = f"({round(self.position.x, 1)}, {round(self.position.y, 1)})" + velocity = tuple(round(value, 1) for value in self.velocity) + acceleration = tuple(round(value, 1) for value in self.acceleration) + return f"DefaultCell(position={position}, velocity={velocity}, acceleration={acceleration}, behavioral_model={self.behavioral_model})" diff --git a/world/utils.py b/world/utils.py new file mode 100644 index 0000000..cd7bf20 --- /dev/null +++ b/world/utils.py @@ -0,0 +1,2 @@ +def get_distance_between_objects(object_a, object_b): + return ((object_a.position.x - object_b.position.x)**2 + (object_a.position.y - object_b.position.y)**2)**0.5 \ No newline at end of file diff --git a/world/world.py b/world/world.py index dd3c38e..392f9a5 100644 --- a/world/world.py +++ b/world/world.py @@ -36,6 +36,35 @@ class Position(BaseModel): :return: Tuple of (x, y). """ return self.x, self.y + + +class Rotation(BaseModel): + """ + Represents rotation in degrees (0-360). + """ + angle: float = Field(..., description="Rotation angle in degrees", ge=0, lt=360) + + def __str__(self) -> str: + return f"{self.angle}°" + + def __repr__(self) -> str: + return f"Rotation({self.angle})" + + def set_rotation(self, angle: float) -> None: + """ + Sets the rotation angle. + + :param angle: New angle in degrees (0-360). + """ + self.angle = angle % 360 + + def get_rotation(self) -> float: + """ + Returns the current rotation angle. + + :return: Angle in degrees. + """ + return self.angle class BaseEntity(ABC): @@ -43,13 +72,14 @@ class BaseEntity(ABC): Abstract base class for all entities in the world. """ - def __init__(self, position: Position) -> None: + def __init__(self, position: Position, rotation: Rotation) -> None: """ - Initializes the entity with a position. + Initializes the entity with a position and rotation. :param position: The position of the entity. """ self.position: Position = position + self.rotation: Rotation = rotation self.interaction_radius: int = 0 self.flags: Dict[str, bool] = { "death": False, @@ -252,4 +282,4 @@ class World: all_objects: List[BaseEntity] = [] for obj_list in self.buffers[self.current_buffer].values(): all_objects.extend(obj_list) - return all_objects \ No newline at end of file + return all_objects