api cleanup

This commit is contained in:
James Turk 2024-04-22 22:54:18 -05:00
parent 3bfa6e0eaf
commit 67c2d5093d
13 changed files with 200 additions and 65 deletions

View File

@ -0,0 +1,94 @@
# Doodles
This is a library that lets you write short programs that draw images or animations.
The design goals are, in approximate order of importance:
* Demonstrate some design patterns & concepts.
* Provide an example of a library design.
* Have a learning tool that's somewhat fun to play with.
Notably absent from this list: "make a useful tool" and "demonstrate the *right* way to do things."
This library demonstrates *ways* to do things, there is almost never a single correct way.
Sometimes choices will be made to provide more interesting code to learn from, or a more fun API to use.
## Rationales
I will document the reason that certain decisions were made, particularly when they were not my first thought.
If any decisions are unclear, I consider that a bug, please file a corresponding GitHub issue.
### Mutability
Often, an API based on chaining like this would be comprised of immutable objects.
Django's QuerySet mostly works in this way.
That was the original intention here as well, but once `Text` was added it was clear that it was going to be a lot more complex than it was worth.
`Text` requires an intermediate buffer to render the text to, and that buffer is then rendered to the screen. This would be incredibly expensive in a pure immutable approach, since there'd be no place to store that state.
The addition of state to the objects simplifies a lot of things, now when an object is created it can be registered with the `World`, if many intermediate copies were created in a chained call like `Circle().pos(100, 100).color(255, 0, 0).z(10).radius(4)` this approach would not be available to us.
### Naming Scheme
This library makes use of @property to create getters, but uses function chaining instead of property-based setters.
This complicates things a bit, since you can't write `self.x(100)` and use `self.x` as a property.
The unorthodox decision was made to use `x(100)` as a setter function, and use `.x_val` as the property name.
This is primarily because setting properties is more common than getting properties in doodles.
## Design Patterns
TODO: flesh this out with more notes
A number of design patterns are utilized in this library.
I'm open to suggestions for more to add, especially where an interesting feature can demonstrate the utility.
### Factory
TODO: No factory yet surprisingly, should be easy to add one to add shapes, but can consider other options.
### Prototype
The `Doodle` class (and an override in `Group`) demonstrate the utility of the Prototype pattern.
Letting these objects specify how they are copied makes the desired behavior possible, the utility of which can be seen in `examples/copies.py`.
This would often be done via the `__copy__/__deepcopy__` methods, but for simplicity as well as API compatibility this is done in `.copy()` here.
### Singleton
The `World` class is treated as a Singleton and contains notes on alternate implementations.
### Bridge / Strategy
TODO: the planned Renderer class will demonstrate these
### Composite
The Group class (along with Doodle) forms a composite.
### Command
The structure of the Doodle object is a command class.
It stores the information about the action to be performed encapsulated.
It's entire purpose is to provide arguments to a draw* method.
### Observer
TODO: room to implement, dynamic properties?
### Template Method
The update method, the draw method.
### Visitor
TODO: maybe use along with group?
### Flyweight Cache
Text's Font Cache is a flyweight

View File

@ -25,9 +25,10 @@ class Doodle(ABC):
def __init__(self, parent=None):
self._parent = parent
self._color = parent._color if parent else Color.BLACK
self._z_index = 0
self._updates = []
self._color = parent._color if parent else Color.BLACK
self._alpha = parent._alpha if parent else 255
self._z_index = 0
# 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.
@ -38,7 +39,7 @@ class Doodle(ABC):
self._register()
def _register(self):
""" register with parent and world """
"""register with parent and world"""
if self._parent:
# register with parent for updates
self._parent.add(self)
@ -87,6 +88,8 @@ class Doodle(ABC):
new._register()
return new
# Setters #######################
def color(self, color: tuple[int, int, int]) -> "Doodle":
"""
Color works as a kind of setter function.
@ -106,13 +109,34 @@ class Doodle(ABC):
self._pos_vec = (x, y)
return self
def z_index(self, z: float) -> "Doodle":
def x(self, x: float) -> "Doodle":
"""
Setter for x component.
"""
self._pos_vec = (x, self._pos_vec[1])
def y(self, y: float) -> "Doodle":
"""
Setter for x component.
"""
self._pos_vec = (self._pos_vec[0], y)
def alpha(self, a: int) -> "Doodle":
"""
Setter for alpha transparency
"""
self._alpha = a
return self
def z(self, z: float) -> "Doodle":
"""
Setter for z_index
"""
self._z_index = z
return self
# Modifiers #################
def move(self, dx: float, dy: float) -> "Doodle":
"""
This shifts the vector by a set amount.
@ -135,47 +159,52 @@ class Doodle(ABC):
# used by all downstream functions
return self.pos(x, y).color(color)
# Getters ################
@property
def x(self) -> float:
def world_x(self) -> float:
"""
A read-only attribute "doodle.x" that will
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.
Note the recursion here, parent.x is an instance of doodle.x.
Note the recursion here, parent.world_x is an instance of doodle.world_x.
For example:
A.x = 100
|--------B.x 10
|--------C.x 20
A x = 100
|--------B x = 10
|--------C.world_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.
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.x + self._pos_vec[0]
return self._parent.world_x + self._pos_vec[0]
return self._pos_vec[0]
@property
def y(self) -> float:
def world_y(self) -> float:
"""
See documentation for .x above.
See documentation for .world_y above.
"""
if self._parent:
return self._parent.y + self._pos_vec[1]
return self._parent.world_y + self._pos_vec[1]
return self._pos_vec[1]
# @property
# def z_index(self) -> float:
# return self._z_index
@property
def pos_vec(self) -> (float, float):
def world_vec(self) -> (float, float):
"""
Obtain derived position vector as a 2-tuple.
"""
return self.x, self.y
return self.world_x, self.world_y
@property
def rgba(self) -> (int, int, int, int):
"""
"""
return (*self._color, self._alpha)
class Group(Doodle):
@ -200,7 +229,7 @@ class Group(Doodle):
self._doodles = []
def __repr__(self):
return f"Group(pos={self.pos_vec}, doodles={len(self._doodles)})"
return f"Group(pos={self.world_vec}, doodles={len(self._doodles)})"
def draw(self, screen):
"""

