Source code for gale.ai.perception

"""
This file contains a perception/vision-cone system for stealth-style
games: a guard's cone of vision, split into a near zone (instant
detection) and a far zone (slower, partial detection), together with a
simple line-of-sight check against rectangular obstacles and a
Perception class that turns sightings into an alert level posted on a
Blackboard for a behavior tree to react to.

Author: Alejandro Mujica (aledrums@gmail.com)
"""

import math

from enum import Enum
from typing import Any, Optional, Sequence, Union

import pygame

from .blackboard import Blackboard


[docs] def has_line_of_sight( origin: pygame.Vector2, target: pygame.Vector2, obstacles: Optional[Sequence[pygame.Rect]] = None, ) -> bool: """ Check whether the straight segment from origin to target is not blocked by any of the given obstacles. :param origin: Point the sight line starts from. :param target: Point the sight line is aimed at. :param obstacles: Axis-aligned rectangles that block vision. The default value is None, meaning nothing blocks vision. :returns: Whether target is visible from origin, i.e. the segment does not cross any obstacle. """ if not obstacles: return True start = (int(origin.x), int(origin.y)) end = (int(target.x), int(target.y)) for obstacle in obstacles: if obstacle.clipline(start, end): return False return True
[docs] class VisionCone: """ Models a guard's field of view as a cone: an origin, a facing direction (orientation, in radians), and a half_angle spreading on both sides of that direction. Detection is split into two zones: range_near, within which a visible target is spotted right away, and range_far, up to which a visible target is only partially noticed (see awareness_gain), building up over time instead of being instant. The cone's pose can be given either as a plain (position, orientation) pair or as an object exposing .position/.orientation (such as a Kinematic), and it is re-read on every call so the cone always follows its owner as it moves and turns. Usage example: guard = Kinematic(x=100, y=100, orientation=0) cone = VisionCone(guard, range_near=80, range_far=250, half_angle=math.radians(30)) if cone.can_see_point(player.position, obstacles=walls): ... """ def __init__( self, origin: Union[Any, pygame.Vector2], range_near: float = 100, range_far: float = 300, half_angle: float = math.pi / 6, orientation: float = 0, ) -> None: """ :param origin: A Kinematic-like object exposing .position/.orientation, or a plain pygame.Vector2 position (in which case orientation is taken from the orientation parameter). :param range_near: Distance within which a visible target is detected instantly. :param range_far: Distance up to which a visible target can be partially detected. Beyond it nothing is seen. :param half_angle: Half of the total field of view, in radians, measured from the facing direction. :param orientation: Facing direction, in radians, used only when origin is a plain position instead of a Kinematic-like object. """ self.origin: Any = origin self.range_near: float = range_near self.range_far: float = range_far self.half_angle: float = half_angle self._fixed_orientation: float = orientation def _pose(self) -> "tuple[pygame.Vector2, float]": if hasattr(self.origin, "position") and hasattr(self.origin, "orientation"): return pygame.Vector2(self.origin.position), self.origin.orientation return pygame.Vector2(self.origin), self._fixed_orientation
[docs] def can_see_point( self, point: pygame.Vector2, obstacles: Optional[Sequence[pygame.Rect]] = None, ) -> bool: """ :param point: The point to test. :param obstacles: Axis-aligned rectangles that block vision. The default value is None, meaning nothing blocks vision. :returns: Whether point lies within range_far, within half_angle of the facing direction, and (if obstacles are given) has a clear line of sight. """ position, orientation = self._pose() offset = pygame.Vector2(point) - position distance = offset.length() if distance == 0: return True if distance > self.range_far: return False facing = pygame.Vector2(math.cos(orientation), math.sin(orientation)) cos_angle = max(-1.0, min(1.0, facing.dot(offset) / distance)) angle = math.acos(cos_angle) if angle > self.half_angle: return False return has_line_of_sight(position, pygame.Vector2(point), obstacles)
[docs] def awareness_gain( self, point: pygame.Vector2, dt: float, obstacles: Optional[Sequence[pygame.Rect]] = None, ) -> float: """ Compute how much awareness should build up this tick for a target at point: full speed (dt per second, i.e. 1.0 * dt) within range_near, a fraction of that scaled by how far into the near..far band the target is when beyond range_near but still within range_far, and zero if point is not visible at all. :param point: Position of the potential target. :param dt: Time elapsed (in seconds) since the last update. :param obstacles: Axis-aligned rectangles that block vision. The default value is None, meaning nothing blocks vision. :returns: The amount of awareness (in the same 0..1 scale used by Perception) to accumulate for this tick. """ if not self.can_see_point(point, obstacles): return 0.0 position, _ = self._pose() distance = (pygame.Vector2(point) - position).length() if distance <= self.range_near: return dt far_band = self.range_far - self.range_near if far_band <= 0: return 0.0 fraction = 1 - (distance - self.range_near) / far_band return dt * fraction
[docs] class AlertLevel(Enum): """ How aware a guard currently is of a target. """ UNAWARE = "unaware" SUSPICIOUS = "suspicious" ALERTED = "alerted"
[docs] class Perception: """ Layers an alert-level state machine on top of one or more VisionCones, turning raw sightings into a 0..1 "awareness" value for a single tracked target (a stealth guard tracking the player being the primary use case) and posting the resulting AlertLevel, together with the last known target position, onto a Blackboard so a behavior tree's Condition/Action nodes can react to it without knowing anything about vision cones. Blackboard keys written by update(): - "alert_level": the current AlertLevel. - "last_known_target_position": the target's position the last time it was seen (kept while SUSPICIOUS or ALERTED, untouched once the guard goes back to UNAWARE). - "awareness": the raw 0..1 awareness float. Usage example: blackboard = Blackboard() cone = VisionCone(guard, range_near=80, range_far=250, half_angle=math.radians(30)) perception = Perception([cone], blackboard) # In the game loop: perception.update(dt, player.position, obstacles=walls) if blackboard.get("alert_level") == AlertLevel.ALERTED: ... """ def __init__( self, vision_cones: Sequence[VisionCone], blackboard: Optional[Blackboard] = None, decay_rate: float = 0.2, suspicious_threshold: float = 0.3, alerted_threshold: float = 1.0, ) -> None: """ :param vision_cones: The vision cones used to look for the target. A target seen by any of them contributes awareness. :param blackboard: The blackboard to post the alert level and last known target position to. The default value is None, so a fresh, empty Blackboard is created. :param decay_rate: Awareness lost per second while the target is not visible by any cone. :param suspicious_threshold: Awareness value at or above which the alert level becomes SUSPICIOUS. :param alerted_threshold: Awareness value at or above which the alert level becomes ALERTED. """ self.vision_cones: Sequence[VisionCone] = vision_cones self.blackboard: Blackboard = ( blackboard if blackboard is not None else Blackboard() ) self.decay_rate: float = decay_rate self.suspicious_threshold: float = suspicious_threshold self.alerted_threshold: float = alerted_threshold self.awareness: float = 0.0 def _alert_level(self) -> AlertLevel: if self.awareness >= self.alerted_threshold: return AlertLevel.ALERTED if self.awareness >= self.suspicious_threshold: return AlertLevel.SUSPICIOUS return AlertLevel.UNAWARE
[docs] def update( self, dt: float, target_point: pygame.Vector2, obstacles: Optional[Sequence[pygame.Rect]] = None, ) -> AlertLevel: """ Look for target_point through every vision cone, accumulate or decay awareness accordingly, and post the resulting state onto the blackboard. :param dt: Time elapsed (in seconds) since the last update. :param target_point: Current position of the tracked target. :param obstacles: Axis-aligned rectangles that block vision. The default value is None, meaning nothing blocks vision. :returns: The alert level after this update. """ gain = max( ( cone.awareness_gain(target_point, dt, obstacles) for cone in self.vision_cones ), default=0.0, ) if gain > 0: self.awareness = min(1.0, self.awareness + gain) else: self.awareness = max(0.0, self.awareness - self.decay_rate * dt) level = self._alert_level() self.blackboard.set("awareness", self.awareness) self.blackboard.set("alert_level", level) if level != AlertLevel.UNAWARE: self.blackboard.set( "last_known_target_position", pygame.Vector2(target_point) ) return level