This commit is contained in:
James Turk 2024-04-21 22:10:04 -05:00
parent be4cdd31d7
commit fca6d11504
12 changed files with 641 additions and 151 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*.pyc

0
README.md Normal file
View File

148
main.py
View File

@ -1,148 +0,0 @@
from abc import ABC, abstractmethod
import sys
import copy
import random
import math
import pygame
WHITE = (255, 255, 255)
BLACK = (0, 0, 0)
WIDTH = 800
HEIGHT = 600
class Doodle(ABC):
def __init__(self):
self._pos_vec = (0, 0)
self._color = BLACK
@abstractmethod
def draw(self, screen) -> None:
pass
def copy(self):
return copy.copy(self)
def color(self, r, g, b):
self._color = (r, g, b)
return self
def pos(self, x, y):
self._pos_vec = (x, y)
return self
def move(self, dx, dy):
return self.pos(self.x + dx, self.y + dy)
def random(self):
x = random.random() * WIDTH
y = random.random() * HEIGHT
r = random.random() * 255
g = random.random() * 255
b = random.random() * 255
return self.pos(x, y).color(r, g, b)
@property
def pos_vec(self):
return self._pos_vec
@property
def x(self):
return self._pos_vec[0]
@property
def y(self):
return self._pos_vec[1]
class Line(Doodle):
def __init__(self):
super().__init__()
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):
pygame.draw.line(screen, self._color, self.pos_vec, self.end_vec)
def to(self, x, y):
self._offset_vec = (x, y)
return self
def random(self):
super().random()
magnitude = random.random() * 100
degrees = random.random() * 360
return self.vec(degrees, magnitude)
def vec(self, degrees, magnitude):
return self.to(magnitude * math.cos(math.radians(degrees)),
magnitude * math.sin(math.radians(degrees)))
@property
def end_vec(self):
return (self.pos_vec[0] + self._offset_vec[0],
self.pos_vec[1] + self._offset_vec[1],
)
class Group(Doodle):
def __init__(self):
super().__init__()
self._doodles = []
def draw(self, screen):
for d in self._doodles:
d.draw(screen)
def color(self, r, g, b):
for d in self._doodles:
d.color(r, g, b)
def copy(self):
return copy.deepcopy(self)
def add(self, doodle):
self._doodles.append(doodle)
return self
def move(self, dx, dy):
for d in self._doodles:
d.move(dx, dy)
return self
def render_scene(screen, background, *drawables):
screen.fill(background)
for d in drawables:
d.draw(screen)
def main():
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Doodles")
g = Group()
for d in range(100, 200, 10):
g.add(
Line().pos(300, 300).vec(d, 100)
)
g2 = g.copy()
g2.move(20, 20)
g3 = g.copy()
g3.move(100, 100).color(0, 100, 100)
g3.add(Line().random().pos(g3.x, g3.y))
g3.add(Line().random().pos(g3.x, g3.y))
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
render_scene(screen, WHITE, g, g2, g3)
pygame.display.flip()
if __name__ == "__main__":
main()

134
poetry.lock generated
View File

