diff --git a/arcade/examples/easing_example_1.py b/arcade/examples/easing_example_1.py new file mode 100644 index 000000000..848cbf6dc --- /dev/null +++ b/arcade/examples/easing_example_1.py @@ -0,0 +1,143 @@ +""" +Easing Example 1 + +Demonstrate the different easing functions available in :py:mod:`arcade.anim`. +Each ball uses a different easing curve to travel from left to right, making it +easy to compare the visual character of each curve. + +If Python and Arcade are installed, this example can be run from the command line with: +python -m arcade.examples.easing_example_1 +""" + +import arcade +from arcade.anim import ease, Easing + +# --- Constants --- +WINDOW_WIDTH = 1280 +WINDOW_HEIGHT = 720 +WINDOW_TITLE = "Easing Example 1" + +X_START = 40 +X_END = 1200 +Y_INTERVAL = 60 +BALL_RADIUS = 13 +LINE_WIDTH = 1.0 +TRAVEL_TIME = 3.0 + +BACKGROUND_COLOR = arcade.types.Color.from_hex_string("#F5D167") +TEXT_COLOR = arcade.types.Color.from_hex_string("#4B1DF2") +BALL_COLOR = arcade.types.Color.from_hex_string("#42B5EB") +LINE_COLOR = arcade.types.Color.from_hex_string("#45E6D0") + +# Each entry is (label, easing function). +EASING_LIST = [ + ("LINEAR", Easing.LINEAR), + ("QUAD_OUT", Easing.QUAD_OUT), + ("QUAD_IN", Easing.QUAD_IN), + ("SINE", Easing.SINE), + ("QUAD", Easing.QUAD), + ("ELASTIC_OUT", Easing.ELASTIC_OUT), + ("BACK_IN", Easing.BACK_IN), + ("BACK_OUT", Easing.BACK_OUT), + ("SINE_IN", Easing.SINE_IN), + ("SINE_OUT", Easing.SINE_OUT), + ("BOUNCE_OUT", Easing.BOUNCE_OUT), +] + + +class EasingCircle(arcade.SpriteCircle): + """A ball that eases along the x-axis using a specific curve.""" + + def __init__(self, radius: int, color: arcade.types.RGBOrA255, + ease_function: Easing): + super().__init__(radius, color) + self.ease_function = ease_function + self.start_time = 0.0 + + +class GameView(arcade.View): + """Main view showing all easing balls.""" + + def __init__(self): + super().__init__() + self.background_color = BACKGROUND_COLOR + self.ball_list: arcade.SpriteList[EasingCircle] | None = None + self.time_elapsed = 0.0 + + def setup(self): + """Create one ball per easing function.""" + self.ball_list = arcade.SpriteList() + self.time_elapsed = 0.0 + + for index, (label, ease_func) in enumerate(EASING_LIST): + ball = EasingCircle(BALL_RADIUS, BALL_COLOR, ease_func) + ball_y = WINDOW_HEIGHT - (index + 1) * Y_INTERVAL + ball.center_x = X_START + ball.center_y = ball_y + ball.start_time = 0.0 + self.ball_list.append(ball) + + def on_draw(self): + """Render the scene.""" + self.clear() + + for index, (label, _ease_func) in enumerate(EASING_LIST): + ball_y = WINDOW_HEIGHT - (index + 1) * Y_INTERVAL + + # Horizontal guide line + arcade.draw_line(X_START, ball_y, X_END, ball_y, LINE_COLOR, LINE_WIDTH) + + # Label for this easing function + arcade.draw_text( + label, + X_END + 10, + ball_y, + color=TEXT_COLOR, + font_size=10, + anchor_y="center", + ) + + # Draw all balls + self.ball_list.draw() + + # Instructions + arcade.draw_text( + "Click to restart", + WINDOW_WIDTH // 2, + 20, + color=TEXT_COLOR, + font_size=14, + anchor_x="center", + anchor_y="center", + ) + + def on_update(self, delta_time: float): + """Update ball positions using the easing functions.""" + self.time_elapsed += delta_time + + for ball in self.ball_list: + eased_x = ease( + X_START, X_END, + ball.start_time, ball.start_time + TRAVEL_TIME, + self.time_elapsed, + func=ball.ease_function, + ) + ball.center_x = eased_x + + def on_mouse_press(self, x: int, y: int, button: int, modifiers: int): + """Restart the animation on click.""" + for ball in self.ball_list: + ball.start_time = self.time_elapsed + + +def main(): + """Main function.""" + window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE) + game = GameView() + game.setup() + window.show_view(game) + arcade.run() + + +if __name__ == "__main__": + main() diff --git a/arcade/examples/easing_example_2.py b/arcade/examples/easing_example_2.py new file mode 100644 index 000000000..2e73737a3 --- /dev/null +++ b/arcade/examples/easing_example_2.py @@ -0,0 +1,240 @@ +""" +Easing Example 2 + +Demonstrate angle and position easing on a ship sprite that turns and moves +toward the mouse cursor. Press number keys to switch between modes: + +- **1**: Instant angle (no easing) +- **2-5**: Angle easing (LINEAR, QUAD_IN, QUAD_OUT, SINE) +- **6-9**: Position easing (LINEAR, QUAD_IN, QUAD_OUT, SINE) + +If Python and Arcade are installed, this example can be run from the command line with: +python -m arcade.examples.easing_example_2 +""" + +import math +import arcade +from arcade.anim import ease, Easing + +# --- Constants --- +WINDOW_WIDTH = 1280 +WINDOW_HEIGHT = 720 +WINDOW_TITLE = "Easing Example 2" + +SHIP_SPEED = 5.0 +EASE_DURATION = 1.0 + +# Mode descriptions shown in the HUD. +MODE_DESCRIPTIONS = { + 1: "Instant (teleport + face mouse)", + 2: "Angle ease: LINEAR", + 3: "Angle ease: QUAD_IN", + 4: "Angle ease: QUAD_OUT", + 5: "Angle ease: SINE", + 6: "Position ease: LINEAR", + 7: "Position ease: QUAD_IN", + 8: "Position ease: QUAD_OUT", + 9: "Position ease: SINE", +} + +# Mapping from mode number to easing function (modes 2-9). +MODE_EASING = { + 2: Easing.LINEAR, + 3: Easing.QUAD_IN, + 4: Easing.QUAD_OUT, + 5: Easing.SINE, + 6: Easing.LINEAR, + 7: Easing.QUAD_IN, + 8: Easing.QUAD_OUT, + 9: Easing.SINE, +} + + +def shortest_angle_delta(from_angle: float, to_angle: float) -> float: + """Return the shortest signed rotation from *from_angle* to *to_angle*. + + Both angles are in degrees. The result is in the range (-180, 180]. + """ + delta = (to_angle - from_angle) % 360 + if delta > 180: + delta -= 360 + return delta + + +class GameView(arcade.View): + """Main view with a ship that eases toward the mouse.""" + + def __init__(self): + super().__init__() + self.background_color = arcade.color.BLACK + + self.ship_sprite: arcade.Sprite | None = None + self.mode = 1 + self.time_elapsed = 0.0 + + # Mouse target + self.target_x = WINDOW_WIDTH / 2 + self.target_y = WINDOW_HEIGHT / 2 + + # Angle easing state + self.angle_start = 0.0 + self.angle_end = 0.0 + self.angle_ease_start_time = 0.0 + + # Position easing state + self.pos_start_x = WINDOW_WIDTH / 2 + self.pos_start_y = WINDOW_HEIGHT / 2 + self.pos_end_x = WINDOW_WIDTH / 2 + self.pos_end_y = WINDOW_HEIGHT / 2 + self.pos_ease_start_time = 0.0 + + def setup(self): + """Set up the game.""" + self.ship_sprite = arcade.Sprite( + ":resources:images/space_shooter/playerShip1_orange.png", + scale=0.5, + ) + self.ship_sprite.center_x = WINDOW_WIDTH / 2 + self.ship_sprite.center_y = WINDOW_HEIGHT / 2 + self.time_elapsed = 0.0 + + def on_draw(self): + """Render the scene.""" + self.clear() + + # Draw the ship + arcade.draw_sprite(self.ship_sprite) + + # Draw a crosshair at the target + arcade.draw_circle_outline( + self.target_x, self.target_y, 10, arcade.color.RED, 2, + ) + + # HUD + description = MODE_DESCRIPTIONS.get(self.mode, "") + arcade.draw_text( + f"Mode {self.mode}: {description}", + 10, WINDOW_HEIGHT - 30, + color=arcade.color.WHITE, + font_size=16, + ) + arcade.draw_text( + "Press 1-9 to change mode. Click to set target.", + 10, WINDOW_HEIGHT - 55, + color=arcade.color.GRAY, + font_size=12, + ) + + def _target_angle(self) -> float: + """Compute the angle from the ship to the target in degrees. + + Arcade uses clockwise-positive angles and the ship sprite + points up at angle 0, so we negate atan2 and add 90. + """ + diff_x = self.target_x - self.ship_sprite.center_x + diff_y = self.target_y - self.ship_sprite.center_y + return -math.degrees(math.atan2(diff_y, diff_x)) + 90 + + def _start_angle_ease(self): + """Record the current angle as the start and set up the ease.""" + self.angle_start = self.ship_sprite.angle + target = self._target_angle() + delta = shortest_angle_delta(self.angle_start, target) + self.angle_end = self.angle_start + delta + self.angle_ease_start_time = self.time_elapsed + + def _start_position_ease(self): + """Record the current position as the start and set up the ease.""" + self.pos_start_x = self.ship_sprite.center_x + self.pos_start_y = self.ship_sprite.center_y + self.pos_end_x = self.target_x + self.pos_end_y = self.target_y + self.pos_ease_start_time = self.time_elapsed + + def on_update(self, delta_time: float): + """Update ship angle and/or position based on current mode.""" + self.time_elapsed += delta_time + ease_func = MODE_EASING.get(self.mode) + + if self.mode == 1: + # Instant angle — always face the target directly + self.ship_sprite.angle = self._target_angle() + + elif 2 <= self.mode <= 5: + # Angle easing + eased_angle = ease( + self.angle_start, self.angle_end, + self.angle_ease_start_time, + self.angle_ease_start_time + EASE_DURATION, + self.time_elapsed, + func=ease_func, + ) + self.ship_sprite.angle = eased_angle + + elif 6 <= self.mode <= 9: + # Position easing — also face the target instantly + self.ship_sprite.angle = self._target_angle() + + eased_x = ease( + self.pos_start_x, self.pos_end_x, + self.pos_ease_start_time, + self.pos_ease_start_time + EASE_DURATION, + self.time_elapsed, + func=ease_func, + ) + eased_y = ease( + self.pos_start_y, self.pos_end_y, + self.pos_ease_start_time, + self.pos_ease_start_time + EASE_DURATION, + self.time_elapsed, + func=ease_func, + ) + self.ship_sprite.center_x = eased_x + self.ship_sprite.center_y = eased_y + + def on_mouse_press(self, x: int, y: int, button: int, modifiers: int): + """Set a new target and begin an easing animation.""" + self.target_x = x + self.target_y = y + + if self.mode == 1: + self.ship_sprite.center_x = x + self.ship_sprite.center_y = y + elif 2 <= self.mode <= 5: + self._start_angle_ease() + elif 6 <= self.mode <= 9: + self._start_position_ease() + + def on_mouse_motion(self, x: int, y: int, dx: int, dy: int): + """Update target for instant-angle mode.""" + if self.mode == 1: + self.target_x = x + self.target_y = y + + def on_key_press(self, key: int, modifiers: int): + """Switch modes with number keys 1-9.""" + key_map = { + arcade.key.KEY_1: 1, arcade.key.KEY_2: 2, arcade.key.KEY_3: 3, + arcade.key.KEY_4: 4, arcade.key.KEY_5: 5, arcade.key.KEY_6: 6, + arcade.key.KEY_7: 7, arcade.key.KEY_8: 8, arcade.key.KEY_9: 9, + } + new_mode = key_map.get(key) + if new_mode is not None: + self.mode = new_mode + if 2 <= new_mode <= 5: + self._start_angle_ease() + elif 6 <= new_mode <= 9: + self._start_position_ease() + + +def main(): + """Main function.""" + window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE) + game = GameView() + game.setup() + window.show_view(game) + arcade.run() + + +if __name__ == "__main__": + main() diff --git a/doc/example_code/easing_example_1.rst b/doc/example_code/easing_example_1.rst new file mode 100644 index 000000000..ae7de6ac8 --- /dev/null +++ b/doc/example_code/easing_example_1.rst @@ -0,0 +1,15 @@ +:orphan: + +.. _easing_example_1: + +Easing Example 1 +================ + +.. image:: images/easing_example_1.png + :width: 600px + :align: center + :alt: Screen shot of Easing Example 1 + +.. literalinclude:: ../../arcade/examples/easing_example_1.py + :caption: easing_example_1.py + :linenos: diff --git a/doc/example_code/easing_example_2.rst b/doc/example_code/easing_example_2.rst new file mode 100644 index 000000000..67b313263 --- /dev/null +++ b/doc/example_code/easing_example_2.rst @@ -0,0 +1,15 @@ +:orphan: + +.. _easing_example_2: + +Easing Example 2 +================ + +.. image:: images/easing_example_2.png + :width: 600px + :align: center + :alt: Screen shot of Easing Example 2 + +.. literalinclude:: ../../arcade/examples/easing_example_2.py + :caption: easing_example_2.py + :linenos: diff --git a/doc/example_code/index.rst b/doc/example_code/index.rst index ccb6508da..4ea5beb25 100644 --- a/doc/example_code/index.rst +++ b/doc/example_code/index.rst @@ -228,9 +228,17 @@ Non-Player Movement Easing ^^^^^^ -.. note:: Easing is a work in progress refactor. +.. figure:: images/thumbs/easing_example_1.png + :figwidth: 170px + :target: easing_example_1.html + + :ref:`easing_example_1` + +.. figure:: images/thumbs/easing_example_2.png + :figwidth: 170px + :target: easing_example_2.html - Please see :py:mod:`arcade.anim`. + :ref:`easing_example_2` Calculating a Path