From c59e8b99dacd904c72d585258a136221d25232d5 Mon Sep 17 00:00:00 2001 From: The-PotatoKing Date: Fri, 17 Oct 2025 05:00:00 +0000 Subject: [PATCH 1/3] Fixed sliding sprite platformer collisions by correctly selecting the platform with the highest top edge. This prevents players from getting stuck when colliding with multiple platforms. --- arcade/physics_engines.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/arcade/physics_engines.py b/arcade/physics_engines.py index d68275d77e..3ef8d1dc90 100644 --- a/arcade/physics_engines.py +++ b/arcade/physics_engines.py @@ -149,14 +149,37 @@ def _move_sprite( # f" {self.player_sprite.change_y}") elif moving_sprite.change_y < 0: # Reset number of jumps + # If we collided while moving down, we may be landing on one or more + # floor-like sprites. If multiple moving platforms overlap under + # the player, only apply the horizontal motion of a single + # platform (the one most directly under the player) to avoid + # accumulating motion from multiple platforms which can cause + # sliding/jitter when the player is between blocks. + + + # Prefer platforms (moving sprites) among the collisions. + platform_items = [ + item + for item in hit_list_x + if getattr(item, "change_x", 0.0) != 0 or getattr(item, "change_y", 0.0) != 0 + ] + + # Choose the platform with the highest top (closest to the player) + chosen_platform = None + if platform_items: + chosen_platform = max(platform_items, + key=lambda s: getattr(s, "top", + float("-inf"))) + + # Nudge the player up until no longer colliding with each collided item 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 - # NOTE: Not all sprites have velocity - if getattr(item, "change_x", 0.0) != 0: - moving_sprite.center_x += item.change_x # type: ignore + # Apply horizontal movement from the chosen platform (once) + if chosen_platform is not None and getattr(chosen_platform, "change_x", 0.0) != 0: + moving_sprite.center_x += chosen_platform.change_x # type: ignore # print(f"Spot Y ({self.player_sprite.center_x}, {self.player_sprite.center_y})") else: From a922a47fe10242b16868620aaf54be56904b3ccd Mon Sep 17 00:00:00 2001 From: The-PotatoKing Date: Fri, 17 Oct 2025 13:49:36 +0000 Subject: [PATCH 2/3] Fixed Formatting from previous commit modified: tests/test_physics_engines.py --- arcade/physics_engines.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/arcade/physics_engines.py b/arcade/physics_engines.py index 3ef8d1dc90..deecd27487 100644 --- a/arcade/physics_engines.py +++ b/arcade/physics_engines.py @@ -156,7 +156,6 @@ def _move_sprite( # accumulating motion from multiple platforms which can cause # sliding/jitter when the player is between blocks. - # Prefer platforms (moving sprites) among the collisions. platform_items = [ item @@ -167,9 +166,9 @@ def _move_sprite( # Choose the platform with the highest top (closest to the player) chosen_platform = None if platform_items: - chosen_platform = max(platform_items, - key=lambda s: getattr(s, "top", - float("-inf"))) + chosen_platform = max( + platform_items, key=lambda s: getattr(s, "top", float("-inf")) + ) # Nudge the player up until no longer colliding with each collided item for item in hit_list_x: From e401070757b89e1261c04df23edbea13ceab2cd9 Mon Sep 17 00:00:00 2001 From: Paul V Craven Date: Thu, 8 Oct 2026 11:19:02 -0500 Subject: [PATCH 3/3] Move with what the player stands on most, and add tests Choose the sprite the player overlaps the most, still or moving, with the highest top as a tie-break. Before, with platforms at the same height, the first one in the list won, so a player standing mostly on a platform moving left could be carried right by one it barely touched, and a still floor didn't stop an edge-touching platform from dragging the player along. Add tests for one platform, platforms moving together, platforms moving apart, and a moving platform next to a still one, plus a changelog entry. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + arcade/physics_engines.py | 39 ++++------ .../test_physics_engine_moving_platforms.py | 72 +++++++++++++++++++ 3 files changed, 85 insertions(+), 27 deletions(-) create mode 100644 tests/unit/physics_engine/test_physics_engine_moving_platforms.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d42aaa89e..28ac23a7c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. ## Unreleased ### Fixes +- Fixed a player in `PhysicsEnginePlatformer` sliding along when standing across two moving platforms. It moved by both platforms' speeds added together, so it slid until it stood on only one. It now moves with the platform it stands on the most, and isn't moved by a platform it only touches the edge of while standing mostly on something still ([#2782](https://github.com/pythonarcade/arcade/issues/2782)). - Fixed the PyInstaller hook bundling Arcade's version file at `arcade/VERSION/VERSION`. Frozen apps printed an error on startup and reported `arcade.VERSION` as `0.0.0`, and builds using `collect_all("arcade")` failed because the hook and `collect_all` put a folder and a file at the same path. The version file is now `arcade/_VERSION`, so it can't be confused with the `arcade.version` module ([#2837](https://github.com/pythonarcade/arcade/issues/2837)). - Fixed drawing into a texture atlas losing new drawing when the atlas grows during `render_into()`. Nested rendering and exception exits now restore the previous camera and render region. - Fixed several `arcade.create_text_sprite()` bugs: diff --git a/arcade/physics_engines.py b/arcade/physics_engines.py index e8cfa5c5b2..46f80d3302 100644 --- a/arcade/physics_engines.py +++ b/arcade/physics_engines.py @@ -324,36 +324,21 @@ def _move_sprite( # 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 - # If we collided while moving down, we may be landing on one or more - # floor-like sprites. If multiple moving platforms overlap under - # the player, only apply the horizontal motion of a single - # platform (the one most directly under the player) to avoid - # accumulating motion from multiple platforms which can cause - # sliding/jitter when the player is between blocks. - - # Prefer platforms (moving sprites) among the collisions. - platform_items = [ - item - for item in hit_list_x - if getattr(item, "change_x", 0.0) != 0 or getattr(item, "change_y", 0.0) != 0 - ] - - # Choose the platform with the highest top (closest to the player) - chosen_platform = None - if platform_items: - chosen_platform = max( - platform_items, key=lambda s: getattr(s, "top", float("-inf")) - ) - - # Nudge the player up until no longer colliding with each collided item + # Move straight up out of what we landed on, which also works + # for ramps for item in hit_list_x: - # Move straight up, which also works for ramps _move_out_of_sprite(moving_sprite, item, 1.0, 0.25) - # Apply horizontal movement from the chosen platform (once) - if chosen_platform is not None and getattr(chosen_platform, "change_x", 0.0) != 0: - moving_sprite.center_x += chosen_platform.change_x # type: ignore + # Move with what the sprite stands on the most, or if that's a + # tie, the highest. Moving with each moving platform it touches + # would add their speeds together. + def footing(item: SpriteType) -> tuple[float, float]: + overlap = min(moving_sprite.right, item.right) - max(moving_sprite.left, item.left) + return overlap, item.top + + # NOTE: Not all sprites have velocity + standing_on = max(hit_list_x, key=footing) + moving_sprite.center_x += getattr(standing_on, "change_x", 0.0) # print(f"Spot Y ({self.player_sprite.center_x}, {self.player_sprite.center_y})") else: diff --git a/tests/unit/physics_engine/test_physics_engine_moving_platforms.py b/tests/unit/physics_engine/test_physics_engine_moving_platforms.py new file mode 100644 index 0000000000..ede5f72d8a --- /dev/null +++ b/tests/unit/physics_engine/test_physics_engine_moving_platforms.py @@ -0,0 +1,72 @@ +""" +A player standing on moving platforms in PhysicsEnginePlatformer moves with +them. Standing across two platforms used to move it by both their speeds +added together, so it slid along until it stood on only one (#2782). +""" + +import pytest + +import arcade + +PLATFORM_WIDTH = 64 +SIZE = 32 + + +def _platforms(*speeds): + """A row of touching platforms with their tops at y = 32.""" + platforms = arcade.SpriteList() + for i, speed in enumerate(speeds): + platform = arcade.SpriteSolidColor( + PLATFORM_WIDTH, 32, center_x=100 + i * PLATFORM_WIDTH, center_y=16 + ) + platform.change_x = speed + platforms.append(platform) + return platforms + + +def _run(player_x, *speeds, frames=30): + platforms = _platforms(*speeds) + player = arcade.SpriteSolidColor(SIZE, SIZE, center_x=player_x, center_y=48) + engine = arcade.PhysicsEnginePlatformer(player, platforms=platforms, gravity_constant=1) + for _ in range(frames): + engine.update() + return player, platforms + + +@pytest.mark.parametrize("speed", [2, -3]) +def test_one_platform(speed): + player, platforms = _run(100, speed) + assert player.center_x == pytest.approx(100 + 30 * speed) + assert player.bottom == pytest.approx(platforms[0].top) + + +@pytest.mark.parametrize("speed", [2, -3]) +@pytest.mark.parametrize("count", [2, 4]) +def test_across_platforms_moving_together(speed, count): + # Centered on the seam between the first two platforms + player, platforms = _run(132, *[speed] * count) + assert player.center_x == pytest.approx(132 + 30 * speed) + assert player.bottom == pytest.approx(platforms[0].top) + + +@pytest.mark.parametrize( + ("player_x", "expected_change"), + [ + # Mostly on the left platform, which moves right + (120, 2), + # Mostly on the right platform, which moves left + (144, -2), + ], +) +def test_across_platforms_moving_apart(player_x, expected_change): + player, _ = _run(player_x, 2, -2, frames=1) + assert player.center_x == pytest.approx(player_x + expected_change) + + +def test_moving_platform_next_to_still_one(): + # Mostly on a still platform, a little on a moving one + player, _ = _run(120, 0, 2, frames=1) + assert player.center_x == pytest.approx(120) + # Mostly on the moving one + player, _ = _run(144, 0, 2, frames=1) + assert player.center_x == pytest.approx(146)