Source code for arcade.physics_engines
"""
Physics engines for top-down or platformers.
"""
from __future__ import annotations
import math
from typing import Iterable
from arcade import (
BasicSprite,
Sprite,
SpriteList,
SpriteType,
check_for_collision,
check_for_collision_with_lists,
)
from arcade.math import get_distance
__all__ = ["PhysicsEngineSimple", "PhysicsEnginePlatformer"]
from arcade.utils import Chain, copy_dunders_unimplemented
def _wiggle_until_free(colliding: Sprite, walls: Iterable[SpriteList]) -> None:
"""Kludge to 'guess' a colliding sprite out of a collision.
It works by iterating over increasing wiggle sizes of 8 points
around the ``colliding`` sprite's original center position. Each
time it fails to find a free position. Although the wiggle distance
starts at 1, it grows quickly since each failed iteration multiplies
wiggle distance by two.
Args:
colliding:
A sprite to move out of the given list of SpriteLists.
walls:
A list of walls to guess our way out of.
"""
# Original x & y of the moving object
o_x, o_y = colliding.position
# fmt: off
try_list = [ # Allocate once so we don't recreate or gc
0.0, 0.0, 0.0, 0.0,
0.0, 0.0, 0.0, 0.0,
0.0, 0.0, 0.0, 0.0,
0.0, 0.0, 0.0, 0.0,
]
wiggle_distance = 1
while True:
# Cache our variant dimensions
o_x_plus = o_x + wiggle_distance
o_y_plus = o_y + wiggle_distance
o_x_minus = o_x - wiggle_distance
o_y_minus = o_y - wiggle_distance
# Burst setting of no-gc region is cheaper than nested lists
try_list[:] = (
o_x , o_y_plus ,
o_x , o_y_minus,
o_x_plus , o_y ,
o_x_minus , o_y ,
o_x_plus , o_y_plus ,
o_x_plus , o_y_minus,
o_x_minus , o_y_plus ,
o_x_minus , o_y_minus
)
# fmt: on
# Iterate and slice the try_list
for strided_index in range(0, 16, 2):
x, y = try_list[strided_index:strided_index + 2]
colliding.position = x, y
check_hit_list = check_for_collision_with_lists(colliding, walls)
# print(f"Vary {vary} ({trapped.center_x} {trapped.center_y}) "
# f"= {len(check_hit_list)}")
if len(check_hit_list) == 0:
return
wiggle_distance *= 2
def _move_sprite(
moving_sprite: Sprite, can_collide: Iterable[SpriteList[SpriteType]], ramp_up: bool
) -> list[SpriteType]:
"""Update a sprite's angle and position, returning a list of collisions.
The steps covered are:
#. Rotation
#. Move in the y direction
#. Move in the x direction
Args:
moving_sprite:
The sprite to move.
can_collide:
An iterable source of SpriteList objects which can be
collided with.
ramp_up:
Whether to enable platformer-like ramp support for x
direction movement.
Returns:
A list of other individual sprites the ``moving_sprite``
collided with.
"""
# See if we are starting this turn with a sprite already colliding with us.
if len(check_for_collision_with_lists(moving_sprite, can_collide)) > 0:
_wiggle_until_free(moving_sprite, can_collide)
original_x, original_y = moving_sprite.position
original_angle = moving_sprite.angle
# --- Rotate
rotating_hit_list = []
if moving_sprite.change_angle:
# Rotate
moving_sprite.angle += moving_sprite.change_angle
# Resolve collisions caused by rotating
rotating_hit_list = check_for_collision_with_lists(moving_sprite, can_collide)
if len(rotating_hit_list) > 0:
max_distance = (moving_sprite.width + moving_sprite.height) / 2
# Resolve any collisions by this weird kludge
_wiggle_until_free(moving_sprite, can_collide)
if (
get_distance(original_x, original_y, moving_sprite.center_x, moving_sprite.center_y)
> max_distance
):
# Ok, glitched trying to rotate. Reset.
moving_sprite.position = original_x, original_y
moving_sprite.angle = original_angle
# --- Move in the y direction
moving_sprite.center_y += moving_sprite.change_y
# Check for wall hit
hit_list_x = check_for_collision_with_lists(moving_sprite, can_collide)
# print(f"Post-y move {hit_list_x}")
complete_hit_list = hit_list_x
# If we hit a wall, move so the edges are at the same point
if len(hit_list_x) > 0:
if moving_sprite.change_y > 0:
while len(check_for_collision_with_lists(moving_sprite, can_collide)) > 0:
moving_sprite.center_y -= 1
# print(f"Spot X ({self.player_sprite.center_x}, {self.player_sprite.center_y})"
# f" {self.player_sprite.change_y}")
elif moving_sprite.change_y < 0:
# Reset number of jumps
for item in hit_list_x:
while check_for_collision(moving_sprite, item):
# self.player_sprite.bottom = item.top <- Doesn't work for ramps
moving_sprite.center_y += 0.25
# NOTE: Not all sprites have velocity
if getattr(item, "change_x", 0.0) != 0:
moving_sprite.center_x += item.change_x # type: ignore
# print(f"Spot Y ({self.player_sprite.center_x}, {self.player_sprite.center_y})")
else:
pass
# TODO: The code below can't execute, as "item" doesn't
# exist. In theory, this condition should never be arrived at.
# Collision while player wasn't moving, most likely
# moving platform.
# if self.player_sprite.center_y >= item.center_y:
# self.player_sprite.bottom = item.top
# else:
# self.player_sprite.top = item.bottom
moving_sprite.change_y = min(0.0, getattr(hit_list_x[0], "change_y", 0.0))
# print(f"Spot D ({self.player_sprite.center_x}, {self.player_sprite.center_y})")
moving_sprite.center_y = round(moving_sprite.center_y, 2)
# print(f"Spot Q ({self.player_sprite.center_x}, {self.player_sprite.center_y})")
# end_time = time.time()
# print(f"Move 1 - {end_time - start_time:7.4f}")
# start_time = time.time()
loop_count = 0
# --- Move in the x direction
if moving_sprite.change_x:
# Keep track of our current y, used in ramping up
almost_original_y = moving_sprite.center_y
# Strip off sign so we only have to write one version of this for
# both directions
direction = math.copysign(1, moving_sprite.change_x)
cur_x_change = abs(moving_sprite.change_x)
upper_bound = cur_x_change
lower_bound: float = 0
cur_y_change: float = 0
exit_loop = False
while not exit_loop:
loop_count += 1
# print(f"{cur_x_change=}, {upper_bound=}, {lower_bound=}, {loop_count=}")
# Move sprite and check for collisions
moving_sprite.center_x = original_x + cur_x_change * direction
collision_check = check_for_collision_with_lists(moving_sprite, can_collide)
# Update collision list
for sprite in collision_check:
if sprite not in complete_hit_list:
complete_hit_list.append(sprite)
# Did we collide?
if len(collision_check) > 0:
# We did collide. Can we ramp up and not collide?
if ramp_up:
cur_y_change = cur_x_change
moving_sprite.center_y = original_y + cur_y_change
collision_check = check_for_collision_with_lists(moving_sprite, can_collide)
if len(collision_check) > 0:
cur_y_change -= cur_x_change
else:
while (len(collision_check) == 0) and cur_y_change > 0:
# print("Ramp up check")
cur_y_change -= 1
moving_sprite.center_y = almost_original_y + cur_y_change
collision_check = check_for_collision_with_lists(
moving_sprite, can_collide
)
cur_y_change += 1
collision_check = []
if len(collision_check) > 0:
# print(f"Yes @ {cur_x_change}")
upper_bound = cur_x_change - 1
if upper_bound - lower_bound <= 0:
cur_x_change = lower_bound
exit_loop = True
# print(f"Exit 2 @ {cur_x_change}")
else:
cur_x_change = (upper_bound + lower_bound) // 2
else:
exit_loop = True
# print(f"Exit 1 @ {cur_x_change}")
else:
# No collision. Keep this new position and exit
lower_bound = cur_x_change
if upper_bound - lower_bound <= 0:
# print(f"Exit 3 @ {cur_x_change}")
exit_loop = True
else:
# print(f"No @ {cur_x_change}")
cur_x_change = (upper_bound + lower_bound) // 2 + (
upper_bound + lower_bound
) % 2
# print(cur_x_change * direction, cur_y_change)
moved_x = original_x + cur_x_change * direction
moved_y = almost_original_y + cur_y_change
moving_sprite.position = moved_x, moved_y
# print(
# f"({moving_sprite.center_x}, {moving_sprite.center_y}) "
# f"{cur_x_change * direction}, {cur_y_change}"
# )
# Add in rotating hit list
for sprite in rotating_hit_list:
if sprite not in complete_hit_list:
complete_hit_list.append(sprite)
# end_time = time.time()
# print(f"Move 2 - {end_time - start_time:7.4f} {loop_count}")
return complete_hit_list
def _add_to_list(dest: list[SpriteList], source: SpriteList | Iterable[SpriteList] | None) -> None:
"""Helper function to add a SpriteList or list of SpriteLists to a list."""
if not source:
return
elif isinstance(source, SpriteList):
dest.append(source)
else:
dest.extend(source)
[docs]
@copy_dunders_unimplemented
class PhysicsEngineSimple:
"""A basic physics engine best for single-player top-down games.
This is the easiest engine to get started with. It's best when:
* You need a top-down view
* You need to collide with non-moving terrain
* You don't need anything else
For side-scrolling games focused on jumping puzzles, you may want
the :py:class:`PhysicsEnginePlatformer` instead. Experienced users
may want to try the
:py:class:`~arcade.pymunk_physics_engine.PymunkPhysicsEngine`.
Args:
player_sprite:
A sprite which will be controlled by the player.
walls:
A :py:class:`.SpriteList` or :py:class:`list` of them which
should stop player movement.
"""
def __init__(
self,
player_sprite: Sprite,
walls: SpriteList | Iterable[SpriteList] | None = None,
) -> None:
self.player_sprite: Sprite = player_sprite
"""The player-controlled :py:class:`.Sprite`."""
self._walls: list[SpriteList] = []
if walls:
_add_to_list(self._walls, walls)
@property
def walls(self) -> list[SpriteList]:
"""Which :py:class:`.SpriteList` instances block player movement.
.. important:: Avoid moving sprites in these lists!
Doing so incurs performance costs.
See :py:class:`PhysicsEnginePlatformer.walls` for further information.
For platformer physics such as moving platforms and gravity, consider
using the :py:class:`PhysicsEnginePlatformer`.
"""
return self._walls
@walls.setter
def walls(self, walls: SpriteList | Iterable[SpriteList] | None = None) -> None:
if walls:
_add_to_list(self._walls, walls)
else:
self._walls.clear()
@walls.deleter
def walls(self) -> None:
self._walls.clear()
[docs]
def update(self) -> list[BasicSprite]:
"""Move :py:attr:`player_sprite` and return any colliding sprites.
Returns:
A :py:class:`list` of the colliding sprites. If there were
zero collisions, it will be empty.
"""
return _move_sprite(self.player_sprite, self.walls, ramp_up=False)
[docs]
@copy_dunders_unimplemented
class PhysicsEnginePlatformer:
"""A single-player engine with gravity and moving platform support.
.. _Super Mario Bros.: https://en.wikipedia.org/wiki/Super_Mario_Bros.
.. _Rayman: https://en.wikipedia.org/wiki/Rayman_(video_game)
This engine is best for simple versions of platformer games like
the `Super Mario Bros.`_ (the first Mario game) and `Rayman`_. It
is more important to pay attention to the performance tips with
this engine than with :py:class:`PhysicsEngineSimple`.
.. important:: For best performance, you must put your sprites
in the right group!
Be sure to add each :py:class:`.Sprite` and :py:class:`.SpriteList`
to the right group, regardless of whether you do so via arguments
or properties:
.. list-table::
:header-rows: 1
* - Creation argument
- Purpose
- :py:class:`list` property
* - ``walls``
- Non-moving sprites the player can stand on.
- :py:attr:`walls`
* - ``platforms``
- Sprites the player can stand on, but which can still move.
- :py:attr:`platforms`
* - ``ladders``
- Ladders which allow gravity-free movement while touched by
the :py:attr:`player_sprite`.
- :py:attr:`ladders`
To learn about the automatic moving platform feature, please see
:py:attr:`platforms`.
Not that if you use the :py:class:`list` properties above, you can
add or remove :py:attr:`platforms` in response to game events. It
is also possible to add new sprites such as terrain in response to
gameplay events, but there may be performance implications due to
the way :py:class:`SpriteList` handles spatial hashing. To learn
more, see :py:attr:`walls`.
Args:
player_sprite:
The player character's sprite. It will be stored on the engine
as :py:attr:`player_sprite`.
platforms:
The initial list of :py:attr:`platforms`, sprites which can move
freely.
gravity_constant:
A constant to subtract from the :py:attr:`player_sprite`'s
velocity (:py:attr:`.Sprite.change_y`) each :py:meth:`update`
when in the air. See :py:attr:`gravity_constant` to learn
more.
ladders:
:py:class:`.Sprite` instances the player can climb without
being affected by gravity.
walls:
:py:class:`Sprite` instances which are static and never move.
**Do not put moving sprites into this!** See :py:attr:`walls`
to learn more.
"""
def __init__(
self,
player_sprite: Sprite,
platforms: SpriteList | Iterable[SpriteList] | None = None,
gravity_constant: float = 0.5,
ladders: SpriteList | Iterable[SpriteList] | None = None,
walls: SpriteList | Iterable[SpriteList] | None = None,
) -> None:
if not isinstance(player_sprite, Sprite):
raise TypeError("player_sprite must be a Sprite, not a basic_sprite!")
self._ladders: list[SpriteList] = []
self._platforms: list[SpriteList] = []
self._walls: list[SpriteList] = []
self._all_obstacles = Chain(self._walls, self._platforms)
_add_to_list(self._ladders, ladders)
_add_to_list(self._platforms, platforms)
_add_to_list(self._walls, walls)
self.player_sprite: Sprite = player_sprite
""" The sprite controlled by the player.
.. important:: This **must** be a :py:class:`.Sprite` or a
subclass of it!
You can't use :py:class:`.BasicSprite` since it lacks
required :py:attr:`~.Sprite.change_y` property.
"""
self.gravity_constant: float = gravity_constant
"""The player's default downward acceleration.
The engine's :py:meth:`update` method subtracts this value
from the :py:attr:`player_sprite`'s :py:attr:`~.Sprite.change_y`
when the player is not touching a sprite in :py:attr:`ladders` or
:py:attr:`walls`.
You can change the value of gravity after engine creation through
this attribute. In addition to responding to GUI events, you can
also change gravity in response to game events such as touching
power-ups.
Values for ``gravity_constant`` work as follows:
.. list-table::
:header-rows: 1
* - ``gravity_constant``
- Effect
* - Greater than zero
- Gravity points downward as expected
* - Less than zero
- Player falls upward (Consider adding a ceiling)
* - Zero
- No gravity
To learn more, please see the following parts of the
:ref:`platformer_tutorial`:
* :ref:`platformer_part_five`
* :ref:`platformer_part_twelve`
"""
self.jumps_since_ground: int = 0
"""How many times the player has jumped since touching a
sprite in :py:attr:`walls`.
This is used throughout the engine's logic, including
the :py:meth:`jump` and :py:meth:`can_jump` methods.
"""
self.allowed_jumps: int = 1
"""Total number of jumps the player should be capable of.
This includes the first jump. To enable multi-jump, see
:py:meth:`enable_multi_jump` instead.
"""
self.allow_multi_jump: bool = False
"""Whether multi-jump is enabled.
For ease of use in simple games, you may want to use the
following methods instead of setting this directly:
* :py:meth:`enable_multi_jump`
* :py:meth:`disable_multi_jump`
"""
# The property object for ladders. This allows us setter/getter/deleter
# capabilities in safe manner
# TODO: figure out what do do with 15_ladders_moving_platforms.py
# It's no longer used by any example or tutorial file
@property
def ladders(self) -> list[SpriteList]:
"""Ladders turn off gravity while touched by the player.
This means that whenever the :py:attr:`player_sprite` collides
with any any :py:class:`.Sprite` or :py:class:`.BasicSprite` in this
list, the following are true:
* The :py:attr:`gravity_constant` is not subtracted from
:py:attr:`player_sprite`\'s :py:attr:`~.Sprite.change_y`
during :py:meth:`update` calls
* The player may otherwise move as freely as you allow
"""
return self._ladders
@ladders.setter
def ladders(self, ladders: SpriteList | Iterable[SpriteList] | None = None) -> None:
if ladders:
_add_to_list(self._ladders, ladders)
else:
self._ladders.clear()
@ladders.deleter
def ladders(self) -> None:
self._ladders.clear()
@property
def platforms(self) -> list[SpriteList]:
""":py:class:`~arcade.sprite_list.sprite_list.SpriteList` instances containing platforms.
.. important:: For best performance, put non-moving terrain in
:py:attr:`.walls` instead.
Platforms are intended to support automatic movement by setting
the appropriate attributes.
You can enable automatic motion by setting one of the following
attribute pairs on a :py:class:`~arcade.sprite.sprite.Sprite`:
.. list-table::
:header-rows: 1
* - Movement Axis
- :py:class:`~arcade.sprite.sprite.Sprite` Attributes to Set
* - X (side to side)
- * :py:attr:`~arcade.sprite.sprite.Sprite.change_x`
* :py:attr:`~arcade.sprite.sprite.Sprite.boundary_left`
* :py:attr:`~arcade.sprite.sprite.Sprite.boundary_right`
* - Y (up and down)
- * :py:attr:`~arcade.sprite.sprite.Sprite.change_y`
* :py:attr:`~arcade.sprite.sprite.Sprite.boundary_bottom`
* :py:attr:`~arcade.sprite.sprite.Sprite.boundary_top`
For a working example, please see :ref:`sprite_moving_platforms`.
"""
return self._platforms
@platforms.setter
def platforms(self, platforms: SpriteList | Iterable[SpriteList] | None = None) -> None:
if platforms:
_add_to_list(self._platforms, platforms)
else:
self._platforms.clear()
@platforms.deleter
def platforms(self) -> None:
self._platforms.clear()
@property
def walls(self) -> list[SpriteList]:
"""Exposes the :py:class:`SpriteList` instances use as terrain.
.. important:: For best performance, only add non-moving sprites!
The walls lists make a tradeoff through **spatial hashing**:
* Collision checking against sprites in the list becomes
very fast
* Moving sprites or adding new ones becomes very slow
This is worth the tradeoff for non-moving terrain, but it means
you have to be careful. If you move too many sprites in the walls
lists every frame, your game may slow down. For moving sprites
the player can stand and jump on, see the :py:attr:`platforms`
feature.
To learn more about spatial hashing, please see the following:
* :ref:`collision_detection_performance`
* :py:class:`~arcade.sprite_list.spatial_hash.SpatialHash`
"""
return self._walls
@walls.setter
def walls(self, walls: SpriteList | Iterable[SpriteList] | None = None) -> None:
if walls:
_add_to_list(self._walls, walls)
else:
self._walls.clear()
@walls.deleter
def walls(self) -> None:
self._walls.clear()
[docs]
def is_on_ladder(self) -> bool:
"""Check if the :py:attr:`player_sprite` touches any :py:attr:`ladders`.
.. warning:: This runs collisions **every** time it is called!
Returns:
``True`` if the :py:attr:`player_sprite` touches any
:py:attr:`ladders`.
"""
if self.ladders:
hit_list = check_for_collision_with_lists(self.player_sprite, self.ladders)
if len(hit_list) > 0:
return True
return False
[docs]
def can_jump(self, y_distance: float = 5) -> bool:
"""Update jump state and return ``True`` if the player can jump.
.. warning:: This runs collisions **every** time it is called!
If you are thinking of calling this repeatedly, first double-check
whether you can store the returned value to a local variable instead.
The player can jump when at least one of the following are true:
after updating state:
.. list-table::
:header-rows: 0
* - The player is "touching" the ground
- :py:attr:`player_sprite`\'s :py:attr:`.BasicSprite.center_y`
is within ``y_distance`` of any sprite in :py:attr:`walls`
or :py:attr:`platforms`
* - The player can air-jump
- :py:attr:`allow_multi_jump` is ``True`` and the player
hasn't jumped more than :py:attr:`allowed_jumps` times
Args:
y_distance:
The distance to temporarily move the :py:attr:`player_sprite`
downward before checking for a collision with either
:py:attr:`walls` or :py:attr:`platforms`.
Returns:
``True`` if the player can jump.
"""
# Temporarily move the player down to collide floor-like sprites
self.player_sprite.center_y -= y_distance
hit_list = check_for_collision_with_lists(self.player_sprite, self._all_obstacles)
self.player_sprite.center_y += y_distance
# Reset the number jumps if the player touched a floor-like sprite
if len(hit_list) > 0:
self.jumps_since_ground = 0
if (
len(hit_list) > 0
or self.allow_multi_jump
and self.jumps_since_ground < self.allowed_jumps
):
return True
else:
return False
[docs]
def enable_multi_jump(self, allowed_jumps: int) -> None:
"""Enable multi-jump.
The ``allowed_jumps`` argument is the total number of jumps
the player should be able to make, including the first from
solid ground in :py:attr:`walls` or any :py:attr:`platforms`.
It will be stored as :py:attr:`allowed_jumps`.
.. important:: If you override :py:meth:`jump`, be sure to call
:py:meth:`increment_jump_counter` inside it!
Otherwise, the player may be able to jump forever.
Args:
allowed_jumps:
Total number of jumps the player should be capable of,
including the first.
"""
self.allowed_jumps = allowed_jumps
self.allow_multi_jump = True
[docs]
def disable_multi_jump(self) -> None:
"""Disable multi-jump.
Calling this function removes the requirement for :py:meth:`jump`
to call :py:meth:`increment_jump_counter` with each jump to
prevent infinite jumping.
"""
self.allow_multi_jump = False
self.allowed_jumps = 1
self.jumps_since_ground = 0
[docs]
def jump(self, velocity: float) -> None:
"""Jump with an initial upward velocity.
This works as follows:
#. Set the :py:attr:`player_sprite`\'s :py:attr:`~.Sprite.change_y`
to the passed ``velocity``
#. Call :py:meth:`increment_jump_counter`
Args:
velocity:
A positive value to set the player's y velocity to.
"""
self.player_sprite.change_y = velocity
self.increment_jump_counter()
[docs]
def increment_jump_counter(self) -> None:
"""Update jump tracking if multi-jump is enabled.
If :py:attr:`allow_multi_jumps` is ``True``, calling this adds
``1`` to :py:attr:`jumps_since_ground`. Otherwise, it does
nothing.
"""
if self.allow_multi_jump:
self.jumps_since_ground += 1
[docs]
def update(self) -> list[BasicSprite]:
"""Move the player and platforms, then return colliding sprites.
The returned sprites will in a :py:class:`list` of individual
sprites taken from all :py:class:`arcade.SpriteList` instances
in the following:
* :attr:`~platforms`
* :attr:`~walls`
The :py:attr:`~ladders` are not included.
Returns:
A list of all sprites the player collided with. If there
were no collisions, the list will be empty.
"""
# start_time = time.time()
# print(f"Spot A ({self.player_sprite.center_x}, {self.player_sprite.center_y})")
# --- Add gravity if we aren't on a ladder
if not self.is_on_ladder():
self.player_sprite.change_y -= self.gravity_constant
# print(f"Spot F ({self.player_sprite.center_x}, {self.player_sprite.center_y})")
# print(f"Spot B ({self.player_sprite.center_x}, {self.player_sprite.center_y})")
for platform_list in self.platforms:
for platform in platform_list:
if platform.change_x != 0 or platform.change_y != 0:
# Check x boundaries and move the platform in x direction
if platform.boundary_left and platform.left <= platform.boundary_left:
platform.left = platform.boundary_left
if platform.change_x < 0:
platform.change_x *= -1
if platform.boundary_right and platform.right >= platform.boundary_right:
platform.right = platform.boundary_right
if platform.change_x > 0:
platform.change_x *= -1
platform.center_x += platform.change_x
# Check y boundaries and move the platform in y direction
if platform.boundary_top is not None and platform.top >= platform.boundary_top:
platform.top = platform.boundary_top
if platform.change_y > 0:
platform.change_y *= -1
if (
platform.boundary_bottom is not None
and platform.bottom <= platform.boundary_bottom
):
platform.bottom = platform.boundary_bottom
if platform.change_y < 0:
platform.change_y *= -1
platform.center_y += platform.change_y
complete_hit_list = _move_sprite(self.player_sprite, self._all_obstacles, ramp_up=True)
# print(f"Spot Z ({self.player_sprite.center_x}, {self.player_sprite.center_y})")
# Return list of encountered sprites
# end_time = time.time()
# print(f"Update - {end_time - start_time:7.4f}\n")
return complete_hit_list