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

185
main.py
View File

@ -1,10 +1,12 @@
import math
import pygame
import time
import sys
import random
from world.world import World, Position
from world.objects import DebugRenderObject, FoodObject, TestVelocityObject
from world.world import World, Position, Rotation
from world.objects import DebugRenderObject, FoodObject, TestVelocityObject, DefaultCell
from world.simulation_interface import Camera
# Initialize Pygame
@ -26,7 +28,7 @@ GRID_HEIGHT = 25 # Number of cells vertically
CELL_SIZE = 20 # Size of each cell in pixels
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):
@ -107,6 +109,16 @@ def draw_grid(screen, camera, showing_grid=True):
for start, end in horizontal_lines:
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():
screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT), vsync=1)
@ -148,10 +160,7 @@ def main():
# sets seed to 67 >_<
random.seed(0)
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 = setup(world)
running = True
while running:
@ -183,7 +192,7 @@ def main():
if event.key == pygame.K_SPACE:
is_paused = not is_paused
if event.key == pygame.K_LSHIFT:
tps = DEFAULT_TPS * 2
tps = DEFAULT_TPS * 4
elif event.type == pygame.KEYUP:
if event.key == pygame.K_LSHIFT:
tps = DEFAULT_TPS
@ -251,8 +260,6 @@ def main():
objects = world.get_objects()
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
selected_objects = [
@ -298,6 +305,133 @@ def main():
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
if selecting and select_start and select_end:
rect_color = (128, 128, 128, 80) # Gray, semi-transparent
@ -353,14 +487,37 @@ def main():
if len(selected_objects) >= 1:
i = 0
max_width = SCREEN_WIDTH - 20 # Leave some padding from the right edge
for each in selected_objects:
obj = each
obj_text = font.render(
f"Object: {str(obj)}", True, WHITE
)
text = f"Object: {str(obj)}"
words = text.split() # Split text into words
line = ""
line_height = 20 # Height of each line of text
line_offset = 0
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 * 20)
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
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):
super().__init__()
# Define input keys
@ -12,13 +12,13 @@ class TestBrain(BehavioralModel):
# Define output keys
self.outputs = {
'acceleration': 0.0, # Linear acceleration
'linear_acceleration': 0.0, # Linear acceleration
'angular_acceleration': 0.0 # Angular acceleration
}
self.weights = {
'distance': 1.0,
'angle': 1.0
'distance': 1,
'angle': 0.5
}
def tick(self, input_data) -> dict:
@ -26,16 +26,22 @@ class TestBrain(BehavioralModel):
Process inputs and produce corresponding outputs.
: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
self.inputs['distance'] = input_data.get('distance', 0.0)
self.inputs['angle'] = input_data.get('angle', 0.0)
# Initialize output dictionary
output_data = {
'acceleration': 0.0,
'angular_acceleration': 0.0
}
output_data = {'linear_acceleration': self.inputs['distance'] * self.weights['distance'],
'angular_acceleration': self.inputs['angle'] * self.weights['angle']}
self.outputs = 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 = {}
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

View File

@ -1,8 +1,15 @@
import math
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
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):
@ -17,7 +24,7 @@ class DebugRenderObject(BaseEntity):
:param position: The position of the object.
:param radius: The radius of the rendered circle.
"""
super().__init__(position)
super().__init__(position, Rotation(angle=0))
self.neighbors: int = 0
self.radius: int = radius
self.max_visual_width: int = radius * 2
@ -93,7 +100,7 @@ class FoodObject(BaseEntity):
:param position: The position of the food.
"""
super().__init__(position)
super().__init__(position, Rotation(angle=0))
self.max_visual_width: int = 10
self.decay: int = 0
self.decay_rate: int = 1
@ -105,7 +112,7 @@ class FoodObject(BaseEntity):
"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.
@ -183,7 +190,7 @@ class TestVelocityObject(BaseEntity):
: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.max_visual_width: int = 10
self.interaction_radius: int = 50
@ -237,3 +244,162 @@ 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,
}
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

@ -38,18 +38,48 @@ class Position(BaseModel):
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):
"""
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.
"""
self.position: Position = position
self.rotation: Rotation = rotation
self.interaction_radius: int = 0
self.flags: Dict[str, bool] = {
"death": False,