api cleanup
This commit is contained in:
parent
3bfa6e0eaf
commit
67c2d5093d
94
README.md
94
README.md
@ -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
|
@ -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):
|
||||
"""
|
||||
|
@ -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)
|
||||
]
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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))
|
||||
# )
|
||||
|
11
src/doodles/examples/scale.py
Normal file
11
src/doodles/examples/scale.py
Normal 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)
|
@ -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)
|
||||
|
@ -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],
|
||||
)
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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":
|
||||
|
@ -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()
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user