416 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 world.physics import Physics
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.energy: int = 1000
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
self.physics = Physics(0.02, 0.05)
def set_brain(self, behavioral_model: CellBrain) -> None:
self.behavioral_model = behavioral_model
def tick(self, interactable: Optional[List[BaseEntity]] = None) -> Union["DefaultCell", List["DefaultCell"]]:
"""
Updates the cell according to its behavioral model.
:param interactable: List of nearby entities (unused).
:return: Self.
"""
if self.energy <= 0:
# too hungry lmao
self.flag_for_death()
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())
distance_to_food = get_distance_between_objects(self, food_object)
if distance_to_food < self.max_visual_width and food_objects:
self.energy += 110
food_object.flag_for_death()
return self
if self.energy >= 1600:
# too much energy, split
duplicate_x, duplicate_y = self.position.get_position()
duplicate_x += random.randint(-self.max_visual_width, self.max_visual_width)
duplicate_y += random.randint(-self.max_visual_width, self.max_visual_width)
duplicate_x_2, duplicate_y_2 = self.position.get_position()
duplicate_x_2 += random.randint(-self.max_visual_width, self.max_visual_width)
duplicate_y_2 += random.randint(-self.max_visual_width, self.max_visual_width)
new_cell = DefaultCell(Position(x=int(duplicate_x), y=int(duplicate_y)), Rotation(angle=random.randint(0, 359)))
new_cell.set_brain(self.behavioral_model.mutate(0.025))
new_cell_2 = DefaultCell(Position(x=int(duplicate_x_2), y=int(duplicate_y_2)), Rotation(angle=random.randint(0, 359)))
new_cell_2.set_brain(self.behavioral_model.mutate(0.025))
return [new_cell, new_cell_2]
input_data = {
"distance": distance_to_food,
"angle": angle_between_food,
"current_speed": math.sqrt(self.velocity[0] ** 2 + self.velocity[1] ** 2),
"current_angular_velocity": self.rotational_velocity,
}
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"]))
# request physics data from Physics class
self.velocity, self.acceleration, self.rotational_velocity, self.angular_acceleration = self.physics.move(output_data["linear_acceleration"], output_data["angular_acceleration"], self.rotation.get_rotation())
# tick velocity
x, y = self.position.get_position()
x += self.velocity[0]
y += self.velocity[1]
self.position.set_position(x, y)
# tick rotational velocity
self.rotation.set_rotation(self.rotation.get_rotation() + self.rotational_velocity)
movement_cost = abs(output_data["angular_acceleration"]) + abs(output_data["linear_acceleration"])
self.energy -= (self.behavioral_model.neural_network.network_cost * 0.15) + 1 + (0.2 * movement_cost)
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)
rotation = round(self.rotation.get_rotation(), 1)
return f"DefaultCell(position={position}, velocity={velocity}, acceleration={acceleration}, rotation={rotation}, energy={self.energy}, age={self.tick_count})"