@ -1,5 +1,65 @@
# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
[[package]]
name = "click"
version = "8.1.7"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.7"
files = [
{file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
{file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
]
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "markdown-it-py"
version = "3.0.0"
description = "Python port of markdown-it. Markdown parsing, done right!"
optional = false
python-versions = ">=3.8"
files = [
{file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"},
{file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"},
]
[package.dependencies]
mdurl = ">=0.1,<1.0"
[package.extras]
benchmarking = ["psutil", "pytest", "pytest-benchmark"]
code-style = ["pre-commit (>=3.0,<4.0)"]
compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"]
linkify = ["linkify-it-py (>=1,<3)"]
plugins = ["mdit-py-plugins"]
profiling = ["gprof2dot"]
rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"]
testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
[[package]]
name = "mdurl"
version = "0.1.2"
description = "Markdown URL utilities"
optional = false
python-versions = ">=3.7"
files = [
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
]
[[package]]
name = "pygame"
version = "2.5.2"
@ -66,7 +126,79 @@ files = [
{file = "pygame-2.5.2.tar.gz", hash = "sha256:c1b89eb5d539e7ac5cf75513125fb5f2f0a2d918b1fd6e981f23bf0ac1b1c24a"},
]
[[package]]
name = "pygments"
version = "2.17.2"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.7"
files = [
{file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"},
{file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"},
]
[package.extras]
plugins = ["importlib-metadata"]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "rich"
version = "13.7.1"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
optional = false
python-versions = ">=3.7.0"
files = [
{file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"},
{file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"},
]
[package.dependencies]
markdown-it-py = ">=2.2.0"
pygments = ">=2.13.0,<3.0.0"
[package.extras]
jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]]
name = "shellingham"
version = "1.5.4"
description = "Tool to Detect Surrounding Shell"
optional = false
python-versions = ">=3.7"
files = [
{file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"},
{file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"},
]
[[package]]
name = "typer"
version = "0.12.3"
description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
optional = false
python-versions = ">=3.7"
files = [
{file = "typer-0.12.3-py3-none-any.whl", hash = "sha256:070d7ca53f785acbccba8e7d28b08dcd88f79f1fbda035ade0aecec71ca5c914"},
{file = "typer-0.12.3.tar.gz", hash = "sha256:49e73131481d804288ef62598d97a1ceef3058905aa536a1134f90891ba35482"},
]
[package.dependencies]
click = ">=8.0.0"
rich = ">=10.11.0"
shellingham = ">=1.3.0"
typing-extensions = ">=3.7.4.3"
[[package]]
name = "typing-extensions"
version = "4.11.0"
description = "Backported and Experimental Type Hints for Python 3.8+"
optional = false
python-versions = ">=3.8"
files = [
{file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"},
{file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"},
]
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "34500777ecbd96a198c62609dff3a5f84da563dcb329987f25690e587613541d"
content-hash = "62a9e20d34820e32f43e8980e200ee7607f2a717ef39bdc9cd89000777e450ab"

View File

@ -1,13 +1,14 @@
[tool.poetry]
name = "drawing"
name = "doodles"
version = "0.1.0"
description = ""
authors = ["Your Name <you@example.com>"]
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.12"
python = "^3.10"
pygame = "^2.5.2"
typer = "^0.12.3"
[build-system]

0
src/doodles/__init__.py Normal file
View File

47
src/doodles/color.py Normal file
View File

@ -0,0 +1,47 @@
import random
class Color:
"""
This class is being used as a namespace, similar to an `enum.Enum` class.
This is done to group many global variables into a namespace for clarity.
There is no intention for anyone to ever declare an instance of this class.
Instead it will be used like Color.BLACK
Palette Source: https://pico-8.fandom.com/wiki/Palette
"""
BLACK = (0, 0, 0)
DARK_BLUE = (29, 43, 83)
PURPLE = (126, 37, 83)
DARK_GREEN = (0, 135, 81)
BROWN = (171, 82, 54)
DARK_GREY = (95, 87, 79)
LIGHT_GREY = (194, 195, 199)
WHITE = (255, 241, 232)
RED = (255, 0, 77)
ORANGE = (255, 163, 0)
YELLOW = (255, 236, 39)
GREEN = (0, 228, 54)
BLUE = (41, 173, 255)
LAVENDER = (131, 118, 156)
PINK = (255, 119, 168)
LIGHT_PEACH = (255, 204, 170)
@staticmethod
def all():
colors = list(Color.__dict__.values())
return [color for color in colors if isinstance(color, tuple)]
@staticmethod
def random():
return random.choice(Color.all())
# Another option would be to just have these be bare methods at the `color` module and
# encourage use like color.random()
# The two are equivalent, but this way makes someone accidentally importing `random()`
# impossible.

313
src/doodles/doodles.py Normal file
View File

@ -0,0 +1,313 @@
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
# 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)
if parent:
# register with parent for updates
self._parent.add(self)
@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
this to opt for a deepcopy or other logic.
"""
return copy.copy(self)
def color(self, r: int, g: int, b: int) -> "Doodle":
"""
Color works as a kind of setter function.
The only unique part is that it returns self, accomodating the
chained object pattern.
"""
self._color = (r, g, b)
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
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
r, g, b = Color.random()
# again here, we opt to use the setters so that
# future extensions to their behavior will be
# used by all downstream functions
return self.pos(x, y).color(r, g, b)
@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]
@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.
"""
def __init__(self):
super().__init__()
self._doodles = []
def draw(self, screen):
"""
Groups, despite being an abstract concept, are drawable.
To draw a group is to draw everything in it.
The draw logic is to just call the draw method of all children.
This works because we know that all doodles are guaranteed
to have such a method.
"""
for d in self._doodles:
d.draw(screen)
def copy(self) -> "Group":
"""
An override.
We are storing a list, so deep copies are necessary.
"""
return copy.deepcopy(self)
def color(self, r: int, g: int, b: int) -> "Doodle":
"""
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?
"""
super().color(r, g, b)
for d in self._doodles:
d.color(r, g, b)
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

View File

@ -0,0 +1,10 @@
from doodles.doodles import Group, Line
from doodles.layouts import make_grid, copies
# Create a group of lines all with same origin, different angles.
g = Group()
for d in range(0, 180, 10):
Line(g).vec(d, 200 - d)
# Make copies, moving each one and modifying the color
make_grid(copies(g), 3, 4, 250, 140, x_offset=70, y_offset=20)

27
src/doodles/layouts.py Normal file
View File

@ -0,0 +1,27 @@
from .world import world
def make_grid(iterable, cols, rows, width, height, *, x_offset=0, y_offset=0):
"""
Arranges the objects in iterable in a grid with the given parameters.
"""
try:
doodle = next(iterable)
for c in range(cols):
for r in range(rows):
doodle.pos(width * c + x_offset, height * r + y_offset)
world.add(doodle)
doodle = next(iterable)
except StopIteration:
pass
def copies(doodle):
"""
Lazily makes an infinite number of copies of a given doodle.
Can be combined with things like `make_grid` that require
an iterable of doodles to repeat.
"""
while True:
yield doodle.copy()

36
src/doodles/main.py Normal file
View File

@ -0,0 +1,36 @@
import sys
import copy
import random
import math
import pygame
import importlib
import typer
from .world import world
def main(modname: str):
pygame.init()
world.init()
pygame.display.set_caption("Doodles")
try:
# try fully-qualified first
mod = importlib.import_module(modname)
except ImportError:
# fall back to example
try:
mod = importlib.import_module("doodles.examples." + modname)
except ImportError:
raise ImportError(f"Tried to import {modname} and doodles.examples.{modname}")
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
world.render()
pygame.display.flip()
if __name__ == "__main__":
typer.run(main)

71
src/doodles/world.py Normal file
View File

@ -0,0 +1,71 @@
from .color import Color
import pygame
class World:
"""
This class is a singleton, only one instance should ever exist.
A common reason for this is a class that manages a resource of some kind,
in this case, our screen.
This class needs to track where entities are in relation to the screen,
and will hold a reference to a pygame variable that lets it draw to the screen.
Multiple instances of this class would
There are two schools of thought about this pattern:
You can use this pattern as we do here, with no extra code.
We instead define an instance variable "world" below, and
documentation would show users to use that global variable.
It would technically be possible for someone to instantiate
a "world2 = World()" instance, but doing so would be admonished
in documentation and not supported.
You could optionally denote this by naming the class _World.
Others prefer to have code-level enforcement of this policy.
There's a nearly infinite number of ways to do this, including
simply keeping a global "_instance_created" variable that gets
set to a non-None value after first creation, and then
raises an exception on invalid use, or with a bit of cleverness
returns the single-instance no matter how hard the user
tries to create a new one.
"""
WIDTH = 800
HEIGHT = 600
_instance = None
def __init__(self):
# This logic forms the basis of a check for prior instances.
# Code could be added here to explicitly disallow them.
if self._instance is None:
self._instance = self
self._drawables = []
self.background_color = Color.WHITE
self.screen = None
def init(self):
"""
Delayed initialization, can't be run at start
but must be run once.
"""
if self.screen:
raise ValueError("Can't initialize world twice!")
self.screen = pygame.display.set_mode((world.WIDTH, world.HEIGHT))
def add(self, drawable):
self._drawables.append(drawable)
def render(self):
"""
Draw world to screen
"""
self.screen.fill(self.background_color)
for d in self._drawables:
d.draw(self.screen)
# our singleton instance
world = World()