// =============================================================================
// Dory MP3 — Wokwi simulation
// Modules: state_machine + input + audio + display (all screens)
// =============================================================================
#include <U8g2lib.h>
#include <Wire.h>
// ---- config -----------------------------------------------------------------
#define PIN_BTN1 25
#define PIN_BTN2 26
#define PIN_BTN3 27
#define PIN_ENC_CLK 34
#define PIN_ENC_DT 35
#define PIN_ENC_SW 32
#define PIN_BUZZER 23
#define PIN_OLED_SDA 21
#define PIN_OLED_SCL 22
#define DEBOUNCE_MS 50
#define LONGPRESS_MS 600
#define IDLE_TIMEOUT_MS 45000
#define VOLUME_FADE_MS 2000
#define VOLUME_MAX 30
#define VOLUME_DEFAULT 15
#define PLAYLIST_COUNT 3
#define FRAME_UI_MS 100
#define FRAME_BOOT_MS 33
#define FRAME_GAME_MS 50
#define DISPLAY_SCREENSAVER 1
#define DISPLAY_BLANK 2
static const char* PLAYLIST_NAMES[3]={"Playlist 1","Playlist 2","Playlist 3"};
// ---- enums ------------------------------------------------------------------
enum AppState { STATE_BOOT,STATE_NOW_PLAYING,STATE_MENU,STATE_GAME,STATE_SCREENSAVER,STATE_EASTER_EGG };
enum AppEvent {
EVENT_BTN1_SHORT,EVENT_BTN1_LONG,EVENT_BTN2_SHORT,EVENT_BTN2_LONG,
EVENT_BTN3_SHORT,EVENT_BTN3_LONG,EVENT_ENC_CW,EVENT_ENC_CCW,
EVENT_ENC_CLICK,EVENT_ENC_LONG,EVENT_IDLE_TIMEOUT,EVENT_BOOT_DONE,EVENT_BATT_LOW
};
static const char* ENAMES[]={"BTN1S","BTN1L","BTN2S","BTN2L","BTN3S","BTN3L",
"ENCCW","ENCCCW","ENCCLK","ENCLONG","IDLE","BOOT","BATT"};
static const char* SNAMES[]={"BOOT","NOW_PLAYING","MENU","GAME","SCREENSAVER","EASTER_EGG"};
AppState currentState=STATE_BOOT;
void onAppEvent(AppEvent e);
// =============================================================================
// StateMachine
// =============================================================================
class StateMachine {
public:
void begin(){
unsigned long t=millis();
_s=STATE_BOOT;_p=STATE_BOOT;_boot=t;_idle=t;_now=t;
_changed=true;currentState=_s;
Serial.print("[SM] ");Serial.println(SNAMES[_s]);
}
// Call ONCE at the very top of loop() before inputSim.update().
// Stores a consistent 'now' so handleEvent and keepAwake never call millis()
// directly — eliminates the timing-underflow that fires screensaver on button press.
void setNow(unsigned long n){ _now=n; }
void update(unsigned long now){
_changed=false;
if(_s==STATE_BOOT&&now-_boot>=2000){
_idle=now; // start idle timer only when NOW_PLAYING begins
_tx(STATE_NOW_PLAYING);return;
}
if(_s==STATE_NOW_PLAYING&&now-_idle>=IDLE_TIMEOUT_MS)_tx(STATE_SCREENSAVER);
}
void handleEvent(AppEvent e){
_idle=_now; // use loop's consistent 'now' — never millis() directly
// Screensaver / Easter Egg: ANY input wakes to NOW_PLAYING — nothing else.
if(_s==STATE_SCREENSAVER){_wake();return;}
if(_s==STATE_EASTER_EGG){_exitEE();return;}
// Normal transitions
if(e==EVENT_BTN1_LONG){_tx(STATE_EASTER_EGG);return;}
if(e==EVENT_ENC_LONG&&(_s==STATE_NOW_PLAYING||_s==STATE_GAME)){_tx(STATE_MENU);return;}
// MENU exit is handled in onAppEvent (playlist-aware)
}
void keepAwake(){_idle=_now;} // uses the same consistent 'now'
void requestState(AppState ns){_tx(ns);}
AppState getState()const{return _s;}
AppState getPrevState()const{return _p;}
bool stateChanged()const{return _changed;}
private:
AppState _s=STATE_BOOT,_p=STATE_BOOT;
bool _changed=false;
unsigned long _idle=0,_boot=0,_now=0;
void _tx(AppState n){
Serial.print("[SM] ");Serial.print(SNAMES[_s]);
Serial.print("->");Serial.println(SNAMES[n]);
_p=_s;_s=n;currentState=n;_changed=true;
}
// Always wake to NOW_PLAYING — avoids "nothing happened" feeling and
// prevents looping back to MENU when screensaver was launched from menu.
void _wake() { _tx(STATE_NOW_PLAYING); }
void _exitEE(){ _tx(STATE_NOW_PLAYING); }
} stateMachine;
// =============================================================================
// AudioManager
// =============================================================================
struct Note{uint16_t f,d;};
static const Note BOOT_J[]={{523,80},{659,80},{784,120},{0,50}};
// Star Wars Main Theme — A major transposition for buzzer
static const Note SW_THEME[]={
{440,500},{440,500},{440,500},{349,375},{0,125},{523,125},
{440,500},{349,375},{0,125},{523,125},{440,1000},{0,250},
{659,500},{659,500},{659,500},{698,375},{0,125},{523,125},
{415,500},{349,375},{0,125},{523,125},{440,1000},{0,500}
};
static const Note IMP[]={
{440,500},{440,500},{440,500},{349,375},{0,125},{523,125},{440,500},
{349,375},{0,125},{523,125},{440,1000},{0,200},
{659,500},{659,500},{659,500},{698,375},{0,125},{523,125},{415,500},
{349,375},{0,125},{523,125},{440,1000},{0,400}
};
static const Note ALARM[]={{880,200},{0,50},{660,200},{0,100}};
class AudioManager{
public:
void begin(){pinMode(PIN_BUZZER,OUTPUT);digitalWrite(PIN_BUZZER,LOW);}
void update(unsigned long now){if(_mel)_tick(now);}
void play(){_playing=true;Serial.println("[DF]play");}
void pause(){_playing=false;Serial.println("[DF]pause");}
void togglePlayPause(){_playing?pause():play();}
void nextTrack(){_trk++;Serial.printf("[DF]f=%d t=%d\n",_fld,_trk);_playing=true;}
void prevTrack(){if(_trk>1)_trk--;Serial.printf("[DF]f=%d t=%d\n",_fld,_trk);_playing=true;}
void toggleShuffle(){_shuf=!_shuf;Serial.printf("[Audio]Shuf:%s\n",_shuf?"ON":"OFF");}
void setShuffle(bool s){_shuf=s;}
void toggleMute(){_mute=!_mute;Serial.printf("[Audio]Mute:%s\n",_mute?"ON":"OFF");}
void setVolume(int8_t v){_vol=(int8_t)constrain((int)v,0,VOLUME_MAX);}
void changeVolume(int8_t d){setVolume(_vol+d);Serial.printf("[DF]vol=%d\n",_vol);}
void setFolder(uint8_t f){_fld=f;_trk=1;}
void playBootJingle(){_play(BOOT_J,4,false);}
void playStarWarsTheme(){_play(SW_THEME,24,false);}
void playImperial(){_play(IMP,24,false);}
void playSelfDestructAlarm(){_play(ALARM,4,true);}
void stopBuzzer(){_mel=nullptr;noTone(PIN_BUZZER);}
bool isPlaying()const{return _playing;}
bool isMuted() const{return _mute;}
bool isShuffle()const{return _shuf;}
int8_t getVolume()const{return _vol;}
uint8_t getFolder()const{return _fld;}
uint8_t getTrack() const{return _trk;}
private:
bool _playing=false,_mute=false,_shuf=false;
int8_t _vol=VOLUME_DEFAULT; uint8_t _fld=1,_trk=1;
const Note* _mel=nullptr; uint8_t _mlen=0,_midx=0; bool _loop=false;
unsigned long _nend=0;
void _play(const Note* m,uint8_t l,bool lp){_mel=m;_mlen=l;_midx=0;_loop=lp;_nend=millis();}
void _tick(unsigned long now){
if(now<_nend)return;
if(_midx>=_mlen){if(_loop)_midx=0;else{stopBuzzer();return;}}
const Note& n=_mel[_midx++];
if(n.f==0)noTone(PIN_BUZZER);else tone(PIN_BUZZER,n.f);
_nend=now+n.d;
}
} audioManager;
// Persistence stub (no NVS in Wokwi)
struct PStub{
void begin(){Serial.println("[NVS]stub");}
int8_t getVolume()const{return VOLUME_DEFAULT;}
bool getShuffle()const{return false;}
uint8_t getPlaylist()const{return 1;}
uint8_t getDisplayMode()const{return _dm;}
void saveDisplayMode(uint8_t m){_dm=m;}
void saveVolume(int8_t){} void saveShuffle(bool){}
void saveTrack(uint8_t){} void savePlaylist(uint8_t){}
uint8_t _dm=DISPLAY_SCREENSAVER;
} persistence;
// =============================================================================
// BITMAPS — Stormtrooper 48×48 (boot logo) + X-Wing 16×16 (game)
// MSB-first, row-major — compatible with u8g2.drawBitmap()
// =============================================================================
static const uint8_t STORM_BMP[] PROGMEM = {
0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x7F,0xFE,0x00,0x00, 0x00,0x07,0x80,0x01,0xE0,0x00,
0x00,0x0C,0x00,0x00,0x20,0x00, 0x00,0x18,0x00,0x00,0x18,0x00,
0x00,0x30,0x00,0x00,0x04,0x00, 0x00,0x20,0x00,0x00,0x04,0x00,
0x00,0x20,0x00,0x00,0x04,0x00, 0x00,0x60,0x00,0x00,0x02,0x00,
0x00,0x40,0x00,0x00,0x02,0x00, 0x00,0x40,0x00,0x00,0x01,0x00,
0x00,0x40,0x00,0x00,0x01,0x00, 0x00,0x40,0x00,0x00,0x01,0x00,
0x00,0x7F,0xE0,0x00,0x01,0x00, 0x00,0x7F,0xFF,0xFF,0xFF,0x00,
0x00,0x7F,0xFF,0xFF,0xFF,0x00, 0x00,0xD7,0xFF,0xFF,0xE1,0x00,
0x01,0xBF,0xFC,0x1F,0xFA,0x80, 0x01,0xBF,0xF1,0xCF,0xFA,0x80,
0x01,0x3F,0xC2,0x37,0xF7,0x80, 0x01,0xEF,0x9C,0x01,0xE7,0xC0,
0x01,0xE0,0x70,0x06,0x06,0x80, 0x01,0xE0,0xC0,0x03,0x06,0x80,
0x01,0xFF,0x80,0x01,0xFF,0x80, 0x01,0xF8,0x00,0x00,0x1D,0xC0,
0x03,0x70,0x00,0x80,0x0C,0x60, 0x05,0xB0,0x07,0xF0,0x08,0x90,
0x09,0x10,0x1F,0xF8,0x09,0xD0, 0x0B,0x90,0x1F,0x7C,0x03,0xF0,
0x0F,0xC0,0xFC,0x0F,0x07,0x90, 0x0D,0x43,0xC0,0x03,0x07,0x90,
0x05,0x64,0x00,0x00,0xCF,0x10, 0x07,0xFC,0x00,0x00,0x26,0x10,
0x01,0x80,0x00,0x00,0x10,0x20, 0x01,0x00,0x00,0x00,0x0E,0x40,
0x01,0x80,0x07,0xF0,0x01,0x80, 0x00,0x80,0x07,0xC8,0x00,0x80,
0x00,0x80,0x0B,0xE8,0x00,0x80, 0x00,0x87,0x97,0xE9,0xE0,0x80,
0x00,0x87,0xDF,0xEF,0xA0,0x80, 0x00,0x4B,0xFF,0xFF,0xA0,0x80,
0x00,0x6B,0xDF,0xFB,0xA3,0x00, 0x00,0x24,0x97,0xE8,0x24,0x00,
0x00,0x1E,0x1F,0xC0,0x2C,0x00, 0x00,0x07,0xF8,0x1F,0xF0,0x00,
};
static const uint8_t XWING_BMP[] PROGMEM = {
0x00,0x00, 0x00,0x00, 0x1C,0x00, 0x3F,0xF0,
0x3C,0x00, 0x3C,0x00, 0xFF,0x00, 0x7F,0xFF,
0x7F,0xFF, 0xFF,0x00, 0x3C,0x00, 0x3C,0x00,
0x1F,0xF0, 0x1C,0x00, 0x00,0x00, 0x00,0x00,
};
// =============================================================================
// SCREEN: BOOT — Stormtrooper slides in, "DORY MP3 PLAYER" on right
// =============================================================================
static int8_t bsx[20],bsy[20]; static uint8_t bspd[20];
static void bsReset(int i,unsigned long s){
bsx[i]=(int8_t)((s*(i+1)*7)%9)-4; bsy[i]=(int8_t)((s*(i+1)*3)%5)-2;
if(!bsx[i])bsx[i]=1; if(!bsy[i])bsy[i]=1; bspd[i]=(uint8_t)(((s+i)%2)+1);
}
static void screenBootDraw(U8G2& u,unsigned long now,bool first){
static unsigned long t0=0;
if(first){ t0=now; for(int i=0;i<20;i++) bsReset(i,now+i*137); }
unsigned long el=now-t0;
// Stars fly outward
for(int i=0;i<20;i++){
int16_t px=64+(int16_t)bsx[i],py=32+(int16_t)bsy[i];
if(px>=0&&px<128&&py>=0&&py<64) u.drawPixel(px,py);
bsx[i]+=(bsx[i]>0)?bspd[i]:-bspd[i];
bsy[i]+=(bsy[i]>0)?(int8_t)(bspd[i]/2+1):-(int8_t)(bspd[i]/2+1);
if(px<0||px>=128||py<0||py>=64) bsReset(i,now+i);
}
// Stormtrooper slides in from left (0→1200ms: x -52 → 4)
int16_t xOff=(el<1200)?(int16_t)(-52+(int16_t)(el*56/1200)):4;
if(xOff<-52) xOff=-52;
u.drawBitmap(xOff, 8, 6, 48, STORM_BMP); // 48×48 bitmap, 6 bytes/row
// Title text appears on right after stormtrooper lands
if(el>=1200){
u.setFont(u8g2_font_logisoso16_tf);
u.drawStr(62, 26, "DORY");
u.drawStr(68, 50, "MP3");
u.setFont(u8g2_font_4x6_tf);
u.drawStr(60, 61, "-- PLAYER --");
}
}
// =============================================================================
// SCREEN: NOW PLAYING — BB-8 + lightsaber volume
// =============================================================================
// Rotation table r=11 from ball center
static const int8_t R11C[16]={11,10,8,4,0,-4,-8,-10,-11,-10,-8,-4,0,4,8,10};
static const int8_t R11S[16]={0,4,8,10,11,10,8,4,0,-4,-8,-10,-11,-10,-8,-4};
// Rotation table r=6 for small accent
static const int8_t R6C[16]={6,6,4,2,0,-2,-4,-6,-6,-6,-4,-2,0,2,4,6};
static const int8_t R6S[16]={0,2,4,6,6,6,4,2,0,-2,-4,-6,-6,-6,-4,-2};
// Fake track names — loops over real track numbers
static const char* TRACK_NAMES[]={"Cantina Band","Imp March","Binary Sunset",
"Duel of Fates","Force Theme","Throne Room","Battle Yavin","Hyperspace"};
static const uint8_t TRACK_NAMES_CNT=8;
// Pause-aware play timer (file scope so screenNowPlayingResetTimer can reach them)
static unsigned long playedMs=0; // accumulated ms while playing
static unsigned long lastPlayTick=0; // timestamp when play last resumed (0=paused)
static bool _timerReset=false;
static unsigned long volUntil=0;
void screenNowPlayingShowVolume(){volUntil=millis()+VOLUME_FADE_MS;}
void screenNowPlayingResetTimer(){playedMs=0;lastPlayTick=0;_timerReset=true;}
static void drawBB8(U8G2& u, int16_t xOff, bool playing, uint8_t spinF){
const uint8_t BX=21,BY=46; // ball center
const uint8_t DX=23,DY=24; // dome center
int16_t bx=BX+xOff, dx=DX+xOff;
// Lower ball
u.drawCircle(bx,BY,16,U8G2_DRAW_ALL);
// Ball detail rings (horizontal lines across ball)
u.drawHLine(bx-16,BY,32); // equator
u.drawCircle(bx,BY,10,U8G2_DRAW_ALL);
// Rotating orange accents (2 dots orbiting the ball)
uint8_t a=spinF&0x0F;
u.drawDisc((uint8_t)(bx+R11C[a]),(uint8_t)(BY+R11S[a]),3);
u.drawDisc((uint8_t)(bx+R11C[(a+8)&0x0F]),(uint8_t)(BY+R11S[(a+8)&0x0F]),3);
// Small accent (lower ball "panel" that moves)
uint8_t b=(spinF*2)&0x0F;
u.drawDisc((uint8_t)(bx+R6C[b]),(uint8_t)(BY+R6S[b]),2);
// Dome
u.drawCircle(dx,DY,10,U8G2_DRAW_ALL);
// Dome orange band
u.drawHLine(dx-8,DY,16);
// Dome accent dot
u.drawDisc(dx+4,DY-6,2);
u.drawDisc(dx-3,DY+4,2);
// Antenna
u.drawLine(dx+2,DY-10,dx+4,DY-14);
u.drawPixel(dx+4,DY-15);
// Neck connector (small rectangle between dome and ball)
u.drawBox(bx-3,BY-19,6,4);
}
static void drawLightsaberVolume(U8G2& u){
int8_t vol=audioManager.getVolume();
// Background (full width)
u.setDrawColor(0); u.drawBox(0,21,128,22); u.setDrawColor(1);
u.drawFrame(0,21,128,22);
// --- Handle (x=3..23) ---
u.drawBox(3,28,3,7); // pommel
u.drawBox(6,27,11,9); // grip body
u.setDrawColor(0);
u.drawVLine(9,27,9); // wrap gap
u.drawVLine(13,27,9); // wrap gap
u.setDrawColor(1);
u.drawBox(10,25,3,2); // activation button
u.drawBox(17,26,6,11); // emitter guard (wider)
u.setDrawColor(0);
u.drawBox(18,28,4,7); // emitter hollow
u.setDrawColor(1);
u.drawVLine(23,26,11); // emitter edge / blade start
// --- Blade (x=24..120, max 96px) ---
uint8_t blen=(uint8_t)((uint16_t)vol*96/VOLUME_MAX);
if(blen>0){
u.drawHLine(24,32,blen); // center line (brightest)
// glow lines (dashed, alternating pixels)
for(uint8_t gx=24;gx<24+blen;gx+=2){
u.drawPixel(gx,31);
u.drawPixel(gx,33);
}
// tip
u.drawPixel(24+blen,32);
u.drawPixel(24+blen-1,31);
u.drawPixel(24+blen-1,33);
}
// Volume number (right side)
char vb[4]; snprintf(vb,sizeof(vb),"%d",vol);
u.setFont(u8g2_font_5x7_tf);
u.drawStr(122-5*(int)strlen(vb),34,vb);
if(audioManager.isMuted()){
u.setFont(u8g2_font_4x6_tf);
u.drawStr(112,28,"MUT");
}
}
// Small battery icon — top-right corner. Simulates slow drain (90%→5% over ~45 min).
static void drawBattery(U8G2& u){
unsigned long drain=millis()/30000UL;
uint8_t pct=(drain<85)?(uint8_t)(90-drain):5;
uint8_t fill=(uint8_t)(pct*9/100); // 0..9 pixels inside 11px shell
u.drawFrame(112,1,13,6); // outer shell 13×6
u.drawBox(125,3,2,2); // terminal nub
if(fill>0)u.drawBox(113,2,fill,4); // charge level
}
static void screenNowPlayingDraw(U8G2& u,unsigned long now,bool first){
static uint8_t spinF=0;
static unsigned long rollStart=0; static bool rolledIn=false;
// Initialise on state entry or track change
if(first){ spinF=0; rollStart=now; rolledIn=false; }
if(first||_timerReset){ playedMs=0; lastPlayTick=0; _timerReset=false; }
// Roll-in xOffset (0→400ms: BB-8 slides from off-screen left)
int16_t xOff=0;
if(!rolledIn){
unsigned long el=now-rollStart;
if(el>=400) rolledIn=true;
else xOff=(int16_t)(-42+(int16_t)(el*42/400));
}
// Pause-aware elapsed timer
bool np=audioManager.isPlaying();
if(np && lastPlayTick==0) lastPlayTick=now; // just resumed
if(!np && lastPlayTick!=0){ playedMs+=now-lastPlayTick; lastPlayTick=0; } // just paused
unsigned long elMs=playedMs+(lastPlayTick ? now-lastPlayTick : 0);
unsigned long elapsed=elMs/1000UL;
// Spin & bounce BB-8 while playing
if(np) spinF++;
int16_t bounce=0;
if(rolledIn&&np){
static const int8_t BT[8]={0,1,2,1,0,-1,-2,-1};
bounce=BT[(now/250)%8];
}
drawBB8(u,xOff+bounce,np,spinF);
// Divider line
u.drawVLine(42+xOff,0,64);
// Battery icon (always, top-right)
drawBattery(u);
if(!rolledIn) return; // don't draw text until BB-8 is in place
uint8_t f=audioManager.getFolder();
const char* tname=TRACK_NAMES[(audioManager.getTrack()-1)%TRACK_NAMES_CNT];
// ── Playlist name ───────────────────────────────────
u.setFont(u8g2_font_4x6_tf);
u.drawStr(46,8,(f>=1&&f<=PLAYLIST_COUNT)?PLAYLIST_NAMES[f-1]:"---");
// ── Track name ──────────────────────────────────────
u.setFont(u8g2_font_5x7_tf);
u.drawStr(46,19,tname);
// ── Status: play/pause + mute ────────────────────────
bool muted=audioManager.isMuted();
u.setFont(u8g2_font_4x6_tf);
if(muted){
// Inverted MUTE badge
u.drawBox(46,22,26,9); u.setDrawColor(0);
u.drawStr(48,30,"MUTED"); u.setDrawColor(1);
u.drawStr(76,30,np?">> PLAY":"|| PAUSE");
} else {
u.drawStr(46,30,np?">> PLAY":"|| PAUSE");
}
if(audioManager.isShuffle()) u.drawStr(100,30,"SHF");
// ── Time: elapsed left, remaining right ─────────────
char tb[8]; snprintf(tb, sizeof(tb), "%lu:%02lu", elapsed/60, elapsed%60);
unsigned long rem=(elapsed<210)?210-elapsed:0;
char rb[8]; snprintf(rb, sizeof(rb), "-%lu:%02lu", rem/60, rem%60);
u.drawStr(46,39,tb);
uint8_t rw=(uint8_t)(strlen(rb)*4);
u.drawStr((uint8_t)(122-rw),39,rb);
// ── Progress bar x=46..121 (76px), y=41..45 ─────────
u.drawFrame(46,41,76,5);
uint8_t fillW=(uint8_t)(elapsed%210*76/210);
if(fillW>0) u.drawBox(46,41,fillW,5);
// Playhead tick (vertical line 1px above and below bar)
uint8_t headX=(uint8_t)(46+fillW); if(headX>121)headX=121;
u.drawVLine(headX,40,7);
// ── Hint ────────────────────────────────────────────
u.drawStr(46,57,"[hold ENC]=menu");
// ── Lightsaber volume overlay ────────────────────────
if(now<volUntil) drawLightsaberVolume(u);
}
// =============================================================================
// SCREEN: MENU
// =============================================================================
static int8_t menuSel=0;
static bool menuInPlaylist=false;
static int8_t playlistSel=0;
static const char* MLABELS[5]={"MUSIC","PLAYLIST","GAME","SCREENSAVER","BLANK SCREEN"};
void screenMenuScroll(int8_t d){
if(menuInPlaylist) playlistSel=(int8_t)((playlistSel+d+PLAYLIST_COUNT)%PLAYLIST_COUNT);
else menuSel =(int8_t)((menuSel+d+5)%5);
}
void screenMenuActivate(){
if(menuInPlaylist){
audioManager.setFolder((uint8_t)(playlistSel+1));
menuInPlaylist=false;
stateMachine.requestState(STATE_NOW_PLAYING);
return;
}
switch(menuSel){
case 0: stateMachine.requestState(STATE_NOW_PLAYING); break;
case 1: menuInPlaylist=true; playlistSel=0; break;
case 2: stateMachine.requestState(STATE_GAME); break;
case 3: persistence.saveDisplayMode(DISPLAY_SCREENSAVER); stateMachine.requestState(STATE_SCREENSAVER); break;
case 4: persistence.saveDisplayMode(DISPLAY_BLANK); stateMachine.requestState(STATE_SCREENSAVER); break;
}
}
// Called when BTN2_SHORT in MENU — back out of playlist submenu or exit menu
void screenMenuBack(){
if(menuInPlaylist){ menuInPlaylist=false; }
else { stateMachine.requestState(STATE_NOW_PLAYING); }
}
static void screenMenuDraw(U8G2& u,unsigned long now,bool first){
(void)now;
if(first){ menuSel=0; menuInPlaylist=false; playlistSel=0; }
u.setFont(u8g2_font_5x7_tf);
if(menuInPlaylist){
u.drawStr(2,8,"< PLAYLIST >");
u.drawHLine(0,10,128);
u.setFont(u8g2_font_6x10_tf);
for(int i=0;i<PLAYLIST_COUNT;i++){
uint8_t y=(uint8_t)(22+i*13);
if(i==playlistSel){
u.drawBox(0,y-9,128,11); u.setDrawColor(0);
u.drawStr(8,y,PLAYLIST_NAMES[i]); u.setDrawColor(1);
}else u.drawStr(8,y,PLAYLIST_NAMES[i]);
}
u.setFont(u8g2_font_4x6_tf);
u.drawStr(4,62,"[ENC] select [O] back");
} else {
u.drawStr(2,8,"-- MENU --");
u.drawHLine(0,10,128);
u.setFont(u8g2_font_6x10_tf);
for(int i=0;i<5;i++){
uint8_t y=(uint8_t)(20+i*9); // 5 items × 9px = y:20,29,38,47,56
if(i==menuSel){
u.drawBox(0,y-8,128,10); u.setDrawColor(0);
u.drawStr(8,y,MLABELS[i]); u.setDrawColor(1);
}else u.drawStr(8,y,MLABELS[i]);
}
u.setFont(u8g2_font_4x6_tf);
u.drawStr(4,63,"[ENC] sel [O] back");
}
}
// =============================================================================
// SCREEN: SCREENSAVER — Radial hyperspace (stars zoom outward from center)
// =============================================================================
struct HStar { float x,y,ox,oy,vx,vy; };
static HStar hs[50];
static bool hsInit=false;
static void hsReset(int i, float seed){
float angle=(float)i*6.2832f/50.0f + fmodf(fabsf(seed), 6.2832f);
float r=0.5f+fmodf(fabsf(seed)*3.7f, 3.0f);
hs[i].x=64.0f+cosf(angle)*r;
hs[i].y=32.0f+sinf(angle)*r;
hs[i].ox=hs[i].x; hs[i].oy=hs[i].y;
float spd=0.05f+fmodf(fabsf(seed)*0.13f, 0.3f);
hs[i].vx=cosf(angle)*spd;
hs[i].vy=sinf(angle)*spd;
}
static void screenSaverDraw(U8G2& u, unsigned long now, bool first){
if(persistence.getDisplayMode()==DISPLAY_BLANK) return;
if(first){
hsInit=true;
for(int i=0;i<50;i++) hsReset(i, (float)i*1.618f+(float)(now%1000)*0.001f);
}
for(int i=0;i<50;i++){
HStar& s=hs[i];
s.ox=s.x; s.oy=s.y;
float dx=s.x-64.0f, dy=s.y-32.0f;
float dist=sqrtf(dx*dx+dy*dy);
if(dist<0.1f) dist=0.1f;
float accel=0.04f+dist*0.010f;
s.vx+=(dx/dist)*accel;
s.vy+=(dy/dist)*accel;
s.x+=s.vx; s.y+=s.vy;
if(s.x<-4||s.x>132||s.y<-4||s.y>68)
hsReset(i, (float)now*0.001f+(float)i*2.618f);
else
u.drawLine((int16_t)s.ox,(int16_t)s.oy,(int16_t)s.x,(int16_t)s.y);
}
}
// =============================================================================
// SCREEN: EASTER EGG — Self-destruct countdown
// =============================================================================
static unsigned long eeEntry=0;
static void screenEasterEggDraw(U8G2& u,unsigned long now,bool first){
if(first)eeEntry=now;
int8_t cd=10-(int8_t)((now-eeEntry)/1000);
if(cd>0){
if((now/400)%2==0){u.setFont(u8g2_font_5x7_tf);u.drawStr(6,10,"!! SELF DESTRUCT !!");}
u.setFont(u8g2_font_logisoso16_tf);
char buf[4]; snprintf(buf,sizeof(buf),"%d",cd);
u.drawStr(cd>=10?54:59,46,buf);
if((now/200)%2==0)u.drawFrame(0,0,128,64);
u.setFont(u8g2_font_4x6_tf);
u.drawStr(14,62,"PRESS KEY TO ABORT");
}else{
u.setFont(u8g2_font_6x10_tf);
u.drawStr(10,16,"AYE WHAT RA");
u.drawStr(10,32,"PRESSING ME");
u.drawStr(10,48,"LIKE THAT!");
u.setFont(u8g2_font_4x6_tf);
u.drawStr(28,62,"press any key");
}
}
// =============================================================================
// GAME: X-Wing vs Death Star
// Controls: BTN1=up BTN3=down BTN2/ENC_CLICK=fire ENC_LONG=exit to menu
// =============================================================================
struct GState {
int score, lives, level;
unsigned long gameStart, levelStart, dsNextShot;
int xwY; // X-Wing top-left Y (0..48)
int xwBulletX; // bullet X (moves right 0→128)
int xwBulletY; // bullet Y (centre of X-Wing, fixed when fired)
bool xwFiring;
bool dsSchedShot;
int dsShotVel; // pixels per frame Death Star shots travel
int dsTots; // number of active Death Star shots (0..4)
int dsShotX[4]; // shot X positions
int dsShotY[4]; // shot Y positions
int dsDir; // 0=moving down, 1=moving up
int dsX; // Death Star centre Y (bounces 0→63)
int dsVelY; // Death Star vertical speed
int dsRadius; // Death Star radius
bool gameOver;
bool cutscene; // true = intro cutscene playing
};
static GState gm;
static void gameReset(){
gm.score=0; gm.lives=5; gm.level=1;
gm.gameStart=millis(); gm.levelStart=gm.gameStart; gm.dsNextShot=0;
gm.xwY=24; gm.xwBulletX=0; gm.xwBulletY=0; gm.xwFiring=false;
gm.dsSchedShot=true; gm.dsShotVel=3; gm.dsTots=0;
for(int i=0;i<4;i++){ gm.dsShotX[i]=200; gm.dsShotY[i]=0; }
gm.dsDir=0; gm.dsX=8; gm.dsVelY=1; gm.dsRadius=10;
gm.gameOver=false; gm.cutscene=true;
}
static void screenGameDraw(U8G2& u, unsigned long now, bool /*first*/){
// ── Intro cutscene (plays while Star Wars theme runs, ~9.25s) ────────────
if(gm.cutscene){
unsigned long el = now - gm.gameStart;
if(el >= 9250UL){
gm.cutscene = false;
gm.levelStart = now; // level timer starts when game actually begins
gm.dsNextShot = 0;
} else {
u.drawBitmap(4, 8, 6, 48, STORM_BMP);
u.setFont(u8g2_font_logisoso16_tf);
u.drawStr(60, 26, "DORY");
u.setFont(u8g2_font_6x10_tf);
u.drawStr(67, 40, "VS");
u.drawStr(55, 54, "MEMORY");
if((now / 500) % 2 == 0){
u.setFont(u8g2_font_4x6_tf);
u.drawStr(22, 63, "[O] skip");
}
return;
}
}
// ── Game Over screen ────────────────────────────────────────
if(gm.gameOver){
u.setFont(u8g2_font_logisoso16_tf);
u.drawStr(8, 30, "GAME OVER");
u.setFont(u8g2_font_5x7_tf);
char buf[32];
snprintf(buf,sizeof(buf),"Score: %d Lvl: %d", gm.score, gm.level);
u.drawStr(6, 46, buf);
u.drawStr(12, 58, "[O] play again");
return;
}
// ── Level progression (every 50s) ───────────────────────────
if(now - gm.levelStart > 50000UL){
gm.levelStart = now;
gm.level++;
gm.dsShotVel++;
if(gm.level % 2 == 0){ gm.dsVelY++; if(gm.dsRadius>4) gm.dsRadius--; }
tone(PIN_BUZZER, 500, 150);
}
// ── Continuous button read for smooth X-Wing movement ───────
if(digitalRead(PIN_BTN1)==LOW && gm.xwY >= 2) gm.xwY -= 2;
if(digitalRead(PIN_BTN3)==LOW && gm.xwY <= 46) gm.xwY += 2;
// ── Death Star shot scheduling ───────────────────────────────
if(gm.dsSchedShot){
gm.dsNextShot = now + (unsigned long)random(400,1200);
gm.dsSchedShot = false;
}
if(now >= gm.dsNextShot && gm.dsTots < 4){
gm.dsSchedShot = true;
gm.dsShotX[gm.dsTots] = 90;
gm.dsShotY[gm.dsTots] = gm.dsX;
gm.dsTots++;
}
// ── Background stars ─────────────────────────────────────────
static const uint8_t STARS[][2]={
{50,30},{30,17},{60,18},{55,16},{25,43},{100,43},
{14,49},{24,24},{78,36},{80,57},{107,11},{5,5},{70,25}
};
for(uint8_t s=0;s<13;s++) u.drawPixel(STARS[s][0],STARS[s][1]);
// ── Move + draw X-Wing bullet ────────────────────────────────
if(gm.xwFiring){
gm.xwBulletX += 8;
u.drawHLine(gm.xwBulletX, gm.xwBulletY, 5);
if(gm.xwBulletX > 128) gm.xwFiring = false;
}
// ── Draw X-Wing ──────────────────────────────────────────────
u.drawBitmap(4, gm.xwY, 2, 16, XWING_BMP);
// ── Move + draw Death Star shots ─────────────────────────────
for(int i=0; i<gm.dsTots; i++){
if(gm.dsShotX[i] >= 0){
u.drawCircle(gm.dsShotX[i], gm.dsShotY[i], 2);
gm.dsShotX[i] -= gm.dsShotVel;
}
}
// Reset shot queue when last shot leaves screen
if(gm.dsTots > 0 && gm.dsShotX[gm.dsTots-1] <= 0){
gm.dsTots = 0;
for(int i=0;i<4;i++) gm.dsShotX[i]=200;
}
// ── Draw Death Star ──────────────────────────────────────────
u.drawDisc(95, gm.dsX, gm.dsRadius);
u.setDrawColor(0);
u.drawDisc(97, gm.dsX+3, gm.dsRadius/3 > 0 ? gm.dsRadius/3 : 1);
u.setDrawColor(1);
// ── HUD ──────────────────────────────────────────────────────
u.setFont(u8g2_font_5x7_tf);
char buf[32];
snprintf(buf,sizeof(buf),"Lives:%d Lvl:%d", gm.lives, gm.level);
u.drawStr(33, 7, buf);
snprintf(buf,sizeof(buf),"Score:%d %lus", gm.score, (now-gm.gameStart)/1000UL);
u.drawStr(33, 63, buf);
// ── Move Death Star ──────────────────────────────────────────
gm.dsX += (gm.dsDir==0) ? gm.dsVelY : -gm.dsVelY;
if(gm.dsX >= 63-gm.dsRadius) gm.dsDir=1;
if(gm.dsX <= gm.dsRadius) gm.dsDir=0;
// ── Collision: X-Wing bullet → Death Star ────────────────────
if(gm.xwFiring){
if(gm.xwBulletY >= gm.dsX-gm.dsRadius && gm.xwBulletY <= gm.dsX+gm.dsRadius &&
gm.xwBulletX > 95-gm.dsRadius && gm.xwBulletX < 95+gm.dsRadius){
gm.xwFiring = false;
gm.score++;
tone(PIN_BUZZER, 600, 30);
}
}
// ── Collision: Death Star shots → X-Wing ─────────────────────
int xwCY = gm.xwY+8;
for(int i=0; i<gm.dsTots; i++){
if(gm.dsShotY[i] >= xwCY-8 && gm.dsShotY[i] <= xwCY+8 &&
gm.dsShotX[i] < 22 && gm.dsShotX[i] > 4){
gm.dsShotX[i] = -100;
gm.lives--;
tone(PIN_BUZZER, 150, 100);
if(gm.lives <= 0){ gm.gameOver=true; tone(PIN_BUZZER,200,400); }
break;
}
}
}
// =============================================================================
// DisplayManager
// =============================================================================
class DisplayManager{
public:
U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2{U8G2_R0,U8X8_PIN_NONE};
void begin(){
Wire.begin(PIN_OLED_SDA,PIN_OLED_SCL);
u8g2.begin();u8g2.setContrast(255);
u8g2.clearBuffer();u8g2.sendBuffer();
Serial.println("[DISP]ready");
}
void update(unsigned long now){
unsigned long iv=(currentState==STATE_BOOT)?FRAME_BOOT_MS:
(currentState==STATE_GAME)?FRAME_GAME_MS:FRAME_UI_MS;
if(now-_last<iv)return; _last=now;
bool first=(currentState!=_drawn);_drawn=currentState;
u8g2.clearBuffer();
switch(currentState){
case STATE_BOOT: screenBootDraw(u8g2,now,first); break;
case STATE_NOW_PLAYING: screenNowPlayingDraw(u8g2,now,first); break;
case STATE_MENU: screenMenuDraw(u8g2,now,first); break;
case STATE_SCREENSAVER: screenSaverDraw(u8g2,now,first); break;
case STATE_EASTER_EGG: screenEasterEggDraw(u8g2,now,first); break;
case STATE_GAME: screenGameDraw(u8g2,now,first); break;
}
u8g2.sendBuffer();
}
private:
unsigned long _last=0; AppState _drawn=STATE_BOOT;
} displayManager;
// =============================================================================
// onAppEvent — single entry point for ALL input events
//
// Rules:
// 1. SM runs first — if a state transition happened, event is consumed.
// 2. Screensaver/EE: ANY key wakes to NOW_PLAYING only (no dual-action).
// 3. Menu: encoder scrolls, ENC_CLICK activates, BTN2_SHORT backs out.
// 4. NOW_PLAYING: buttons control playback, encoder adjusts volume.
// =============================================================================
void onAppEvent(AppEvent e){
if(e!=EVENT_ENC_CW&&e!=EVENT_ENC_CCW){
Serial.print("[IN]");Serial.println(ENAMES[e]);
}
AppState before = stateMachine.getState();
stateMachine.handleEvent(e);
AppState after = stateMachine.getState();
// If a state transition happened, don't also fire an action.
// (Waking screensaver, entering menu, easter-egg — the event is "used up".)
if(after != before) return;
// ── Encoder rotation ────────────────────────────────────
if(e==EVENT_ENC_CW || e==EVENT_ENC_CCW){
int8_t d=(e==EVENT_ENC_CW)?1:-1;
if(before==STATE_MENU){ screenMenuScroll(d); }
else if(before==STATE_GAME){
// CW (right) = down, CCW (left) = up — mirrors button layout
gm.xwY = constrain(gm.xwY + d*4, 0, 48);
}
else { audioManager.changeVolume(d); screenNowPlayingShowVolume(); }
return;
}
// ── Menu controls ────────────────────────────────────────
if(before==STATE_MENU){
if(e==EVENT_ENC_CLICK) screenMenuActivate();
if(e==EVENT_BTN2_SHORT) screenMenuBack();
return;
}
// ── NOW_PLAYING playback controls ────────────────────────
if(before==STATE_NOW_PLAYING){
switch(e){
case EVENT_BTN1_SHORT: audioManager.prevTrack(); screenNowPlayingResetTimer(); break;
case EVENT_BTN2_SHORT: audioManager.togglePlayPause(); break;
case EVENT_BTN2_LONG: audioManager.toggleMute(); break;
case EVENT_BTN3_SHORT: audioManager.nextTrack(); screenNowPlayingResetTimer(); break;
case EVENT_BTN3_LONG: audioManager.toggleShuffle(); break;
default: break;
}
}
// ── GAME controls ────────────────────────────────────────
// BTN1/BTN3 movement is handled by direct pin reads inside screenGameDraw.
// BTN2_SHORT / ENC_CLICK: fire bullet (or restart if game over)
if(before==STATE_GAME){
if(e==EVENT_BTN2_SHORT || e==EVENT_ENC_CLICK){
if(gm.cutscene){ gm.cutscene=false; gm.levelStart=millis(); gm.dsNextShot=0; }
else if(gm.gameOver){ gameReset(); }
else if(!gm.xwFiring){
gm.xwFiring = true;
gm.xwBulletX = 20;
gm.xwBulletY = gm.xwY + 8; // centre of X-Wing
}
}
}
}
// =============================================================================
// InputSim — encoder gray-code + button debounce (all in class to avoid
// Arduino preprocessor forward-decl issues with struct params)
// =============================================================================
class InputSim{
public:
void begin(){
uint8_t pins[4]={PIN_BTN1,PIN_BTN2,PIN_BTN3,PIN_ENC_SW};
for(int i=0;i<4;i++){
_b[i].pin=pins[i];pinMode(pins[i],INPUT_PULLUP);
_b[i].rl=digitalRead(pins[i]);
}
pinMode(PIN_ENC_CLK,INPUT_PULLUP);
pinMode(PIN_ENC_DT, INPUT_PULLUP);
_encState = (uint8_t)((digitalRead(PIN_ENC_CLK)?2:0)|(digitalRead(PIN_ENC_DT)?1:0));
_lastEncMs = 0;
}
void update(unsigned long now){_enc(now);for(int i=0;i<4;i++)_btn(i,now);}
private:
struct Btn{uint8_t pin=0;bool pressed=false,lf=false;
unsigned long pa=0,lc=0;bool rl=true;} _b[4];
// Encoder: 4-state quadrature FSM + accumulator.
// Both quadrature steps in a detent must agree on direction before firing.
// A single noisy step (±1) is ignored — the event only fires at ±2 agreement.
uint8_t _encState = 3; // CLK=1,DT=1 = rest position
int8_t _encAccum = 0; // quadrature step accumulator
unsigned long _lastEncMs = 0;
static const AppEvent SE[4],LE[4];
void _enc(unsigned long now){
static const int8_t T[16]={0,1,-1,0,-1,0,0,1,1,0,0,-1,0,-1,1,0};
bool clk=digitalRead(PIN_ENC_CLK);
bool dt =digitalRead(PIN_ENC_DT);
_encState=(uint8_t)((_encState<<2)|((uint8_t)(clk?2:0))|(dt?1:0))&0x0F;
int8_t d=T[_encState];
if(!d) return;
_encAccum+=d;
if(_encAccum>=2){
_encAccum=0;
if(now-_lastEncMs>=200){_lastEncMs=now;onAppEvent(EVENT_ENC_CW);}
}else if(_encAccum<=-2){
_encAccum=0;
if(now-_lastEncMs>=200){_lastEncMs=now;onAppEvent(EVENT_ENC_CCW);}
}
}
void _btn(int i,unsigned long now){
bool raw=digitalRead(_b[i].pin);
if(raw!=_b[i].rl){_b[i].rl=raw;_b[i].lc=now;return;}
if(now-_b[i].lc<DEBOUNCE_MS)return;
bool dn=(raw==LOW);
if(dn&&!_b[i].pressed){_b[i].pressed=true;_b[i].lf=false;_b[i].pa=now;}
else if(dn&&_b[i].pressed&&!_b[i].lf&&now-_b[i].pa>=LONGPRESS_MS){
_b[i].lf=true;onAppEvent(LE[i]);
}else if(!dn&&_b[i].pressed){
if(!_b[i].lf)onAppEvent(SE[i]);
_b[i].pressed=false;_b[i].lf=false;
}
}
}inputSim;
const AppEvent InputSim::SE[4]={EVENT_BTN1_SHORT,EVENT_BTN2_SHORT,EVENT_BTN3_SHORT,EVENT_ENC_CLICK};
const AppEvent InputSim::LE[4]={EVENT_BTN1_LONG, EVENT_BTN2_LONG, EVENT_BTN3_LONG, EVENT_ENC_LONG};
// =============================================================================
// setup / loop
// =============================================================================
void setup(){
Serial.begin(115200);
Serial.println("[Dory MP3] Boot");
persistence.begin();
audioManager.begin();
audioManager.setVolume(persistence.getVolume());
audioManager.setShuffle(persistence.getShuffle());
audioManager.setFolder(persistence.getPlaylist());
inputSim.begin();
displayManager.begin();
stateMachine.begin();
audioManager.playBootJingle();
}
void loop(){
unsigned long now=millis();
stateMachine.setNow(now); // must be first — locks consistent 'now' for this tick
inputSim.update(now);
// Keep screen awake while music plays or game is running
if(audioManager.isPlaying()||currentState==STATE_GAME)stateMachine.keepAwake();
// State-entry effects
if(stateMachine.stateChanged()){
switch(stateMachine.getState()){
case STATE_NOW_PLAYING:audioManager.stopBuzzer();break;
case STATE_EASTER_EGG:audioManager.playSelfDestructAlarm();break;
case STATE_GAME:gameReset();audioManager.playStarWarsTheme();break;
default:break;
}
}
audioManager.update(now);
displayManager.update(now);
stateMachine.update(now);
}