Edit on GitHub

vi.agent

The Agent class is where all the magic happens!

Inheriting the Agent class allows you to modify the behaviour of the agents in the simulation.

  1"""The Agent class is where all the magic happens!
  2
  3Inheriting the Agent class allows you to modify the behaviour of the agents in the simulation.
  4"""
  5
  6from __future__ import annotations
  7
  8from copy import copy
  9from typing import TYPE_CHECKING
 10
 11import pygame as pg
 12from pygame.math import Vector2
 13from pygame.sprite import Sprite
 14
 15from .util import random_angle, random_pos, round_pos
 16
 17
 18if TYPE_CHECKING:
 19    from collections.abc import Generator
 20    from typing import Any, Self
 21
 22    from pygame.mask import Mask
 23    from pygame.rect import Rect
 24    from pygame.sprite import Group
 25    from pygame.surface import Surface
 26
 27    from ._static import _StaticSprite
 28    from .config import Config
 29    from .simulation import HeadlessSimulation, Shared
 30
 31
 32__all__ = [
 33    "Agent",
 34]
 35
 36
 37class Agent[ConfigClass: Config = Config](Sprite):
 38    """The `Agent` class is home to Violet's various additions and is built on top of [PyGame's Sprite](https://www.pygame.org/docs/ref/sprite.html) class.
 39
 40    While you can simply add this `Agent` class to your simulations by calling `batch_spawn_agents`,
 41    the agents won't actually do anything interesting.
 42    Sure, they'll move around the screen very energetically, but they don't *interact* with each other.
 43
 44    That's where you come in!
 45    By inheriting this `Agent` class you can make use of all its utilities,
 46    while also programming the behaviour of your custom agent.
 47    """
 48
 49    id: int
 50    """The unique identifier of the agent."""
 51
 52    config: ConfigClass
 53    """The config of the simulation that's shared with all agents.
 54
 55    The config can be overriden when inheriting the Agent class.
 56    However, the config must always:
 57
 58    1. Inherit `Config`
 59    2. Be decorated by `@dataclass`
 60    """
 61
 62    shared: Shared
 63    """Attributes that are shared between the simulation and all agents."""
 64
 65    _images: list[Surface]
 66    """A list of images which you can use to change the current image of the agent."""
 67
 68    _image_index: int
 69    """The currently selected image."""
 70
 71    _image_cache: tuple[int, Surface] | None = None
 72
 73    _area: Rect
 74    """The area in which the agent is free to move."""
 75
 76    move: Vector2
 77    """A two-dimensional vector representing the delta between the agent's current and next position.
 78
 79    Note that `move` isn't added to the agent's `pos` automatically.
 80    Instead, you should manually add the move delta to `pos`, like so:
 81
 82    ```python
 83    self.pos += self.move
 84    ```
 85
 86    The vector `Vector2(2, 1)` indicates that the agent will be moving 2 pixels along the x axis and 1 pixel along the y axis.
 87    You can use the `Vector2` class to calculate the vector's magnitude.
 88
 89    This property is also used to automatically rotate the agent's image
 90    when `vi.config.Schema.image_rotation` is enabled.
 91    """
 92
 93    pos: Vector2
 94    """The current (centre) position of the agent."""
 95
 96    _obstacles: Group[Any]
 97    """The group of obstacles the agent can collide with."""
 98
 99    _sites: Group[Any]
