From 8a2b1de370cf93f601c4bc2183a1423bd8518eea Mon Sep 17 00:00:00 2001
From: Einar Forselv <eforselv@gmail.com>
Date: Sun, 6 Oct 2024 04:18:42 +0200
Subject: [PATCH 01/15] Initial work

---
 arcade/sprite/base.py           | 103 ++--
 benchmarks/sprite/README.md     |   8 +
 benchmarks/sprite/main.py       | 138 ++++++
 benchmarks/sprite/sprite_alt.py | 816 ++++++++++++++++++++++++++++++++
 4 files changed, 1008 insertions(+), 57 deletions(-)
 create mode 100644 benchmarks/sprite/README.md
 create mode 100644 benchmarks/sprite/main.py
 create mode 100644 benchmarks/sprite/sprite_alt.py

diff --git a/arcade/sprite/base.py b/arcade/sprite/base.py
index d66b1d2c9f..a215054f0a 100644
--- a/arcade/sprite/base.py
+++ b/arcade/sprite/base.py
@@ -2,8 +2,6 @@
 
 from typing import TYPE_CHECKING, Any, Iterable, TypeVar
 
-from pyglet.math import Vec2
-
 import arcade
 from arcade.color import BLACK, WHITE
 from arcade.exceptions import ReplacementWarning, warning
@@ -262,72 +260,29 @@ def scale_y(self, new_scale_y: AsFloat):
             sprite_list._update_size(self)
 
     @property
-    def scale(self) -> Vec2:
+    def scale(self) -> Point2:
         """Get or set the x & y scale of the sprite as a pair of values.
 
-        You may set it to either a single value or a pair of values:
-
-        .. list-table::
-           :header-rows: 0
-
-           * - Single value
-             - ``sprite.scale = 2.0``
-
-           * - Tuple or :py:class:`~pyglet,math.Vec2`
-             - ``sprite.scale = (1.0, 3.0)``
-
-        The two-channel version is useful for making health bars and
-        other indicators.
+        See :py:attr:`.scale_x` and :py:attr:`.scale_y` for individual access.
 
-        .. note:: Returns a :py:class:`pyglet.math.Vec2` for
-                  compatibility.
-
-        Arcade versions lower than 3,0 used one or both of the following
-        for scale:
-
-        * A single :py:class:`float` on versions <= 2.6
-        * A ``scale_xy`` property and exposing only the x component
-          on some intermediate dev releases
-
-        Although scale is internally stored as a :py:class:`tuple`, we
-        return a :py:class:`pyglet.math.Vec2` to allow the in-place
-        operators to work in addition to setting values directly:
-
-        * Old-style (``sprite.scale *= 2.0``)
-        * New-style (``sprite.scale *= 2.0, 2.0``)
+        See :py:meth:`.scale_multiply_uniform` and :py:meth:`.scale_set_uniform`
+        for uniform scaling.
 
         .. note:: Negative scale values are supported.
 
-                  This applies to both single-axis and dual-axis.
-                  Negatives will flip & mirror the sprite, but the
-                  with will use :py:func:`abs` to report total width
-                  and height instead of negatives.
+            This applies to both single-axis and dual-axis.
+            Negatives will flip & mirror the sprite, but the
+            with will use :py:func:`abs` to report total width
+            and height instead of negatives.
 
         """
-        return Vec2(*self._scale)
+        return self._scale
 
     @scale.setter
-    def scale(self, new_scale: Point2 | AsFloat):
-        if isinstance(new_scale, (float, int)):
-            scale_x = new_scale
-            scale_y = new_scale
-
-        else:  # Treat it as some sort of iterable or sequence
-            # Don't abstract this. Keep it here since it's a hot code path
-            try:
-                scale_x, scale_y = new_scale  # type / length implicit check
-            except ValueError:
-                raise ValueError(
-                    "scale must be a tuple-like object which unpacks to exactly 2 coordinates"
-                )
-            except TypeError:
-                raise TypeError(
-                    "scale must be a tuple-like object which unpacks to exactly 2 coordinates"
-                )
-
-        new_scale = scale_x, scale_y
+    def scale(self, new_scale: Point2):
         if new_scale == self._scale:
             return
+        scale_x, scale_y = new_scale
 
         self._hit_box.scale = new_scale
         tex_width, tex_height = self._texture.size
@@ -607,6 +562,40 @@ def update_animation(self, delta_time: float = 1 / 60, *args, **kwargs) -> None:
 
     # --- Scale methods -----
 
