doodles-py/src/doodles/doodles.py

403 lines
13 KiB
Python

"""
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
import time
from abc import ABC, abstractmethod
from typing import Callable, Self, Any
from .color import Color
from .world import world
UpdateCallable = Callable[[float], Any]
class Doodle(ABC):
"""
A doodle is a set of drawing primitives.
Each doodle has a position and a color.
Default: (0, 0) & black
Additionally a doodle can have a parent,
which forms the basis of a hierarchy between them.
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 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[tuple[str, UpdateCallable]]
_color: tuple[int, int, int]
_alpha: int
_z_index: float
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.
#
# 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):
"""
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:
self._parent.add(self)
world.add(self)
@abstractmethod
def draw(self) -> None:
"""
All doodles need to be drawable, but there is no
way we can provide an implementation without
knowing more about a concrete shape (Circle, Line, etc.)
We define this interface, and mark it abstract so that
derived classes will be forced to conform to it.
"""
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
# animate #######################
def animate(self, prop_name: str, update_func: UpdateCallable) -> Self:
self._updates.append((prop_name, update_func))
return self
def update(self) -> None:
"""
Default implementation is to support
update function behavior.
Can be overriden (see examples.balls)
to provide per-object update behavior.
"""
cur_time = time.time()
for prop, anim_func in self._updates:
# attributes on Doodle are set via setter functions
# prop is the name of a setter function, which we
# retrieve here, and then populate with the result
# of anim_func(time)
setter = getattr(self, prop)
new_val = anim_func(cur_time)
setter(new_val)
# Setters #######################
# 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.
The only unique part is that it returns self, accomodating the
chained object pattern.
"""
self._color = color
return self
def pos(self, x: float, y: float) -> Self:
"""
Another setter, just like color.
As noted above, this encapsulates our storage decision for our 2D vector.
"""
self._pos_vec = (x, y)
return self
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) -> Self:
"""
Setter for x component.
"""
self._pos_vec = (self._pos_vec[0], y)
return self
def alpha(self, a: int) -> Self:
"""
Setter for alpha transparency
"""
self._alpha = a
return self
def z(self, z: float) -> Self:
"""
Setter for z_index
"""
self._z_index = z
return self
# Modifiers #################
# 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.
By calling self.pos() instead of setting the vector again
here it will make use of any future validation logic added to that
function.
"""
return self.pos(self._pos_vec[0] + dx, self._pos_vec[1] + dy)
def random(self) -> Self:
"""
Randomize the position and color.
"""
x = random.random() * world.WIDTH
y = random.random() * world.HEIGHT
color = Color.random()
# again here, we opt to use the setters so that
# future extensions to their behavior will be
# used by all downstream functions
return self.pos(x, y).color(color)
# Getters ################
@property
def world_x(self) -> float:
"""
A read-only attribute "doodle.world_x" that will
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 another example:
A x = 100
|--------B x = 10
|--------C.world_x 20
When drawing object C, world_x will call B.world_x which will call
A.world_x.
B will return 110, and C therefore returns 130.
"""
if self._parent:
return self._parent.world_x + self._pos_vec[0]
return self._pos_vec[0]
@property
def world_y(self) -> float:
"""
See documentation for .world_y above.
"""
if self._parent:
return self._parent.world_y + self._pos_vec[1]
return self._pos_vec[1]
@property
def world_vec(self) -> tuple[float, float]:
"""
Obtain derived position vector as a 2-tuple.
"""
return self.world_x, self.world_y
@property
def rgba(self) -> tuple[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.
(This is analagous to files & directories making up a tree hierarchy.)
This is an example of a design that requires cooperation between
two classes.
Each Doodle needs a _parent reference, which should only ever
be a Group.
In turn, each Group has a list of _doodles.
This design is possible in Python due to light type coupling, but
in some languages would be much trickier to pull off.
"""
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) -> 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) -> Self:
"""
An override of copy that handles the special
case of having a mutable list of Doodles
as an attribute.
"""
# 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]) -> Self:
"""
An override of Doodle.color.
What _color should do on Groups is a bit ambiguous.
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) -> Self:
"""
The only unique method of this class, allowing us
to add objects to the group.
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
return self