Merge pull request #1 from jamesturk/pygame-encapsulation

pygame encapsulation
This commit is contained in:
James Turk 2024-04-23 22:23:10 -05:00 committed by GitHub
commit a403490df0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 159 additions and 86 deletions

View File

@ -46,7 +46,7 @@ class Doodle(ABC):
world.add(self) world.add(self)
@abstractmethod @abstractmethod
def draw(self, screen) -> None: def draw(self) -> None:
""" """
All doodles need to be drawable, but there is no All doodles need to be drawable, but there is no
way we can provide an implementation without way we can provide an implementation without
@ -231,7 +231,7 @@ class Group(Doodle):
def __repr__(self): def __repr__(self):
return f"Group(pos={self.world_vec}, doodles={len(self._doodles)})" return f"Group(pos={self.world_vec}, doodles={len(self._doodles)})"
def draw(self, screen): def draw(self):
""" """
Groups, despite being an abstract concept, are drawable. Groups, despite being an abstract concept, are drawable.
To draw a group is to draw everything in it. To draw a group is to draw everything in it.

View File

@ -0,0 +1,23 @@
import abc
class DrawEngine(abc.ABC):
@abc.abstractmethod
def init(self):
pass
@abc.abstractmethod
def render(self, background_color: "Color", drawables: list["Doodle"]):
pass
@abc.abstractmethod
def circle_draw(self, screen):
pass
@abc.abstractmethod
def rect_draw(self, screen):
pass
@abc.abstractmethod
def line_draw(self, screen):
pass

View File