+    def scale_multiply_uniform(self, factor: AsFloat) -> None:
+        """Scale the sprite uniformly by a factor.
+
+        This multiplies both the x and y scale values by the factor.
+
+        Args:
+            factor: The factor to scale up the sprite by.
+        """
+        self._scale = self._scale[0] * factor, self._scale[1] * factor
+        tex_width, tex_height = self._texture.size
+        self._width = tex_width * factor
+        self._height = tex_height * factor
+
+        self.update_spatial_hash()
+        for sprite_list in self.sprite_lists:
+            sprite_list._update_size(self)
+
+    def scale_set_uniform(self, new_scale: AsFloat) -> None:
+        """Set the sprite's scale uniformly.
+
+        This sets both the x and y scale values to the same value.
+
+        Args:
+            new_scale: The new scale value for the sprite.
+        """
+        tex_width, tex_height = self._texture.size
+        tex_width, tex_height = self._texture.size
+        self._width = tex_width * new_scale
+        self._height = tex_height * new_scale
+
+        self.update_spatial_hash()
+        for sprite_list in self.sprite_lists:
+            sprite_list._update_size(self)
+
     def rescale_relative_to_point(self, point: Point2, scale_by: AsFloat | Point2) -> None:
         """Rescale the sprite and its distance from the passed point.
 
@@ -695,7 +684,7 @@ def rescale_xy_relative_to_point(self, point: Point, factors_xy: Iterable[float]
            Use :py:meth:`.rescale_relative_to_point` instead.
 
            This was added during the 3.0 development cycle before scale was
-           made into a vector quantitity.
+           made into a vector quantity.
 
         This method can scale by different amounts on each axis. To
         scale along only one axis, set the other axis to ``1.0`` in
diff --git a/benchmarks/sprite/README.md b/benchmarks/sprite/README.md
new file mode 100644
index 0000000000..f102384242
--- /dev/null
+++ b/benchmarks/sprite/README.md
@@ -0,0 +1,8 @@
+
+# Sprite Benchmark
+
+Contains performance comparison between two alternative implementations of
+sprite handling scaling somewhat differently. This was done at the end
+of arcade 3.0 to measure performance of scaling changes.
+
+This can be changed and adjusted as needed to test different scenarios.
diff --git a/benchmarks/sprite/main.py b/benchmarks/sprite/main.py
new file mode 100644
index 0000000000..c20ea0b4b1
--- /dev/null
+++ b/benchmarks/sprite/main.py
@@ -0,0 +1,138 @@
+"""
+Quick and dirty system measuring differences between two sprite classes.
+"""
+import gc
+import math
+import timeit
+import arcade
+
+from sprite_alt import BasicSprite as SpriteA
+from arcade import BasicSprite as SpriteB
+
+N = 10
+MEASUREMENT_CONFIG = [
+    {"name": "populate", "number": N, "measure_method": "populate", "post_methods": ["flush"]},
+    {"name": "scale_set", "number": N, "measure_method": "scale_set", "post_methods": []},
+    {"name": "scale_mult", "number": N, "measure_method": "scale_mult", "post_methods": []},
+    {"name": "scale_mult_uniform", "number": N, "measure_method": "scale_mult_uniform", "post_methods": []},
+]
+
+
+class Measurement:
+    def __init__(self, avg=0.0, min=0.0, max=0.0):
+        self.avg = avg
+        self.min = min
+        self.max = max
+
+    @classmethod
+    def from_values(cls, values: list[float]) -> "Measurement":
+        return cls(avg=sum(values) / len(values), min=min(values), max=max(values))
+
+    # TODO: Compare measurements
+
+    def __str__(self):
+        return f"avg={self.avg}, min={self.min}, max={self.max}"
+
+
+class SpriteCollection:
+    sprite_type = None
+    sprite_count = 100_000
+
+    def __init__(self):
+        self.spritelist = arcade.SpriteList(lazy=True, capacity=self.sprite_count)
+
+    def flush(self):
+        """Remove all sprites from the spritelist."""
+        self.spritelist.clear()
+
+    def populate(self):
+        """Populate the spritelist with sprites."""
+        texture = arcade.load_texture(":assets:images/items/coinBronze.png")
+        N = int(math.sqrt(self.sprite_count))
+        for y in range(N):
+            for x in range(N):
+                self.spritelist.append(
+                    self.sprite_type(
+                        texture=texture,
+                        center_x=x * 64,
+                        center_y=y * 64,
+                        scale=((1.0, 1.0)),
+                    )
+                )
+
+    # Scale
+    def scale_set(self):
+        """Set the scale of all sprites."""
+        for sprite in self.spritelist:
+            sprite.scale = 2.0
+
+    def scale_mult(self):
+        """Multiply the scale of all sprites."""
+        for sprite in self.spritelist:
+            sprite.scale *= 2.0
+
+    def scale_mult_uniform(self):
+        """Multiply the scale of all sprites uniformly."""
+        for sprite in self.spritelist:
+            sprite.scale_multiply_uniform(2.0)
+
+    # Rotate
+    # Move
+    # Collision detection
+
+
+class SpriteCollectionA(SpriteCollection):
+    sprite_type = SpriteA
+
+class SpriteCollectionB(SpriteCollection):
+    sprite_type = SpriteB
+
+
+def measure_sprite_collection(collection: SpriteCollection, number=10) -> dict[str, Measurement]:
+    """Perform actions on the sprite collections and measure the time."""
+    print(f"Measuring {collection.__class__.__name__}...")
+    measurements: dict[str, Measurement] = {}
+
+    for config in MEASUREMENT_CONFIG:
+        name = config["name"]
+        number = config["number"]
+        measure_method = getattr(collection, config["measure_method"])
+        post_methods = [getattr(collection, method) for method in config.get("post_methods", [])]
+
+        results = []
+        try:
+            for _ in range(number):
+                results.append(timeit.timeit(measure_method, number=1))
+                for method in post_methods:
+                    method()
+            measurement = Measurement.from_values(results)
+            measurements[name] = measurement
+            print(f"{name}: {measurement}")
+        except Exception as e:
+            print(f"Failed to measure {name}: {e}")
+
+        collection.flush()
+        collection.populate()
+        gc_until_nothing()
+
+    return measurements
+
+
+def gc_until_nothing():
+    """Run the garbage collector until no more objects are found."""
+    while gc.collect():
+        pass
+
+
+def main():
+    a = SpriteCollectionA()
+    b = SpriteCollectionB()
+
+    m1 = measure_sprite_collection(a)
+    gc_until_nothing()
+    m2 = measure_sprite_collection(b)
+    # FIXME: Compare measurements
+
+
+if __name__ == '__main__':
+    main()
diff --git a/benchmarks/sprite/sprite_alt.py b/benchmarks/sprite/sprite_alt.py
new file mode 100644
index 0000000000..139da41bc6
--- /dev/null
+++ b/benchmarks/sprite/sprite_alt.py
@@ -0,0 +1,816 @@
+"""
+Replace this with a copy/alternative version of BasicSprite
+"""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, Iterable, TypeVar
+
+from pyglet.math import Vec2
+
+import arcade
+from arcade.color import BLACK, WHITE
+from arcade.exceptions import ReplacementWarning, warning
+from arcade.hitbox import HitBox
+from arcade.texture import Texture
+from arcade.types import LRBT, RGBA255, AsFloat, Color, Point, Point2, PointList, Rect, RGBOrA255
+from arcade.utils import copy_dunders_unimplemented
+
+if TYPE_CHECKING:
+    from arcade.sprite_list import SpriteList
+
+# Type from sprite that can be any BasicSprite or any subclass of BasicSprite
+SpriteType = TypeVar("SpriteType", bound="BasicSprite")
+
+
+@copy_dunders_unimplemented  # See https://github.com/pythonarcade/arcade/issues/2074
+class BasicSprite:
+    """
+    The absolute minimum needed for a sprite.
+
+    It does not support features like rotation or changing the hitbox
+    after creation. For more built-in features, please see
+    :py:class:`~arcade.Sprite`.
+
+    Args:
+        texture:
+            The texture data to use for this sprite.
+        scale:
+            The scaling factor for drawing the texture.
+        center_x:
+            Location of the sprite along the X axis in pixels.
+        center_y:
+            Location of the sprite along the Y axis in pixels.
+    """
+
+    __slots__ = (
+        "_position",
+        "_depth",
+        "_width",
+        "_height",
+        "_scale",
+        "_color",
+        "_texture",
+        "_hit_box",
+        "_visible",
+        "sprite_lists",
+        "_angle",
+        "__weakref__",
+    )
+
+    def __init__(
+        self,
+        texture: Texture,
+        scale: float | Point2 = 1.0,
+        center_x: float = 0,
+        center_y: float = 0,
+        visible: bool = True,
+        **kwargs: Any,
+    ) -> None:
+        self._position = (center_x, center_y)
+        self._depth = 0.0
+        self._texture = texture
+        width, height = texture.size
+        self._scale = (scale, scale) if isinstance(scale, (float, int)) else scale
+        self._width = width * self._scale[0]
+        self._height = height * self._scale[1]
+        self._visible = bool(visible)
+        self._color: Color = WHITE
+        self.sprite_lists: list["SpriteList"] = []
+        """The sprite lists this sprite is a member of"""
+
+        # Core properties we don't use, but spritelist expects it
+        self._angle = 0.0
+
+        self._hit_box = HitBox(self._texture.hit_box_points, self._position, self._scale)
+
+    # --- Core Properties ---
+
+    @property
+    def position(self) -> Point2:
+        """Get or set the center x and y position of the sprite."""
+        return self._position
+
+    @position.setter
+    def position(self, new_value: Point2):
+        if new_value == self._position:
+            return
+
+        self._position = new_value
+        self._hit_box.position = new_value
+        self.update_spatial_hash()
+
+        for sprite_list in self.sprite_lists:
+            sprite_list._update_position(self)
+
+    @property
+    def center_x(self) -> float:
+        """Get or set the center x position of the sprite."""
+        return self._position[0]
+
+    @center_x.setter
+    def center_x(self, new_value: float):
+        if new_value == self._position[0]:
+            return
+
+        self.position = (new_value, self._position[1])
+
+    @property
+    def center_y(self) -> float:
+        """Get or set the center y position of the sprite."""
+        return self._position[1]
+
+    @center_y.setter
+    def center_y(self, new_value: float):
+        if new_value == self._position[1]:
+            return
+
+        self.position = (self._position[0], new_value)
+
+    @property
+    def depth(self) -> float:
+        """
+        Get or set the depth of the sprite.
+
+        This is really the z coordinate of the sprite
+        and can be used with OpenGL depth testing with opaque
+        sprites.
+        """
+        return self._depth
+
+    @depth.setter
+    def depth(self, new_value: float):
+        if new_value != self._depth:
+            self._depth = new_value
+            for sprite_list in self.sprite_lists:
+                sprite_list._update_depth(self)
+
+    @property
+    def width(self) -> float:
+        """Get or set width or the sprite in pixels"""
+        return self._width
+
+    @width.setter
+    def width(self, new_value: float):
+        if new_value != self._width:
+            self._scale = new_value / self._texture.width, self._scale[1]
+            self._hit_box.scale = self._scale
+            self._width = new_value
+
+            self.update_spatial_hash()
+            for sprite_list in self.sprite_lists:
+                sprite_list._update_width(self)
+
+    @property
+    def height(self) -> float:
+        """Get or set the height of the sprite in pixels."""
+        return self._height
+
+    @height.setter
+    def height(self, new_value: float):
+        if new_value != self._height:
+            self._scale = self._scale[0], new_value / self._texture.height
+            self._hit_box.scale = self._scale
+            self._height = new_value
+
+            self.update_spatial_hash()
+            for sprite_list in self.sprite_lists:
+                sprite_list._update_height(self)
+
+    @property
+    def size(self) -> Point:
+        """
+        Get or set the size of the sprite as a pair of values.
+
+        This is faster than getting or setting width and height separately.
+        """
+        return self._width, self._height
+
+    @size.setter
+    def size(self, new_value: Point2):
+        try:
+            width, height = new_value
+        except ValueError:
+            raise ValueError(
+                "size must be a tuple-like object which unpacks to exactly 2 coordinates"
+            )
+        except TypeError:
+            raise TypeError(
+                "size must be a tuple-like object which unpacks to exactly 2 coordinates"
+            )
+
+        if width != self._width or height != self._height:
+            texture_width, texture_height = self._texture.size
+            self._scale = width / texture_width, height / texture_height
+            self._hit_box.scale = self._scale
+            self._width = width
+            self._height = height
+
+            self.update_spatial_hash()
+
+            for sprite_list in self.sprite_lists:
+                sprite_list._update_size(self)
+
+    @property
+    def scale_x(self) -> float:
+        """
+        Get or set the sprite's x scale value.
+
+        .. note:: Negative values are supported. They will flip &
+                  mirror the sprite.
+        """
+        return self._scale[0]
+
+    @scale_x.setter
+    def scale_x(self, new_scale_x: AsFloat):
+        old_scale_x, old_scale_y = self._scale
+        if new_scale_x == old_scale_x:
+            return
+
+        new_scale = (new_scale_x, old_scale_y)
+
+        # Apply scale to hitbox first to raise any exceptions quickly
+        self._hit_box.scale = new_scale
+        self._scale = new_scale
+        self._width = self._texture.width * new_scale_x
+
+        self.update_spatial_hash()
+        for sprite_list in self.sprite_lists:
+            sprite_list._update_size(self)
+
+    @property
+    def scale_y(self) -> float:
+        """
+        Get or set the sprite's y scale value.
+
+        .. note:: Negative values are supported. They will flip &
+                  mirror the sprite.
+        """
+        return self._scale[1]
+
+    @scale_y.setter
+    def scale_y(self, new_scale_y: AsFloat):
+        old_scale_x, old_scale_y = self._scale
+        if new_scale_y == old_scale_y:
+            return
+
+        new_scale = (old_scale_x, new_scale_y)
+
+        # Apply scale to hitbox first to raise any exceptions quickly
+        self._hit_box.scale = new_scale
+        self._scale = new_scale
+        self._height = self._texture.height * new_scale_y
+
+        self.update_spatial_hash()
+        for sprite_list in self.sprite_lists:
+            sprite_list._update_size(self)
+
+    @property
+    def scale(self) -> Vec2:
+        """Get or set the x & y scale of the sprite as a pair of values.
+
+        You may set it to either a single value or a pair of values:
+
+        .. list-table::
+           :header-rows: 0
+
+           * - Single value
+             - ``sprite.scale = 2.0``
+
+           * - Tuple or :py:class:`~pyglet,math.Vec2`
+             - ``sprite.scale = (1.0, 3.0)``
+
+        The two-channel version is useful for making health bars and
+        other indicators.
+
+        .. note:: Returns a :py:class:`pyglet.math.Vec2` for
+                  compatibility.
+
+        Arcade versions lower than 3,0 used one or both of the following
+        for scale:
+
+        * A single :py:class:`float` on versions <= 2.6
+        * A ``scale_xy`` property and exposing only the x component
+          on some intermediate dev releases
+
+        Although scale is internally stored as a :py:class:`tuple`, we
+        return a :py:class:`pyglet.math.Vec2` to allow the in-place
+        operators to work in addition to setting values directly:
+
+        * Old-style (``sprite.scale *= 2.0``)
+        * New-style (``sprite.scale *= 2.0, 2.0``)
+
+        .. note:: Negative scale values are supported.
+
+                  This applies to both single-axis and dual-axis.
+                  Negatives will flip & mirror the sprite, but the
+                  with will use :py:func:`abs` to report total width
+                  and height instead of negatives.
+
+        """
+        return Vec2(*self._scale)
+
+    @scale.setter
+    def scale(self, new_scale: Point2 | AsFloat):
+        if isinstance(new_scale, (float, int)):
+            scale_x = new_scale
+            scale_y = new_scale
+
+        else:  # Treat it as some sort of iterable or sequence
+            # Don't abstract this. Keep it here since it's a hot code path
+            try:
+                scale_x, scale_y = new_scale  # type / length implicit check
+            except ValueError:
+                raise ValueError(
+                    "scale must be a tuple-like object which unpacks to exactly 2 coordinates"
+                )
+            except TypeError:
+                raise TypeError(
+                    "scale must be a tuple-like object which unpacks to exactly 2 coordinates"
+                )
+
+        new_scale = scale_x, scale_y
+        if new_scale == self._scale:
+            return
+
+        self._hit_box.scale = new_scale
+        tex_width, tex_height = self._texture.size
+        self._scale = new_scale
+        self._width = tex_width * scale_x
+        self._height = tex_height * scale_y
+
+        self.update_spatial_hash()
+        for sprite_list in self.sprite_lists:
+            sprite_list._update_size(self)
+
+    @property
+    def left(self) -> float:
+        """
+        The leftmost x coordinate in the hit box.
+
+        When setting this property the sprite is positioned
+        relative to the leftmost x coordinate in the hit box.
+        """
+        return self._hit_box.left
+
+    @left.setter
+    def left(self, amount: float):
+        leftmost = self.left
+        diff = amount - leftmost
+        self.center_x += diff
+
+    @property
+    def right(self) -> float:
+        """
+        The rightmost x coordinate in the hit box.
+
+        When setting this property the sprite is positioned
+        relative to the rightmost x coordinate in the hit box.
+        """
+        return self._hit_box.right
+
+    @right.setter
+    def right(self, amount: float):
+        rightmost = self.right
+        diff = rightmost - amount
+        self.center_x -= diff
+
+    @property
+    def bottom(self) -> float:
+        """
+        The lowest y coordinate in the hit box.
+
+        When setting this property the sprite is positioned
+        relative to the lowest y coordinate in the hit box.
+        """
+        return self._hit_box.bottom
+
+    @bottom.setter
+    def bottom(self, amount: float):
+        lowest = self.bottom
+        diff = lowest - amount
+        self.center_y -= diff
+
+    @property
+    def top(self) -> float:
+        """
+        The highest y coordinate in the hit box.
+
+        When setting this property the sprite is positioned
+        relative to the highest y coordinate in the hit box.
+        """
+        return self._hit_box.top
+
+    @top.setter
+    def top(self, amount: float):
+        highest = self.top
+        diff = highest - amount
+        self.center_y -= diff
+
+    @property
+    def rect(self) -> Rect:
+        """A rectangle with with the sprites left, right, bottom, and top values."""
+        return LRBT(self.left, self.right, self.bottom, self.top)
+
+    @property
+    def visible(self) -> bool:
+        """Get or set the visibility of this sprite.
+
+        When set to ``False``, each :py:class:`~arcade.SpriteList` and
+        its attached shaders will treat the sprite as if has an
+        :py:attr:`.alpha` of 0. However, the sprite's actual values for
+        :py:attr:`.alpha` and :py:attr:`.color` will not change.
+
+        .. code-block:: python
+
+            # The initial color of the sprite
+            >>> sprite.color
+            Color(255, 255, 255, 255)
+
+            # Make the sprite invisible
+            >>> sprite.visible = False
+            # The sprite's color value has not changed
+            >>> sprite.color
+            Color(255, 255, 255, 255)
+            # The sprite's alpha value hasn't either
+            >>> sprite.alpha
+            255
+
+            # Restore visibility
+            >>> sprite.visible = True
+            # Shorthand to toggle visible
+            >>> sprite.visible = not sprite.visible
+
+        """
+        return self._visible
+
+    @visible.setter
+    def visible(self, value: bool):
+        value = bool(value)
+        if self._visible == value:
+            return
+
+        self._visible = value
+
+        for sprite_list in self.sprite_lists:
+            sprite_list._update_color(self)
+
+    @property
+    def rgb(self) -> tuple[int, int, int]:
+        """Get or set only the sprite's RGB color components.
+
+        If a 4-color RGBA tuple is passed:
+
+        * The new color's alpha value will be ignored
+        * The old alpha value will be preserved
+        """
+        return self._color[:3]
+
+    @rgb.setter
+    def rgb(self, color: RGBOrA255):
+
+        # Fast validation of size by unpacking channel values
+        try:
+            r, g, b, *_a = color
+            if len(_a) > 1:  # Alpha's only used to validate here
+                raise ValueError()
+
+        except ValueError:  # It's always a length issue
+            raise ValueError(
+                (
+                    f"{self.__class__.__name__},rgb takes 3 or 4 channel"
+                    f" colors, but got {len(color)} channels"
+                )
+            )
+
+        # Unpack to avoid index / . overhead & prep for repack
+        current_r, current_b, current_g, a = self._color
+
+        # Do nothing if equivalent to current color
+        if current_r == r and current_g == g and current_b == b:
+            return
+
+        # Preserve the current alpha value & update sprite lists
+        self._color = Color(r, g, b, a)
+        for sprite_list in self.sprite_lists:
+            sprite_list._update_color(self)
+
+    @property
+    def color(self) -> Color:
+        """
+        Get or set the RGBA multiply color for the sprite.
+
+        When setting the color, it may be specified as any of the following:
+
+        * an RGBA :py:class:`tuple` with each channel value between 0 and 255
+        * an instance of :py:class:`~arcade.types.Color`
+        * an RGB :py:class:`tuple`, in which case the color will be treated as opaque
+
+        Example usage::
+
+            >>> print(sprite.color)
+            Color(255, 255, 255, 255)
+
+            >>> sprite.color = arcade.color.RED
+
+            >>> sprite.color = 255, 0, 0
+
+            >>> sprite.color = 255, 0, 0, 128
+
+        """
+        return self._color
+
+    @color.setter
+    def color(self, color: RGBOrA255):
+        if color == self._color:
+            return
+
+        r, g, b, *_a = color
+
+        if _a:
+            if len(_a) > 1:
+                raise ValueError(f"iterable must unpack to 3 or 4 values not {len(color)}")
+            a = _a[0]
+        else:
+            a = self._color.a
+
+        # We don't handle alpha and .visible interactions here
+        # because it's implemented in SpriteList._update_color
+        self._color = Color(r, g, b, a)
+
+        for sprite_list in self.sprite_lists:
+            sprite_list._update_color(self)
+
+    @property
+    def alpha(self) -> int:
+        """Get or set the alpha value of the sprite"""
+        return self._color[3]
+
+    @alpha.setter
+    def alpha(self, alpha: int):
+        self._color = Color(self._color[0], self._color[1], self._color[2], int(alpha))
+
+        for sprite_list in self.sprite_lists:
+            sprite_list._update_color(self)
+
+    @property
+    def texture(self) -> Texture:
+        """
+        Get or set the visible texture for this sprite
+        This property can be changed over time to animate a sprite.
+
+        Note that this doesn't change the hit box of the sprite.
+        """
+        return self._texture
+
+    @texture.setter
+    def texture(self, texture: Texture):
+        if texture == self._texture:
+            return
+
+        if __debug__ and not isinstance(texture, Texture):
+            raise TypeError(
+                f"The 'texture' parameter must be an instance of arcade.Texture,"
+                f" but is an instance of '{type(texture)}'."
+            )
+
+        self._texture = texture
+        self._width = texture.width * self._scale[0]
+        self._height = texture.height * self._scale[1]
+        self.update_spatial_hash()
+        for sprite_list in self.sprite_lists:
+            sprite_list._update_texture(self)
+
+    # ---- Update methods ----
+
+    def update(self, delta_time: float = 1 / 60, *args, **kwargs) -> None:
+        """
+        Generic update method. It can be called manually
+        or by the SpriteList's update method.
+
+        Args:
+            delta_time: Time since last update in seconds
+            *args: Additional positional arguments
+            **kwargs: Additional keyword arguments
+        """
+        pass
+
+    def update_animation(self, delta_time: float = 1 / 60, *args, **kwargs) -> None:
+        """
+        Generic update animation method. Usually involves changing
+        the active texture on the sprite.
+
+        This can be called manually or by the SpriteList's update_animation method.
+
+        Args:
+            delta_time: Time since last update in seconds
+            *args: Additional positional arguments
+            **kwargs: Additional keyword arguments
+        """
+        pass
+
+    # --- Scale methods -----
+
+    def rescale_relative_to_point(self, point: Point2, scale_by: AsFloat | Point2) -> None:
+        """Rescale the sprite and its distance from the passed point.
+
+        This function does two things:
+
+        1. Multiply both values in the sprite's :py:attr:`.scale`
+           value by the values in ``scale_by``:
+
+           * If ``scale_by`` is an :py:class:`int` or :py:class:`float`,
+             use it for both the x and y axes
+           * If ``scale_by`` is a tuple-like object which unpacks to
+             two numbers, then use
+           * Otherwise, raise an exception
+
+        2. Scale the distance between the sprite and ``point`` by
+           ``factor``.
+
+        .. note:: If ``point`` equals the sprite's :py:attr:`.position`
+                  the distance will be zero and the sprite won't move.
+
+        Args:
+            point:
+                The point to scale relative to.
+            scale_by:
+                A multiplier for both the sprite scale and its distance
+                from the point. Note that although factor may be negative,
+                it may have unexpected effects.
+
+        """
+        # abort if the multiplier wouldn't do anything
+        if isinstance(scale_by, (float, int)):
+            if scale_by == 1.0:
+                return
+            factor_x = scale_by
+            factor_y = scale_by
+        else:
+            try:
+                factor_x, factor_y = scale_by
+                if factor_x == 1.0 and factor_y == 1.0:
+                    return
+            except ValueError:
+                raise ValueError(
+                    "factor must be a float, int, or tuple-like "
+                    "which unpacks as two float-like values"
+                )
+            except TypeError:
+                raise TypeError(
+                    "factor must be a float, int, or tuple-like unpacks as two float-like values"
+                )
+
+        # set the scale and, if this sprite has a texture, the size data
+        old_scale_x, old_scale_y = self._scale
+        new_scale_x = old_scale_x * factor_x
+        new_scale_y = old_scale_y * factor_y
+        self._scale = new_scale_x, new_scale_y
+
+        tex_width, tex_height = self._texture.size
+        self._width = tex_width * new_scale_x
+        self._height = tex_height * new_scale_y
+
+        # If the scaling point is the sprite's center, it doesn't move
+        old_position = self._position
+        position_changed = point != old_position  # Stored to use below
+
+        # be lazy about math; only do it if we have to
+        if position_changed:
+            point_x, point_y = point
+            old_x, old_y = old_position
+            self.position = (
+                (old_x - point_x) * factor_x + point_x,
+                (old_y - point_y) * factor_y + point_y,
+            )
+
+        # rebuild all spatial metadata
+        self.update_spatial_hash()
+        for sprite_list in self.sprite_lists:
+            sprite_list._update_size(self)
+            if position_changed:
+                sprite_list._update_position(self)
+
+    @warning(warning_type=ReplacementWarning, new_name="rescale_relative_to_point")
+    def rescale_xy_relative_to_point(self, point: Point, factors_xy: Iterable[float]) -> None:
+        """Rescale the sprite and its distance from the passed point.
+
+        .. deprecated:: 3.0
+           Use :py:meth:`.rescale_relative_to_point` instead.
+
+           This was added during the 3.0 development cycle before scale was
+           made into a vector quantitity.
+
+        This method can scale by different amounts on each axis. To
+        scale along only one axis, set the other axis to ``1.0`` in
+        ``factors_xy``.
+
+        Internally, this function does the following:
+
+        1. Multiply the x & y of the sprite's :py:attr:`~scale_xy`
+           attribute by the corresponding part from ``factors_xy``.
+        2. Scale the x & y of the difference between the sprite's
+           position and ``point`` by the corresponding component from
+           ``factors_xy``.
+
+        If ``point`` equals the sprite's :py:attr:`~position`,
+        the distance will be zero and the sprite will not move.
+
+        Args:
+            point:
+                The reference point for rescaling.
+            factors_xy:
+                A 2-length iterable containing x and y
+                multipliers for ``scale`` & distance to ``point``.
+        """
+        self.rescale_relative_to_point(point, factors_xy)  # type: ignore
+
+    # ---- Utility Methods ----
+
+    @property
+    def hit_box(self) -> HitBox:
+        """The hit box for this sprite."""
+        return self._hit_box
+
+    def update_spatial_hash(self) -> None:
+        """Update the sprites location in the spatial hash if present."""
+        for sprite_list in self.sprite_lists:
+            if sprite_list.spatial_hash is not None:
+                sprite_list.spatial_hash.move(self)
+
+    def register_sprite_list(self, new_list: SpriteList) -> None:
+        """
+        Register this sprite as belonging to a list.
+
+        We will automatically remove ourselves from the list when kill() is called.
+        """
+        self.sprite_lists.append(new_list)
+
+    def remove_from_sprite_lists(self) -> None:
+        """Remove the sprite from all sprite lists."""
+        while len(self.sprite_lists) > 0:
+            self.sprite_lists[0].remove(self)
+
+        self.sprite_lists.clear()
+
+    # ----- Drawing Methods -----
+
+    def draw_hit_box(self, color: RGBA255 = BLACK, line_thickness: float = 2.0) -> None:
+        """
+        Draw a sprite's hit-box. This is useful for debugging.
+
+        Args:
+            color:
+                Color of box
+            line_thickness:
+                How thick the box should be
+        """
+        points: PointList = self.hit_box.get_adjusted_points()
+        # NOTE: This is a COPY operation. We don't want to modify the points.
+        points = tuple(points) + tuple(points[:-1])
+        arcade.draw_line_strip(points, color=color, line_width=line_thickness)
+
+    # ---- Shortcut Methods ----
+
+    def kill(self) -> None:
+        """
+        Alias of ``remove_from_sprite_lists()``.
+        """
+        self.remove_from_sprite_lists()
+
+    def collides_with_point(self, point: Point2) -> bool:
+        """
+        Check if point is within the current sprite.
+
+        Args:
+            point: Point to check.
+        Returns:
+            ``True`` if the point is contained within the sprite's boundary.
+        """
+        from arcade.geometry import is_point_in_polygon
+
+        x, y = point
+        return is_point_in_polygon(x, y, self.hit_box.get_adjusted_points())
+
+    def collides_with_sprite(self: SpriteType, other: SpriteType) -> bool:
+        """Will check if a sprite is overlapping (colliding) another Sprite.
+
+        Args:
+            other: the other sprite to check against.
+        Returns:
+            ``True`` or ``False``, whether or not they are overlapping.
+        """
+        from arcade import check_for_collision
+
+        return check_for_collision(self, other)
+
+    def collides_with_list(self: SpriteType, sprite_list: "SpriteList") -> list[SpriteType]:
+        """Check if current sprite is overlapping with any other sprite in a list
+
+        Args:
+            sprite_list: SpriteList to check against
+        Returns:
+            List of all overlapping Sprites from the original SpriteList
+        """
+        from arcade import check_for_collision_with_list
+
+        return check_for_collision_with_list(self, sprite_list)

From ca6aa9b1073c3b0b38f2abe52b051c0dc7fce74b Mon Sep 17 00:00:00 2001
From: Einar Forselv <eforselv@gmail.com>
Date: Mon, 7 Oct 2024 02:34:50 +0200
Subject: [PATCH 02/15] More fixes + tests

---
 arcade/examples/sprite_explosion_particles.py |  4 +-
 arcade/examples/sprite_move_animation.py      |  3 +-
 arcade/sprite/base.py                         | 46 +++++++++++++------
 .../crt_filter/crt_filter_example.py          |  2 +-
 .../pymunk_demo_platformer.py                 |  2 +-
 .../pymunk_demo_platformer_08.py              |  2 +-
 .../pymunk_demo_platformer_09.py              |  2 +-
 .../pymunk_demo_platformer_10.py              |  2 +-
 .../pymunk_demo_platformer_11.py              |  2 +-
 .../pymunk_demo_platformer_12.py              |  2 +-
 tests/unit/sprite/test_sprite.py              | 16 +++----
 .../sprite/test_sprite_animated_walking.py    |  4 +-
 tests/unit/sprite/test_sprite_hitbox.py       | 10 ++--
 tests/unit/sprite/test_sprite_render.py       | 10 ++--
 14 files changed, 63 insertions(+), 44 deletions(-)

diff --git a/arcade/examples/sprite_explosion_particles.py b/arcade/examples/sprite_explosion_particles.py
index b61ba7f1ba..9778f3340f 100644
--- a/arcade/examples/sprite_explosion_particles.py
+++ b/arcade/examples/sprite_explosion_particles.py
@@ -75,7 +75,7 @@ class Smoke(arcade.SpriteCircle):
     def __init__(self, size):
         super().__init__(size, arcade.color.LIGHT_GRAY, soft=True)
         self.change_y = SMOKE_RISE_RATE
-        self.scale = SMOKE_START_SCALE
+        self.scale_set_uniform(SMOKE_START_SCALE)
 
     def update(self, delta_time: float = 1/60):
         """Update this particle"""
@@ -90,7 +90,7 @@ def update(self, delta_time: float = 1/60):
             self.alpha -= int(SMOKE_FADE_RATE * time_step)
             self.center_x += self.change_x * time_step
             self.center_y += self.change_y * time_step
-            self.scale += SMOKE_EXPANSION_RATE * time_step
+            self.scale_add_uniform(SMOKE_EXPANSION_RATE * time_step)
 
 
 class Particle(arcade.SpriteCircle):
diff --git a/arcade/examples/sprite_move_animation.py b/arcade/examples/sprite_move_animation.py
index aeb0813e38..01a0d4f76a 100644
--- a/arcade/examples/sprite_move_animation.py
+++ b/arcade/examples/sprite_move_animation.py
@@ -112,10 +112,9 @@ def setup(self):
         # Set up the player
         self.score = 0
         self.player = PlayerCharacter(self.idle_texture_pair, self.walk_texture_pairs)
-
         self.player.center_x = SCREEN_WIDTH // 2
         self.player.center_y = SCREEN_HEIGHT // 2
-        self.player.scale = 0.8
+        self.player.scale_set_uniform(0.8)
 
         self.player_list.append(self.player)
 
diff --git a/arcade/sprite/base.py b/arcade/sprite/base.py
index a215054f0a..58160c9f19 100644
--- a/arcade/sprite/base.py
+++ b/arcade/sprite/base.py
@@ -562,35 +562,55 @@ def update_animation(self, delta_time: float = 1 / 60, *args, **kwargs) -> None:
 
     # --- Scale methods -----
 
-    def scale_multiply_uniform(self, factor: AsFloat) -> None:
-        """Scale the sprite uniformly by a factor.
+    def scale_set_uniform(self, new_scale: AsFloat) -> None:
+        """Set the sprite's scale uniformly.
 
-        This multiplies both the x and y scale values by the factor.
+        This sets both the x and y scale values to the same value.
 
         Args:
-            factor: The factor to scale up the sprite by.
+            new_scale: The new scale value for the sprite.
         """
