lots more documentation
This commit is contained in:
parent
a403490df0
commit
ee898302d3
@ -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]
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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?
|
||||
|
@ -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):
|
||||
|
@ -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],
|
||||
|
@ -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)
|
||||
|
@ -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":
|
||||
|
@ -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(
|
||||
|
@ -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()
|
||||
|
Loading…
Reference in New Issue
Block a user