@ -2,12 +2,6 @@ import random
import itertools import itertools
from doodles import Group, Circle, Color, Text from doodles import Group, Circle, Color, Text
# TODO: depending on system these fonts often do not have all the
# necessary characters, find 3 widely available fonts that do
Text.make_font("small", 16, "mono")
Text.make_font("medium", 24, "copperplate")
Text.make_font("large", 48, "papyrus")
# Via ChatGPT # Via ChatGPT
hello_world = [ hello_world = [
"Hello, World!", # English "Hello, World!", # English

View File

@ -1,8 +1,8 @@
import math import math
import random import random
import pygame
from typing import Callable from typing import Callable
from .doodles import Doodle from .doodles import Doodle
from .world import world
class Line(Doodle): class Line(Doodle):
@ -22,7 +22,7 @@ class Line(Doodle):
def __repr__(self): def __repr__(self):
return f"Line(pos={self.world_vec}, end={self.end_vec}, {self._color})" return f"Line(pos={self.world_vec}, end={self.end_vec}, {self._color})"
def draw(self, screen): def draw(self):
""" """
Implementation of the abstract draw function for the line. Implementation of the abstract draw function for the line.
@ -41,7 +41,7 @@ class Line(Doodle):
to the class and gaining flexibility from separating to the class and gaining flexibility from separating
presentation logic from data manipulation. presentation logic from data manipulation.
""" """
pygame.draw.aaline(screen, self._color, self.world_vec, self.end_vec) world.draw_engine.line_draw(self)
def to(self, x: float, y: float) -> "Doodle": def to(self, x: float, y: float) -> "Doodle":
""" """

View File

@ -1,6 +1,6 @@
import random import random
import pygame
from .doodles import Doodle from .doodles import Doodle
from .world import world
class Circle(Doodle): class Circle(Doodle):
@ -15,8 +15,9 @@ class Circle(Doodle):
def __repr__(self): def __repr__(self):
return f"Circle(pos={self.world_vec}, radius={self._radius}, {self._color}, parent={self._parent}))" return f"Circle(pos={self.world_vec}, radius={self._radius}, {self._color}, parent={self._parent}))"
def draw(self, screen): def draw(self):
pygame.draw.circle(screen, self.rgba, self.world_vec, self._radius) # TODO: do we need to override draw? can we move this to Doodle.draw
world.draw_engine.circle_draw(self)
def radius(self, r: float) -> "Doodle": def radius(self, r: float) -> "Doodle":
""" """
@ -25,6 +26,10 @@ class Circle(Doodle):
self._radius = r self._radius = r
return self return self
@property
def radius_val(self) -> float:
return self._radius
def grow(self, by: float): def grow(self, by: float):
""" """
Modify radius by an amount. (Negative to shrink.) Modify radius by an amount. (Negative to shrink.)
@ -50,14 +55,8 @@ class Rectangle(Doodle):
def __repr__(self): def __repr__(self):
return f"Rect(pos={self.world_vec}, width={self._width}, height={self._height}, parent={self._parent})" return f"Rect(pos={self.world_vec}, width={self._width}, height={self._height}, parent={self._parent})"
def draw(self, screen): def draw(self):
rect = pygame.Rect( world.draw_engine.rect_draw(self)
self.world_x - self._width / 2,
self.world_y - self._height / 2,
self._width,
self._height,
)
pygame.draw.rect(screen, self._color, rect)
def width(self, w: float) -> "Doodle": def width(self, w: float) -> "Doodle":
""" """

View File

@ -1,85 +1,50 @@
import pygame import pygame
from .doodles import Doodle from .doodles import Doodle
from .world import world
# TOOD: make configurable
DEFAULT_FONT_SIZE = 24
class Text(Doodle): class Text(Doodle):
# Having each bit of text on the screen load a separate copy
# of its font would be wasteful, since the most common case would
# be for most text to use the same font.
#
# The solution here is to use a class attribute, shared by *all* instances
# of the class.
#
# This is an implementation of the Flyweight design pattern, which
# allows multiple objects to share some state.
#
# This can quickly become a mess if the shared state is mutable,
# note that here, once a font is loaded it does not change.
# This avoids nearly all pitfalls associated with this approach.
_fonts: dict[str, pygame.font.Font] = {}
# this method is attached to the class `Text`, not individual instances
# like normal methods (which take self as their implicit parameter)
@classmethod
def make_font(cls, name, size, font=None, bold=False, italic=False):
"""
The way fonts work in most graphics libraries requires choosing a font
size, as well as any variation (bold, italic) at the time of creation.
It would be nice if we could allow individual Text objects vary these,
but doing so would be much more complex or require significantly more
memory.
"""
if font is None:
font = pygame.font.Font(None, size)
else:
path = pygame.font.match_font(font, bold=bold, italic=italic)
font = pygame.font.Font(path, size)
cls._fonts[name] = font
@classmethod
def get_font(cls, name=None):
if not name:
# None -> default font
# load on demand
if None not in cls._fonts:
cls._fonts[None] = pygame.font.Font(None, DEFAULT_FONT_SIZE)
return cls._fonts[None]
else:
return cls._fonts[name]
def __init__(self, parent=None): def __init__(self, parent=None):
""" """
Text will be centered at `pos` Text will be centered at `pos`
""" """
super().__init__(parent) super().__init__(parent)
self._text = "" self._text = ""
self._rendered = None self._rendered = None # the surface we pre-render the text to
self._font = None self._font = None
def __repr__(self): def __repr__(self):
return f"Text(pos={self.pos_vec}, text={self._text}, parent={self._parent})" return f"Text(pos={self.pos_vec}, text={self._text}, parent={self._parent})"
def draw(self, screen): def draw(self):
text_rect = self._rendered.get_rect(center=self.world_vec) world.draw_engine.text_draw(self)
screen.blit(self._rendered, text_rect)
def text(self, text: str) -> "Doodle": def text(self, text: str) -> "Doodle":
""" """
A setter for the text A setter for the text.
This is the only place text can change,
so we can pre-render the surface here.
""" """
self._text = text self._text = text
self._render()
return self
def _render(self):
"""
This function needs to set the _rendered property.
_rendered may be relied upon by draw_engine.draw_text.
"""
# text needs to be rendered once on change to be performant # text needs to be rendered once on change to be performant
# doing this in draw would be much slower since it is called # doing this in draw would be much slower since it is called
# much more often than the text changes # much more often than the text changes
if not self._font: if not self._font:
self._font = self.get_font() # default font self._font = world.draw_engine.get_font() # default font
self._rendered = self._font.render(self._text, True, self._color) self._rendered = world.draw_engine.text_render(
return self self._text, self._font, self._color
)
def font(self, font: str) -> "Doodle": def font(self, font: str) -> "Doodle":
# TODO: error checking self._font = world.draw_engine.get_font(font)
self._font = self._fonts[font]
return self return self

View File

@ -1,5 +1,100 @@
from .color import Color from .color import Color
import pygame import pygame
# TODO: fix this with a dynamic load
from .draw_engine import DrawEngine
# TODO: make configurable
DEFAULT_FONT_SIZE = 24
class PygameDrawEngine(DrawEngine):
# Having each bit of text on the screen load a separate copy
# of its font would be wasteful, since the most common case would
# be for most text to use the same font.
#
# The solution here is to use a class attribute, shared by *all* instances
# of the class.
#
# This is an implementation of the Flyweight design pattern, which
# allows multiple objects to share some state.
#
# This can quickly become a mess if the shared state is mutable,
# note that here, once a font is loaded it does not change.
# This avoids nearly all pitfalls associated with this approach.
_fonts: dict[str, pygame.font.Font] = {}
# this method is attached to the class `Text`, not individual instances
# like normal methods (which take self as their implicit parameter)
@classmethod
def make_font(cls, name, size, font=None, bold=False, italic=False):
"""
The way fonts work in most graphics libraries requires choosing a font
size, as well as any variation (bold, italic) at the time of creation.
It would be nice if we could allow individual Text objects vary these,
but doing so would be much more complex or require significantly more
memory.
"""
if font is None:
font = pygame.font.Font(None, size)
else:
path = pygame.font.match_font(font, bold=bold, italic=italic)
font = pygame.font.Font(path, size)
cls._fonts[name] = font
@classmethod
def get_font(cls, name=None):
if not name:
# None -> default font
# load on demand
if None not in cls._fonts:
cls._fonts[None] = pygame.font.Font(None, DEFAULT_FONT_SIZE)
return cls._fonts[None]
else:
return cls._fonts[name]
def init(self):
self.screen = pygame.display.set_mode((world.WIDTH, world.HEIGHT))
self.buffer = pygame.Surface((world.WIDTH, world.HEIGHT), pygame.SRCALPHA)
# TODO: depending on system these fonts often do not have all the
# necessary characters, find 3 widely available fonts that do
world.draw_engine.make_font("small", 16, "mono")
world.draw_engine.make_font("medium", 24, "copperplate")
world.draw_engine.make_font("large", 48, "papyrus")
def render(self, background_color: Color, drawables: list["Doodle"]):
self.buffer.fill((*background_color, 255))
for d in sorted(drawables, key=lambda d: d._z_index):
d.draw()
self.screen.blit(self.buffer, (0, 0))
pygame.display.flip()
def circle_draw(self, c: "Circle"):
pygame.draw.circle(self.buffer, c.rgba, c.world_vec, c.radius_val)
def rect_draw(self, r: "Rectangle"):
# TODO: make accessors
rect = pygame.Rect(
r.world_x - r._width / 2,
r.world_y - r._height / 2,
r._width,
r._height,
)
pygame.draw.rect(self.buffer, r.rgba, rect)
def line_draw(self, ll: "Line"):
pygame.draw.aaline(self.buffer, ll.rgba, ll.world_vec, ll.end_vec)
def text_render(self, text: str, font: str, color: Color) -> "TODO":
""" returns an intermediated RenderedText """
# TODO: add accessor text_val
return font.render(text, True, color)
def text_draw(self, txt: "Text"):
# this is a tight coupling, intentionally left
text_rect = txt._rendered.get_rect(center=txt.world_vec)
self.buffer.blit(txt._rendered, text_rect)
class World: class World:
@ -48,6 +143,7 @@ class World:
self._drawables = [] self._drawables = []
self.background_color = Color.WHITE self.background_color = Color.WHITE
self.screen = None self.screen = None
self.draw_engine = PygameDrawEngine()
def init(self): def init(self):
""" """
@ -57,10 +153,9 @@ class World:
if self.screen: if self.screen:
raise ValueError("Can't initialize world twice!") raise ValueError("Can't initialize world twice!")
pygame.init() pygame.init()
self.screen = pygame.display.set_mode((world.WIDTH, world.HEIGHT))
self.buffer = pygame.Surface((world.WIDTH, world.HEIGHT), pygame.SRCALPHA)
self.clock = pygame.time.Clock() self.clock = pygame.time.Clock()
self._elapsed = 0 self._elapsed = 0
self.draw_engine.init()
def clear(self): def clear(self):
self._drawables = [] self._drawables = []
@ -83,11 +178,8 @@ class World:
self.tick() self.tick()
# rendering # rendering
self.buffer.fill((*self.background_color, 255)) self.draw_engine.render(self.background_color, self._drawables)
for d in sorted(self._drawables, key=lambda d: d._z_index):
d.draw(self.buffer)
self.screen.blit(self.buffer, (0, 0))
pygame.display.flip()
# our singleton instance # our singleton instance