-        self._scale = self._scale[0] * factor, self._scale[1] * factor
+        self._scale = new_scale, new_scale
+        self._hit_box.scale = self._scale
         tex_width, tex_height = self._texture.size
-        self._width = tex_width * factor
-        self._height = tex_height * factor
+        self._width = tex_width * new_scale
+        self._height = tex_height * new_scale
 
         self.update_spatial_hash()
         for sprite_list in self.sprite_lists:
             sprite_list._update_size(self)
 
-    def scale_set_uniform(self, new_scale: AsFloat) -> None:
-        """Set the sprite's scale uniformly.
+    def scale_add_uniform(self, factor: AsFloat) -> None:
+        """Add to the sprite's scale uniformly.
 
-        This sets both the x and y scale values to the same value.
+        This adds the factor to both the x and y scale values.
 
         Args:
-            new_scale: The new scale value for the sprite.
+            factor: The factor to add to the sprite's scale.
         """
+        self._scale = self._scale[0] + factor, self._scale[1] + factor
+        self._hit_box.scale = self._scale
         tex_width, tex_height = self._texture.size
+        self._width = tex_width * self._scale[0]
+        self._height = tex_height * self._scale[1]
+
+        self.update_spatial_hash()
+        for sprite_list in self.sprite_lists:
+            sprite_list._update_size(self)
+
+    def scale_multiply_uniform(self, factor: AsFloat) -> None:
+        """Scale the sprite uniformly by a factor.
+
+        This multiplies both the x and y scale values by the factor.
+
+        Args:
+            factor: The factor to scale up the sprite by.
+        """
+        self._scale = self._scale[0] * factor, self._scale[1] * factor
+        self._hit_box.scale = self._scale
         tex_width, tex_height = self._texture.size
-        self._width = tex_width * new_scale
-        self._height = tex_height * new_scale
+        self._width = tex_width * factor
+        self._height = tex_height * factor
 
         self.update_spatial_hash()
         for sprite_list in self.sprite_lists:
diff --git a/doc/tutorials/crt_filter/crt_filter_example.py b/doc/tutorials/crt_filter/crt_filter_example.py
index 3ce8a95e5f..b93d0e8c49 100644
--- a/doc/tutorials/crt_filter/crt_filter_example.py
+++ b/doc/tutorials/crt_filter/crt_filter_example.py
@@ -30,7 +30,7 @@ def __init__(self, width, height, title):
 
         # Load the pac-man map image and scale it up to the window size
         map = arcade.Sprite("Pac-man.png", center_x=width / 2, center_y=height / 2)
-        map.scale = width / map.width
+        map.scale_set_uniform(width / map.width)
         self.sprite_list.append(map)
 
         # Slice out some textures from the sprite sheet
diff --git a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer.py b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer.py
index 5660dd6d10..40678242d0 100644
--- a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer.py
+++ b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer.py
@@ -59,7 +59,7 @@ def __init__(self):
         super().__init__()
 
         # Set our scale
-        self.scale = SPRITE_SCALING_PLAYER
+        self.scale_set_uniform(SPRITE_SCALING_PLAYER)
 
         # Images from Kenney.nl's Character pack
         # main_path = ":resources:images/animated_characters/female_adventurer/femaleAdventurer"
diff --git a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_08.py b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_08.py
index bb4eac0de1..cae663e7de 100644
--- a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_08.py
+++ b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_08.py
@@ -73,7 +73,7 @@ def __init__(self):
         super().__init__()
 
         # Set our scale
-        self.scale = SPRITE_SCALING_PLAYER
+        self.scale_set_uniform(SPRITE_SCALING_PLAYER)
 
         # Images from Kenney.nl's Character pack
         # main_path = ":resources:images/animated_characters/female_adventurer/femaleAdventurer"
diff --git a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_09.py b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_09.py
index 72ea0286e6..8b680bdc54 100644
--- a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_09.py
+++ b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_09.py
@@ -83,7 +83,7 @@ def __init__(self):
         super().__init__()
 
         # Set our scale
-        self.scale = SPRITE_SCALING_PLAYER
+        self.scale_set_uniform(SPRITE_SCALING_PLAYER)
 
         # Images from Kenney.nl's Character pack
         # main_path = ":resources:images/animated_characters/female_adventurer/femaleAdventurer"
diff --git a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_10.py b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_10.py
index 71b018a02f..87c3420d39 100644
--- a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_10.py
+++ b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_10.py
@@ -83,7 +83,7 @@ def __init__(self):
         super().__init__()
 
         # Set our scale
-        self.scale = SPRITE_SCALING_PLAYER
+        self.scale_set_uniform(SPRITE_SCALING_PLAYER)
 
         # Images from Kenney.nl's Character pack
         # main_path = ":resources:images/animated_characters/female_adventurer/femaleAdventurer"
diff --git a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_11.py b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_11.py
index 09becc54aa..47f460b40c 100644
--- a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_11.py
+++ b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_11.py
@@ -83,7 +83,7 @@ def __init__(self):
         super().__init__()
 
         # Set our scale
-        self.scale = SPRITE_SCALING_PLAYER
+        self.scale_set_uniform(SPRITE_SCALING_PLAYER)
 
         # Images from Kenney.nl's Character pack
         # main_path = ":resources:images/animated_characters/female_adventurer/femaleAdventurer"
diff --git a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_12.py b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_12.py
index ffbe99ae8d..92dac8ddc7 100644
--- a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_12.py
+++ b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_12.py
@@ -85,7 +85,7 @@ def __init__(self,
         super().__init__()
 
         # Set our scale
-        self.scale = SPRITE_SCALING_PLAYER
+        self.scale_set_uniform(SPRITE_SCALING_PLAYER)
 
         # Images from Kenney.nl's Character pack
         # main_path = ":resources:images/animated_characters/female_adventurer/femaleAdventurer"
diff --git a/tests/unit/sprite/test_sprite.py b/tests/unit/sprite/test_sprite.py
index ae470699de..a961d27889 100644
--- a/tests/unit/sprite/test_sprite.py
+++ b/tests/unit/sprite/test_sprite.py
@@ -130,7 +130,7 @@ def test_sprite_scale_xy(window):
     sprite = arcade.SpriteSolidColor(20, 20, color=arcade.color.WHITE)
 
     # setting vector equivalent of previous scale doesn't change values
-    sprite.scale = 1.0
+    sprite.scale = 1.0, 1.0
     sprite.scale = (1.0, 1.0)
     assert sprite.scale == (1.0, 1.0)
     assert sprite.width, sprite.height == (20, 20)
@@ -180,14 +180,14 @@ def test_sprite_scale_resets_mismatched_xy_settings(window):
 
     # check if x dimension is properly reset
     sprite.scale = 3.0, 2.0
-    sprite.scale = 2.0
+    sprite.scale_set_uniform(2.0)
     assert sprite.scale == (2.0, 2.0)
     assert sprite.width == 40
     assert sprite.height == 40
 
     # check if y dimension is properly reset
     sprite.scale = 5.0, 3.0
-    sprite.scale = 5.0
+    sprite.scale_set_uniform(5.0)
     assert sprite.scale_x == 5.0
     assert sprite.scale == (5.0, 5.0)
     assert sprite.width == 100
@@ -195,7 +195,7 @@ def test_sprite_scale_resets_mismatched_xy_settings(window):
 
     # check if both dimensions properly reset
     sprite.scale = 0.5, 4.0
-    sprite.scale = 1.0
+    sprite.scale_set_uniform(1.0)
     assert sprite.scale_x == 1.0
     assert sprite.scale == (1.0, 1.0)
     assert sprite.width == 20
@@ -203,7 +203,7 @@ def test_sprite_scale_resets_mismatched_xy_settings(window):
 
     # edge case: setting negative values works
     sprite.scale = 0.5, 4.0
-    sprite.scale = -1.0
+    sprite.scale_set_uniform(-1.0)
     assert sprite.scale_x == -1.0
     assert sprite.scale == (-1.0, -1.0)
     assert sprite.width == -20
@@ -211,21 +211,21 @@ def test_sprite_scale_resets_mismatched_xy_settings(window):
 
     # edge case: x scale < 0 is reset to positive
     sprite.scale = -1.0, 1.0
-    sprite.scale = 2.0
+    sprite.scale_set_uniform(2.0)
     assert sprite.scale == (2.0, 2.0)
     assert sprite.width == 40
     assert sprite.height == 40
 
     # edge case: y scale < 0 is reset to positive
     sprite.scale = 1.0, -1.0
-    sprite.scale = 2.0
+    sprite.scale_set_uniform(2.0)
     assert sprite.scale == (2.0, 2.0)
     assert sprite.width == 40
     assert sprite.height == 40
 
     # edge case: x < 0, y < 0 is reset to positive
     sprite.scale = -1.0, -1.0
-    sprite.scale = 2.0
+    sprite.scale_set_uniform(2.0)
     assert sprite.scale == (2.0, 2.0)
     assert sprite.width == 40
     assert sprite.height == 40
diff --git a/tests/unit/sprite/test_sprite_animated_walking.py b/tests/unit/sprite/test_sprite_animated_walking.py
index 24e9769810..dc750ea198 100644
--- a/tests/unit/sprite/test_sprite_animated_walking.py
+++ b/tests/unit/sprite/test_sprite_animated_walking.py
@@ -16,7 +16,7 @@ def test_sprite_animated_old(window: arcade.Window):
 
     player = arcade.AnimatedWalkingSprite()
 
-    player.scale = 1
+    player.scale_set_uniform(1.0)
     player.stand_right_textures = []
     player.stand_right_textures.append(
         arcade.load_texture(":resources:images/animated_characters/female_person/femalePerson_idle.png"))
@@ -39,7 +39,7 @@ def test_sprite_animated_old(window: arcade.Window):
 
     player.center_x = SCREEN_WIDTH // 2
     player.center_y = SCREEN_HEIGHT // 2
-    player.scale = 0.8
+    player.scale_set_uniform(0.8)
     player.change_x = 2
     player.texture = player.stand_left_textures[0]
 
diff --git a/tests/unit/sprite/test_sprite_hitbox.py b/tests/unit/sprite/test_sprite_hitbox.py
index 01b07b42a6..594ca758b7 100644
--- a/tests/unit/sprite/test_sprite_hitbox.py
+++ b/tests/unit/sprite/test_sprite_hitbox.py
@@ -9,7 +9,7 @@ def test_1():
     )
     hit_box = arcade.hitbox.HitBox(((-10, -10), (-10, 10), (10, 10), (10, -10)))
     my_sprite.hit_box = hit_box
-    my_sprite.scale = 1.0
+    my_sprite.scale_set_uniform(1.0)
     my_sprite.angle = 0
     my_sprite.center_x = 100
     my_sprite.center_y = 100
@@ -19,22 +19,22 @@ def test_1():
     print(f"Hitbox: {my_sprite.scale} -> {my_sprite.hit_box.points} -> {hitbox}")
     assert hitbox == [(90.0, 90.0), (90.0, 110.0), (110.0, 110.0), (110.0, 90.0)]
 
-    my_sprite.scale = 0.5
+    my_sprite.scale = 0.5, 0.5
     hitbox = my_sprite.hit_box.get_adjusted_points()
     print(f"Hitbox: {my_sprite.scale} -> {my_sprite.hit_box.points} -> {hitbox}")
     assert hitbox == [(95.0, 95.0), (95.0, 105.0), (105.0, 105.0), (105.0, 95.0)]
 
-    my_sprite.scale = 1
+    my_sprite.scale_set_uniform(1.0)
     hitbox = my_sprite.hit_box.get_adjusted_points()
     print(f"Hitbox: {my_sprite.scale} -> {my_sprite.hit_box.points} -> {hitbox}")
     assert hitbox == [(90.0, 90.0), (90.0, 110.0), (110.0, 110.0), (110.0, 90.0)]
 
-    my_sprite.scale = 2.0
+    my_sprite.scale_set_uniform(2.0)
     hitbox = my_sprite.hit_box.get_adjusted_points()
     print(f"Hitbox: {my_sprite.scale} -> {my_sprite.hit_box.points} -> {hitbox}")
     assert hitbox == [(80.0, 80.0), (80.0, 120.0), (120.0, 120.0), (120.0, 80.0)]
 
-    my_sprite.scale = 2.0
+    my_sprite.scale_set_uniform(2.0)
     hitbox = my_sprite.hit_box.get_adjusted_points()
     print(f"Hitbox: {my_sprite.scale} -> {my_sprite.hit_box.points} -> {hitbox}")
     assert hitbox == [(80.0, 80.0), (80.0, 120.0), (120.0, 120.0), (120.0, 80.0)]
diff --git a/tests/unit/sprite/test_sprite_render.py b/tests/unit/sprite/test_sprite_render.py
index 5fa02e2be7..e8798b43e6 100644
--- a/tests/unit/sprite/test_sprite_render.py
+++ b/tests/unit/sprite/test_sprite_render.py
@@ -123,7 +123,7 @@ def update(delta_time):
         frame_counter += 1
         if frame_counter == 5:
             character_list.pop()
-            coin_list[0].scale = 2.0
+            coin_list[0].scale_set_uniform(2.0)
 
         coin_hit_list = arcade.check_for_collision_with_list(character_sprite, coin_list)
         for coin in coin_hit_list:
@@ -217,16 +217,16 @@ def test_render_scaled(window):
     assert gold_1.scale == (1.0, 1.0)
     assert gold_1.width, gold_1.height == (64, 64)
 
-    gold_1.scale = 2.0
+    gold_1.scale_set_uniform(2.0)
     assert gold_1.scale == (2.0, 2.0)
     assert gold_1.width, gold_1.height == (128, 128)
 
-    gold_1.scale *= 0.25
+    gold_1.scale_multiply_uniform(0.25)
     assert gold_1.scale == (0.5, 0.5)
     assert gold_1.width, gold_1.height == (32, 32)
 
     # edge case: negative scale values are supported
-    gold_1.scale *= -1.0
+    gold_1.scale_multiply_uniform(-1.0)
     assert gold_1.scale == (-0.5, -0.5)
     assert gold_1.width, gold_1.height == (-32, -32)
 
@@ -245,7 +245,7 @@ def on_draw():
         character_list.draw()
 
     def update(delta_time):
-        character_sprite.scale += 0.1
+        character_sprite.scale_add_uniform(0.1)
 
     window.on_draw = on_draw
     window.update = update

From d369c451e2bf5db21b6e98e6244f5c302be75ea2 Mon Sep 17 00:00:00 2001
From: Einar Forselv <eforselv@gmail.com>
Date: Mon, 7 Oct 2024 02:46:11 +0200
Subject: [PATCH 03/15] Example fixes

---
 arcade/examples/particle_fireworks.py | 10 ++++++----
 1 file changed, 6 insertions(+), 4 deletions(-)

diff --git a/arcade/examples/particle_fireworks.py b/arcade/examples/particle_fireworks.py
index b74e5a455d..5eb8c0b3dd 100644
--- a/arcade/examples/particle_fireworks.py
+++ b/arcade/examples/particle_fireworks.py
@@ -382,10 +382,12 @@ def firework_spark_mutator(particle: FadeParticle):
 
 
 def rocket_smoke_mutator(particle: LifetimeParticle):
-    particle.scale = lerp(
-        0.5,
-        3.0,
-        particle.lifetime_elapsed / particle.lifetime_original  # type: ignore
+    particle.scale_set_uniform(
+        lerp(
+            0.5,
+            3.0,
+            particle.lifetime_elapsed / particle.lifetime_original,  # type: ignore
+        )
     )
 
 

From d0a68db0ea1b120425b6d5c9d043205ee4156f7e Mon Sep 17 00:00:00 2001
From: DragonMoffon <happydog.aja@gmail.com>
Date: Thu, 10 Oct 2024 22:27:20 +1300
Subject: [PATCH 04/15] Updated scale method names, and reimplimented scalar
 setting of scale

The names we had for the scale methods didn't feel right for arcade/python so they have been changed to be more readable.

While the concern about confusing the user is valid I think the change is worth it. During the interim before we do a larger re-write this is a nicer developer experience than using a method. Most developers set the sprite scale once, and then don't change it in the future.

the `isinstance` check for `AsFloat` is no-where near the cost of creating a vector.

In my ideal world sprite would not have a scale property at all, and instead scale is a method that changes the size of the sprite.
---
 arcade/sprite/base.py                         | 42 +++++++------------
 tests/unit/sprite/test_sprite.py              | 23 ++++++----
 .../sprite/test_sprite_animated_walking.py    |  4 +-
 tests/unit/sprite/test_sprite_hitbox.py       |  8 ++--
 tests/unit/sprite/test_sprite_render.py       | 10 ++---
 5 files changed, 42 insertions(+), 45 deletions(-)

diff --git a/arcade/sprite/base.py b/arcade/sprite/base.py
index 58160c9f19..0e81f880a5 100644
--- a/arcade/sprite/base.py
+++ b/arcade/sprite/base.py
@@ -279,10 +279,19 @@ def scale(self) -> Point2:
         return self._scale
 
     @scale.setter
-    def scale(self, new_scale: Point2):
+    def scale(self, new_scale: Point2 | AsFloat):
         if new_scale == self._scale:
             return
-        scale_x, scale_y = new_scale
+
+        if isinstance(new_scale, AsFloat):
+            new_scale = new_scale, new_scale
+
+        try:
+            scale_x, scale_y = new_scale
+        except ValueError:
+            raise ValueError("scale must be a scalar or a length 2 tuple-like")
+        except TypeError:
+            raise TypeError("scale must be a scalar or a length 2 tuple-like")
 
         self._hit_box.scale = new_scale
         tex_width, tex_height = self._texture.size
@@ -562,27 +571,8 @@ def update_animation(self, delta_time: float = 1 / 60, *args, **kwargs) -> None:
 
     # --- Scale methods -----
 
-    def scale_set_uniform(self, new_scale: AsFloat) -> None:
-        """Set the sprite's scale uniformly.
-
-        This sets both the x and y scale values to the same value.
-
-        Args:
-            new_scale: The new scale value for the sprite.
-        """
-        self._scale = new_scale, new_scale
-        self._hit_box.scale = self._scale
-        tex_width, tex_height = self._texture.size
-        self._width = tex_width * new_scale
-        self._height = tex_height * new_scale
-
-        self.update_spatial_hash()
-        for sprite_list in self.sprite_lists:
-            sprite_list._update_size(self)
-
-    def scale_add_uniform(self, factor: AsFloat) -> None:
-        """Add to the sprite's scale uniformly.
-
+    def add_scale(self, factor: AsFloat) -> None:
+        """Add to the sprite's scale by the factor.
         This adds the factor to both the x and y scale values.
 
         Args:
@@ -598,14 +588,14 @@ def scale_add_uniform(self, factor: AsFloat) -> None:
         for sprite_list in self.sprite_lists:
             sprite_list._update_size(self)
 
-    def scale_multiply_uniform(self, factor: AsFloat) -> None:
-        """Scale the sprite uniformly by a factor.
-
+    def multiply_scale(self, factor: AsFloat) -> None:
+        """multiply the sprite's scale by the factor.
         This multiplies both the x and y scale values by the factor.
 
         Args:
             factor: The factor to scale up the sprite by.
         """
