Add rotation and behavioral model to entities, implement DefaultCell entity
Some checks failed
Build Simulation and Test / Run All Tests (push) Failing after 35s

This commit is contained in:
Sam 2025-06-14 17:30:31 -05:00
parent 589bb13688
commit f0576e52d6
6 changed files with 400 additions and 35 deletions

189
main.py
View File

@ -1,10 +1,12 @@
import math
import pygame import pygame
import time import time
import sys import sys
import random import random
from world.world import World, Position from world.world import World, Position, Rotation
from world.objects import DebugRenderObject, FoodObject, TestVelocityObject from world.objects import DebugRenderObject, FoodObject, TestVelocityObject, DefaultCell
from world.simulation_interface import Camera from world.simulation_interface import Camera
# Initialize Pygame # Initialize Pygame
@ -26,7 +28,7 @@ GRID_HEIGHT = 25 # Number of cells vertically
CELL_SIZE = 20 # Size of each cell in pixels CELL_SIZE = 20 # Size of each cell in pixels
DEFAULT_TPS = 20 # Number of ticks per second for the simulation DEFAULT_TPS = 20 # Number of ticks per second for the simulation
FOOD_SPAWNING = False FOOD_SPAWNING = True
def draw_grid(screen, camera, showing_grid=True): def draw_grid(screen, camera, showing_grid=True):
@ -107,6 +109,16 @@ def draw_grid(screen, camera, showing_grid=True):
for start, end in horizontal_lines: for start, end in horizontal_lines:
pygame.draw.line(screen, GRAY, start, end) pygame.draw.line(screen, GRAY, start, end)
def setup(world: World):
if FOOD_SPAWNING:
world.add_object(FoodObject(Position(x=random.randint(-100, 100), y=random.randint(-100, 100))))
world.add_object(TestVelocityObject(Position(x=random.randint(-100, 100), y=random.randint(-100, 100))))
world.add_object(DefaultCell(Position(x=0,y=0), Rotation(angle=0)))
return world
def main(): def main():
screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT), vsync=1) screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT), vsync=1)
@ -148,10 +160,7 @@ def main():
# sets seed to 67 >_< # sets seed to 67 >_<
random.seed(0) random.seed(0)
if FOOD_SPAWNING: world = setup(world)
world.add_object(FoodObject(Position(x=random.randint(-100, 100), y=random.randint(-100, 100))))
world.add_object(TestVelocityObject(Position(x=random.randint(-100, 100), y=random.randint(-100, 100))))
running = True running = True
while running: while running:
@ -183,7 +192,7 @@ def main():
if event.key == pygame.K_SPACE: if event.key == pygame.K_SPACE:
is_paused = not is_paused is_paused = not is_paused
if event.key == pygame.K_LSHIFT: if event.key == pygame.K_LSHIFT:
tps = DEFAULT_TPS * 2 tps = DEFAULT_TPS * 4
elif event.type == pygame.KEYUP: elif event.type == pygame.KEYUP:
if event.key == pygame.K_LSHIFT: if event.key == pygame.K_LSHIFT:
tps = DEFAULT_TPS tps = DEFAULT_TPS
@ -251,8 +260,6 @@ def main():
objects = world.get_objects() objects = world.get_objects()
food = len([obj for obj in objects if isinstance(obj, FoodObject)]) food = len([obj for obj in objects if isinstance(obj, FoodObject)])
# if food < 10 and FOOD_SPAWNING == True:
# world.add_object(FoodObject(Position(x=random.randint(-100, 100), y=random.randint(-100, 100))))
# ensure selected objects are still valid or have not changed position, if so, reselect them # ensure selected objects are still valid or have not changed position, if so, reselect them
selected_objects = [ selected_objects = [
@ -298,6 +305,133 @@ def main():
1 # 1 pixel thick 1 # 1 pixel thick
) )
# Draw direction arrow
rotation_angle = obj.rotation.get_rotation()
arrow_length = obj.max_visual_width/2 * camera.zoom # Scale arrow length with zoom
arrow_color = (255, 255, 255) # Green
# Calculate the arrow's end-point based on rotation angle
end_x = screen_x + arrow_length * math.cos(math.radians(rotation_angle))
end_y = screen_y + arrow_length * math.sin(math.radians(rotation_angle))
# Draw the arrow line
pygame.draw.line(screen, arrow_color, (screen_x, screen_y), (end_x, end_y), 2)
# Draw a rotated triangle for the arrowhead
tip_size = 3 * camera.zoom # Scale triangle tip size with zoom
left_tip_x = end_x - tip_size * math.cos(math.radians(rotation_angle + 150 + 180))
left_tip_y = end_y - tip_size * math.sin(math.radians(rotation_angle + 150 + 180))
right_tip_x = end_x - tip_size * math.cos(math.radians(rotation_angle - 150 + 180))
right_tip_y = end_y - tip_size * math.sin(math.radians(rotation_angle - 150 + 180))
# Draw arrowhead (triangle) for direction
pygame.draw.polygon(
screen,
arrow_color,
[(end_x, end_y), (left_tip_x, left_tip_y), (right_tip_x, right_tip_y)]
)
# Draw angular acceleration arrow (if present)
if hasattr(obj, 'angular_acceleration'):
angular_acceleration = obj.angular_acceleration
# Scale the angular acceleration value for visibility
angular_accel_magnitude = abs(
angular_acceleration) * 50 * camera.zoom # Use absolute magnitude for scaling
# Determine the perpendicular direction based on the sign of angular_acceleration
angular_direction = rotation_angle + 90 if angular_acceleration >= 0 else rotation_angle - 90
# Calculate the end of the angular acceleration vector
angular_acc_end_x = end_x + angular_accel_magnitude * math.cos(
math.radians(angular_direction))
angular_acc_end_y = end_y + angular_accel_magnitude * math.sin(
math.radians(angular_direction))
# Draw the angular acceleration vector as a red line
pygame.draw.line(screen, (52, 134, 235), (end_x, end_y),
(angular_acc_end_x, angular_acc_end_y), 2)
# Add an arrowhead to the angular acceleration vector
angular_tip_size = 2.5 * camera.zoom
left_angular_tip_x = angular_acc_end_x - angular_tip_size * math.cos(
math.radians(angular_direction + 150 + 180))
left_angular_tip_y = angular_acc_end_y - angular_tip_size * math.sin(
math.radians(angular_direction + 150 + 180))
right_angular_tip_x = angular_acc_end_x - angular_tip_size * math.cos(
math.radians(angular_direction - 150 + 180))
right_angular_tip_y = angular_acc_end_y - angular_tip_size * math.sin(
math.radians(angular_direction - 150 + 180))
# Draw arrowhead (triangle) for angular acceleration
pygame.draw.polygon(
screen,
(52, 134, 235), # Red arrowhead
[(angular_acc_end_x, angular_acc_end_y), (left_angular_tip_x, left_angular_tip_y),
(right_angular_tip_x, right_angular_tip_y)]
)
# If object has an acceleration attribute, draw a red vector with arrowhead
if hasattr(obj, 'acceleration') and isinstance(obj.acceleration, tuple) and len(
obj.acceleration) == 2:
acc_x, acc_y = obj.acceleration
# Calculate acceleration magnitude and direction
acc_magnitude = math.sqrt(acc_x ** 2 + acc_y ** 2)
if acc_magnitude > 0:
acc_direction = math.degrees(math.atan2(acc_y, acc_x)) # Get the angle in degrees
# Calculate scaled acceleration vector's end point
acc_vector_length = acc_magnitude * 1000 * camera.zoom # Scale length with zoom
acc_end_x = screen_x + acc_vector_length * math.cos(math.radians(acc_direction))
acc_end_y = screen_y + acc_vector_length * math.sin(math.radians(acc_direction))
# Draw the acceleration vector as a red line
pygame.draw.line(screen, (255, 0, 0), (screen_x, screen_y), (acc_end_x, acc_end_y), 2)
# Add arrowhead to acceleration vector
acc_tip_size = 5 * camera.zoom
left_tip_x = acc_end_x - acc_tip_size * math.cos(math.radians(acc_direction + 150 + 180))
left_tip_y = acc_end_y - acc_tip_size * math.sin(math.radians(acc_direction + 150 + 180))
right_tip_x = acc_end_x - acc_tip_size * math.cos(math.radians(acc_direction - 150 + 180))
right_tip_y = acc_end_y - acc_tip_size * math.sin(math.radians(acc_direction - 150 + 180))
pygame.draw.polygon(
screen,
(255, 0, 0), # Red arrowhead
[(acc_end_x, acc_end_y), (left_tip_x, left_tip_y), (right_tip_x, right_tip_y)]
)
# If object has a velocity attribute, draw a blue vector with arrowhead
if hasattr(obj, 'velocity') and isinstance(obj.velocity, tuple) and len(obj.velocity) == 2:
vel_x, vel_y = obj.velocity
# Calculate velocity magnitude and direction
vel_magnitude = math.sqrt(vel_x ** 2 + vel_y ** 2)
if vel_magnitude > 0:
vel_direction = math.degrees(math.atan2(vel_y, vel_x)) # Get the angle in degrees
# Calculate scaled velocity vector's end point
vel_vector_length = vel_magnitude * 50 * camera.zoom # Scale length with zoom
vel_end_x = screen_x + vel_vector_length * math.cos(math.radians(vel_direction))
vel_end_y = screen_y + vel_vector_length * math.sin(math.radians(vel_direction))
# Draw the velocity vector as a blue line
pygame.draw.line(screen, (0, 0, 255), (screen_x, screen_y), (vel_end_x, vel_end_y), 2)
# Add arrowhead to velocity vector
vel_tip_size = 5 * camera.zoom
left_tip_x = vel_end_x - vel_tip_size * math.cos(math.radians(vel_direction + 150 + 180))
left_tip_y = vel_end_y - vel_tip_size * math.sin(math.radians(vel_direction + 150 + 180))
right_tip_x = vel_end_x - vel_tip_size * math.cos(math.radians(vel_direction - 150 + 180))
right_tip_y = vel_end_y - vel_tip_size * math.sin(math.radians(vel_direction - 150 + 180))
pygame.draw.polygon(
screen,
(0, 0, 255), # Blue arrowhead
[(vel_end_x, vel_end_y), (left_tip_x, left_tip_y), (right_tip_x, right_tip_y)]
)
# Draw selection rectangle if selecting # Draw selection rectangle if selecting
if selecting and select_start and select_end: if selecting and select_start and select_end:
rect_color = (128, 128, 128, 80) # Gray, semi-transparent rect_color = (128, 128, 128, 80) # Gray, semi-transparent
@ -353,14 +487,37 @@ def main():
if len(selected_objects) >= 1: if len(selected_objects) >= 1:
i = 0 i = 0
max_width = SCREEN_WIDTH - 20 # Leave some padding from the right edge
for each in selected_objects: for each in selected_objects:
obj = each obj = each
obj_text = font.render( text = f"Object: {str(obj)}"
f"Object: {str(obj)}", True, WHITE words = text.split() # Split text into words
) line = ""
obj_rect = obj_text.get_rect() line_height = 20 # Height of each line of text
obj_rect.topleft = (10, 30 + i * 20) line_offset = 0
screen.blit(obj_text, obj_rect)
for word in words:
test_line = f"{line} {word}".strip()
test_width, _ = font.size(test_line)
# Check if the line width exceeds the limit
if test_width > max_width and line:
obj_text = font.render(line, True, WHITE)
obj_rect = obj_text.get_rect()
obj_rect.topleft = (10, 30 + i * line_height + line_offset)
screen.blit(obj_text, obj_rect)
line = word # Start a new line
line_offset += line_height
else:
line = test_line
# Render the last line
if line:
obj_text = font.render(line, True, WHITE)
obj_rect = obj_text.get_rect()
obj_rect.topleft = (10, 30 + i * line_height + line_offset)
screen.blit(obj_text, obj_rect)
i += 1 i += 1
legend_font = pygame.font.Font("freesansbold.ttf", 14) legend_font = pygame.font.Font("freesansbold.ttf", 14)

