From 37343739e859f9ec3963faa59fe3dcfb91f97710 Mon Sep 17 00:00:00 2001 From: James Turk Date: Tue, 23 Apr 2024 21:26:34 -0500 Subject: [PATCH 1/3] most things working, not text, draw_engine interface still fluid --- src/doodles/doodles.py | 4 ++-- src/doodles/draw_engine.py | 17 +++++++++++++++++ src/doodles/lines.py | 5 +++-- src/doodles/shapes.py | 20 ++++++++++---------- src/doodles/world.py | 24 +++++++++++++++++++++++- 5 files changed, 55 insertions(+), 15 deletions(-) create mode 100644 src/doodles/draw_engine.py diff --git a/src/doodles/doodles.py b/src/doodles/doodles.py index 3bef6f5..060d3ac 100644 --- a/src/doodles/doodles.py +++ b/src/doodles/doodles.py @@ -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. diff --git a/src/doodles/draw_engine.py b/src/doodles/draw_engine.py new file mode 100644 index 0000000..a362f3f --- /dev/null +++ b/src/doodles/draw_engine.py @@ -0,0 +1,17 @@ +import abc + + +class DrawEngine(abc.ABC): + @abc.abstractmethod + def circle_draw(self, screen): + pass + + @abc.abstractmethod + def rect_draw(self, screen): + pass + + @abc.abstractmethod + def line_draw(self, screen): + pass + + diff --git a/src/doodles/lines.py b/src/doodles/lines.py index a4b3201..8df0f96 100644 --- a/src/doodles/lines.py +++ b/src/doodles/lines.py @@ -3,6 +3,7 @@ import random import pygame from typing import Callable from .doodles import Doodle +from .world import world class Line(Doodle): @@ -22,7 +23,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 +42,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": """ diff --git a/src/doodles/shapes.py b/src/doodles/shapes.py index 84dbe55..8cdae99 100644 --- a/src/doodles/shapes.py +++ b/src/doodles/shapes.py @@ -1,6 +1,7 @@ import random import pygame from .doodles import Doodle +from .world import world class Circle(Doodle): @@ -15,8 +16,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 +27,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 +56,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": """ diff --git a/src/doodles/world.py b/src/doodles/world.py index b791fff..9a10f57 100644 --- a/src/doodles/world.py +++ b/src/doodles/world.py @@ -1,5 +1,26 @@ from .color import Color import pygame +# TODO: fix this with a dynamic load +from .draw_engine import DrawEngine + + +class PygameDrawEngine(DrawEngine): + def circle_draw(self, c: "Circle"): + pygame.draw.circle(world.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(world.buffer, r.rgba, rect) + + def line_draw(self, ll: "Line"): + print("line_draw", ll) + pygame.draw.aaline(world.buffer, ll.rgba, ll.world_vec, ll.end_vec) class World: @@ -48,6 +69,7 @@ class World: self._drawables = [] self.background_color = Color.WHITE self.screen = None + self.draw_engine = PygameDrawEngine() def init(self): """ @@ -85,7 +107,7 @@ class World: # rendering self.buffer.fill((*self.background_color, 255)) for d in sorted(self._drawables, key=lambda d: d._z_index): - d.draw(self.buffer) + d.draw() self.screen.blit(self.buffer, (0, 0)) pygame.display.flip() From 39c9c53bdcb7894cbf7c24a2f86c19676347032d Mon Sep 17 00:00:00 2001 From: James Turk Date: Tue, 23 Apr 2024 21:37:25 -0500 Subject: [PATCH 2/3] add DrawEngine methods to initialize and render --- src/doodles/draw_engine.py | 10 ++++++++-- src/doodles/lines.py | 1 - src/doodles/shapes.py | 1 - src/doodles/world.py | 28 +++++++++++++++++----------- 4 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/doodles/draw_engine.py b/src/doodles/draw_engine.py index a362f3f..b7908cc 100644 --- a/src/doodles/draw_engine.py +++ b/src/doodles/draw_engine.py @@ -2,6 +2,14 @@ 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 @@ -13,5 +21,3 @@ class DrawEngine(abc.ABC): @abc.abstractmethod def line_draw(self, screen): pass - - diff --git a/src/doodles/lines.py b/src/doodles/lines.py index 8df0f96..42ea444 100644 --- a/src/doodles/lines.py +++ b/src/doodles/lines.py @@ -1,6 +1,5 @@ import math import random -import pygame from typing import Callable from .doodles import Doodle from .world import world diff --git a/src/doodles/shapes.py b/src/doodles/shapes.py index 8cdae99..426c908 100644 --- a/src/doodles/shapes.py +++ b/src/doodles/shapes.py @@ -1,5 +1,4 @@ import random -import pygame from .doodles import Doodle from .world import world diff --git a/src/doodles/world.py b/src/doodles/world.py index 9a10f57..9d8c12e 100644 --- a/src/doodles/world.py +++ b/src/doodles/world.py @@ -5,8 +5,19 @@ from .draw_engine import DrawEngine class PygameDrawEngine(DrawEngine): + def init(self): + self.screen = pygame.display.set_mode((world.WIDTH, world.HEIGHT)) + self.buffer = pygame.Surface((world.WIDTH, world.HEIGHT), pygame.SRCALPHA) + + 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(world.buffer, c.rgba, c.world_vec, c.radius_val) + pygame.draw.circle(self.buffer, c.rgba, c.world_vec, c.radius_val) def rect_draw(self, r: "Rectangle"): # TODO: make accessors @@ -16,11 +27,10 @@ class PygameDrawEngine(DrawEngine): r._width, r._height, ) - pygame.draw.rect(world.buffer, r.rgba, rect) + pygame.draw.rect(self.buffer, r.rgba, rect) def line_draw(self, ll: "Line"): - print("line_draw", ll) - pygame.draw.aaline(world.buffer, ll.rgba, ll.world_vec, ll.end_vec) + pygame.draw.aaline(self.buffer, ll.rgba, ll.world_vec, ll.end_vec) class World: @@ -79,10 +89,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 = [] @@ -105,11 +114,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.screen.blit(self.buffer, (0, 0)) - pygame.display.flip() + self.draw_engine.render(self.background_color, self._drawables) + # our singleton instance From 04d765059fcc9ea826d09c32b40c0769fc4a42f2 Mon Sep 17 00:00:00 2001 From: James Turk Date: Tue, 23 Apr 2024 22:07:25 -0500 Subject: [PATCH 3/3] everything works, even fonts --- src/doodles/examples/words.py | 6 --- src/doodles/text.py | 79 ++++++++++------------------------- src/doodles/world.py | 64 ++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 63 deletions(-) diff --git a/src/doodles/examples/words.py b/src/doodles/examples/words.py index a1c4651..159e292 100644 --- a/src/doodles/examples/words.py +++ b/src/doodles/examples/words.py @@ -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 diff --git a/src/doodles/text.py b/src/doodles/text.py index 5f71a4c..f433043 100644 --- a/src/doodles/text.py +++ b/src/doodles/text.py @@ -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 diff --git a/src/doodles/world.py b/src/doodles/world.py index 9d8c12e..d8d9e6a 100644 --- a/src/doodles/world.py +++ b/src/doodles/world.py @@ -3,12 +3,66 @@ 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): @@ -32,6 +86,16 @@ class PygameDrawEngine(DrawEngine): 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: """