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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page.
- Added `arcade.CollisionMethod`, an enum for the `method` argument of `check_for_collision_with_list` and `check_for_collision_with_lists`: `AUTO`, `SPATIAL`, `GPU`, and `SIMPLE`. It's an `IntEnum`, so the numbers `0` to `3` still work.
- Added `arcade.has_collision_with_list()` and `arcade.has_collision_with_lists()`, which return `True` as soon as they find a collision. They're faster than checking whether `check_for_collision_with_list()` returns an empty list, by about 18x when many sprites overlap.
- Added `arcade.check_for_collision_between_lists(list_a, list_b)`, which returns every colliding `(sprite_a, sprite_b)` pair between two lists, such as bullets and enemies. Passing the same list twice returns each pair once.
- Added `arcade.get_collision_info(sprite1, sprite2)`, which returns a `CollisionInfo` with the smallest move that separates two colliding sprites: a unit `normal` (the direction to move `sprite1`) and a `depth` in pixels, or `None` if they don't collide. For example, `player.position += info.normal * info.depth` pushes a player out of a wall. Correct for convex hit boxes.

### Misc Changes
- Sped up several `SpriteList` operations: `swap()` no longer searches the draw order (about 1000x faster at the end of a 10,000 sprite list), `insert()` and item assignment check membership with a dictionary instead of scanning the list, and drawing uploads only the buffer slots in use instead of the whole capacity (moving one sprite and drawing is 1.6-2.3x faster).
Expand Down
4 changes: 4 additions & 0 deletions arcade/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,10 @@ def configure_logging(level: int | None = None):

