diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a0fc438f..6c73aa0a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,10 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Fixed `arcade.gui.experimental.pixelated_ui()` raising `AttributeError`. It now turns off text anti-aliasing and makes font glyphs use nearest-neighbor filtering, as documented. - Fixed `InputManager.parse()` raising `AttributeError`, so a saved input configuration couldn't be loaded. +### Breaking Changes +- `arcade.math.get_angle_degrees()` now measures clockwise from straight up, like `Sprite.angle`: 0 is up, 90 is right, 180 is down and -90 is left. It used to measure clockwise from the right, which disagreed with `get_angle_radians()`, which already measured from up ([#2754](https://github.com/pythonarcade/arcade/issues/2754)). The two now always give the same angle in different units. A sprite whose image points up faces a point with `sprite.angle = get_angle_degrees(...)`. For the old value, subtract 90. + - Fixed the `sprite_rotate_around_tank` example's corrected barrel, which sat 90 degrees off from the mouse, and updated `turn_and_move` for the new angles. `turn_and_move`'s sprite also now stops when moving straight along an axis, which it never did. + ### 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. - Added the `sprite_pixel_demolition` example, a stress test where every pixel of the Arcade logo is a sprite (28,000, or 71,000 with smaller pixels) and exploding bullets knock them loose as debris. Debris can move as Python sprites or on the GPU with a shader (press G), which keeps 60 FPS with 15,000 pieces in flight. diff --git a/arcade/examples/sprite_rotate_around_tank.py b/arcade/examples/sprite_rotate_around_tank.py index 9bb6fbd83..2473e251c 100644 --- a/arcade/examples/sprite_rotate_around_tank.py +++ b/arcade/examples/sprite_rotate_around_tank.py @@ -157,8 +157,9 @@ def move_tank(self, delta_time): self.tank.center_x, self.tank.center_y, self.mouse_pos[0], self.mouse_pos[1]) - # Compensate for fact that the barrel sits horzontally rather than virtically - mouse_angle -= 90 + # At an angle of 0 the barrel points down from the tank's center, + # so turn it around to point at the mouse + mouse_angle -= 180 # Rotate the barrel sprite with one end at the tank's center # Subtract the old angle to get the change in angle @@ -209,8 +210,8 @@ def correct(self, correct: bool): self._correct = correct if correct: angle = get_angle_radians( - self.tank.center_y, self.tank.center_x, - self.mouse_pos[1], self.mouse_pos[0]) + self.tank.center_x, self.tank.center_y, + self.mouse_pos[0], self.mouse_pos[1]) self.barrel.position = ( self.barrel.center_x + math.sin(angle) * TANK_BARREL_LENGTH_HALF, diff --git a/arcade/examples/turn_and_move.py b/arcade/examples/turn_and_move.py index 8b3829313..810af9dd8 100644 --- a/arcade/examples/turn_and_move.py +++ b/arcade/examples/turn_and_move.py @@ -13,8 +13,8 @@ WINDOW_HEIGHT = 720 WINDOW_TITLE = "Turn and Move Example" -# Image might not be lined up right, set this to offset -IMAGE_ROTATION = -90 +# The image points down, so turn it around. Angles are measured from up. +IMAGE_ROTATION = -180 class Player(arcade.Sprite): @@ -61,8 +61,8 @@ def update(self, delta_time: float = 1 / 60): dest_y = self._destination_point[1] # Do math to calculate how to get the sprite to the destination. - # Calculation the angle in radians between the start points - # and end points. This is the angle the player will travel. + # Calculate the angle in degrees from the start point to the + # end point, clockwise from up. This is the angle the player will travel. target_angle = arcade.math.get_angle_degrees(start_x, start_y, dest_x, dest_y) current_angle = self.angle - IMAGE_ROTATION @@ -73,19 +73,19 @@ def update(self, delta_time: float = 1 / 60): if angle_diff < 0.1 or 359.9 < angle_diff: self.angle = target_angle + IMAGE_ROTATION target_radians = math.radians(target_angle) - self.change_x = math.cos(-target_radians) * self.speed - self.change_y = math.sin(-target_radians) * self.speed + self.change_x = math.sin(target_radians) * self.speed + self.change_y = math.cos(target_radians) * self.speed # Fine-tune our change_x/change_y if we are really close to destination # point and just need to set to that location. traveling = False - if abs(self.center_x - dest_x) < abs(self.change_x * delta_time): + if abs(self.center_x - dest_x) <= abs(self.change_x * delta_time): self.center_x = dest_x else: self.center_x += self.change_x * delta_time traveling = True - if abs(self.center_y - dest_y) < abs(self.change_y * delta_time): + if abs(self.center_y - dest_y) <= abs(self.change_y * delta_time): self.center_y = dest_y else: self.center_y += self.change_y * delta_time diff --git a/arcade/math.py b/arcade/math.py index a2179f407..c31760fa2 100644 --- a/arcade/math.py +++ b/arcade/math.py @@ -427,7 +427,12 @@ def rotate_around_point(source: Point2, target: Point2, angle: float): def get_angle_degrees(x1: float, y1: float, x2: float, y2: float) -> float: """ - Get the angle in degrees between two points. + Get the angle in degrees from the first point to the second. + + The angle is measured clockwise from straight up, like + :py:attr:`Sprite.angle `: 0 is up, 90 is right, + 180 is down and -90 is left. A sprite whose image points up faces the + second point with ``sprite.angle = get_angle_degrees(...)``. Args: x1 (float): x coordinate of the first point @@ -437,12 +442,16 @@ def get_angle_degrees(x1: float, y1: float, x2: float, y2: float) -> float: """ x_diff = x2 - x1 y_diff = y2 - y1 - return -math.degrees(math.atan2(y_diff, x_diff)) + return math.degrees(math.atan2(x_diff, y_diff)) def get_angle_radians(x1: float, y1: float, x2: float, y2: float) -> float: """ - Get the angle in radians between two points. + Get the angle in radians from the first point to the second. + + The same angle as :py:func:`get_angle_degrees`, in radians: measured + clockwise from straight up, like + :py:attr:`Sprite.radians `. Args: x1 (float): x coordinate of the first point diff --git a/tests/unit/test_math.py b/tests/unit/test_math.py index 224aaa144..f6d26e8e8 100644 --- a/tests/unit/test_math.py +++ b/tests/unit/test_math.py @@ -10,7 +10,10 @@ import pytest from pytest import approx +import arcade from arcade.math import ( + get_angle_degrees, + get_angle_radians, lerp, lerp_2d, lerp_angle, @@ -127,3 +130,42 @@ def test_rotate_around_point_180_reflects_through_source(): rx, ry = rotate_around_point(source, target, 180.0) assert rx == approx(2.0 * source[0] - target[0]) assert ry == approx(2.0 * source[1] - target[1]) + + +@pytest.mark.parametrize( + ("x", "y", "expected"), + [ + (0, 10, 0.0), + (10, 0, 90.0), + (0, -10, 180.0), + (-10, 0, -90.0), + (10, 10, 45.0), + (-10, -10, -135.0), + ], +) +def test_get_angle_degrees_is_clockwise_from_up(x, y, expected): + # Measured from the first point, like Sprite.angle: 0 is up, 90 is right + assert get_angle_degrees(5, 7, 5 + x, 7 + y) == approx(expected) + + +@pytest.mark.parametrize( + ("x1", "y1", "x2", "y2"), + [(0, 0, 10, 0), (0, 0, 0, 10), (3, 4, -2, 9), (-5, 1, -8, -6), (1, 1, 1, -1)], +) +def test_get_angle_radians_matches_degrees(x1, y1, x2, y2): + # The two functions give the same angle in different units (#2754) + assert get_angle_radians(x1, y1, x2, y2) == approx( + math.radians(get_angle_degrees(x1, y1, x2, y2)) + ) + + +def test_get_angle_degrees_points_an_up_facing_sprite(window): + # A sprite whose image points up, turned by get_angle_degrees, faces the point + sprite = arcade.SpriteSolidColor(10, 10, center_x=100, center_y=100) + for target in [(200, 100), (100, 300), (40, 20), (180, 190)]: + sprite.angle = get_angle_degrees(*sprite.position, *target) + # Sprite.angle turns clockwise, so straight up rotated by angle is (sin, cos) + facing = (math.sin(sprite.radians), math.cos(sprite.radians)) + to_target = (target[0] - 100, target[1] - 100) + length = math.hypot(*to_target) + assert facing == approx((to_target[0] / length, to_target[1] / length))