import math import random 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, Union from world.utils import get_distance_between_objects from math import atan2, degrees 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, Rotation(angle=0)) 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 chance_to_grow(decay_rate): return ((2**(-20*(decay_rate-1)))*12.5)+0.1 def chance(percent): return random.random() < percent / 100 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, Rotation(angle=0)) self.max_visual_width: int = 8 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) -> Union["FoodObject", List["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 = [] # filter neighbors to only other food objects food_neighbors = [obj for obj in interactable if isinstance(obj, FoodObject)] self.neighbors = len(food_neighbors) 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 - self.normalize_decay_to_color(), 0, 0), camera.world_to_screen(*self.position.get_position()), int((self.max_visual_width // 2) * camera.zoom) ) def __repr__(self) -> str: """ Returns a string representation of the food object. :return: String representation. """ return f"FoodObject({self.position}, decay={self.decay:.1f}, decay_rate={self.decay_rate * (1 + (self.neighbors / 10))})" class TestVelocityObject(BaseEntity): """ Test object that moves in a randomly set direction. """ def __init__(self, position: Position) -> None: """ Initializes the test velocity object. :param position: The position of the object. """ 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 self.flags: dict[str, bool] = { "death": False, "can_interact": True, } def tick(self, interactable: Optional[List[BaseEntity]] = None) -> "TestVelocityObject": """ Updates the object by moving it according to its velocity. :param interactable: List of nearby entities (unused). :return: Self. """ if interactable is None: interactable = [] x, y = self.position.get_position() x += self.velocity[0] y += self.velocity[1] self.position.set_position(x, y) return self def render(self, camera: Any, screen: Any) -> None: """ Renders the test object 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) -> str: """ Returns a string representation of the test object. :return: String representation. """ return f"TestVelocityObject({self.position}, velocity={self.velocity})" 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, } self.tick_count = 0 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. """ self.tick_count += 1 if self.tick_count % 100 == 0: self.behavioral_model = self.behavioral_model.mutate(1) 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.1, output_data["linear_acceleration"])) output_data["angular_acceleration"] = max(-0.1, min(0.1, output_data["angular_acceleration"])) # 2. Apply drag force drag_coefficient = 0.02 drag_x = -self.velocity[0] * drag_coefficient drag_y = -self.velocity[1] * drag_coefficient # 3. Combine all forces total_linear_accel = output_data["linear_acceleration"] total_linear_accel = max(-0.1, min(0.1, total_linear_accel)) # 4. Convert to world coordinates x_component = total_linear_accel * math.cos(math.radians(self.rotation.get_rotation())) y_component = total_linear_accel * math.sin(math.radians(self.rotation.get_rotation())) # 5. Add drag to total acceleration total_accel_x = x_component + drag_x total_accel_y = y_component + drag_y self.acceleration = (total_accel_x, total_accel_y) rotational_drag = 0.05 self.angular_acceleration = output_data["angular_acceleration"] - self.rotational_velocity * rotational_drag # 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 max_speed = 0.5 speed = math.sqrt(self.velocity[0] ** 2 + self.velocity[1] ** 2) if speed > max_speed: scale = max_speed / speed self.velocity = (self.velocity[0] * scale, self.velocity[1] * scale) # 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(-3, min(3, 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}"