DEV Community

Cover image for Hallowe'en Unicorn
djchadderton
djchadderton

Posted on

Hallowe'en Unicorn

After tinkering with the Pimoroni Galactic Unicorn LED panel for almost a year, I decided to put together a few different techniques into an animated display for Hallowe'en. I've combined custom sprite animation with scrolling, bordered text, fading between colours and use of bounding boxes to only target one part of the screen.

The setup

First of all, flash the Pico with the Pimoroni custom build of MicroPython, which has built-in support for controlling all of the features of the device as well as the PicoGraphics and Bitbank PNGdec libraries.

The main file imports the standard libraries, initiates the classes needed for the display, creates constants for the display width and height and defines some pen colours:

from galactic import GalacticUnicorn
from picographics import PicoGraphics, DISPLAY_GALACTIC_UNICORN

gu = GalacticUnicorn()
display = PicoGraphics(display=DISPLAY_GALACTIC_UNICORN)

WIDTH = GalacticUnicorn.WIDTH
HEIGHT = GalacticUnicorn.HEIGHT

# Define colours
BLACK = display.create_pen(0, 0, 0)
SKY = display.create_pen(20, 13, 237)
RED = display.create_pen(96, 0, 0)
YELLOW = display.create_pen(127, 80, 0)
Enter fullscreen mode Exit fullscreen mode

Sprite class

I've used the PNG sprite class I created in my article on the Pimoroni Display Pack, which has a similar API to the built-in PicoGraphics sprite but uses PNGs directly rather than converting them, which may cause memory problems for larger images, but with the images I used I had no such issues on the Pico 2 version of the Unicorn.

Here is the class, which I put in its own file:

# pngsprite.py
from pngdec import PNG

class PNGSprite:
    def __init__(self, display, png_file, width, height):
        self.png_file = png_file
        self.width = width
        self.height = height
        self.display = display

    def get_sprite(self, sx, sy, dx, dy, scale=1):
        png = PNG(self.display)
        png.open_file(self.png_file)
        png.decode(dx, dy, scale, source=(sx * self.width, sy * self.height, self.width, self.height))
Enter fullscreen mode Exit fullscreen mode

A spritesheet should be a PNG file in which each sprite image has the same width and the same height. They should be side-by-side on the PNG image with no gaps between them, but there can also be multiple rows.

The three spritesheets used here are defined like this:

from pngsprite import PNGSprite

ghost_sprites = PNGSprite(display, "pacman_ghosts.png", 11, 11)
bat_sprite = PNGSprite(display, "bat3.png", 31, 13)
pumpkin_sprite = PNGSprite(display, "pumpkins.png", 13, 11) 
Enter fullscreen mode Exit fullscreen mode

The display variable is passed in, initiated from the PicoGraphics library, then the name of the image file as a string, then the width of each image in the spritesheet in pixels, then the height.

To grab a single image from the spritesheet and display it, you can use the get_sprite() method like so:

spritesheet.get_sprite(sx, sy, dx. dy, scale)
Enter fullscreen mode Exit fullscreen mode

Where sx is the number of the sprite counting from the left and starting from zero on the spritesheet, sy is the number counting down, dx is the number of pixels from the left of the screen where it should be placed (again from zero) and dy the number of pixels from the top.

scale is optional and defaults to 1. It allows scaling up of the image only and only in whole numbers.

Utility functions

There are a few functions that perform useful tasks defined in the main file.

clear_to_colour(), as the name suggests, clears the screen to a particular colour, taking a Pen object, and immediately updates the display.

def clear_to_colour(colour):
    display.set_pen(colour)
    display.clear()
    gu.update(display)
Enter fullscreen mode Exit fullscreen mode

fade_colour() will take two colours and fade between them, taking as individual parameters the R, G and B values of the colours to move from and to, the number of steps to use between them and the delay between each step (optional, defaults to 0.1 seconds).

from utime import sleep

def fade_colour(startR, startG, startB, endR, endG, endB, steps, delay = 0.1):
    for i in range(steps):
        r = startR + (endR - startR) * i // steps
        g = startG + (endG - startG) * i // steps
        b = startB + (endB - startB) * i // steps
        clear_to_colour(display.create_pen(r, g, b))
        sleep(delay)
Enter fullscreen mode Exit fullscreen mode

Sequence player

The final function plays a sequence defined in a separate variable. I put these sequences in a separate file and imported them:

