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 000000000..006067d43 Binary files /dev/null and b/doc/example_code/images/sprite_bullets_sweep.png differ 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)