Merge pull request #2 from jamesturk/new-anim

New Animations, Polygon, and Liskov Demo
This commit is contained in:
James Turk 2024-04-30 21:24:32 -05:00 committed by GitHub
commit 25e1b9d6fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 154 additions and 29 deletions

View File

@ -19,9 +19,9 @@ then there are public/non-public files.)
"""
from .doodles import Doodle, Group
from .lines import Line
from .shapes import Circle, Rectangle
from .shapes import Circle, Rectangle, Polygon
from .color import Color
from .text import Text
__all__ = ["Doodle", "Group", "Line", "Circle", "Rectangle", "Color", "Text"]
__all__ = ["Doodle", "Group", "Line", "Circle", "Rectangle", "Color", "Text", "Polygon"]

View File

@ -12,11 +12,13 @@ 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
from typing import Callable, Self, Any
from .color import Color
from .world import world
UpdateCallable = Callable[[float], Any]
class Doodle(ABC):
"""
@ -38,7 +40,7 @@ class Doodle(ABC):
# annotations for instance attributes
_parent: Self | None
_updates: list[Callable]
_updates: list[tuple[str, UpdateCallable, dict[str, Any]]]
_color: tuple[int, int, int]
_alpha: int
_z_index: float
@ -110,17 +112,11 @@ class Doodle(ABC):
new._register()
return new
# Dynamic Update Logic ############
# animate #######################
# These methods relate to a WIP feature
# designed to demonstrate a functional hybrid
# approach to having objects update themselves.
#
# This feature isn't complete, or documented yet.
# TODO
def register_update(self, method, *args):
self._updates.append((method, args))
def animate(self, prop_name: str, update_func: UpdateCallable, **kwargs) -> Self:
self._updates.append((prop_name, update_func, kwargs))
return self
def update(self) -> None:
"""
@ -130,9 +126,19 @@ class Doodle(ABC):
Can be overriden (see examples.balls)
to provide per-object update behavior.
"""
for method, args in self._updates:
evaled_args = [arg() for arg in args]
method(*evaled_args)
cur_time = time.time()
for prop, anim_func, kwargs 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)
# kwargs (if set) are passed through directly to anim_func
# allowing constant arguments to be passed as well as
# the variable function-based argument (anim_func)
setter = getattr(self, prop)
new_val = anim_func(cur_time)
setter(new_val, **kwargs)
# Setters #######################

View File

@ -1,9 +1,31 @@
import time
import math
from doodles import Circle, Color, Line, Group
def color_func(t):
cycle = [Color.RED, Color.ORANGE, Color.GREEN, Color.BLUE]
return cycle[int(t) % 4]
def size_func_factory(min_size, factor):
def size_function(t):
return math.sin(t) * factor + min_size
return size_function
def create():
g = Group().pos(400, 300)
Circle(g).radius(300).color(Color.BLACK).z(1)
Circle(g).radius(290).color(Color.BROWN).z(10)
Circle(g).color(Color.BLACK).z(1).animate("radius", size_func_factory(260, 50))
Circle(g).z(10).animate("color", color_func).animate(
"radius", size_func_factory(250, 50)
)
Circle(g).radius(20).color(Color.BLACK).z(50)
Line(g).vec(lambda: time.time() % 60 / 60 * 360, 200).z(100)
# Line(g).vec(
# lambda: time.time() % 60 / 60 * 360,
# 200
# ).z(100)
l = Line(g).vec(0, 200).z(100).animate("degrees", lambda t: t % 60 / 60 * 360)
# l.animate("color", color_func)

View File

@ -0,0 +1,25 @@
"""
Demo of the interchangable nature of these classes.
"""
from doodles import Polygon, Line, Rectangle, Circle, Color
import random
import math
types = [Polygon, Line, Rectangle, Circle]
def rainbow(t) -> tuple[int, int, int]:
"""cycles through colors based on time"""
t = t % 1.0
r = int(255 * (1 + math.sin(2 * math.pi * (t + 0.0 / 3))) / 2)
g = int(255 * (1 + math.sin(2 * math.pi * (t + 1.0 / 3))) / 2)
b = int(255 * (1 + math.sin(2 * math.pi * (t + 2.0 / 3))) / 2)
return (r, g, b)
def create():
for _ in range(100):
DoodleType = random.choice(types)
doodle = DoodleType().random().animate("color", rainbow)