100    """The group of sites on which the agent can appear."""
101
102    __simulation: HeadlessSimulation[ConfigClass]
103
104    _moving: bool = True
105    """The agent's movement will freeze when moving is set to False."""
106
107    def __init__(
108        self,
109        images: list[Surface],
110        simulation: HeadlessSimulation[ConfigClass],
111        pos: Vector2 | None = None,
112        move: Vector2 | None = None,
113    ) -> None:
114        Sprite.__init__(self, simulation._all, simulation._agents)
115
116        self.__simulation = simulation
117
118        self.id = simulation._agent_id()
119        self.config = simulation.config
120        self.shared = simulation.shared
121
122        # Default to first image in case no image is given
123        self._image_index = 0
124        self._images = images
125
126        self._obstacles = simulation._obstacles
127        self._sites = simulation._sites
128
129        self._area = simulation._area
130        self.move = (
131            move
132            if move is not None
133            else random_angle(self.config.movement_speed, prng=self.shared.prng_move)
134        )
135
136        # On spawn acts like the __init__ for non-pygame facing state.
137        # It could be used to override the initial image if necessary.
138        self.on_spawn()
139
140        if pos is not None:
141            self.pos = pos
142
143        if not hasattr(self, "pos"):
144            # Keep changing the position until the position no longer collides with any obstacle.
145            while True:
146                self.pos = random_pos(self._area, prng=self.shared.prng_move)
147
148                obstacle_hit = pg.sprite.spritecollideany(
149                    self,  # type: ignore[reportArgumentType]
150                    self._obstacles,
151                    pg.sprite.collide_mask,
152                )
153
154                if not bool(obstacle_hit) and self._area.contains(self.rect):
155                    break
156
157    def _get_image(self) -> Surface:
158        image = self._images[self._image_index]
159
160        if self.config.image_rotation:
161            angle = self.move.angle_to(Vector2((0, -1)))
162
163            return pg.transform.rotate(image, angle)
164
165        return image
166
167    @property
168    def image(self) -> Surface:
169        """The read-only image that's used for PyGame's rendering."""
170        if self._image_cache is not None:
171            frame, image = self._image_cache
172            if frame == self.shared.counter:
173                return image
174
175        new_image = self._get_image()
176        self._image_cache = (self.shared.counter, new_image)
177
178        return new_image
179
180    @property
181    def center(self) -> tuple[int, int]:
182        """The read-only centre position of the agent."""
183        return round_pos(self.pos)
184
185    @property
186    def rect(self) -> Rect:
187        """The read-only bounding-box that's used for PyGame's rendering."""
188        rect = self.image.get_rect()
189        rect.center = self.center
190
191        return rect
192
193    @property
194    def mask(self) -> Mask:
195        """A read-only bit-mask of the image used for collision detection with obstacles and sites."""
196        return pg.mask.from_surface(self.image)
197
198    def update(self) -> None:
199        """Run your own agent logic at every tick of the simulation.
200
201        Every frame of the simulation, this method is called automatically for every agent of the simulation.
202        To add your own logic, inherit the `Agent` class and override this method with your own.
203        """
204
205    def obstacle_intersections(
206        self,
207        scale: float = 1,
208    ) -> Generator[Vector2]:
209        """Retrieve the centre coordinates of all obstacle intersections.
210
211        If you not only want to check for obstacle collision,
212        but also want to retrieve the coordinates of pixel groups
213        that are colliding with your agent, then `obstacle_intersections` is for you!
214
215        If you only have one obstacle in your environment,
216        but it doesn't have a fill, only a stroke,
217        then your agent could possibly be colliding with different areas of the stroke.
218        Therefore, this method checks the bitmasks of both the agent and the obstacle
219        to calculate the overlapping bitmask.
220        From this overlapping bitmask,
221        the centre coordinates of all groups of connected pixels are returned.
222
223        To emulate a bigger (or smaller) radius,
224        you can pass along the `scale` option.
225        A scale of 2 makes your agent twice as big,
226        but only for calculating the intersecting bitmasks.
227        """
228        mask = pg.mask.from_surface(self.image)
229
230        # Scale the mask to the desired size
231        width, height = mask.get_size()
232        mask = mask.scale((width * scale, height * scale))
233
234        # Align the mask to the centre position of the agent
235        rect = mask.get_rect()
236        rect.center = self.center
237
238        for sprite in self._obstacles.sprites():
239            obstacle: _StaticSprite = sprite
240
241            # Calculate the mask offset
242            x = obstacle.rect.x - rect.x
243            y = obstacle.rect.y - rect.y
244
245            overlap = mask.overlap_mask(obstacle.mask, offset=(x, y))
246
247            # For some reason PyGame has the wrong type hint here (single instead of list)
248            overlap_rects: list[pg.rect.Rect] = overlap.get_bounding_rects()  # type: ignore[reportAssignmentType]
249
250            for overlap_rect in overlap_rects:
251                # Undo the offset
252                overlap_rect.x += rect.x
253                overlap_rect.y += rect.y
254
255                # Return the centre coordinates of the connected pixels
256                yield Vector2(overlap_rect.center)
257
258    def on_spawn(self) -> None:
259        """Run any code when the agent is spawned into the simulation.
260
261        This method is a replacement for `__init__`, which you should not overwrite directly.
262        Instead, you can make alterations to your Agent within this function instead.
263
264        You should override this method when inheriting Agent to add your own logic.
265
266        Some examples include:
267        - Changing the image or state of your Agent depending on its assigned identifier.
268        """
269
270    def there_is_no_escape(self) -> bool:
271        """Pac-Man-style teleport the agent to the other side of the screen when it is outside of the playable area.
272
273        Examples
274        --------
275        An agent that will always move to the right, until snapped back to reality.
276
277        ```python
278        class MyAgent(Agent):
279            def on_spawn(self):
280                self.move = Vector2((5, 0))
281
282            def change_position(self):
283                self.there_is_no_escape()
284                self.pos += self.move
285        ```
286
287        """
288        changed = False
289
290        if self.pos.x < self._area.left:
291            changed = True
292            self.pos.x = self._area.right
293
294        if self.pos.x > self._area.right:
295            changed = True
296            self.pos.x = self._area.left
297
298        if self.pos.y < self._area.top:
299            changed = True
300            self.pos.y = self._area.bottom
301
302        if self.pos.y > self._area.bottom:
303            changed = True
304            self.pos.y = self._area.top
305
306        return changed
307
308    def change_position(self) -> None:
309        """Change the position of the agent.
310
311        The agent's new position is calculated as follows:
312        1. The agent checks whether it's outside of the visible screen area.
313        If this is the case, then the agent will be teleported to the other edge of the screen.
314        2. If the agent collides with any obstacles, then the agent will turn around 180 degrees.
315        3. If the agent has not collided with any obstacles, it will have the opportunity to slightly change its angle.
316        """
317        if not self._moving:
318            return
319
320        changed = self.there_is_no_escape()
321
322        prng = self.shared.prng_move
323
324        # Always calculate the random angle so a seed could be used.
325        deg = prng.uniform(-30, 30)
326
327        # Only update angle if the agent was teleported to a different area of the simulation.
328        if changed:
329            self.move.rotate_ip(deg)
330
331        # Obstacle Avoidance
332        obstacle_hit = pg.sprite.spritecollideany(
333            self,  # type: ignore[reportArgumentType]
334            self._obstacles,
335            pg.sprite.collide_mask,
336        )
337
338        collision = bool(obstacle_hit)
339
340        # Reverse direction when colliding with an obstacle.
341        if collision and not self._still_stuck:
342            self.move.rotate_ip(180)
343            self._still_stuck = True
344
345        if not collision:
346            self._still_stuck = False
347
348        # Random opportunity to slightly change angle.
349        # Probabilities are pre-computed so a seed could be used.
350        should_change_angle = prng.random()
351        deg = prng.uniform(-10, 10)
352
353        # Only allow the angle opportunity to take place when no collisions have occured.
354        # This is done so an agent always turns 180 degrees. Any small change in the number of degrees
355        # allows the agent to possibly escape the obstacle.
356        if not collision and not self._still_stuck and should_change_angle < 0.25:  # noqa: PLR2004
357            self.move.rotate_ip(deg)
358
359        # Actually update the position at last.
360        self.pos += self.move
361
362    def in_proximity_accuracy(
363        self,
364    ) -> Generator[tuple[Agent[ConfigClass], float]]:
365        """Retrieve other agents that are in the `vi.config.Schema.radius` of the current agent.
366
367        This proximity method calculates the distances between agents to determine whether
368        an agent is in the radius of the current agent.
369
370        To calculate the distances between agents, up to four chunks have to be retrieved from the Proximity Engine.
371        These extra lookups, in combination with the vector distance calculation, have a noticable impact on performance.
372        Note however that this performance impact is only noticable with >1000 agents.
373
374        If you want to speed up your simulation at the cost of some accuracy,
375        consider using the `in_proximity_performance` method instead.
376
377        This function doesn't return the agents as a `list` or as a `set`.
378        Instead, you are given a generator.
379
380        Examples
381        --------
382        Count the number of agents that are in proximity
383        and change to image `1` if there is at least one agent nearby.
384
385        ```python
386        from vi.util import count
387
388
389        class MyAgent(Agent):
390            def update(self) -> None:
391                if count(self.in_proximity_accuracy()) >= 1:
392                    self.change_image(1)
393                else:
394                    self.change_image(0)
395        ```
396
397        Kill the first `Human` agent that's in proximity.
398
399        ```python
400        class Zombie(Agent):
401            def update(self) -> None:
402                for agent, _ in self.in_proximity_accuracy():
403                    # Don't want to kill other zombies
404                    if isinstance(Agent, Human):
405                        agent.kill()
406                        break
407        ```
408
409        Calculate the average distance of agents that are in proximity.
410
411        ```python
412        from statistics import fmean
413
414        class Heimerdinger(Agent):
415            def update(self) -> None:
416                distances = [dist for _, dist in self.in_proximity_accuracy()]
417                dist_mean = fmean(distances) if len(distances) > 0 else 0
418        ```
419
420        """
421        return self.__simulation._proximity.in_proximity_accuracy(self)
422
423    def in_proximity_performance(
424        self,
425    ) -> Generator[Agent[ConfigClass]]:
426        """Retrieve other agents that are in the `vi.config.Schema.radius` of the current agent.
427
428        Unlike `in_proximity_accuracy`, this proximity method does not calculate the distances between agents.
429        Instead, it retrieves agents that are in the same chunk as the current agent,
430        irrespective of their position within the chunk.
431
432        If you find yourself limited by the performance of `in_proximity_accuracy`,
433        you can swap the function call for this one instead.
434        This performance method roughly doubles the frame rates of the simulation.
435        """
436        return self.__simulation._proximity.in_proximity_performance(self)
437
438    def on_site(self) -> bool:
439        """Check whether the agent is currently on a site.
440
441        Examples
442        --------
443        Stop the agent's movement when it reaches a site (think of a nice beach).
444
445        ```python
446        class TravellingAgent(Agent):
447            def update(self) -> None:
448                if self.on_site():
449                    # crave that star damage
450                    self.freeze_movement()
451        ```
452
453        """
454        return self.on_site_id() is not None
455
456    def on_site_id(self) -> int | None:
457        """Get the identifier of the site the agent is currently on.
458
459        Examples
460        --------
461        Stop the agent's movement when it reaches a site to inspect.
462        In addition, the current site identifier is saved to the DataFrame.
463
464        ```python
465        class SiteInspector(Agent):
466            def update(self) -> None:
467                site_id = self.on_site_id()
468
469                # Save the site id to the DataFrame
470                self.save_data("site", site_id)
471
472                # bool(site_id) would be inaccurate
473                # as a site_id of 0 will return False.
474                # Therefore, we check whether it is not None instead.
475                if site_id is not None:
476                    # Inspect the site
477                    self.freeze_movement()
478        ```
479
480        """
481        site: _StaticSprite | None = pg.sprite.spritecollideany(
482            self,  # type: ignore[reportArgumentType]
483            self._sites,
484            pg.sprite.collide_mask,
485        )
486
487        if site is not None:
488            return site.id
489        return None
490
491    def freeze_movement(self) -> None:
492        """Freeze the movement of the agent. The movement can be continued by calling `continue_movement`."""
493        self._moving = False
494
495    def continue_movement(self) -> None:
496        """Continue the movement of the agent from before its movement was frozen."""
497        self._moving = True
498
499    def change_image(self, index: int) -> None:
500        """Change the image of the agent.
501
502        If you want to change the agent's image to the second image in the images list,
503        then you can change the image to index 1:
504
505        ```python
506        self.change_image(1)
507        ```
508        """
509        self._image_index = index
510
511    def save_data(self, column: str, value: Any) -> None:  # noqa: ANN401
512        """Add extra data to the simulation's metrics.
513
514        The following data is collected automatically:
515        - agent identifier
516        - current frame
517        - x and y coordinates
518
519        Examples
520        --------
521        Saving the number of agents that are currently in proximity:
522
523        ```python
524        from vi.util import count
525
526
527        class MyAgent(Agent):
528            def update(self) -> None:
529                in_proximity = count(self.in_proximity_accuracy())
530
531                self.save_data("in_proximity", in_proximity)
532        ```
533
534        """
535        self.__simulation._metrics._temporary_snapshots[column].append(value)
536
537    def _collect_replay_data(self) -> None:
538        """Add the minimum data needed for the replay simulation to the dataframe."""
539        x, y = self.center
540        snapshots = self.__simulation._metrics._temporary_snapshots
541
542        snapshots["frame"].append(self.shared.counter)
543        snapshots["id"].append(self.id)
544
545        snapshots["x"].append(x)
546        snapshots["y"].append(y)
547
548        snapshots["image_index"].append(self._image_index)
549
550        if self.config.image_rotation:
551            angle = self.move.angle_to(Vector2((0, -1)))
552            snapshots["angle"].append(round(angle))
553
554    def __copy__(self) -> Self:
555        """Create a copy of this agent and spawn it into the simulation.
556
557        Note that this only copies the `pos` and `move` vectors.
558        Any other attributes will be set to their defaults.
559        """
560        cls = self.__class__
561        agent = cls(self._images, self.__simulation)
562
563        # We want to make sure to copy the position and movement vectors.
564        # Otherwise, the original as well as the clone will continue sharing these vectors.
565        agent.pos = self.pos.copy()
566        agent.move = self.move.copy()
567
568        return agent
569
570    def reproduce(self) -> Self:
571        """Create a new agent and spawn it into the simulation.
572
573        All values will be reset to their defaults,
574        except for the agent's position and movement vector.
575        These will be cloned from the original agent.
576        """
577        return copy(self)
578
579    def kill(self) -> None:
580        """Kill the agent.
581
582        While violence usually isn't the right option,
583        sometimes you just want to murder some innocent agents inside your little computer.
584
585        But fear not!
586        By *killing* the agent, all you're really doing is removing it from the simulation.
587        """
588        super().kill()
589
590    def is_dead(self) -> bool:
591        """Is the agent dead?
592
593        Death occurs when `kill` is called.
594        """
595        return not self.is_alive()
596
597    def is_alive(self) -> bool:
598        """Is the agent still alive?
599
600        Death occurs when `kill` is called.
601        """
602        return super().alive()
class Agent(pygame.sprite.Sprite, typing.Generic[ConfigClass]):
 38class Agent[ConfigClass: Config = Config](Sprite):
 39    """The `Agent` class is home to Violet's various additions and is built on top of [PyGame's Sprite](https://www.pygame.org/docs/ref/sprite.html) class.
 40
 41    While you can simply add this `Agent` class to your simulations by calling `batch_spawn_agents`,
 42    the agents won't actually do anything interesting.
 43    Sure, they'll move around the screen very energetically, but they don't *interact* with each other.
 44
 45    That's where you come in!
 46    By inheriting this `Agent` class you can make use of all its utilities,
 47    while also programming the behaviour of your custom agent.
 48    """
 49
 50    id: int
 51    """The unique identifier of the agent."""
 52
 53    config: ConfigClass
 54    """The config of the simulation that's shared with all agents.
 55
 56    The config can be overriden when inheriting the Agent class.
 57    However, the config must always:
 58
 59    1. Inherit `Config`
 60    2. Be decorated by `@dataclass`
 61    """
 62
 63    shared: Shared
 64    """Attributes that are shared between the simulation and all agents."""
 65
 66    _images: list[Surface]
 67    """A list of images which you can use to change the current image of the agent."""
 68
 69    _image_index: int
 70    """The currently selected image."""
 71
 72    _image_cache: tuple[int, Surface] | None = None
 73
 74    _area: Rect
 75    """The area in which the agent is free to move."""
 76
 77    move: Vector2
 78    """A two-dimensional vector representing the delta between the agent's current and next position.
 79
 80    Note that `move` isn't added to the agent's `pos` automatically.
 81    Instead, you should manually add the move delta to `pos`, like so:
 82
 83    ```python
 84    self.pos += self.move
 85    ```
 86
 87    The vector `Vector2(2, 1)` indicates that the agent will be moving 2 pixels along the x axis and 1 pixel along the y axis.
 88    You can use the `Vector2` class to calculate the vector's magnitude.
 89
 90    This property is also used to automatically rotate the agent's image
 91    when `vi.config.Schema.image_rotation` is enabled.
 92    """
 93
 94    pos: Vector2
 95    """The current (centre) position of the agent."""
 96
 97    _obstacles: Group[Any]
 98    """The group of obstacles the agent can collide with."""
 99