View File

@ -14,6 +14,7 @@ it is left as a pass-through like we see here.
Objects without an update method are static.
"""
class Ball(Circle):
def __init__(self):
super().__init__()
@ -21,24 +22,27 @@ class Ball(Circle):
def update(self):
self.move(0, self.speed)
if self.y > world.HEIGHT + 20:
self.move(0, -world.HEIGHT-20)
if self.world_y > world.HEIGHT + 20:
self.move(0, -world.HEIGHT - 20)
class GravityBall(Circle):
def __init__(self):
super().__init__()
self.accel = 0.5 # accel per frame
self.accel = 0.5 # accel per frame
self.speed = random.random() * 10
def update(self):
self.speed += self.accel
self.move(0, self.speed)
if self.y > world.HEIGHT - 10:
self.speed *= -0.98 # dampening
self.pos(self.x, world.HEIGHT - 10.01)
if self.world_y > world.HEIGHT - 10:
self.speed *= -0.98 # dampening
self.y(world.HEIGHT - 10.01)
def create():
[Ball().pos(40*i, 0).radius(10).color(Color.BLUE) for i in range(21)]
[GravityBall().pos(20+40*i, 0).radius(10).color(Color.PURPLE) for i in range(21)]
[Ball().pos(40 * i, 0).radius(10).color(Color.BLUE) for i in range(21)]
[
GravityBall().pos(20 + 40 * i, 0).radius(10).color(Color.PURPLE)
for i in range(21)
]

View File

@ -10,6 +10,6 @@ def create():
color = color_cycle()
g = Group().pos(400, 300)
for r in range(20, 100, 12):
Circle(g).radius(r).color(next(color)).z_index(-r)
Circle(g).radius(r).color(next(color)).z(-r)
for r in range(100, 250, 12):
Circle(g).radius(r).color(next(color)).z_index(-r)
Circle(g).radius(r).color(next(color)).z(-r)

View File

@ -3,7 +3,7 @@ from doodles import Circle, Color, Line, Group
def create():
g = Group().pos(400, 300)
Circle(g).radius(300).color(Color.BLACK).z_index(1)
Circle(g).radius(290).color(Color.BROWN).z_index(10)
Circle(g).radius(20).color(Color.BLACK).z_index(50)
Line(g).vec(lambda: time.time() % 60 / 60 * 360, 200).z_index(100)
Circle(g).radius(300).color(Color.BLACK).z(1)
Circle(g).radius(290).color(Color.BROWN).z(10)
Circle(g).radius(20).color(Color.BLACK).z(50)
Line(g).vec(lambda: time.time() % 60 / 60 * 360, 200).z(100)

View File

@ -1,13 +1,9 @@
from doodles import Group, Circle, Color
def original():
def create():
g = Group()
c = Circle(g).radius(80).color(Color.RED).pos(0, 0)
for _ in range(15):
c = c.copy().move(45, 45)
return g
def create():
r = original()
r.copy().move(200, 0).color(Color.GREEN)
r.copy().move(400, 0).color(Color.BLUE)
g.copy().move(200, 0).color(Color.GREEN)
g.copy().move(400, 0).color(Color.BLUE)

View File

@ -3,10 +3,10 @@ import random
def create():
for _ in range(25):
Rectangle().random(200).color(Color.BLACK).z_index(10)
Rectangle().random(150).color(Color.DARK_BLUE).z_index(15)
Rectangle().random(100).color(Color.DARK_GREY).z_index(20)
Rectangle().random(50).color(Color.LIGHT_GREY).z_index(30)
Rectangle().random(200).color(Color.BLACK).z(10)
Rectangle().random(150).color(Color.DARK_BLUE).z(15)
Rectangle().random(100).color(Color.DARK_GREY).z(20)
Rectangle().random(50).color(Color.LIGHT_GREY).z(30)
# Rectangle().random(250).color(
# random.choice((Color.BLACK, Color.LIGHT_GREY, Color.DARK_GREY, Color.WHITE))
# )

View File

@ -0,0 +1,11 @@
from doodles import Group, Circle, Color
def tri():
g = Group()
Circle(g).radius(50).color(Color.RED).pos(0, 0).alpha(128)
Circle(g).radius(50).color(Color.GREEN).pos(-25, 35).alpha(128)
Circle(g).radius(50).color(Color.BLUE).pos(25, 35).alpha(128)
return g
def create():
r = tri().move(200, 200)

View File

@ -7,7 +7,6 @@ from doodles import Group, Circle, Color, Text
Text.make_font("small", 16, "mono")
Text.make_font("medium", 24, "copperplate")
Text.make_font("large", 48, "papyrus")
print(Text._fonts)
# Via ChatGPT
hello_world = [
@ -39,4 +38,4 @@ def create():
for greeting in itertools.chain.from_iterable(itertools.repeat(hello_world, 3)):
Text().random().font(
random.choice(("small", "medium", "large"))
).text(greeting)
).color(random.choice((Color.LIGHT_GREY, Color.DARK_GREY))).text(greeting)

View File

@ -20,7 +20,7 @@ class Line(Doodle):
self._offset_vec = (10, 0)
def __repr__(self):
return f"Line(pos={self.pos_vec}, end={self.end_vec}, {self._color})"
return f"Line(pos={self.world_vec}, end={self.end_vec}, {self._color})"
def draw(self, screen):
"""
@ -41,7 +41,7 @@ class Line(Doodle):
to the class and gaining flexibility from separating
presentation logic from data manipulation.
"""
pygame.draw.aaline(screen, self._color, self.pos_vec, self.end_vec)
pygame.draw.aaline(screen, self._color, self.world_vec, self.end_vec)
def to(self, x: float, y: float) -> "Doodle":
"""
@ -93,9 +93,9 @@ class Line(Doodle):
@property
def end_vec(self):
"""
Parallel to pos_vec for end of line.
Parallel to world_vec for end of line.
"""
return (
self.x + self._offset_vec[0],
self.y + self._offset_vec[1],
self.world_x + self._offset_vec[0],
self.world_y + self._offset_vec[1],
)

