Pixel-art GIF player slab

Built-in DVD bounce, dino run, and space invader on the C6 slab. Short BOOT skips loops; drop more GIFs on a TF card.

Pixel-art GIF player slab
DifficultyBeginner
Build time45 min
Est. cost$20
Parts4
Files5
Steps3

Things you'll need

QtyPartType
1
Waveshare ESP32-C6-LCD-1.47 (silhouette slab)
Waveshare ESP32-C6-LCD-1.47 (silhouette slab) 172×320 ST7789, GPIO8 WS2812, TF slot, BOOT/RESET. No touch or IMU. Amazon B0DK5J6LX3.
electronics
1
GIF player housing
GIF player housing Landscape window for the 1.47" panel. Leave the TF slot open on the +X edge if you will load cards.
printed
1
USB-C cable Data-capable cable for flash and power.
electronics
1
PLA or PETG About 12–20 g for the snap shells.
consumable

Tools & software

1
PlatformIO or Arduino IDE Flash over USB-C. Serial 115200.

Files & downloads

Download all

Flash writes a precompiled image over USB-C in Chrome or Edge. The .ino below is the source — you do not need Arduino IDE unless you want to change the sketch.

What you're building

A pixel-art TV. Three loops play with no SD card: a bouncing DVD logo, a dino that hops a cactus, and a space invader. After that, any /gifs/*.gif on a FAT32 TF card. Short BOOT is next. Hold BOOT rotates.

The RGB LED pulses each frame so the board feels alive even from the side.

What you will learn

  • That animation is many still pictures in a row.
  • What a framebuffer is (a picture in RAM you show all at once).
  • Why drawing “live” on this screen tears — and how one blit fixes it.
  • How a file on a card is just another source of frames.

Meet the silhouette slab

This project uses the Waveshare ESP32-C6-LCD-1.47 — a slim rectangle with a 1.47 inch screen.

  • A 172×320 color screen. You can turn it portrait or landscape in software.
  • No touch. No motion sensor. Interaction is the BOOT button (a short press vs a ~0.7 second hold).
  • A single RGB LED on the board (GPIO8) that can glow any color.
  • Wi-Fi that is 2.4 GHz only.
  • Keep the backlight at 50%. Full brightness can leave a heat spot on the panel.

The program is a .ino file (Arduino-style C++). You will not memorize it. You will learn the ideas, then see the small piece of code that does each job.

Note

Word to know — GPIO. It means “general purpose input/output” — a pin the chip can turn on or read. BOOT is GPIO9. The RGB LED is GPIO8. You do not rewire anything; the board already connected those pins.

The finished gadget

Pixel-art GIF player slab
Pixel-art GIF player slab on Silhouette slab (ESP32-C6).

Build it

1

Print the housing

0.2 mm. Leave the TF slot open on the +X edge if you will hot-swap cards. The three built-in loops need no card.

Print the housing
2

Flash the slab

Flash means copy the program onto the chip. Easiest path: use the attached gif-player.bin with the on-page Flash to board button (Chrome or Edge, USB-C). To change the code later: Arduino IDE → ESP32C6 Dev Module, USB CDC On Boot, 115200 — or unzip platformio.zip next to the .ino and run python3 -m platformio run -t upload. Keep the backlight at 50%. This slab has no touch screen and no motion sensor — you press BOOT (the button on GPIO9) to interact.

Flash the slab
3

Play loops

Short BOOT = next loop. Hold BOOT = rotate. Optional: FAT32 card with a /gifs folder of .gif files.

Play loops

Lesson 1 — Film is a flipbook

A movie is not moving ink. It is still pictures shown fast enough that your eye blends them. The DVD logo is: each frame, add velocity to x and y; if you hit an edge, flip that velocity. The dino is: a run cycle plus a hop height. The invader is: slide, maybe drop.

That is simulation + drawing. Games are the same loop: update, draw, repeat.

The playlist

gif-player.ino
// Always first, no SD needed:
//   DVD bounce  →  dino run  →  invader
// Then /gifs/*.gif on the TF card.
// Short BOOT = next. Hold BOOT = rotate.

Lesson 2 — Compose, then show (tearing)

This LCD hates lots of little draw calls. If you fillRect a cactus, then a dino, then a cloud, the panel can show a frame that is half old and half new — a tear. It looks like a glitch, but it is timing.

The fix: paint the whole scene into a buffer in RAM, then draw16bitRGBBitmap once. Same idea as the 8-ball sprite. You will meet this again in the slot machine and the eyes.

One postcard, not a hundred stickers

gif-player.ino
// 1) draw dino, cactus, ground into g_fb (RAM)
// 2) then, once:
gfx->draw16bitRGBBitmap(x, y, g_fb, w, h);

Lesson 3 — Files are streams of bytes

A .gif on a TF card is a file: a name, a size, and bytes. The program opens /gifs/whatever.gif and a library turns those bytes into frames. You do not need to learn the GIF format. You do need the idea: storage → bytes → pictures.

The card and the LCD share some wires (MOSI / SCLK). The firmware starts the card once at boot. Starting it again later can hang the bus — a real hardware gotcha, and a good story about shared resources.

Try this

  • Time a DVD bounce with a stopwatch. How many edge hits per 10 seconds?
  • Type list on serial to see built-ins plus card files.
  • Drop a tiny GIF in /gifs (keep it small) and press BOOT past the invader.

Talking to the board (serial)

Your computer can talk to the board over the same USB-C cable. That text chat is called serial. In Arduino IDE or PlatformIO, open the Serial Monitor at 115200 baud (that number is the speed — both sides must match).

Type a word, press Enter, and the board answers. Try help first. Then try status.

Commands for this project: help · status · next · rotate · list

Tip

If you see nothing, pick the right USB port, set 115200, and press the board’s RESET once. On a Mac the port often looks like /dev/cu.usbmodem….

Warning

Never add a second SD.begin() after the panel is running. Backlight stays at 50%.


The complete program (reference)

Everything above taught the ideas. Below is the full sketch so you can search, copy, and tinker. It is long on purpose — that is a real program, not a toy snippet. Scroll inside the box. The downloadable .ino is the same file.

Wi-Fi and API fields are placeholders (YOUR_WIFI_SSID_HERE, YOUR_WIFI_PASSWORD_HERE). Never paste a real password into a public copy.

gif-player.ino (complete)

gif-player.ino
/*
 * PrintPal GIF Player — ESP32-C6-LCD-1.47 (LCD-only, no touch / IMU)
 *
 * Built-in loops always play first (no SD needed):
 *   DVD bounce  →  dino run  →  invader bounce
 * Then any /gifs/*.gif on the TF card. Short BOOT = next loop.
 * Hold BOOT ~0.7s = portrait (172×320) / landscape (320×172).
 *
 * RGB (GPIO8) pulses each frame. Backlight stays at 50%.
 * Serial 115200: help | status | next | rotate | list
 */