View File

@ -1,7 +1,7 @@
from world.behavorial import BehavioralModel from world.behavioral import BehavioralModel
class TestBrain(BehavioralModel): class CellBrain(BehavioralModel):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
# Define input keys # Define input keys
@ -12,13 +12,13 @@ class TestBrain(BehavioralModel):
# Define output keys # Define output keys
self.outputs = { self.outputs = {
'acceleration': 0.0, # Linear acceleration 'linear_acceleration': 0.0, # Linear acceleration
'angular_acceleration': 0.0 # Angular acceleration 'angular_acceleration': 0.0 # Angular acceleration
} }
self.weights = { self.weights = {
'distance': 1.0, 'distance': 1,
'angle': 1.0 'angle': 0.5
} }
def tick(self, input_data) -> dict: def tick(self, input_data) -> dict:
@ -26,16 +26,22 @@ class TestBrain(BehavioralModel):
Process inputs and produce corresponding outputs. Process inputs and produce corresponding outputs.
:param input_data: Dictionary containing 'distance' and 'angle' values :param input_data: Dictionary containing 'distance' and 'angle' values
:return: Dictionary with 'acceleration' and 'angular_acceleration' values :return: Dictionary with 'linear_acceleration' and 'angular_acceleration' values
""" """
# Update internal input state # Update internal input state
self.inputs['distance'] = input_data.get('distance', 0.0) self.inputs['distance'] = input_data.get('distance', 0.0)
self.inputs['angle'] = input_data.get('angle', 0.0) self.inputs['angle'] = input_data.get('angle', 0.0)
# Initialize output dictionary # Initialize output dictionary
output_data = { output_data = {'linear_acceleration': self.inputs['distance'] * self.weights['distance'],
'acceleration': 0.0, 'angular_acceleration': self.inputs['angle'] * self.weights['angle']}
'angular_acceleration': 0.0
} self.outputs = output_data
return output_data return output_data
def __repr__(self):
inputs = {key: round(value, 1) for key, value in self.inputs.items()}
outputs = {key: round(value, 1) for key, value in self.outputs.items()}
weights = {key: round(value, 1) for key, value in self.weights.items()}
return f"CellBrain(inputs={inputs}, outputs={outputs}, weights={weights})"

