diff --git a/README.md b/README.md index e69de29..aeb0880 100644 --- a/README.md +++ b/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 diff --git a/src/doodles/doodles.py b/src/doodles/doodles.py index e0ddf71..3bef6f5 100644 --- a/src/doodles/doodles.py +++ b/src/doodles/doodles.py @@ -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): """ diff --git a/src/doodles/examples/balls.py b/src/doodles/examples/balls.py index 464e594..0b4db70 100644 --- a/src/doodles/examples/balls.py +++ b/src/doodles/examples/balls.py @@ -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) + ] diff --git a/src/doodles/examples/circles.py b/src/doodles/examples/circles.py index 681f67f..8c38996 100644 --- a/src/doodles/examples/circles.py +++ b/src/doodles/examples/circles.py @@ -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) diff --git a/src/doodles/examples/clock.py b/src/doodles/examples/clock.py index 03e6bb9..0d75495 100644 --- a/src/doodles/examples/clock.py +++ b/src/doodles/examples/clock.py @@ -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) diff --git a/src/doodles/examples/copies.py b/src/doodles/examples/copies.py index 36d7612..2bfaad0 100644 --- a/src/doodles/examples/copies.py +++ b/src/doodles/examples/copies.py @@ -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) diff --git a/src/doodles/examples/rects.py b/src/doodles/examples/rects.py index b672152..1f03a5e 100644 --- a/src/doodles/examples/rects.py +++ b/src/doodles/examples/rects.py @@ -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)) # ) diff --git a/src/doodles/examples/scale.py b/src/doodles/examples/scale.py new file mode 100644 index 0000000..459d917 --- /dev/null +++ b/src/doodles/examples/scale.py @@ -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) diff --git a/src/doodles/examples/words.py b/src/doodles/examples/words.py index 9754178..a1c4651 100644 --- a/src/doodles/examples/words.py +++ b/src/doodles/examples/words.py @@ -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) diff --git a/src/doodles/lines.py b/src/doodles/lines.py index 2865692..a4b3201 100644 --- a/src/doodles/lines.py +++ b/src/doodles/lines.py @@ -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], ) diff --git a/src/doodles/shapes.py b/src/doodles/shapes.py index 7f2407b..84dbe55 100644 --- a/src/doodles/shapes.py +++ b/src/doodles/shapes.py @@ -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, ) diff --git a/src/doodles/text.py b/src/doodles/text.py index 50bb9c0..5f71a4c 100644 --- a/src/doodles/text.py +++ b/src/doodles/text.py @@ -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": diff --git a/src/doodles/world.py b/src/doodles/world.py index 53036b4..b791fff 100644 --- a/src/doodles/world.py +++ b/src/doodles/world.py @@ -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() -