#include <Arduino.h>
#include <math.h>
#include <SPI.h>
#include <SD.h>
#include <Arduino_GFX_Library.h>
#include <Adafruit_NeoPixel.h>
#include <AnimatedGIF.h>
#include <Preferences.h>
#include "c6_slab_pins.h"

#define GIF_DIR "/gifs"
#define MAX_GIFS 48
#define MAX_PATH 80
#define BL_CAP C6_BL_PWM
#define BOOT_LONG_MS 700UL
#define N_BUILTIN 3

enum PlayKind : uint8_t { KIND_DVD, KIND_DINO, KIND_INVADER, KIND_SD };

constexpr uint16_t rgb565(uint8_t r, uint8_t g, uint8_t b) {
  return (uint16_t)(((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3));
}

static const uint16_t COL_BG   = rgb565(7, 11, 18);
static const uint16_t COL_MINT = rgb565(61, 255, 184);
static const uint16_t COL_PINK = rgb565(255, 79, 216);
static const uint16_t COL_BLUE = rgb565(122, 162, 255);
static const uint16_t COL_DIM  = rgb565(28, 39, 64);
static const uint16_t COL_EYE  = rgb565(240, 248, 255);
static const uint16_t COL_SAND = rgb565(140, 120, 80);
static const uint16_t COL_CACT = rgb565(48, 180, 88);
static const uint16_t COL_CLOUD = rgb565(48, 62, 88);

static const uint8_t SPRITE[8][8] = {
  {0, 0, 1, 1, 1, 1, 0, 0},
  {0, 1, 1, 2, 2, 1, 1, 0},
  {1, 1, 4, 1, 1, 4, 1, 1},
  {1, 1, 1, 1, 1, 1, 1, 1},
  {1, 2, 1, 1, 1, 1, 2, 1},
  {0, 1, 2, 2, 2, 2, 1, 0},
  {0, 0, 1, 1, 1, 1, 0, 0},
  {0, 0, 0, 1, 1, 0, 0, 0},
};
static const uint16_t SPRITE_PAL[] = {COL_BG, COL_MINT, COL_PINK, COL_BLUE, COL_EYE};

Arduino_DataBus *bus = new Arduino_ESP32SPI(
    C6_LCD_DC_PIN, C6_LCD_CS_PIN, C6_LCD_SCLK_PIN, C6_LCD_MOSI_PIN, GFX_NOT_DEFINED);
Arduino_GFX *gfx = new Arduino_ST7789(
    bus, C6_LCD_RST_PIN, 0, true, C6_LCD_W, C6_LCD_H, 34, 0, 34, 0);

Adafruit_NeoPixel rgb(1, C6_RGB_PIN, NEO_GRB + NEO_KHZ800);
AnimatedGIF gif;
Preferences prefs;
static File g_gifFile;

static bool g_landscape = false;
static bool g_sd = false;
static char g_paths[MAX_GIFS][MAX_PATH];
static int g_sdCount = 0;
static int g_index = 0;
static uint32_t g_frame = 0;
static bool g_skip = false;
static bool g_gifOpen = false;
static int16_t g_xOff = 0, g_yOff = 0;
static uint16_t g_line[320];

static float g_sx = 20.f, g_sy = 40.f, g_svx = 1.6f, g_svy = 1.2f;
static uint8_t g_hue = 0;
static int16_t g_prevX = -1, g_prevY = -1;
static int g_dinoX = 12, g_ground = 0, g_scroll = 0, g_cactus = 200, g_hop = 0;
static int16_t g_prevDinoX = -1, g_prevDinoY = -1, g_prevCactus = -1;
static const int SPR_SCALE = 7;
static const int FB_MAX_W = 160;
static const int FB_MAX_H = 64;
static uint16_t *g_fb = nullptr;

static bool g_bootDown = false;
static bool g_bootLong = false;
static uint32_t g_bootDownAt = 0;

static int sw() { return gfx->width(); }
static int sh() { return gfx->height(); }
static int playH() { return sh(); }
static int sprPx() { return 8 * SPR_SCALE; }

static int playlistLen() { return N_BUILTIN + g_sdCount; }

static PlayKind kindAt(int i) {
  if (i < 0) i = 0;
  if (i < N_BUILTIN) return (PlayKind)i;
  return KIND_SD;
}

static const char *kindName(PlayKind k) {
  if (k == KIND_DVD) return "DVD-BOUNCE";
  if (k == KIND_DINO) return "DINO-RUN";
  if (k == KIND_INVADER) return "INVADER";
  return "SD";
}

static void lcdIdle() { digitalWrite(C6_LCD_CS_PIN, HIGH); }
static void sdIdle() { digitalWrite(C6_TF_CS_PIN, HIGH); }

static void reclaimLcd() {
  sdIdle();
  lcdIdle();
  bus->begin();
}

static void pulseRgb(uint32_t frame) {
  uint16_t hue = (uint16_t)(frame * 2500u);
  uint8_t val = (frame & 1) ? 96 : 28;
  rgb.setPixelColor(0, rgb.gamma32(rgb.ColorHSV(hue, 230, val)));
  rgb.show();
}

static const char *baseName(const char *path) {
  const char *s = strrchr(path, '/');
  return s ? s + 1 : path;
}

static bool isGifName(const char *name) {
  if (!name || name[0] == '.' || name[0] == '_') return false;
  size_t n = strlen(name);
  if (n < 4) return false;
  char a = name[n - 4], b = name[n - 3], c = name[n - 2], d = name[n - 1];
  if (a != '.') return false;
  return (b == 'g' || b == 'G') && (c == 'i' || c == 'I') && (d == 'f' || d == 'F');
}

static void applyOrient() { gfx->setRotation(g_landscape ? 1 : 0); }

static uint16_t hsv565(uint8_t h) {
  static const uint8_t kHues[][3] = {
      {61, 255, 184}, {255, 79, 216}, {122, 162, 255}, {255, 196, 64},
      {255, 88, 88},  {72, 255, 128}, {255, 160, 40},  {180, 120, 255},
  };
  const uint8_t *p = kHues[(h / 32) & 7];
  return rgb565(p[0], p[1], p[2]);
}

static void ensureFb() {
  if (!g_fb) g_fb = (uint16_t *)malloc(FB_MAX_W * FB_MAX_H * sizeof(uint16_t));
}

static void fbFill(int stride, int w, int h, uint16_t col) {
  for (int y = 0; y < h; y++) {
    uint16_t *row = g_fb + y * stride;
    for (int x = 0; x < w; x++) row[x] = col;
  }
}

static void fbRect(int stride, int x, int y, int w, int h, uint16_t col) {
  for (int j = 0; j < h; j++) {
    const int yy = y + j;
    if (yy < 0) continue;
    uint16_t *row = g_fb + yy * stride;
    for (int i = 0; i < w; i++) {
      const int xx = x + i;
      if (xx < 0) continue;
      row[xx] = col;
    }
  }
}

// Stadium / capsule. Height must be odd (2r+1) so the end-caps meet the bar.
static void fbCapsule(int stride, int x, int y, int bw, int bh, uint16_t col) {
  const int r = (bh - 1) / 2;
  const int r2 = r * r;
  for (int row = 0; row < bh; row++) {
    const int yy = row - r;
    int disc = r2 - yy * yy;
    int dx = 0;
    if (disc > 0) {
      dx = (int)(sqrtf((float)disc) + 0.5f);
      if (dx > r) dx = r;
    }
    const int x0 = x + r - dx;
    const int x1 = x + (bw - 1 - r) + dx;
    uint16_t *dst = g_fb + (y + row) * stride;
    for (int xx = x0; xx <= x1; xx++) dst[xx] = col;
  }
}

static void blitFb(int x, int y, int w, int h, int stride) {
  if (!g_fb || w <= 0 || h <= 0) return;
  if (stride == w) {
    gfx->draw16bitRGBBitmap(x, y, g_fb, w, h);
    return;
  }
  for (int row = 0; row < h; row++) {
    gfx->draw16bitRGBBitmap(x, y + row, g_fb + row * stride, w, 1);
  }
}

static void fillClipped(int x, int y, int w, int h, uint16_t col) {
  if (w <= 0 || h <= 0) return;
  if (x < 0) {
    w += x;
    x = 0;
  }
  if (y < 0) {
    h += y;
    y = 0;
  }
  if (x + w > sw()) w = sw() - x;
  if (y + h > playH()) h = playH() - y;
  if (w > 0 && h > 0) gfx->fillRect(x, y, w, h, col);
}

// Erase the old AABB that is not covered by the new one (L-shaped leftover).
static void eraseOldNotNew(int ox, int oy, int nx, int ny, int w, int h, uint16_t bg) {
  if (ox < 0) return;
  if (oy < ny) fillClipped(ox, oy, w, ny - oy, bg);
  if (oy + h > ny + h) fillClipped(ox, ny + h, w, (oy + h) - (ny + h), bg);
  const int y0 = (oy > ny) ? oy : ny;
  const int y1 = (oy + h < ny + h) ? (oy + h) : (ny + h);
  const int hh = y1 - y0;
  if (hh <= 0) return;
  if (ox < nx) fillClipped(ox, y0, nx - ox, hh, bg);
  if (ox + w > nx + w) fillClipped(nx + w, y0, (ox + w) - (nx + w), hh, bg);
}

static void closeGif() {
  if (g_gifOpen) {
    gif.close();
    g_gifOpen = false;
  }
}

static void resetBuiltin() {
  g_sx = 12.f;
  g_sy = 20.f;
  g_svx = 2.2f;
  g_svy = 1.6f;
  g_prevX = -1;
  g_prevDinoX = -1;
  g_prevDinoY = -1;
  g_prevCactus = -1;
  g_hue = 0;
  g_dinoX = 12;
  g_scroll = 0;
  g_cactus = sw() + 40;
  g_hop = 0;
  g_ground = playH() - 22;
}

static void dvdSize(int *bw, int *bh, int *ts) {
  // Heights stay odd so capsule end-caps are 2r+1 and meet the bar.
  if (g_landscape) {
    *bw = 149;
    *bh = 59;
    *ts = 4;
  } else {
    *bw = 119;
    *bh = 51;
    *ts = 3;
  }
}

// 5×7 caps for the DVD hole — stamped into the same bitmap as the capsule.
static const uint8_t GLYPH_D[7] = {0x1E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1E};
static const uint8_t GLYPH_V[7] = {0x11, 0x11, 0x11, 0x11, 0x0A, 0x0A, 0x04};

static void fbGlyph(int stride, int x, int y, const uint8_t *g, int s, uint16_t col) {
  for (int row = 0; row < 7; row++) {
    for (int colb = 0; colb < 5; colb++) {
      if (g[row] & (0x10 >> colb)) fbRect(stride, x + colb * s, y + row * s, s, s, col);
    }
  }
}

static void fbDvdWord(int stride, int bw, int bh, int s, uint16_t col) {
  const int gw = 5 * s, gap = s, tw = 3 * gw + 2 * gap, th = 7 * s;
  const int x = (bw - tw) / 2;
  const int y = (bh - th) / 2;
  fbGlyph(stride, x, y, GLYPH_D, s, col);
  fbGlyph(stride, x + gw + gap, y, GLYPH_V, s, col);
  fbGlyph(stride, x + 2 * (gw + gap), y, GLYPH_D, s, col);
}

static void drawDvdFrame() {
  int bw, bh, ts;
  dvdSize(&bw, &bh, &ts);
  const int maxX = sw() - bw - 2;
  const int maxY = playH() - bh - 2;
  bool hit = false;
  g_sx += g_svx;
  g_sy += g_svy;
  if (g_sx < 2) { g_sx = 2; g_svx = -g_svx; hit = true; }
  if (g_sx > maxX) { g_sx = (float)maxX; g_svx = -g_svx; hit = true; }
  if (g_sy < 2) { g_sy = 2; g_svy = -g_svy; hit = true; }
  if (g_sy > maxY) { g_sy = (float)maxY; g_svy = -g_svy; hit = true; }
  if (hit) g_hue = (uint8_t)(g_hue + 32);
  const uint16_t c = hsv565(g_hue);
  const int x = (int)g_sx, y = (int)g_sy;

  ensureFb();
  fbFill(bw, bw, bh, COL_BG);
  fbCapsule(bw, 0, 0, bw, bh, c);
  fbCapsule(bw, 7, 8, bw - 14, bh - 16, COL_BG);
  fbDvdWord(bw, bw, bh, ts, c);
  blitFb(x, y, bw, bh, bw);
  eraseOldNotNew(g_prevX, g_prevY, x, y, bw, bh, COL_BG);
  g_prevX = (int16_t)x;
  g_prevY = (int16_t)y;
}

static void drawClouds() {
  gfx->fillCircle(28, 28, 10, COL_CLOUD);
  gfx->fillCircle(40, 26, 13, COL_CLOUD);
  gfx->fillCircle(54, 30, 9, COL_CLOUD);
  gfx->fillCircle(sw() - 50, 48, 11, COL_CLOUD);
  gfx->fillCircle(sw() - 36, 44, 14, COL_CLOUD);
  gfx->fillCircle(sw() - 22, 50, 8, COL_CLOUD);
  if (!g_landscape) {
    gfx->fillCircle(70, 78, 8, COL_CLOUD);
    gfx->fillCircle(82, 74, 11, COL_CLOUD);
    gfx->fillCircle(94, 80, 7, COL_CLOUD);
  }
}

static const int DINO_W = 54;
static const int DINO_H = 54;
static const int CACT_W = 34;
static const int CACT_H = 42;

static void restoreGround(int x, int y, int w, int h) {
  const int gy = g_ground;
  if (w <= 0 || h <= 0) return;
  if (y <= gy && y + h > gy) gfx->drawFastHLine(x, gy, w, COL_SAND);
  if (y <= gy + 1 && y + h > gy + 1) gfx->drawFastHLine(x, gy + 1, w, COL_SAND);
  if (y <= gy + 6 && y + h > gy + 4) {
    for (int tx = -g_scroll; tx < sw(); tx += 22) {
      const int ix = (tx > x) ? tx : x;
      const int ix2 = (tx + 10 < x + w) ? (tx + 10) : (x + w);
      if (ix2 > ix) gfx->fillRect(ix, gy + 4, ix2 - ix, 2, COL_DIM);
    }
  }
}

static void composeCactus() {
  fbFill(CACT_W, CACT_W, CACT_H, COL_BG);
  fbRect(CACT_W, 10, 0, 12, 42, COL_CACT);
  fbRect(CACT_W, 0, 16, 10, 6, COL_CACT);
  fbRect(CACT_W, 22, 8, 10, 6, COL_CACT);
  fbRect(CACT_W, 0, 16, 4, 16, COL_CACT);
  fbRect(CACT_W, 28, 8, 4, 14, COL_CACT);
}

static void composeDino(int hopY, int frame) {
  const int leg = ((frame / 4) & 1) ? 4 : -4;
  fbFill(DINO_W, DINO_W, DINO_H, COL_BG);
  fbRect(DINO_W, 10, 16, 32, 22, COL_EYE);
  fbRect(DINO_W, 34, 0, 20, 20, COL_EYE);
  fbRect(DINO_W, 46, 6, 6, 6, COL_BG);
  fbRect(DINO_W, 14, 38, 8, 12 + leg, COL_EYE);
  fbRect(DINO_W, 28, 38, 8, 12 - leg, COL_EYE);
  fbRect(DINO_W, 0, 22, 12, 6, COL_EYE);
  (void)hopY;
}

static void drawDinoFrame() {
  const int gy = g_ground;
  const int dx = g_dinoX;
  g_scroll = (g_scroll + 4) % 22;
  g_cactus -= 4;
  if (g_cactus < -20) {
    g_cactus = sw() + 36 + (int)(g_frame % 50);
  }
  if (g_hop > 0) {
    g_hop--;
  } else if ((g_frame % 48) == 12) {
    g_hop = 14;
  }

  const int hopY = (g_hop > 0) ? (int)(sinf((14 - g_hop) * 0.20f) * 34) : 0;
  const int dy = gy - 36 - hopY;
  const int cx = g_cactus;
  const int cactusX = cx - 10;
  const int cactusY = gy - 42;
  const int dinoX = dx - 10;
  const int dinoY = dy - 16;

  ensureFb();
  composeCactus();
  blitFb(cactusX, cactusY, CACT_W, CACT_H, CACT_W);
  eraseOldNotNew(g_prevCactus, cactusY, cactusX, cactusY, CACT_W, CACT_H, COL_BG);
  if (g_prevCactus >= 0) restoreGround(g_prevCactus, cactusY, CACT_W, CACT_H);

  composeDino(hopY, (int)g_frame);
  blitFb(dinoX, dinoY, DINO_W, DINO_H, DINO_W);
  eraseOldNotNew(g_prevDinoX, g_prevDinoY, dinoX, dinoY, DINO_W, DINO_H, COL_BG);
  if (g_prevDinoX >= 0) restoreGround(g_prevDinoX, g_prevDinoY, DINO_W, DINO_H);

  // 2px tick strip only — never wipe the whole ground band
  gfx->fillRect(0, gy + 4, sw(), 2, COL_BG);
  for (int x = -g_scroll; x < sw(); x += 22) {
    gfx->fillRect(x, gy + 4, 10, 2, COL_DIM);
  }
  gfx->drawFastHLine(0, gy, sw(), COL_SAND);
  gfx->drawFastHLine(0, gy + 1, sw(), COL_SAND);

  if (dinoY < 90) drawClouds();

  g_prevDinoX = (int16_t)dinoX;
  g_prevDinoY = (int16_t)dinoY;
  g_prevCactus = (int16_t)cactusX;
}

static void drawStars() {
  for (int i = 0; i < 28; i++) {
    int x = (i * 37 + 11) % sw();
    int y = (i * 53 + 19) % playH();
    gfx->drawPixel(x, y, (i & 1) ? COL_DIM : COL_BLUE);
  }
}

static bool spriteHole(int px, int py) {
  const int cx = px / SPR_SCALE, cy = py / SPR_SCALE;
  if (cx < 0 || cy < 0 || cx > 7 || cy > 7) return true;
  return SPRITE[cy][cx] == 0;
}

static void restoreStars(int x, int y, int w, int h, bool holesOnly) {
  for (int i = 0; i < 28; i++) {
    int sx = (i * 37 + 11) % sw();
    int sy = (i * 53 + 19) % playH();
    if (sx < x || sx >= x + w || sy < y || sy >= y + h) continue;
    if (holesOnly && !spriteHole(sx - x, sy - y)) continue;
    gfx->drawPixel(sx, sy, (i & 1) ? COL_DIM : COL_BLUE);
  }
}

static void restoreStarsLeftover(int ox, int oy, int nx, int ny, int w, int h) {
  for (int i = 0; i < 28; i++) {
    int sx = (i * 37 + 11) % sw();
    int sy = (i * 53 + 19) % playH();
    const bool inOld = (sx >= ox && sx < ox + w && sy >= oy && sy < oy + h);
    const bool inNew = (sx >= nx && sx < nx + w && sy >= ny && sy < ny + h);
    if (inOld && !inNew) gfx->drawPixel(sx, sy, (i & 1) ? COL_DIM : COL_BLUE);
  }
}

static void composeInvader() {
  const int px = sprPx();
  fbFill(px, px, px, COL_BG);
  for (int y = 0; y < 8; y++) {
    for (int x = 0; x < 8; x++) {
      const uint8_t c = SPRITE[y][x];
      if (!c) continue;
      fbRect(px, x * SPR_SCALE, y * SPR_SCALE, SPR_SCALE, SPR_SCALE, SPRITE_PAL[c]);
    }
  }
}

static void drawInvaderFrame() {
  const int px = sprPx();
  const int maxX = sw() - px - 2;
  const int maxY = playH() - px - 2;
  g_sx += g_svx;
  g_sy += g_svy;
  if (g_sx < 2 || g_sx > maxX) g_svx = -g_svx;
  if (g_sy < 2 || g_sy > maxY) g_svy = -g_svy;
  if (g_sx < 2) g_sx = 2;
  if (g_sx > maxX) g_sx = (float)maxX;
  if (g_sy < 2) g_sy = 2;
  if (g_sy > maxY) g_sy = (float)maxY;
  const int x = (int)g_sx, y = (int)g_sy;

  ensureFb();
  composeInvader();
  blitFb(x, y, px, px, px);
  restoreStars(x, y, px, px, true);
  eraseOldNotNew(g_prevX, g_prevY, x, y, px, px, COL_BG);
  if (g_prevX >= 0) restoreStarsLeftover(g_prevX, g_prevY, x, y, px, px);
  g_prevX = (int16_t)x;
  g_prevY = (int16_t)y;
}

static void *gifOpen(const char *path, int32_t *size) {
  lcdIdle();
  g_gifFile = SD.open(path, FILE_READ);
  if (!g_gifFile) return nullptr;
  *size = (int32_t)g_gifFile.size();
  return &g_gifFile;
}
static void gifClose(void *handle) {
  lcdIdle();
  File *f = static_cast<File *>(handle);
  if (f) f->close();
}
static int32_t gifRead(GIFFILE *file, uint8_t *buf, int32_t len) {
  lcdIdle();
  File *f = static_cast<File *>(file->fHandle);
  if (!f) return 0;
  int32_t n = len;
  if ((file->iSize - file->iPos) < len) n = file->iSize - file->iPos - 1;
  if (n <= 0) return 0;
  n = (int32_t)f->read(buf, n);
  file->iPos = (int32_t)f->position();
  return n;
}
static int32_t gifSeek(GIFFILE *file, int32_t pos) {
  lcdIdle();
  File *f = static_cast<File *>(file->fHandle);
  if (!f) return 0;
  f->seek(pos);
  file->iPos = (int32_t)f->position();
  return file->iPos;
}

static void GIFDraw(GIFDRAW *pDraw) {
  sdIdle();
  const int y = pDraw->iY + pDraw->y + g_yOff;
  if (y < 0 || y >= playH()) return;
  uint8_t *s = pDraw->pPixels;
  uint16_t *pal = pDraw->pPalette;
  if (!s || !pal) return;
  if (pDraw->ucDisposalMethod == 2 && pDraw->ucHasTransparency) {
    for (int i = 0; i < pDraw->iWidth; i++) {
      if (s[i] == pDraw->ucTransparent) s[i] = pDraw->ucBackground;
    }
    pDraw->ucHasTransparency = 0;
  }
  const int x0 = pDraw->iX + g_xOff;
  const int wSrc = pDraw->iWidth;
  const int maxW = sw();
  if (!pDraw->ucHasTransparency) {
    int start = 0, n = wSrc, x = x0;
    if (x < 0) { start = -x; n += x; x = 0; }
    if (x + n > maxW) n = maxW - x;
    if (n <= 0) return;
    for (int i = 0; i < n; i++) g_line[i] = pal[s[start + i]];
    gfx->draw16bitRGBBitmap(x, y, g_line, n, 1);
    return;
  }
  const uint8_t tc = pDraw->ucTransparent;
  int runAt = -1, runN = 0;
  for (int i = 0; i < wSrc; i++) {
    const int x = x0 + i;
    const bool on = (x >= 0 && x < maxW && s[i] != tc);
    if (on) {
      if (runAt < 0) { runAt = x; runN = 0; }
      g_line[runN++] = pal[s[i]];
    } else if (runAt >= 0) {
      gfx->draw16bitRGBBitmap(runAt, y, g_line, runN, 1);
      runAt = -1;
      runN = 0;
    }
  }
  if (runAt >= 0) gfx->draw16bitRGBBitmap(runAt, y, g_line, runN, 1);
}

static bool mountSd() {
  pinMode(C6_LCD_CS_PIN, OUTPUT);
  pinMode(C6_TF_CS_PIN, OUTPUT);
  lcdIdle();
  sdIdle();
  SPI.begin(C6_TF_SCLK_PIN, C6_TF_MISO_PIN, C6_TF_MOSI_PIN, C6_TF_CS_PIN);
  const bool ok = SD.begin(C6_TF_CS_PIN, SPI, 20000000);
  sdIdle();
  reclaimLcd();
  if (!ok) {
    Serial.println("SD mount failed (CS GPIO4)");
    return false;
  }
  Serial.println("SD ok");
  return true;
}

static bool ensureSd() {
  static bool tried = false;
  if (tried) {
    return g_sd;
  }
  tried = true;
  g_sd = mountSd();
  if (g_sd) {
    scanGifs();
    reclaimLcd();
  }
  return g_sd;
}

static int scanGifs() {
  g_sdCount = 0;
  lcdIdle();
  File dir = SD.open(GIF_DIR);
  if (!dir || !dir.isDirectory()) {
    if (dir) dir.close();
    return 0;
  }
  while (g_sdCount < MAX_GIFS) {
    File f = dir.openNextFile();
    if (!f) break;
    const char *name = f.name();
    const bool skip = f.isDirectory() || !isGifName(baseName(name));
    if (!skip) {
      if (name[0] == '/') {
        strncpy(g_paths[g_sdCount], name, MAX_PATH - 1);
      } else {
        snprintf(g_paths[g_sdCount], MAX_PATH, "%s/%s", GIF_DIR, name);
      }
      g_paths[g_sdCount][MAX_PATH - 1] = 0;
      g_sdCount++;
    }
    f.close();
  }
  dir.close();
  Serial.printf("found %d gif(s)\n", g_sdCount);
  return g_sdCount;
}

static bool openSdAt(int sdIndex) {
  closeGif();
  if (sdIndex < 0 || sdIndex >= g_sdCount) return false;
  const char *path = g_paths[sdIndex];
  lcdIdle();
  gif.begin(0);
  if (!gif.open(path, gifOpen, gifClose, gifRead, gifSeek, GIFDraw)) {
    Serial.printf("open fail %s\n", path);
    return false;
  }
  g_gifOpen = true;
  const int cw = gif.getCanvasWidth();
  const int ch = gif.getCanvasHeight();
  g_xOff = (cw > 0 && cw < sw()) ? (sw() - cw) / 2 : 0;
  g_yOff = (ch > 0 && ch < playH()) ? (playH() - ch) / 2 : 0;
  gfx->fillScreen(COL_BG);
  return true;
}

static void startIndex() {
  closeGif();
  resetBuiltin();
  g_frame = 0;
  g_skip = false;
  const PlayKind k = kindAt(g_index);
  if (k == KIND_SD) {
    if (!openSdAt(g_index - N_BUILTIN)) {
      g_index = 0;
      resetBuiltin();
      gfx->fillScreen(COL_BG);
    }
  } else {
    gfx->fillScreen(COL_BG);
    if (k == KIND_INVADER) {
      drawStars();
    } else if (k == KIND_DINO) {
      drawClouds();
    }
  }
  Serial.printf("play %s\n", kindName(kindAt(g_index)));
}

static void nextClip() {
  g_index = (g_index + 1) % N_BUILTIN;
  startIndex();
}

static void toggleOrient() {
  g_landscape = !g_landscape;
  prefs.putUChar("land", g_landscape ? 1 : 0);
  applyOrient();
  startIndex();
  Serial.printf("orient %s\n", g_landscape ? "landscape" : "portrait");
}

static void printHelp() {
  Serial.println(F("GIF PLAYER  ESP32-C6-LCD-1.47"));
  Serial.println(F("  help | status | next | rotate | list"));
  Serial.println(F("BOOT short = next loop. Hold BOOT = rotate."));
  Serial.println(F("Built-ins: DVD-BOUNCE, DINO-RUN, INVADER, then /gifs"));
}

static void printStatus() {
  Serial.printf("index=%d/%d kind=%s frame=%lu %dx%d\n", g_index + 1,
                playlistLen(), kindName(kindAt(g_index)), (unsigned long)g_frame,
                sw(), sh());
  Serial.printf("sd=%d files=%d\n", g_sd ? 1 : 0, g_sdCount);
}

static void printList() {
  Serial.println(F("0 DVD-BOUNCE"));
  Serial.println(F("1 DINO-RUN"));
  Serial.println(F("2 INVADER"));
  for (int i = 0; i < g_sdCount; i++) {
    Serial.printf("%d %s\n", i + N_BUILTIN, g_paths[i]);
  }
}

static void handleCmd(String line) {
  line.toLowerCase();
  if (line == "help" || line == "?") printHelp();
  else if (line == "status") printStatus();
  else if (line == "next") nextClip();
  else if (line == "rotate" || line == "orient") toggleOrient();
  else if (line == "list") printList();
  else if (line == "sd") { ensureSd(); printList(); }
  else Serial.println("?  try help");
}

static void pollSerial() {
  static String acc;
  while (Serial.available()) {
    char c = (char)Serial.read();
    if (c == '\n' || c == '\r') {
      acc.trim();
      if (acc.length()) handleCmd(acc);
      acc = "";
    } else if (acc.length() < 40) {
      acc += c;
    }
  }
}

static void pollBoot() {
  const bool down = digitalRead(C6_BOOT_PIN) == LOW;
  const uint32_t now = millis();
  if (now < 400) return;
  if (down && !g_bootDown) {
    g_bootDown = true;
    g_bootLong = false;
    g_bootDownAt = now;
  }
  if (down && g_bootDown && !g_bootLong && (now - g_bootDownAt) >= BOOT_LONG_MS) {
    g_bootLong = true;
    toggleOrient();
  }
  if (!down && g_bootDown) {
    if (!g_bootLong && (now - g_bootDownAt) > 40) nextClip();
    g_bootDown = false;
  }
}

void setup() {
  pinMode(C6_LCD_CS_PIN, OUTPUT);
  pinMode(C6_TF_CS_PIN, OUTPUT);
  lcdIdle();
  sdIdle();
  pinMode(C6_LCD_BL_PIN, OUTPUT);
  analogWrite(C6_LCD_BL_PIN, BL_CAP);
  pinMode(C6_BOOT_PIN, INPUT_PULLUP);

  Serial.begin(115200);
  delay(50);

  prefs.begin("gifplay", false);
  g_landscape = prefs.getUChar("land", 0) != 0;

  rgb.begin();
  rgb.setBrightness(48);
  rgb.setPixelColor(0, rgb.Color(20, 80, 60));
  rgb.show();

  if (!gfx->begin()) Serial.println("LCD begin failed");
  applyOrient();
  gfx->fillScreen(COL_BG);
  ensureFb();
  if (!g_fb) Serial.println("sprite fb alloc failed");

  printHelp();
  g_index = 0;
  startIndex();
}

void loop() {
  pollSerial();
  pollBoot();

  const PlayKind k = kindAt(g_index);
  if (k != KIND_SD) {
    if (k == KIND_DVD) drawDvdFrame();
    else if (k == KIND_DINO) drawDinoFrame();
    else drawInvaderFrame();
    g_frame++;
    if ((g_frame & 3) == 0) {
      pulseRgb(g_frame);
    }
    delay(32);
    return;
  }

  if (!g_gifOpen) {
    if (!openSdAt(g_index - N_BUILTIN)) {
      g_index = 0;
      startIndex();
    }
    return;
  }

  int delayMs = 0;
  sdIdle();
  const int rc = gif.playFrame(false, &delayMs);
  g_frame++;
  pulseRgb(g_frame);
  if (rc == 0) gif.reset();
  if (delayMs < 16) delayMs = 16;
  const uint32_t until = millis() + (uint32_t)delayMs;
  while (millis() < until) {
    pollSerial();
    pollBoot();
    if (kindAt(g_index) != KIND_SD) break;
    delay(4);
  }
}

Board and housing

Licensed under CC BY-SA 4.0. Check with the author before commercial use.