lots more documentation

This commit is contained in:
James Turk 2024-04-26 14:48:30 -05:00
parent a403490df0
commit ee898302d3
10 changed files with 443 additions and 139 deletions

View File

@ -1,7 +1,27 @@
"""
This file exposes the "public interface" for the library.
While you've often dealt with blank __init__ files, this is
making explicit which classes and functions are meant
to be used outside of the library.
Importing these here means a user can import these as
"from doodle import Line" instead of "from doodle.lines import Line"
This means the library author(s) can move around the implementation
as they wish without disrupting users.
For small libraries, it makes sense for a single __init__ to do this,
whereas for larger libraries like Django it is not common to require
users to import the specific portions they're using. (Though even
then there are public/non-public files.)
"""
from .doodles import Doodle, Group
from .lines import Line
from .shapes import Circle, Rectangle
from .color import Color
from .text import Text
__all__ = [Doodle, Group, Line, Circle, Rectangle, Color, Text]

View File

@ -7,8 +7,15 @@ class Color:
This is done to group many global variables into a namespace for clarity.
There is no intention for anyone to ever declare an instance of this class.
Instead it will be used like Color.BLACK
There is no reason for anyone to ever declare an instance of this class.
Instead it will be used like Color.BLACK.
Another option would be to just have these be bare methods
at the `color` module and encourage use like color.random()
The two are equivalent, but this way was chosen as a demonstration
and to make `from colors import random` impossible, since that would
be confusing downstream.
Palette Source: https://pico-8.fandom.com/wiki/Palette
"""
@ -30,6 +37,9 @@ class Color:
PINK = (255, 119, 168)
LIGHT_PEACH = (255, 204, 170)
def __init__(self):
raise NotImplementedError("Color is not meant to be invoked directly")
@staticmethod
def all():
colors = list(Color.__dict__.values())
@ -38,10 +48,3 @@ class Color:
@staticmethod
def random():
return random.choice(Color.all())
# Another option would be to just have these be bare methods at the `color` module and
# encourage use like color.random()
# The two are equivalent, but this way makes someone accidentally importing `random()`
# impossible.

View File

@ -1,6 +1,19 @@
"""
This module defines the base interface that all Doodle objects
will need to implement.
It defines two classes: Doodle and Group.
Their docstrings will give specific detail, but the implementations
are closely linked, as a Group is both a type of Doodle and contains
a collection of additional Doodle-derived classes.
This is a reasonable example of when two classes might reasonable share a file.
"""
import random
import copy
from abc import ABC, abstractmethod
from typing import Callable, Self
from .color import Color
from .world import world
@ -17,18 +30,35 @@ class Doodle(ABC):
Doodles are drawn relative to their parent, so if a circle
is placed at (100, 100) and has a child point placed at (10, 10)
that point would appear at (110, 110).
that point would appear ag (110, 110).
Careful attention is paid in this inheritance hierarchy to the
Liskov substitution principle.
"""
# annotations for instance attributes
_parent: Self | None
_updates: list[Callable]
_color: tuple[int, int, int]
_alpha: int
_z_index: int
def __init__(self, parent=None):
# To avoid all child constructors having an ever-expanding
# number of parameters as more customization options arrive,
# the design decision was made
# that at creation time, all Doodles will have defaults
# positioned at (0, 0), black, opaque, etc.
#
# All child classes should follow this same guidance
# setting a *visible* (i.e. non-zero) default.
self._parent = parent
self._updates = []
self._color = parent._color if parent else Color.BLACK
self._alpha = parent._alpha if parent else 255
self._z_index = 0
# Design Note:
# Is storing this vector in a tuple the right thing to do?
# It might make more sense to store _x and _y, or use
# a library's optimized 2D vector implementation.
@ -36,12 +66,19 @@ class Doodle(ABC):
# All references to _pos_vec are internal to the class,
# so it will be trivial to swap this out later.
self._pos_vec = (0, 0)
self._register()
def _register(self):
"""register with parent and world"""
"""
This private method ensures that the parent
knows about the child object.
It also registers every drawable object with the
World singleton (see world.py) which ensures that
it is drawn.
"""
if self._parent:
# register with parent for updates
self._parent.add(self)
world.add(self)
@ -57,6 +94,31 @@ class Doodle(ABC):
"""
pass
def copy(self) -> Self:
"""
It will be useful to have the ability to obtain a copy
of a given doodle to create repetitive designs.
This method is provided to fit the chained-object pattern
that will be used by the rest of the Doodle API.
Additionally, while a shallow copy is enough for most
cases, it will be possible for child classes to override
this.
"""
new = copy.copy(self)
new._register()
return new
# Dynamic Update Logic ############
# These methods relate to a WIP feature
# designed to demonstrate a functional hybrid
# approach to having objects update themselves.
#
# This feature isn't complete, or documented yet.
# TODO
def register_update(self, method, *args):
self._updates.append((method, args))
@ -72,25 +134,16 @@ class Doodle(ABC):
evaled_args = [arg() for arg in args]
method(*evaled_args)
def copy(self) -> "Doodle":
"""
It will be useful to have the ability to obtain a copy
of a given doodle to create repetitive designs.
This method is provided to fit the chained-object pattern
that will be used by the rest of the Doodle API.
Additionally, while a shallow copy is enough for most
cases, it will be possible for child classes to override
this.
"""
new = copy.copy(self)
new._register()
return new
# Setters #######################
def color(self, color: tuple[int, int, int]) -> "Doodle":
# As noted in the design documentation, the decision
# was made to have setters be the name of the attribute.
#
# These modify & return the object.
#
# All setters (and modifiers) must return self.
def color(self, color: tuple[int, int, int]) -> Self:
"""
Color works as a kind of setter function.
@ -100,7 +153,7 @@ class Doodle(ABC):
self._color = color
return self
def pos(self, x: float, y: float) -> "Doodle":
def pos(self, x: float, y: float) -> Self:
"""
Another setter, just like color.
@ -109,26 +162,28 @@ class Doodle(ABC):
self._pos_vec = (x, y)
return self
def x(self, x: float) -> "Doodle":
def x(self, x: float) -> Self:
"""
Setter for x component.
"""
self._pos_vec = (x, self._pos_vec[1])
return self
def y(self, y: float) -> "Doodle":
def y(self, y: float) -> Self:
"""
Setter for x component.
"""
self._pos_vec = (self._pos_vec[0], y)
return self
def alpha(self, a: int) -> "Doodle":
def alpha(self, a: int) -> Self:
"""
Setter for alpha transparency
"""
self._alpha = a
return self
def z(self, z: float) -> "Doodle":
def z(self, z: float) -> Self:
"""
Setter for z_index
"""
@ -137,7 +192,13 @@ class Doodle(ABC):
# Modifiers #################
def move(self, dx: float, dy: float) -> "Doodle":
# These modify properties like setters, but allow
# multiple operations to be done in a single call.
#
# They can also take advantage of knowledge of current
# state, like `move` which applies a delta to the position.
def move(self, dx: float, dy: float) -> Self:
"""
This shifts the vector by a set amount.
@ -147,7 +208,7 @@ class Doodle(ABC):
"""
return self.pos(self._pos_vec[0] + dx, self._pos_vec[1] + dy)
def random(self) -> "Doodle":
def random(self) -> Self:
"""
Randomize the position and color.
"""
@ -168,9 +229,12 @@ class Doodle(ABC):
return the screen position derived from the parent position
plus the current object's x component.
If a parent object is at (100, 100) and the child is at (10, 10)
that should come through as (110, 110) in world coordinates.
Note the recursion here, parent.world_x is an instance of doodle.world_x.
For example:
For another example:
A x = 100
|--------B x = 10
@ -203,12 +267,19 @@ class Doodle(ABC):
@property
def rgba(self) -> (int, int, int, int):
"""
Access for color+alpha, used by draw functions
which need a 4-tuple.
"""
return (*self._color, self._alpha)
class Group(Doodle):
"""
A concrete-in-implementation, abstract-in-concept doodle.
A group is merely a list of other doodles, allowing
doodles to be arranged/moved/updated in a tree-like manner.
For now, only Group objects can have child doodles.
It may be desirable to let any object serve as a parent
but for now, groups are needed.
@ -225,71 +296,104 @@ class Group(Doodle):
"""
def __init__(self, parent=None):
# Like all constructors derived from doodle, this constructor
# initializes the parent Doodle class, and then adds its own
# additional attributse.
super().__init__(parent)
self._doodles = []
def __repr__(self):
return f"Group(pos={self.world_vec}, doodles={len(self._doodles)})"
def draw(self):
def draw(self) -> None:
"""
Groups, despite being an abstract concept, are drawable.
To draw a group is to draw everything in it.
This is done by default, since all drawables will be
registered with the scene upon creation.
Thus the *complete* implementation of this function
is to "pass", the drawing will be handled by the child
doodles.
(If they weren't already registered with the world singleton
this would loop over all child doodles and draw them).
"""
pass
def copy(self) -> "Group":
"""
An override.
We are storing a list, so deep copies are necessary.
An override of copy that handles the special
case of having a mutable list of Doodles
as an attribute.
"""
new = copy.copy(self)
new._register()
# still a shallow copy of base data since
# we're going to overwrite _doodles separately
new = super().copy()
new._doodles = []
for child in self._doodles:
# this code happens outside of Doodle.copy so that
# the child is never accidentally registered with
# the old parent
child = copy.copy(child)
child._parent = new
child._register()
return new
def color(self, color: tuple[int, int, int]) -> "Doodle":
def color(self, color: tuple[int, int, int]) -> Doodle:
"""
Another override.
An override of Doodle.color.
Nothing will ever be drawn in the parent
color, but we do want to have the set
cascade down to child objects.
What _color should do on Groups is a bit ambiguous.
We don't cascade pos() calls, why not?
The way parent-child relationships work here means
this color will never be used directly.
Instead the decision was made that child objects
will be colored by a call to this, and new
children will be colored the color set on the parent
group when added.
Subsequent calls to color on child elements will recolor them
allowing a group to have different colors within it.
The position is handled differently (see world_x)
since a moving parent should always move the children.
"""
super().color(color)
for d in self._doodles:
d.color(color)
return self
def add(self, doodle: "Doodle") -> "Group":
def add(self, doodle: Doodle) -> Self:
"""
The only unique method of this class, allowing us
to add objects to the group.
Note the violation of class boundaries here.
A simple implementation, but note that we set doodle._parent.
This assignment is, strictly speaking, a violation of class
boundaries. Sometimes two classes work together
in a way that makes this necessary, as noted above.
In some languages this would be done via a "protected"
attribute, which is a status between public and private
that only lets certain classes access internals.
In Python, _parent is merely a suggestion,
and in this case, it is a suggestion that we can safely
ignore.
These classes are tightly coupled to one another,
hence their implementations living side-by-side and
being allowed to peek at one another's internals.
The alternative would be to have a set_parent() method
but since we want groups to add children and not vice-versa
there's no reason to add set_parent() to Doodle since
it shouldn't be called by anything other than Group.
So in effect, we're ensuring the encapsulation of Doodle's
behavior everywhere else by breaking that rule here.
"""
self._doodles.append(doodle)
doodle._parent = self
# This assignment is, strictly speaking, a violation of class
# boundaries. Sometimes two classes work together
# in a way that makes this necessary, as noted above.
# In some languages this would be done via a "protected"
# attribute, which is a status between public and private
# that only lets certain classes access internals.
#
# In Python, _parent is merely a suggestion,
# and since it is likely that the same author wrote both
# classes, it is a suggestion that we can safely ignore
# if we understand the implications of tightly
# binding the implementations of these two classes.
return self

View File

@ -2,22 +2,76 @@ import abc
class DrawEngine(abc.ABC):
"""
This is an abstract class that defines the methods needed
to have a drawing backend.
This interface was *extracted* not designed.
The first version of this library had a hard dependency on pygame
for drawing. Each shape had an overriden draw method that called
pygame functions directly.
The refactor in https://github.com/jamesturk/doodles/pull/1
pulled this out by searching the code for all pygame references
and extracting them into their own class. `PygameDrawEngine`.
These are the signatures of that classes methods, so that
a new implementation (to draw in OpenGL, or the browser, or on PDFs)
could provide the necessary implementations of these functions.
Note that starting with these as embedded and then extracting them
is a perfectly valid way to get here, you could also be diligent
while writing in the first place, and ensure that code needing
isolation (such as a library you want to avoid tight coupling to)
only is added to a specific class or module.
TODO: this got behind the current impl
"""
@abc.abstractmethod
def init(self):
pass
"""
Called once, provides a place for the backend to initialize
itself as needed.
"""
@abc.abstractmethod
def render(self, background_color: "Color", drawables: list["Doodle"]):
pass
"""
Workhorse function, should set background and then draw all Doodles.
Will be called once per frame.
"""
@abc.abstractmethod
def circle_draw(self, screen):
pass
def circle_draw(self, circle: "Circle"):
"""
Method to draw a Circle obj.
"""
@abc.abstractmethod
def rect_draw(self, screen):
pass
def rect_draw(self, rect: "Rect"):
"""
Method to draw a Rectangle obj.
"""
@abc.abstractmethod
def line_draw(self, screen):
pass
def line_draw(self, line: "Line"):
"""
Method to draw a Line obj.
"""
@abc.abstractmethod
def text_render(self, text: "Text"):
"""
Method to pre-render a text object.
"""
@abc.abstractmethod
def text_draw(self, text: "Text"):
"""
Method to draw a pre-rendered text object.
"""
# TODO: should make_font/get_font become part of the
# reqwuired interface?

View File

@ -1,8 +1,24 @@
"""
Experimental ideas for defining layouts.
"""
def make_grid(iterable, cols, rows, width, height, *, x_offset=0, y_offset=0):
"""
Arranges the objects in iterable in a grid with the given parameters.
This function attempts to create an evenly-spaced grid of drawables.
The drawables are provided in an iterable, and when the iterable
runs out of items the grid stops.
A lot more behavior could be offered here, but this was
done to validate the concept.
By using an iterable here, it is possible to pass in lists that are
too big or too small, and the grid will still work.
(Leaving empty slots empty, or not drawing extras depending on the
relationship of the grid size to the iterable.)
Another neat trick is using an infinite generator (as done in examples/grid)
since only as many doodles as needed to fill the grid will be gathered.
"""
try:
for c in range(cols):

View File

@ -1,3 +1,9 @@
"""
Class to draw lines.
This is the most well-documented version of a concrete doodle,
the easiest to learn from.
"""
import math
import random
from typing import Callable
@ -6,6 +12,11 @@ from .world import world
class Line(Doodle):
# Adds one attribute to Doodle: the distance/offset vector
# from the position. Together these form the end points of the
# line.
_offset_vec: tuple[float, float]
def __init__(self, parent=None):
"""
We keep the same interface as Doodle, to follow the Liskov substitution
@ -26,23 +37,19 @@ class Line(Doodle):
"""
Implementation of the abstract draw function for the line.
Note: This is a classic violation of single responsibility.
This class passes the responsibility of actually drawing to
the world.draw_engine, which is designed to be configurable.
Instead, you could imagine a class like:
An earlier version had Pygame drawing code in here, but that
violated the Single Responsibility Principle.
class DrawingBackend:
def draw_doodle(doodle_type, doodle): ...
class PygameBackend(DrawingBackend):
def draw_line(...): ...
This would make it possible to attach different
drawing backends, restoring single-responsibility
to the class and gaining flexibility from separating
presentation logic from data manipulation.
Instead, as a pass-through, the actual drawing logic is not coupled
to the mathematical representation of a line.
"""
world.draw_engine.line_draw(self)
## Setters / Modifiers / Getters ##############
def to(self, x: float, y: float) -> "Doodle":
"""
A setter for the line's offset vector.
@ -58,7 +65,12 @@ class Line(Doodle):
def vec(self, degrees: float, magnitude: float):
"""
Alternate constructor, to create offset vector from angle & length.
Alternate setter, to create offset vector from angle & length.
This is similar to the constructor/alternate constructor concept
where there's a base constructor that sets the propertries
directly (`to`), but there is also an alternate option
that handles commonly used case.
"""
if isinstance(degrees, Callable):
self.register_update(
@ -75,9 +87,10 @@ class Line(Doodle):
def random(self) -> "Doodle":
"""
Overrides the parent's random, by extending the behavior.
Overrides the parent's random, since a random line
also needs to have a offset vector.
This is an example of the open/closed principle.
This is an example of the **Open/Closed Principle**.
We aren't modifying the parent classes' random function
since doing so would be fragile and break if the
parent class added more options.
@ -93,7 +106,8 @@ class Line(Doodle):
@property
def end_vec(self):
"""
Parallel to world_vec for end of line.
Line goes from world_x, world_y to this position which
results from adding (world_x, world_y) + (offset_x, offset_y).
"""
return (
self.world_x + self._offset_vec[0],

View File

@ -1,3 +1,16 @@
"""
Entrypoint for running doodles.
Understanding this module is not important to undertanding
the architecture.
The most interesting thing here is that it dynamically loads
a module based on name, using `importlib`.
This file also currently contains a hard dependency on pygame,
it probably makes sense to factor this out too so that the
underlying library is swappable, not just the drawing code.
"""
import sys
from pathlib import Path
import pygame
@ -6,14 +19,25 @@ import typer
from .world import world
def get_examples():
def get_examples() -> list[str]:
"""
Looks in the doodles/examples directory and gets a list of modules.
"""
module_path = Path(__file__).parent / "examples"
submodules = [
file.stem for file in module_path.glob("*.py") if file.name != "__init__.py"
]
return submodules
def load_module(modname):
def load_module(modname: str):
"""
Loads a module by name (either by absolute path or from within
examples).
Once loaded, the module's `create()` function is called, which should
create instances of all drawable objects.
"""
pygame.display.set_caption("doodles: " + modname)
world.clear()
try:
@ -31,8 +55,16 @@ def load_module(modname):
def main(modname: str = None):
"""
Entrypoint method.
Loads a module then runs the core app loop.
"""
# initalize world and underlying frameworks
world.init()
# get list of all examples and load first one if no name given
examples = get_examples()
ex_index = 0
if modname:
@ -40,7 +72,11 @@ def main(modname: str = None):
else:
load_module(examples[ex_index])
# run until the application is quit
while True:
# this is a pygame event loop, it monitors which keys are pressed
# and changes the example if left/right are pressed
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
@ -52,7 +88,10 @@ def main(modname: str = None):
elif event.key == pygame.K_LEFT:
ex_index = (ex_index - 1) % len(examples)
load_module(examples[ex_index])
# update the world animations and draw
world.update()
if __name__ == "__main__":
typer.run(main)

View File

@ -1,3 +1,10 @@
"""
Adds more drawables, like `lines`. For the most part
these classes only differ from `Line` in implementation.
The interface & decisions are the same but specific
to `Circle` and `Rectangle`.
"""
import random
from .doodles import Doodle
from .world import world
@ -5,9 +12,6 @@ from .world import world
class Circle(Doodle):
def __init__(self, parent=None):
"""
This is a less interesting class than Line, but very similar.
"""
super().__init__(parent)
# circle is a position & radius
self._radius = 0
@ -60,22 +64,19 @@ class Rectangle(Doodle):
def width(self, w: float) -> "Doodle":
"""
A setter for the width
Set new width.
"""
self._width = w
return self
def height(self, h: float) -> "Doodle":
"""
A setter for the height
Set new height.
"""
self._height = h
return self
def grow(self, dw: float, dh: float):
"""
Modify radius by an amount. (Negative to shrink.)
"""
return self.width(self._w + dw).height(self._h + dh)
def random(self, upper=100) -> "Doodle":

View File

@ -1,3 +1,21 @@
"""
This is the most complicated Doodle-derived class.
Like shapes, the interface is mostly identical to Line.
If you are mainly trying to understand the interfaces it is
safe to skip this one.
There is some more complexity here because fonts need to be
pre-rendered. This means that when `text` is called, an internal
cached copy of the font already drawn to a temporary piece of memory
"surface" in graphics programming parlance.
This is an example of a type of class where when a property changes
some additional computation can be done to precompute/cache
some expensive logic.
Look at _render() for more.
"""
import pygame
from .doodles import Doodle
from .world import world
@ -6,7 +24,15 @@ from .world import world
class Text(Doodle):
def __init__(self, parent=None):
"""
Text will be centered at `pos`
A text object stores a reference to a pre-loaded font,
the text to be drawn, and an internal `_rendered` object
that *can* be used to store the cached text.
Not all implementations will require pre-rendering, but
Pygame (the first implementation) does, so it is necessary
for the interface as written here.
When drawn, text will be centered at `pos`
"""
super().__init__(parent)
self._text = ""
@ -35,10 +61,14 @@ class Text(Doodle):
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.
(draw called ~60 times per second, _render only called when
text is updated.)
"""
# 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 = world.draw_engine.get_font() # default font
self._rendered = world.draw_engine.text_render(

View File

@ -1,11 +1,13 @@
"""
This is the most complex/implementation specific module.
TODO: this should be split into two modules once there
is a non-Pygame implementation.
"""
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
@ -22,38 +24,32 @@ class PygameDrawEngine(DrawEngine):
# 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] = {}
DEFAULT_FONT_SIZE = 24
# 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]
# Required Interface Methods ##############
def init(self):
"""
This is a deferred-constructor of sorts.
We don't do this work in `__init__` since we have less control
over when the object is created than when we actually want to do
the platform-specific initialization.
For example, by the point this is called, we need to have called
`pygame.init()`, but that would require us know that, and only construct
the object after it was called, ex:
# this would break
engine = PygameDrawEngine()
pygame.init()
# this would work
pygame.init()
engine = PygameDrawEngine()
This isn't a perfect solution to that problem, but a fair compromise for now.
"""
self.screen = pygame.display.set_mode((world.WIDTH, world.HEIGHT))
self.buffer = pygame.Surface((world.WIDTH, world.HEIGHT), pygame.SRCALPHA)
@ -87,8 +83,7 @@ class PygameDrawEngine(DrawEngine):
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
"""returns an intermediated RenderedText"""
return font.render(text, True, color)
def text_draw(self, txt: "Text"):
@ -96,6 +91,35 @@ class PygameDrawEngine(DrawEngine):
text_rect = txt._rendered.get_rect(center=txt.world_vec)
self.buffer.blit(txt._rendered, text_rect)
def make_font(self, 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)
self._fonts[name] = font
def get_font(self, name=None):
"""
Load a font by name, if no name is given, use the default font.
"""
if not name:
# None -> default font
# load on demand
if None not in self._fonts:
self._fonts[None] = pygame.font.Font(None, self.DEFAULT_FONT_SIZE)
return self._fonts[None]
else:
return self._fonts[name]
class World:
"""
@ -181,6 +205,5 @@ class World:
self.draw_engine.render(self.background_color, self._drawables)
# our singleton instance
world = World()