From 7b60e3521d1fb398ade9a1eea955ea413230863b Mon Sep 17 00:00:00 2001 From: Paul V Craven Date: Thu, 1 Oct 2026 12:31:25 -0500 Subject: [PATCH] Fix collision method and duplicate oddities, document touching - CollisionMethod.SPATIAL on a list without a spatial hash now chooses the same way as AUTO (check every sprite for 1500 or fewer, else the GPU). Before, it always used the GPU, which is slow when called many times per frame and needs an open window. - check_for_collision_with_lists returns each colliding sprite once, even if it's in more than one of the lists, in the order first found. Only checked when more than one list is passed. - Document that hit boxes that only touch don't collide, while a point exactly on an edge counts for get_sprites_at_point and collides_with_point. Correct get_sprites_in_rect's docs, which said touching sprites were returned, and its outdated tuple description. Add tests for the new SPATIAL paths, duplicates, and touching behavior. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++ arcade/sprite/base.py | 7 +++ arcade/sprite_list/collision.py | 37 +++++++++---- tests/unit/sprite/test_sprite_collision.py | 62 +++++++++++++++++++++- 4 files changed, 98 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab3d7924c..daff14bf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Rotated hit boxes at right angles (90, 180, 270 degrees, etc.) now have exact point coordinates. Before, values like `sin(radians(180))` being about 1.2e-16 instead of 0 left tiny errors that could make exactly touching sprites count as colliding, or not, differently from unrotated ones. - Fixed sprite collision checks missing collisions when a sprite's hit box is bigger than its texture, such as a custom hit box used as a melee reach area. The quick distance check estimated each sprite's size from its texture; it now uses the actual hit box, cached until the scale changes. This also makes the check tighter for most sprites, so brute-force list checks are about 20-40% faster. - Fixed `Sprite.rescale_relative_to_point()` (and `SpriteList.rescale()`, which uses it) not rescaling the sprite's hit box. The sprite was drawn at its new size but collided, and was placed in spatial hashes, at its old size. +- `check_for_collision_with_lists` no longer returns the same sprite more than once when it's in more than one of the lists. +- `CollisionMethod.SPATIAL` (`method=1`) on a sprite list without a spatial hash now chooses the same way as `AUTO`, checking every sprite in lists of 1500 or fewer. Before, it always used the GPU, which is slow when called many times per frame and needed an open window. +- Documented that sprites whose hit boxes only touch don't count as colliding, while a point exactly on a hit box's edge does count for `get_sprites_at_point` and `collides_with_point`. Corrected `get_sprites_in_rect`'s docs, which said touching sprites were included. ### New Features - Added `HitBox.get_adjusted_bounds()`, which returns the cached `(left, right, bottom, top)` bounds of the adjusted hit box points. diff --git a/arcade/sprite/base.py b/arcade/sprite/base.py index 3bc1b4bd4..9e8726b49 100644 --- a/arcade/sprite/base.py +++ b/arcade/sprite/base.py @@ -806,6 +806,10 @@ def collides_with_point(self, point: Point2) -> bool: """ Check if point is within the current sprite. + A point exactly on the edge of the hit box counts. Note this is + different from :py:meth:`collides_with_sprite`, where sprites that + only touch don't count as colliding. + Args: point: Point to check. Returns: @@ -819,6 +823,9 @@ def collides_with_point(self, point: Point2) -> bool: def collides_with_sprite(self, other: BasicSprite) -> bool: """Will check if a sprite is overlapping (colliding) another Sprite. + Sprites whose hit boxes only touch, sharing an edge or a corner, + don't count as overlapping. + Args: other: the other sprite to check against. Returns: diff --git a/arcade/sprite_list/collision.py b/arcade/sprite_list/collision.py index 48e196b2d..8d7b131ec 100644 --- a/arcade/sprite_list/collision.py +++ b/arcade/sprite_list/collision.py @@ -39,7 +39,8 @@ class CollisionMethod(IntEnum): SPATIAL = 1 """ - Use the sprite list's spatial hash. If it doesn't have one, use the GPU. + Use the sprite list's spatial hash. If it doesn't have one, choose the + same way as :py:attr:`AUTO`. """ GPU = 2 @@ -104,6 +105,10 @@ def check_for_collision(sprite1: BasicSprite, sprite2: BasicSprite) -> bool: """ Check for a collision between two sprites. + Sprites whose hit boxes only touch, sharing an edge or a corner, don't + count as colliding. This lets the physics engines rest a sprite against + a wall or on the ground. + Args: sprite1: First sprite sprite2: Second sprite @@ -201,13 +206,12 @@ def _get_sprites_to_check( method: CollisionMethod | int, ) -> Iterable[SpriteType]: """Get the sprites in a list to check for collisions, using ``method``.""" - if sprite_list.spatial_hash is not None and (method == _AUTO or method == _SPATIAL): - return sprite_list.spatial_hash.get_sprites_near_sprite(sprite) - if ( - method == _SIMPLE - or (method == _AUTO and len(sprite_list) <= 1500) - or get_window().ctx._gl_api == "webgl" - ): + if method == _AUTO or method == _SPATIAL: + if sprite_list.spatial_hash is not None: + return sprite_list.spatial_hash.get_sprites_near_sprite(sprite) + if len(sprite_list) <= 1500: + return sprite_list + if method == _SIMPLE or get_window().ctx._gl_api == "webgl": return sprite_list # GPU transform - Not on WebGL return _get_nearby_sprites(sprite, sprite_list) @@ -263,6 +267,9 @@ def check_for_collision_with_lists( """ Check for a collision between a Sprite, and a list of SpriteLists. + Each colliding sprite is returned once, even if it's in more than one of + the lists. + Args: sprite: Sprite to check @@ -283,12 +290,17 @@ def check_for_collision_with_lists( ) sprites: list[SpriteType] = [] + list_count = 0 for sprite_list in sprite_lists: + list_count += 1 for sprite2 in _get_sprites_to_check(sprite, sprite_list, method): if sprite is not sprite2 and _check_for_collision(sprite, sprite2): sprites.append(sprite2) + # A sprite can be in more than one of the lists, but is only returned once + if list_count > 1 and len(sprites) > 1: + return list(dict.fromkeys(sprites)) return sprites @@ -298,6 +310,10 @@ def get_sprites_at_point(point: Point, sprite_list: SpriteSequence[SpriteType]) the specified point. If a sprite has a different center_x/center_y but touches the point, this will return that sprite. + A point exactly on the edge of a sprite's hit box counts. Note this is + different from :py:func:`check_for_collision`, where sprites that only + touch don't count as colliding. + Args: point: Point to check sprite_list: SpriteList to check against @@ -355,9 +371,10 @@ def get_sprites_in_rect(rect: Rect, sprite_list: SpriteSequence[SpriteType]) -> """ Get a list of sprites in a particular rectangle. This function sees if any sprite overlaps the specified rectangle. If a sprite has a different - center_x/center_y but touches the rectangle, this will return that sprite. + center_x/center_y but overlaps the rectangle, this will return that sprite. - The rectangle is specified as a tuple of (left, right, bottom, top). + As with :py:func:`check_for_collision`, a sprite whose hit box only + touches the rectangle, sharing an edge or a corner, isn't included. Args: rect: Rectangle to check diff --git a/tests/unit/sprite/test_sprite_collision.py b/tests/unit/sprite/test_sprite_collision.py index f7ebb5ce7..520acb5ab 100644 --- a/tests/unit/sprite/test_sprite_collision.py +++ b/tests/unit/sprite/test_sprite_collision.py @@ -243,7 +243,10 @@ def test_collision_method_values(): (arcade.CollisionMethod.AUTO, False, 10, False, "simple"), (arcade.CollisionMethod.AUTO, False, 1501, False, "gpu"), (arcade.CollisionMethod.SPATIAL, True, 10, False, "spatial"), - (arcade.CollisionMethod.SPATIAL, False, 10, False, "gpu"), + # Without a spatial hash, SPATIAL chooses the same way as AUTO + (arcade.CollisionMethod.SPATIAL, False, 10, False, "simple"), + (arcade.CollisionMethod.SPATIAL, False, 1501, False, "gpu"), + (arcade.CollisionMethod.SPATIAL, False, 1501, True, "simple"), (arcade.CollisionMethod.GPU, True, 10, False, "gpu"), (arcade.CollisionMethod.GPU, False, 10, False, "gpu"), (arcade.CollisionMethod.SIMPLE, True, 10, False, "simple"), @@ -296,6 +299,63 @@ class ctx: assert calls == ([] if expected == "simple" else [expected]) +def test_check_for_collision_with_lists_no_duplicates(window): + """A sprite in more than one of the lists is only returned once""" + sprite = arcade.SpriteSolidColor(10, 10) + shared = arcade.SpriteSolidColor(10, 10, center_x=5) + only_a = arcade.SpriteSolidColor(10, 10, center_x=-5) + only_b = arcade.SpriteSolidColor(10, 10, center_y=5) + list_a = arcade.SpriteList() + list_a.extend([only_a, shared]) + list_b = arcade.SpriteList() + list_b.extend([shared, only_b]) + + # In the order first found + assert arcade.check_for_collision_with_lists(sprite, [list_a, list_b]) == [ + only_a, + shared, + only_b, + ] + assert arcade.check_for_collision_with_lists(sprite, [list_b, list_a]) == [ + shared, + only_b, + only_a, + ] + # The same list twice, and lists from a generator + assert arcade.check_for_collision_with_lists(sprite, [list_a, list_a]) == [only_a, shared] + assert arcade.check_for_collision_with_lists(sprite, (sl for sl in [list_a, list_b])) == [ + only_a, + shared, + only_b, + ] + + # With a spatial hash. It finds sprites in no particular order. + hashed_b = arcade.SpriteList(use_spatial_hash=True) + hashed_b.extend([shared, only_b]) + hits = arcade.check_for_collision_with_lists(sprite, [list_a, hashed_b]) + assert len(hits) == 3 + assert set(hits) == {only_a, shared, only_b} + + +def test_touching_edges(window): + """Touching hit boxes don't collide, but a point on an edge counts""" + a = arcade.SpriteSolidColor(10, 10) + b = arcade.SpriteSolidColor(10, 10, center_x=10) # Shares a's right edge + c = arcade.SpriteSolidColor(10, 10, center_x=10, center_y=10) # Shares a corner + sprite_list = arcade.SpriteList() + sprite_list.append(a) + + assert arcade.check_for_collision(a, b) is False + assert arcade.check_for_collision(a, c) is False + assert a.collides_with_sprite(b) is False + assert arcade.check_for_collision_with_list(b, sprite_list) == [] + assert arcade.get_sprites_in_rect(arcade.LRBT(5, 8, -2, 2), sprite_list) == [] + + assert a.collides_with_point((5, 0)) is True + assert a.collides_with_point((5, 5)) is True + assert arcade.get_sprites_at_point((5, 0), sprite_list) == [a] + + 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)