doodles-py/src/doodles/doodles.py

423 lines
12 KiB
Python
Raw Normal View History

2024-04-22 03:10:04 +00:00
import random
import copy
import math
import pygame
from abc import ABC, abstractmethod
from .color import Color
from .world import world
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 at (110, 110).
Careful attention is paid in this inheritance hierarchy to the
Liskov substitution principle.
"""
def __init__(self, parent=None):
self._parent = parent
self._color = parent._color if parent else Color.BLACK
2024-04-22 03:34:36 +00:00
self._z_index = 0
2024-04-22 03:10:04 +00:00
# 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)
2024-04-22 05:11:55 +00:00
self._register()
def _register(self):
""" register with parent and world """
if self._parent:
2024-04-22 03:10:04 +00:00
# register with parent for updates
self._parent.add(self)
2024-04-22 03:34:36 +00:00
world.add(self)
2024-04-22 03:10:04 +00:00
@abstractmethod
def draw(self, screen) -> 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) -> "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
2024-04-22 05:11:55 +00:00
this.
2024-04-22 03:10:04 +00:00
"""
2024-04-22 03:34:36 +00:00
new = copy.copy(self)
2024-04-22 05:11:55 +00:00
new._register()
2024-04-22 03:34:36 +00:00
return new
2024-04-22 03:10:04 +00:00
2024-04-22 05:11:55 +00:00
def color(self, color: tuple[int, int, int]) -> "Doodle":
2024-04-22 03:10:04 +00:00
"""
Color works as a kind of setter function.
The only unique part is that it returns self, accomodating the
chained object pattern.
"""
2024-04-22 05:11:55 +00:00
self._color = color
2024-04-22 03:10:04 +00:00
return self
def pos(self, x: float, y: float) -> "Doodle":
"""
Another setter, just like color.
As noted above, this encapsulates our storage decision for our 2D vector.
"""
self._pos_vec = (x, y)
return self
2024-04-22 03:34:36 +00:00
def z_index(self, z: float) -> "Doodle":
"""
Setter for z_index
"""
self._z_index = z
return self
2024-04-22 03:10:04 +00:00
def move(self, dx: float, dy: float) -> "Doodle":
"""
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) -> "Doodle":
"""
Randomize the position and color.
"""
x = random.random() * world.WIDTH
y = random.random() * world.HEIGHT
2024-04-22 05:11:55 +00:00
color = Color.random()
2024-04-22 03:10:04 +00:00
# again here, we opt to use the setters so that
# future extensions to their behavior will be
# used by all downstream functions
2024-04-22 05:11:55 +00:00
return self.pos(x, y).color(color)
2024-04-22 03:10:04 +00:00
@property
def x(self) -> float:
"""
A read-only attribute "doodle.x" that will
return the screen position derived from the parent position
plus the current object's x component.
Note the recursion here, parent.x is an instance of doodle.x.
For example:
A.x = 100
|--------B.x 10
|--------C.x 20
When drawing object C, parent.x will call B.x, which will call A.x.
B.x will return 110, and C.x will therefore return 130.
"""
if self._parent:
return self._parent.x + self._pos_vec[0]
return self._pos_vec[0]
@property
def y(self) -> float:
"""
See documentation for .x above.
"""
if self._parent:
return self._parent.y + self._pos_vec[1]
return self._pos_vec[1]
2024-04-22 03:34:36 +00:00
# @property
# def z_index(self) -> float:
# return self._z_index
2024-04-22 03:10:04 +00:00
@property
def pos_vec(self) -> (float, float):
"""
Obtain derived position vector as a 2-tuple.
"""
return self.x, self.y
class Line(Doodle):
def __init__(self, parent=None):
"""
We keep the same interface as Doodle, to follow the Liskov substitution
principle.
We could add more *optional* arguments, but no more required ones
than the parent class.
"""
super().__init__(parent)
# a line is stored as a position (on the parent class)
# and an offset vector
self._offset_vec = (10, 0)
def __repr__(self):
return f"Line(pos={self.pos_vec}, end={self.end_vec}, {self._color})"
def draw(self, screen):
"""
Implementation of the abstract draw function for the line.
Note: This is a classic violation of single responsibility.
Instead, you could imagine a class like:
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.
"""
pygame.draw.line(screen, self._color, self.pos_vec, self.end_vec)
def to(self, x: float, y: float) -> "Doodle":
"""
A setter for the line's offset vector.
Example usage:
Line().pos(10, 10).to(50, 50)
Makes a line from (10, 10) to (50, 50).
"""
self._offset_vec = (x, y)
return self
def vec(self, degrees: float, magnitude: float):
"""
Alternate constructor, to create offset vector from angle & length.
"""
return self.to(
magnitude * math.cos(math.radians(degrees)),
magnitude * math.sin(math.radians(degrees)),
)
def random(self) -> "Doodle":
"""
Overrides the parent's random, by extending the behavior.
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.
Instead we just call it, and extend it with additional
randomization.
"""
super().random()
magnitude = random.random() * 100
degrees = random.random() * 360
return self.vec(degrees, magnitude)
@property
def end_vec(self):
"""
Parallel to pos_vec for end of line.
"""
return (
self.x + self._offset_vec[0],
self.y + self._offset_vec[1],
)
class Group(Doodle):
"""
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.
"""
2024-04-22 05:11:55 +00:00
def __init__(self, parent=None):
super().__init__(parent)
2024-04-22 03:10:04 +00:00
self._doodles = []
2024-04-22 05:11:55 +00:00
def __repr__(self):
return f"Group(pos={self.pos_vec}, doodles={len(self._doodles)})"
2024-04-22 03:10:04 +00:00
def draw(self, screen):
"""
Groups, despite being an abstract concept, are drawable.
To draw a group is to draw everything in it.
2024-04-22 03:34:36 +00:00
This is done by default, since all drawables will be
registered with the scene upon creation.
2024-04-22 03:10:04 +00:00
"""
2024-04-22 03:34:36 +00:00
pass
2024-04-22 03:10:04 +00:00
def copy(self) -> "Group":
"""
An override.
We are storing a list, so deep copies are necessary.
"""
2024-04-22 05:11:55 +00:00
new = copy.copy(self)
new._register()
new._doodles = []
for child in self._doodles:
child = copy.copy(child)
child._parent = new
child._register()
2024-04-22 03:34:36 +00:00
return new
2024-04-22 03:10:04 +00:00
2024-04-22 05:11:55 +00:00
def color(self, color: tuple[int, int, int]) -> "Doodle":
2024-04-22 03:10:04 +00:00
"""
Another override.
Nothing will ever be drawn in the parent
color, but we do want to have the set
cascade down to child objects.
We don't cascade pos() calls, why not?
"""
2024-04-22 05:11:55 +00:00
super().color(color)
2024-04-22 03:10:04 +00:00
for d in self._doodles:
2024-04-22 05:11:55 +00:00
d.color(color)
2024-04-22 03:10:04 +00:00
return self
def add(self, doodle: "Doodle") -> "Group":
"""
The only unique method of this class, allowing us
to add objects to the group.
Note the violation of class boundaries 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
2024-04-22 03:34:36 +00:00
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
def __repr__(self):
2024-04-22 05:11:55 +00:00
return f"Circle(pos={self.pos_vec}, radius={self._radius}, {self._color}, parent={self._parent}))"
2024-04-22 03:34:36 +00:00
def draw(self, screen):
pygame.draw.circle(screen, self._color, self.pos_vec, self._radius)
def radius(self, r: float) -> "Doodle":
"""
A setter for the circle's radius.
"""
self._radius = r
return self
def grow(self, by: float):
"""
Modify radius by an amount. (Negative to shrink.)
"""
return self.radius(self._radius + by)
def random(self) -> "Doodle":
super().random()
# constrain to 10-100
2024-04-22 05:11:55 +00:00
return self.radius(random.random()*90 + 10)
2024-04-22 05:23:07 +00:00
class Rectangle(Doodle):
def __init__(self, parent=None):
"""
For compatibility with circle, the rectangle is centered at pos
and expands out width/2, height/2 in each cardinal direction.
"""
super().__init__(parent)
self._width = 100
self._height = 100
def __repr__(self):
return f"Rect(pos={self.pos_vec}, width={self._width}, height={self._height}, parent={self._parent})"
def draw(self, screen):
rect = pygame.Rect(
self.x - self._width/2,
self.y - self._height/2,
self._width,
self._height,
)
pygame.draw.rect(screen, self._color, rect)
def width(self, w: float) -> "Doodle":
"""
A setter for the width
"""
self._width = w
return self
def height(self, h: float) -> "Doodle":
"""
A setter for the 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":
super().random()
# constrain to 10-100
return self.width(random.random()*upper + 10).height(random.random()*upper + 10)