From 1bca7eaaa0c9d6b411d4761f1673fa035210db6f Mon Sep 17 00:00:00 2001 From: Paul V Craven Date: Mon, 5 Oct 2026 15:44:02 -0500 Subject: [PATCH] Add sweep_sprite for collisions along a moving sprite's path sweep_sprite(sprite, dx, dy, sprite_list) checks the whole straight-line path of a moving sprite and returns a SweepInfo (sprite, fraction, distance, normal) for the first sprite it would hit, or None. Fast sprites can no longer jump over thin walls between frames. It doesn't move the sprite. It extends the separating axis test to a moving shape: for each axis (x and y from the bounds, then the cached edge normals) it finds when the projections overlap during the move, and the sprites first overlap at the latest start, if before the earliest end. Starting already overlapping is an immediate hit at fraction 0, with the deepest overlap and get_collision_info's push-out normal. Touching isn't a hit, and a move ending exactly touching isn't either. Exact for convex hit boxes that don't rotate during the move. Candidates come from the spatial hash (near the path's bounding box) if there is one, otherwise the whole list, with a radius and bounding box check before the axis test. Add the sprite_bullets_sweep example comparing it with check_for_collision_with_list on fast lasers and thin walls, with its docs page, screenshot, and gallery entry under Shooting with Sprites. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +- arcade/__init__.py | 4 + arcade/examples/sprite_bullets_sweep.py | 176 ++++++++++++ arcade/sprite_list/__init__.py | 4 + arcade/sprite_list/collision.py | 259 +++++++++++++++++- .../images/sprite_bullets_sweep.png | Bin 0 -> 19913 bytes doc/example_code/index.rst | 6 + doc/example_code/sprite_bullets_sweep.rst | 22 ++ tests/unit/sprite/test_sprite_collision.py | 172 ++++++++++++ 9 files changed, 644 insertions(+), 2 deletions(-) create mode 100644 arcade/examples/sprite_bullets_sweep.py create mode 100644 doc/example_code/images/sprite_bullets_sweep.png create mode 100644 doc/example_code/sprite_bullets_sweep.rst diff --git a/CHANGELOG.md b/CHANGELOG.md index e3d094f1d..f1be1f029 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,8 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. ## Unreleased -- No unreleased changes yet +### New Features +- Added `arcade.sweep_sprite(sprite, dx, dy, sprite_list)`, which checks the whole path of a moving sprite and returns a `SweepInfo` for the first sprite it would hit (the `sprite`, how far along the move as a `fraction` and `distance`, and the surface `normal`), or `None`. Fast sprites can't pass through thin walls this way. A sprite that already overlaps one is an immediate hit. Added the `sprite_bullets_sweep` example comparing it with a plain collision check. ## 4.0.0.dev8 diff --git a/arcade/__init__.py b/arcade/__init__.py index 3a070143a..18ce4c1a2 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -185,9 +185,11 @@ def configure_logging(level: int | None = None): from .sprite_list import SpriteSequence from .sprite_list import CollisionInfo from .sprite_list import CollisionMethod +from .sprite_list import SweepInfo 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 sweep_sprite 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 @@ -319,6 +321,7 @@ def configure_logging(level: int | None = None): "SpriteSequence", "CollisionInfo", "CollisionMethod", + "SweepInfo", "SpriteSolidColor", "Text", "TextPool", @@ -337,6 +340,7 @@ def configure_logging(level: int | None = None): "check_for_collision", "get_collision_info", "get_collision_info_with_list", + "sweep_sprite", "check_for_collision_with_list", "check_for_collision_with_lists", "check_for_collision_between_lists", diff --git a/arcade/examples/sprite_bullets_sweep.py b/arcade/examples/sprite_bullets_sweep.py new file mode 100644 index 000000000..d347b21ff --- /dev/null +++ b/arcade/examples/sprite_bullets_sweep.py @@ -0,0 +1,176 @@ +""" +Fast Bullets and Thin Walls + +Lasers move so far each frame that they can jump right over a thin wall. + +In the top lane, each laser moves and then checks for a collision with +arcade.check_for_collision_with_list(). A laser that lands on the far side +of a wall never overlaps it, so many pass straight through. + +In the bottom lane, each laser uses arcade.sweep_sprite() to check its +whole path before it moves, so it always stops at the first wall. + +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_bullets_sweep +""" + +import random + +import arcade +from pyglet.math import Vec2 + +WINDOW_WIDTH = 1280 +WINDOW_HEIGHT = 720 +WINDOW_TITLE = "Fast Bullets and Thin Walls Example" + +# Pixels per frame. Much more than a wall plus a laser is wide. +LASER_SPEED = 80 +LASER_SCALE = 0.5 +# Fire a laser in each lane this often, in frames +FIRE_INTERVAL = 4 + +WALL_WIDTH = 6 +WALL_X_POSITIONS = (500, 760, 1020) + +# Each lane is (bottom, top) +TOP_LANE = (390, 640) +BOTTOM_LANE = (40, 290) + + +class Lane: + """One lane of walls and lasers, and its counts.""" + + def __init__(self, bottom: int, top: int, use_sweep: bool): + self.bottom = bottom + self.top = top + self.use_sweep = use_sweep + self.hits = 0 + self.passed = 0 + + self.walls = arcade.SpriteList() + for x in WALL_X_POSITIONS: + wall = arcade.SpriteSolidColor( + WALL_WIDTH, + top - bottom, + center_x=x, + center_y=(bottom + top) / 2, + color=arcade.color.LIGHT_GRAY, + ) + self.walls.append(wall) + + self.lasers = arcade.SpriteList() + + def fire(self): + """Add a laser at the left edge, at a random height.""" + laser = arcade.Sprite(":resources:images/space_shooter/laserBlue01.png", scale=LASER_SCALE) + # A random start, so lasers don't all land on the same spots + x = random.uniform(0, LASER_SPEED) + laser.position = x, random.uniform(self.bottom + 10, self.top - 10) + laser.change_x = LASER_SPEED + self.lasers.append(laser) + + def update(self, sparks: arcade.SpriteList): + """Move the lasers, removing any that hit a wall or leave the screen.""" + for laser in list(self.lasers): + move = Vec2(laser.change_x, laser.change_y) + if self.use_sweep: + # Check the whole path before moving + hit = arcade.sweep_sprite(laser, move.x, move.y, self.walls) + if hit: + laser.position += move * hit.fraction + self.hit(laser, laser.right, sparks) + continue + laser.position += move + else: + # Move, then check where the laser ended up + laser.position += move + if arcade.check_for_collision_with_list(laser, self.walls): + self.hit(laser, laser.center_x, sparks) + continue + + if laser.left > WINDOW_WIDTH: + self.passed += 1 + laser.remove_from_sprite_lists() + + def hit(self, laser: arcade.Sprite, x: float, sparks: arcade.SpriteList): + """Remove a laser that hit a wall, leaving a spark where it hit.""" + self.hits += 1 + spark = arcade.SpriteCircle(5, arcade.color.ORANGE_RED) + spark.position = x, laser.center_y + sparks.append(spark) + laser.remove_from_sprite_lists() + + +class GameView(arcade.View): + """Main application class.""" + + def __init__(self): + super().__init__() + self.background_color = arcade.color.DARK_MIDNIGHT_BLUE + self.lanes = [ + Lane(*TOP_LANE, use_sweep=False), + Lane(*BOTTOM_LANE, use_sweep=True), + ] + self.sparks = arcade.SpriteList() + self.frame = 0 + + self.titles = [ + arcade.Text( + "check_for_collision_with_list() after moving: lasers can pass through", + 20, + TOP_LANE[1] + 30, + arcade.color.WHITE, + 16, + ), + arcade.Text( + "sweep_sprite() before moving: every laser hits the first wall", + 20, + BOTTOM_LANE[1] + 30, + arcade.color.WHITE, + 16, + ), + ] + self.counts = [ + arcade.Text("", 20, TOP_LANE[1] + 6, arcade.color.LIGHT_GRAY, 12), + arcade.Text("", 20, BOTTOM_LANE[1] + 6, arcade.color.LIGHT_GRAY, 12), + ] + + def on_update(self, delta_time): + """Movement and game logic""" + self.frame += 1 + for lane in self.lanes: + if self.frame % FIRE_INTERVAL == 0: + lane.fire() + lane.update(self.sparks) + + # Fade the sparks out + for spark in list(self.sparks): + spark.alpha = max(0, spark.alpha - 8) + if spark.alpha == 0: + spark.remove_from_sprite_lists() + + for lane, text in zip(self.lanes, self.counts): + text.text = f"Hit a wall: {lane.hits} Passed through every wall: {lane.passed}" + + def on_draw(self): + """Render the screen.""" + self.clear() + for lane in self.lanes: + lane.walls.draw() + lane.lasers.draw() + self.sparks.draw() + for text in self.titles + self.counts: + text.draw() + + +def main(): + """Main function""" + window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE) + window.show_view(GameView()) + arcade.run() + + +if __name__ == "__main__": + main() diff --git a/arcade/sprite_list/__init__.py b/arcade/sprite_list/__init__.py index 4ff968007..069c01120 100644 --- a/arcade/sprite_list/__init__.py +++ b/arcade/sprite_list/__init__.py @@ -3,11 +3,13 @@ from .collision import ( CollisionInfo, CollisionMethod, + SweepInfo, get_distance_between_sprites, get_closest_sprite, check_for_collision, get_collision_info, get_collision_info_with_list, + sweep_sprite, check_for_collision_with_list, check_for_collision_with_lists, check_for_collision_between_lists, @@ -25,11 +27,13 @@ "SpatialHash", "CollisionInfo", "CollisionMethod", + "SweepInfo", "get_distance_between_sprites", "get_closest_sprite", "check_for_collision", "get_collision_info", "get_collision_info_with_list", + "sweep_sprite", "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 72aeaae52..6dcdd4eb9 100644 --- a/arcade/sprite_list/collision.py +++ b/arcade/sprite_list/collision.py @@ -13,7 +13,7 @@ from arcade.math import get_distance from arcade.sprite import BasicSprite, SpriteType from arcade.types import Point -from arcade.types.rect import Rect +from arcade.types.rect import LRBT, Rect from arcade.window_commands import get_window from .sprite_list import SpriteSequence @@ -87,6 +87,38 @@ class CollisionInfo(NamedTuple): """ +class SweepInfo(NamedTuple): + """ + The first sprite hit by a moving sprite. Returned by :py:func:`sweep_sprite`. + + To move the sprite up to the point where it hits:: + + hit = arcade.sweep_sprite(bullet, dx, dy, walls) + if hit: + bullet.position += Vec2(dx, dy) * hit.fraction + """ + + sprite: BasicSprite + """The sprite that was hit.""" + + fraction: float + """ + How far along the move the hit happens, from 0.0 (at the start) to just + under 1.0. It's 0.0 if the moving sprite already overlaps :py:attr:`sprite`. + """ + + distance: float + """How far the moving sprite travels before the hit, in pixels.""" + + normal: Vec2 + """ + A unit vector pointing out of the hit sprite's surface, back toward the + moving sprite. Useful for bouncing. If the moving sprite started inside + the hit sprite, this is the way to push it out, as from + :py:func:`get_collision_info`. + """ + + # Module-level aliases, so the hot path doesn't look up enum members every call _AUTO = CollisionMethod.AUTO _SPATIAL = CollisionMethod.SPATIAL @@ -674,6 +706,231 @@ def get_collision_info_with_list( return results +def _sweep_axis( + min_1: float, max_1: float, min_2: float, max_2: float, speed: float +) -> tuple[float, float] | None: + """ + When two projections on an axis overlap, if one moves at ``speed``. + + Returns the ``(start, end)`` of the overlap as fractions of the move. + Touching doesn't count as overlapping. If ``speed`` is 0, returns + ``(-inf, inf)`` if they overlap, otherwise ``None``. + """ + if speed == 0: + if max_1 <= min_2 or max_2 <= min_1: + return None + return float("-inf"), float("inf") + t1 = (min_2 - max_1) / speed + t2 = (max_2 - min_1) / speed + if t1 > t2: + return t2, t1 + return t1, t2 + + +def _sweep_against( + sprite: BasicSprite, other: BasicSprite, dx: float, dy: float, best_fraction: float +) -> tuple[float, float, float] | None: + """ + When ``sprite``, moving by ``(dx, dy)``, first overlaps ``other``. + + Returns ``(fraction, normal_x, normal_y)`` if they overlap at some point + during the move, strictly before ``best_fraction``. A negative fraction means + they already overlap at the start. The normal is a unit vector out of + ``other``. Exact for convex hit boxes that don't rotate during the move. + """ + hit_box1 = sprite._hit_box + hit_box2 = other._hit_box + points1 = hit_box1.get_adjusted_points() + points2 = hit_box2.get_adjusted_points() + if not points1 or not points2: + return None + + # For each axis, find when the projections overlap during the move. The + # sprites overlap while they overlap on every axis, so they first + # overlap at the latest of the start times, if that's before the + # earliest of the end times. + enter = float("-inf") + leave = float("inf") + normal_x = 0.0 + normal_y = 0.0 + + # The y axis first, then x, then the other edges, so ties prefer them + left1, right1, bottom1, top1 = hit_box1.get_adjusted_bounds() + left2, right2, bottom2, top2 = hit_box2.get_adjusted_bounds() + for min_1, max_1, min_2, max_2, speed, axis_x, axis_y in ( + (bottom1, top1, bottom2, top2, dy, 0.0, 1.0), + (left1, right1, left2, right2, dx, 1.0, 0.0), + ): + overlap = _sweep_axis(min_1, max_1, min_2, max_2, speed) + if overlap is None: + return None + start, end = overlap + if start > enter: + enter = start + if speed > 0: + # 0.0 - x avoids -0.0 for the zero component + normal_x, normal_y = 0.0 - axis_x, 0.0 - axis_y + else: + normal_x, normal_y = axis_x, axis_y + leave = min(leave, end) + if enter >= leave or enter >= best_fraction or leave <= 0: + return None + + axes = hit_box1._get_axes() | hit_box2._get_axes() + for axis_x, axis_y in axes.values(): + projected_1 = [axis_x * px + axis_y * py for px, py in points1] + projected_2 = [axis_x * px + axis_y * py for px, py in points2] + speed = dx * axis_x + dy * axis_y + overlap = _sweep_axis( + min(projected_1), max(projected_1), min(projected_2), max(projected_2), speed + ) + if overlap is None: + return None + start, end = overlap + if start > enter: + enter = start + length = hypot(axis_x, axis_y) + if speed > 0: + normal_x, normal_y = -axis_x / length, -axis_y / length + else: + normal_x, normal_y = axis_x / length, axis_y / length + leave = min(leave, end) + if enter >= leave or enter >= best_fraction or leave <= 0: + return None + + return enter, normal_x, normal_y + + +def sweep_sprite( + sprite: BasicSprite, + dx: float, + dy: float, + sprite_list: SpriteSequence[SpriteType], +) -> SweepInfo | None: + """ + Find the first sprite in a list that a sprite would hit while moving. + + :py:func:`check_for_collision` only checks where a sprite is, so a fast + sprite can move past a thin wall between two frames without ever + overlapping it. This checks the whole path instead: it imagines + ``sprite`` moving in a straight line by ``(dx, dy)`` and returns the + first sprite it would hit, and where. It doesn't move ``sprite``:: + + hit = arcade.sweep_sprite(bullet, bullet.change_x, bullet.change_y, walls) + if hit: + # Move up to the wall, then remove the bullet + bullet.position += Vec2(bullet.change_x, bullet.change_y) * hit.fraction + bullet.remove_from_sprite_lists() + else: + bullet.position += Vec2(bullet.change_x, bullet.change_y) + + If ``sprite`` already overlaps a sprite in the list, that is an immediate + hit, with a :py:attr:`~SweepInfo.fraction` of 0.0. If it overlaps + several, the deepest overlap is returned. + + As with :py:func:`check_for_collision`, sprites that only touch don't + count: a sprite can slide along a wall, or move away from one it's + touching, without hitting it. A move that ends exactly touching a + sprite doesn't hit it either. + + .. note:: ``sprite`` is assumed to keep the same angle during the move. + The result is only exact for convex hit boxes. + + If the list has a spatial hash, only sprites near the path are checked. + Otherwise every sprite in the list is. If two sprites are hit at the + same moment, either may be returned. + + Args: + sprite: + The moving sprite + dx: + How far it moves along x + dy: + How far it moves along y + sprite_list: + The sprites it may hit + + Returns: + A :py:class:`SweepInfo` for the first sprite hit, or ``None``. + """ + 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 4 is a {type(sprite_list)} instead of expected SpriteList.") + + # Everything the sprite passes over is inside this box + left, right, bottom, top = sprite._hit_box.get_adjusted_bounds() + path_left = left + min(dx, 0.0) + path_right = right + max(dx, 0.0) + path_bottom = bottom + min(dy, 0.0) + path_top = top + max(dy, 0.0) + + candidates: Iterable[SpriteType] + if sprite_list.spatial_hash is not None: + candidates = sprite_list.spatial_hash.get_sprites_near_rect( + LRBT(path_left, path_right, path_bottom, path_top) + ) + else: + candidates = sprite_list + + # Hits must be strictly before this, so a move that ends exactly + # touching a sprite doesn't hit it + best_fraction = 1.0 + best: tuple[SpriteType, float, float, float] | None = None + deepest_start: tuple[SpriteType, CollisionInfo] | None = None + for other in candidates: + if other is sprite: + continue + # Quick check with a circle around the other sprite's hit box, then + # its bounding box. The radius is cached until its scale changes. + other_hit_box = other._hit_box + radius = other_hit_box._radius + if radius is None: + radius = other_hit_box._get_radius() + other_x, other_y = other._position + if ( + other_x + radius <= path_left + or other_x - radius >= path_right + or other_y + radius <= path_bottom + or other_y - radius >= path_top + ): + continue + other_left, other_right, other_bottom, other_top = other_hit_box.get_adjusted_bounds() + if ( + path_right <= other_left + or other_right <= path_left + or path_top <= other_bottom + or other_top <= path_bottom + ): + continue + + # Once something overlaps at the start, only other overlaps matter + limit = 0.0 if deepest_start is not None else best_fraction + result = _sweep_against(sprite, other, dx, dy, limit) + if result is None: + continue + fraction, normal_x, normal_y = result + if fraction < 0: + # Already overlapping at the start. Keep the deepest. + info = _get_collision_info(sprite, other) + if info is not None and (deepest_start is None or info.depth > deepest_start[1].depth): + deepest_start = (other, info) + elif deepest_start is None: + best_fraction = fraction + best = (other, fraction, normal_x, normal_y) + + if deepest_start is not None: + return SweepInfo(deepest_start[0], 0.0, 0.0, deepest_start[1].normal) + if best is None: + return None + hit_sprite, fraction, normal_x, normal_y = best + return SweepInfo(hit_sprite, fraction, fraction * hypot(dx, dy), Vec2(normal_x, normal_y)) + + def check_for_collision_with_lists( sprite: BasicSprite, sprite_lists: Iterable[SpriteSequence[SpriteType]], diff --git a/doc/example_code/images/sprite_bullets_sweep.png b/doc/example_code/images/sprite_bullets_sweep.png new file mode 100644 index 0000000000000000000000000000000000000000..006067d438cf3ccc11ff3d145a90cbc28abf4029 GIT binary patch literal 19913 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 Qp_VkyUQ{nQZF%Sa1Bz&Bg8%>k literal 0 HcmV?d00001 diff --git a/doc/example_code/index.rst b/doc/example_code/index.rst index 867c875ac..82583876d 100644 --- a/doc/example_code/index.rst +++ b/doc/example_code/index.rst @@ -367,6 +367,12 @@ Shooting with Sprites :ref:`sprite_explosion_particles` +.. figure:: images/thumbs/sprite_bullets_sweep.png + :figwidth: 170px + :target: sprite_bullets_sweep.html + + :ref:`sprite_bullets_sweep` + Audio ----- diff --git a/doc/example_code/sprite_bullets_sweep.rst b/doc/example_code/sprite_bullets_sweep.rst new file mode 100644 index 000000000..dc20df225 --- /dev/null +++ b/doc/example_code/sprite_bullets_sweep.rst @@ -0,0 +1,22 @@ +:orphan: + +.. _sprite_bullets_sweep: + +Fast Bullets and Thin Walls +=========================== + +.. image:: images/sprite_bullets_sweep.png + :width: 600px + :align: center + :alt: Screenshot of fast lasers passing through thin walls in one lane and stopping in the other + +A sprite that moves far enough in one frame can jump right over a thin wall, +because :py:func:`arcade.check_for_collision_with_list` only checks where a +sprite is, not where it went. In the top lane, lasers move and then check +for collisions, so some pass through. In the bottom lane, each laser uses +:py:func:`arcade.sweep_sprite` to check its whole path before moving, so it +always stops at the first wall. + +.. literalinclude:: ../../arcade/examples/sprite_bullets_sweep.py + :caption: sprite_bullets_sweep.py + :linenos: diff --git a/tests/unit/sprite/test_sprite_collision.py b/tests/unit/sprite/test_sprite_collision.py index 5cbb4cb55..c995e76b5 100644 --- a/tests/unit/sprite/test_sprite_collision.py +++ b/tests/unit/sprite/test_sprite_collision.py @@ -1,3 +1,4 @@ +import math import random import pytest @@ -722,6 +723,177 @@ def test_get_collision_info_with_list_type_errors(window): arcade.get_collision_info_with_list(sprite, "moo") +def _walls(*sprites, spatial=False): + sprite_list = arcade.SpriteList(use_spatial_hash=spatial) + sprite_list.extend(sprites) + return sprite_list + + +@pytest.mark.parametrize("spatial", [False, True]) +def test_sweep_sprite_thin_wall(window, spatial): + """A fast sprite hits a thin wall it would otherwise pass through""" + wall = arcade.SpriteSolidColor(6, 100, center_x=30) # Left edge at x=27 + walls = _walls(wall, spatial=spatial) + sprite = arcade.SpriteSolidColor(10, 10) # Right edge at x=5 + + # The plain check at the end position misses it + sprite.center_x = 50 + assert not arcade.check_for_collision(sprite, wall) + sprite.center_x = 0 + + hit = arcade.sweep_sprite(sprite, 50, 0, walls) + assert hit == (wall, 22 / 50, 22.0, Vec2(-1.0, 0.0)) + # It doesn't move the sprite + assert sprite.position == (0, 0) + # Moving to the hit leaves them touching + sprite.position += Vec2(50, 0) * hit.fraction + assert sprite.right == pytest.approx(wall.left) + + +def test_sweep_sprite_misses(window): + wall = arcade.SpriteSolidColor(6, 100, center_x=30) + walls = _walls(wall) + sprite = arcade.SpriteSolidColor(10, 10) + assert arcade.sweep_sprite(sprite, -50, 0, walls) is None # Moving away + assert arcade.sweep_sprite(sprite, 21, 0, walls) is None # Stops short + assert arcade.sweep_sprite(sprite, 22, 0, walls) is None # Ends exactly touching + assert arcade.sweep_sprite(sprite, 0, 0, walls) is None # Not moving + sprite.center_y = 55 # Passes just above the wall's top edge + assert arcade.sweep_sprite(sprite, 50, 0, walls) is None + assert arcade.sweep_sprite(sprite, 50, 0, arcade.SpriteList()) is None + + +def test_sweep_sprite_ends_touching_slanted_edge(window): + """Ending exactly touching along a slanted edge isn't a hit, but going further is""" + # Diamonds with integer corners, so the touching point is exact + sprite = arcade.SpriteSolidColor(10, 10) + sprite.hit_box = arcade.hitbox.HitBox([(5, 0), (0, 5), (-5, 0), (0, -5)]) + wall = arcade.SpriteSolidColor(20, 20) + wall.hit_box = arcade.hitbox.HitBox([(10, 0), (0, 10), (-10, 0), (0, -10)]) + # Set after the hit box, which is created at (0, 0) + wall.position = 30, 10 + walls = _walls(wall) + # After moving 25, the sprite's upper right edge lies along the wall's + # lower left edge. The bounding boxes overlap well before that. + assert arcade.sweep_sprite(sprite, 25, 0, walls) is None + hit = arcade.sweep_sprite(sprite, 26, 0, walls) + assert hit.fraction == pytest.approx(25 / 26) + assert hit.normal.x == pytest.approx(-(2**-0.5)) + assert hit.normal.y == pytest.approx(-(2**-0.5)) + + +def test_sweep_sprite_touching(window): + """Touching isn't a hit, unless the sprite moves into the other one""" + wall = arcade.SpriteSolidColor(6, 100, center_x=30) + walls = _walls(wall) + sprite = arcade.SpriteSolidColor(10, 10, center_x=22) # Right edge touches the wall + assert arcade.sweep_sprite(sprite, -5, 0, walls) is None # Away + assert arcade.sweep_sprite(sprite, 0, 30, walls) is None # Sliding along it + assert arcade.sweep_sprite(sprite, 5, 3, walls) == (wall, 0.0, 0.0, Vec2(-1.0, 0.0)) + + +def test_sweep_sprite_starts_overlapping(window): + """Starting inside a sprite is an immediate hit, with the push-out direction""" + near = arcade.SpriteSolidColor(10, 10, center_x=9) # Overlaps by 1 + deep = arcade.SpriteSolidColor(10, 10, center_y=-6) # Overlaps by 4 + ahead = arcade.SpriteSolidColor(10, 10, center_x=-30) + sprite = arcade.SpriteSolidColor(10, 10) + walls = _walls(ahead, near, deep) + + hit = arcade.sweep_sprite(sprite, -100, 0, walls) + # The deepest overlap, even though another sprite is in the way + assert hit.sprite is deep + assert hit.fraction == 0.0 + assert hit.distance == 0.0 + assert hit.normal == arcade.get_collision_info(sprite, deep).normal + # Also when not moving + assert arcade.sweep_sprite(sprite, 0, 0, walls).sprite is deep + + +@pytest.mark.parametrize("spatial", [False, True]) +def test_sweep_sprite_first_hit(window, spatial): + """The closest sprite along the path is returned, not the first in the list""" + far = arcade.SpriteSolidColor(10, 10, center_x=100) + near = arcade.SpriteSolidColor(10, 10, center_x=50) + behind = arcade.SpriteSolidColor(10, 10, center_x=-50) + sprite = arcade.SpriteSolidColor(10, 10) + walls = _walls(far, behind, near, sprite, spatial=spatial) # It skips itself + + hit = arcade.sweep_sprite(sprite, 200, 0, walls) + assert hit.sprite is near + assert hit.fraction == pytest.approx(40 / 200) + assert arcade.sweep_sprite(sprite, -200, 0, walls).sprite is behind + + +def test_sweep_sprite_diagonal(window): + """Hitting a rotated wall gives the wall's surface normal""" + wall = arcade.SpriteSolidColor(20, 200, center_x=60) + wall.angle = 45 + walls = _walls(wall) + sprite = arcade.SpriteSolidColor(10, 10) + hit = arcade.sweep_sprite(sprite, 100, 0, walls) + assert hit.sprite is wall + assert hit.normal.x == pytest.approx(-(2**-0.5)) + assert abs(hit.normal.y) == pytest.approx(2**-0.5) + assert hit.distance == pytest.approx(hit.fraction * 100) + + +def test_sweep_sprite_type_errors(window): + sprite = arcade.SpriteSolidColor(10, 10) + with pytest.raises(TypeError): + arcade.sweep_sprite("moo", 1, 0, arcade.SpriteList()) + with pytest.raises(TypeError): + arcade.sweep_sprite(sprite, 1, 0, "moo") + + +def test_sweep_sprite_matches_stepping(window): + """Compare with moving in small steps, for random sprites and moves""" + rng = random.Random(79) + textures = [ + arcade.load_texture(":resources:images/tiles/grassMid.png"), + arcade.load_texture(":resources:images/items/coinGold.png"), + arcade.load_texture(":resources:images/space_shooter/laserBlue01.png"), + arcade.load_texture( + ":resources:images/animated_characters/female_person/femalePerson_idle.png" + ), + ] + + def collides_at(sprite, other, start, dx, dy, fraction): + sprite.position = start[0] + dx * fraction, start[1] + dy * fraction + result = arcade.check_for_collision(sprite, other) + sprite.position = start + return result + + hits = 0 + for _ in range(2000): + shared_angle = rng.choice([0, 90, 45, 30, rng.uniform(0, 360)]) + sprite = _random_convex_sprite(rng, textures, shared_angle) + other = _random_convex_sprite(rng, textures, shared_angle) + if arcade.check_for_collision(sprite, other): + continue + angle = rng.uniform(0, 2 * math.pi) + speed = rng.choice([1, 10, 50, 200]) + dx, dy = round(math.cos(angle) * speed, 3), round(math.sin(angle) * speed, 3) + start = sprite.position + hit = arcade.sweep_sprite(sprite, dx, dy, _walls(other)) + samples = [i / 200 for i in range(200)] + if hit is None: + assert not any(collides_at(sprite, other, start, dx, dy, t) for t in samples) + continue + hits += 1 + fraction = hit.fraction + assert 0 <= fraction < 1 + assert hit.normal.length() == pytest.approx(1.0) + # The normal points back against the move + assert hit.normal.x * dx + hit.normal.y * dy < 0 + # Nothing before the hit, and overlapping just after it + before = [t for t in samples if t < fraction - 1e-7] + [max(0.0, fraction - 1e-7)] + assert not any(collides_at(sprite, other, start, dx, dy, t) for t in before) + after = [fraction + e for e in (1e-9, 1e-7, 1e-6, 1e-5) if fraction + e < 1] + assert any(collides_at(sprite, other, start, dx, dy, t) for t in after) + assert hits > 100 + + 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)