Simple script to demonstrate animating software architecture diagrams using PyGame
Requires * brew install imagemagick
Usage: ./arch-animate.py -h
import logging
import os
import subprocess
import sys
from argparse import ArgumentParser, RawDescriptionHelpFormatter
from pathlib import Path
import pygame as pg
from pygame.locals import QUIT
from common_utils import setup_logging
def parse_args():
parser = ArgumentParser(description=__doc__, formatter_class=RawDescriptionHelpFormatter)
parser.add_argument(
"-c", "--convert-to-animation", default=False, action="store_true", help="Generate animated gif"
)
parser.add_argument(
"-v",
"--verbose",
action="count",
default=0,
dest="verbose",
help="Increase verbosity of logging output",
)
return parser.parse_args()
directory_now = os.path.dirname(os.path.realpath(__file__))
output_directory = Path.cwd() / "output_dir"
output_directory.mkdir(exist_ok=True)
pg.init()
colors
bg_color = (255, 255, 255) # white
aqua = (0, 255, 255)
black = (0, 0, 0)
blue = (0, 0, 255)
fuchsia = (255, 0, 255)
gray = (128, 128, 128)
green = (0, 128, 0)
lime = (0, 255, 0)
maroon = (128, 0, 0)
navy_blue = (0, 0, 128)
olive = (128, 128, 0)
purple = (128, 0, 128)
red = (255, 0, 0)
silver = (192, 192, 192)
teal = (0, 128, 128)
white = (255, 255, 255)
yellow = (255, 255, 0)
fonts
application_label_font = pg.font.SysFont("Arial", 15)
def convert_files_to_animated_gif(frame_delay, filename_list):
target_filename = "arch-animate-final.gif"
command_list = ["convert", "-delay", frame_delay, "-loop", "0"] + filename_list + [target_filename]
logging.info(f"🚒 Converting to animated gif {' '.join(command_list)}")
subprocess.call(command_list, cwd=output_directory)
logging.info("Deleting temporary generated files ...")
for f in output_directory.glob("temp-arch-animate*.png"):
f.unlink(missing_ok=True)
logging.info(f"✅ Generated {target_filename}")
Update game. Called once per frame. dt is the amount of time passed since last frame. If you want to have constant apparent movement no matter your framerate, what you can do is something like x += v * dt
def update(dt):
and this will scale your velocity based on time. Extend as necessary."""
#DIVIDER
for event in pg.event.get():
#DIVIDER
if event.type == QUIT:
pg.quit() # Opposite of pygame.init
sys.exit() # Not including this line crashes the script on Windows. Possibly
#DIVIDER
#DIVIDER
def darker(c):
shade_factor = 0.5
newR = c[0] * (1 - shade_factor)
newG = c[1] * (1 - shade_factor)
newB = c[2] * (1 - shade_factor)
return newR, newG, newB
#DIVIDER
def lighten(c):
factor = 0.2
return [255 - (255 - c[0]) * (1 - factor), 255 - (255 - c[1]) * (1 - factor), 255 - (255 - c[2]) * (1 - factor)]
#DIVIDER
class Application:
#DIVIDER
def __init__(self, label, start_x, start_y, height, width, color):
self.label = label
self.x = start_x
self.y = start_y
self.height = height
self.width = width
self.color = color
self.border_width = 4
#DIVIDER
def draw_on(self, drawing_screen):
pg.draw.rect(drawing_screen, lighten(self.color), [self.x, self.y, self.width, self.height], 0)
for i in range(self.border_width):
pg.draw.rect(
drawing_screen,
darker(self.color),
[self.x - i, self.y - i, self.width + self.border_width, self.height + self.border_width],
1,
)
rendered_label = application_label_font.render(self.label, True, black)
drawing_screen.blit(rendered_label, (self.x, self.y - 20))
#DIVIDER
def move_to(self, x, y):
self.x = self.x + x
self.y += y
#DIVIDER
def centre(self):
return self.x + (self.width / 2), self.y + (self.height / 2)
#DIVIDER
class Message:
#DIVIDER
def __init__(self, source_component: Application):
self.x, self.y = source_component.centre()
self.color = yellow
self.radius = 8
self.border_width = 1
#DIVIDER
def draw_on(self, drawing_screen):
pg.draw.circle(drawing_screen, lighten(self.color), (self.x, self.y), self.radius, 0)
for i in range(self.border_width):
pg.draw.circle(drawing_screen, darker(self.color), (self.x - i, self.y - i), self.radius + i, 1)
#DIVIDER
def move_to(self, target_component):
target_centre_x, target_centre_y = target_component.centre()
dx, dy = (target_centre_x - self.x, target_centre_y - self.y)
step_x, step_y = (dx / 25.0, dy / 25.0)
self.x = self.x + step_x
self.y = self.y + step_y
#DIVIDER
app_a = Application("Gateway", 100, 100, 50, 100, lime)
app_b = Application("Payment Service", 400, 100, 50, 100, fuchsia)
message = Message(app_a)
message_2 = Message(app_b)
#DIVIDER
def draw_scene(screen):
app_a.draw_on(screen)
app_b.draw_on(screen)
message.draw_on(screen)
message.move_to(app_b)
message_2.draw_on(screen)
message_2.move_to(app_a)
#DIVIDER
def draw(screen):
"""
Draw things to the window. Called once per frame.
Go through events that are passed to the script by the window.
We need to handle these events. Initially the only one you’ll want to care about is the QUIT event, because if you don’t handle it, your game will crash whenever someone tries to exit.
on other operating systems too, but I don’t know for sure. Handle other events as you wish.
Define actors in the diagram
Implement to animate actors
screen.fill(bg_color)
draw_scene(screen)
pg.display.flip()
def draw_diagram(args): fps = 60 fps_clock = pg.time.Clock()
img_height = 640 img_width = 480
final_height = int(round(0.3 * img_height)) final_width = int(round(0.3 * img_width))
screen = pg.display.set_mode((img_height, img_width)) pg.display.set_caption(“Arch Animate”)
frame_number = 0 dt = 1 / fps * 1000 while True: update(dt) draw(screen)
# Save screen
shrunk_surface = pg.transform.smoothscale(screen, (final_width, final_height))
if args.convert_to_animation:
pg.image.save(shrunk_surface, output_directory / f"temp-arch-animate-{frame_number}.png")
frame_number += 1
dt = fps_clock.tick(fps)
def main(args): draw_diagram(args)
if name == “main”: args = parse_args() setup_logging(args.verbose) main(args)