/*
ESP32-S3 (WROOM-1-N16R8) — STABLE SMOOTH STICKS + LittleFS + Audio + 2x74HC595
- TFT_eSPI (ILI9488)
- LittleFS label: "littlefs"
- RAW565 screens from LittleFS
- MAX98357 I2S plays /go.wav (scheduled 1.5s after press)
- Falling sticks idle screen with PSRAM background cache
- NO sprites, NO dynamic alloc per frame -> avoids trails/inverted artefacts on ILI9488 SPI
*/
#include "esp_heap_caps.h"
/* ===== ESP8266Audio (works on ESP32-S3) ===== */
#include "AudioFileSourceFS.h"
#include "AudioGeneratorWAV.h"
#include "AudioOutputI2S.h"
/* ================= PINS ================= */
#define BUTTON_START 21 // INPUT_PULLUP -> press to GND
// 74HC595 control (SAFE pins)
#define SR_DATA 15
#define SR_CLOCK 16
#define SR_LATCH 17
// I2S pins (SAFE pins)
#define I2S_BCLK 4
#define I2S_LRCLK 5
#define I2S_DIN 6
/* ================= CONSTANTS ================= */
#define SCREEN_WIDTH 320
#define SCREEN_HEIGHT 480
#define CHUNK_H 8
// Idle sticks
#define NUM_STICKS 10
#define STRIP_W 8
#define MAX_X_SLOTS (SCREEN_WIDTH / STRIP_W)
#define VISIBLE_Y 106
#define REVEAL_CLEAR_HEIGHT 8
// Stick look
#define HEAD_H 6
#define TAIL_H 6
#define MIN_STICK_H 40
#define MAX_STICK_H 120
// Smoothness (fixed frame pacing)
#define TARGET_FPS 50
#define FRAME_US (1000000UL / TARGET_FPS)
// Outputs
#define NUM_FIRES 12
#define NUM_LEDS 4
#define CHANNEL_ON_IS_HIGH 1
TFT_eSPI tft;
/* ================= AUDIO ================= */
AudioGeneratorWAV *wav = nullptr;
AudioFileSourceFS *wavFile = nullptr;
AudioOutputI2S *i2sOut = nullptr;
TaskHandle_t audioTaskHandle = nullptr;
unsigned long audioStartTime = 0;
bool audioScheduled = false;
void audioInit() {
i2sOut = new AudioOutputI2S();
i2sOut->SetPinout(I2S_BCLK, I2S_LRCLK, I2S_DIN);
i2sOut->SetGain(0.70f);
wav = new AudioGeneratorWAV();
}
inline void audioPump() {
if (wav && wav->isRunning()) {
if (!wav->loop()) {
wav->stop();
if (wavFile) { delete wavFile; wavFile = nullptr; }
}
}
}
void audioTask(void *param) {
for (;;) {
audioPump();
vTaskDelay(1);
}
}
void playWavOnce(const char *path) {
if (!wav || !i2sOut) return;
if (wav->isRunning()) {
wav->stop();
if (wavFile) { delete wavFile; wavFile = nullptr; }
}
wavFile = new AudioFileSourceFS(LittleFS, path);
wav->begin(wavFile, i2sOut);
}
inline void serviceScheduledAudio() {
if (audioScheduled && (int32_t)(millis() - audioStartTime) >= 0) {
playWavOnce("/go.wav");
audioScheduled = false;
}
}
/* ================= SHIFT REGISTERS (2x74HC595) ================= */
static uint16_t srState = 0;
void srWrite16(uint16_t value) {
#if CHANNEL_ON_IS_HIGH
uint16_t v = value;
#else
uint16_t v = ~value;
#endif
digitalWrite(SR_LATCH, LOW);
shiftOut(SR_DATA, SR_CLOCK, MSBFIRST, (uint8_t)(v >> 8)); // U2
shiftOut(SR_DATA, SR_CLOCK, MSBFIRST, (uint8_t)(v & 0xFF)); // U1
digitalWrite(SR_LATCH, HIGH);
}
inline void setAllOff() {
srState = 0;
srWrite16(srState);
}
inline void setFire(uint8_t ch, bool on) {
if (ch < 1 || ch > 12) return;
uint8_t bit = (ch <= 8) ? (ch - 1) : (8 + (ch - 9));
if (on) srState |= (1u << bit);
else srState &= ~(1u << bit);
srWrite16(srState);
}
inline void setLed(uint8_t led, bool on) {
if (led < 1 || led > 4) return;
uint8_t bit = 12 + (led - 1);
if (on) srState |= (1u << bit);
else srState &= ~(1u << bit);
srWrite16(srState);
}
/* ================= RAW DRAW (LittleFS, CHUNKED) ================= */
bool showRAW(const char *filename) {
File f = LittleFS.open(filename, "r");
if (!f) {
Serial.print("Missing RAW: "); Serial.println(filename);
return false;
}
static uint16_t buf[SCREEN_WIDTH * CHUNK_H];
for (int y = 0; y < SCREEN_HEIGHT; y += CHUNK_H) {
int h = min(CHUNK_H, SCREEN_HEIGHT - y);
size_t need = (size_t)SCREEN_WIDTH * (size_t)h * 2;
size_t got = f.read((uint8_t*)buf, need);
if (got != need) break;
tft.pushImage(0, y, SCREEN_WIDTH, h, buf);
serviceScheduledAudio();
yield();
}
f.close();
return true;
}
/* ================= STICKS (PSRAM BG CACHE, STABLE DRAW) ================= */
struct Stick {
int x;
int h;
int lastY;
float y; // subpixel position
float vy; // pixels per frame (fixed fps)
};
Stick sticks[NUM_STICKS];
// Flattened PSRAM cache: [NUM_STICKS][SCREEN_HEIGHT][STRIP_W]
uint16_t *bgCachePSRAM = nullptr;
uint16_t revealBG[SCREEN_WIDTH * REVEAL_CLEAR_HEIGHT];
static uint32_t nextFrameUs = 0;
inline uint16_t* bgStripPtr(int stickIndex, int y) {
return bgCachePSRAM + (stickIndex * SCREEN_HEIGHT + y) * STRIP_W;
}
static inline void frameWait() {
uint32_t now = micros();
int32_t wait = (int32_t)(nextFrameUs - now);
if (wait > 0) {
if (wait > 1500) delayMicroseconds((uint32_t)wait - 1000);
while ((int32_t)(nextFrameUs - micros()) > 0) {}
} else {
if (wait < -(int32_t)(FRAME_US * 5)) nextFrameUs = now;
}
nextFrameUs += FRAME_US;
}
bool buildBackgroundCacheFromSbg() {
if (!bgCachePSRAM) return false;
File f = LittleFS.open("/sbg.raw565", "r");
if (!f) return false;
static uint16_t line[SCREEN_WIDTH];
for (int y = 0; y < SCREEN_HEIGHT; y++) {
size_t got = f.read((uint8_t*)line, SCREEN_WIDTH * 2);
if (got != SCREEN_WIDTH * 2) { f.close(); return false; }
for (int i = 0; i < NUM_STICKS; i++) {
memcpy(bgStripPtr(i, y), &line[sticks[i].x], STRIP_W * 2);
}
serviceScheduledAudio();
yield();
}
f.close();
// reveal band
File f2 = LittleFS.open("/sbg.raw565", "r");
if (!f2) return false;
uint32_t off = (uint32_t)VISIBLE_Y * (uint32_t)SCREEN_WIDTH * 2;
f2.seek(off, fs::SeekSet);
size_t need = (size_t)SCREEN_WIDTH * REVEAL_CLEAR_HEIGHT * 2;
if (f2.read((uint8_t*)revealBG, need) != need) { f2.close(); return false; }
f2.close();
return true;
}
void initIdleScreen() {
showRAW("/sbg.raw565");
bool used[MAX_X_SLOTS] = {};
for (int i = 0; i < NUM_STICKS; i++) {
int s;
do { s = random(MAX_X_SLOTS); } while (used[s]);
used[s] = true;
sticks[i].x = s * STRIP_W;
sticks[i].h = random(MIN_STICK_H, MAX_STICK_H + 1);
sticks[i].y = (float)random(-200, -40);
sticks[i].lastY = (int)sticks[i].y;
// fixed-speed per frame (stable at fixed FPS)
sticks[i].vy = random(18, 36) / 10.0f; // 3.0..5.5 px/frame
}
if (bgCachePSRAM) buildBackgroundCacheFromSbg();
nextFrameUs = micros();
}
inline void restoreBG(int i, int y, int h) {
if (!bgCachePSRAM) return;
if (h <= 0) return;
// clip
if (y < 0) { h += y; y = 0; }
if (y + h > SCREEN_HEIGHT) h = SCREEN_HEIGHT - y;
if (h <= 0) return;
// skip if above visible band
if (y + h <= VISIBLE_Y) return;
if (y < VISIBLE_Y) {
int cut = VISIBLE_Y - y;
y += cut;
h -= cut;
if (h <= 0) return;
}
// push in smaller chunks to keep bus smooth
const int HSTEP = 24;
for (int yy = y; yy < y + h; yy += HSTEP) {
int hh = min(HSTEP, (y + h) - yy);
tft.pushImage(sticks[i].x, yy, STRIP_W, hh, bgStripPtr(i, yy));
}
}
inline void drawStick(int x, int y, int h) {
int top = max(y, VISIBLE_Y);
int bot = min(y + h, SCREEN_HEIGHT);
if (bot <= top) return;
int visibleH = bot - top;
// head
int headH = min(HEAD_H, visibleH);
tft.fillRect(x, top, STRIP_W, headH, TFT_YELLOW);
// tail/body
int rem = visibleH - headH;
int tailH = min(TAIL_H, rem);
int bodyH = rem - tailH;
if (bodyH > 0) tft.fillRect(x, top + headH, STRIP_W, bodyH, TFT_BLUE);
if (tailH > 0) tft.fillRect(x, bot - tailH, STRIP_W, tailH, TFT_YELLOW);
}
void drawIdleSticks() {
frameWait();
tft.startWrite();
// refresh reveal band
//tft.pushImage(0, VISIBLE_Y, SCREEN_WIDTH, REVEAL_CLEAR_HEIGHT, revealBG);
for (int i = 0; i < NUM_STICKS; i++) {
// restore old
restoreBG(i, sticks[i].lastY, sticks[i].h);
// update
sticks[i].y += sticks[i].vy;
int y = (int)(sticks[i].y);
sticks[i].lastY = y;
// recycle
if (y > SCREEN_HEIGHT + 60) {
sticks[i].y = (float)random(-200, -40);
sticks[i].h = random(MIN_STICK_H, MAX_STICK_H + 1);
sticks[i].vy = random(30, 55) / 10.0f;
sticks[i].lastY = (int)sticks[i].y;
continue;
}
// draw new
drawStick(sticks[i].x, y, sticks[i].h);
}
tft.endWrite();
serviceScheduledAudio();
yield();
}
/* ================= FIRE SEQUENCE ================= */
void runFireSequence() {
setAllOff();
int order[NUM_FIRES];
for (int i = 0; i < NUM_FIRES; i++) order[i] = i + 1;
for (int i = NUM_FIRES - 1; i > 0; i--) {
int j = random(0, i + 1);
int t = order[i]; order[i] = order[j]; order[j] = t;
}
for (int i = 0; i < NUM_FIRES; i++) {
setFire(order[i], true);
delay(500);
setFire(order[i], false);
delay(1000);
serviceScheduledAudio();
yield();
}
}
/* ================= SETUP ================= */
void setup() {
Serial.begin(115200);
delay(100);
Serial.println();
Serial.println("=== BOOT: STABLE STICKS (NO SPRITES) ===");
pinMode(BUTTON_START, INPUT_PULLUP);
pinMode(SR_DATA, OUTPUT);
pinMode(SR_CLOCK, OUTPUT);
pinMode(SR_LATCH, OUTPUT);
setAllOff();
// TFT
tft.init();
tft.setRotation(0);
// IMPORTANT: keep swapBytes ON globally (RAW565 + cached strips)
tft.setSwapBytes(true);
tft.fillScreen(TFT_RED); delay(200);
tft.fillScreen(TFT_GREEN); delay(200);
tft.fillScreen(TFT_BLUE); delay(200);
tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setTextSize(2);
tft.setCursor(10, 10);
tft.println("ESP32-S3 OK");
tft.println("Mounting LittleFS...");
bool ok = LittleFS.begin(false, "/littlefs", 10, "littlefs");
Serial.print("LittleFS = ");
Serial.println(ok ? "OK" : "FAIL");
if (!ok) {
tft.fillScreen(TFT_RED);
tft.setCursor(10, 10);
tft.println("LittleFS FAIL");
while (1) delay(250);
}
// PSRAM cache
size_t words = (size_t)NUM_STICKS * (size_t)SCREEN_HEIGHT * (size_t)STRIP_W;
size_t bytes = words * sizeof(uint16_t);
bgCachePSRAM = (uint16_t*)heap_caps_malloc(bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
Serial.print("bgCachePSRAM = ");
Serial.println(bgCachePSRAM ? "OK" : "FAIL");
// Audio
audioInit();
xTaskCreatePinnedToCore(audioTask, "AudioTask", 4096, nullptr, 2, &audioTaskHandle, 0);
randomSeed((uint32_t)esp_random());
}
/* ================= LOOP ================= */
void loop() {
initIdleScreen();
while (digitalRead(BUTTON_START) == HIGH) {
drawIdleSticks();
}
delay(50);
if (digitalRead(BUTTON_START) == HIGH) return;
audioStartTime = millis() + 1500;
audioScheduled = true;
showRAW("/ready.raw565"); delay(1000);
showRAW("/3.raw565"); delay(450);
showRAW("/2.raw565"); delay(450);
showRAW("/1.raw565"); delay(450);
showRAW("/go.raw565");
for (int i = 0; i < 10; i++) {
tft.invertDisplay(i & 1);
delay(105);
serviceScheduledAudio();
}
tft.invertDisplay(false);
runFireSequence();
while (wav && wav->isRunning()) {
delay(10);
yield();
}
}