diff --git a/arcade/sprite/base.py b/arcade/sprite/base.py index 68e0d57b30..92cd2d42e8 100644 --- a/arcade/sprite/base.py +++ b/arcade/sprite/base.py @@ -1,9 +1,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Iterable, List, TypeVar, Any +from typing import TYPE_CHECKING, Iterable, List, TypeVar import arcade -from arcade.types import Point, Color, RGBA255, PointList +from arcade.types import Point, Color, RGBA255, PointList, RGB from arcade.color import BLACK from arcade.hitbox import HitBox from arcade.texture import Texture @@ -49,7 +49,7 @@ def __init__( scale: float = 1.0, center_x: float = 0, center_y: float = 0, - **kwargs: Any, + **kwargs, ) -> None: self._position = (center_x, center_y) self._depth = 0.0 @@ -58,6 +58,7 @@ def __init__( self._height = texture.height * scale self._scale = scale, scale self._color: Color = Color(255, 255, 255, 255) + self._rgb: RGB = RGB(255, 255, 255) self.sprite_lists: List["SpriteList"] = [] # Core properties we don't use, but spritelist expects it @@ -319,6 +320,25 @@ def visible(self, value: bool): for sprite_list in self.sprite_lists: sprite_list._update_color(self) + @property + def rgb(self) -> RGB: + """ Gets the sprites RGB. """ + return self._rgb + + @rgb.setter + def rgb(self, color: RGB): + if ( + self._rgb[0] == color[0] + and self._rgb[1] == color[1] + and self._rgb[2] == color[2] + ): + return + self._rgb = RGB(color[0], color[1], color[2]) + self._color = Color(self._rgb[0], self._rgb[1], self._rgb[2], self.alpha) + + for sprite_list in self.sprite_lists: + sprite_list._update_color(self) + @property def color(self) -> Color: """ @@ -342,28 +362,31 @@ def color(self) -> Color: >>> sprite.color = 255, 0, 0, 128 """ - return self._color + return Color(self.rgb[0], self.rgb[1], self.rgb[2], self.alpha) @color.setter def color(self, color: RGBA255): - if len(color) == 4: + if len(color) == 3 or isinstance(color, Color): if ( - self._color[0] == color[0] - and self._color[1] == color[1] - and self._color[2] == color[2] - and self._color[3] == color[3] + self._rgb[0] == color[0] + and self._rgb[1] == color[1] + and self._rgb[2] == color[2] ): return - self._color = Color.from_iterable(color) + self._rgb = RGB(color[0], color[1], color[2]) + self._color = Color(self._rgb[0], self._rgb[1], self._rgb[2], self.alpha) - elif len(color) == 3: + elif len(color) == 4: if ( - self._color[0] == color[0] - and self._color[1] == color[1] - and self._color[2] == color[2] + self._rgb[0] == color[0] + and self._rgb[1] == color[1] + and self._rgb[2] == color[2] + and self._rgb[3] == color[3] ): return - self._color = Color(color[0], color[1], color[2], self._color[3]) + self._rgb = color + self._color = Color.from_iterable(color) + else: raise ValueError("Color must be three or four ints from 0-255") diff --git a/arcade/types.py b/arcade/types.py index f904c5b2ff..5315600059 100644 --- a/arcade/types.py +++ b/arcade/types.py @@ -5,7 +5,6 @@ from __future__ import annotations -import sys from array import array import ctypes import random @@ -20,8 +19,7 @@ Sequence, Tuple, Union, - TYPE_CHECKING, - TypeVar + TYPE_CHECKING, TypeVar ) from typing_extensions import Self @@ -75,6 +73,92 @@ ] +class RGB(Tuple): + """ + A :py:class:`tuple` subclass representing an RGB color. + + All channels are byte values from 0 to 255, inclusive. If any are + outside this range, a :py:class:`~arcade.utils.ByteRangeError` will + be raised, which can be handled as a :py:class:`ValueError`. + + :param r: the red channel of the color, between 0 and 255 + :param g: the green channel of the color, between 0 and 255 + :param b: the blue channel of the color, between 0 and 255 + """ + + def __new__(cls, r: int, g: int, b: int): + + if not 0 <= r <= 255: + raise ByteRangeError("r", r) + + if not 0 <= g <= 255: + raise ByteRangeError("g", g) + + if not 0 <= g <= 255: + raise ByteRangeError("b", b) + + # Typechecking is ignored because of a mypy bug involving + # tuples & super: + # https://github.com/python/mypy/issues/8541 + return super().__new__(cls, (r, g, b)) # type: ignore + + def __deepcopy__(self, _) -> Self: + """Allow :py:func:`~copy.deepcopy` to be used with Color""" + return self.__class__(r=self.r, g=self.g, b=self.b) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(r={self.r}, g={self.g}, b={self.b})" + + @property + def r(self) -> int: + return self[0] + + @property + def g(self) -> int: + return self[1] + + @property + def b(self) -> int: + return self[2] + + @classmethod + def random( + cls, + r: Optional[int] = None, + g: Optional[int] = None, + b: Optional[int] = None, + ) -> Self: + """ + Return a random color. + + The parameters are optional and can be used to fix the value of + a particular channel. If a channel is not fixed, it will be + randomly generated. + + Examples:: + + # Randomize all channels + >>> Color.random() + Color(r=35, g=145, b=4, a=200) + + # Random color with fixed alpha + >>> Color.random(a=255) + Color(r=25, g=99, b=234, a=255) + + :param r: Fixed value for red channel + :param g: Fixed value for green channel + :param b: Fixed value for blue channel + """ + if r is None: + r = random.randint(0, 255) + if g is None: + g = random.randint(0, 255) + if b is None: + b = random.randint(0, 255) + + return cls(r, g, b) + + class Color(RGBA255): """ A :py:class:`tuple` subclass representing an RGBA Color. @@ -450,11 +534,11 @@ class TiledObject(NamedTuple): type: Optional[str] = None -if sys.version_info >= (3, 12): - from collections.abc import Buffer as BufferProtocol -else: - # This is used instead of the typing_extensions version since they - # use an ABC which registers virtual subclasses. This will not work - # with ctypes.Array since virtual subclasses must be concrete. - # See: https://peps.python.org/pep-0688/ - BufferProtocol = Union[ByteString, memoryview, array, ctypes.Array] +# This is a temporary workaround for the lack of a way to type annotate +# objects implementing the buffer protocol. Although there is a PEP to +# add typing, it is scheduled for 3.12. Since that is years away from +# being our minimum Python version, we have to use a workaround. See +# the PEP and Python doc for more information: +# https://peps.python.org/pep-0688/ +# https://docs.python.org/3/c-api/buffer.html +BufferProtocol = Union[ByteString, memoryview, array, ctypes.Array]