View File

@ -13,10 +13,10 @@ class Circle(Doodle):
self._radius = 0
def __repr__(self):
return f"Circle(pos={self.pos_vec}, radius={self._radius}, {self._color}, parent={self._parent}))"
return f"Circle(pos={self.world_vec}, radius={self._radius}, {self._color}, parent={self._parent}))"
def draw(self, screen):
pygame.draw.circle(screen, self._color, self.pos_vec, self._radius)
pygame.draw.circle(screen, self.rgba, self.world_vec, self._radius)
def radius(self, r: float) -> "Doodle":
"""
@ -48,12 +48,12 @@ class Rectangle(Doodle):
self._height = 100
def __repr__(self):
return f"Rect(pos={self.pos_vec}, width={self._width}, height={self._height}, parent={self._parent})"
return f"Rect(pos={self.world_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.world_x - self._width / 2,
self.world_y - self._height / 2,
self._width,
self._height,
)

View File

@ -63,7 +63,7 @@ class Text(Doodle):
return f"Text(pos={self.pos_vec}, text={self._text}, parent={self._parent})"
def draw(self, screen):
text_rect = self._rendered.get_rect(center=(self.x, self.y))
text_rect = self._rendered.get_rect(center=self.world_vec)
screen.blit(self._rendered, text_rect)
def text(self, text: str) -> "Doodle":

View File

@ -1,6 +1,7 @@
from .color import Color
import pygame
class World:
"""
This class is a singleton, only one instance should ever exist.
@ -57,6 +58,7 @@ class World:
raise ValueError("Can't initialize world twice!")
pygame.init()
self.screen = pygame.display.set_mode((world.WIDTH, world.HEIGHT))
self.buffer = pygame.Surface((world.WIDTH, world.HEIGHT), pygame.SRCALPHA)
self.clock = pygame.time.Clock()
self._elapsed = 0
@ -81,12 +83,12 @@ class World:
self.tick()
# rendering
self.screen.fill(self.background_color)
self.buffer.fill((*self.background_color, 255))
for d in sorted(self._drawables, key=lambda d: d._z_index):
d.draw(self.screen)
d.draw(self.buffer)
self.screen.blit(self.buffer, (0, 0))
pygame.display.flip()
# our singleton instance
world = World()