100    _sites: Group[Any]
101    """The group of sites on which the agent can appear."""
102
103    __simulation: HeadlessSimulation[ConfigClass]
104
105    _moving: bool = True
106    """The agent's movement will freeze when moving is set to False."""
107
108    def __init__(
109        self,
110        images: list[Surface],
111        simulation: HeadlessSimulation[ConfigClass],
112        pos: Vector2 | None = None,
113        move: Vector2 | None = None,
114    ) -> None:
115        Sprite.__init__(self, simulation._all, simulation._agents)
116
117        self.__simulation = simulation
118
119        self.id = simulation._agent_id()
120        self.config = simulation.config
121        self.shared = simulation.shared
122
123        # Default to first image in case no image is given
124        self._image_index = 0
125        self._images = images
126
127        self._obstacles = simulation._obstacles
128        self._sites = simulation._sites
129
130        self._area = simulation._area
131        self.move = (
132            move
133            if move is not None
134            else random_angle(self.config.movement_speed, prng=self.shared.prng_move)
135        )
136
137        # On spawn acts like the __init__ for non-pygame facing state.
138        # It could be used to override the initial image if necessary.
139        self.on_spawn()
140
141        if pos is not None:
142            self.pos = pos
143
144        if not hasattr(self, "pos"):
145            # Keep changing the position until the position no longer collides with any obstacle.
146            while True:
147                self.pos = random_pos(self._area, prng=self.shared.prng_move)
148
149                obstacle_hit = pg.sprite.spritecollideany(
150                    self,  # type: ignore[reportArgumentType]
151                    self._obstacles,
152                    pg.sprite.collide_mask,
153                )
154
155                if not bool(obstacle_hit) and self._area.contains(self.rect):
156                    break
157
158    def _get_image(self) -> Surface:
159        image = self._images[self._image_index]
160
161        if self.config.image_rotation:
162            angle = self.move.angle_to(Vector2((0, -1)))
163
164            return pg.transform.rotate(image, angle)
165
166        return image
167
168    @property
169    def image(self) -> Surface:
170        """The read-only image that's used for PyGame's rendering."""
171        if self._image_cache is not None:
172            frame, image = self._image_cache
173            if frame == self.shared.counter:
174                return image
175
176        new_image = self._get_image()
177        self._image_cache = (self.shared.counter, new_image)
178
179        return new_image
180
181    @property
182    def center(self) -> tuple[int, int]:
183        """The read-only centre position of the agent."""
184        return round_pos(self.pos)
185
186    @property
187    def rect(self) -> Rect:
188        """The read-only bounding-box that's used for PyGame's rendering."""
189        rect = self.image.get_rect()
190        rect.center = self.center
191
192        return rect
193
194    @property
195    def mask(self) -> Mask:
196        """A read-only bit-mask of the image used for collision detection with obstacles and sites."""
197        return pg.mask.from_surface(self.image)
198
199    def update(self) -> None:
200        """Run your own agent logic at every tick of the simulation.
201
202        Every frame of the simulation, this method is called automatically for every agent of the simulation.
203        To add your own logic, inherit the `Agent` class and override this method with your own.
204        """
205
206    def obstacle_intersections(
207        self,
208        scale: float = 1,
209    ) -> Generator[Vector2]:
210        """Retrieve the centre coordinates of all obstacle intersections.
211
212        If you not only want to check for obstacle collision,
213        but also want to retrieve the coordinates of pixel groups
214        that are colliding with your agent, then `obstacle_intersections` is for you!
215
216        If you only have one obstacle in your environment,
217        but it doesn't have a fill, only a stroke,
218        then your agent could possibly be colliding with different areas of the stroke.
219        Therefore, this method checks the bitmasks of both the agent and the obstacle
220        to calculate the overlapping bitmask.
221        From this overlapping bitmask,
222        the centre coordinates of all groups of connected pixels are returned.
223
224        To emulate a bigger (or smaller) radius,
225        you can pass along the `scale` option.
226        A scale of 2 makes your agent twice as big,
227        but only for calculating the intersecting bitmasks.
228        """
229        mask = pg.mask.from_surface(self.image)
230
231        # Scale the mask to the desired size
232        width, height = mask.get_size()
233        mask = mask.scale((width * scale, height * scale))
234
235        # Align the mask to the centre position of the agent
236        rect = mask.get_rect()
237        rect.center = self.center
238
239        for sprite in self._obstacles.sprites():
240            obstacle: _StaticSprite = sprite
241
242            # Calculate the mask offset
243            x = obstacle.rect.x - rect.x
244            y = obstacle.rect.y - rect.y
245
246            overlap = mask.overlap_mask(obstacle.mask, offset=(x, y))
247
248            # For some reason PyGame has the wrong type hint here (single instead of list)
249            overlap_rects: list[pg.rect.Rect] = overlap.get_bounding_rects()  # type: ignore[reportAssignmentType]
250
251            for overlap_rect in overlap_rects:
252                # Undo the offset
253                overlap_rect.x += rect.x
254                overlap_rect.y += rect.y
255
256                # Return the centre coordinates of the connected pixels
257                yield Vector2(overlap_rect.center)
258
259    def on_spawn(self) -> None:
260        """Run any code when the agent is spawned into the simulation.
261
262        This method is a replacement for `__init__`, which you should not overwrite directly.
263        Instead, you can make alterations to your Agent within this function instead.
264
265        You should override this method when inheriting Agent to add your own logic.
266
267        Some examples include:
268        - Changing the image or state of your Agent depending on its assigned identifier.
269        """
270
271    def there_is_no_escape(self) -> bool:
272        """Pac-Man-style teleport the agent to the other side of the screen when it is outside of the playable area.
273
274        Examples
275        --------
276        An agent that will always move to the right, until snapped back to reality.
277
278        ```python
279        class MyAgent(Agent):
280            def on_spawn(self):
281                self.move = Vector2((5, 0))
282
283            def change_position(self):
284                self.there_is_no_escape()
285                self.pos += self.move
286        ```
287
288        """
289        changed = False
290
291        if self.pos.x < self._area.left:
292            changed = True
293            self.pos.x = self._area.right
294
295        if self.pos.x > self._area.right:
296            changed = True
297            self.pos.x = self._area.left
298
299        if self.pos.y < self._area.top:
300            changed = True
301            self.pos.y = self._area.bottom
302
303        if self.pos.y > self._area.bottom:
304            changed = True
305            self.pos.y = self._area.top
306
307        return changed
308
309    def change_position(self) -> None:
310        """Change the position of the agent.
311
312        The agent's new position is calculated as follows:
313        1. The agent checks whether it's outside of the visible screen area.
314        If this is the case, then the agent will be teleported to the other edge of the screen.
315        2. If the agent collides with any obstacles, then the agent will turn around 180 degrees.
316        3. If the agent has not collided with any obstacles, it will have the opportunity to slightly change its angle.
317        """
318        if not self._moving:
319            return
320
321        changed = self.there_is_no_escape()
322
323        prng = self.shared.prng_move
324
325        # Always calculate the random angle so a seed could be used.
326        deg = prng.uniform(-30, 30)
327
328        # Only update angle if the agent was teleported to a different area of the simulation.
329        if changed:
330            self.move.rotate_ip(deg)
331
332        # Obstacle Avoidance
333        obstacle_hit = pg.sprite.spritecollideany(
334            self,  # type: ignore[reportArgumentType]
335            self._obstacles,
336            pg.sprite.collide_mask,
337        )
338
339        collision = bool(obstacle_hit)
340
341        # Reverse direction when colliding with an obstacle.
342        if collision and not self._still_stuck:
343            self.move.rotate_ip(180)
344            self._still_stuck = True
345
346        if not collision:
347            self._still_stuck = False
348
349        # Random opportunity to slightly change angle.
350        # Probabilities are pre-computed so a seed could be used.
351        should_change_angle = prng.random()
352        deg = prng.uniform(-10, 10)
353
354        # Only allow the angle opportunity to take place when no collisions have occured.
355        # This is done so an agent always turns 180 degrees. Any small change in the number of degrees
356        # allows the agent to possibly escape the obstacle.
357        if not collision and not self._still_stuck and should_change_angle < 0.25:  # noqa: PLR2004
358            self.move.rotate_ip(deg)
359
360        # Actually update the position at last.
361        self.pos += self.move
362
363    def in_proximity_accuracy(
364        self,
365    ) -> Generator[tuple[Agent[ConfigClass], float]]:
366        """Retrieve other agents that are in the `vi.config.Schema.radius` of the current agent.
367
368        This proximity method calculates the distances between agents to determine whether
369        an agent is in the radius of the current agent.
370
371        To calculate the distances between agents, up to four chunks have to be retrieved from the Proximity Engine.
372        These extra lookups, in combination with the vector distance calculation, have a noticable impact on performance.
373        Note however that this performance impact is only noticable with >1000 agents.
374
375        If you want to speed up your simulation at the cost of some accuracy,
376        consider using the `in_proximity_performance` method instead.
377
378        This function doesn't return the agents as a `list` or as a `set`.
379        Instead, you are given a generator.
380
381        Examples
382        --------
383        Count the number of agents that are in proximity
384        and change to image `1` if there is at least one agent nearby.
385
386        ```python
387        from vi.util import count
388
389
390        class MyAgent(Agent):
391            def update(self) -> None:
392                if count(self.in_proximity_accuracy()) >= 1:
393                    self.change_image(1)
394                else:
395                    self.change_image(0)
396        ```
397
398        Kill the first `Human` agent that's in proximity.
399
400        ```python
401        class Zombie(Agent):
402            def update(self) -> None:
403                for agent, _ in self.in_proximity_accuracy():
404                    # Don't want to kill other zombies
405                    if isinstance(Agent, Human):
406                        agent.kill()
407                        break
408        ```
409
410        Calculate the average distance of agents that are in proximity.
411
412        ```python
413        from statistics import fmean
414
415        class Heimerdinger(Agent):
416            def update(self) -> None:
417                distances = [dist for _, dist in self.in_proximity_accuracy()]
418                dist_mean = fmean(distances) if len(distances) > 0 else 0
419        ```
420
421        """
422        return self.__simulation._proximity.in_proximity_accuracy(self)
423
424    def in_proximity_performance(
425        self,
426    ) -> Generator[Agent[ConfigClass]]:
427        """Retrieve other agents that are in the `vi.config.Schema.radius` of the current agent.
428
429        Unlike `in_proximity_accuracy`, this proximity method does not calculate the distances between agents.
430        Instead, it retrieves agents that are in the same chunk as the current agent,
431        irrespective of their position within the chunk.
432
433        If you find yourself limited by the performance of `in_proximity_accuracy`,
434        you can swap the function call for this one instead.
435        This performance method roughly doubles the frame rates of the simulation.
436        """
437        return self.__simulation._proximity.in_proximity_performance(self)
438
439    def on_site(self) -> bool:
440        """Check whether the agent is currently on a site.
441
442        Examples
443        --------
444        Stop the agent's movement when it reaches a site (think of a nice beach).
445
446        ```python
447        class TravellingAgent(Agent):
448            def update(self) -> None:
449                if self.on_site():
450                    # crave that star damage
451                    self.freeze_movement()
452        ```
453
454        """
455        return self.on_site_id() is not None
456
457    def on_site_id(self) -> int | None:
458        """Get the identifier of the site the agent is currently on.
459
460        Examples
461        --------
462        Stop the agent's movement when it reaches a site to inspect.
463        In addition, the current site identifier is saved to the DataFrame.
464
465        ```python
466        class SiteInspector(Agent):
467            def update(self) -> None:
468                site_id = self.on_site_id()
469
470                # Save the site id to the DataFrame
471                self.save_data("site", site_id)
472
473                # bool(site_id) would be inaccurate
474                # as a site_id of 0 will return False.
475                # Therefore, we check whether it is not None instead.
476                if site_id is not None:
477                    # Inspect the site
478                    self.freeze_movement()
479        ```
480
481        """
482        site: _StaticSprite | None = pg.sprite.spritecollideany(
483            self,  # type: ignore[reportArgumentType]
484            self._sites,
485            pg.sprite.collide_mask,
486        )
487
488        if site is not None:
489            return site.id
490        return None
491
492    def freeze_movement(self) -> None:
493        """Freeze the movement of the agent. The movement can be continued by calling `continue_movement`."""
494        self._moving = False
495
496    def continue_movement(self) -> None:
497        """Continue the movement of the agent from before its movement was frozen."""
498        self._moving = True
499
500    def change_image(self, index: int) -> None:
501        """Change the image of the agent.
502
503        If you want to change the agent's image to the second image in the images list,
504        then you can change the image to index 1:
505
506        ```python
507        self.change_image(1)
508        ```
509        """
510        self._image_index = index
511
512    def save_data(self, column: str, value: Any) -> None:  # noqa: ANN401
513        """Add extra data to the simulation's metrics.
514
515        The following data is collected automatically:
516        - agent identifier
517        - current frame
518        - x and y coordinates
519
520        Examples
521        --------
522        Saving the number of agents that are currently in proximity:
523
524        ```python
525        from vi.util import count
526
527
528        class MyAgent(Agent):
529            def update(self) -> None:
530                in_proximity = count(self.in_proximity_accuracy())
531
532                self.save_data("in_proximity", in_proximity)
533        ```
534
535        """
536        self.__simulation._metrics._temporary_snapshots[column].append(value)
537
538    def _collect_replay_data(self) -> None:
539        """Add the minimum data needed for the replay simulation to the dataframe."""
540        x, y = self.center
541        snapshots = self.__simulation._metrics._temporary_snapshots
542
543        snapshots["frame"].append(self.shared.counter)
544        snapshots["id"].append(self.id)
545
546        snapshots["x"].append(x)
547        snapshots["y"].append(y)
548
549        snapshots["image_index"].append(self._image_index)
550
551        if self.config.image_rotation:
552            angle = self.move.angle_to(Vector2((0, -1)))
553            snapshots["angle"].append(round(angle))
554
555    def __copy__(self) -> Self:
556        """Create a copy of this agent and spawn it into the simulation.
557
558        Note that this only copies the `pos` and `move` vectors.
559        Any other attributes will be set to their defaults.
560        """
561        cls = self.__class__
562        agent = cls(self._images, self.__simulation)
563
564        # We want to make sure to copy the position and movement vectors.
565        # Otherwise, the original as well as the clone will continue sharing these vectors.
566        agent.pos = self.pos.copy()
567        agent.move = self.move.copy()
568
569        return agent
570
571    def reproduce(self) -> Self:
572        """Create a new agent and spawn it into the simulation.
573
574        All values will be reset to their defaults,
575        except for the agent's position and movement vector.
576        These will be cloned from the original agent.
577        """
578        return copy(self)
579
580    def kill(self) -> None:
581        """Kill the agent.
582
583        While violence usually isn't the right option,
584        sometimes you just want to murder some innocent agents inside your little computer.
585
586        But fear not!
587        By *killing* the agent, all you're really doing is removing it from the simulation.
588        """
589        super().kill()
590
591    def is_dead(self) -> bool:
592        """Is the agent dead?
593
594        Death occurs when `kill` is called.
595        """
596        return not self.is_alive()
597
598    def is_alive(self) -> bool:
599        """Is the agent still alive?
600
601        Death occurs when `kill` is called.
602        """
603        return super().alive()

