diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d20b64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/main.py deleted file mode 100644 index bd9999d..0000000 --- a/main.py +++ /dev/null @@ -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() diff --git a/poetry.lock b/poetry.lock index 78cbfb4..3a294db 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml index b0e6485..d8e964f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,14 @@ [tool.poetry] -name = "drawing" +name = "doodles" version = "0.1.0" description = "" authors = ["Your Name "] readme = "README.md" [tool.poetry.dependencies] -python = "^3.12" +python = "^3.10" pygame = "^2.5.2" +typer = "^0.12.3" [build-system] diff --git a/src/doodles/__init__.py b/src/doodles/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/doodles/color.py b/src/doodles/color.py new file mode 100644 index 0000000..22a8f5b --- /dev/null +++ b/src/doodles/color.py @@ -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. diff --git a/src/doodles/doodles.py b/src/doodles/doodles.py new file mode 100644 index 0000000..71ce213 --- /dev/null +++ b/src/doodles/doodles.py @@ -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 diff --git a/src/doodles/examples/grid.py b/src/doodles/examples/grid.py new file mode 100644 index 0000000..2154676 --- /dev/null +++ b/src/doodles/examples/grid.py @@ -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) diff --git a/src/doodles/layouts.py b/src/doodles/layouts.py new file mode 100644 index 0000000..6ecc814 --- /dev/null +++ b/src/doodles/layouts.py @@ -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() diff --git a/src/doodles/main.py b/src/doodles/main.py new file mode 100644 index 0000000..506369d --- /dev/null +++ b/src/doodles/main.py @@ -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) diff --git a/src/doodles/world.py b/src/doodles/world.py new file mode 100644 index 0000000..36ea360 --- /dev/null +++ b/src/doodles/world.py @@ -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() +