Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page.
- Added `arcade.has_collision_with_list()` and `arcade.has_collision_with_lists()`, which return `True` as soon as they find a collision. They're faster than checking whether `check_for_collision_with_list()` returns an empty list, by about 18x when many sprites overlap.
- Added `arcade.check_for_collision_between_lists(list_a, list_b)`, which returns every colliding `(sprite_a, sprite_b)` pair between two lists, such as bullets and enemies. Passing the same list twice returns each pair once.
- Added `arcade.get_collision_info(sprite1, sprite2)`, which returns a `CollisionInfo` with the smallest move that separates two colliding sprites: a unit `normal` (the direction to move `sprite1`) and a `depth` in pixels, or `None` if they don't collide. For example, `player.position += info.normal * info.depth` pushes a player out of a wall. Correct for convex hit boxes.
- Added `arcade.get_collision_info_with_list(sprite, sprite_list)`, which returns a `(sprite, CollisionInfo)` pair for each sprite in the list that `sprite` collides with, deepest overlap first. Added the `sprite_push_out` example, which uses it to push a player out of walls, sliding along rotated ones.

### Misc Changes
- Sped up several `SpriteList` operations: `swap()` no longer searches the draw order (about 1000x faster at the end of a 10,000 sprite list), `insert()` and item assignment check membership with a dictionary instead of scanning the list, and drawing uploads only the buffer slots in use instead of the whole capacity (moving one sprite and drawing is 1.6-2.3x faster).
Expand Down
2 changes: 2 additions & 0 deletions arcade/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ def configure_logging(level: int | None = None):
from .sprite_list import CollisionMethod
from .sprite_list import check_for_collision
from .sprite_list import get_collision_info
from .sprite_list import get_collision_info_with_list
from .sprite_list import check_for_collision_with_list
from .sprite_list import check_for_collision_with_lists
from .sprite_list import check_for_collision_between_lists
Expand Down Expand Up @@ -335,6 +336,7 @@ def configure_logging(level: int | None = None):
"astar_calculate_path",
"check_for_collision",
"get_collision_info",
"get_collision_info_with_list",
"check_for_collision_with_list",
"check_for_collision_with_lists",
"check_for_collision_between_lists",
Expand Down
133 changes: 133 additions & 0 deletions arcade/examples/sprite_push_out.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""
Push a Sprite Out of Walls

Move the player with the arrow keys. It isn't stopped by a physics engine;
instead, after each move, any walls it overlaps push it back out using
arcade.get_collision_info_with_list(). Pushing out along the smallest
overlap lets the player slide along walls, including the rotated ones.

Artwork from https://kenney.nl