The Agent class is home to Violet's various additions and is built on top of PyGame's Sprite class.

While you can simply add this Agent class to your simulations by calling batch_spawn_agents, the agents won't actually do anything interesting. Sure, they'll move around the screen very energetically, but they don't interact with each other.

That's where you come in! By inheriting this Agent class you can make use of all its utilities, while also programming the behaviour of your custom agent.

id: int

The unique identifier of the agent.

config: 'ConfigClass'

The config of the simulation that's shared with all agents.

The config can be overriden when inheriting the Agent class. However, the config must always:

  1. Inherit Config
  2. Be decorated by @dataclass
shared: vi.simulation.Shared

Attributes that are shared between the simulation and all agents.

move: pygame.math.Vector2

A two-dimensional vector representing the delta between the agent's current and next position.

Note that move isn't added to the agent's pos automatically. Instead, you should manually add the move delta to pos, like so:

self.pos += self.move

The vector Vector2(2, 1) indicates that the agent will be moving 2 pixels along the x axis and 1 pixel along the y axis. You can use the Vector2 class to calculate the vector's magnitude.

This property is also used to automatically rotate the agent's image when vi.config.Schema.image_rotation is enabled.

pos: pygame.math.Vector2

The current (centre) position of the agent.

image: pygame.surface.Surface
168    @property
169    def image(self) -> Surface:
170        """The read-only image that's used for PyGame's rendering."""
171        if self._image_cache is not None:
172            frame, image = self._image_cache
173            if frame == self.shared.counter:
174                return image
175
176        new_image = self._get_image()
177        self._image_cache = (self.shared.counter, new_image)
178
179        return new_image

