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}"