diff --git a/creature.py b/creature.py index 016f697..efd5ec2 100644 --- a/creature.py +++ b/creature.py @@ -4,9 +4,23 @@ import numpy as np from config import Config from neural_network import NeuralNetwork +from functools import lru_cache +from typing import Optional, Tuple, Type, Union, List, Dict, Any +import weakref +from enum import Enum, auto + +class CreatureType(Enum): + HERBIVORE = auto() + CARNIVORE = auto() class Creature: - def __init__(self, x, y, direction=None, energy=None, brain=None): + __slots__ = ('x', 'y', 'direction', 'energy', 'age', 'brain', 'speed', + 'show_vision', '_cached_vision_cone', '_cached_direction', + '_last_energy_ratio', '_cached_color', 'stats') + + def __init__(self, x: float, y: float, direction: Optional[float]=None, + energy: Optional[float]=None, brain: Optional[NeuralNetwork]=None): + """Initialize a new creature with given parameters.""" self.x = x self.y = y @@ -14,118 +28,174 @@ def __init__(self, x, y, direction=None, energy=None, brain=None): self.direction = direction if direction is not None else random.uniform(0, 360) # Internal state - self.energy = energy + self.energy = energy # Will be set by subclass if None self.age = 0 - # Neural network setup - input_size = self.get_vision_resolution() + 1 # Vision + energy level - hidden_size = Config.HIDDEN_LAYER_SIZE - output_size = 2 # Speed and turn - - self.brain = brain if brain is not None else NeuralNetwork(input_size, hidden_size, output_size) + # Neural network will be initialized in subclasses + self.brain = brain # Movement properties self.speed = 0 # Vision property for rendering self.show_vision = Config.SHOW_VISION_CONES + + # Caching for better performance + self._cached_vision_cone = None + self._cached_direction = None + self._last_energy_ratio = -1 # Invalid value to ensure first update + self._cached_color = None + + # Statistics tracking + self.stats = { + 'distance_moved': 0.0, + 'energy_consumed': 0.0, + 'offspring': 0, + 'food_eaten': 0 + } - def __repr__(self): - return f"location: ({self.x}, {self.y}), direction: {self.direction}, energy: {self.energy}" + def __repr__(self) -> str: + """String representation of creature.""" + return f"{self.__class__.__name__}: pos=({self.x:.1f}, {self.y:.1f}), dir={self.direction:.1f}°, energy={self.energy:.1f}" # Vision property getters - def get_vision_resolution(self): + def get_vision_resolution(self) -> int: + """Get the number of vision rays.""" # Will be overridden by subclasses pass - def get_vision_range(self): + def get_vision_range(self) -> float: + """Get the distance a creature can see.""" # Will be overridden by subclasses pass - def get_vision_angle(self): + def get_vision_angle(self) -> float: + """Get the width of vision in degrees.""" # Will be overridden by subclasses pass # Movement and physical property getters - def get_max_speed(self): + def get_max_speed(self) -> float: + """Get the maximum speed the creature can move.""" # Will be overridden by subclasses pass - def get_max_turn(self): + def get_max_turn(self) -> float: + """Get the maximum turn rate in degrees.""" # Will be overridden by subclasses pass - def get_color(self): - # Will be overridden by subclasses + def get_color(self) -> Tuple[int, int, int]: + """Get the color representing the creature, with energy-based variation.""" + # Check if we need to recalculate color + energy_ratio = self.energy / self.get_max_energy() + if abs(energy_ratio - self._last_energy_ratio) > 0.05 or self._cached_color is None: + self._last_energy_ratio = energy_ratio + self._cached_color = self._calculate_color(energy_ratio) + return self._cached_color + + def _calculate_color(self, energy_ratio: float) -> Tuple[int, int, int]: + """Calculate color based on energy level - overridden by subclasses.""" pass - def get_size(self): + def get_size(self) -> int: + """Get the radius of the creature.""" # Will be overridden by subclasses pass - def get_energy_consumption(self): + def get_energy_consumption(self) -> float: + """Get the base energy consumption rate per tick.""" # Will be overridden by subclasses pass - def get_max_energy(self): + def get_max_energy(self) -> float: + """Get the maximum energy capacity.""" # Will be overridden by subclasses pass - def get_position(self): + def get_position(self) -> Tuple[float, float]: + """Get the current position of the creature.""" return (self.x, self.y) - def think(self, inputs): - # Process inputs through the neural network - outputs = self.brain.forward(inputs) - return outputs - - def act(self, outputs): + def think(self, inputs: List[float]) -> List[float]: + """Process inputs through the neural network.""" + return self.brain.forward(inputs) + + @staticmethod + @lru_cache(maxsize=360) + def _get_movement_vector(direction_degrees: int, speed: float) -> Tuple[float, float]: + """Calculate movement vector from direction and speed (cached for performance).""" + rad_direction = math.radians(direction_degrees) + return ( + math.cos(rad_direction) * speed, + math.sin(rad_direction) * speed + ) + + def act(self, outputs: List[float]) -> None: + """Execute actions based on neural network outputs.""" # Extract actions from neural network outputs - speed_output = outputs[0] # Between 0 and 1 - turn_output = outputs[1] # Between 0 and 1 + speed_output = min(max(outputs[0], 0.0), 1.0) # Clamp between 0 and 1 + turn_output = min(max(outputs[1], 0.0), 1.0) # Clamp between 0 and 1 # Convert to actual speed and turn values - self.speed = speed_output * self.get_max_speed() + max_speed = self.get_max_speed() + self.speed = speed_output * max_speed # Convert turn from [0,1] to [-max_turn, max_turn] - turn = (turn_output * 2 - 1) * self.get_max_turn() - self.direction += turn + max_turn = self.get_max_turn() + turn = (turn_output * 2 - 1) * max_turn - # Keep direction in [0, 360) - self.direction = self.direction % 360 + # Update direction efficiently, keeping it in [0, 360) + old_direction = self.direction + self.direction = (self.direction + turn) % 360 - # Move the creature - rad_direction = math.radians(self.direction) - self.x += math.cos(rad_direction) * self.speed - self.y += math.sin(rad_direction) * self.speed + # Only invalidate vision cone cache if direction changed significantly + if abs(old_direction - self.direction) > 1.0: + self._cached_vision_cone = None + self._cached_direction = self.direction - # Boundary checking - self.x = max(0, min(self.x, Config.SCREEN_WIDTH)) - self.y = max(0, min(self.y, Config.SCREEN_HEIGHT)) + # Move the creature using pre-calculated and cached vectors for common directions + # Round direction to nearest degree for cache lookup + direction_int = round(self.direction) + dx, dy = self._get_movement_vector(direction_int, self.speed) - # Energy consumption based on actions - energy_use = self.get_energy_consumption() * (0.5 + 0.5 * self.speed / self.get_max_speed()) - self.energy -= energy_use + # Store old position to calculate distance moved + old_x, old_y = self.x, self.y + + # Update position with boundary checking in one step + self.x = max(0, min(self.x + dx, Config.SCREEN_WIDTH)) + self.y = max(0, min(self.y + dy, Config.SCREEN_HEIGHT)) + + # Track distance moved for statistics + actual_dx = self.x - old_x + actual_dy = self.y - old_y + distance_moved = math.sqrt(actual_dx**2 + actual_dy**2) + self.stats['distance_moved'] += distance_moved + + # Energy consumption based on actions with variable cost depending on speed + # This creates a more realistic energy model where moving costs energy + base_consumption = self.get_energy_consumption() + speed_factor = 0.5 + 0.5 * (self.speed / max_speed) + energy_used = base_consumption * speed_factor + + self.energy -= energy_used + self.stats['energy_consumed'] += energy_used - def can_reproduce(self): - # Check if the creature has enough energy to reproduce + def can_reproduce(self) -> bool: + """Check if the creature has enough energy to reproduce.""" return False # Will be overridden by subclasses def reproduce(self): - # Create a new creature with mutated brain + """Create a new creature with mutated brain.""" return None # Will be overridden by subclasses - def render(self, surface): - # Draw the vision cone if enabled - if self.show_vision: - self.render_vision_cone(surface) + def _generate_vision_cone_points(self) -> List[Tuple[int, int]]: + """Generate the points for the vision cone.""" + half_angle = self.get_vision_angle() / 2 + vision_range = self.get_vision_range() + x, y = int(self.x), int(self.y) - # Draw the creature - pygame.draw.circle(surface, self.get_color(), (int(self.x), int(self.y)), self.get_size()) - - def render_vision_cone(self, surface): # Calculate the starting and ending angles of the vision cone - half_angle = self.get_vision_angle() / 2 start_angle = self.direction - half_angle end_angle = self.direction + half_angle @@ -133,167 +203,305 @@ def render_vision_cone(self, surface): start_rad = math.radians(start_angle) end_rad = math.radians(end_angle) - # Calculate the points for vision cone edges - start_x = self.x + math.cos(start_rad) * self.get_vision_range() - start_y = self.y + math.sin(start_rad) * self.get_vision_range() + # Create points for the vision cone (center + arc points) + points = [(x, y)] - end_x = self.x + math.cos(end_rad) * self.get_vision_range() - end_y = self.y + math.sin(end_rad) * self.get_vision_range() + # Add arc points efficiently + steps = 10 # Number of points along arc + step_angle = (end_rad - start_rad) / steps - # Get cone color based on creature type (with transparency) - if isinstance(self, Herbivore): - cone_color = (0, 255, 0, Config.VISION_CONE_OPACITY) # Semi-transparent green - else: # Carnivore - cone_color = (255, 0, 0, Config.VISION_CONE_OPACITY) # Semi-transparent red + # Generate all points at once + for i in range(steps + 1): + angle = start_rad + step_angle * i + arc_x = x + int(math.cos(angle) * vision_range) + arc_y = y + int(math.sin(angle) * vision_range) + points.append((arc_x, arc_y)) - # Create a semi-transparent surface for the cone - cone_surface = pygame.Surface((Config.SCREEN_WIDTH, Config.SCREEN_HEIGHT), pygame.SRCALPHA) + return points + + def render(self, surface: pygame.Surface) -> None: + """Render the creature and its vision cone if enabled.""" + # Draw the vision cone if enabled + if self.show_vision: + self.render_vision_cone(surface) - # Draw the vision cone with filled polygon - points = [(int(self.x), int(self.y)), - (int(start_x), int(start_y))] + # Draw the creature + pygame.draw.circle(surface, self.get_color(), (int(self.x), int(self.y)), self.get_size()) + + def render_vision_cone(self, surface: pygame.Surface) -> None: + """Render the creature's vision cone with optimized rendering.""" + # Check if we can use cached vision cone + if (self._cached_vision_cone is not None and + abs(self._cached_direction - self.direction) < 1.0): + # Use cached vision cone + cone_surface, pos = self._cached_vision_cone + surface.blit(cone_surface, pos) + return + + # Generate vision cone points + points = self._generate_vision_cone_points() - # Add arc points to create a smooth cone - steps = 10 - for i in range(steps + 1): - angle = start_rad + (end_rad - start_rad) * i / steps - x = self.x + math.cos(angle) * self.get_vision_range() - y = self.y + math.sin(angle) * self.get_vision_range() - points.append((int(x), int(y))) + # Calculate bounding box for efficient surface size + min_x = min(p[0] for p in points) + min_y = min(p[1] for p in points) + max_x = max(p[0] for p in points) + max_y = max(p[1] for p in points) + + # Add padding + padding = 5 + min_x = max(0, min_x - padding) + min_y = max(0, min_y - padding) + max_x = min(Config.SCREEN_WIDTH, max_x + padding) + max_y = min(Config.SCREEN_HEIGHT, max_y + padding) + + # Create surface sized to bounding box + width = max_x - min_x + height = max_y - min_y + if width <= 0 or height <= 0: + return - points.append((int(end_x), int(end_y))) + # Determine cone color based on creature type + is_herbivore = isinstance(self, Herbivore) + cone_color = (0, 255, 0, Config.VISION_CONE_OPACITY) if is_herbivore else (255, 0, 0, Config.VISION_CONE_OPACITY) + outline_color = (cone_color[0], cone_color[1], cone_color[2], 100) + + cone_surface = pygame.Surface((width, height), pygame.SRCALPHA) + + # Adjust points for the local surface + local_points = [(p[0] - min_x, p[1] - min_y) for p in points] # Draw the polygon on the transparent surface - pygame.draw.polygon(cone_surface, cone_color, points) + pygame.draw.polygon(cone_surface, cone_color, local_points) + + # Draw the outline with optimized approach (fewer line segments) + pygame.draw.lines(cone_surface, outline_color, False, local_points, 1) - # Draw the outline of the vision cone - for i in range(len(points) - 1): - pygame.draw.line(cone_surface, (cone_color[0], cone_color[1], cone_color[2], 100), - points[i], points[i+1], 1) + # Cache the vision cone + self._cached_vision_cone = (cone_surface, (min_x, min_y)) + self._cached_direction = self.direction # Blit the transparent surface onto the main surface - surface.blit(cone_surface, (0, 0)) + surface.blit(cone_surface, (min_x, min_y)) + class Herbivore(Creature): - def __init__(self, x, y, direction=None, energy=None, brain=None): - energy = Config.HERBIVORE_INITIAL_ENERGY if energy is None else energy + __slots__ = ('_food_detection_mask', 'generation') + + # Class-level energy configuration cache for performance + _energy_config = None + + def __init__(self, x: float, y: float, direction: Optional[float]=None, + energy: Optional[float]=None, brain: Optional[NeuralNetwork]=None, + generation: int=1): + """Initialize a herbivore with specific properties.""" + # Cache energy configuration if not already cached + if Herbivore._energy_config is None: + Herbivore._energy_config = { + 'initial': Config.HERBIVORE_INITIAL_ENERGY, + 'reproduction': Config.HERBIVORE_REPRODUCTION_THRESHOLD, + 'max': Config.HERBIVORE_MAX_ENERGY, + 'consumption': Config.HERBIVORE_ENERGY_CONSUMPTION + } + + energy = Herbivore._energy_config['initial'] if energy is None else energy super().__init__(x, y, direction, energy, brain) + + # Initialize brain if not provided + if self.brain is None: + input_size = self.get_vision_resolution() + 1 # Vision + energy level + hidden_size = Config.HIDDEN_LAYER_SIZE + output_size = 2 # Speed and turn + self.brain = NeuralNetwork(input_size, hidden_size, output_size) + + # Specialized properties for herbivores + self._food_detection_mask = None # For efficient food detection + self.generation = generation - def get_vision_resolution(self): + def get_vision_resolution(self) -> int: return Config.HERBIVORE_VISION_RESOLUTION - def get_vision_range(self): + def get_vision_range(self) -> float: return Config.HERBIVORE_VISION_RANGE - def get_vision_angle(self): + def get_vision_angle(self) -> float: return Config.HERBIVORE_VISION_ANGLE - def get_max_speed(self): + def get_max_speed(self) -> float: return Config.HERBIVORE_MAX_SPEED - def get_max_turn(self): + def get_max_turn(self) -> float: return Config.HERBIVORE_MAX_TURN - def get_color(self): - # Adjust color based on energy level - energy_ratio = self.energy / Config.HERBIVORE_MAX_ENERGY - green = min(255, int(Config.HERBIVORE_COLOR[1] * energy_ratio * 1.5)) - return (0, green, 0) + def _calculate_color(self, energy_ratio: float) -> Tuple[int, int, int]: + # More nuanced color based on energy and generation + gen_factor = min(1.0, self.generation / 10) # Darker for later generations + base_green = Config.HERBIVORE_COLOR[1] + green = min(255, int(base_green * energy_ratio * 1.5)) + blue = min(100, int(50 * gen_factor)) # Add blue tint for later generations + return (0, green, blue) - def get_size(self): + def get_size(self) -> int: return Config.HERBIVORE_SIZE - def get_energy_consumption(self): - return Config.HERBIVORE_ENERGY_CONSUMPTION - - def get_max_energy(self): - return Config.HERBIVORE_MAX_ENERGY + def get_energy_consumption(self) -> float: + return Herbivore._energy_config['consumption'] - def can_reproduce(self): - return (self.energy > Config.HERBIVORE_REPRODUCTION_THRESHOLD and - random.random() < Config.HERBIVORE_REPRODUCTION_CHANCE) + def get_max_energy(self) -> float: + return Herbivore._energy_config['max'] - def reproduce(self): + def can_reproduce(self) -> bool: + # Enhanced reproduction logic with energy check and random chance + reproduction_energy = Herbivore._energy_config['reproduction'] + + if self.energy > reproduction_energy: + # Higher energy levels increase reproduction chance + energy_factor = min(1.0, (self.energy - reproduction_energy) / + (Herbivore._energy_config['max'] - reproduction_energy)) + chance_modifier = 0.5 + 0.5 * energy_factor + return random.random() < Config.HERBIVORE_REPRODUCTION_CHANCE * chance_modifier + return False + + def reproduce(self) -> Optional['Herbivore']: + """Create a new herbivore with mutated traits.""" if not self.can_reproduce(): return None # Create offspring with mutation mutated_brain = self.brain.mutate() - # Calculate position nearby parent + # Calculate position nearby parent using efficient placement angle = random.uniform(0, 360) distance = self.get_size() * 3 - new_x = self.x + math.cos(math.radians(angle)) * distance - new_y = self.y + math.sin(math.radians(angle)) * distance + angle_rad = math.radians(angle) - # Ensure offspring is within boundaries - new_x = max(0, min(new_x, Config.SCREEN_WIDTH)) - new_y = max(0, min(new_y, Config.SCREEN_HEIGHT)) + # Calculate new position with boundary checking + new_x = min(max(0, self.x + math.cos(angle_rad) * distance), Config.SCREEN_WIDTH) + new_y = min(max(0, self.y + math.sin(angle_rad) * distance), Config.SCREEN_HEIGHT) # Split energy between parent and offspring offspring_energy = self.energy / 2 self.energy = self.energy / 2 - return Herbivore(new_x, new_y, random.uniform(0, 360), offspring_energy, mutated_brain) + # Update stats + self.stats['offspring'] += 1 + + # Increment generation for offspring + new_generation = self.generation + 1 + + return Herbivore(new_x, new_y, random.uniform(0, 360), offspring_energy, mutated_brain, new_generation) + class Carnivore(Creature): - def __init__(self, x, y, direction=None, energy=None, brain=None): - energy = Config.CARNIVORE_INITIAL_ENERGY if energy is None else energy + __slots__ = ('hunt_cooldown', 'kill_count', 'generation') + + # Class-level energy configuration cache for performance + _energy_config = None + + def __init__(self, x: float, y: float, direction: Optional[float]=None, + energy: Optional[float]=None, brain: Optional[NeuralNetwork]=None, + generation: int=1): + """Initialize a carnivore with specific properties.""" + # Cache energy configuration if not already cached + if Carnivore._energy_config is None: + Carnivore._energy_config = { + 'initial': Config.CARNIVORE_INITIAL_ENERGY, + 'reproduction': Config.CARNIVORE_REPRODUCTION_THRESHOLD, + 'max': Config.CARNIVORE_MAX_ENERGY, + 'consumption': Config.CARNIVORE_ENERGY_CONSUMPTION + } + + energy = Carnivore._energy_config['initial'] if energy is None else energy super().__init__(x, y, direction, energy, brain) + + # Initialize brain if not provided + if self.brain is None: + input_size = self.get_vision_resolution() + 1 # Vision + energy level + hidden_size = Config.HIDDEN_LAYER_SIZE + output_size = 2 # Speed and turn + self.brain = NeuralNetwork(input_size, hidden_size, output_size) + + # Specialized properties for carnivores + self.hunt_cooldown = 0 + self.kill_count = 0 + self.generation = generation - def get_vision_resolution(self): + def get_vision_resolution(self) -> int: return Config.CARNIVORE_VISION_RESOLUTION - def get_vision_range(self): + def get_vision_range(self) -> float: return Config.CARNIVORE_VISION_RANGE - def get_vision_angle(self): + def get_vision_angle(self) -> float: return Config.CARNIVORE_VISION_ANGLE - def get_max_speed(self): + def get_max_speed(self) -> float: return Config.CARNIVORE_MAX_SPEED - def get_max_turn(self): + def get_max_turn(self) -> float: return Config.CARNIVORE_MAX_TURN - def get_color(self): - # Adjust color based on energy level - energy_ratio = self.energy / Config.CARNIVORE_MAX_ENERGY + def _calculate_color(self, energy_ratio: float) -> Tuple[int, int, int]: + # More nuanced color based on energy, kills, and generation + kill_factor = min(1.0, self.kill_count / 10) # More kills = more purple tint + gen_factor = min(1.0, self.generation / 10) # Later generations get darker + red = min(255, int(Config.CARNIVORE_COLOR[0] * energy_ratio * 1.5)) - return (red, 0, 0) + green = 0 + blue = min(100, int(80 * kill_factor)) # Purple tint for successful hunters + + # Darken with generations + if gen_factor > 0.5: + red = int(red * (1.5 - gen_factor)) + + return (red, green, blue) - def get_size(self): + def get_size(self) -> int: return Config.CARNIVORE_SIZE - def get_energy_consumption(self): - return Config.CARNIVORE_ENERGY_CONSUMPTION - - def get_max_energy(self): - return Config.CARNIVORE_MAX_ENERGY + def get_energy_consumption(self) -> float: + return Carnivore._energy_config['consumption'] - def can_reproduce(self): - return (self.energy > Config.CARNIVORE_REPRODUCTION_THRESHOLD and - random.random() < Config.CARNIVORE_REPRODUCTION_CHANCE) + def get_max_energy(self) -> float: + return Carnivore._energy_config['max'] - def reproduce(self): + def can_reproduce(self) -> bool: + # Enhanced reproduction logic with energy check and random chance + reproduction_energy = Carnivore._energy_config['reproduction'] + + if self.energy > reproduction_energy: + # Higher energy levels and kill count increase reproduction chance + energy_factor = min(1.0, (self.energy - reproduction_energy) / + (Carnivore._energy_config['max'] - reproduction_energy)) + kill_bonus = min(0.5, self.kill_count * 0.05) # Successful hunters more likely to reproduce + chance_modifier = 0.5 + 0.5 * energy_factor + kill_bonus + return random.random() < Config.CARNIVORE_REPRODUCTION_CHANCE * chance_modifier + return False + + def reproduce(self) -> Optional['Carnivore']: + """Create a new carnivore with mutated traits.""" if not self.can_reproduce(): return None # Create offspring with mutation mutated_brain = self.brain.mutate() - # Calculate position nearby parent + # Calculate position nearby parent using efficient placement angle = random.uniform(0, 360) distance = self.get_size() * 3 - new_x = self.x + math.cos(math.radians(angle)) * distance - new_y = self.y + math.sin(math.radians(angle)) * distance + angle_rad = math.radians(angle) - # Ensure offspring is within boundaries - new_x = max(0, min(new_x, Config.SCREEN_WIDTH)) - new_y = max(0, min(new_y, Config.SCREEN_HEIGHT)) + # Calculate new position with boundary checking + new_x = min(max(0, self.x + math.cos(angle_rad) * distance), Config.SCREEN_WIDTH) + new_y = min(max(0, self.y + math.sin(angle_rad) * distance), Config.SCREEN_HEIGHT) # Split energy between parent and offspring offspring_energy = self.energy / 2 self.energy = self.energy / 2 - return Carnivore(new_x, new_y, random.uniform(0, 360), offspring_energy, mutated_brain) + # Update stats + self.stats['offspring'] += 1 + + # Increment generation for offspring + new_generation = self.generation + 1 + + return Carnivore(new_x, new_y, random.uniform(0, 360), offspring_energy, mutated_brain, new_generation) \ No newline at end of file diff --git a/neural_network.py b/neural_network.py index adca98a..1606cb3 100644 --- a/neural_network.py +++ b/neural_network.py @@ -3,56 +3,136 @@ from config import Config class NeuralNetwork: + """ + A simple feed-forward neural network implementation with one hidden layer. + + This class provides functionality for creating, using, and evolving a basic neural network + with configurable input, hidden, and output layer sizes. + + Attributes: + input_size (int): Number of input neurons + hidden_size (int): Number of neurons in the hidden layer + output_size (int): Number of output neurons + weights_input_hidden (numpy.ndarray): Weight matrix connecting input to hidden layer + weights_hidden_output (numpy.ndarray): Weight matrix connecting hidden to output layer + """ + def __init__(self, input_size, hidden_size, output_size, weights=None): + """ + Initialize a neural network with specified architecture. + + Args: + input_size (int): Number of input neurons + hidden_size (int): Number of neurons in the hidden layer + output_size (int): Number of output neurons + weights (list, optional): Pre-defined weights as [input_hidden_weights, hidden_output_weights]. + If None, random weights will be generated. + """ self.input_size = input_size self.hidden_size = hidden_size self.output_size = output_size if weights is None: # Initialize weights with random values between -1 and 1 + # Shape: (hidden_size, input_size) for input->hidden connections self.weights_input_hidden = np.random.uniform(-1, 1, (hidden_size, input_size)) + # Shape: (output_size, hidden_size) for hidden->output connections self.weights_hidden_output = np.random.uniform(-1, 1, (output_size, hidden_size)) else: + # Use provided weights self.weights_input_hidden = weights[0] self.weights_hidden_output = weights[1] def forward(self, inputs): - # Convert inputs to numpy array + """ + Perform forward propagation through the neural network. + + Takes input values, processes them through the hidden layer with sigmoid activation, + then through the output layer with sigmoid activation. + + Args: + inputs (list or numpy.ndarray): Input values for the neural network + + Returns: + numpy.ndarray: Output values after forward propagation + """ + # Convert inputs to numpy array for matrix operations inputs = np.array(inputs) - # Calculate hidden layer activations + # Calculate hidden layer activations: + # 1. Matrix multiplication of weights and inputs + # 2. Apply sigmoid activation function hidden_inputs = np.dot(self.weights_input_hidden, inputs) hidden_outputs = self.sigmoid(hidden_inputs) - # Calculate output layer activations + # Calculate output layer activations: + # 1. Matrix multiplication of weights and hidden layer outputs + # 2. Apply sigmoid activation function final_inputs = np.dot(self.weights_hidden_output, hidden_outputs) final_outputs = self.sigmoid(final_inputs) return final_outputs def sigmoid(self, x): + """ + Apply the sigmoid activation function element-wise. + + The sigmoid function transforms values to the range (0, 1) with an S-shaped curve, + providing non-linear activation capabilities. + + Args: + x (float or numpy.ndarray): Input value(s) to the sigmoid function + + Returns: + float or numpy.ndarray: Transformed value(s) between 0 and 1 + """ return 1 / (1 + np.exp(-x)) def get_weights(self): + """ + Get the current weights of the neural network. + + Returns: + list: A list containing two weight matrices: + [weights_input_hidden, weights_hidden_output] + """ return [self.weights_input_hidden, self.weights_hidden_output] def mutate(self, mutation_rate=None, mutation_range=None): - """Create a mutated copy of the neural network""" - # Use default parameters if not specified + """ + Create a mutated copy of the neural network. + + This method creates a new neural network with slightly modified weights + based on the mutation parameters. Only a subset of weights (determined by + mutation_rate) will be modified by random values within the mutation_range. + + Args: + mutation_rate (float, optional): Probability (0-1) of each weight being mutated. + Defaults to value from Config. + mutation_range (float, optional): Maximum amount by which a weight can be adjusted. + Defaults to value from Config. + + Returns: + NeuralNetwork: A new neural network instance with mutated weights + """ + # Use default parameters from Config if not specified if mutation_rate is None: mutation_rate = Config.MUTATION_RATE if mutation_range is None: mutation_range = Config.MUTATION_RANGE - # Create a copy of current weights + # Create a copy of current weights to avoid modifying the original network mutated_weights = [np.copy(self.weights_input_hidden), np.copy(self.weights_hidden_output)] - # Mutate weights with probability mutation_rate + # For each weight matrix: + # 1. Create a boolean mask where True means "mutate this weight" + # 2. Generate random mutation values within the specified range + # 3. Apply mutations only to the selected weights (where mask is True) for weight_matrix in mutated_weights: mask = np.random.random(weight_matrix.shape) < mutation_rate mutations = np.random.uniform(-mutation_range, mutation_range, weight_matrix.shape) weight_matrix[mask] += mutations[mask] # Return a new neural network with mutated weights - return NeuralNetwork(self.input_size, self.hidden_size, self.output_size, mutated_weights) + return NeuralNetwork(self.input_size, self.hidden_size, self.output_size, mutated_weights) \ No newline at end of file diff --git a/simulation.py b/simulation.py index 7c8234e..bdb0c75 100644 --- a/simulation.py +++ b/simulation.py @@ -21,6 +21,10 @@ def __init__(self, grass_count=Config.MAX_GRASS//2, herbivore_count=Config.INITI self.grid = [[False for _ in range(self.grid_height)] for _ in range(self.grid_width)] self.grass_positions = set() # Fast lookup for existing grass positions + # Spatial partitioning grids for collision detection + self.grass_spatial_grid = {} + self.creature_spatial_grid = {} + # Initialize grass self.initialize_grass(grass_count) @@ -37,6 +41,57 @@ def __init__(self, grass_count=Config.MAX_GRASS//2, herbivore_count=Config.INITI self.carnivore_count_history = [] self.grass_count_history = [] + def get_grid_cell(self, x, y): + """Convert world coordinates to grid cell coordinates""" + grid_x = max(0, min(int(x / self.grid_size), self.grid_width - 1)) + grid_y = max(0, min(int(y / self.grid_size), self.grid_height - 1)) + return (grid_x, grid_y) + + def update_spatial_grids(self): + """Update the spatial partitioning grids with current entity positions""" + # Clear previous grids + self.grass_spatial_grid = {} + self.creature_spatial_grid = {} + + # Add grass to spatial grid + for grass in self.grass: + grid_pos = self.get_grid_cell(grass.x, grass.y) + if grid_pos not in self.grass_spatial_grid: + self.grass_spatial_grid[grid_pos] = [] + self.grass_spatial_grid[grid_pos].append(grass) + + # Add creatures to spatial grid + for creature in self.creatures: + grid_pos = self.get_grid_cell(creature.x, creature.y) + if grid_pos not in self.creature_spatial_grid: + self.creature_spatial_grid[grid_pos] = [] + self.creature_spatial_grid[grid_pos].append(creature) + + def get_nearby_entities(self, entity, is_grass=False): + """Get entities in the same and adjacent grid cells""" + grid_pos = self.get_grid_cell(entity.x, entity.y) + nearby = [] + + # Check current cell and all adjacent cells (including diagonals) + for dx in [-1, 0, 1]: + for dy in [-1, 0, 1]: + check_pos = (grid_pos[0] + dx, grid_pos[1] + dy) + + # Skip out-of-bounds cells + if (check_pos[0] < 0 or check_pos[0] >= self.grid_width or + check_pos[1] < 0 or check_pos[1] >= self.grid_height): + continue + + # Get entities from appropriate grid + if is_grass: + if check_pos in self.grass_spatial_grid: + nearby.extend(self.grass_spatial_grid[check_pos]) + else: + if check_pos in self.creature_spatial_grid: + nearby.extend(self.creature_spatial_grid[check_pos]) + + return nearby + def initialize_grass(self, grass_count): """Create initial grass""" initial_count = min(grass_count, Config.MAX_GRASS) @@ -69,8 +124,14 @@ def add_grass(self): pos_key = (x // self.grid_size, y // self.grid_size) if pos_key not in self.grass_positions: # Add the new grass - self.grass.append(Grass(x, y)) + new_grass = Grass(x, y) + self.grass.append(new_grass) self.grass_positions.add(pos_key) + + # Add to spatial grid + if pos_key not in self.grass_spatial_grid: + self.grass_spatial_grid[pos_key] = [] + self.grass_spatial_grid[pos_key].append(new_grass) return True attempts += 1 @@ -84,7 +145,15 @@ def remove_grass(self, grass): pos_key = (grass.x // self.grid_size, grass.y // self.grid_size) if pos_key in self.grass_positions: self.grass_positions.remove(pos_key) + + # Remove from grass list self.grass.remove(grass) + + # Remove from spatial grid + if pos_key in self.grass_spatial_grid and grass in self.grass_spatial_grid[pos_key]: + self.grass_spatial_grid[pos_key].remove(grass) + if not self.grass_spatial_grid[pos_key]: # If no more grass in this cell + del self.grass_spatial_grid[pos_key] def calculate_adaptive_growth_rate(self): """Calculate growth rate based on current grass count""" @@ -155,6 +224,11 @@ def initialize_creatures(self, herbivore_count, carnivore_count): y = random.randint(50, Config.SCREEN_HEIGHT - 50) herbivore = Herbivore(x, y) self.creatures.append(herbivore) + # Add to spatial grid + grid_pos = self.get_grid_cell(x, y) + if grid_pos not in self.creature_spatial_grid: + self.creature_spatial_grid[grid_pos] = [] + self.creature_spatial_grid[grid_pos].append(herbivore) # Create initial carnivores for _ in range(carnivore_count): @@ -162,6 +236,11 @@ def initialize_creatures(self, herbivore_count, carnivore_count): y = random.randint(50, Config.SCREEN_HEIGHT - 50) carnivore = Carnivore(x, y) self.creatures.append(carnivore) + # Add to spatial grid + grid_pos = self.get_grid_cell(x, y) + if grid_pos not in self.creature_spatial_grid: + self.creature_spatial_grid[grid_pos] = [] + self.creature_spatial_grid[grid_pos].append(carnivore) def update(self): # Increment simulation tick @@ -170,6 +249,9 @@ def update(self): # Update grass self.update_grass() + # Update spatial grids with current positions + self.update_spatial_grids() + # Process creature sensing, thinking, and acting for creature in self.creatures: # Sense the environment @@ -223,18 +305,20 @@ def update_creature(self, creature): if creature.energy <= 0: return False # Dead - # Handle feeding behavior based on creature type + # Handle feeding behavior based on creature type using spatial partitioning if isinstance(creature, Herbivore): - # Check for grass consumption - for grass in self.grass[:]: # Create a copy of the list for safe iteration + # Check for grass consumption using nearby grass only + nearby_grass = self.get_nearby_entities(creature, is_grass=True) + for grass in nearby_grass: distance = math.sqrt((creature.x - grass.x)**2 + (creature.y - grass.y)**2) if distance < creature.get_size() + grass.size: creature.energy = min(creature.energy + grass.energy, creature.get_max_energy()) self.remove_grass(grass) elif isinstance(creature, Carnivore): - # Check for herbivore hunting - for target in self.creatures[:]: # Create a copy of the list for safe iteration + # Check for herbivore hunting using nearby creatures only + nearby_creatures = self.get_nearby_entities(creature, is_grass=False) + for target in nearby_creatures: if isinstance(target, Herbivore): distance = math.sqrt((creature.x - target.x)**2 + (creature.y - target.y)**2) if distance < creature.get_size() + target.get_size(): @@ -289,43 +373,57 @@ def cast_ray_for_creature(self, creature, angle_rad): dx = math.cos(angle_rad) dy = math.sin(angle_rad) - # Cast the ray to check for objects - for distance in range(1, creature.get_vision_range(), 5): # Step by 5 for efficiency + # Precompute grid cells that the ray passes through + max_distance = creature.get_vision_range() + ray_points = [] + for distance in range(0, max_distance, 5): # Step by 5 for efficiency check_x = creature.x + dx * distance check_y = creature.y + dy * distance - # Check boundaries - if check_x < 0 or check_x >= Config.SCREEN_WIDTH or check_y < 0 or check_y >= Config.SCREEN_HEIGHT: + # Stop if out of bounds + if (check_x < 0 or check_x >= Config.SCREEN_WIDTH or + check_y < 0 or check_y >= Config.SCREEN_HEIGHT): detection = 0.5 # Edge of screen break + + # Add point to ray path + ray_points.append((check_x, check_y)) - # Check creatures first - they have priority in vision - for other in self.creatures: - if other != creature: # Don't detect self - dist = math.sqrt((other.x - check_x)**2 + (other.y - check_y)**2) - if dist < other.get_size(): - if isinstance(creature, Herbivore): - if isinstance(other, Carnivore): - detection = -1 # Carnivore: danger - else: - detection = 0.2 # Other herbivores: neutral - else: # I'm a carnivore - if isinstance(other, Herbivore): - detection = 1 # Herbivore: food - else: - detection = 0.3 # Other carnivores: neutral/competition - break - - # Check grass - only for herbivores - if detection == 0 and isinstance(creature, Herbivore): - for grass in self.grass: - dist = math.sqrt((grass.x - check_x)**2 + (grass.y - check_y)**2) + # Check grid cells along ray path + visited_cells = set() + for point_x, point_y in ray_points: + grid_cell = self.get_grid_cell(point_x, point_y) + + # Skip already checked cells + if grid_cell in visited_cells: + continue + visited_cells.add(grid_cell) + + # Check for creatures in this cell + if grid_cell in self.creature_spatial_grid: + for other in self.creature_spatial_grid[grid_cell]: + if other != creature: # Don't detect self + dist = math.sqrt((other.x - point_x)**2 + (other.y - point_y)**2) + if dist < other.get_size(): + if isinstance(creature, Herbivore): + if isinstance(other, Carnivore): + detection = -1 # Carnivore: danger + else: + detection = 0.2 # Other herbivores: neutral + else: # I'm a carnivore + if isinstance(other, Herbivore): + detection = 1 # Herbivore: food + else: + detection = 0.3 # Other carnivores: neutral/competition + return detection + + # For herbivores, check for grass in this cell + if isinstance(creature, Herbivore) and grid_cell in self.grass_spatial_grid: + for grass in self.grass_spatial_grid[grid_cell]: + dist = math.sqrt((grass.x - point_x)**2 + (grass.y - point_y)**2) if dist < grass.size: detection = 0.8 # Grass: food for herbivores - break - - if detection != 0: # If something was detected, stop the ray - break + return detection return detection @@ -384,6 +482,12 @@ def spawn_new_herbivore(self): print(f"Spawned new random herbivore (initial energy: {initial_energy})") self.creatures.append(new_herbivore) + + # Add to spatial grid + grid_pos = self.get_grid_cell(new_herbivore.x, new_herbivore.y) + if grid_pos not in self.creature_spatial_grid: + self.creature_spatial_grid[grid_pos] = [] + self.creature_spatial_grid[grid_pos].append(new_herbivore) def spawn_new_carnivore(self): """Spawn a new carnivore by cloning the best carnivore or creating a random one""" @@ -423,6 +527,12 @@ def spawn_new_carnivore(self): print(f"Spawned new random carnivore (initial energy: {initial_energy})") self.creatures.append(new_carnivore) + + # Add to spatial grid + grid_pos = self.get_grid_cell(new_carnivore.x, new_carnivore.y) + if grid_pos not in self.creature_spatial_grid: + self.creature_spatial_grid[grid_pos] = [] + self.creature_spatial_grid[grid_pos].append(new_carnivore) def render(self, surface): # Render grass @@ -547,8 +657,11 @@ def load_simulation(cls, filename='simulation_save.pkl'): simulation.carnivore_count_history = save_data['carnivore_count_history'] simulation.grass_count_history = save_data['grass_count_history'] + # Rebuild spatial grids after loading + simulation.update_spatial_grids() + print(f"Simulation loaded from {filename}") return simulation except Exception as e: print(f"Error loading simulation: {e}") - return None + return None \ No newline at end of file