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.
A pixel-art TV. Three loops play with no TF card: DVD-BOUNCE (a logo that hits the edges), DINO-RUN (a dino that hops a cactus), and INVADER (a space invader). A short press of BOOT skips to the next of those three. Hold BOOT for about 0.7 seconds and the picture rotates: portrait is 172×320, landscape is 320×172.
Extra pictures are .gif files in the folder /gifs on the TF card. Format the card as FAT (FAT32 is the usual choice on a computer). This sketch keeps at most 48 files (MAX_GIFS). Each path is stored in an 80-byte buffer (MAX_PATH), so /gifs/ plus the file name can use 79 characters. Names end in .gif. The sketch mounts the card when you type sd on the serial monitor, then list shows the names. That short BOOT press cycles the three built-in loops.
The RGB LED (GPIO8) pulses along with the animation. Keep the backlight at 50%.
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 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 swap cards. The three built-in loops need no card.
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.
3
Play loops
A short BOOT press skips to the next built-in loop: DVD-BOUNCE, then DINO-RUN, then INVADER, then back to the start. Hold BOOT for about 0.7 seconds to rotate. Optional card: a FAT TF card with a /gifs folder of .gif files (up to 48). Type sd on serial to mount and scan that folder, then list to read the names.
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, and bounce when it hits an edge.
That is simulation + drawing. Games are the same loop: update, draw, repeat.
The playlist
gif-player.ino
// Always, no TF card needed:
// DVD-BOUNCE → DINO-RUN → INVADER
// Short BOOT = skip to the next of those three.
// Hold BOOT ~0.7s = portrait (172×320) / landscape (320×172).
// /gifs/*.gif — up to 48 files. Type sd, then list.
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 draw16bitRGBBitmaponce. 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 folder is /gifs. When the sketch opens one, a library turns those bytes into frames. You can learn the idea without memorizing the GIF format: storage → bytes → pictures.
Limits written in this sketch. At most 48 GIFs (MAX_GIFS). Each path sits in an 80-byte buffer (MAX_PATH), which leaves 79 characters for the text. The name must end in .gif. gif.begin(0) asks the decoder for 16-bit RGB565 colors, which is what the ST7789 panel shows. Inside the file, each pixel is still an index into a palette. The AnimatedGIF library linked by this sketch allows 256 palette colors and a width up to 480 pixels. A GIF smaller than the screen is centered. Each decoded line is clipped to the screen; the line buffer is 320 pixels wide. The sketch sets no maximum file size in bytes.
The card and the LCD share MOSI and SCLK. The sketch calls SD.beginonce, the first time you type sd. A second SD.begin() after the panel is running can hang that shared bus.
Try this
Time a DVD-BOUNCE with a stopwatch. How many edge hits in 10 seconds?
Type sd, then list. You should see DVD-BOUNCE, DINO-RUN, INVADER, then any /gifs files.
A short BOOT press skips to the next built-in loop. Hold BOOT for about 0.7 seconds to rotate.
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 · sd
sd mounts the TF card once and scans /gifs. list prints the three built-in names plus those files. next does the same job as a short BOOT press: it skips to the next built-in loop.
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.
This sketch does not join Wi-Fi. BOOT is GPIO9. The backlight PWM value is 128, which is half of 255.
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
Silhouette slab (ESP32-C6)GIF player housingCAD of the snap-fit shell.