diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f03abf81..05a72d107 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/arcade/__init__.py b/arcade/__init__.py index b85a49048..3a070143a 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -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 @@ -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", diff --git a/arcade/examples/sprite_push_out.py b/arcade/examples/sprite_push_out.py new file mode 100644 index 000000000..637c3bcbe --- /dev/null +++ b/arcade/examples/sprite_push_out.py @@ -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() diff --git a/arcade/sprite_list/__init__.py b/arcade/sprite_list/__init__.py index 07e3fe24e..4ff968007 100644 --- a/arcade/sprite_list/__init__.py +++ b/arcade/sprite_list/__init__.py @@ -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, @@ -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", diff --git a/arcade/sprite_list/collision.py b/arcade/sprite_list/collision.py index 8bb1a765b..7897cc04a 100644 --- a/arcade/sprite_list/collision.py +++ b/arcade/sprite_list/collision.py @@ -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 @@ -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]], diff --git a/doc/example_code/images/sprite_push_out.png b/doc/example_code/images/sprite_push_out.png new file mode 100644 index 000000000..784a8795a Binary files /dev/null and b/doc/example_code/images/sprite_push_out.png differ diff --git a/doc/example_code/index.rst b/doc/example_code/index.rst index 7da70d943..867c875ac 100644 --- a/doc/example_code/index.rst +++ b/doc/example_code/index.rst @@ -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` + diff --git a/doc/example_code/sprite_push_out.rst b/doc/example_code/sprite_push_out.rst new file mode 100644 index 000000000..9f2dbfa0b --- /dev/null +++ b/doc/example_code/sprite_push_out.rst @@ -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: diff --git a/tests/unit/sprite/test_sprite_collision.py b/tests/unit/sprite/test_sprite_collision.py index bbda724b9..5cbb4cb55 100644 --- a/tests/unit/sprite/test_sprite_collision.py +++ b/tests/unit/sprite/test_sprite_collision.py @@ -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)