diff --git a/CHANGELOG.md b/CHANGELOG.md index 05a72d1075..82fb2d3170 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Fixed `SpriteList.preload_textures()` raising `AttributeError` on a lazy sprite list that hadn't been drawn yet. It now preloads into the atlas the list will use. - Fixed GPU collision checks (`CollisionMethod.GPU`, also used automatically for sprite lists over 1500 sprites without a spatial hash) missing sprites flipped with a negative scale, whose negative width or height made them look smaller than they are. - Fixed `SpriteList.__setitem__` raising when setting a negative index to the sprite already there, such as `sprite_list[-1] = sprite_list[-1]`. +- Fixed `PhysicsEngineSimple` and `PhysicsEnginePlatformer` leaving a sprite overlapping a wall after it rotated against the wall while moving sideways. The sideways move was measured from where the sprite was before rotating, undoing the move out of the wall. ### New Features - Added `HitBox.get_adjusted_bounds()`, which returns the cached `(left, right, bottom, top)` bounds of the adjusted hit box points. @@ -38,6 +39,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Added `arcade.get_collision_info_with_list(sprite, sprite_list)`, which returns a `(sprite, CollisionInfo)` pair for each sprite in the list that `sprite` collides with, deepest overlap first. Added the `sprite_push_out` example, which uses it to push a player out of walls, sliding along rotated ones. ### Misc Changes +- `PhysicsEngineSimple` and `PhysicsEnginePlatformer` now move sprites out of walls exactly, instead of in steps: landing on a floor or ramp and hitting a ceiling stop the sprite exactly at the surface (before, it could stop up to 0.25 px above a floor or 1 px below a ceiling), and rotating into a wall moves the sprite the smallest distance out of it (before, a whole number of pixels). The older searches are kept as a fallback, for example for concave hit boxes. Collisions are also faster: a hard landing went from about 230 to 42 µs, and a ceiling bump from about 104 to 36 µs. - 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). - The platformer and simple physics engines and `AStarBarrierList` now use `has_collision_with_list(s)` where they only need to know whether there's a collision. Building an `AStarBarrierList` is about 7-9% faster. - Sped up sprite collision checks. Sprites that pass the quick distance check are now compared by cached hit box bounds before the polygon test, and the polygon test skips horizontal and vertical edges, which the bounds check already covers. Checks that reach the polygon test are about 2-4x faster, e.g. 8.0 to 2.3 µs for two box hit boxes and 21.6 to 10.2 µs for two default octagon hit boxes. `are_polygons_intersecting` is also faster (7.3 to 1.6 µs for two rectangles). diff --git a/arcade/physics_engines.py b/arcade/physics_engines.py index d93f420ca4..438988c4eb 100644 --- a/arcade/physics_engines.py +++ b/arcade/physics_engines.py @@ -12,18 +12,130 @@ SpriteType, check_for_collision, check_for_collision_with_lists, + get_collision_info_with_list, has_collision_with_lists, ) from arcade.math import get_distance +from arcade.sprite_list.collision import _get_separation_distance __all__ = ["PhysicsEngineSimple", "PhysicsEnginePlatformer"] from arcade.utils import Chain, copy_dunders_unimplemented +# How many times to try moving a sprite out of overlapping walls before +# falling back to the older, slower search +_MAX_PUSHES = 8 + + +def _min_move(distance: float, position: float) -> float: + """Make sure a move is big enough to change ``position``. + + Moving a sprite exactly out of a wall can leave it overlapping by a + rounding error, which still counts as a collision. A move of that size + may be too small to change a large coordinate at all. + """ + return max(distance, 1e-9 * (1.0 + abs(position))) + + +def _push_out_of_walls(colliding: Sprite, walls: Iterable[SpriteSequence[BasicSprite]]) -> bool: + """Move a sprite out of the walls it overlaps, using the smallest moves. + + Each step moves the sprite out of the wall it overlaps most deeply, as + found by :py:func:`arcade.get_collision_info_with_list`. This is exact + for convex hit boxes, but can fail, for example when wedged between two + walls, or with concave hit boxes. + + Args: + colliding: + A sprite to move out of the given list of SpriteLists. + walls: + The walls to move it out of. + Returns: + ``True`` if the sprite no longer overlaps any wall. + """ + for _ in range(_MAX_PUSHES): + deepest = None + for wall_list in walls: + hits = get_collision_info_with_list(colliding, wall_list) + if hits and (deepest is None or hits[0][1].depth > deepest.depth): + deepest = hits[0][1] + if deepest is None: + return True + x, y = colliding.position + normal_x, normal_y = deepest.normal + distance = _min_move(deepest.depth, max(abs(x), abs(y))) + colliding.position = x + normal_x * distance, y + normal_y * distance + return not has_collision_with_lists(colliding, walls) + + +def _move_out_of_sprite( + moving: Sprite, other: BasicSprite, direction_y: float, step: float +) -> None: + """Move a sprite straight up or down until it no longer collides with another. + + Args: + moving: + The sprite to move. + other: + The sprite to move it out of. + direction_y: + ``1.0`` to move up, ``-1.0`` to move down. + step: + The step size for the fallback search, if the exact move fails. + """ + original_y = moving.center_y + for _ in range(_MAX_PUSHES): + distance = _get_separation_distance(moving, other, 0.0, direction_y) + if distance == 0.0: + return + y = moving.center_y + moving.center_y = y + direction_y * _min_move(distance, y) + + # Fall back to stepping, as older versions did + moving.center_y = original_y + while check_for_collision(moving, other): + moving.center_y += direction_y * step + + +def _move_out_of_walls( + moving: Sprite, + walls: Iterable[SpriteSequence[BasicSprite]], + direction_y: float, + step: float, +) -> None: + """Move a sprite straight up or down until it no longer overlaps any wall. + + Args: + moving: + The sprite to move. + walls: + The walls to move it out of. + direction_y: + ``1.0`` to move up, ``-1.0`` to move down. + step: + The step size for the fallback search, if the exact move fails. + """ + original_y = moving.center_y + for _ in range(_MAX_PUSHES): + hits = check_for_collision_with_lists(moving, walls) + if not hits: + return + distance = max(_get_separation_distance(moving, hit, 0.0, direction_y) for hit in hits) + y = moving.center_y + moving.center_y = y + direction_y * _min_move(distance, y) + + # Fall back to stepping, as older versions did + moving.center_y = original_y + while has_collision_with_lists(moving, walls): + moving.center_y += direction_y * step + def _wiggle_until_free(colliding: Sprite, walls: Iterable[SpriteSequence[BasicSprite]]) -> None: """Kludge to 'guess' a colliding sprite out of a collision. + This is only used as a fallback, when moving the sprite out of the walls + the smallest distance (see :py:func:`_push_out_of_walls`) fails. + It works by iterating over increasing wiggle sizes of 8 points around the ``colliding`` sprite's original center position. Each time it fails to find a free position. Although the wiggle distance @@ -101,9 +213,17 @@ def _move_sprite( A list of other individual sprites the ``moving_sprite`` collided with. """ + # Whether a collision was resolved this update, which may leave the + # sprite exactly touching a wall + resolved = False + # See if we are starting this turn with a sprite already colliding with us. if has_collision_with_lists(moving_sprite, can_collide): - _wiggle_until_free(moving_sprite, can_collide) + resolved = True + start = moving_sprite.position + if not _push_out_of_walls(moving_sprite, can_collide): + moving_sprite.position = start + _wiggle_until_free(moving_sprite, can_collide) original_x, original_y = moving_sprite.position original_angle = moving_sprite.angle @@ -118,10 +238,13 @@ def _move_sprite( rotating_hit_list = check_for_collision_with_lists(moving_sprite, can_collide) if len(rotating_hit_list) > 0: + resolved = True max_distance = (moving_sprite.width + moving_sprite.height) / 2 - # Resolve any collisions by this weird kludge - _wiggle_until_free(moving_sprite, can_collide) + # Move out of the walls the smallest distance, or guess if that fails + if not _push_out_of_walls(moving_sprite, can_collide): + moving_sprite.position = original_x, original_y + _wiggle_until_free(moving_sprite, can_collide) if ( get_distance(original_x, original_y, moving_sprite.center_x, moving_sprite.center_y) > max_distance @@ -130,6 +253,10 @@ def _move_sprite( moving_sprite.position = original_x, original_y moving_sprite.angle = original_angle + # Measure the moves below from where rotating left the sprite, + # so the x move doesn't undo moving it out of a wall + original_x, original_y = moving_sprite.position + # --- Move in the y direction moving_sprite.center_y += moving_sprite.change_y @@ -141,16 +268,14 @@ def _move_sprite( # If we hit a wall, move so the edges are at the same point if len(hit_list_x) > 0: if moving_sprite.change_y > 0: - while has_collision_with_lists(moving_sprite, can_collide): - moving_sprite.center_y -= 1 + _move_out_of_walls(moving_sprite, can_collide, -1.0, 1) # print(f"Spot X ({self.player_sprite.center_x}, {self.player_sprite.center_y})" # f" {self.player_sprite.change_y}") elif moving_sprite.change_y < 0: # Reset number of jumps for item in hit_list_x: - while check_for_collision(moving_sprite, item): - # self.player_sprite.bottom = item.top <- Doesn't work for ramps - moving_sprite.center_y += 0.25 + # Move straight up, which also works for ramps + _move_out_of_sprite(moving_sprite, item, 1.0, 0.25) # NOTE: Not all sprites have velocity if getattr(item, "change_x", 0.0) != 0: @@ -170,7 +295,13 @@ def _move_sprite( moving_sprite.change_y = min(0.0, getattr(hit_list_x[0], "change_y", 0.0)) # print(f"Spot D ({self.player_sprite.center_x}, {self.player_sprite.center_y})") - moving_sprite.center_y = round(moving_sprite.center_y, 2) + exact_y = moving_sprite.center_y + rounded_y = round(exact_y, 2) + if rounded_y != exact_y: + moving_sprite.center_y = rounded_y + if (resolved or hit_list_x) and has_collision_with_lists(moving_sprite, can_collide): + # Rounding moved it back into a wall it was moved out of + moving_sprite.center_y = exact_y # print(f"Spot Q ({self.player_sprite.center_x}, {self.player_sprite.center_y})") # end_time = time.time() diff --git a/arcade/sprite_list/collision.py b/arcade/sprite_list/collision.py index 7897cc04a0..72aeaae52b 100644 --- a/arcade/sprite_list/collision.py +++ b/arcade/sprite_list/collision.py @@ -282,6 +282,68 @@ def _get_collision_info(sprite1: BasicSprite, sprite2: BasicSprite) -> Collision return CollisionInfo(Vec2(best_x, best_y), best_depth) +def _get_separation_distance( + sprite1: BasicSprite, sprite2: BasicSprite, direction_x: float, direction_y: float +) -> float: + """ + How far ``sprite1`` must move along a direction to stop colliding with ``sprite2``. + + Unlike :py:func:`get_collision_info`, the direction is fixed, for + example straight up to land on a slope. Returns ``0.0`` if the sprites + don't collide. Like the other separating axis functions, this is only + exact for convex hit boxes; callers should check the result. + + Args: + sprite1: The sprite to move + sprite2: The sprite to move it away from + direction_x: X component of the unit direction to move ``sprite1`` + direction_y: Y component of the unit direction to move ``sprite1`` + """ + hit_box1 = sprite1._hit_box + hit_box2 = sprite2._hit_box + points1 = hit_box1.get_adjusted_points() + points2 = hit_box2.get_adjusted_points() + if not points1 or not points2: + return 0.0 + + 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 0.0 + + # Moving a distance t along the direction shifts sprite1's projection on + # an axis by t * (direction . axis). The sprites are separated once they + # are on any one axis, so the answer is the smallest distance that + # separates them on some axis. + best = float("inf") + if direction_x > 0: + best = min(best, (right2 - left1) / direction_x) + elif direction_x < 0: + best = min(best, (right1 - left2) / -direction_x) + if direction_y > 0: + best = min(best, (top2 - bottom1) / direction_y) + elif direction_y < 0: + best = min(best, (top1 - bottom2) / -direction_y) + + 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 0.0 + speed = direction_x * normal_x + direction_y * normal_y + if speed > 0: + best = min(best, (max_2 - min_1) / speed) + elif speed < 0: + best = min(best, (max_1 - min_2) / -speed) + + return best + + def _check_for_collision(sprite1: BasicSprite, sprite2: BasicSprite) -> bool: """ Check for collision between two sprites. diff --git a/tests/unit/physics_engine/test_physics_engine2.py b/tests/unit/physics_engine/test_physics_engine2.py index 8fdeca83ea..7357086737 100644 --- a/tests/unit/physics_engine/test_physics_engine2.py +++ b/tests/unit/physics_engine/test_physics_engine2.py @@ -1,6 +1,7 @@ """Physics engine tests.""" import copy +import math import pytest @@ -8,6 +9,17 @@ OUT_OF_THE_WAY = (250, 250) +# How far a 10x10 sprite rotated by 1 or 45 degrees sticks out past its +# unrotated edge. Rotating next to a wall moves the sprite exactly this far. +ROTATED_1_OVERHANG = 5 * (math.cos(math.radians(1)) + math.sin(math.radians(1))) - 5 +ROTATED_45_OVERHANG = 5 * math.sqrt(2) - 5 + + +def check_rotated_out_of_wall(moving_sprite, wall_list, expected_position): + """Rotating moved the sprite exactly out of the wall""" + assert moving_sprite.position == pytest.approx(expected_position, abs=1e-6) + assert not arcade.check_for_collision_with_list(moving_sprite, wall_list) + def check_spritelists_prop_clears_instead_of_overwrites(engine, prop_name: str): """Some properties are backed by lists which shouldn't be recreated. @@ -132,7 +144,7 @@ def basic_tests(moving_sprite, wall_list, physics_engine): collisions = physics_engine.update() assert len(collisions) == 1 assert collisions[0] == wall_sprite_1 - assert moving_sprite.position == (-1, 0) + check_rotated_out_of_wall(moving_sprite, wall_list, (-ROTATED_1_OVERHANG, 0)) # Check rotation 45 degrees wall_sprite_1.position = (10, 0) @@ -144,7 +156,7 @@ def basic_tests(moving_sprite, wall_list, physics_engine): collisions = physics_engine.update() assert len(collisions) == 1 assert collisions[0] == wall_sprite_1 - assert moving_sprite.position == (-4, 0) + check_rotated_out_of_wall(moving_sprite, wall_list, (-ROTATED_45_OVERHANG, 0)) # - Rotate, with block to the left # Check rotation one degree @@ -157,7 +169,7 @@ def basic_tests(moving_sprite, wall_list, physics_engine): collisions = physics_engine.update() assert len(collisions) == 1 assert collisions[0] == wall_sprite_1 - assert moving_sprite.position == (1, 0) + check_rotated_out_of_wall(moving_sprite, wall_list, (ROTATED_1_OVERHANG, 0)) # Check rotation 45 degrees wall_sprite_1.position = (-10, 0) @@ -169,7 +181,7 @@ def basic_tests(moving_sprite, wall_list, physics_engine): collisions = physics_engine.update() assert len(collisions) == 1 assert collisions[0] == wall_sprite_1 - assert moving_sprite.position == (4, 0) + check_rotated_out_of_wall(moving_sprite, wall_list, (ROTATED_45_OVERHANG, 0)) # - Rotate, with block above # Check rotation one degree @@ -182,7 +194,9 @@ def basic_tests(moving_sprite, wall_list, physics_engine): collisions = physics_engine.update() assert len(collisions) == 1 assert collisions[0] == wall_sprite_1 - assert moving_sprite.position == (0, -1) + # The engines round y to 2 decimal places, unless that would move the + # sprite back into the wall + check_rotated_out_of_wall(moving_sprite, wall_list, (0, round(-ROTATED_1_OVERHANG, 2))) # Check rotation 45 degrees wall_sprite_1.position = (0, 10) @@ -194,7 +208,7 @@ def basic_tests(moving_sprite, wall_list, physics_engine): collisions = physics_engine.update() assert len(collisions) == 1 assert collisions[0] == wall_sprite_1 - assert moving_sprite.position == (0, -4) + check_rotated_out_of_wall(moving_sprite, wall_list, (0, -ROTATED_45_OVERHANG)) # - Rotate, between two blocks # Check rotation one degree diff --git a/tests/unit/physics_engine/test_physics_engine_push_out.py b/tests/unit/physics_engine/test_physics_engine_push_out.py new file mode 100644 index 0000000000..81705cdacd --- /dev/null +++ b/tests/unit/physics_engine/test_physics_engine_push_out.py @@ -0,0 +1,178 @@ +""" +Tests for how the simple and platformer physics engines move sprites out of walls. +""" + +import math +import random + +import pytest + +import arcade +from arcade.sprite_list.collision import _get_separation_distance + +R = ":resources:images/" + + +def make_walls(*walls): + wall_list = arcade.SpriteList() + wall_list.extend(walls) + return wall_list + + +def box(width, height, x=0.0, y=0.0, angle=0.0): + sprite = arcade.SpriteSolidColor(width, height, center_x=x, center_y=y) + sprite.angle = angle + return sprite + + +@pytest.mark.parametrize("fall_speed", [0.3, 1, 5, 7.7, 18, 31]) +def test_land_exactly_on_floor(window, fall_speed): + """Landing leaves the sprite exactly on the floor, without a gap""" + floor = box(200, 20, y=-10) # Top at y=0 + player = box(10, 10, y=5 + fall_speed - 0.1) # Overlaps the floor after falling + engine = arcade.PhysicsEnginePlatformer(player, walls=make_walls(floor), gravity_constant=0) + player.change_y = -fall_speed + + hits = engine.update() + assert hits == [floor] + assert player.bottom == pytest.approx(0, abs=1e-9) + assert not arcade.check_for_collision(player, floor) + assert engine.can_jump() + + +@pytest.mark.parametrize("slope", [5, 12, 30, -20]) +def test_land_on_ramp(window, slope): + """Landing on a rotated wall rests the sprite on its surface""" + ramp = box(200, 20, angle=slope) + walls = make_walls(ramp) + player = box(10, 10, x=30, y=60) + engine = arcade.PhysicsEnginePlatformer(player, walls=walls, gravity_constant=1) + for _ in range(60): + engine.update() + + assert not arcade.check_for_collision(player, ramp) + # Resting on it: y is rounded to 2 decimal places, so within 0.01 + player.center_y -= 0.011 + assert arcade.check_for_collision(player, ramp) + + +@pytest.mark.parametrize("speed", [0.4, 1, 6, 13.3]) +def test_ceiling_bump_exact(window, speed): + """Jumping into a ceiling stops the sprite right below it""" + ceiling = box(200, 20, y=60) # Bottom at y=50 + player = box(10, 10, y=45 - speed + 0.1) + engine = arcade.PhysicsEnginePlatformer(player, walls=make_walls(ceiling), gravity_constant=0) + player.change_y = speed + + assert engine.update() == [ceiling] + assert not arcade.check_for_collision(player, ceiling) + assert player.top == pytest.approx(50, abs=0.01) + assert player.change_y == 0 + + +@pytest.mark.parametrize("engine_type", ["simple", "platformer"]) +@pytest.mark.parametrize("change_x", [-3, 2, 5]) +def test_rotate_while_moving_sideways(window, engine_type, change_x): + """Rotating against a wall while moving sideways doesn't end in the wall""" + wall = box(20, 200, x=20) # Left edge at x=10 + walls = make_walls(wall) + player = box(10, 20, x=4.9) + if engine_type == "simple": + engine = arcade.PhysicsEngineSimple(player, walls) + else: + engine = arcade.PhysicsEnginePlatformer(player, walls=walls, gravity_constant=0) + + player.change_x = change_x + player.change_angle = 3 + for _ in range(40): + engine.update() + assert not arcade.check_for_collision(player, wall) + + +@pytest.mark.parametrize("engine_type", ["simple", "platformer"]) +def test_start_overlapping_wedged(window, engine_type): + """Starting wedged between two walls still frees the sprite (using the fallback search)""" + left = box(20, 20, x=-12) + right = box(20, 20, x=12) + walls = make_walls(left, right) + player = box(10, 10) # Overlaps both walls by 3 pixels + if engine_type == "simple": + engine = arcade.PhysicsEngineSimple(player, walls) + else: + engine = arcade.PhysicsEnginePlatformer(player, walls=walls, gravity_constant=0) + + engine.update() + assert not arcade.check_for_collision_with_list(player, walls) + + +def test_start_overlapping_concave_wall(window): + """A wall with a concave detailed hit box still frees the sprite""" + texture = arcade.load_texture( + R + "space_shooter/meteorGrey_big1.png", hit_box_algorithm=arcade.hitbox.algo_detailed + ) + meteor = arcade.Sprite(texture) + walls = make_walls(meteor) + for x, y in ((0, 0), (30, 10), (-40, -20), (10, 35)): + player = box(10, 10, x=x, y=y) + engine = arcade.PhysicsEngineSimple(player, walls) + engine.update() + assert not arcade.check_for_collision(player, meteor) + + +@pytest.mark.parametrize("engine_type", ["simple", "platformer"]) +def test_start_overlapping_smallest_move(window, engine_type): + """A sprite starting inside a wall is moved out the shortest way""" + wall = box(40, 40, angle=30) + walls = make_walls(wall) + player = box(10, 10, x=18, y=3) + expected = arcade.get_collision_info(player, wall) + start = player.position + if engine_type == "simple": + engine = arcade.PhysicsEngineSimple(player, walls) + else: + engine = arcade.PhysicsEnginePlatformer(player, walls=walls, gravity_constant=0) + + engine.update() + assert not arcade.check_for_collision(player, wall) + moved = math.dist(start, player.position) + # Exactly the smallest move, apart from rounding y to 2 decimal places + assert moved == pytest.approx(expected.depth, abs=0.011) + + +def test_separation_distance(window): + """_get_separation_distance is the exact distance to move along a direction""" + rng = random.Random(10) + textures = [ + arcade.load_texture(R + "tiles/grassMid.png"), + arcade.load_texture(R + "items/coinGold.png"), + arcade.load_texture(R + "space_shooter/laserBlue01.png"), + ] + + def random_sprite(): + sprite = arcade.Sprite(rng.choice(textures)) + sprite.scale = (rng.choice([-1, 1]) * rng.choice([0.25, 0.5, 1]), + rng.choice([-1, 1]) * rng.choice([0.25, 0.5, 1])) # fmt: skip + sprite.angle = rng.choice([0, 90, 30, rng.uniform(0, 360)]) + sprite.position = rng.randint(-80, 80) / 2, rng.randint(-80, 80) / 2 + return sprite + + checked = 0 + for _ in range(3000): + a, b = random_sprite(), random_sprite() + angle = rng.choice([90, -90, 0, 180, rng.uniform(0, 360)]) + dx = round(math.cos(math.radians(angle)), 12) + dy = round(math.sin(math.radians(angle)), 12) + distance = _get_separation_distance(a, b, dx, dy) + colliding = arcade.check_for_collision(a, b) + assert (distance > 0) is colliding + if not colliding: + continue + checked += 1 + start = a.position + a.position = start[0] + dx * (distance + 1e-6), start[1] + dy * (distance + 1e-6) + assert not arcade.check_for_collision(a, b) + if distance > 1e-5: + a.position = start[0] + dx * (distance - 1e-6), start[1] + dy * (distance - 1e-6) + assert arcade.check_for_collision(a, b) + a.position = start + assert checked > 300