The read-only image that's used for PyGame's rendering.

center: tuple[int, int]
181    @property
182    def center(self) -> tuple[int, int]:
183        """The read-only centre position of the agent."""
184        return round_pos(self.pos)

The read-only centre position of the agent.

rect: pygame.rect.Rect
186    @property
187    def rect(self) -> Rect:
188        """The read-only bounding-box that's used for PyGame's rendering."""
189        rect = self.image.get_rect()
190        rect.center = self.center
191
192        return rect

The read-only bounding-box that's used for PyGame's rendering.

mask: pygame.mask.Mask
194    @property
195    def mask(self) -> Mask:
196        """A read-only bit-mask of the image used for collision detection with obstacles and sites."""
197        return pg.mask.from_surface(self.image)

A read-only bit-mask of the image used for collision detection with obstacles and sites.

def obstacle_intersections(self, scale: float = 1) -> Generator[pygame.math.Vector2]:
206    def obstacle_intersections(
207        self,
208        scale: float = 1,
209    ) -> Generator[Vector2]:
210        """Retrieve the centre coordinates of all obstacle intersections.
211
212        If you not only want to check for obstacle collision,
213        but also want to retrieve the coordinates of pixel groups
214        that are colliding with your agent, then `obstacle_intersections` is for you!
215
216        If you only have one obstacle in your environment,
217        but it doesn't have a fill, only a stroke,
218        then your agent could possibly be colliding with different areas of the stroke.
219        Therefore, this method checks the bitmasks of both the agent and the obstacle
220        to calculate the overlapping bitmask.
221        From this overlapping bitmask,
222        the centre coordinates of all groups of connected pixels are returned.
223
224        To emulate a bigger (or smaller) radius,
225        you can pass along the `scale` option.
226        A scale of 2 makes your agent twice as big,
227        but only for calculating the intersecting bitmasks.
228        """
229        mask = pg.mask.from_surface(self.image)
230
231        # Scale the mask to the desired size
232        width, height = mask.get_size()
233        mask = mask.scale((width * scale, height * scale))
234
235        # Align the mask to the centre position of the agent
236        rect = mask.get_rect()
237        rect.center = self.center
238
239        for sprite in self._obstacles.sprites():
240            obstacle: _StaticSprite = sprite
241
242            # Calculate the mask offset
243            x = obstacle.rect.x - rect.x
244            y = obstacle.rect.y - rect.y
245
246            overlap = mask.overlap_mask(obstacle.mask, offset=(x, y))
247
248            # For some reason PyGame has the wrong type hint here (single instead of list)
249            overlap_rects: list[pg.rect.Rect] = overlap.get_bounding_rects()  # type: ignore[reportAssignmentType]
250
251            for overlap_rect in overlap_rects:
252                # Undo the offset
253                overlap_rect.x += rect.x
254                overlap_rect.y += rect.y
255
256                # Return the centre coordinates of the connected pixels
257                yield Vector2(overlap_rect.center)

