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)