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 .doodles import Doodle, Group
|
||||||
from .lines import Line
|
from .lines import Line
|
||||||
from .shapes import Circle, Rectangle
|
from .shapes import Circle, Rectangle
|
||||||
from .color import Color
|
from .color import Color
|
||||||
from .text import Text
|
from .text import Text
|
||||||
|
|
||||||
|
|
||||||
__all__ = [Doodle, Group, Line, Circle, Rectangle, Color, 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.
|
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.
|
There is no reason for anyone to ever declare an instance of this class.
|
||||||
Instead it will be used like Color.BLACK
|
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
|
Palette Source: https://pico-8.fandom.com/wiki/Palette
|
||||||
"""
|
"""
|
||||||
@ -30,6 +37,9 @@ class Color:
|
|||||||
PINK = (255, 119, 168)
|
PINK = (255, 119, 168)
|
||||||
LIGHT_PEACH = (255, 204, 170)
|
LIGHT_PEACH = (255, 204, 170)
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
raise NotImplementedError("Color is not meant to be invoked directly")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def all():
|
def all():
|
||||||
colors = list(Color.__dict__.values())
|
colors = list(Color.__dict__.values())
|
||||||
@ -38,10 +48,3 @@ class Color:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def random():
|
def random():
|
||||||
return random.choice(Color.all())
|
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 random
|
||||||
import copy
|
import copy
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Callable, Self
|
||||||
from .color import Color
|
from .color import Color
|
||||||
from .world import world
|
from .world import world
|
||||||
|
|
||||||
@ -17,18 +30,35 @@ class Doodle(ABC):
|
|||||||
|
|
||||||
Doodles are drawn relative to their parent, so if a circle
|
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)
|
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
|
Careful attention is paid in this inheritance hierarchy to the
|
||||||
Liskov substitution principle.
|
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):
|
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._parent = parent
|
||||||
self._updates = []
|
self._updates = []
|
||||||
self._color = parent._color if parent else Color.BLACK
|
self._color = parent._color if parent else Color.BLACK
|
||||||
self._alpha = parent._alpha if parent else 255
|
self._alpha = parent._alpha if parent else 255
|
||||||
self._z_index = 0
|
self._z_index = 0
|
||||||
|
|
||||||
|
# Design Note:
|
||||||
# Is storing this vector in a tuple the right thing to do?
|
# Is storing this vector in a tuple the right thing to do?
|
||||||
# It might make more sense to store _x and _y, or use
|
# It might make more sense to store _x and _y, or use
|
||||||
# a library's optimized 2D vector implementation.
|
# a library's optimized 2D vector implementation.
|
||||||
@ -36,12 +66,19 @@ class Doodle(ABC):
|
|||||||
# All references to _pos_vec are internal to the class,
|
# All references to _pos_vec are internal to the class,
|
||||||
# so it will be trivial to swap this out later.
|
# so it will be trivial to swap this out later.
|
||||||
self._pos_vec = (0, 0)
|
self._pos_vec = (0, 0)
|
||||||
|
|
||||||
self._register()
|
self._register()
|
||||||
|
|
||||||
def _register(self):
|
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:
|
if self._parent:
|
||||||
# register with parent for updates
|
|
||||||
self._parent.add(self)
|
self._parent.add(self)
|
||||||
world.add(self)
|
world.add(self)
|
||||||
|
|
||||||
@ -57,6 +94,31 @@ class Doodle(ABC):
|
|||||||
"""
|
"""
|
||||||
pass
|
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):
|
def register_update(self, method, *args):
|
||||||
self._updates.append((method, args))
|
self._updates.append((method, args))
|
||||||
|
|
||||||
@ -72,25 +134,16 @@ class Doodle(ABC):
|
|||||||
evaled_args = [arg() for arg in args]
|
evaled_args = [arg() for arg in args]
|
||||||
method(*evaled_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 #######################
|
# 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.
|
Color works as a kind of setter function.
|
||||||
|
|
||||||
@ -100,7 +153,7 @@ class Doodle(ABC):
|
|||||||
self._color = color
|
self._color = color
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def pos(self, x: float, y: float) -> "Doodle":
|
def pos(self, x: float, y: float) -> Self:
|
||||||
"""
|
"""
|
||||||
Another setter, just like color.
|
Another setter, just like color.
|
||||||
|
|
||||||
@ -109,26 +162,28 @@ class Doodle(ABC):
|
|||||||
self._pos_vec = (x, y)
|
self._pos_vec = (x, y)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def x(self, x: float) -> "Doodle":
|
def x(self, x: float) -> Self:
|
||||||
"""
|
"""
|
||||||
Setter for x component.
|
Setter for x component.
|
||||||
"""
|
"""
|
||||||
self._pos_vec = (x, self._pos_vec[1])
|
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.
|
Setter for x component.
|
||||||
"""
|
"""
|
||||||
self._pos_vec = (self._pos_vec[0], y)
|
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
|
Setter for alpha transparency
|
||||||
"""
|
"""
|
||||||
self._alpha = a
|
self._alpha = a
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def z(self, z: float) -> "Doodle":
|
def z(self, z: float) -> Self:
|
||||||
"""
|
"""
|
||||||
Setter for z_index
|
Setter for z_index
|
||||||
"""
|
"""
|
||||||
@ -137,7 +192,13 @@ class Doodle(ABC):
|
|||||||
|
|
||||||
# Modifiers #################
|
# 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.
|
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)
|
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.
|
Randomize the position and color.
|
||||||
"""
|
"""
|
||||||
@ -168,9 +229,12 @@ class Doodle(ABC):
|
|||||||
return the screen position derived from the parent position
|
return the screen position derived from the parent position
|
||||||
plus the current object's x component.
|
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.
|
Note the recursion here, parent.world_x is an instance of doodle.world_x.
|
||||||
|
|
||||||
For example:
|
For another example:
|
||||||
|
|
||||||
A x = 100
|
A x = 100
|
||||||
|--------B x = 10
|
|--------B x = 10
|
||||||
@ -203,12 +267,19 @@ class Doodle(ABC):
|
|||||||
@property
|
@property
|
||||||
def rgba(self) -> (int, int, int, int):
|
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)
|
return (*self._color, self._alpha)
|
||||||
|
|
||||||
|
|
||||||
class Group(Doodle):
|
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.
|
For now, only Group objects can have child doodles.
|
||||||
It may be desirable to let any object serve as a parent
|
It may be desirable to let any object serve as a parent
|
||||||
but for now, groups are needed.
|
but for now, groups are needed.
|
||||||
@ -225,71 +296,104 @@ class Group(Doodle):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
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)
|
super().__init__(parent)
|
||||||
self._doodles = []
|
self._doodles = []
|
||||||
|
|
||||||
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):
|
def draw(self) -> None:
|
||||||
"""
|
"""
|
||||||
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.
|
||||||
|
|
||||||
This is done by default, since all drawables will be
|
This is done by default, since all drawables will be
|
||||||
registered with the scene upon creation.
|
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
|
pass
|
||||||
|
|
||||||
def copy(self) -> "Group":
|
def copy(self) -> "Group":
|
||||||
"""
|
"""
|
||||||
An override.
|
An override of copy that handles the special
|
||||||
|
case of having a mutable list of Doodles
|
||||||
We are storing a list, so deep copies are necessary.
|
as an attribute.
|
||||||
"""
|
"""
|
||||||
new = copy.copy(self)
|
# still a shallow copy of base data since
|
||||||
new._register()
|
# we're going to overwrite _doodles separately
|
||||||
|
new = super().copy()
|
||||||
new._doodles = []
|
new._doodles = []
|
||||||
for child in self._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 = copy.copy(child)
|
||||||
child._parent = new
|
child._parent = new
|
||||||
child._register()
|
child._register()
|
||||||
return new
|
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
|
What _color should do on Groups is a bit ambiguous.
|
||||||
color, but we do want to have the set
|
|
||||||
cascade down to child objects.
|
|
||||||
|
|
||||||
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)
|
super().color(color)
|
||||||
for d in self._doodles:
|
for d in self._doodles:
|
||||||
d.color(color)
|
d.color(color)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def add(self, doodle: "Doodle") -> "Group":
|
def add(self, doodle: Doodle) -> Self:
|
||||||
"""
|
"""
|
||||||
The only unique method of this class, allowing us
|
The only unique method of this class, allowing us
|
||||||
to add objects to the group.
|
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)
|
self._doodles.append(doodle)
|
||||||
doodle._parent = self
|
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
|
return self
|
||||||
|
@ -2,22 +2,76 @@ import abc
|
|||||||
|
|
||||||
|
|
||||||
class DrawEngine(abc.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
|
@abc.abstractmethod
|
||||||
def init(self):
|
def init(self):
|
||||||
pass
|
"""
|
||||||
|
Called once, provides a place for the backend to initialize
|
||||||
|
itself as needed.
|
||||||
|
"""
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def render(self, background_color: "Color", drawables: list["Doodle"]):
|
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
|
@abc.abstractmethod
|
||||||
def circle_draw(self, screen):
|
def circle_draw(self, circle: "Circle"):
|
||||||
pass
|
"""
|
||||||
|
Method to draw a Circle obj.
|
||||||
|
"""
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def rect_draw(self, screen):
|
def rect_draw(self, rect: "Rect"):
|
||||||
pass
|
"""
|
||||||
|
Method to draw a Rectangle obj.
|
||||||
|
"""
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def line_draw(self, screen):
|
def line_draw(self, line: "Line"):
|
||||||
pass
|
"""
|
||||||
|
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):
|
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:
|
try:
|
||||||
for c in range(cols):
|
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 math
|
||||||
import random
|
import random
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
@ -6,6 +12,11 @@ from .world import world
|
|||||||
|
|
||||||
|
|
||||||
class Line(Doodle):
|
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):
|
def __init__(self, parent=None):
|
||||||
"""
|
"""
|
||||||
We keep the same interface as Doodle, to follow the Liskov substitution
|
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.
|
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:
|
Instead, as a pass-through, the actual drawing logic is not coupled
|
||||||
def draw_doodle(doodle_type, doodle): ...
|
to the mathematical representation of a line.
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
world.draw_engine.line_draw(self)
|
world.draw_engine.line_draw(self)
|
||||||
|
|
||||||
|
## Setters / Modifiers / Getters ##############
|
||||||
|
|
||||||
def to(self, x: float, y: float) -> "Doodle":
|
def to(self, x: float, y: float) -> "Doodle":
|
||||||
"""
|
"""
|
||||||
A setter for the line's offset vector.
|
A setter for the line's offset vector.
|
||||||
@ -58,7 +65,12 @@ class Line(Doodle):
|
|||||||
|
|
||||||
def vec(self, degrees: float, magnitude: float):
|
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):
|
if isinstance(degrees, Callable):
|
||||||
self.register_update(
|
self.register_update(
|
||||||
@ -75,9 +87,10 @@ class Line(Doodle):
|
|||||||
|
|
||||||
def random(self) -> "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
|
We aren't modifying the parent classes' random function
|
||||||
since doing so would be fragile and break if the
|
since doing so would be fragile and break if the
|
||||||
parent class added more options.
|
parent class added more options.
|
||||||
@ -93,7 +106,8 @@ class Line(Doodle):
|
|||||||
@property
|
@property
|
||||||
def end_vec(self):
|
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 (
|
return (
|
||||||
self.world_x + self._offset_vec[0],
|
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
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import pygame
|
import pygame
|
||||||
@ -6,14 +19,25 @@ import typer
|
|||||||
from .world import world
|
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"
|
module_path = Path(__file__).parent / "examples"
|
||||||
submodules = [
|
submodules = [
|
||||||
file.stem for file in module_path.glob("*.py") if file.name != "__init__.py"
|
file.stem for file in module_path.glob("*.py") if file.name != "__init__.py"
|
||||||
]
|
]
|
||||||
return submodules
|
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)
|
pygame.display.set_caption("doodles: " + modname)
|
||||||
world.clear()
|
world.clear()
|
||||||
try:
|
try:
|
||||||
@ -31,8 +55,16 @@ def load_module(modname):
|
|||||||
|
|
||||||
|
|
||||||
def main(modname: str = None):
|
def main(modname: str = None):
|
||||||
|
"""
|
||||||
|
Entrypoint method.
|
||||||
|
|
||||||
|
Loads a module then runs the core app loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# initalize world and underlying frameworks
|
||||||
world.init()
|
world.init()
|
||||||
|
|
||||||
|
# get list of all examples and load first one if no name given
|
||||||
examples = get_examples()
|
examples = get_examples()
|
||||||
ex_index = 0
|
ex_index = 0
|
||||||
if modname:
|
if modname:
|
||||||
@ -40,7 +72,11 @@ def main(modname: str = None):
|
|||||||
else:
|
else:
|
||||||
load_module(examples[ex_index])
|
load_module(examples[ex_index])
|
||||||
|
|
||||||
|
# run until the application is quit
|
||||||
while True:
|
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():
|
for event in pygame.event.get():
|
||||||
if event.type == pygame.QUIT:
|
if event.type == pygame.QUIT:
|
||||||
pygame.quit()
|
pygame.quit()
|
||||||
@ -52,7 +88,10 @@ def main(modname: str = None):
|
|||||||
elif event.key == pygame.K_LEFT:
|
elif event.key == pygame.K_LEFT:
|
||||||
ex_index = (ex_index - 1) % len(examples)
|
ex_index = (ex_index - 1) % len(examples)
|
||||||
load_module(examples[ex_index])
|
load_module(examples[ex_index])
|
||||||
|
|
||||||
|
# update the world animations and draw
|
||||||
world.update()
|
world.update()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
typer.run(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
|
import random
|
||||||
from .doodles import Doodle
|
from .doodles import Doodle
|
||||||
from .world import world
|
from .world import world
|
||||||
@ -5,9 +12,6 @@ from .world import world
|
|||||||
|
|
||||||
class Circle(Doodle):
|
class Circle(Doodle):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
"""
|
|
||||||
This is a less interesting class than Line, but very similar.
|
|
||||||
"""
|
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
# circle is a position & radius
|
# circle is a position & radius
|
||||||
self._radius = 0
|
self._radius = 0
|
||||||
@ -60,22 +64,19 @@ class Rectangle(Doodle):
|
|||||||
|
|
||||||
def width(self, w: float) -> "Doodle":
|
def width(self, w: float) -> "Doodle":
|
||||||
"""
|
"""
|
||||||
A setter for the width
|
Set new width.
|
||||||
"""
|
"""
|
||||||
self._width = w
|
self._width = w
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def height(self, h: float) -> "Doodle":
|
def height(self, h: float) -> "Doodle":
|
||||||
"""
|
"""
|
||||||
A setter for the height
|
Set new height.
|
||||||
"""
|
"""
|
||||||
self._height = h
|
self._height = h
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def grow(self, dw: float, dh: float):
|
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)
|
return self.width(self._w + dw).height(self._h + dh)
|
||||||
|
|
||||||
def random(self, upper=100) -> "Doodle":
|
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
|
import pygame
|
||||||
from .doodles import Doodle
|
from .doodles import Doodle
|
||||||
from .world import world
|
from .world import world
|
||||||
@ -6,7 +24,15 @@ from .world import world
|
|||||||
class Text(Doodle):
|
class Text(Doodle):
|
||||||
def __init__(self, parent=None):
|
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)
|
super().__init__(parent)
|
||||||
self._text = ""
|
self._text = ""
|
||||||
@ -35,10 +61,14 @@ class Text(Doodle):
|
|||||||
This function needs to set the _rendered property.
|
This function needs to set the _rendered property.
|
||||||
|
|
||||||
_rendered may be relied upon by draw_engine.draw_text.
|
_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:
|
if not self._font:
|
||||||
self._font = world.draw_engine.get_font() # default font
|
self._font = world.draw_engine.get_font() # default font
|
||||||
self._rendered = world.draw_engine.text_render(
|
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
|
from .color import Color
|
||||||
import pygame
|
import pygame
|
||||||
# TODO: fix this with a dynamic load
|
|
||||||
from .draw_engine import DrawEngine
|
from .draw_engine import DrawEngine
|
||||||
|
|
||||||
# TODO: make configurable
|
|
||||||
DEFAULT_FONT_SIZE = 24
|
|
||||||
|
|
||||||
|
|
||||||
class PygameDrawEngine(DrawEngine):
|
class PygameDrawEngine(DrawEngine):
|
||||||
# Having each bit of text on the screen load a separate copy
|
# 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.
|
# note that here, once a font is loaded it does not change.
|
||||||
# This avoids nearly all pitfalls associated with this approach.
|
# This avoids nearly all pitfalls associated with this approach.
|
||||||
_fonts: dict[str, pygame.font.Font] = {}
|
_fonts: dict[str, pygame.font.Font] = {}
|
||||||
|
DEFAULT_FONT_SIZE = 24
|
||||||
|
|
||||||
# this method is attached to the class `Text`, not individual instances
|
# Required Interface Methods ##############
|
||||||
# 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):
|
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.screen = pygame.display.set_mode((world.WIDTH, world.HEIGHT))
|
||||||
self.buffer = pygame.Surface((world.WIDTH, world.HEIGHT), pygame.SRCALPHA)
|
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)
|
pygame.draw.aaline(self.buffer, ll.rgba, ll.world_vec, ll.end_vec)
|
||||||
|
|
||||||
def text_render(self, text: str, font: str, color: Color) -> "TODO":
|
def text_render(self, text: str, font: str, color: Color) -> "TODO":
|
||||||
""" returns an intermediated RenderedText """
|
"""returns an intermediated RenderedText"""
|
||||||
# TODO: add accessor text_val
|
|
||||||
return font.render(text, True, color)
|
return font.render(text, True, color)
|
||||||
|
|
||||||
def text_draw(self, txt: "Text"):
|
def text_draw(self, txt: "Text"):
|
||||||
@ -96,6 +91,35 @@ class PygameDrawEngine(DrawEngine):
|
|||||||
text_rect = txt._rendered.get_rect(center=txt.world_vec)
|
text_rect = txt._rendered.get_rect(center=txt.world_vec)
|
||||||
self.buffer.blit(txt._rendered, text_rect)
|
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:
|
class World:
|
||||||
"""
|
"""
|
||||||
@ -181,6 +205,5 @@ class World:
|
|||||||
self.draw_engine.render(self.background_color, self._drawables)
|
self.draw_engine.render(self.background_color, self._drawables)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# our singleton instance
|
# our singleton instance
|
||||||
world = World()
|
world = World()
|
||||||
|
Loading…
Reference in New Issue
Block a user