Retrieve the centre coordinates of all obstacle intersections.

If you not only want to check for obstacle collision, but also want to retrieve the coordinates of pixel groups that are colliding with your agent, then obstacle_intersections is for you!

If you only have one obstacle in your environment, but it doesn't have a fill, only a stroke, then your agent could possibly be colliding with different areas of the stroke. Therefore, this method checks the bitmasks of both the agent and the obstacle to calculate the overlapping bitmask. From this overlapping bitmask, the centre coordinates of all groups of connected pixels are returned.

To emulate a bigger (or smaller) radius, you can pass along the scale option. A scale of 2 makes your agent twice as big, but only for calculating the intersecting bitmasks.

def on_spawn(self) -> None:
259    def on_spawn(self) -> None:
260        """Run any code when the agent is spawned into the simulation.
261
262        This method is a replacement for `__init__`, which you should not overwrite directly.
263        Instead, you can make alterations to your Agent within this function instead.
264
265        You should override this method when inheriting Agent to add your own logic.
266
267        Some examples include:
268        - Changing the image or state of your Agent depending on its assigned identifier.
269        """

Run any code when the agent is spawned into the simulation.

This method is a replacement for __init__, which you should not overwrite directly. Instead, you can make alterations to your Agent within this function instead.

You should override this method when inheriting Agent to add your own logic.

