Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Refactor colors #718

Draft
wants to merge 17 commits into
base: canon
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/reference/scenes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ transition at any time.

.. autoattribute:: background_color

An RGB triple of the background, eg ``(0, 127, 255)``
The background color of the scene, e.g. ``ppb.RGBColor(0, 127, 255)``

.. autoattribute:: main_camera

Expand Down
20 changes: 10 additions & 10 deletions examples/rectangular_assets/main.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import ppb


tall_rectangle = ppb.Rectangle(200, 0, 0, (1, 2))
wide_rectangle = ppb.Rectangle(100, 200, 0, (2, 1))
square = ppb.Square(200, 200, 100)
tall_triangle = ppb.Triangle(0, 200, 0, (1, 2))
wide_triangle = ppb.Triangle(0, 200, 100, (2, 1))
square_triangle = ppb.Triangle(50, 200, 150)
tall_ellipse = ppb.Ellipse(0, 0, 200, (1, 2))
wide_ellipse = ppb.Ellipse(100, 0, 200, (2, 1))
circle = ppb.Circle(150, 50, 200)
tall_rectangle = ppb.Rectangle(ppb.RGBColor(200, 0, 0), (1, 2))
wide_rectangle = ppb.Rectangle(ppb.RGBColor(100, 200, 0), (2, 1))
square = ppb.Square(ppb.RGBColor(200, 200, 100))
tall_triangle = ppb.Triangle(ppb.RGBColor(0, 200, 0), (1, 2))
wide_triangle = ppb.Triangle(ppb.RGBColor(0, 200, 100), (2, 1))
square_triangle = ppb.Triangle(ppb.RGBColor(50, 200, 150))
tall_ellipse = ppb.Ellipse(ppb.RGBColor(0, 0, 200), (1, 2))
wide_ellipse = ppb.Ellipse(ppb.RGBColor(100, 0, 200), (2, 1))
circle = ppb.Circle(ppb.RGBColor(150, 50, 200))


def setup(scene):
scene.background_color = (0, 0, 0)
scene.background_color = ppb.RGBColor(0, 0, 0)
scene.add(ppb.RectangleSprite(width=0.5, height=1, image=tall_rectangle, position=(-2, 2)))
scene.add(ppb.RectangleSprite(width=1, height=0.5, image=wide_rectangle, position=(0, 2)))
scene.add(ppb.Sprite(size=1, image=square, position=(2, 2)))
Expand Down
6 changes: 3 additions & 3 deletions examples/two-phase-updates/three_body.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ def on_update(self, event, signal):


def setup(scene):
scene.add(Planet(position=(3, 0), velocity=Vector(0, 1), image=ppb.Circle(40, 200, 150)))
scene.add(Planet(position=(-3, 3), velocity=Vector(1, -1), image=ppb.Circle(200, 150, 40)))
scene.add(Planet(position=(-3, -3), velocity=Vector(-1, 0), image=ppb.Circle(150, 40, 200)))
scene.add(Planet(position=(3, 0), velocity=Vector(0, 1), image=ppb.Circle(ppb.RGBColor(40, 200, 150))))
scene.add(Planet(position=(-3, 3), velocity=Vector(1, -1), image=ppb.Circle(ppb.RGBColor(200, 150, 40))))
scene.add(Planet(position=(-3, -3), velocity=Vector(-1, 0), image=ppb.Circle(ppb.RGBColor(150, 40, 200))))