+
         self._scale = self._scale[0] * factor, self._scale[1] * factor
         self._hit_box.scale = self._scale
         tex_width, tex_height = self._texture.size
diff --git a/tests/unit/sprite/test_sprite.py b/tests/unit/sprite/test_sprite.py
index a961d27889..58fa1b7af1 100644
--- a/tests/unit/sprite/test_sprite.py
+++ b/tests/unit/sprite/test_sprite.py
@@ -180,22 +180,25 @@ def test_sprite_scale_resets_mismatched_xy_settings(window):
 
     # check if x dimension is properly reset
     sprite.scale = 3.0, 2.0
-    sprite.scale_set_uniform(2.0)
+    assert sprite.scale == (3.0, 2.0)
+    sprite.scale = 2.0
     assert sprite.scale == (2.0, 2.0)
     assert sprite.width == 40
     assert sprite.height == 40
 
     # check if y dimension is properly reset
     sprite.scale = 5.0, 3.0
-    sprite.scale_set_uniform(5.0)
-    assert sprite.scale_x == 5.0
+    assert sprite.scale == (5.0, 3.0)
+    sprite.scale = 5.0
+    assert sprite.scale_y == 5.0
     assert sprite.scale == (5.0, 5.0)
     assert sprite.width == 100
     assert sprite.height == 100
 
     # check if both dimensions properly reset
     sprite.scale = 0.5, 4.0