Some examples include:

  • Changing the image or state of your Agent depending on its assigned identifier.
def there_is_no_escape(self) -> bool:
271    def there_is_no_escape(self) -> bool:
272        """Pac-Man-style teleport the agent to the other side of the screen when it is outside of the playable area.
273
274        Examples
275        --------
276        An agent that will always move to the right, until snapped back to reality.
277
278        ```python
279        class MyAgent(Agent):
280            def on_spawn(self):
281                self.move = Vector2((5, 0))
282
283            def change_position(self):
284                self.there_is_no_escape()
285                self.pos += self.move
286        ```
287
288        """
289        changed = False
290
291        if self.pos.x < self._area.left:
292            changed = True
293            self.pos.x = self._area.right
294
295        if self.pos.x > self._area.right:
296            changed = True
297            self.pos.x = self._area.left
298
299        if self.pos.y < self._area.top:
300            changed = True
301            self.pos.y = self._area.bottom
302
303        if self.pos.y > self._area.bottom:
304            changed = True
305            self.pos.y = self._area.top
306
307        return changed

Pac-Man-style teleport the agent to the other side of the screen when it is outside of the playable area.

Examples

An agent that will always move to the right, until snapped back to reality.

class MyAgent(Agent):
    def on_spawn(self):
        self.move = Vector2((5, 0))

    def change_position(self):
        self.there_is_no_escape()
        self.pos += self.move
def change_position(self) -> None:
309    def change_position(self) -> None:
310        """Change the position of the agent.
311
312        The agent's new position is calculated as follows:
313        1. The agent checks whether it's outside of the visible screen area.
314        If this is the case, then the agent will be teleported to the other edge of the screen.
315        2. If the agent collides with any obstacles, then the agent will turn around 180 degrees.
316        3. If the agent has not collided with any obstacles, it will have the opportunity to slightly change its angle.
317        """
318        if not self._moving:
319            return
320
321        changed = self.there_is_no_escape()
322
323        prng = self.shared.prng_move
324
325        # Always calculate the random angle so a seed could be used.
326        deg = prng.uniform(-30, 30)
327
328        # Only update angle if the agent was teleported to a different area of the simulation.
329        if changed:
330            self.move.rotate_ip(deg)
331
332        # Obstacle Avoidance
333        obstacle_hit = pg.sprite.spritecollideany(
334            self,  # type: ignore[reportArgumentType]
335            self._obstacles,
336            pg.sprite.collide_mask,
337        )
338
339        collision = bool(obstacle_hit)
340
341        # Reverse direction when colliding with an obstacle.
342        if collision and not self._still_stuck:
343            self.move.rotate_ip(180)
344            self._still_stuck = True
345
346        if not collision:
347            self._still_stuck = False
348
349        # Random opportunity to slightly change angle.
350        # Probabilities are pre-computed so a seed could be used.
351        should_change_angle = prng.random()
352        deg = prng.uniform(-10, 10)
353
354        # Only allow the angle opportunity to take place when no collisions have occured.
355        # This is done so an agent always turns 180 degrees. Any small change in the number of degrees
356        # allows the agent to possibly escape the obstacle.
357        if not collision and not self._still_stuck and should_change_angle < 0.25:  # noqa: PLR2004
358            self.move.rotate_ip(deg)
359
360        # Actually update the position at last.
361        self.pos += self.move

Change the position of the agent.

The agent's new position is calculated as follows:

  1. The agent checks whether it's outside of the visible screen area. If this is the case, then the agent will be teleported to the other edge of the screen.
  2. If the agent collides with any obstacles, then the agent will turn around 180 degrees.
  3. If the agent has not collided with any obstacles, it will have the opportunity to slightly change its angle.
def in_proximity_accuracy(self) -> 'Generator[tuple[Agent[ConfigClass], float]]':
363    def in_proximity_accuracy(
364        self,
365    ) -> Generator[tuple[Agent[ConfigClass], float]]:
366        """Retrieve other agents that are in the `vi.config.Schema.radius` of the current agent.
367
368        This proximity method calculates the distances between agents to determine whether
369        an agent is in the radius of the current agent.
370
371        To calculate the distances between agents, up to four chunks have to be retrieved from the Proximity Engine.
372        These extra lookups, in combination with the vector distance calculation, have a noticable impact on performance.
373        Note however that this performance impact is only noticable with >1000 agents.
374
375        If you want to speed up your simulation at the cost of some accuracy,
376        consider using the `in_proximity_performance` method instead.
377
378        This function doesn't return the agents as a `list` or as a `set`.
379        Instead, you are given a generator.
380
381        Examples
382        --------
383        Count the number of agents that are in proximity
384        and change to image `1` if there is at least one agent nearby.
385
386        ```python
387        from vi.util import count
388
389
390        class MyAgent(Agent):
391            def update(self) -> None:
392                if count(self.in_proximity_accuracy()) >= 1:
393                    self.change_image(1)
394                else:
395                    self.change_image(0)
396        ```
397
398        Kill the first `Human` agent that's in proximity.
399
400        ```python
401        class Zombie(Agent):
402            def update(self) -> None:
403                for agent, _ in self.in_proximity_accuracy():
404                    # Don't want to kill other zombies
405                    if isinstance(Agent, Human):
406                        agent.kill()
407                        break
408        ```
409
410        Calculate the average distance of agents that are in proximity.
411
412        ```python
413        from statistics import fmean
414
415        class Heimerdinger(Agent):
416            def update(self) -> None:
417                distances = [dist for _, dist in self.in_proximity_accuracy()]
418                dist_mean = fmean(distances) if len(distances) > 0 else 0
419        ```
420
421        """
422        return self.__simulation._proximity.in_proximity_accuracy(self)

Retrieve other agents that are in the vi.config.Schema.radius of the current agent.

This proximity method calculates the distances between agents to determine whether an agent is in the radius of the current agent.

To calculate the distances between agents, up to four chunks have to be retrieved from the Proximity Engine. These extra lookups, in combination with the vector distance calculation, have a noticable impact on performance. Note however that this performance impact is only noticable with >1000 agents.

If you want to speed up your simulation at the cost of some accuracy, consider using the in_proximity_performance method instead.

This function doesn't return the agents as a list or as a set. Instead, you are given a generator.

Examples

Count the number of agents that are in proximity and change to image 1 if there is at least one agent nearby.