if __name__ == "__main__":
Expand Down
4 changes: 3 additions & 1 deletion ppb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
* :mod:`buttons`
* :mod:`keycodes`
* :mod:`flags`
* :mod:`colors`
* :mod:`directions`
* :class:`Signal`
"""
Expand All @@ -47,6 +48,7 @@
from ppb.assets import Rectangle
from ppb.assets import Square
from ppb.assets import Triangle
from ppb.colors import Color, RGBColor, HSVColor
from ppb.engine import GameEngine
from ppb.engine import Signal
from ppb.scenes import Scene
Expand All @@ -62,7 +64,7 @@
# Shortcuts
'Scene', 'Sprite', 'RectangleSprite', 'Vector',
'Image', 'Circle', 'Ellipse', 'Square', 'Rectangle', 'Triangle',
'Font', 'Text', 'Sound',
'Color', 'RGBColor', 'HSVColor', 'Font', 'Text', 'Sound',
'events', 'buttons', 'keycodes', 'flags', 'directions', 'Signal',
# Local stuff
'run', 'make_engine',
Expand Down
33 changes: 16 additions & 17 deletions ppb/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
)

from ppb.assetlib import BackgroundMixin, FreeingMixin, AbstractAsset
from ppb.colors import Color, BLACK, MAGENTA
from ppb.systems.sdl_utils import sdl_call

__all__ = (
Expand All @@ -31,8 +32,6 @@
"Ellipse"
)

BLACK = 0, 0, 0
MAGENTA = 255, 71, 182
DEFAULT_SPRITE_SIZE = 64


Expand All @@ -41,7 +40,7 @@ class AspectRatio(NamedTuple):
height: Union[int, float]


def _create_surface(color, aspect_ratio: AspectRatio = AspectRatio(1, 1)):
def _create_surface(color: Color, aspect_ratio: AspectRatio = AspectRatio(1, 1)):
"""
Creates a surface for assets and sets the color key.
"""
Expand All @@ -58,7 +57,7 @@ def _create_surface(color, aspect_ratio: AspectRatio = AspectRatio(1, 1)):
_check_error=lambda rv: not rv
)
color_key = BLACK if color != BLACK else MAGENTA
color = sdl2.ext.Color(*color_key)
color = sdl2.ext.Color(*color_key.to_rgb())
sdl_call(
SDL_SetColorKey, surface, True, sdl2.ext.prepare_color(color, surface.contents),
_check_error=lambda rv: rv < 0
Expand All @@ -72,8 +71,8 @@ def _create_surface(color, aspect_ratio: AspectRatio = AspectRatio(1, 1)):

class Shape(BackgroundMixin, FreeingMixin, AbstractAsset):
"""Shapes are drawing primitives that are good for rapid prototyping."""
def __init__(self, red: int, green: int, blue: int, aspect_ratio: aspect_ratio_type = AspectRatio(1, 1)):
self.color = red, green, blue
def __init__(self, color: Color, aspect_ratio: aspect_ratio_type = AspectRatio(1, 1)):
self.color = color
self.aspect_ratio = AspectRatio(*aspect_ratio)
self._start()

Expand All @@ -85,7 +84,7 @@ def _background(self):
_check_error=lambda rv: not rv
)
try:
self._draw_shape(renderer, rgb=self.color)
self._draw_shape(renderer, color=self.color)
finally:
sdl_call(SDL_DestroyRenderer, renderer)
return surface
Expand All @@ -104,9 +103,9 @@ class Rectangle(Shape):
A rectangle image of a single color.
"""

def _draw_shape(self, renderer, rgb, **_):
def _draw_shape(self, renderer, color: Color, **_):
sdl_call(
SDL_SetRenderDrawColor, renderer, *(int(c) for c in rgb), 255,
SDL_SetRenderDrawColor, renderer, *color.to_rgb(), 255,
_check_error=lambda rv: rv < 0
)
sdl_call(
Expand All @@ -120,17 +119,17 @@ class Square(Rectangle):
A constructor for :class:`~ppb.Rectangle` that produces a square image.
"""

def __init__(self, r, g, b):
def __init__(self, color: Color):
# This cuts out the aspect_ratio parameter
super().__init__(r, g, b)
super().__init__(color)


class Triangle(Shape):
"""
A triangle image of a single color.
"""

def _draw_shape(self, renderer, rgb, **_):
def _draw_shape(self, renderer, color: Color, **_):
w, h = c_int(), c_int()
sdl_call(SDL_GetRendererOutputSize, renderer, byref(w), byref(h))
width, height = w.value, h.value
Expand All @@ -140,7 +139,7 @@ def _draw_shape(self, renderer, rgb, **_):
0, height,
int(width / 2), 0,
width, height,
*rgb, 255,
*color.to_rgb(), 255,
_check_error=lambda rv: rv < 0
)

Expand All @@ -150,7 +149,7 @@ class Ellipse(Shape):
An ellipse image of a single color.
"""

def _draw_shape(self, renderer, rgb, **_):
def _draw_shape(self, renderer, color: Color, **_):
w, h = c_int(), c_int()
sdl_call(SDL_GetRendererOutputSize, renderer, byref(w), byref(h))
half_width, half_height = int(w.value / 2), int(h.value / 2)
Expand All @@ -159,14 +158,14 @@ def _draw_shape(self, renderer, rgb, **_):
filledEllipseRGBA, renderer,
half_width, half_height, # Center
half_width, half_height, # Radius
*rgb, 255,
*color.to_rgb(), 255,
_check_error=lambda rv: rv < 0
)


class Circle(Ellipse):
"""A convenience constructor for :class:`~ppb.Ellipse` that is a perfect circle."""

def __init__(self, r, g, b):
def __init__(self, color: Color):
# This cuts out the aspect_ratio parameter
super().__init__(r, g, b)
super().__init__(color)
68 changes: 68 additions & 0 deletions ppb/colors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from dataclasses import dataclass
from abc import ABC, abstractmethod
import colorsys

class Color(ABC):
"""Abstract base class for a color."""
@abstractmethod
def to_rgb(self):
"""Convert to a tuple of red, green, and blue values."""
pass


@dataclass(frozen=True)
class RGBColor(Color):
"""An RGB color, with red, green, and blue values ranging from 0 to 255."""
red: int
green: int
blue: int

def __post_init__(self):
for key, value in {'red': self.red, 'green': self.green, 'blue': self.blue}.items():
if value < 0:
raise ValueError(f'{key} cannot be less than 0.')
elif value > 255:
raise ValueError(f'{key} cannot be greater than 255.')

def __iter__(self):
return (self.red, self.green, self.blue)

def to_rgb(self):
return (self.red, self.green, self.blue)


