Skip to content

Animation & sequencing

These three modules move animation and timed logic off hand-rolled frame counters. picogame_anim drives a sprite’s frame on a time basis, picogame_seq lets you write timelines as generators, and picogame_cutscene paints a full-screen image with almost no RAM. They are pure-Python helpers over the engine - see /reference/ for the bare signatures.

What it is: a tiny frame-animation driver. You give it a list of frame indices and an fps, call tick(dt) once per frame with the real elapsed seconds, and it sets sprite.frame for you. Reach for it whenever you would otherwise write sprite.frame = (f // 4) % n - it makes the animation frame-rate independent (driven by real dt, not loop count) and keeps the math out of your update loop.

There are two classes:

  • FrameAnim(sprite, frames, *, fps=8, loop=True) - plays one sequence. frames is a list/tuple of frame indices (referenced, not copied - treat it read-only). fps is animation speed; loop keyword-only. On construction it sets the sprite to frames[0].
    • .tick(dt) - advance by dt real seconds. Accumulates time, steps the frame when enough has passed. No-op once a non-looping anim is finished or if frames is empty. No return value.
    • .configure(frames, fps=8, loop=True) - re-point this same instance at a new sequence and reset it. Returns self. Lets you reuse one FrameAnim instead of allocating a new one on every switch.
    • .reset() - back to frame 0, clears the done flag and time accumulator.
    • Attributes you can read: .done (True when a non-looping anim has reached its last frame), .i (current index into frames), .frames, .fps, .loop.
  • AnimatedSprite(sprite, anims) - a sprite with named states. anims is a dict {name: (frames, fps, loop)}. Internally holds one reusable FrameAnim (no per-switch allocation).
    • .play(name) - switch to that named animation. Looks up anims[name] and reconfigures. Calling play with the name already playing is a no-op, so it is safe to call every frame.
    • .tick(dt) - advance the current animation.
import picogame_anim
hero = picogame_anim.AnimatedSprite(self.spr, {
"run": (DINO_RUN, 12, True),
"jump": ((DINO_JUMP,), 1, False),
})
hero.play("run")
# each frame, with dt = real seconds since last frame:
hero.play("jump" if self.jumping else "run") # cheap to call every frame
hero.tick(dt)

For a single sequence (a spinning coin, a walk cycle) skip the dict and use FrameAnim directly: spin = picogame_anim.FrameAnim(sprite, list(range(COIN_FRAMES)), fps=15).

Gotchas: you must pass real dt (seconds per frame, e.g. from picogame_clock), not a frame count - passing 1 makes it run at fps frames per call. The frames list is held by reference, so do not mutate it while it is in use. .done only ever becomes True for loop=False.

What it is: a way to write timed and staged logic as generators (a coroutine pattern). Each yield means “resume me next frame”; a Seq advances one generator one step per tick(). Reach for it for cutscenes, intros, staged AI, and any “do X over N frames” timeline that would otherwise become a tangle of frame counters and if-ladders. Compose sub-sequences with yield from.

Generator helpers (call them with yield from):

  • wait(frames) - pause for frames frames (yields that many times, doing nothing).
  • over(frames, fn) - generic tween: calls fn(t) each frame with t ramping 0..1 (specifically i/frames for i in 1..frames) over frames frames.
  • move_over(sprite, x, y, frames) - glide a sprite from its current position to (x, y) over frames frames, linearly, via sprite.move(...).

The driver:

  • Seq(gen=None) - wraps one generator. If gen is None it starts already .done.
    • .start(gen) - point it at a (new) generator and clear done. Returns self, so it is reusable.
    • .tick() - advance to the next yield. Catches StopIteration and sets .done. Returns the done flag (True once finished), so you can branch on it.
    • .done - True when the sequence has finished.
import picogame_seq as seq
def intro(hero, label):
yield from seq.wait(30)
label.set("GO!")
yield from seq.move_over(hero, 120, hero.y, 20) # glide over 20 frames
s = seq.Seq(intro(player, hud))
# each frame:
if not s.tick(): # advances one step; True once the intro is over
... # still running

Gotchas: tick() advances exactly one step (to the next yield) per call, so drive it once per game frame. Anything between yields runs in a single frame - keep heavy work behind a yield. A Seq runs one generator; to run several timelines at once, give each its own Seq, or merge them with yield from inside one generator.

What it is: a full-screen image player that uses almost no RAM. A 320x240 frame is 150 KB in RGB565 (75 KB in PAL8), which does not fit the ~138 KB heap - see /memory/. Instead the image lives on flash as a raw, row-major file, and the module streams it one ~24-row band at a time through a small reused buffer, blitting each band straight to the LCD. Only one band is ever in RAM; once drawn, the LCD holds the picture, so a static cutscene costs nothing afterwards. Reach for it for title screens, story panels, maps, and chapter cards.

Bake the raw file first with tools/bake_image.py (PNG to wire-order RGB565 rows, or PAL8 plus a palette). See /scene-format/ for engine bitmap formats and /hardware/ for the display and buttons.

  • show(pg, display, buffer, path, w=320, h=240, band=24, palette=None) - render the image at path to display, streaming band by band. buffer is your strip buffer. Pass a palette to read a 1-byte-per-pixel PAL8 file; omit it for 2-byte RGB565. Returns nothing.
  • play(pg, display, buffer, btn, path, w=320, h=240, band=24, palette=None) - calls show(), then blocks (polling btn) until A or B is pressed, then returns. Use this for “press to continue” screens; use show() when you want to keep running your own loop.
import picogame_cutscene as cut
import board
cut.play(pg, board.DISPLAY, bufA, btn, "intro.dat") # RGB565
cut.play(pg, board.DISPLAY, bufA, btn, "map.dat", palette=PAL) # PAL8 + palette

Gotchas: band MUST divide h evenly (240 % 24 == 0) or you will miss rows. buffer must be at least w * band * 2 bytes (RGB565) - or w * band for PAL8. The file must be raw, row-major, in wire order from bake_image.py; a normal PNG will not work. play() blocks your game loop entirely while waiting for the button.