Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 93 additions & 11 deletions src/agent.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Agent module containing the Agent class and related utilities for simulation."""
from __future__ import annotations
from food import FoodSource
import math
import random
from collections import deque
import pygame
import typing
import logging
Expand Down Expand Up @@ -45,10 +45,10 @@ def torus_diff(ax: float, ay: float, bx: float, by: float) -> typing.Tuple[float
max_y = float(Agent.bound_y)
tx = float(bx - ax)
ty = float(by - ay)
dx = tx if abs(tx) < max_x / 2 else tx - _sign(tx) * max_x
dx = tx if abs(tx) < max_x / 2 else tx - _sign(tx) * max_x
dy = ty if abs(ty) < max_y / 2 else ty - _sign(ty) * max_y
return (dx, dy)


class Agent:
"""Represents an agent in the simulation with neural network-based decision making."""
Expand Down Expand Up @@ -88,6 +88,13 @@ class Agent:
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger()


LOOP_WINDOW = 24
LOOP_MAX_SPAN = 3.0
LOOP_TURN_STD_EPS = 0.035
LOOP_TURN_MEAN_MIN = 0.18
ESCAPE_STEPS = 14

def __init__(self, position: tuple, environment : Environment,/, decision_matrix : typing.List[typing.List[int]] = None, genome = None, species = None ):
from mating import Mating

Expand All @@ -98,7 +105,7 @@ def __init__(self, position: tuple, environment : Environment,/, decision_matrix

self.uuid = uuid.uuid4()

self.group_id = species if species else random.randint(0,1) # team/species id (same -> friend, different -> enemy)
self.group_id = species if species else random.randint(0,1) # species id (same -> friend, different -> enemy)

if genome:
self.body_points = genome
Expand All @@ -109,7 +116,7 @@ def __init__(self, position: tuple, environment : Environment,/, decision_matrix
self.max_energy = (10.0 + _sqrt_scale(self.body_points["energy"], 2.0)) * 2
self.base_speed = 0.5 + _sqrt_scale(self.body_points["speed"], 0.2)
self.attack_power = 0.5 + _sqrt_scale(self.body_points["attack"], 0.06)
self.max_age = int(200 + _sqrt_scale(self.body_points["lifespan"], 14.0)) * 2
self.max_age = int((300 + _sqrt_scale(self.body_points["lifespan"], 18.0)) * 2.5)
self.sight = 70.0 + _sqrt_scale(self.body_points["sight"], 6.0)
self.agility = 30.0 + _sqrt_scale(self.body_points["agility"], 2.0)

Expand All @@ -120,6 +127,12 @@ def __init__(self, position: tuple, environment : Environment,/, decision_matrix
self.angle = random.random() * 360.0
self.last_action = self.actions["ACTION_MOVE"]

#tracks recent motion, does not change the NN input/output interface.
self._loop_positions = deque(maxlen=self.LOOP_WINDOW)
self._loop_turns = deque(maxlen=self.LOOP_WINDOW)
self._escape_steps_left = 0
self._loop_position_anchor = (self.x, self.y)

self.input_size = len(self.sense())
self.action_count = len(self.actions)
self.output_size = self.action_count + 2
Expand Down Expand Up @@ -155,6 +168,66 @@ def set_inputs(self, inputs):
raise ValueError(f"expected input vector length {self.input_size}, got {len(inputs)}")
self._inputs_override = [float(v) for v in inputs]

#keep random-weight agents from moving in perfect circles
def _record_motion_sample(self, applied_turn: float, did_move: bool) -> None:
if not did_move:
return

if len(self._loop_positions) == 0:
self._loop_position_anchor = (self.x, self.y)

self._loop_turns.append(float(_clamp(applied_turn, -1.0, 1.0)))
self._loop_positions.append((self.x, self.y))

def _unwrapped_positions(self) -> typing.List[typing.Tuple[float, float]]:
ax, ay = self._loop_position_anchor
out: typing.List[typing.Tuple[float, float]] = []
for px, py in self._loop_positions:
dx, dy = torus_diff(ax, ay, px, py)
out.append((dx, dy))
return out

@staticmethod
def _mean_std(values: typing.Sequence[float]) -> typing.Tuple[float, float]:
n = len(values)
if n == 0:
return 0.0, 0.0
mean = sum(values) / n
var = sum((v - mean) * (v - mean) for v in values) / n
return mean, math.sqrt(var)

def _looks_like_circle(self) -> bool:
if len(self._loop_turns) < self.LOOP_WINDOW:
return False

mean_turn, std_turn = self._mean_std(self._loop_turns)
if abs(mean_turn) < self.LOOP_TURN_MEAN_MIN or std_turn > self.LOOP_TURN_STD_EPS:
return False

pts = self._unwrapped_positions()
xs = [p[0] for p in pts]
ys = [p[1] for p in pts]
span = max(max(xs) - min(xs), max(ys) - min(ys))
return span <= self.LOOP_MAX_SPAN

def _apply_anti_loop(self, action: int, turn: float, intensity: float) -> typing.Tuple[int, float, float]:
if self._escape_steps_left > 0:
self._escape_steps_left -= 1
escape_turn = _clamp(turn, -1.0, 1.0) + (random.random() * 2.0 - 1.0) * 0.55
return self.actions["ACTION_MOVE"], _clamp(escape_turn, -1.0, 1.0), max(0.35, intensity)

if action != self.actions["ACTION_MOVE"]:
return action, turn, intensity

if self._looks_like_circle():
self._escape_steps_left = self.ESCAPE_STEPS
mean_turn, _ = self._mean_std(self._loop_turns)
flipped = -_clamp(mean_turn, -1.0, 1.0)
escape_turn = flipped + (random.random() * 2.0 - 1.0) * 0.35
return self.actions["ACTION_MOVE"], _clamp(escape_turn, -1.0, 1.0), max(0.45, intensity)

return action, turn, intensity

def sense(self, foods=None, agents=None):
"""Generate normalized sensory input vector."""
bx = float(self.bound_x) if self.bound_x > 0 else 1.0
Expand Down Expand Up @@ -189,7 +262,7 @@ def sense(self, foods=None, agents=None):
food_dx_n = _clamp(dx / max(1e-9, self.sight), -1.0, 1.0)
food_dy_n = _clamp(dy / max(1e-9, self.sight), -1.0, 1.0)
self._last_food = best
if d <= self.sight: # Adding boolean to deal with semantic discontinuity ( 1 could mean food is far away and 0,95 mean is really close)
if d <= self.sight: # Adding boolean to deal with discontinuity ( 1 could mean food is far away and 0,95 mean is really close)
food_in_sight = 1

friend_count_n = 0.0
Expand All @@ -202,7 +275,7 @@ def sense(self, foods=None, agents=None):
friends = 0
enemies = 0
best_friend_d2 = 1e18
best_friend = None
best_friend = None
best_enemy_d2 = 1e18
best_enemy = None

Expand Down Expand Up @@ -240,7 +313,7 @@ def sense(self, foods=None, agents=None):
else:
self._last_enemy = None


if best_friend is not None: # Adding this part to enable to agent getting information where are friends
dx, dy = torus_diff(self.x, self.y, best_friend[0], best_friend[1])
d = math.sqrt(best_friend_d2)
Expand Down Expand Up @@ -333,7 +406,7 @@ def _move(self, turn: float, intensity: float, speed_modifier: float = 1.0):
new_y = self.y - math.sin(rad) * sp
self._apply_bounds(new_x, new_y, new_angle)

cost = 0.02 + 0.06 * inten
cost = 0.01 + 0.05 * inten
self.energy = max(0.0, self.energy - cost)

def _idle(self):
Expand Down Expand Up @@ -372,14 +445,20 @@ def update(self, speed_modifier):
return

if self._inputs_override is None:
inputs = self.sense(self.environment.food_sources, self.environment.get_agents())
nearby_agents = self.environment.get_nearby_agents(self, self.sight * 2)
inputs = self.sense(self.environment.food_sources, nearby_agents)
else:
inputs = self._inputs_override

outputs = self.think(inputs)
action, turn, intensity = self.decide(outputs)

#breaks circles when turn stays nearly constant.
action, turn, intensity = self._apply_anti_loop(action, turn, intensity)
self.last_action = action

did_move = action == self.actions["ACTION_MOVE"]

if action == self.actions["ACTION_MOVE"]:
self.logger.debug(f"Agent - {self.uuid}; action - move")
self._move(turn, intensity)
Expand All @@ -396,6 +475,9 @@ def update(self, speed_modifier):
self.logger.debug(f"Agent - {self.uuid}; action - attack")
self._attack()

#to record motion samples after the action was applied.
self._record_motion_sample(applied_turn=turn, did_move=did_move)

self._tick_body()

def render(self, window: pygame.window, cell_size: int, offset: tuple):
Expand All @@ -418,7 +500,7 @@ def render(self, window: pygame.window, cell_size: int, offset: tuple):
rad = math.radians(self.angle - 135.0)
points.append((env_x + math.cos(rad) * r, env_y - math.sin(rad) * r))

color = (100, 200, 255) if self.group_id == 0 else (255, 255, 255)
color = (100, 200, 255) if self.group_id == 0 else (255, 255, 255)
pygame.draw.polygon(window, color, points)

bar_w = int(cell_size * 0.8)
Expand Down
Loading