If Python and Arcade are installed, this example can be run from the command line with:
python -m arcade.examples.sprite_push_out
"""

import arcade

SPRITE_SCALING = 0.5

WINDOW_WIDTH = 1280
WINDOW_HEIGHT = 720
WINDOW_TITLE = "Push a Sprite Out of Walls Example"

MOVEMENT_SPEED = 5

# A sprite can overlap more than one wall. Pushing it out of one can push
# it into another, so try a few times.
MAX_PUSHES = 4


class GameView(arcade.View):
"""Main application class."""

def __init__(self):
super().__init__()

self.player_sprite = arcade.Sprite(
":resources:images/animated_characters/female_person/femalePerson_idle.png",
scale=SPRITE_SCALING,
)
self.player_list = arcade.SpriteList()
self.player_list.append(self.player_sprite)

self.wall_list = arcade.SpriteList()

self.background_color = arcade.color.AMAZON

def setup(self):
"""Set up the game and initialize the variables."""
self.player_sprite.position = 200, 300

self.wall_list.clear()
wall_texture = ":resources:images/tiles/boxCrate_double.png"

# The walls of a room
for x in range(96, 1200, 64):
self.wall_list.append(
arcade.Sprite(wall_texture, scale=SPRITE_SCALING, center_x=x, center_y=96)
)
self.wall_list.append(
arcade.Sprite(wall_texture, scale=SPRITE_SCALING, center_x=x, center_y=608)
)
for y in range(160, 608, 64):
self.wall_list.append(
arcade.Sprite(wall_texture, scale=SPRITE_SCALING, center_x=96, center_y=y)
)
self.wall_list.append(
arcade.Sprite(wall_texture, scale=SPRITE_SCALING, center_x=1184, center_y=y)
)

# Rotated walls the player slides along instead of stopping at
for x, y, angle in ((450, 380, 45), (800, 260, -30), (950, 420, 15)):
self.wall_list.append(
arcade.Sprite(
wall_texture,
scale=SPRITE_SCALING * 2,
center_x=x,
center_y=y,
angle=angle,
)
)

def on_draw(self):
"""Render the screen."""
self.clear()
self.wall_list.draw()
self.player_list.draw()

def push_player_out_of_walls(self):
"""Move the player out of any walls it overlaps."""
for _ in range(MAX_PUSHES):
# Results are sorted with the deepest overlap first
hits = arcade.get_collision_info_with_list(self.player_sprite, self.wall_list)
if not hits:
return
_wall, info = hits[0]
# Move the smallest distance that separates the player from this wall
self.player_sprite.position += info.normal * info.depth

def on_update(self, delta_time):
"""Movement and game logic"""
self.player_sprite.update()
self.push_player_out_of_walls()

def on_key_press(self, key, modifiers):
"""Called whenever a key is pressed."""
if key == arcade.key.UP:
self.player_sprite.change_y = MOVEMENT_SPEED
elif key == arcade.key.DOWN:
self.player_sprite.change_y = -MOVEMENT_SPEED
elif key == arcade.key.LEFT:
self.player_sprite.change_x = -MOVEMENT_SPEED
elif key == arcade.key.RIGHT:
self.player_sprite.change_x = MOVEMENT_SPEED

def on_key_release(self, key, modifiers):
"""Called when the user releases a key."""
if key in (arcade.key.UP, arcade.key.DOWN):
self.player_sprite.change_y = 0
elif key in (arcade.key.LEFT, arcade.key.RIGHT):
self.player_sprite.change_x = 0


def main():
"""Main function"""
window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE)
game = GameView()
game.setup()
window.show_view(game)
arcade.run()


if __name__ == "__main__":
main()
2 changes: 2 additions & 0 deletions arcade/sprite_list/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
get_closest_sprite,
check_for_collision,
get_collision_info,
get_collision_info_with_list,
check_for_collision_with_list,
check_for_collision_with_lists,
check_for_collision_between_lists,
Expand All @@ -28,6 +29,7 @@
"get_closest_sprite",
"check_for_collision",
"get_collision_info",
"get_collision_info_with_list",
"check_for_collision_with_list",
"check_for_collision_with_lists",
"check_for_collision_between_lists",
Expand Down
63 changes: 63 additions & 0 deletions arcade/sprite_list/collision.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,11 @@ def get_collision_info(sprite1: BasicSprite, sprite2: BasicSprite) -> CollisionI
elif not isinstance(sprite2, BasicSprite):
raise TypeError("Parameter 2 is not an instance of a Sprite class.")

return _get_collision_info(sprite1, sprite2)


def _get_collision_info(sprite1: BasicSprite, sprite2: BasicSprite) -> CollisionInfo | None:
""":py:func:`get_collision_info` without the argument type checks."""
hit_box1 = sprite1._hit_box
hit_box2 = sprite2._hit_box

Expand Down Expand Up @@ -549,6 +554,64 @@ def check_for_collision_between_lists(
return pairs


def get_collision_info_with_list(
sprite: BasicSprite,
sprite_list: SpriteSequence[SpriteType],
method: CollisionMethod | int = CollisionMethod.AUTO,
) -> list[tuple[SpriteType, CollisionInfo]]:
"""
Find the sprites in a list that a sprite collides with, and how to separate them.

This works like :py:func:`check_for_collision_with_list`, but also
returns a :py:class:`CollisionInfo` for each colliding sprite, as from
:py:func:`get_collision_info`. The results are sorted deepest overlap
first, which is usually the one to resolve first::

for wall, info in arcade.get_collision_info_with_list(player, walls):
# Each move changes the remaining overlaps, so check again
info = arcade.get_collision_info(player, wall)
if info:
player.position += info.normal * info.depth

Each :py:class:`CollisionInfo` is how to separate ``sprite`` from that
one sprite. Moving ``sprite`` to resolve one overlap changes the others,
so they don't add up to a move that resolves them all.

Args:
sprite:
The sprite to separate
sprite_list:
SpriteList to check against
method:
How to find the sprites to check. See :py:class:`CollisionMethod`.
Defaults to :py:attr:`CollisionMethod.AUTO`.

