This is a game about skeletons, pumpkins, and a catapult having a Spooky experience under the full moon. If you've thought about making a game in CircuitPython but aren't sure where to start, this project might be a useful source of ideas.
Charging a Pumpkin
This is how it looks when you hold the USB gamepad's A button to charge up a pumpkin.
Pumpkin in Flight
This is how it looks when a pumpkin is about to meet a skeleton.
This embedded content is from a site (www.youtube.com, flickr.com, etc) that does not comply with the Do Not Track (DNT) setting now enabled on your browser.
Clicking through to the embedded content will allow you to be tracked by the embed provider.
Hardware
This uses the setup of ESP32-S3 Feather TFT, MAX3421E USB Host FeatherWing, and Feather Doubler that I've been using for previous USB gamepad projects. If you want more details on the hardware and gamepad stuff, you might check out some of my other Adafruit Playground guides:
Parts
Pinouts
| TFT feather | USB Host | ST7789 TFT |
|---|---|---|
| SCK | SCK | |
| MOSI | MOSI | |
| MISO | MISO | |
| D9 | IRQ | |
| D10 | CS | |
| TFT_CS | CS | |
| TFT_DC | DC |
Tools and Consumables
You will need soldering tools and solder.
Soldering the Headers
The TFT Feather, USB Host FeatherWing and the Doubler all come in kit form, so you will need to solder the headers.
If you are unfamiliar with soldering headers, you might want to read:
Updating CircuitPython
NOTE: To update CircuitPython on the ESP32-S3 TFT Feather with 2MB PSRAM and 4MB Flash, you need to use the .BIN file (combination bootloader and CircuitPython core)
Download the CircuitPython 9.1.4 .BIN file from the Feather ESP32-S3 TFT PSRAM page on circuitpython.org
Follow the instructions in the Web Serial ESPTool section of the "CircuitPython on ESP32 Quick Start" learn guide to update your board: first erase the flash, then program the .BIN file.
Installing CircuitPython Code
To copy the project bundle files to your CIRCUITPY drive:
Download the project bundle .zip file using the Download Project Bundle button below.
Expand the zip file by opening it, or use
unzipin a Terminal. The zip archive should expand to a folder. When you open the folder, it should contain aREADME.txtfile and aCircuitPython 9.xfolder.Open the CircuitPython 9.x folder and copy all of its contents to your CIRCUITPY drive.
To learn more about copying libraries to your CIRCUITPY drive, check out the CircuitPython Libraries section of the Welcome to CircuitPython! learn guide.
Sprites and Background
I made sprites for this project using the Pixaki app for iPad.
My general workflow for making a spritesheet and background image is:
Open a new canvas of 120 pixels wide by 67 pixels high, with a black background and a grid with 8 pixel spacing. This lets me get a good feel for how things will look on a Feather TFT screen with 2x scaling using a
displayio.Group(scale=2).Pick four colors for my palette (in this case black, gray, white, and orange) and sketch my idea for how a scene will look once the game is done. By separating things into different layers, it's easier to move stuff around and experiment.
Once I have a scene I like, make a new layer for the spritesheet and copy chunks of the other layers into the spritesheet layer, aligning the tiles into an 8x8 grid.
Hide all the other layers except the spritesheet and the background, then use Pixaki's PNG export feature to export the spritesheet. Also, take a a screenshot of the spritesheet layer with the 8x8 grid lines. Annotating this screenshot with sprite tile numbers makes it a lot easier to write the code for assembling spritesheet tiles into sprites with animation cycles.
Turn the spritesheet layer off, turn the other layers back on, then take a screenshot with the 8x8 grid lines. Annotating this screenshot with pixel numbers for the grid lines (0, 8, 16, 24, ...) makes it easier to set the x= and y= arguments when I write the code to create displayio.TileGrid objects for the sprites.
Use AirDrop to transfer the screenshots and spritesheet PNG to my mac.
Background
Spritesheet
Pumpkin Flight Physics
Initially, I wanted to try accurately modelling pumpkin flight, including drag. That got messy because the Feather TFT screen is very small, with a wide aspect ratio, such that it's hard to show realistic trajectories for Earth gravity.
Eventually, I just started making up velocity and acceleration numbers that, in my subjective opinion, "looked good". The math works by evaluating (x, y) displacement for each animation frame time interval. The horizontal velocity gets updated each animation frame with acceleration from "drag", and the vertical velocity gets updated with acceleration from "gravity". If those drag and gravity numbers are in any way realistic, it's purely by accident. I just made them up. The goal here is entertainment though, so it's fine.
That said, if you want to read about accurate pumpkin flight math, some potentially useful search terms include: rigid body dynamics, projectile motion, classical mechanics, drag coefficient, drag equation, and ballistic flight.
The most readily useable references I found for calculating displacement as a function of time and initial velocity were from NASA's Glenn Research Center:
Code
For this project, I made a tiny game engine from scratch in CircuitPython using
mostly built-in CircuitPython libraries. For sprites, I used displayio.TileGrid
and adafruit_imageload. For the gamepad, I used usb.core and max3421e.Max3421E.
For timing to regulate the frame rate, I used supervisor.ticks_ms.
The techniques I used are mostly pretty straightforward intermediate level stuff. Some of it was
tedious to keep track of, but there's nothing all that esoteric going on. One
of the big challenges for a project like this is to stay organized so the
coordinates and sprite tile numbers don't get mixed up. To help keep sprite
tile stuff from cluttering up the rest of the code, I made two classes just for
managing sprite animation cycles: catapult.Catapult and skeletons.Skeletons.
To understand how the code works, I recommend that you start by reading through
code.py. It has extensive comments to help explain the rationale of how I
structured the code and the strategy I used to keep the frame rate smooth.
CIRCUITPY/code.py
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Copyright 2024 Sam Blenny
#
# Hardware:
# - Adafruit ESP32-S3 TFT Feather - 4MB Flash, 2MB PSRAM (#5483)
# - Adafruit USB Host FeatherWing with MAX3421E (#5858)
# - 8BitDo SN30 Pro USB gamepad
#
# Pinouts:
# | TFT feather | USB Host | ST7789 TFT |
# | ----------- | -------- | ---------- |
# | SCK | SCK | |
# | MOSI | MOSI | |
# | MISO | MISO | |
# | D9 | IRQ | |
# | D10 | CS | |
# | TFT_CS | | CS |
# | TFT_DC | | DC |
#
# Related Documentation:
# - https://learn.adafruit.com/adafruit-esp32-s3-tft-feather
# - https://learn.adafruit.com/adafruit-1-14-240x135-color-tft-breakout
# - https://learn.adafruit.com/adafruit-usb-host-featherwing-with-max3421e
# - https://docs.circuitpython.org/en/latest/shared-bindings/displayio/
# - https://docs.circuitpython.org/projects/display_text/en/latest/api.html
# - https://learn.adafruit.com/circuitpython-display_text-library?view=all
#
from board import D9, D10, SPI, TFT_CS, TFT_DC
from digitalio import DigitalInOut, Direction
from displayio import Bitmap, Group, Palette, TileGrid, release_displays
from fourwire import FourWire
import gc
from max3421e import Max3421E
from micropython import const
from supervisor import ticks_ms
from usb.core import USBError
from terminalio import FONT
from time import sleep
from adafruit_display_text import bitmap_label
import adafruit_imageload
from adafruit_st7789 import ST7789
from catapult import Catapult
from gamepad import (
XInputGamepad, UP, DOWN, LEFT, RIGHT, START, SELECT, A, B, X, Y)
from skeletons import Skeletons
from statemachine import StateMachine
def handle_input(machine, prev, buttons, repeat):
# Respond to gamepad button state change events.
#
# This function translates from the packed bitfield of gamepad button
# states into the state machine's input event constants. If you wanted to
# remap the gamepad buttons, or convert this program to use a different
# type of gamepad (or keyboard, or mouse, etc), this function would be a
# great place to start. If you want to change how the input device works
# here, you'll probably also need to make some matching changes in main().
#
diff = prev ^ buttons
mh = machine.handleGamepad
#print(f"{buttons:016b}")
if repeat:
# Check for hold-time triggered repeating events
if (buttons & A):
mh(machine.A_HOLD)
else:
# Check for edge-triggered events
if (diff & A) and (buttons & A): # A pressed
mh(machine.A_DN)
elif (diff & A) and (not (buttons & A)): # A released
mh(machine.A_UP)
elif (diff & SELECT) and (buttons == SELECT): # SELECT pressed
mh(machine.SELECT)
elif (diff & START) and (buttons == START): # START pressed
mh(machine.START)
def elapsed_ms(prev, now):
# Calculate elapsed ms between two timestamps from supervisor.ticks_ms().
#
# The CircuitPython ticks counter rolls over at 2**29, so this uses a bit
# mask of (2**29)-1 = 0x3fffffff for the subtraction. If you want to learn
# more about why doing it this way gives the correct result even when the
# interval spans a rollover, try reading about "modular arithmetic",
# "integer overflow", and "two's complement" arithmetic.
#
MASK = const(0x3fffffff)
return (now - prev) & MASK
def main():
# This function has initialization code and the main event loop. Under
# normal circumstances, this function does not return.
# The Feather TFT defaults to using the built-in display for a console.
# So, first, release the default display so we can re-initialize it below.
release_displays()
gc.collect()
# Initialize SPI bus which gets shared by ST7783 (TFT) and Max3421E (USB)
spi = SPI()
# Initialize ST7789 display with native display size of 240x135px.
# IMPORTANT: Note how auto_refresh is set to false. This gives the state
# machine and event loop code (see below) more direct control over when the
# display refreshes. The point is to minimize SPI bus contention between
# the display and the USB host chip, and to hopefully reduce tearing.
#
TFT_W = const(240)
TFT_H = const(135)
bus = FourWire(spi, command=TFT_DC, chip_select=TFT_CS)
display = ST7789(bus, rotation=270, width=TFT_W, height=TFT_H, rowstart=40,
colstart=53, auto_refresh=False)
gc.collect()
# Load PNG images and put them into TileGrid objects:
# This is is the most memory intensive thing in the whole program. Doing
# these large heap allocations early, then keeping the objects around for
# the the length of the program, helps to avoid memory fragmentation.
#
gc.collect()
# Background image with moon, trees, hill, and grass
(bmp0, pal0) = adafruit_imageload.load(
"pumpkin-toss-bkgnd.png", bitmap=Bitmap, palette=Palette)
bkgnd = TileGrid(bmp0, pixel_shader=pal0)
gc.collect()
# Title screen overlay
gc.collect()
(bmp1, pal1) = adafruit_imageload.load(
"pumpkin-toss-title.png", bitmap=Bitmap, palette=Palette)
x = ((TFT_W // 2) - bmp1.width) // 2
y = ((TFT_H // 2) - bmp1.height) // 2
title_screen = TileGrid(bmp1, pixel_shader=pal1, x=x, y=y)
# Shared spritesheet for catapult, pumpkin and skeleton animation cycles
(bmp2, pal2) = adafruit_imageload.load(
"pumpkin-toss-sprites.png", bitmap=Bitmap, palette=Palette)
gc.collect()
# Mark background color (black) as transparent
pal2.make_transparent(0)
# Prepare instances of the Catapult and Skeletons classes using the shared
# spritesheet. These objects manage the details of setting TileGrid tiles
# to draw animation cycles for sprites. The main reasons for these classes
# are:
# 1. Have a dedicated spot for the lists of tile numbers that define each
# frame of the various animation cycles
# 2. Export functions and constants that the state machine can use to
# control animations at a higher level of abstraction (without having to
# clutter the state machine code with tile numbers from the spritesheet)
# The x,y coordinates come from my bkgnd-with-grid.jpeg reference image.
#
cat = Catapult(bmp2, pal2, x=0, y=25, splat_y=57, chg_x=0, chg_y=8)
skels = Skeletons(bmp2, pal2, x0=54, x1=116, y=44)
# Make a text label for status messages
status = bitmap_label.Label(FONT, text="", color=0xFF8000)
status.x = 8 # NOTE: these are 1x coordinates! (sprites use 2x)
status.y = 8 # NOTE: these are 1x coordinates! (sprites use 2x)
# Arrange all the TileGrids and sub-groups into the root display group. The
# sprites and background use 2x scaling (grp2), but the status line goes in
# a 1x scaled group (grp1) because the built-in font looks huge at 2x.
#
grp1 = Group(scale=1)
grp2 = Group(scale=2)
grp2.append(bkgnd)
grp2.append(skels.group())
grp2.append(cat.group())
grp2.append(title_screen)
grp1.append(grp2)
grp1.append(status)
display.root_group = grp1
display.refresh()
# This initializes the state machine object, giving it references to the
# sprite manager objects (Catapult and Skeletons) and status text label
# object. The point of structuring the code this way is to have the state
# machine be responsible for higher level timing and sprite behavior, while
# the sprite managers take care of low-level details about TileGrid
# changes. There's also some subtle memory allocation and data flow stuff
# going on here, with the goal of keeping display updates smooth, at a
# steady frame rate:
#
# 1. The sprite manager objects (cat and skels) contain references to
# large bitmaps which were loaded above from PNG files. Allocating
# these objects early and keeping references to them alive for the whole
# length of the program helps to avoid memory fragmentation, flash
# access, and pressure on the garbage collector. This should reduce
# timing jitter due to memory allocations and garbage collection.
#
# 2. The state machine causes the sprite manager objects to update TileGrid
# tile numbers, but calls to displayio.Display.refresh() only happen
# in the event loop, here in the main() function (remember the display
# was initialized with auto_refresh=false). This allows several tile
# updates for different sprites to happen together in the same animation
# frame, with hopefully just one display refresh per frame.
#
machine = StateMachine(grp2, cat, skels, title_screen, status)
# Initialize MAX3421E USB host chip which is needed by usb.core to make
# gamepad input work.
print("Initializing USB host port...")
gc.collect()
usbHost = Max3421E(spi, chip_select=D10, irq=D9)
gc.collect()
sleep(0.1)
# Initialize gamepad manager object (see gamepad.py)
gp = XInputGamepad()
# Gamepad status update strings for debug prints on the serial console and
# display status line
GP_FIND = 'Finding USB gamepad'
GP_READY = 'gamepad ready'
GP_ERR1 = 'USB ERR1: bug in code.py?'
GP_ERR2 = 'USB ERR2: gamepad unplugged?'
# Cache frequently used callables to save time on dictionary name lookups
# (this is a standard MicroPython performance boosting trick)
_collect = gc.collect
_elapsed = elapsed_ms
_ms = ticks_ms
_refresh = display.refresh
# MAIN EVENT LOOP
# This sets up a loop to run the following sequence over and over:
#
# 1. Attempt to poll a USB gamepad for button press inputs
#
# 2. Check for gamepad input events, and if needed, call the appropriate
# state machine input event handler
#
# 3. Call the state machine's tick() function to update animations and
# other timer-controlled state
#
# 4. If requested by the state machine, refresh the display
#
# Initialize timers for gamepad button hold detection.
DELAY_MS = const(133) # Gamepad button hold delay before repeat (ms)
REPEAT_MS = const(133) # Gamepad button interval between repeats (ms)
prev_ms = _ms()
hold_tmr = 0
repeat_tmr = 0
# OUTER LOOP: try to connect to a USB gamepad.
print(GP_FIND)
status.text = GP_FIND
_refresh()
while True:
_collect()
# Begin by updating the display, even if gamepad is not connected
now_ms = _ms()
interval = _elapsed(prev_ms, now_ms)
if interval >= 16:
prev_ms = now_ms
# Update animations and display if needed
if machine.tick(interval):
_refresh()
try:
# Attempt to connect to USB gamepad
if gp.find_and_configure():
status.text = "" # clear the "Finding USB gamepad" status text
_refresh()
print(gp.device_info_str())
connected = True
prev_btn = 0
hold_tmr = 0
repeat_tmr = 0
# INNER LOOP: gamepad is connected, so start polling buttons
#
# IMPORTANT: gp.poll() here is a generator that polls the
# gamepad buttons at the start of each iteration through this
# loop. Doing it this way avoids many memory allocations that
# would be required to poll using a regular class method call.
# The point of this approach is to get a better frame rate with
# less of latency and jitter.
#
for buttons in gp.poll():
# Update A-button timers
now_ms = _ms()
interval = _elapsed(prev_ms, now_ms)
prev_ms = now_ms
if buttons & A:
hold_tmr += interval
repeat_tmr += interval
else:
hold_tmr = 0
repeat_tmr = 0
# Handle hold-time triggered gamepad input events
if hold_tmr >= DELAY_MS:
if hold_tmr == repeat_tmr:
# First re-trigger event after initial delay
repeat_tmr -= DELAY_MS
handle_input(machine, prev_btn, buttons, True)
elif repeat_tmr >= REPEAT_MS:
# Another re-trigger event after repeat interval
repeat_tmr -= REPEAT_MS
handle_input(machine, prev_btn, buttons, True)
# Handle edge-triggered gamepad input events
if prev_btn != buttons:
handle_input(machine, prev_btn, buttons, False)
# Save button values
prev_btn = buttons
#
# --- UPDATE ANIMATIONS & DISPLAY --------------------------
# This part is short but very important. The call to
# .tick() below lets the state machine update the animation
# cycles for sprites and do whatever other timer-based
# things need to be done. You could implement this in a
# different way using async, but I prefer it this way. The
# advantage of this approach is you can see how the gamepad
# polling and state machine updates take turns.
#
need_refresh = machine.tick(interval)
if need_refresh:
_refresh()
# Doing garbage collection after every refresh makes
# the loop run slower. But, it seems to reduce jitter,
# making for a smoother frame rate. It's too close to
# make an easy call, but this method seems to look
# subjectively a little better than other ways I tried.
_collect()
# ---------------------------------------------------------
#
# [END OF INNER LOOP]
# Making it here means gp.poll() decided to end the loop with a
# `return`, which is possible but not normal (see gamepad.py).
print(GP_ERR1)
print(GP_FIND)
status.text = GP_ERR1
_refresh()
else:
# Making it here means no gamepad is connected, and when
# gp.find_and_configure() looked, it did not find one. This
# normal and often happens at boot time because it takes a
# while for all the USB stuff to initialize.
#
# Since there is no gamepad yet, wait a bit, then try again.
#
sleep(0.1)
except USBError as e:
# Making it here means there was a USBError exception during a call
# to gp.find_and_configure() or gp.poll().
#
# This is normal when someone unplugs the gamepad. When that
# happens, it's usually possible to reconnect without resetting
# CircuitPython. But, sometimes, more serious and mysterious
# USBError exceptions happen and usb.core gets confused. In that
# case, further calls to find_and_configure() don't work until
# after resetting the board.
#
# So, hope for the best, log the error, and stay in the outer loop
# so it can attempt to find a gamepad.
#
print(GP_ERR2)
print(GP_FIND)
status.text = GP_ERR2
_refresh()
main()
CIRCUITPY/catapult.py
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Copyright 2024 Sam Blenny
#
from displayio import Group, TileGrid
import gc
from micropython import const
class Catapult:
# This class manages animation cycles for the catapult and pumpkin.
# The spritesheet tile numbers used here come from the annotations in
# sprites-with-grid.jpeg. The x,y coordinates for screen positions come
# from bkgnd-with-grid.jpeg.
# Names of catapult animation cycle frames (public, for state machine)
# CAUTION: these must match row indexes of _C_TILES
LOAD = const(0)
TOSS1 = const(1)
TOSS2 = const(2)
TOSS3 = const(3)
# Names of pumpkin animation cycle frames (public, for state machine)
# CAUTION: these must match indexes of _P_TILES
HIDE = const(0)
FLY = const(1)
SPLAT1 = const(2)
SPLAT2 = const(3)
SPLAT3 = const(4)
# Tile tuples for the catapult sprite. Catapult sprite is 16x16 px. Tiles
# in the spritesheet are 8x8. So, each frame of the catapult animation
# cycle uses 4 tiles: (top-left, top-right, bottom-left, bottom-right).
_C_TILES = (
(12, 13, 22, 23), # LOAD: stopped, arm at 30°, pumpkin in basket
(14, 15, 24, 25), # TOSS1: moving, arm at 45°, pumpkin in basket
(16, 17, 26, 27), # TOSS2: moving, arm at 60°, pumpkin in basket
(18, 19, 28, 29), # TOSS3: stopped, arm at 60°, basket empty
)
# Tiles for the pumpkin sprite (in flight and splatted). Pumpkin sprite is
# 8x8. So, each frame of the pumpkin flight-splat animation cycle uses 1
# tile of the spritesheet.
_P_TILES = (
0, # HIDE: in-flight sprite is hidden (pumpkin is in the catapult)
10, # FLY: pumpkin is flying (catapult basket empty)
11, # SPLAT1: partial splat at 3/4 height
20, # SPLAT2: partial splat at 1/2 height
21, # SPLAT3: full splat
)
# Catapult charge-up power limits (public)
CHARGE_ZERO = const(0) # hide the charge-up bar
CHARGE_MAX = const(20) # 100% power
# Tiles for the catapult charge-up indicator bar. The indicator bar goes
# from 0% to 100% in 20 increments (bars) of 5% each. The charge bar is
# seven 8x8 tiles wide. The leftmost and rightmost tiles are the ends of
# the rounded rectangle. The 5 inner 8x8 tiles can each represent 4 bars of
# charge (1 bar == 2 px).
_CHG_TILES = (
(0, 0, 0, 0, 0, 0, 0), # CHARGE_ZERO: transparent (hide charge bar)
(1, 3, 2, 2, 2, 2, 7), # 1/20
(1, 4, 2, 2, 2, 2, 7), # 2/20
(1, 5, 2, 2, 2, 2, 7), # 3/20
(1, 6, 2, 2, 2, 2, 7), # 4/20
(1, 6, 3, 2, 2, 2, 7), # 5/20
(1, 6, 4, 2, 2, 2, 7), # 6/20
(1, 6, 5, 2, 2, 2, 7), # 7/20
(1, 6, 6, 2, 2, 2, 7), # 8/20
(1, 6, 6, 3, 2, 2, 7), # 9/20
(1, 6, 6, 4, 2, 2, 7), # 10/20
(1, 6, 6, 5, 2, 2, 7), # 11/20
(1, 6, 6, 6, 2, 2, 7), # 12/20
(1, 6, 6, 6, 3, 2, 7), # 13/20
(1, 6, 6, 6, 4, 2, 7), # 14/20
(1, 6, 6, 6, 5, 2, 7), # 15/20
(1, 6, 6, 6, 6, 2, 7), # 16/20
(1, 6, 6, 6, 6, 3, 7), # 17/20
(1, 6, 6, 6, 6, 4, 7), # 18/20
(1, 6, 6, 6, 6, 5, 7), # 19/20
(1, 6, 6, 6, 6, 6, 7), # 20/20 = CHARGE_MAX
)
def __init__(self, bitmap, palette, x, y, splat_y, chg_x, chg_y):
# This sets up catapult and pumpkin sprites.
# Args:
# - bitmap, palette: Shared spritesheet from adafruit_imageload
# - x, y: Coordinate of top left corner of catapult sprite
# - splat_y: Ground height in pumpkin splat zone
#
# The spritesheet has 8x8 tiles. The pumpkin sprite is 8x8. The
# catapult sprite is 16x16 (4 tiles per sprite/frame).
#
gc.collect()
# Make catapult TileGrid
tgc = TileGrid(
bitmap, pixel_shader=palette, width=2, height=2,
tile_width=8, tile_height=8, x=x, y=y)
gc.collect()
# Make catapult charge-up indicator TileGrid
tgchg = TileGrid(
bitmap, pixel_shader=palette, width=7, height=1,
tile_width=8, tile_height=8, x=chg_x, y=chg_y)
gc.collect()
# Make pumpkin TileGrid
tgp = TileGrid(
bitmap, pixel_shader=palette, width=1, height=1,
tile_width=8, tile_height=8, x=x, y=y)
gc.collect()
# Arrange TileGrids into a group
grp = Group(scale=1)
grp.append(tgc)
grp.append(tgchg)
grp.append(tgp)
# Save arguments and object references
self.x = x
self.y = y
self.splat_y = y
self.tgc = tgc
self.tgchg = tgchg
self.tgp = tgp
self.grp = grp
# Set sprite tiles for initial animation frame
self.set_catapult(LOAD)
self.set_charge(CHARGE_ZERO)
self.set_pumpkin(HIDE, 0, 0)
def group(self):
# Return the displayio.Group object for catapult and pumpkin TileGrids
return self.grp
def set_charge(self, power):
# Set catapult charge-up indicator bar
if (CHARGE_ZERO <= power) and (power <= CHARGE_MAX):
for (i, tile) in enumerate(self._CHG_TILES[power]):
self.tgchg[i] = tile
else:
raise Exception(f"charge power out of range: {power}")
def set_catapult(self, frame):
# Set catapult sprite tiles for the specified animation cycle frame
if (LOAD <= frame) and (frame <= TOSS3):
(topL, topR, botL, botR) = self._C_TILES[frame]
self.tgc[0] = topL
self.tgc[1] = topR
self.tgc[2] = botL
self.tgc[3] = botR
else:
raise Exception(f"catapult frame out of range: {frame}")
def set_pumpkin(self, frame, x, y):
# Set pumpkin sprite tile and relative position for the specified
# animation cycle frame. Pumpkin (x,y) is relative to catapult (x,y).
# Returns:
# - (x, y) coordinate of pumpkin in absolute screen px (vs x, y args
# to this method which are relative to the catapult coordinates)
if (HIDE <= frame) and (frame <= SPLAT3):
self.tgp[0] = self._P_TILES[frame]
pumpkin_x = self.x + round(x) # x argument can be float
pumpkin_y = self.y + round(y) # y argument can be float
self.tgp.x = pumpkin_x
self.tgp.y = pumpkin_y
return (pumpkin_x, pumpkin_y)
else:
raise Exception(f"pumpkin frame out of range: {frame}")
CIRCUITPY/skeletons.py
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Copyright 2024 Sam Blenny
#
from displayio import Group, TileGrid
import gc
from micropython import const
class Skeletons:
# This class manages animation cycles for the skeletons.
# The spritesheet tile numbers used here come from the annotations in
# sprites-with-grid.jpeg. The x,y coordinates for screen positions come
# from bkgnd-with-grid.jpeg.
# Names of skeleton animation cycle frames (public, for state machine)
# CAUTION: these must match indexes of _S_TILES
HIDE = const(0)
RISE1 = const(1)
RISE2 = const(2)
RISE3 = const(3)
RISE4 = const(4)
RISE5 = const(5)
STAND1 = const(6)
STAND2 = const(7)
STAND3 = const(8)
STAND4 = const(9)
# Tile tuples for the skeleton sprite. Catapult sprite is 8x16 px. Tiles
# in the spritesheet are 8x8. So, each frame of the catapult animation
# cycle uses 2 tiles: (top, bottom).
_S_TILES = (
(30, 40), # HIDE: not visible (below ground or whatever)
(31, 41), # RISE1: 1 px of skull and sword
(32, 42), # RISE2: 3 px of skull and sword
(33, 43), # RISE3: skull, arms, full sword
(34, 44), # RISE4: full torso
(35, 45), # RISE5: most of legs
(36, 46), # STAND1: both legs down, sword up, R arm up
(37, 47), # STAND2: left leg up, sword level, R arm up
(38, 48), # STAND3: both legs down, sword level, R arm down
(39, 49), # STAND4: right leg up, sword up, R arm down
)
# Upper limit on skeleton mob size
_MAX_SKELLIES = const(4)
# Speed of rise and walk cycle animations in tick() calls per frame
_TICKS_PER_FRAME = const(9)
# Delay before skeletons respawn
_RESPAWN_FRAMES = const(31)
def __init__(self, bitmap, palette, x0, x1, y):
# This sets up skeleton sprites.
# Args:
# - bitmap, palette: Shared spritesheet from adafruit_imageload
# - x0, x1: Skeleton spawn zone x coordinate range
# - y: Skeleton spawn zone y coordinate
#
# The spritesheet has 8x8 tiles. The skeleton sprite is 8x16 (2 tiles
# tall). So, there are 2 tiles per sprite/frame of the animation cycle
# for each skeleton.
#
gc.collect()
# Make TileGrids and group
grp = Group(scale=1)
skellies = []
for i in range(_MAX_SKELLIES):
gc.collect()
x = x0 + (((x1 - x0) // _MAX_SKELLIES) * i)
tg = TileGrid(
bitmap, pixel_shader=palette, width=1, height=2,
tile_width=8, tile_height=8, x=x, y=y)
skellies.append(tg)
grp.append(tg)
gc.collect()
# Save object references
self.y = y
self.skellies = skellies
self.frames = [HIDE] * _MAX_SKELLIES
self.timers = [0] * _MAX_SKELLIES
self.grp = grp
# Set sprite tiles for initial animation frame (title screen)
for (n, f) in enumerate((RISE3, STAND1, STAND2, STAND3)):
self.set_skellie(n, f)
def reset(self):
# Reset skeletons for start of game (vs title screen)
count = len(self.skellies)
wake_timers = [7 * n * _TICKS_PER_FRAME for n in range(count)]
for (n, t) in enumerate(reversed(wake_timers)):
self.set_skellie(n, HIDE)
self.timers[n] = t
def check_hit(self, px, py):
# Check for pumpkin collision with skeleton hitbox.
# args:
# - (px, py): screen coordinates of pumpkin sprite top-left corner
# returns: True for hitbox collision, False otherwise
if (py < self.y) or (py >= self.y + 10):
# Anything outside skeleton's top tile (head/torso) is a miss
return False
for (n, (f, s)) in enumerate(zip(self.frames, self.skellies)):
# Within the head/torso height range, check for x range alignment
left = s.x - 3
right = s.x + 3
if (f >= STAND1) and (left <= px) and (px <= right):
self.timers[n] = _RESPAWN_FRAMES * _TICKS_PER_FRAME
self.set_skellie(n, HIDE)
return True
return False
def tick(self):
# Update skeleton animation cycles (rise, idle, walk)
# Returns: True if display needs refresh, False if display unchanged
_timers = self.timers
needs_refresh = False
for (i, (f, t)) in enumerate(zip(self.frames, _timers)):
if t > 0:
# On ticks when the frame doesn't need to chage, just update
# the countdown timer for this skeleton
_timers[i] = t - 1
else:
# When the timer hits 0, update the animation frame and timer
if f <= STAND3:
self.set_skellie(i, f + 1)
else:
self.set_skellie(i, STAND1)
# Reset countdown timer
_timers[i] = _TICKS_PER_FRAME
needs_refresh = True
return needs_refresh
def group(self):
# Return the displayio.Group object for the skeleton TileGrids
return self.grp
def set_skellie(self, n, frame):
# Set animation cycle frame for skeleton sprite number n.
if (n < 0) or (n >= len(self.skellies)):
raise Exception(f"skeleton index out of range: {n}")
elif (frame < HIDE) or (STAND4 < frame):
raise Exception(f"skeleton frame out of range: {frame}")
else:
self.frames[n] = frame
(top, bottom) = self._S_TILES[frame]
self.skellies[n][0] = top
self.skellies[n][1] = bottom
CIRCUITPY/statemachine.py
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Copyright 2024 Sam Blenny
##
from micropython import const
from catapult import Catapult
class StateMachine:
# Animation frame duration in ms for target frame rate
_FRAME_MS = const(33)
# Maximum timer time in ms = (2**30) - 1
_MAX_MS = const(1073741823)
# Button Press Constants (public)
# CAUTION: These values must match column indexes of StateMachine.TABLE
A_DN = const(0)
A_HOLD = const(1)
A_UP = const(2)
SELECT = const(3)
START = const(4)
# States (private)
# CAUTION: These values must match row indexes of StateMachine.TABLE
_TITLE = const(0)
_READY = const(1)
_CHARGE = const(2)
_TOSS = const(3)
_PAUSE = const(4)
# Lookup table for resolving state constants to names
_STATE_NAMES = {
_TITLE: "Title",
_READY: "Ready",
_CHARGE: "Charge",
_TOSS: "Toss",
_PAUSE: "Pause",
}
# Actions that are not state transitions (private)
_NOP = const(10)
_PLAY = const(11)
_LOAD = const(12)
_RESUME = const(13)
# LookUp Table (private) of actions (including NOP and state transitions)
# for possible button press events in each of the possible states. NOP is
# short for "No OPeration", and it means to do nothing.
_TABLE = (
# A_DN, A_HOLD, A_UP, SELECT, START State
(_NOP, _NOP, _NOP, _NOP, _PLAY ), # title (start screen)
(_CHARGE, _NOP, _NOP, _PAUSE, _NOP ), # ready (pumpkin loaded)
(_NOP, _CHARGE, _TOSS, _PAUSE, _NOP ), # charge (charging launcher)
(_NOP, _NOP, _NOP, _PAUSE, _NOP ), # toss (pumpkin toss animation)
(_NOP, _NOP, _NOP, _RESUME, _RESUME), # pause
)
# Maximum catapult launch power (private)
_CHARGE_MAX = const(5)
# Pumpkin flight out-of-basket initial position (_PX, _PY)
_PX = const(4)
_PY = const(1)
# Pumpkin flight initial velocity (px/ms) when catapult is 100% charged.
# Horizontal velocity is _PU. Vertical velocity is _PV.
_PV = 750 / 1000
_PU = 1600 / 1000
# Pumpkin acceleration due to gravity (px/ms)
_PG = 10 / 1000
def __init__(self, group, catapult, skeletons, title_screen, status):
# Initialize state machine, saving catapult and skeleton references
# args:
# - group: displayio.Group (initially contains title_screen)
# - catapult: catapult.Catapult that manages catapult/pumpkin sprites
# - skeletons: skeletons.Skeletons that manages skeleton sprites
# - title_screen: displayio.TileGrid (initially contained by group)
# - status: adafruit_display_text.bitmap_label.Label for status text
#
self.group = group
self.catapult = catapult
self.skeletons = skeletons
self.title_screen = title_screen
self.status = status
self.prev_state = None
self.load_pumpkin()
self.state = _TITLE
self.frame_ms = 0
self.need_repaint = True
def load_pumpkin(self):
# Reset state for firing a pumpkin
self.timer = 0
self.charge = Catapult.CHARGE_ZERO
self.pumpkin_xyvu = (_PX, _PY, self._PV, self._PU)
self.catapult.set_catapult(Catapult.LOAD)
self.catapult.set_charge(Catapult.CHARGE_ZERO)
self.catapult.set_pumpkin(Catapult.HIDE, _PX, _PY)
self.state = _READY
def paint(self):
# Repaint the scene
s = self._STATE_NAMES[self.state]
t = self.timer
c = self.charge
def tick(self, elapsed_ms):
# Update animations and timer-based state transitions
# Returns:
# - True: caller should refresh the displayio display
# - False: display does not need to be refreshed
#
if self.state == _PAUSE:
# Do not update animations when paused
return False
t0 = self.timer
t1 = min(_MAX_MS, t0 + elapsed_ms)
self.timer = t1
# Rate limit updates to match target frame length in ms
frame_ms = self.frame_ms + elapsed_ms
if frame_ms < _FRAME_MS:
self.frame_ms = frame_ms
return False # CAUTION: this returns early, skipping code below!
else:
self.frame_ms = frame_ms % _FRAME_MS
# Update pumpkin flight animation
_set_cat = self.catapult.set_catapult
_set_charge = self.catapult.set_charge
_set_pumpkin = self.catapult.set_pumpkin
if self.state == _TOSS:
self.need_repaint = True
(x, y, v, u) = self.pumpkin_xyvu
if t1 <= _FRAME_MS * 1:
_set_cat(Catapult.LOAD)
elif t1 <= _FRAME_MS * 2:
_set_cat(Catapult.TOSS1)
elif t1 <= _FRAME_MS * 3:
_set_cat(Catapult.TOSS2)
elif t1 <= _FRAME_MS * 4:
_set_cat(Catapult.TOSS3)
_set_pumpkin(Catapult.FLY, x, y)
elif (t1 > 3000) or (y >= self.catapult.splat_y):
print("SPLAT!")
self.load_pumpkin()
else:
# Pumpkin in flight: update position
x += u * elapsed_ms
y -= v * elapsed_ms
# ajust vertical velocity for acceleration due to gravity
v -= self._PG * elapsed_ms
# adjust horizontal velocity for acceleration due to drag
# (this is not physically realistic, I just tuned it for feel)
u = max(0.2 * self._PU, u - (((u ** 2) * 0.01) * elapsed_ms))
# Update pumpkin position.
# Note: x,y arguments are relative to catapult while the px,py
# return value are screen coordinates for skeleton hitbox test
(px, py) = _set_pumpkin(Catapult.FLY, x, y)
self.pumpkin_xyvu = (x, y, v, u)
if self.skeletons.check_hit(px, py):
print("POW!")
self.load_pumpkin()
# Update skeleton animations
s = self.state
if (s == _READY) or (s == _CHARGE) or (s == _TOSS):
if self.skeletons.tick():
self.need_repaint = True
# Update screen; returns True if caller should refresh hardware display
if self.need_repaint:
self.paint()
self.need_repaint = False
return True
else:
return False
def handleGamepad(self, button):
# Handle a button press event
# args:
# - button: one of the button constants
# Check lookup table for the action code for this button event
if (button < 0) or (button > START):
print("Button value out of range:", button)
return
# Look up action code as a function of button event and current state
a = self._TABLE[self.state][button]
# Handle the action code
if a == _NOP:
pass
elif a == _PLAY:
self.group.remove(self.title_screen)
self.load_pumpkin()
self.skeletons.reset()
self.need_repaint = True
elif a == _CHARGE:
if self.state == _READY:
self.state = _CHARGE
self.status.text = "Pumpkin Power"
self.charge = min(Catapult.CHARGE_MAX, self.charge + 1)
self.catapult.set_charge(self.charge)
self.need_repaint = True
elif a == _TOSS:
self.status.text = ""
print("FIRE!")
(x, y, v, u) = self.pumpkin_xyvu
power_percent = self.charge / Catapult.CHARGE_MAX
v = self._PV * (0.5 * (1 + power_percent))
u = self._PU * power_percent
self.pumpkin_xyvu = (x, y, v, u)
self.catapult.set_charge(Catapult.CHARGE_ZERO)
self.state = _TOSS
self.timer = 0
self.need_repaint = True
elif a == _PAUSE:
print("PAUSED")
self.prev_state = self.state
self.state = _PAUSE
elif a == _RESUME:
print("RESUMING")
self.state = self.prev_state or _READY
self.need_repaint = True
CIRCUITPY/gamepad.py
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Copyright 2024 Sam Blenny
#
# Gamepad driver for XInput compatible USB wired gamepad with MAX421E.
#
# The button names used here match the Nintendo SNES style button
# cluster layout, but the USB IDs and protocol match the Xbox 360 USB
# wired controller. This is meant to work with widely available USB
# wired xinput compatible gamepads for the retrogaming market. In
# particular, I tested this package using my 8BitDo SN30 Pro USB wired
# gamepad.
#
# CAUTION: If you try to use a USB adapter with a wireless xinput
# compatible gamepad, it probably won't work with this driver in its
# current form. In my testing, compared to wired gamepads, USB wireless
# adapters have extra delays and errors that require retries and
# low-level error handling. I haven't been able to get a USB wireless
# gamepad adapter working yet in CircuitPython yet.
#
from micropython import const
from struct import unpack
from time import sleep
from usb import core
from usb.core import USBError, USBTimeoutError
# Gamepad button bitmask constants
UP = const(0x0001) # dpad: Up
DOWN = const(0x0002) # dpad: Down
LEFT = const(0x0004) # dpad: Left
RIGHT = const(0x0008) # dpad: Right
START = const(0x0010)
SELECT = const(0x0020)
L = const(0x0100) # Left shoulder button
R = const(0x0200) # Right shoulder button
B = const(0x1000) # button cluster: bottom button (Nintendo B, Xbox A)
A = const(0x2000) # button cluster: right button (Nintendo A, Xbox B)
Y = const(0x4000) # button cluster: left button (Nintendo Y, Xbox X)
X = const(0x8000) # button cluster: top button (Nintendo X, Xbox Y)
class XInputGamepad:
# Constants for USB device IO
_INTERFACE = const(0)
_TIMEOUT_MS = const(100)
_ENDPOINT = const(0x81)
def __init__(self):
# Initialize buffer used in polling USB gamepad events
self.buf64 = bytearray(64)
# Variable to hold the gamepad's usb.core.Device object
self.device = None
def find_and_configure(self):
# Connect to a USB wired Xbox 360 style gamepad (vid:pid=045e:028e)
#
# Returns: True = success, False = device not found or config failed
# Exceptions: may raise usb.core.USBError or usb.core.USBTimeoutError
#
device = core.find(idVendor=0x045e, idProduct=0x028e)
sleep(0.1)
if device:
self._configure(device) # may raise usb.core.USBError
return True # end retry loop
else:
# No gamepad was found
self.reset()
return False
def _configure(self, device):
# Prepare USB gamepad for use (set configuration, drain buffer, etc)
#
# Exceptions: may raise usb.core.USBError or usb.core.USBTimeoutError
#
try:
# Make sure CircuitPython core is not claiming the device
if device.is_kernel_driver_active(_INTERFACE):
device.detach_kernel_driver(_INTERFACE)
# Make sure that configuration is set
device.set_configuration()
except USBError as e:
self.reset()
raise e
# Initial reads may give old data, so drain gamepad's buffer. This
# may raise an exception (with no string description nor errno!)
# when buffer is already empty. If that happens, ignore it.
try:
sleep(0.1)
for _ in range(8):
__ = device.read(0x81, self.buf64, timeout=_TIMEOUT_MS)
except USBTimeoutError:
pass
except USBError as e:
self.reset()
raise e
# All good, so save a reference to the device object
self.device = device
def poll(self):
# Generator to poll gamepad for button changes (ignore sticks/triggers)
# Yields:
# buttons: Uint16 containing bitfield of individual button values
# Exceptions: may raise usb.core.USBError or usb.core.USBTimeoutError
#
# This generator is meant to be used with a `for` loop. The point is to
# allow for faster polling by reducing the Python VM overhead spent on
# memory allocation, method calls, and dictionary lookups. To read more
# about generators, see https://peps.python.org/pep-0255/
#
# Expected endpoint 0x81 report format:
# bytes 0,1: prefix that doesn't change [ignored]
# bytes 2,3: button bitfield for dpad, ABXY, etc (uint16)
# byte 4: L2 left trigger (analog uint8) [ignored]
# byte 5: R2 right trigger (analog uint8) [ignored]
# bytes 6,7: LX left stick X axis (int16) [ignored]
# bytes 8,9: LY left stick Y axis (int16) [ignored]
# bytes 10,11: RX right stick X axis (int16) [ignored]
# bytes 12,13: RY right stick Y axis (int16) [ignored]
# bytes 14..19: ???, but they don't change
#
if self.device is None:
# Caller is trying to poll buttons when gamepad is not connected
return
# Caching frequently used objects saves time on dictionary name lookups
_devread = self.device.read
_buf = self.buf64
_unpack = unpack
# Generator loop (note how this uses yield instead of return)
prev = 0
while True:
try:
# Poll gamepad endpoint to get button and joystick status bytes
n = _devread(_ENDPOINT, _buf, timeout=_TIMEOUT_MS)
if n < 14:
# skip unexpected responses (too short to be a full report)
yield prev
# Only bytes 2 and 3 are interesting (ignore sticks/triggers)
(buttons,) = _unpack('<H', self.buf64[2:4])
prev = buttons
yield buttons
except USBTimeoutError:
pass
except USBError as e:
self.reset()
raise e
def device_info_str(self):
# Return string describing gamepad device (or lack thereof)
d = self.device
if d is None:
return "[Gamepad not connected]"
(v, pi, pr, m) = (d.idVendor, d.idProduct, d.product, d.manufacturer)
if (v is None) or (pi is None):
# Sometimes the usb.core or Max3421E will return 0000:0000 for
# reasons that I do not understand
return "[bad vid:pid]"
else:
return "Connected: %04x:%04x prod='%s' mfg='%s'" % (v, pi, pr, m)
def reset(self):
# Reset USB device and gamepad button polling state
self.device = None