View File

@ -0,0 +1,19 @@
from doodles import Polygon, Color
import random
def new_point(t):
return (random.random() * 100 - 50, random.random() * 100 - 50)
def create():
for _ in range(5):
p = Polygon().random(3)
for pt in range(3):
p.animate("point", new_point, to_modify=pt)
p = Polygon().random(8)
for pt in range(8):
p.animate("point", new_point, to_modify=pt)
p = Polygon().random(100)
for pt in range(100):
p.animate("point", new_point, to_modify=pt)

View File

@ -63,7 +63,7 @@ class Line(Doodle):
self._offset_vec = (x, y)
return self
def vec(self, degrees: float | Callable, magnitude: float):
def vec(self, degrees: float, magnitude: float):
"""
Alternate setter, to create offset vector from angle & length.
@ -72,14 +72,16 @@ class Line(Doodle):
directly (`to`), but there is also an alternate option
that handles commonly used case.
"""
if callable(degrees):
self.register_update(
self.to,
lambda: magnitude * math.cos(math.radians(degrees())),
lambda: magnitude * math.sin(math.radians(degrees())),
)
return self
return self.to(
magnitude * math.cos(math.radians(degrees)),
magnitude * math.sin(math.radians(degrees)),
)
def degrees(self, degrees: float):
"""
Alternate setter, like calling vec(new_degrees, old_magnitude).
"""
magnitude = math.sqrt(self._offset_vec[0] ** 2 + self._offset_vec[1] ** 2)
return self.to(
magnitude * math.cos(math.radians(degrees)),
magnitude * math.sin(math.radians(degrees)),

View File

@ -5,7 +5,7 @@ these classes only differ from `Line` in implementation.
The interface & decisions are the same but specific
to `Circle` and `Rectangle`.
"""
from typing import Self
from typing import Self, Optional
import random
from .doodles import Doodle
from .world import world
@ -86,3 +86,48 @@ class Rectangle(Doodle):
return self.width(random.random() * size + 10).height(
random.random() * size + 10
)
class Polygon(Doodle):
"""
All points are *relative* to the center point.
That is to say, if you had a triangle with coordinates:
(100, 100)
(0, 100)
(-100, 0)
And the object was moved to (50, 50), the actual triangle drawn
on screen would be:
(150, 150)
(50, 150)
(50, 50)
"""
_points: list[tuple[float, float]]
def __init__(self, parent=None):
super().__init__(parent)
self._points = []
def __repr__(self):
return f"Polygon(pos={self.world_vec}, points={self._points})"
def draw(self):
world.draw_engine.polygon_draw(self)
def point(
self, point: tuple[float, float], to_modify: Optional[int] = None
) -> Self:
if to_modify is not None:
self._points[to_modify] = point
else:
self._points.append(point)
return self
def random(self, n_points: int = 5) -> Self:
super().random()
for _ in range(n_points):
self.point((random.random() * 100 - 50, random.random() * 100 - 50))
return self

View File

@ -90,6 +90,12 @@ class PygameDrawEngine(DrawEngine):
def line_draw(self, ll: "Line"):
pygame.draw.aaline(self.buffer, ll.rgba, ll.world_vec, ll.end_vec)
def polygon_draw(self, p: "Polygon"):
# calculate offset points from center of world
offset_points = [(x + p.world_x, y + p.world_y) for (x, y) in p._points]
# draw using anti-aliased lines
pygame.draw.aalines(self.buffer, p.rgba, closed=True, points=offset_points)
# TODO: hard to type, revisit
def text_render(self, text: str, font, color: tuple[int, int, int]):
"""returns an intermediated RenderedText"""