-    sprite.scale_set_uniform(1.0)
+    assert sprite.scale == (0.5, 4.0)
+    sprite.scale = 1.0
     assert sprite.scale_x == 1.0
     assert sprite.scale == (1.0, 1.0)
     assert sprite.width == 20
@@ -203,7 +206,8 @@ def test_sprite_scale_resets_mismatched_xy_settings(window):
 
     # edge case: setting negative values works
     sprite.scale = 0.5, 4.0
-    sprite.scale_set_uniform(-1.0)
+    assert sprite.scale == (0.5, 4.0)
+    sprite.scale = -1.0
     assert sprite.scale_x == -1.0
     assert sprite.scale == (-1.0, -1.0)
     assert sprite.width == -20
@@ -211,21 +215,24 @@ def test_sprite_scale_resets_mismatched_xy_settings(window):
 
     # edge case: x scale < 0 is reset to positive
     sprite.scale = -1.0, 1.0
-    sprite.scale_set_uniform(2.0)
+    assert sprite.scale == (-1.0, 1.0)
+    sprite.scale = 2.0
     assert sprite.scale == (2.0, 2.0)
     assert sprite.width == 40
     assert sprite.height == 40
 
     # edge case: y scale < 0 is reset to positive
     sprite.scale = 1.0, -1.0
-    sprite.scale_set_uniform(2.0)
+    assert sprite.scale == (1.0, -1.0)
+    sprite.scale = 2.0
     assert sprite.scale == (2.0, 2.0)
     assert sprite.width == 40
     assert sprite.height == 40
 
     # edge case: x < 0, y < 0 is reset to positive
     sprite.scale = -1.0, -1.0
-    sprite.scale_set_uniform(2.0)
+    assert sprite.scale == (-1.0, -1.0)
+    sprite.scale = 2.0
     assert sprite.scale == (2.0, 2.0)
     assert sprite.width == 40
     assert sprite.height == 40
diff --git a/tests/unit/sprite/test_sprite_animated_walking.py b/tests/unit/sprite/test_sprite_animated_walking.py
index dc750ea198..fb2bfee07f 100644
--- a/tests/unit/sprite/test_sprite_animated_walking.py
+++ b/tests/unit/sprite/test_sprite_animated_walking.py
@@ -16,7 +16,7 @@ def test_sprite_animated_old(window: arcade.Window):
 
     player = arcade.AnimatedWalkingSprite()
 
