Some checks failed
Build Simulation and Test / Run All Tests (push) Failing after 33s
415 lines
14 KiB
Python
415 lines
14 KiB
Python
import math
|
|
import random
|
|
|
|
from config.constants import MAX_VELOCITY, MAX_ACCELERATION, MAX_ROTATIONAL_VELOCITY, MAX_ANGULAR_ACCELERATION
|
|
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(-MAX_ACCELERATION, min(MAX_ACCELERATION, output_data["linear_acceleration"]))
|
|
output_data["angular_acceleration"] = max(-MAX_ANGULAR_ACCELERATION, min(MAX_ANGULAR_ACCELERATION, 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
|
|
speed = math.sqrt(self.velocity[0] ** 2 + self.velocity[1] ** 2)
|
|
if speed > MAX_VELOCITY:
|
|
scale = MAX_VELOCITY / 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(-MAX_ROTATIONAL_VELOCITY, min(MAX_ROTATIONAL_VELOCITY, 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}"
|