from vi.util import count


class MyAgent(Agent):
    def update(self) -> None:
        if count(self.in_proximity_accuracy()) >= 1:
            self.change_image(1)
        else:
            self.change_image(0)

Kill the first Human agent that's in proximity.

class Zombie(Agent):
    def update(self) -> None:
        for agent, _ in self.in_proximity_accuracy():
            # Don't want to kill other zombies
            if isinstance(Agent, Human):
                agent.kill()
                break

Calculate the average distance of agents that are in proximity.

from statistics import fmean

class Heimerdinger(Agent):
    def update(self) -> None:
        distances = [dist for _, dist in self.in_proximity_accuracy()]
        dist_mean = fmean(distances) if len(distances) > 0 else 0
def in_proximity_performance(self) -> 'Generator[Agent[ConfigClass]]':
424    def in_proximity_performance(
425        self,
426    ) -> Generator[Agent[ConfigClass]]:
427        """Retrieve other agents that are in the `vi.config.Schema.radius` of the current agent.
428
429        Unlike `in_proximity_accuracy`, this proximity method does not calculate the distances between agents.
430        Instead, it retrieves agents that are in the same chunk as the current agent,
431        irrespective of their position within the chunk.
432
433        If you find yourself limited by the performance of `in_proximity_accuracy`,
434        you can swap the function call for this one instead.
435        This performance method roughly doubles the frame rates of the simulation.
436        """
437        return self.__simulation._proximity.in_proximity_performance(self)

Retrieve other agents that are in the vi.config.Schema.radius of the current agent.

Unlike in_proximity_accuracy, this proximity method does not calculate the distances between agents. Instead, it retrieves agents that are in the same chunk as the current agent, irrespective of their position within the chunk.

If you find yourself limited by the performance of in_proximity_accuracy, you can swap the function call for this one instead. This performance method roughly doubles the frame rates of the simulation.

def on_site(self) -> bool:
439    def on_site(self) -> bool:
440        """Check whether the agent is currently on a site.
441
442        Examples
443        --------
444        Stop the agent's movement when it reaches a site (think of a nice beach).
445
446        ```python
447        class TravellingAgent(Agent):
448            def update(self) -> None:
449                if self.on_site():
450                    # crave that star damage
451                    self.freeze_movement()
452        ```
453
454        """
455        return self.on_site_id() is not None

Check whether the agent is currently on a site.

Examples

Stop the agent's movement when it reaches a site (think of a nice beach).

class TravellingAgent(Agent):
    def update(self) -> None:
        if self.on_site():
            # crave that star damage
            self.freeze_movement()
def on_site_id(self) -> int | None:
457    def on_site_id(self) -> int | None:
458        """Get the identifier of the site the agent is currently on.
459
460        Examples
461        --------
462        Stop the agent's movement when it reaches a site to inspect.
463        In addition, the current site identifier is saved to the DataFrame.
464
465        ```python
466        class SiteInspector(Agent):
467            def update(self) -> None:
468                site_id = self.on_site_id()
469
470                # Save the site id to the DataFrame
471                self.save_data("site", site_id)
472
473                # bool(site_id) would be inaccurate
474                # as a site_id of 0 will return False.
475                # Therefore, we check whether it is not None instead.
476                if site_id is not None:
477                    # Inspect the site
478                    self.freeze_movement()
479        ```
480
481        """
482        site: _StaticSprite | None = pg.sprite.spritecollideany(
483            self,  # type: ignore[reportArgumentType]
484            self._sites,
485            pg.sprite.collide_mask,
486        )
487
488        if site is not None:
489            return site.id
490        return None

Get the identifier of the site the agent is currently on.

Examples

Stop the agent's movement when it reaches a site to inspect. In addition, the current site identifier is saved to the DataFrame.

class SiteInspector(Agent):
    def update(self) -> None:
        site_id = self.on_site_id()

        # Save the site id to the DataFrame
        self.save_data("site", site_id)

        # bool(site_id) would be inaccurate
        # as a site_id of 0 will return False.
        # Therefore, we check whether it is not None instead.
        if site_id is not None:
            # Inspect the site
            self.freeze_movement()
def freeze_movement(self) -> None:
492    def freeze_movement(self) -> None:
493        """Freeze the movement of the agent. The movement can be continued by calling `continue_movement`."""
494        self._moving = False

Freeze the movement of the agent. The movement can be continued by calling continue_movement.

def continue_movement(self) -> None:
496    def continue_movement(self) -> None:
497        """Continue the movement of the agent from before its movement was frozen."""
498        self._moving = True

Continue the movement of the agent from before its movement was frozen.

def change_image(self, index: int) -> None:
500    def change_image(self, index: int) -> None:
501        """Change the image of the agent.
502
503        If you want to change the agent's image to the second image in the images list,
504        then you can change the image to index 1:
505
506        ```python
507        self.change_image(1)
508        ```
509        """
510        self._image_index = index

Change the image of the agent.

If you want to change the agent's image to the second image in the images list, then you can change the image to index 1:

self.change_image(1)
def save_data(self, column: str, value: Any) -> None:
512    def save_data(self, column: str, value: Any) -> None:  # noqa: ANN401
513        """Add extra data to the simulation's metrics.
514
515        The following data is collected automatically:
516        - agent identifier
517        - current frame
518        - x and y coordinates
519
520        Examples
521        --------
522        Saving the number of agents that are currently in proximity:
523
524        ```python
525        from vi.util import count
526
527
528        class MyAgent(Agent):
529            def update(self) -> None:
530                in_proximity = count(self.in_proximity_accuracy())
531
532                self.save_data("in_proximity", in_proximity)
533        ```
534
535        """
536        self.__simulation._metrics._temporary_snapshots[column].append(value)

Add extra data to the simulation's metrics.

The following data is collected automatically:

  • agent identifier
  • current frame
  • x and y coordinates

Examples

Saving the number of agents that are currently in proximity:

from vi.util import count


class MyAgent(Agent):
    def update(self) -> None:
        in_proximity = count(self.in_proximity_accuracy())

        self.save_data("in_proximity", in_proximity)
def reproduce(self) -> Self:
571    def reproduce(self) -> Self:
572        """Create a new agent and spawn it into the simulation.
573
574        All values will be reset to their defaults,
575        except for the agent's position and movement vector.
576        These will be cloned from the original agent.
577        """
578        return copy(self)

Create a new agent and spawn it into the simulation.

All values will be reset to their defaults, except for the agent's position and movement vector. These will be cloned from the original agent.

def is_dead(self) -> bool:
591    def is_dead(self) -> bool:
592        """Is the agent dead?
593
594        Death occurs when `kill` is called.
595        """
596        return not self.is_alive()

Is the agent dead?

Death occurs when kill is called.

def is_alive(self) -> bool:
598    def is_alive(self) -> bool:
599        """Is the agent still alive?
600
601        Death occurs when `kill` is called.
602        """
603        return super().alive()

Is the agent still alive?

Death occurs when kill is called.