-    player.scale_set_uniform(1.0)
+    player.scale = 1.0
     player.stand_right_textures = []
     player.stand_right_textures.append(
         arcade.load_texture(":resources:images/animated_characters/female_person/femalePerson_idle.png"))
@@ -39,7 +39,7 @@ def test_sprite_animated_old(window: arcade.Window):
 
     player.center_x = SCREEN_WIDTH // 2
     player.center_y = SCREEN_HEIGHT // 2
-    player.scale_set_uniform(0.8)
+    player.scale = 0.8
     player.change_x = 2
     player.texture = player.stand_left_textures[0]
 
diff --git a/tests/unit/sprite/test_sprite_hitbox.py b/tests/unit/sprite/test_sprite_hitbox.py
index 594ca758b7..5a44ba8b80 100644
--- a/tests/unit/sprite/test_sprite_hitbox.py
+++ b/tests/unit/sprite/test_sprite_hitbox.py
@@ -9,7 +9,7 @@ def test_1():
     )
     hit_box = arcade.hitbox.HitBox(((-10, -10), (-10, 10), (10, 10), (10, -10)))
     my_sprite.hit_box = hit_box
-    my_sprite.scale_set_uniform(1.0)
+    my_sprite.scale = 1.0
     my_sprite.angle = 0
     my_sprite.center_x = 100
     my_sprite.center_y = 100
@@ -24,17 +24,17 @@ def test_1():
     print(f"Hitbox: {my_sprite.scale} -> {my_sprite.hit_box.points} -> {hitbox}")
     assert hitbox == [(95.0, 95.0), (95.0, 105.0), (105.0, 105.0), (105.0, 95.0)]
 
-    my_sprite.scale_set_uniform(1.0)
+    my_sprite.scale = 1.0
     hitbox = my_sprite.hit_box.get_adjusted_points()
     print(f"Hitbox: {my_sprite.scale} -> {my_sprite.hit_box.points} -> {hitbox}")
     assert hitbox == [(90.0, 90.0), (90.0, 110.0), (110.0, 110.0), (110.0, 90.0)]
 
-    my_sprite.scale_set_uniform(2.0)
+    my_sprite.scale = 2.0
     hitbox = my_sprite.hit_box.get_adjusted_points()
     print(f"Hitbox: {my_sprite.scale} -> {my_sprite.hit_box.points} -> {hitbox}")
     assert hitbox == [(80.0, 80.0), (80.0, 120.0), (120.0, 120.0), (120.0, 80.0)]
 
-    my_sprite.scale_set_uniform(2.0)
+    my_sprite.scale = 2.0
     hitbox = my_sprite.hit_box.get_adjusted_points()
     print(f"Hitbox: {my_sprite.scale} -> {my_sprite.hit_box.points} -> {hitbox}")
     assert hitbox == [(80.0, 80.0), (80.0, 120.0), (120.0, 120.0), (120.0, 80.0)]
diff --git a/tests/unit/sprite/test_sprite_render.py b/tests/unit/sprite/test_sprite_render.py
index e8798b43e6..8c82a9fb80 100644
--- a/tests/unit/sprite/test_sprite_render.py
+++ b/tests/unit/sprite/test_sprite_render.py
@@ -123,7 +123,7 @@ def update(delta_time):
         frame_counter += 1
         if frame_counter == 5:
             character_list.pop()
-            coin_list[0].scale_set_uniform(2.0)
+            coin_list[0].scale = 2.0
 
         coin_hit_list = arcade.check_for_collision_with_list(character_sprite, coin_list)
         for coin in coin_hit_list:
@@ -217,16 +217,16 @@ def test_render_scaled(window):
     assert gold_1.scale == (1.0, 1.0)
     assert gold_1.width, gold_1.height == (64, 64)
 
-    gold_1.scale_set_uniform(2.0)
+    gold_1.scale = 2.0
     assert gold_1.scale == (2.0, 2.0)
     assert gold_1.width, gold_1.height == (128, 128)
 
-    gold_1.scale_multiply_uniform(0.25)
+    gold_1.multiply_scale(0.25)
     assert gold_1.scale == (0.5, 0.5)
     assert gold_1.width, gold_1.height == (32, 32)
 
     # edge case: negative scale values are supported
-    gold_1.scale_multiply_uniform(-1.0)
+    gold_1.multiply_scale(-1.0)
     assert gold_1.scale == (-0.5, -0.5)
     assert gold_1.width, gold_1.height == (-32, -32)
 
@@ -245,7 +245,7 @@ def on_draw():
         character_list.draw()
 
     def update(delta_time):
-        character_sprite.scale_add_uniform(0.1)
+        character_sprite.add_scale(0.1)
 
     window.on_draw = on_draw
     window.update = update

From 492ce7ae54e152664175e55aeac0201d3536944b Mon Sep 17 00:00:00 2001
From: DragonMoffon <happydog.aja@gmail.com>
Date: Thu, 10 Oct 2024 22:36:17 +1300
Subject: [PATCH 05/15] Fix examples and tutorials

---
 arcade/examples/sprite_move_animation.py                     | 2 +-
 doc/tutorials/crt_filter/crt_filter_example.py               | 2 +-
 doc/tutorials/pymunk_platformer/pymunk_demo_platformer.py    | 5 +----
 doc/tutorials/pymunk_platformer/pymunk_demo_platformer_08.py | 5 +----
 doc/tutorials/pymunk_platformer/pymunk_demo_platformer_09.py | 5 +----
 doc/tutorials/pymunk_platformer/pymunk_demo_platformer_10.py | 5 +----
 doc/tutorials/pymunk_platformer/pymunk_demo_platformer_11.py | 5 +----
 doc/tutorials/pymunk_platformer/pymunk_demo_platformer_12.py | 5 +----
 8 files changed, 8 insertions(+), 26 deletions(-)

diff --git a/arcade/examples/sprite_move_animation.py b/arcade/examples/sprite_move_animation.py
index 01a0d4f76a..fa5a0f6bd0 100644
--- a/arcade/examples/sprite_move_animation.py
+++ b/arcade/examples/sprite_move_animation.py
@@ -114,7 +114,7 @@ def setup(self):
         self.player = PlayerCharacter(self.idle_texture_pair, self.walk_texture_pairs)
         self.player.center_x = SCREEN_WIDTH // 2
         self.player.center_y = SCREEN_HEIGHT // 2
-        self.player.scale_set_uniform(0.8)
+        self.player.scale = 0.8
 
         self.player_list.append(self.player)
 
diff --git a/doc/tutorials/crt_filter/crt_filter_example.py b/doc/tutorials/crt_filter/crt_filter_example.py
index b93d0e8c49..3ce8a95e5f 100644
--- a/doc/tutorials/crt_filter/crt_filter_example.py
+++ b/doc/tutorials/crt_filter/crt_filter_example.py
@@ -30,7 +30,7 @@ def __init__(self, width, height, title):
 
         # Load the pac-man map image and scale it up to the window size
         map = arcade.Sprite("Pac-man.png", center_x=width / 2, center_y=height / 2)
-        map.scale_set_uniform(width / map.width)
+        map.scale = width / map.width
         self.sprite_list.append(map)
 
         # Slice out some textures from the sprite sheet
diff --git a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer.py b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer.py
index 40678242d0..f316acd685 100644
--- a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer.py
+++ b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer.py
@@ -56,10 +56,7 @@
 class PlayerSprite(arcade.Sprite):
     def __init__(self):
         # Let parent initialize
-        super().__init__()
-
-        # Set our scale
-        self.scale_set_uniform(SPRITE_SCALING_PLAYER)
+        super().__init__(scale=SPRITE_SCALING_PLAYER)
 
         # Images from Kenney.nl's Character pack
         # main_path = ":resources:images/animated_characters/female_adventurer/femaleAdventurer"
diff --git a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_08.py b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_08.py
index cae663e7de..7c871bcc5a 100644
--- a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_08.py
+++ b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_08.py
@@ -70,10 +70,7 @@ class PlayerSprite(arcade.Sprite):
     def __init__(self):
         """ Init """
         # Let parent initialize
-        super().__init__()
-
-        # Set our scale
-        self.scale_set_uniform(SPRITE_SCALING_PLAYER)
+        super().__init__(scale=SPRITE_SCALING_PLAYER)
 
         # Images from Kenney.nl's Character pack
         # main_path = ":resources:images/animated_characters/female_adventurer/femaleAdventurer"
diff --git a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_09.py b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_09.py
index 8b680bdc54..5c410f02d2 100644
--- a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_09.py
+++ b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_09.py
@@ -80,10 +80,7 @@ class PlayerSprite(arcade.Sprite):
     def __init__(self):
         """ Init """
         # Let parent initialize
-        super().__init__()
-
-        # Set our scale
-        self.scale_set_uniform(SPRITE_SCALING_PLAYER)
+        super().__init__(scale=SPRITE_SCALING_PLAYER)
 
         # Images from Kenney.nl's Character pack
         # main_path = ":resources:images/animated_characters/female_adventurer/femaleAdventurer"
diff --git a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_10.py b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_10.py
index 87c3420d39..a1ec9f0fe7 100644
--- a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_10.py
+++ b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_10.py
@@ -80,10 +80,7 @@ class PlayerSprite(arcade.Sprite):
     def __init__(self):
         """ Init """
         # Let parent initialize
-        super().__init__()
-
-        # Set our scale
-        self.scale_set_uniform(SPRITE_SCALING_PLAYER)
+        super().__init__(scale=SPRITE_SCALING_PLAYER)
 
         # Images from Kenney.nl's Character pack
         # main_path = ":resources:images/animated_characters/female_adventurer/femaleAdventurer"
diff --git a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_11.py b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_11.py
index 47f460b40c..cd76c04232 100644
--- a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_11.py
+++ b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_11.py
@@ -80,10 +80,7 @@ class PlayerSprite(arcade.Sprite):
     def __init__(self):
         """ Init """
         # Let parent initialize
-        super().__init__()
-
-        # Set our scale
-        self.scale_set_uniform(SPRITE_SCALING_PLAYER)
+        super().__init__(scale=SPRITE_SCALING_PLAYER)
 
         # Images from Kenney.nl's Character pack
         # main_path = ":resources:images/animated_characters/female_adventurer/femaleAdventurer"
diff --git a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_12.py b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_12.py
index 92dac8ddc7..b1d20eadb7 100644
--- a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_12.py
+++ b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_12.py
@@ -82,10 +82,7 @@ def __init__(self,
                  hit_box_algorithm: arcade.hitbox.HitBoxAlgorithm):
         """ Init """
         # Let parent initialize
-        super().__init__()
-
-        # Set our scale
-        self.scale_set_uniform(SPRITE_SCALING_PLAYER)
+        super().__init__(scale=SPRITE_SCALING_PLAYER)
 
         # Images from Kenney.nl's Character pack
         # main_path = ":resources:images/animated_characters/female_adventurer/femaleAdventurer"

From b39cf5d7bba7a80268038acaa25007a37aac15b9 Mon Sep 17 00:00:00 2001
From: DragonMoffon <happydog.aja@gmail.com>
Date: Thu, 10 Oct 2024 22:42:58 +1300
Subject: [PATCH 06/15] fixing typing error

---
 arcade/sprite/base.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/arcade/sprite/base.py b/arcade/sprite/base.py
index 0e81f880a5..e6f8f076f4 100644
--- a/arcade/sprite/base.py
+++ b/arcade/sprite/base.py
@@ -283,7 +283,7 @@ def scale(self, new_scale: Point2 | AsFloat):
         if new_scale == self._scale:
             return
 
-        if isinstance(new_scale, AsFloat):
+        if isinstance(new_scale, (float, int)):
             new_scale = new_scale, new_scale
 
         try:

From 3c50ddf6f6167f7bc6a43e1210ba618cd42710c1 Mon Sep 17 00:00:00 2001
From: DragonMoffon <happydog.aja@gmail.com>
Date: Thu, 10 Oct 2024 22:49:10 +1300
Subject: [PATCH 07/15] linting and type pass

---
 arcade/examples/particle_fireworks.py         | 11 +++++------
 arcade/examples/sprite_explosion_particles.py |  2 +-
 2 files changed, 6 insertions(+), 7 deletions(-)