Returns:
A list of ``(colliding_sprite, CollisionInfo)`` tuples, deepest first,
or an empty list.
"""
if __debug__:
if not isinstance(sprite, BasicSprite):
raise TypeError(
f"Parameter 1 is not an instance of the Sprite class, "
f"it is an instance of {type(sprite)}."
)
if not isinstance(sprite_list, SpriteSequence):
raise TypeError(f"Parameter 2 is a {type(sprite_list)} instead of expected SpriteList.")

results: list[tuple[SpriteType, CollisionInfo]] = []
for sprite2 in _get_sprites_to_check(sprite, sprite_list, method):
if sprite is not sprite2:
info = _get_collision_info(sprite, sprite2)
if info is not None:
results.append((sprite2, info))

# Deepest first. The sort is stable, so equal depths keep the order found.
if len(results) > 1:
results.sort(key=lambda result: result[1].depth, reverse=True)
return results


def check_for_collision_with_lists(
sprite: BasicSprite,
sprite_lists: Iterable[SpriteSequence[SpriteType]],
Expand Down
Binary file added doc/example_code/images/sprite_push_out.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions doc/example_code/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,12 @@ Player Movement

:ref:`sprite_rotate_around_tank`

.. figure:: images/thumbs/sprite_push_out.png
:figwidth: 170px
:target: sprite_push_out.html

:ref:`sprite_push_out`




Expand Down
20 changes: 20 additions & 0 deletions doc/example_code/sprite_push_out.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
:orphan:

.. _sprite_push_out:

Push a Sprite Out of Walls
==========================

.. image:: images/sprite_push_out.png
:width: 600px
:align: center
:alt: Screenshot of a player sprite being pushed out of rotated walls

Instead of a physics engine, this example moves the player freely and then
uses :py:func:`arcade.get_collision_info_with_list` to push it back out of
any walls it overlaps. Because the push is along the smallest overlap, the
player slides along walls, including rotated ones.

.. literalinclude:: ../../arcade/examples/sprite_push_out.py
:caption: sprite_push_out.py
:linenos:
48 changes: 48 additions & 0 deletions tests/unit/sprite/test_sprite_collision.py
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,54 @@ def test_get_collision_info_separates(window):
assert checked > 300


@pytest.mark.parametrize("method", list(arcade.CollisionMethod))
@pytest.mark.parametrize("spatial", [False, True])
def test_get_collision_info_with_list(window, method, spatial):
player = arcade.SpriteSolidColor(10, 10)
walls = arcade.SpriteList(use_spatial_hash=spatial)
shallow = arcade.SpriteSolidColor(10, 10, center_x=9) # 1 pixel overlap
deep = arcade.SpriteSolidColor(10, 10, center_y=-6) # 4 pixel overlap
touching = arcade.SpriteSolidColor(10, 10, center_x=-10)
far = arcade.SpriteSolidColor(10, 10, center_x=100)
walls.extend([shallow, deep, touching, far])
# A sprite never collides with itself
walls.append(player)

results = arcade.get_collision_info_with_list(player, walls, method=method)
# Deepest first, each matching get_collision_info
assert [wall for wall, _ in results] == [deep, shallow]
for wall, info in results:
assert info == arcade.get_collision_info(player, wall)
assert results[0][1] == (Vec2(0.0, 1.0), 4.0)
assert results[1][1] == (Vec2(-1.0, 0.0), 1.0)

# Same sprites as check_for_collision_with_list finds
hits = arcade.check_for_collision_with_list(player, walls, method=method)
assert {wall for wall, _ in results} == set(hits)

assert arcade.get_collision_info_with_list(player, arcade.SpriteList(), method=method) == []


def test_get_collision_info_with_list_equal_depths(window):
"""Equal depths keep the order the sprites were found in"""
player = arcade.SpriteSolidColor(10, 10)
walls = arcade.SpriteList()
left = arcade.SpriteSolidColor(10, 10, center_x=-8)
right = arcade.SpriteSolidColor(10, 10, center_x=8)
walls.extend([left, right])
results = arcade.get_collision_info_with_list(player, walls)
assert [wall for wall, _ in results] == [left, right]
assert [info.depth for _, info in results] == [2.0, 2.0]


def test_get_collision_info_with_list_type_errors(window):
sprite = arcade.SpriteSolidColor(10, 10)
with pytest.raises(TypeError):
arcade.get_collision_info_with_list("moo", arcade.SpriteList())
with pytest.raises(TypeError):
arcade.get_collision_info_with_list(sprite, "moo")


def test_check_for_collision_with_list(window):
# TODO: Check that the right collision function is called internally
a = arcade.SpriteSolidColor(50, 50, color=arcade.csscolor.RED)
Expand Down
Loading