@dataclass(frozen=True)
class HSVColor(Color):
"""
An HSV color, with hue ranging from 0 to 360,
saturation ranging from 0 to 100, and value ranging from 0 to 100.
"""
hue: float
saturation: float
value: float

def __post_init__(self):
for key, (value, max_value) in {
'hue': (self.hue, 360),
'saturation': (self.saturation, 100),
'value': (self.value, 100),
}.items():
if value < 0:
raise ValueError(f'{key} cannot be less than 0.')
elif value > max_value:
raise ValueError(f'{key} cannot be greater than {max_value}.')

def to_rgb(self):
red, green, blue = colorsys.hsv_to_rgb(self.hue / 360, self.saturation / 100, self.value / 100)
return (round(red * 255), round(green * 255), round(blue * 255))


BLACK = RGBColor(0, 0, 0)
WHITE = RGBColor(255, 255, 255)
GRAY = RGBColor(127, 127, 127)
RED = RGBColor(255, 0, 0)
GREEN = RGBColor(0, 255, 0)
BLUE = RGBColor(0, 0, 255)
CYAN = RGBColor(0, 255, 255)
MAGENTA = RGBColor(255, 0, 255)
YELLOW = RGBColor(255, 255, 0)
3 changes: 2 additions & 1 deletion ppb/scenes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
from typing import Sequence

from ppb.camera import Camera
from ppb.colors import Color
from ppb.gomlib import GameObject


class Scene(GameObject):
# Background color, in RGB, each channel is 0-255
background_color: Sequence[int] = (0, 0, 100)
background_color: Color
camera_class = Camera
show_cursor = True

Expand Down
4 changes: 2 additions & 2 deletions ppb/systems/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,9 @@ def on_render(self, render_event, signal):
sdl_call(SDL_RenderPresent, self.renderer)

def render_background(self, scene):
bg = scene.background_color
background_color = scene.background_color
sdl_call(
SDL_SetRenderDrawColor, self.renderer, int(bg[0]), int(bg[1]), int(bg[2]), 255,
SDL_SetRenderDrawColor, self.renderer, *background_color.to_rgb(), 255,
_check_error=lambda rv: rv < 0
)
sdl_call(SDL_RenderClear, self.renderer, _check_error=lambda rv: rv < 0)
Expand Down
9 changes: 5 additions & 4 deletions ppb/systems/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

from ppb.assetlib import Asset, ChainingMixin, AbstractAsset, FreeingMixin
from ppb.systems.sdl_utils import ttf_call
from ppb.colors import Color, RGBColor

# From https://www.freetype.org/freetype2/docs/reference/ft2-base_interface.html:
# [Since 2.5.6] In multi-threaded applications it is easiest to use one
Expand Down Expand Up @@ -109,11 +110,11 @@ class Text(ChainingMixin, FreeingMixin, AbstractAsset):
"""
A bit of rendered text.
"""
def __init__(self, txt, *, font, color=(0, 0, 0)):
def __init__(self, txt, *, font, color=RGBColor(0, 0, 0)):
"""
:param txt: The text to display.
:param font: The font to use (a :py:class:`ppb.Font`)
:param color: The color to use.
:param font: The font to use (a :py:class:`ppb.Font`).
:param color: The color to use (a :py:class:`ppb.Color`).
"""
self.txt = txt
self.font = font
Expand All @@ -128,7 +129,7 @@ def _background(self):
with _freetype_lock:
return ttf_call(
TTF_RenderUTF8_Blended, self.font.load(), self.txt.encode('utf-8'),
SDL_Color(*self.color),
SDL_Color(*self.color.to_rgb()),
_check_error=lambda rv: not rv
)

Expand Down
47 changes: 47 additions & 0 deletions tests/test_colors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import pytest
from ppb import Color, RGBColor, HSVColor
from contextlib import nullcontext as does_not_raise

@pytest.mark.parametrize(
['red', 'green', 'blue', 'should_raise'],
[
[0, 0, 0, False],
[255, 255, 255, False],
[-1, 0, 0, True],
[256, 0, 0, True],
[0, -1, 0, True],
[0, 256, 0, True],
[0, 0, -1, True],
[0, 0, 256, True],
]
)
def test_rgb_color_validation(red, green, blue, should_raise):
with pytest.raises(ValueError) if should_raise else does_not_raise():
RGBColor(red, green, blue)

@pytest.mark.parametrize(
['hue', 'saturation', 'value', 'should_raise'],
[
[0, 0, 0, False],
[360, 100, 100, False],
[-1, 0, 0, True],
[361, 0, 0, True],
[0, -1, 0, True],
[0, 101, 0, True],
[0, 0, -1, True],
[0, 0, 101, True],
]
)
def test_hsv_color_validation(hue, saturation, value, should_raise):
with pytest.raises(ValueError) if should_raise else does_not_raise():
HSVColor(hue, saturation, value)

@pytest.mark.parametrize(
['color', 'red', 'green', 'blue'],
[
[RGBColor(50, 40, 30), 50, 40, 30],
[HSVColor(259, 46, 54.5), 95, 75, 139],
]
)
def test_to_rgb(color, red, green, blue):
assert color.to_rgb() == (red, green, blue)
Loading