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)
@abstractmethod
def draw(self, screen) -> None:
def draw(self) -> None:
"""
All doodles need to be drawable, but there is no
way we can provide an implementation without
@ -231,7 +231,7 @@ class Group(Doodle):
def __repr__(self):
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.
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
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
hello_world = [
"Hello, World!", # English

View File

@ -1,8 +1,8 @@
import math
import random
import pygame
from typing import Callable
from .doodles import Doodle
from .world import world
class Line(Doodle):
@ -22,7 +22,7 @@ class Line(Doodle):
def __repr__(self):
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.
@ -41,7 +41,7 @@ class Line(Doodle):
to the class and gaining flexibility from separating
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":
"""

View File

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

View File

@ -1,85 +1,50 @@
import pygame
from .doodles import Doodle
from .world import world
# TOOD: make configurable
DEFAULT_FONT_SIZE = 24
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):
"""
Text will be centered at `pos`
"""
super().__init__(parent)
self._text = ""
self._rendered = None
self._rendered = None # the surface we pre-render the text to
self._font = None
def __repr__(self):
return f"Text(pos={self.pos_vec}, text={self._text}, parent={self._parent})"
def draw(self, screen):
text_rect = self._rendered.get_rect(center=self.world_vec)
screen.blit(self._rendered, text_rect)
def draw(self):
world.draw_engine.text_draw(self)
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._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
# doing this in draw would be much slower since it is called
# much more often than the text changes
if not self._font:
self._font = self.get_font() # default font
self._rendered = self._font.render(self._text, True, self._color)
return self
self._font = world.draw_engine.get_font() # default font
self._rendered = world.draw_engine.text_render(
self._text, self._font, self._color
)
def font(self, font: str) -> "Doodle":
# TODO: error checking
self._font = self._fonts[font]
self._font = world.draw_engine.get_font(font)
return self

View File

@ -1,5 +1,100 @@
from .color import Color
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:
@ -48,6 +143,7 @@ class World:
self._drawables = []
self.background_color = Color.WHITE
self.screen = None
self.draw_engine = PygameDrawEngine()
def init(self):
"""
@ -57,10 +153,9 @@ class World:
if self.screen:
raise ValueError("Can't initialize world twice!")
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._elapsed = 0
self.draw_engine.init()
def clear(self):
self._drawables = []
@ -83,11 +178,8 @@ class World:
self.tick()
# rendering
self.buffer.fill((*self.background_color, 255))
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()
self.draw_engine.render(self.background_color, self._drawables)
# our singleton instance