diff --git a/arcade/examples/particle_fireworks.py b/arcade/examples/particle_fireworks.py
index 5eb8c0b3dd..8d3dced15c 100644
--- a/arcade/examples/particle_fireworks.py
+++ b/arcade/examples/particle_fireworks.py
@@ -382,15 +382,14 @@ def firework_spark_mutator(particle: FadeParticle):
 
 
 def rocket_smoke_mutator(particle: LifetimeParticle):
-    particle.scale_set_uniform(
-        lerp(
-            0.5,
-            3.0,
-            particle.lifetime_elapsed / particle.lifetime_original,  # type: ignore
-        )
+    particle.scale = lerp(
+        0.5,
+        3.0,
+        particle.lifetime_elapsed / particle.lifetime_original,  # type: ignore
     )
 
 
+
 def main():
     app = FireworksApp()
     app.run()
diff --git a/arcade/examples/sprite_explosion_particles.py b/arcade/examples/sprite_explosion_particles.py
index 9778f3340f..81ee1a1018 100644
--- a/arcade/examples/sprite_explosion_particles.py
+++ b/arcade/examples/sprite_explosion_particles.py
@@ -90,7 +90,7 @@ def update(self, delta_time: float = 1/60):
             self.alpha -= int(SMOKE_FADE_RATE * time_step)
             self.center_x += self.change_x * time_step
             self.center_y += self.change_y * time_step
-            self.scale_add_uniform(SMOKE_EXPANSION_RATE * time_step)
+            self.add_scale(SMOKE_EXPANSION_RATE * time_step)
 
 
 class Particle(arcade.SpriteCircle):

From 6744f6922818a33a1c2a6d4e370fa9b710fbf41d Mon Sep 17 00:00:00 2001
From: DragonMoffon <happydog.aja@gmail.com>
Date: Fri, 11 Oct 2024 22:46:43 +1300
Subject: [PATCH 08/15] last place the old method was hiding

---
 arcade/examples/sprite_explosion_particles.py | 2 +-
 arcade/sprite/base.py                         | 5 +++--
 2 files changed, 4 insertions(+), 3 deletions(-)

diff --git a/arcade/examples/sprite_explosion_particles.py b/arcade/examples/sprite_explosion_particles.py
index 81ee1a1018..02fd21e44d 100644
--- a/arcade/examples/sprite_explosion_particles.py
+++ b/arcade/examples/sprite_explosion_particles.py
@@ -75,7 +75,7 @@ class Smoke(arcade.SpriteCircle):
     def __init__(self, size):
         super().__init__(size, arcade.color.LIGHT_GRAY, soft=True)
         self.change_y = SMOKE_RISE_RATE
-        self.scale_set_uniform(SMOKE_START_SCALE)
+        self.scale = SMOKE_START_SCALE
 
     def update(self, delta_time: float = 1/60):
         """Update this particle"""
diff --git a/arcade/sprite/base.py b/arcade/sprite/base.py
index e6f8f076f4..5dcd56bd00 100644
--- a/arcade/sprite/base.py
+++ b/arcade/sprite/base.py
@@ -262,11 +262,12 @@ def scale_y(self, new_scale_y: AsFloat):
     @property
     def scale(self) -> Point2:
         """Get or set the x & y scale of the sprite as a pair of values.
+        You may set both the x & y with a single scalar, but scale will always return
+        a length 2 tuple of the x & y scale
 
         See :py:attr:`.scale_x` and :py:attr:`.scale_y` for individual access.
 
-        See :py:meth:`.scale_multiply_uniform` and :py:meth:`.scale_set_uniform`
-        for uniform scaling.
+        See :py:meth:`.scale_multiply_uniform` for uniform scaling.
 
         .. note:: Negative scale values are supported.
 

From 01ca6df9a0fdc53e59e39d557633f6e1c4082445 Mon Sep 17 00:00:00 2001
From: DragonMoffon <happydog.aja@gmail.com>
Date: Sat, 12 Oct 2024 14:07:36 +1300
Subject: [PATCH 09/15] perf tests and improved scale setter

---
 arcade/sprite/base.py     | 28 ++++++++++++++++++----------
 benchmarks/sprite/main.py |  6 +++---
 2 files changed, 21 insertions(+), 13 deletions(-)

diff --git a/arcade/sprite/base.py b/arcade/sprite/base.py
index 5dcd56bd00..898303cace 100644
--- a/arcade/sprite/base.py
+++ b/arcade/sprite/base.py
@@ -281,18 +281,26 @@ def scale(self) -> Point2:
 
     @scale.setter
     def scale(self, new_scale: Point2 | AsFloat):
-        if new_scale == self._scale:
-            return
-
         if isinstance(new_scale, (float, int)):
-            new_scale = new_scale, new_scale
+            scale_x = new_scale
+            scale_y = new_scale
 
-        try:
-            scale_x, scale_y = new_scale
-        except ValueError:
-            raise ValueError("scale must be a scalar or a length 2 tuple-like")
-        except TypeError:
-            raise TypeError("scale must be a scalar or a length 2 tuple-like")
+        else:  # Treat it as some sort of iterable or sequence
+            # Don't abstract this. Keep it here since it's a hot code path
+            try:
+                scale_x, scale_y = new_scale  # type / length implicit check
+            except ValueError:
+                raise ValueError(
+                    "scale must be a tuple-like object which unpacks to exactly 2 coordinates"
+                )
+            except TypeError:
+                raise TypeError(
+                    "scale must be a tuple-like object which unpacks to exactly 2 coordinates"
+                )
+
+        new_scale = scale_x, scale_y
+        if new_scale == self._scale:
+            return
 
         self._hit_box.scale = new_scale
         tex_width, tex_height = self._texture.size
diff --git a/benchmarks/sprite/main.py b/benchmarks/sprite/main.py
index c20ea0b4b1..b18e2784ce 100644
--- a/benchmarks/sprite/main.py
+++ b/benchmarks/sprite/main.py
@@ -9,7 +9,7 @@
 from sprite_alt import BasicSprite as SpriteA
 from arcade import BasicSprite as SpriteB
 
-N = 10
+N = 100
 MEASUREMENT_CONFIG = [
     {"name": "populate", "number": N, "measure_method": "populate", "post_methods": ["flush"]},
     {"name": "scale_set", "number": N, "measure_method": "scale_set", "post_methods": []},
@@ -56,7 +56,7 @@ def populate(self):
                         texture=texture,
                         center_x=x * 64,
                         center_y=y * 64,
-                        scale=((1.0, 1.0)),
+                        scale=(1.0, 1.0),
                     )
                 )
 
@@ -74,7 +74,7 @@ def scale_mult(self):
     def scale_mult_uniform(self):
         """Multiply the scale of all sprites uniformly."""
         for sprite in self.spritelist:
-            sprite.scale_multiply_uniform(2.0)
+            sprite.multiply_scale(2.0)
 
     # Rotate
     # Move

From 1981ad5a26d1585fbb394104089f72389a614bd4 Mon Sep 17 00:00:00 2001
From: DragonMoffon <happydog.aja@gmail.com>
Date: Sat, 12 Oct 2024 15:12:55 +1300
Subject: [PATCH 10/15] comparing func vs unpacking and random numbers in perf
 test

---
 benchmarks/sprite/main.py       |  20 +++--
 benchmarks/sprite/sprite_alt.py | 128 +++++++++++++++++---------------
 2 files changed, 83 insertions(+), 65 deletions(-)

diff --git a/benchmarks/sprite/main.py b/benchmarks/sprite/main.py
index b18e2784ce..e7aaf9bc71 100644
--- a/benchmarks/sprite/main.py
+++ b/benchmarks/sprite/main.py
@@ -5,14 +5,19 @@
 import math
 import timeit
 import arcade
+from itertools import cycle
+from random import random
 
 from sprite_alt import BasicSprite as SpriteA
 from arcade import BasicSprite as SpriteB
 
+random_numbers = cycle(tuple(random() + 0.1 for _ in range(1009)))
+
 N = 100
 MEASUREMENT_CONFIG = [
     {"name": "populate", "number": N, "measure_method": "populate", "post_methods": ["flush"]},
     {"name": "scale_set", "number": N, "measure_method": "scale_set", "post_methods": []},
+    {"name": "scale_set_uniform", "number": N, "measure_method": "scale_set_uniform", "post_methods": []},
     {"name": "scale_mult", "number": N, "measure_method": "scale_mult", "post_methods": []},
     {"name": "scale_mult_uniform", "number": N, "measure_method": "scale_mult_uniform", "post_methods": []},
 ]
@@ -64,17 +69,22 @@ def populate(self):
     def scale_set(self):
         """Set the scale of all sprites."""
         for sprite in self.spritelist:
-            sprite.scale = 2.0
+            sprite.scale = next(random_numbers)
 
-    def scale_mult(self):
-        """Multiply the scale of all sprites."""
+    def scale_set_uniform(self):
+        """Set the scale of all sprites."""
         for sprite in self.spritelist:
-            sprite.scale *= 2.0
+            sprite.scale_set_uniform(next(random_numbers))
 
     def scale_mult_uniform(self):
+        """Multiply the scale of all sprites."""
+        for sprite in self.spritelist:
+            sprite.scale_multiply_uniform(next(random_numbers))
+
+    def scale_mult(self):
         """Multiply the scale of all sprites uniformly."""
         for sprite in self.spritelist:
-            sprite.multiply_scale(2.0)
+            sprite.multiply_scale(next(random_numbers, 1.0))
 
     # Rotate
     # Move
diff --git a/benchmarks/sprite/sprite_alt.py b/benchmarks/sprite/sprite_alt.py
index 139da41bc6..066a57d4f2 100644
--- a/benchmarks/sprite/sprite_alt.py
+++ b/benchmarks/sprite/sprite_alt.py
@@ -1,12 +1,7 @@
-"""
-Replace this with a copy/alternative version of BasicSprite
-"""
 from __future__ import annotations
 
 from typing import TYPE_CHECKING, Any, Iterable, TypeVar
 
-from pyglet.math import Vec2
-
 import arcade
 from arcade.color import BLACK, WHITE
 from arcade.exceptions import ReplacementWarning, warning
@@ -265,72 +260,29 @@ def scale_y(self, new_scale_y: AsFloat):
             sprite_list._update_size(self)
 
     @property
-    def scale(self) -> Vec2:
+    def scale(self) -> Point2:
         """Get or set the x & y scale of the sprite as a pair of values.
 
-        You may set it to either a single value or a pair of values:
-
-        .. list-table::
-           :header-rows: 0
-
-           * - Single value
-             - ``sprite.scale = 2.0``
-
-           * - Tuple or :py:class:`~pyglet,math.Vec2`
-             - ``sprite.scale = (1.0, 3.0)``
-
-        The two-channel version is useful for making health bars and
-        other indicators.
-
-        .. note:: Returns a :py:class:`pyglet.math.Vec2` for
-                  compatibility.
-
-        Arcade versions lower than 3,0 used one or both of the following
-        for scale:
+        See :py:attr:`.scale_x` and :py:attr:`.scale_y` for individual access.
 
-        * A single :py:class:`float` on versions <= 2.6
-        * A ``scale_xy`` property and exposing only the x component
-          on some intermediate dev releases
-
-        Although scale is internally stored as a :py:class:`tuple`, we
-        return a :py:class:`pyglet.math.Vec2` to allow the in-place
-        operators to work in addition to setting values directly:
-
-        * Old-style (``sprite.scale *= 2.0``)
-        * New-style (``sprite.scale *= 2.0, 2.0``)
+        See :py:meth:`.scale_multiply_uniform` and :py:meth:`.scale_set_uniform`
+        for uniform scaling.
 
         .. note:: Negative scale values are supported.
 
-                  This applies to both single-axis and dual-axis.
-                  Negatives will flip & mirror the sprite, but the
-                  with will use :py:func:`abs` to report total width
-                  and height instead of negatives.
+            This applies to both single-axis and dual-axis.
+            Negatives will flip & mirror the sprite, but the
+            with will use :py:func:`abs` to report total width
+            and height instead of negatives.
 
         """
-        return Vec2(*self._scale)
+        return self._scale
 
     @scale.setter
-    def scale(self, new_scale: Point2 | AsFloat):
-        if isinstance(new_scale, (float, int)):
-            scale_x = new_scale
-            scale_y = new_scale
-
-        else:  # Treat it as some sort of iterable or sequence
-            # Don't abstract this. Keep it here since it's a hot code path
-            try:
-                scale_x, scale_y = new_scale  # type / length implicit check
-            except ValueError:
-                raise ValueError(
-                    "scale must be a tuple-like object which unpacks to exactly 2 coordinates"
-                )
-            except TypeError:
-                raise TypeError(
-                    "scale must be a tuple-like object which unpacks to exactly 2 coordinates"
-                )
-
-        new_scale = scale_x, scale_y
+    def scale(self, new_scale: Point2):
         if new_scale == self._scale:
             return
+        scale_x, scale_y = new_scale
 
         self._hit_box.scale = new_scale
         tex_width, tex_height = self._texture.size
@@ -610,6 +562,62 @@ def update_animation(self, delta_time: float = 1 / 60, *args, **kwargs) -> None:
 
     # --- Scale methods -----
 
+    def scale_set_uniform(self, new_scale: AsFloat) -> None:
+        """Set the sprite's scale uniformly.
+
+        This sets both the x and y scale values to the same value.
+
+        Args:
+            new_scale: The new scale value for the sprite.
+        """
+        if self._scale == (new_scale, new_scale):
+            return
+        self._scale = new_scale, new_scale
+        self._hit_box.scale = self._scale
+        tex_width, tex_height = self._texture.size
+        self._width = tex_width * new_scale
+        self._height = tex_height * new_scale
+
+        self.update_spatial_hash()
+        for sprite_list in self.sprite_lists:
+            sprite_list._update_size(self)
+
+    def scale_add_uniform(self, factor: AsFloat) -> None:
+        """Add to the sprite's scale uniformly.
+
+        This adds the factor to both the x and y scale values.
+
+        Args:
+            factor: The factor to add to the sprite's scale.
+        """
+        self._scale = self._scale[0] + factor, self._scale[1] + factor
+        self._hit_box.scale = self._scale
+        tex_width, tex_height = self._texture.size
+        self._width = tex_width * self._scale[0]
+        self._height = tex_height * self._scale[1]
+
+        self.update_spatial_hash()
+        for sprite_list in self.sprite_lists:
+            sprite_list._update_size(self)
+
+    def scale_multiply_uniform(self, factor: AsFloat) -> None:
+        """Scale the sprite uniformly by a factor.
+
+        This multiplies both the x and y scale values by the factor.
+
+        Args:
+            factor: The factor to scale up the sprite by.
+        """
+        self._scale = self._scale[0] * factor, self._scale[1] * factor
+        self._hit_box.scale = self._scale
+        tex_width, tex_height = self._texture.size
+        self._width = tex_width * factor
+        self._height = tex_height * factor
+
+        self.update_spatial_hash()
+        for sprite_list in self.sprite_lists:
+            sprite_list._update_size(self)
+
     def rescale_relative_to_point(self, point: Point2, scale_by: AsFloat | Point2) -> None:
         """Rescale the sprite and its distance from the passed point.
 
@@ -698,7 +706,7 @@ def rescale_xy_relative_to_point(self, point: Point, factors_xy: Iterable[float]
            Use :py:meth:`.rescale_relative_to_point` instead.
 
            This was added during the 3.0 development cycle before scale was
-           made into a vector quantitity.
+           made into a vector quantity.
 
         This method can scale by different amounts on each axis. To
         scale along only one axis, set the other axis to ``1.0`` in

From c46defd83390052f892072332d86963f9f94bf4a Mon Sep 17 00:00:00 2001
From: DragonMoffon <happydog.aja@gmail.com>
Date: Mon, 14 Oct 2024 22:47:39 +1300
Subject: [PATCH 11/15] arcade scale incorect type unit test

---
 tests/unit/sprite/test_sprite.py | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/tests/unit/sprite/test_sprite.py b/tests/unit/sprite/test_sprite.py
index 58fa1b7af1..56735e145e 100644
--- a/tests/unit/sprite/test_sprite.py
+++ b/tests/unit/sprite/test_sprite.py
@@ -237,6 +237,13 @@ def test_sprite_scale_resets_mismatched_xy_settings(window):
     assert sprite.width == 40
     assert sprite.height == 40
 
+def test_sprite_scale_invalid(window):
+    sprite = arcade.Sprite(SPRITE_TEXTURE_FEMALE_PERSON_IDLE)
+
+    with pytest.raises(ValueError):
+        sprite.scale = 1, 2, 3
+    with pytest.raises(TypeError):
+        sprite.scale = test_sprite_scale_invalid
 
 # TODO: Possibly separate into a movement module
 def test_strafe(window):

From cb4d1beb9cfaaecf4f8c58176fd1d4cd65b80d5f Mon Sep 17 00:00:00 2001
From: A J Andrews <happydog.aja@gmail.com>
Date: Tue, 15 Oct 2024 09:34:15 +1300
Subject: [PATCH 12/15] Update scale init argument, and delete floating
 simple.py file

---
 arcade/sprite/animated.py | 3 ++-
 arcade/sprite/simple.py   | 7 -------
 arcade/sprite/sprite.py   | 2 +-
 3 files changed, 3 insertions(+), 9 deletions(-)
 delete mode 100644 arcade/sprite/simple.py

diff --git a/arcade/sprite/animated.py b/arcade/sprite/animated.py
index f5a8ddff5a..5c8d543072 100644
--- a/arcade/sprite/animated.py
+++ b/arcade/sprite/animated.py
@@ -5,6 +5,7 @@
 import math
 
 from arcade import Texture
+from arcade.types import Point2
 
 from .enums import (
     FACE_DOWN,
@@ -236,7 +237,7 @@ class AnimatedWalkingSprite(Sprite):
 
     def __init__(
         self,
-        scale: float = 1.0,
+        scale: float | Point2 = 1.0,
         center_x: float = 0.0,
         center_y: float = 0.0,
         **kwargs,
diff --git a/arcade/sprite/simple.py b/arcade/sprite/simple.py
deleted file mode 100644
index 0d21a60979..0000000000
--- a/arcade/sprite/simple.py
+++ /dev/null
@@ -1,7 +0,0 @@
-# from .base import BasicSprite
-
-
-from __future__ import annotations
-
-# class SimpleSprite(BasicSprite):
-#     pass
diff --git a/arcade/sprite/sprite.py b/arcade/sprite/sprite.py
index 1a31ce6c0b..a8a69e2e0f 100644
--- a/arcade/sprite/sprite.py
+++ b/arcade/sprite/sprite.py
@@ -70,7 +70,7 @@ class Sprite(BasicSprite, PymunkMixin):
     def __init__(
         self,
         path_or_texture: PathOrTexture | None = None,
-        scale: float = 1.0,
+        scale: float = 1.0 | Point2,
         center_x: float = 0.0,
         center_y: float = 0.0,
         angle: float = 0.0,

From ab3e00a60cb434ebaf6c4b793d843c028b90cc70 Mon Sep 17 00:00:00 2001
From: A J Andrews <happydog.aja@gmail.com>
Date: Tue, 15 Oct 2024 09:55:56 +1300
Subject: [PATCH 13/15] Adding correct typing to Sprite and AnimatedSprite for
 scale, and adding validation unit tests

---
 arcade/sprite/base.py            |  2 +-
 arcade/sprite/sprite.py          |  2 +-
 tests/unit/sprite/test_sprite.py | 21 +++++++++++++++++++++
 3 files changed, 23 insertions(+), 2 deletions(-)

diff --git a/arcade/sprite/base.py b/arcade/sprite/base.py
index 898303cace..348175f1b0 100644
--- a/arcade/sprite/base.py
+++ b/arcade/sprite/base.py
@@ -65,7 +65,7 @@ def __init__(
         self._depth = 0.0
         self._texture = texture
         width, height = texture.size
-        self._scale = (scale, scale) if isinstance(scale, (float, int)) else scale
+        self._scale = (scale, scale) if isinstance(scale, (float, int)) else tuple(scale[:2])
         self._width = width * self._scale[0]
         self._height = height * self._scale[1]
         self._visible = bool(visible)
diff --git a/arcade/sprite/sprite.py b/arcade/sprite/sprite.py
index dc14353f64..59b9e6b1fc 100644
--- a/arcade/sprite/sprite.py
+++ b/arcade/sprite/sprite.py
@@ -70,7 +70,7 @@ class Sprite(BasicSprite, PymunkMixin):
     def __init__(
         self,
         path_or_texture: PathOrTexture | None = None,
-        scale: float = 1.0 | Point2,
+        scale: float | Point2 = 1.0,
         center_x: float = 0.0,
         center_y: float = 0.0,
         angle: float = 0.0,
diff --git a/tests/unit/sprite/test_sprite.py b/tests/unit/sprite/test_sprite.py
index 56735e145e..b7d7bee261 100644
--- a/tests/unit/sprite/test_sprite.py
+++ b/tests/unit/sprite/test_sprite.py
@@ -125,6 +125,27 @@ def test_sprite_rgb_property_basics():
     assert sprite.color.a == 15
     assert sprite.alpha == 15
 
+def test_sprite_scale_constructor(window):
+    sprite = arcade.BasicSprite(SPRITE_TEXTURE_GOLD_COIN, scale=2.0)
+    assert sprite.scale == (2.0, 2.0)
+    sprite = arcade.BasicSprite(SPRITE_TEXTURE_GOLD_COIN, scale=(2.0, 1.0))
+    assert sprite.scale == (2.0, 1.0)
+    sprite = arcade.Sprite(SPRITE_TEXTURE_GOLD_COIN, scale=3.0)
+    assert sprite.scale == (3.0, 3.0)
+    sprite = arcade.Sprite(SPRITE_TEXTURE_GOLD_COIN, scale=(3.0, 1.0))
+    assert sprite.scale == (3.0, 1.0)
+    sprite = arcade.Sprite(SPRITE_TEXTURE_GOLD_COIN, scale=(1.0, 2.0, 10.0, 100.0))
+    assert sprite.scale == (1.0, 2.0)
+    with pytest.raises(TypeError):
+        sprite = arcade.sprite(SPRITE_TEXTURE_GOLD_COIN, scale = test_sprite_scale_constructor)
+
+    sprite = arcade.Sprite(SPRITE_TEXTURE_GOLD_COIN, scale=1.0)
+    assert isinstance(sprite.scale, tuple)
+    sprite = arcade.Sprite(SPRITE_TEXTURE_GOLD_COIN, scale=(1.0, 1.0))
+    assert isinstance(sprite.scale, tuple)
+    sprite = arcade.Sprite(SPRITE_TEXTURE_GOLD_COIN, scale=[1.0, 1.0])
+    assert isinstance(sprite.scale, tuple)
+
 
 def test_sprite_scale_xy(window):
     sprite = arcade.SpriteSolidColor(20, 20, color=arcade.color.WHITE)

From e144435f22bec6060d3e9cbe41a24108e9554372 Mon Sep 17 00:00:00 2001
From: A J Andrews <happydog.aja@gmail.com>
Date: Tue, 15 Oct 2024 10:02:44 +1300
Subject: [PATCH 14/15] Sprite now makes input scale a 2 length tuple rather
 than arbitary in length

---
 arcade/sprite/base.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/arcade/sprite/base.py b/arcade/sprite/base.py
index 348175f1b0..59ef1b69bf 100644
--- a/arcade/sprite/base.py
+++ b/arcade/sprite/base.py
@@ -65,7 +65,7 @@ def __init__(
         self._depth = 0.0
         self._texture = texture
         width, height = texture.size
-        self._scale = (scale, scale) if isinstance(scale, (float, int)) else tuple(scale[:2])
+        self._scale = (scale, scale) if isinstance(scale, (float, int)) else (scale[0], scale[1])
         self._width = width * self._scale[0]
         self._height = height * self._scale[1]
         self._visible = bool(visible)

From 41fa91d10c54f9874ffaf9759c954e37d8ae9c5e Mon Sep 17 00:00:00 2001
From: A J Andrews <happydog.aja@gmail.com>
Date: Tue, 15 Oct 2024 10:26:00 +1300
Subject: [PATCH 15/15] removing sprite.simple from quick index generation

---
 util/update_quick_index.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/util/update_quick_index.py b/util/update_quick_index.py
index a3d9458952..ca35cd57d9 100644
--- a/util/update_quick_index.py
+++ b/util/update_quick_index.py
@@ -99,7 +99,6 @@
             "arcade.sprite",
             "arcade.sprite.base",
             "arcade.sprite.sprite",
-            "arcade.sprite.simple",
             "arcade.sprite.colored",
             "arcade.sprite.mixins",
             "arcade.sprite.animated",
