Merge pull request #1 from jamesturk/pygame-encapsulation
pygame encapsulation
This commit is contained in:
commit
a403490df0
@ -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.
|
||||||
|
23
src/doodles/draw_engine.py
Normal file
23
src/doodles/draw_engine.py
Normal 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
|
@ -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
|
||||||
|
@ -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":
|
||||||
"""
|
"""
|
||||||
|
@ -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":
|
||||||
"""
|
"""
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user