from sequences import ghosts, bats, pumpkin, pumpkin2
Enter fullscreen mode Exit fullscreen mode

Here is the sequence player:

def play_sequence(sequence, spritesheet, background):
    for frame in sequence:
        display.set_pen(background)
        display.clear()
        for sprite in frame["frame"]:
            scale = frame["scale"] if "scale" in frame else 1

            spritesheet.get_sprite(*sprite, scale)

        gu.update(display)
        sleep(frame["pause"])
Enter fullscreen mode Exit fullscreen mode

It takes a list of frames in the sequence, a PNGSprite object and a background colour as a Pen object.

The frames in the sequence list are dictionaries that must have "frame" and "pause" entries and can also have an optional "scale".

frame is a list consisting of a tuple for each object that should be on screen for that frame. There should be four integers in that tuple: the number across in the spritesheet of the desired sprite, the number down, the position across the screen from the left where it should be and the position down.

The pause is the time that this frame lingers on the screen before moving to the next frame.

scale is an integer allowing the image to be scaled up on that frame, defaulting to 1

For example:

ghosts = [
    {"frame": [(2, 0, -10, 0)], "pause": 0.1},
    {"frame": [(3, 0, -9, 0)], "pause": 0.1},
    {"frame": [(2, 0, -8, 0)], "pause": 0.1},
    {"frame": [(3, 0, -7, 0)], "pause": 0.1},
    ...
    ]
Enter fullscreen mode Exit fullscreen mode

This system works well if all sprites are from the spritesheet but falls down if you need to mix images from different sheets.

The sequences

The different sequences are played in a while True infinite loop.

Ghosts

Pacman-style ghosts on a Galactic Unicorn

The ghost sequence is pretty straightforward, if a little tedious to programme in individual steps. The spritesheet has Pacman-style ghosts in three different colours on three rows, each with two images of them going left and two going right and a slight animation between the two.

play_sequence(ghosts, ghost_sprites, BLACK)
Enter fullscreen mode Exit fullscreen mode

Bat

Bat on a Galactic Unicorn

This uses the fade to colour function then utilises a spritesheet of a single image that flies past a couple of times then uses scaling to make it seem like it flies at the screen.

    fade_colour(0, 0, 0, 20, 13, 237, 10)
    play_sequence(bats, bat_sprite, SKY)
    clear_to_colour(BLACK)
Enter fullscreen mode Exit fullscreen mode

Pumpkin

Pumpkin and scrolling message on a Galactic Unicorn

This one is a bit more complicated. Firstly a sequence is played of the pumpkin appearing and animating its mouth:

play_sequence(pumpkin, pumpkin_sprite, BLACK)
Enter fullscreen mode Exit fullscreen mode

The blank area next to the pumpkin is defined as a clipping area so the text animation will not bleed into the pumpkin.

win_width = 41
display.set_clip(14, 0, win_width, 11)
Enter fullscreen mode Exit fullscreen mode

The parameters for the scrolling text are defined:

text = "Happy Hallowe'en"
win_x = 14
win_y = 4
scale = 0.4
display.set_font("serif")
msg_width = display.measure_text(text, scale)
Enter fullscreen mode Exit fullscreen mode

In a loop, the text is drawn with a shadow appearing from the right and gradually scrolling off to the left.

for z in range (msg_width + win_width + 2):
        x = win_x - z + win_width
        y = win_y

        clear_to_colour(BLACK)

        # Draw shadow
        display.set_pen(YELLOW)

        for dy in (-1, 0, 1):
            for dx in (-1, 0, 1):
                display.text(text, x + dx, y + dy, -1, scale)

        # Draw text
        display.set_pen(RED)
        display.text(text, x, y, -1, scale)
        gu.update(display)
        sleep(0.1)
Enter fullscreen mode Exit fullscreen mode

The clipping bounds are removed and the pumpkin moves through a different sequence, crossing the screen, before the whole loop goes back to the beginning.

display.remove_clip()

play_sequence(pumpkin2, pumpkin_sprite, BLACK)
clear_to_colour(BLACK)
sleep(1)
Enter fullscreen mode Exit fullscreen mode

The full listing together with my images can be found on Github at https://github.com/djchadderton/halloween-unicorn. It is currently playing in my front window.

I hope you have fun playing around with some of the ideas. I was wondering about turning the display to the vertical for fireworks for 5 November (Bonfire Night here in the UK), then there's Christmas...

Top comments (0)