From 58514774a4ca251a86dcba8f7353e6a52da74faf Mon Sep 17 00:00:00 2001 From: Paul V Craven Date: Tue, 6 Oct 2026 14:30:30 -0500 Subject: [PATCH] Make a new Sprite.hit_box match the sprite right away The hit_box setter kept the given hit box's position and scale, which for a new HitBox are (0, 0) and 1, so it hit nothing until the sprite moved (and kept the wrong scale until the sprite was scaled). The spatial hash wasn't updated for the new hit box's size either. Now the setter gives the hit box the sprite's position, scale and angle, for HitBox and RotatableHitBox alike, and updates the spatial hash. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + arcade/sprite/sprite.py | 22 +++++--- tests/unit/sprite/test_sprite_collision.py | 2 +- tests/unit/sprite/test_sprite_hitbox.py | 64 ++++++++++++++++++++++ 4 files changed, 81 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9717adcf91..7808248ef2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Fixed drawing on scaled (HiDPI) displays, such as Windows set to 125% or 150%: - After the first frame that drew text, the whole game was drawn too small, in the bottom left corner of the window. pyglet sets the window camera's viewport and scissor when it draws text, and Arcade gave them in window units instead of pixels, so they also stayed wrong for everything drawn after. Text was too small even in the first frame, and GUI text was drawn at the wrong size, outside its widget. - `Window.get_pixel_ratio()` returned pyglet's display scale instead of the framebuffer to window size ratio, which differ when pyglet's `dpi_scaling` option is `"platform"`. Everything on screen was then drawn scaled up, cut off at the right and top. +- Fixed setting `Sprite.hit_box`. The new hit box stayed at (0, 0) with a scale of 1 until the sprite moved or was scaled, so it collided with nothing, and a spatial hash didn't account for its size. It now takes the sprite's position, scale and angle right away. ### New Features - Added `arcade.sweep_sprite(sprite, dx, dy, sprite_list)`, which checks the whole path of a moving sprite and returns a `SweepInfo` for the first sprite it would hit (the `sprite`, how far along the move as a `fraction` and `distance`, and the surface `normal`), or `None`. Fast sprites can't pass through thin walls this way. A sprite that already overlaps one is an immediate hit. Added the `sprite_bullets_sweep` example comparing it with a plain collision check. diff --git a/arcade/sprite/sprite.py b/arcade/sprite/sprite.py index 7f54dff850..5a5577b5e7 100644 --- a/arcade/sprite/sprite.py +++ b/arcade/sprite/sprite.py @@ -221,17 +221,25 @@ def change_y(self, new_value: float) -> None: @property def hit_box(self) -> HitBox: - """Get or set the hit box for this sprite.""" + """ + Get or set the hit box for this sprite. + + The hit box's points are relative to the sprite's center, before + scaling and rotation. Setting it moves, scales and rotates the hit + box to match the sprite, so its own position, scale and angle + don't matter. + """ return self._hit_box @hit_box.setter def hit_box(self, hit_box: HitBox | RotatableHitBox) -> None: - if type(hit_box) is HitBox: - self._hit_box = hit_box.create_rotatable(self.angle) - else: - # Mypy doesn't seem to understand the type check above - # It still thinks hit_box can be a union here - self._hit_box = hit_box # type: ignore + if not isinstance(hit_box, RotatableHitBox): + hit_box = hit_box.create_rotatable(self.angle) + hit_box.position = self._position + hit_box.scale = self._scale + hit_box.angle = self.angle + self._hit_box = hit_box + self.update_spatial_hash() @property def texture(self) -> Texture: diff --git a/tests/unit/sprite/test_sprite_collision.py b/tests/unit/sprite/test_sprite_collision.py index c995e76b52..90b21cb813 100644 --- a/tests/unit/sprite/test_sprite_collision.py +++ b/tests/unit/sprite/test_sprite_collision.py @@ -770,7 +770,7 @@ def test_sweep_sprite_ends_touching_slanted_edge(window): sprite.hit_box = arcade.hitbox.HitBox([(5, 0), (0, 5), (-5, 0), (0, -5)]) wall = arcade.SpriteSolidColor(20, 20) wall.hit_box = arcade.hitbox.HitBox([(10, 0), (0, 10), (-10, 0), (0, -10)]) - # Set after the hit box, which is created at (0, 0) + # Moved after setting the hit box wall.position = 30, 10 walls = _walls(wall) # After moving 25, the sprite's upper right edge lies along the wall's diff --git a/tests/unit/sprite/test_sprite_hitbox.py b/tests/unit/sprite/test_sprite_hitbox.py index 13bbf1f407..c69935c9f3 100644 --- a/tests/unit/sprite/test_sprite_hitbox.py +++ b/tests/unit/sprite/test_sprite_hitbox.py @@ -107,3 +107,67 @@ def test_2(): (-8.0, -32.0), (-28.0, -17.0), ] + + +SQUARE = ((-10, -10), (-10, 10), (10, 10), (10, -10)) + + +def _rounded(points): + return [(round(x, 6), round(y, 6)) for x, y in points] + + +def test_set_hit_box_matches_sprite(): + """A new hit box takes the sprite's position, scale and angle right away""" + sprite = arcade.SpriteSolidColor(20, 20, center_x=100, center_y=50) + sprite.scale = 2 + sprite.angle = 90 + sprite.hit_box = hitbox.HitBox(SQUARE) + + assert sprite.hit_box.position == (100, 50) + assert sprite.hit_box.scale == (2, 2) + assert sprite.hit_box.angle == 90 + # Clockwise rotation by 90 degrees, then scaled by 2 and moved + assert _rounded(sprite.hit_box.get_adjusted_points()) == [ + (80, 70), (120, 70), (120, 30), (80, 30) + ] # fmt: skip + + +def test_set_rotatable_hit_box_matches_sprite(): + """A RotatableHitBox's own position, scale and angle are replaced too""" + sprite = arcade.SpriteSolidColor(20, 20, center_x=100, center_y=50) + sprite.scale = 0.5 + sprite.hit_box = hitbox.RotatableHitBox(SQUARE, position=(7, 7), angle=30, scale=(3, 3)) + assert sprite.hit_box.position == (100, 50) + assert sprite.hit_box.scale == (0.5, 0.5) + assert sprite.hit_box.angle == 0 + assert _rounded(sprite.hit_box.get_adjusted_points()) == [ + (95, 45), (95, 55), (105, 55), (105, 45) + ] # fmt: skip + + +def test_set_hit_box_follows_sprite_afterwards(): + sprite = arcade.SpriteSolidColor(20, 20, center_x=100, center_y=50) + sprite.hit_box = hitbox.HitBox(SQUARE) + sprite.position = 10, 20 + sprite.scale = 3 + assert _rounded(sprite.hit_box.get_adjusted_points()) == [ + (-20, -10), (-20, 50), (40, 50), (40, -10) + ] # fmt: skip + + +def test_set_hit_box_collides_right_away(): + """The hit box used to stay at (0, 0) until the sprite moved, so it hit nothing""" + sprite = arcade.SpriteSolidColor(20, 20, center_x=100, center_y=100) + sprite.hit_box = hitbox.HitBox(SQUARE) + other = arcade.SpriteSolidColor(20, 20, center_x=110, center_y=100) + assert arcade.check_for_collision(sprite, other) + + +def test_set_hit_box_updates_spatial_hash(): + """A bigger hit box is found by a spatial hash without moving the sprite""" + sprite_list = arcade.SpriteList(use_spatial_hash=True) + sprite = arcade.SpriteSolidColor(20, 20, center_x=100, center_y=100) + sprite_list.append(sprite) + sprite.hit_box = hitbox.HitBox([(-200, -200), (200, -200), (200, 200), (-200, 200)]) + far_away = arcade.SpriteSolidColor(10, 10, center_x=250, center_y=250) + assert arcade.check_for_collision_with_list(far_away, sprite_list) == [sprite]