462 lines
17 KiB
Python
462 lines
17 KiB
Python
import math
|
|
import random
|
|
|
|
from config.constants import MAX_VELOCITY, MAX_ACCELERATION, MAX_ROTATIONAL_VELOCITY, MAX_ANGULAR_ACCELERATION
|
|
from typing import List, Any, Union, Optional
|
|
from world.base.brain import CellBrain
|
|
from world.world import Position, BaseEntity, Rotation
|
|
import pygame
|
|
|
|
from world.utils import get_distance_between_objects
|
|
from world.physics import Physics
|
|
|
|
|
|
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 = 400
|
|
self.interaction_radius: int = 50
|
|
self.neighbors: int = 0
|
|
self.claimed_this_tick = False # Track if food was claimed this tick
|
|
self.flags: dict[str, bool] = {
|
|
"death": False,
|
|
"can_interact": True,
|
|
}
|
|
|
|
def try_claim(self) -> bool:
|
|
"""
|
|
Atomically claims this food for consumption within the current tick.
|
|
|
|
:return: True if successfully claimed, False if already claimed this tick
|
|
"""
|
|
if not self.claimed_this_tick:
|
|
self.claimed_this_tick = True
|
|
return True
|
|
return False
|
|
|
|
def reset_claim(self) -> None:
|
|
"""Reset the claim for next tick"""
|
|
self.claimed_this_tick = False
|
|
|
|
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
|
|
"""
|
|
# Reset claim at the beginning of each tick
|
|
self.reset_claim()
|
|
|
|
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, entity_config=None) -> None:
|
|
"""
|
|
Initializes the cell.
|
|
|
|
:param starting_position: The position of the object.
|
|
:param entity_config: Configuration for entity hyperparameters.
|
|
"""
|
|
|
|
super().__init__(starting_position, starting_rotation)
|
|
|
|
# Use entity config or defaults
|
|
if entity_config:
|
|
cell_config = entity_config.entity_types.get("default_cell", {})
|
|
self.drag_coefficient: float = cell_config.get("drag_coefficient", 0.02)
|
|
self.energy: int = cell_config.get("starting_energy", 1000)
|
|
self.max_visual_width: int = cell_config.get("max_visual_width", 10)
|
|
self.interaction_radius: int = cell_config.get("interaction_radius", 50)
|
|
self.reproduction_energy: int = cell_config.get("reproduction_energy", 1700)
|
|
self.food_energy_value: int = cell_config.get("food_energy_value", 140)
|
|
self.energy_cost_base: float = cell_config.get("energy_cost_base", 1.5)
|
|
self.neural_network_complexity_cost: float = cell_config.get("neural_network_complexity_cost", 0.08)
|
|
self.movement_cost: float = cell_config.get("movement_cost", 0.25)
|
|
self.reproduction_count: int = cell_config.get("reproduction_count", 2)
|
|
self.mutation_rate: float = cell_config.get("mutation_rate", 0.05)
|
|
self.offspring_offset_range: int = cell_config.get("offspring_offset_range", 10)
|
|
else:
|
|
# Fallback to hardcoded defaults
|
|
self.drag_coefficient: float = 0.02
|
|
self.energy: int = 1000
|
|
self.max_visual_width: int = 10
|
|
self.interaction_radius: int = 50
|
|
self.reproduction_energy: int = 1700
|
|
self.food_energy_value: int = 140
|
|
self.energy_cost_base: float = 1.5
|
|
self.neural_network_complexity_cost: float = 0.08
|
|
self.movement_cost: float = 0.25
|
|
self.reproduction_count: int = 2
|
|
self.mutation_rate: float = 0.05
|
|
self.offspring_offset_range: int = 10
|
|
|
|
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.flags: dict[str, bool] = {
|
|
"death": False,
|
|
"can_interact": True,
|
|
}
|
|
|
|
self.tick_count = 0
|
|
self.entity_config = entity_config
|
|
|
|
self.physics = Physics(self.drag_coefficient, self.drag_coefficient*1.5)
|
|
|
|
|
|
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:
|
|
# Use atomic consumption to prevent race conditions
|
|
if food_object.try_claim():
|
|
self.energy += self.food_energy_value
|
|
food_object.flag_for_death()
|
|
return self
|
|
|
|
if self.energy >= self.reproduction_energy:
|
|
# too much energy, split
|
|
offspring = []
|
|
|
|
for _ in range(self.reproduction_count):
|
|
duplicate_x, duplicate_y = self.position.get_position()
|
|
duplicate_x += random.randint(-self.offspring_offset_range, self.offspring_offset_range)
|
|
duplicate_y += random.randint(-self.offspring_offset_range, self.offspring_offset_range)
|
|
|
|
new_cell = DefaultCell(
|
|
Position(x=int(duplicate_x), y=int(duplicate_y)),
|
|
Rotation(angle=random.randint(0, 359)),
|
|
entity_config=getattr(self, 'entity_config', None)
|
|
)
|
|
new_cell.set_brain(self.behavioral_model.mutate(self.mutation_rate))
|
|
offspring.append(new_cell)
|
|
|
|
return offspring
|
|
|
|
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 * self.neural_network_complexity_cost) + self.energy_cost_base + (self.movement_cost * 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})"
|