from .sprite_list import SpriteList
from .sprite_list import SpriteSequence
from .sprite_list import CollisionInfo
from .sprite_list import CollisionMethod
from .sprite_list import check_for_collision
from .sprite_list import get_collision_info
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
Expand Down Expand Up @@ -314,6 +316,7 @@ def configure_logging(level: int | None = None):
"SpriteCircle",
"SpriteList",
"SpriteSequence",
"CollisionInfo",
"CollisionMethod",
"SpriteSolidColor",
"Text",
Expand All @@ -331,6 +334,7 @@ def configure_logging(level: int | None = None):
"Window",
"astar_calculate_path",
"check_for_collision",
"get_collision_info",
"check_for_collision_with_list",
"check_for_collision_with_lists",
"check_for_collision_between_lists",
Expand Down
4 changes: 4 additions & 0 deletions arcade/sprite_list/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from .sprite_list import SpriteList, SpriteSequence
from .spatial_hash import SpatialHash
from .collision import (
CollisionInfo,
CollisionMethod,
get_distance_between_sprites,
get_closest_sprite,
check_for_collision,
get_collision_info,
check_for_collision_with_list,
check_for_collision_with_lists,
check_for_collision_between_lists,
Expand All @@ -20,10 +22,12 @@
"SpriteList",
"SpriteSequence",
"SpatialHash",
"CollisionInfo",
"CollisionMethod",
"get_distance_between_sprites",
"get_closest_sprite",
"check_for_collision",
"get_collision_info",
"check_for_collision_with_list",
"check_for_collision_with_lists",
"check_for_collision_between_lists",
Expand Down
144 changes: 143 additions & 1 deletion arcade/sprite_list/collision.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from collections.abc import Iterable
from enum import IntEnum
from typing import TypeVar
from math import hypot
from typing import NamedTuple, TypeVar

from pyglet.math import Vec2

from arcade.geometry import (
_are_polygons_overlapping_on_axes,
Expand Down Expand Up @@ -59,6 +62,31 @@ class CollisionMethod(IntEnum):
"""


class CollisionInfo(NamedTuple):
"""
How two colliding sprites overlap. Returned by :py:func:`get_collision_info`.

Moving the first sprite by ``normal * depth`` is the smallest move that
separates the two sprites, leaving their hit boxes just touching::

info = arcade.get_collision_info(player, wall)
if info:
player.position += info.normal * info.depth
"""

normal: Vec2
"""
A unit vector pointing the way to move the first sprite to separate it
from the second. Swapping the sprites flips its direction.
"""

depth: float
"""
How far, in pixels, to move the first sprite along :py:attr:`normal` to
separate the sprites. Always greater than zero.
"""


# Module-level aliases, so the hot path doesn't look up enum members every call
_AUTO = CollisionMethod.AUTO
_SPATIAL = CollisionMethod.SPATIAL
Expand Down Expand Up @@ -135,6 +163,120 @@ def check_for_collision(sprite1: BasicSprite, sprite2: BasicSprite) -> bool:
return _check_for_collision(sprite1, sprite2)


def get_collision_info(sprite1: BasicSprite, sprite2: BasicSprite) -> CollisionInfo | None:
"""
Check for a collision between two sprites, and find how to separate them.

This works like :py:func:`check_for_collision`, but instead of ``True``
it returns a :py:class:`CollisionInfo` with the smallest move that
separates the sprites: the direction to move ``sprite1`` and how far.
This is useful for pushing a sprite out of a wall, or bouncing::

info = arcade.get_collision_info(player, wall)
if info:
player.position += info.normal * info.depth

As with :py:func:`check_for_collision`, sprites that only touch don't
count as colliding.

.. note:: After moving by exactly ``normal * depth`` the hit boxes touch,
but floating point rounding can leave them overlapping by a
tiny amount. Add a small extra distance if they must not
overlap at all.

.. warning:: The result is only correct for convex hit boxes. The
detailed hit box algorithm can create concave ones.

When more than one move is equally small, the y axis is preferred, then
the x axis, then other directions, and moving up or right over moving
down or left. So two identical sprites in the same place are separated
by moving ``sprite1`` up.

Args:
sprite1: The sprite to separate
sprite2: The sprite to separate it from

Returns:
A :py:class:`CollisionInfo` if the sprites collide, otherwise ``None``.
"""
if __debug__:
if not isinstance(sprite1, BasicSprite):
raise TypeError("Parameter 1 is not an instance of a Sprite class.")
if isinstance(sprite2, SpriteSequence):
raise TypeError(
"Parameter 2 is a instance of the SpriteList instead of a required "
"Sprite. A list isn't supported here; check each sprite in it instead."
)
elif not isinstance(sprite2, BasicSprite):
raise TypeError("Parameter 2 is not an instance of a Sprite class.")

hit_box1 = sprite1._hit_box
hit_box2 = sprite2._hit_box

# Quick check with circles around each hit box, as in _check_for_collision
radius1 = hit_box1._radius
if radius1 is None:
radius1 = hit_box1._get_radius()
radius2 = hit_box2._radius
if radius2 is None:
radius2 = hit_box2._get_radius()
radius_sum = radius1 + radius2
diff_x = sprite1._position[0] - sprite2._position[0]
diff_y = sprite1._position[1] - sprite2._position[1]
if diff_x * diff_x + diff_y * diff_y > radius_sum * radius_sum:
return None

points1 = hit_box1.get_adjusted_points()
points2 = hit_box2.get_adjusted_points()
if not points1 or not points2:
return None

left1, right1, bottom1, top1 = hit_box1.get_adjusted_bounds()
left2, right2, bottom2, top2 = hit_box2.get_adjusted_bounds()
if right1 <= left2 or right2 <= left1 or top1 <= bottom2 or top2 <= bottom1:
return None

# Find the smallest overlap. The y and x axes come from the bounds, then
# the hit boxes' other edge directions. For each axis there are two
# ways to move: in the positive direction (sprite1 past the top of
# sprite2's range) or the negative one. Ties keep the earlier choice.
up = top2 - bottom1
down = top1 - bottom2
if up <= down:
best_depth, best_x, best_y = up, 0.0, 1.0
else:
best_depth, best_x, best_y = down, 0.0, -1.0

right = right2 - left1
left = right1 - left2
if right < best_depth and right <= left:
best_depth, best_x, best_y = right, 1.0, 0.0
elif left < best_depth and left < right:
best_depth, best_x, best_y = left, -1.0, 0.0

axes = hit_box1._get_axes() | hit_box2._get_axes()
for normal_x, normal_y in axes.values():
projected_1 = [normal_x * px + normal_y * py for px, py in points1]
projected_2 = [normal_x * px + normal_y * py for px, py in points2]
min_1 = min(projected_1)
max_1 = max(projected_1)
min_2 = min(projected_2)
max_2 = max(projected_2)
if max_1 <= min_2 or max_2 <= min_1:
return None

# The normals aren't unit length, so convert the overlaps to pixels
length = hypot(normal_x, normal_y)
positive = (max_2 - min_1) / length
negative = (max_1 - min_2) / length
if positive < best_depth and positive <= negative:
best_depth, best_x, best_y = positive, normal_x / length, normal_y / length
elif negative < best_depth and negative < positive:
best_depth, best_x, best_y = negative, -normal_x / length, -normal_y / length

return CollisionInfo(Vec2(best_x, best_y), best_depth)


def _check_for_collision(sprite1: BasicSprite, sprite2: BasicSprite) -> bool:
"""
Check for collision between two sprites.
Expand Down
6 changes: 5 additions & 1 deletion benchmarks/collisions/micro.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import timeit

import arcade
from arcade import check_for_collision, check_for_collision_with_list
from arcade import check_for_collision, check_for_collision_with_list, get_collision_info
from arcade.geometry import are_polygons_intersecting

R = ":resources:images/"
Expand Down Expand Up @@ -63,6 +63,10 @@ def bench_pairs():
"are_polygons_intersecting box/box", lambda: are_polygons_intersecting(square_a, square_b)
)

print("--- get_collision_info, same pairs")
for label, (a, b) in cases.items():
bench(label, lambda a=a, b=b: get_collision_info(a, b))


def bench_changing_pairs():
print("--- check_for_collision, sprites changing every call")
Expand Down
140 changes: 140 additions & 0 deletions tests/unit/sprite/test_sprite_collision.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import pytest

import arcade
from pyglet.math import Vec2


def test_sprites_at_point():
Expand Down Expand Up @@ -534,6 +535,145 @@ def test_gpu_collision_flipped_sprites(window, wall_scale, bullet_scale):
assert arcade.check_for_collision_with_list(bullet, walls, method=gpu) == []


def _reference_min_overlap(sprite1, sprite2):
"""Smallest overlap over the x and y axes and every edge normal, in pixels"""
poly_a = sprite1.hit_box.get_adjusted_points()
poly_b = sprite2.hit_box.get_adjusted_points()
axes = [(1.0, 0.0), (0.0, 1.0)]
for polygon in (poly_a, poly_b):
for i in range(len(polygon)):
(x1, y1), (x2, y2) = polygon[i], polygon[(i + 1) % len(polygon)]
length = ((y2 - y1) ** 2 + (x1 - x2) ** 2) ** 0.5
if length:
axes.append(((y2 - y1) / length, (x1 - x2) / length))
best = None
for nx, ny in axes:
projected_a = [nx * x + ny * y for x, y in poly_a]
projected_b = [nx * x + ny * y for x, y in poly_b]
overlap = min(max(projected_a) - min(projected_b), max(projected_b) - min(projected_a))
best = overlap if best is None else min(best, overlap)
return best


def _random_convex_sprite(rng, textures, shared_angle):
sprite = arcade.Sprite(rng.choice(textures))
sprite.scale = (rng.choice([-1, 1]) * rng.choice([0.25, 0.5, 1, 1.5]),
rng.choice([-1, 1]) * rng.choice([0.25, 0.5, 1, 1.5])) # fmt: skip
if rng.random() < 0.5:
sprite.angle = shared_angle
else:
sprite.angle = rng.choice([0, 0, 90, 180, 30, rng.uniform(0, 360)])
sprite.position = rng.randint(-160, 160) / 2, rng.randint(-160, 160) / 2
return sprite


def test_get_collision_info_basics(window):
a = arcade.SpriteSolidColor(10, 10)
b = arcade.SpriteSolidColor(10, 10, center_x=8, center_y=1)
# The smallest overlap is 2 pixels in x, so move a left
assert arcade.get_collision_info(a, b) == (Vec2(-1.0, 0.0), 2.0)
# Swapping the sprites flips the normal
assert arcade.get_collision_info(b, a) == (Vec2(1.0, 0.0), 2.0)

# Sinking into the floor: pushed straight up by exactly the overlap
player = arcade.SpriteSolidColor(10, 10, center_x=20, center_y=3)
floor = arcade.SpriteSolidColor(100, 10)
info = arcade.get_collision_info(player, floor)
assert info.normal == Vec2(0.0, 1.0)
assert info.depth == 7.0
player.position += info.normal * info.depth
assert player.position == (20, 10)
assert arcade.check_for_collision(player, floor) is False

# Identical sprites in the same place: moved up
assert arcade.get_collision_info(a, arcade.SpriteSolidColor(10, 10)) == (Vec2(0.0, 1.0), 10.0)

# Touching or apart: no collision
assert arcade.get_collision_info(a, arcade.SpriteSolidColor(10, 10, center_x=10)) is None
assert arcade.get_collision_info(a, arcade.SpriteSolidColor(10, 10, center_x=50)) is None


def test_get_collision_info_diagonal(window):
"""A diagonal edge gives a diagonal normal"""
box = arcade.SpriteSolidColor(20, 20)
box.angle = 45
other = arcade.SpriteSolidColor(20, 20, center_x=12, center_y=12)
info = arcade.get_collision_info(other, box)
assert info.normal.x == pytest.approx(2**-0.5)
assert info.normal.y == pytest.approx(2**-0.5)
assert info.depth == pytest.approx(_reference_min_overlap(other, box))


def test_get_collision_info_type_errors(window):
a = arcade.SpriteSolidColor(10, 10)
with pytest.raises(TypeError):
arcade.get_collision_info("moo", a)
with pytest.raises(TypeError):
arcade.get_collision_info(a, "moo")
with pytest.raises(TypeError):
arcade.get_collision_info(a, arcade.SpriteList())


def test_get_collision_info_matches_check_for_collision(window):
"""It finds a collision exactly when check_for_collision does, for any hit box"""
rng = random.Random(77)
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/space_shooter/meteorGrey_big1.png",
hit_box_algorithm=arcade.hitbox.algo_detailed,
),
]
results = {True: 0, False: 0}
for _ in range(3000):
shared_angle = rng.choice([0, 90, 180, 45, 30, rng.uniform(0, 360)])
a = _random_convex_sprite(rng, textures, shared_angle)
b = _random_convex_sprite(rng, textures, shared_angle)
expected = arcade.check_for_collision(a, b)
assert (arcade.get_collision_info(a, b) is not None) is expected
assert (arcade.get_collision_info(b, a) is not None) is expected
results[expected] += 1
assert min(results.values()) > 300


def test_get_collision_info_separates(window):
"""For convex hit boxes, moving by normal * depth is the smallest move that separates them"""
rng = random.Random(78)
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"
),
]
checked = 0
for _ in range(3000):
shared_angle = rng.choice([0, 90, 180, 45, 30, rng.uniform(0, 360)])
a = _random_convex_sprite(rng, textures, shared_angle)
b = _random_convex_sprite(rng, textures, shared_angle)
info = arcade.get_collision_info(a, b)
if info is None:
continue
checked += 1
assert info.depth > 0
assert info.normal.length() == pytest.approx(1.0)
assert info.depth == pytest.approx(_reference_min_overlap(a, b), abs=1e-6)

start = a.position
# A little further than depth separates them
a.position = start + info.normal * (info.depth + 1e-6)
assert arcade.check_for_collision(a, b) is False
# A little less doesn't
if info.depth > 1e-5:
a.position = start + info.normal * (info.depth - 1e-6)
assert arcade.check_for_collision(a, b) is True
a.position = start
assert checked > 300


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