View File

@ -24,4 +24,8 @@ class BehavioralModel:
""" """
output_data = {} output_data = {}
for key in self.outputs:
if key not in output_data:
raise KeyError(f"Output key '{key}' not found in output data.")
return output_data return output_data

View File

@ -1,8 +1,15 @@
import math
import random import random
from world.world import Position, BaseEntity from world.base.brain import CellBrain
from world.behavioral import BehavioralModel
from world.world import Position, BaseEntity, Rotation
import pygame import pygame
from typing import Optional, List, Any from typing import Optional, List, Any, Union
from world.utils import get_distance_between_objects
from math import atan2, degrees
class DebugRenderObject(BaseEntity): class DebugRenderObject(BaseEntity):
@ -17,7 +24,7 @@ class DebugRenderObject(BaseEntity):
:param position: The position of the object. :param position: The position of the object.
:param radius: The radius of the rendered circle. :param radius: The radius of the rendered circle.
""" """
super().__init__(position) super().__init__(position, Rotation(angle=0))
self.neighbors: int = 0 self.neighbors: int = 0
self.radius: int = radius self.radius: int = radius
self.max_visual_width: int = radius * 2 self.max_visual_width: int = radius * 2
@ -93,7 +100,7 @@ class FoodObject(BaseEntity):
:param position: The position of the food. :param position: The position of the food.
""" """
super().__init__(position) super().__init__(position, Rotation(angle=0))
self.max_visual_width: int = 10 self.max_visual_width: int = 10
self.decay: int = 0 self.decay: int = 0
self.decay_rate: int = 1 self.decay_rate: int = 1
@ -105,7 +112,7 @@ class FoodObject(BaseEntity):
"can_interact": True, "can_interact": True,
} }
def tick(self, interactable: Optional[List[BaseEntity]] = None) -> Optional["FoodObject"]: def tick(self, interactable: Optional[List[BaseEntity]] = None) -> Union["FoodObject", List["FoodObject"]]:
""" """
Updates the food object, increasing decay and flagging for death if decayed. Updates the food object, increasing decay and flagging for death if decayed.
@ -183,7 +190,7 @@ class TestVelocityObject(BaseEntity):
:param position: The position of the object. :param position: The position of the object.
""" """
super().__init__(position) super().__init__(position, Rotation(angle=random.randint(0, 360)))
self.velocity = (random.uniform(-0.1, 0.5), random.uniform(-0.1, 0.5)) self.velocity = (random.uniform(-0.1, 0.5), random.uniform(-0.1, 0.5))
self.max_visual_width: int = 10 self.max_visual_width: int = 10
self.interaction_radius: int = 50 self.interaction_radius: int = 50
@ -237,3 +244,162 @@ class DefaultCell(BaseEntity):
""" """
Cell object 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,
}
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.
"""
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.02, output_data["linear_acceleration"]))
output_data["angular_acceleration"] = max(-0.1, min(0.1, output_data["angular_acceleration"]))
# output acceleration is acceleration along its current rotation.
x_component = output_data["linear_acceleration"] * math.cos(math.radians(self.rotation.get_rotation()))
y_component = output_data["linear_acceleration"] * math.sin(math.radians(self.rotation.get_rotation()))
self.acceleration = (x_component, y_component)
# # add drag according to current velocity
# drag_coefficient = 0.3
# drag_x = -self.velocity[0] * drag_coefficient
# drag_y = -self.velocity[1] * drag_coefficient
# self.acceleration = (self.acceleration[0] + drag_x, self.acceleration[1] + drag_y)
# 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
self.velocity = (max(-0.5, min(0.5, self.velocity[0])), max(-0.5, min(0.5, self.velocity[1])))
# 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(-0.5, min(0.5, 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}, behavioral_model={self.behavioral_model})"

2
world/utils.py Normal file
View File

@ -0,0 +1,2 @@
def get_distance_between_objects(object_a, object_b):
return ((object_a.position.x - object_b.position.x)**2 + (object_a.position.y - object_b.position.y)**2)**0.5

View File

@ -36,6 +36,35 @@ class Position(BaseModel):
:return: Tuple of (x, y). :return: Tuple of (x, y).
""" """
return self.x, self.y return self.x, self.y
class Rotation(BaseModel):
"""
Represents rotation in degrees (0-360).
"""
angle: float = Field(..., description="Rotation angle in degrees", ge=0, lt=360)
def __str__(self) -> str:
return f"{self.angle}°"
def __repr__(self) -> str:
return f"Rotation({self.angle})"
def set_rotation(self, angle: float) -> None:
"""
Sets the rotation angle.
:param angle: New angle in degrees (0-360).
"""
self.angle = angle % 360
def get_rotation(self) -> float:
"""
Returns the current rotation angle.
:return: Angle in degrees.
"""
return self.angle
class BaseEntity(ABC): class BaseEntity(ABC):
@ -43,13 +72,14 @@ class BaseEntity(ABC):
Abstract base class for all entities in the world. Abstract base class for all entities in the world.
""" """
def __init__(self, position: Position) -> None: def __init__(self, position: Position, rotation: Rotation) -> None:
""" """
Initializes the entity with a position. Initializes the entity with a position and rotation.
:param position: The position of the entity. :param position: The position of the entity.
""" """
self.position: Position = position self.position: Position = position
self.rotation: Rotation = rotation
self.interaction_radius: int = 0 self.interaction_radius: int = 0
self.flags: Dict[str, bool] = { self.flags: Dict[str, bool] = {
"death": False, "death": False,
@ -252,4 +282,4 @@ class World:
all_objects: List[BaseEntity] = [] all_objects: List[BaseEntity] = []
for obj_list in self.buffers[self.current_buffer].values(): for obj_list in self.buffers[self.current_buffer].values():
all_objects.extend(obj_list) all_objects.extend(obj_list)
return all_objects return all_objects