Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions arcade/sprite/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
37 changes: 27 additions & 10 deletions arcade/sprite_list/collision.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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


Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
62 changes: 61 additions & 1 deletion tests/unit/sprite/test_sprite_collision.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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)
Expand Down
Loading