#include <MD_Parola.h>
#include <MD_MAX72xx.h>
#include <Adafruit_NeoPixel.h>
#include <Servo.h>
// ────────────────────────────────────────────────
// CONFIGURAZIONE
// ────────────────────────────────────────────────
struct Config {
// ── Pin ───────────────────────────────────────
uint8_t pinStripLeft = 5;
uint8_t pinStripRight = 7;
uint8_t pinDisplayDin = 2;
uint8_t pinDisplayClk = 4;
uint8_t pinDisplayCs = 3;
uint8_t numDisplays = 4;
uint8_t pinBuzzer = 12;
uint8_t pinServo = 6;
uint8_t pinStartBtn = 10;
uint8_t pinStopBtn = 11;
uint8_t pinGo1 = 8;
uint8_t pinGo2 = 9;
uint8_t pinLedGo1 = A1;
uint8_t pinLedGo2 = A2;
uint8_t pinCom = A0;
uint8_t pinCloseBtn = A3;
// ── Valori fisici / meccanici ─────────────────
uint8_t servoClosed = 0;
uint8_t servoOpen = 180;
uint16_t servoMoveMs = 2000;
uint8_t ledsPerStrip = 16;
uint8_t brightnessStrip = 200;
// ── Gameplay ──────────────────────────────────
uint32_t durationSeconds = 3600UL;
uint16_t debounceMs = 300;
uint16_t goStabilityMs = 300;
// ── Animazioni comete ─────────────────────────
uint8_t cometTailLength = 5;
uint8_t cometBrightnessStep = 48;
uint8_t cometMaxBrightness = 255;
uint16_t idleCometSpeedMs = 110;
uint16_t countdownCometSpeedMs = 55;
uint16_t winCometSpeedMs = 110;
// ── Timing generali ───────────────────────────
uint16_t blinkPeriodMs = 500;
uint16_t countdownBlinks = 7;
uint16_t reminderIntervalMs = 5000;
uint16_t reminderFlashMs = 120;
uint16_t checkGoIntervalMs = 50;
// ── Fading timeout ────────────────────────────
uint32_t fadeStepTimeout = 3;
uint32_t fadeDelayMs = 4;
uint32_t fadePauseMs = 220;
// ── Color win phase ───────────────────────────
uint32_t colorWinSpeed = 25;
};
constexpr Config cfg {};
// ────────────────────────────────────────────────
// STILI
// ────────────────────────────────────────────────
constexpr uint32_t COLOR_IDLE = 0x1E1EDC; // 30, 30, 220 - BLU
constexpr uint32_t COLOR_COUNTDOWN = 0xDC1400; // 220, 20, 0 - ROSSO
struct CometStyle {
uint32_t baseColor;
uint16_t speedMs;
};
// Stili predefiniti
constexpr CometStyle styleIdle {
.baseColor = COLOR_IDLE,
.speedMs = cfg.idleCometSpeedMs
};
constexpr CometStyle styleCountdown {
.baseColor = COLOR_COUNTDOWN,
.speedMs = cfg.countdownCometSpeedMs
};
constexpr CometStyle styleWin {
.baseColor = 0,
.speedMs = cfg.winCometSpeedMs
};
// ────────────────────────────────────────────────
// SUONI
// ────────────────────────────────────────────────
enum class Sound : uint8_t {
Start,
CountdownTick,
CountdownEnd,
LastMinute,
Last60s,
Win,
Timeout,
WinShort
};
struct SoundDef {
uint16_t freq;
uint16_t durationMs;
};
constexpr SoundDef sounds[] PROGMEM = {
{1550, 120}, // Start
{ 980, 35}, // CountdownTick
{1650, 80}, // CountdownEnd
{ 420, 12}, // LastMinute
{ 520, 40}, // Last60s
{1250, 700}, // Win
{ 380,1400}, // Timeout
{ 140, 80} // WinShort
};
// ────────────────────────────────────────────────
// STATI
// ────────────────────────────────────────────────
enum class GameState : uint8_t { IDLE, COUNTDOWN, RUNNING, TIMEOUT, WON };
struct Game {
GameState state = GameState::IDLE;
uint32_t startMillis = 0;
uint32_t frozenSeconds = 0;
uint32_t lastBeepSecond = cfg.durationSeconds + 1;
uint32_t lastDisplayedSec = ~0UL;
uint32_t lastMinuteBeep = 0;
uint32_t lastBlinkMs = 0;
uint8_t blinkCounter = 0;
bool bothGO_active = false;
bool stopEnabled = false;
char timeBuf[6] = "00:00";
} game;
// ────────────────────────────────────────────────
// HARDWARE
// ────────────────────────────────────────────────
MD_Parola display(MD_MAX72XX::FC16_HW, cfg.pinDisplayDin, cfg.pinDisplayClk, cfg.pinDisplayCs, cfg.numDisplays);
Adafruit_NeoPixel stripLeft (cfg.ledsPerStrip, cfg.pinStripLeft, NEO_GRB + NEO_KHZ800);
Adafruit_NeoPixel stripRight(cfg.ledsPerStrip, cfg.pinStripRight, NEO_GRB + NEO_KHZ800);
Servo servo;
// ────────────────────────────────────────────────
// FONT
// ────────────────────────────────────────────────
const uint8_t fontTimer[] PROGMEM = {
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,64,0,6,126,255,195,195,255,126,6,0,0,255,255,0,0,
6,243,251,219,219,223,206,6,219,219,219,219,255,126,6,15,31,24,24,255,255,6,
223,223,219,219,251,115,6,126,255,219,219,251,122,6,3,3,3,3,255,254,
6,118,255,219,219,255,118,6,78,223,219,219,255,126,2,102,102
};
// ────────────────────────────────────────────────
// FUNZIONI HELPER
// ────────────────────────────────────────────────
inline bool elapsed(uint32_t from, uint32_t duration, uint32_t now) {
return (now - from) >= duration;
}
inline void clearStrips() {
stripLeft.clear(); stripLeft.show();
stripRight.clear(); stripRight.show();
}
inline void fillStrips(uint32_t color) {
stripLeft.fill(color);
stripRight.fill(color);
}
uint32_t secondsRemaining(uint32_t now) {
if (game.startMillis == 0) return cfg.durationSeconds;
if (now < game.startMillis) return cfg.durationSeconds;
uint32_t passed = (now - game.startMillis) / 1000UL;
return (passed >= cfg.durationSeconds) ? 0 : cfg.durationSeconds - passed;
}
void formatTime(uint32_t sec, char* buf) {
uint32_t m = sec / 60;
uint32_t s = sec % 60;
buf[0] = '0' + m / 10;
buf[1] = '0' + m % 10;
buf[2] = ':';
buf[3] = '0' + s / 10;
buf[4] = '0' + s % 10;
buf[5] = '\0';
}
uint32_t cometSpeed(uint32_t remainingSec) {
if (remainingSec >= 3600) return 100;
if (remainingSec <= 60) return 30;
uint32_t progress = remainingSec - 60;
return 30 + (70UL * progress) / (3600 - 60);
}
uint32_t cometColor(uint32_t remainingSec) {
uint32_t min = remainingSec / 60;
if (min >= 51) return stripLeft.Color( 0, 255, 0);
else if (min >= 41) return stripLeft.Color( 80, 255, 0);
else if (min >= 31) return stripLeft.Color(255, 255, 0);
else if (min >= 21) return stripLeft.Color(255, 160, 0);
else if (min >= 11) return stripLeft.Color(255, 80, 0);
else return stripLeft.Color(255, 0, 0);
}
void playSound(Sound snd) {
uint8_t idx = static_cast<uint8_t>(snd);
SoundDef s;
memcpy_P(&s, &sounds[idx], sizeof(SoundDef));
tone(cfg.pinBuzzer, s.freq, s.durationMs);
}
void drawComets(const CometStyle& style, uint32_t now, bool useHSV = false, uint16_t hue = 0) {
static int8_t headLeft = 0; static int8_t dirLeft = 1;
static int8_t headRight = 15; static int8_t dirRight = -1;
static uint32_t lastUpdate = 0;
if (!elapsed(lastUpdate, style.speedMs, now)) return;
lastUpdate = now;
stripLeft.clear();
stripRight.clear();
uint32_t base = useHSV ? stripLeft.ColorHSV(hue * 256) : style.baseColor;
for (uint8_t i = 0; i < cfg.cometTailLength; i++) {
int8_t px = headLeft - dirLeft * i;
if (px >= 0 && px < cfg.ledsPerStrip) {
uint8_t bri = cfg.cometMaxBrightness - i * cfg.cometBrightnessStep;
uint8_t r = ((base >> 16) & 255) * bri / 255;
uint8_t g = ((base >> 8) & 255) * bri / 255;
uint8_t b = ( base & 255) * bri / 255;
stripLeft.setPixelColor(px, r, g, b);
}
}
for (uint8_t i = 0; i < cfg.cometTailLength; i++) {
int8_t px = headRight - dirRight * i;
if (px >= 0 && px < cfg.ledsPerStrip) {
uint8_t bri = cfg.cometMaxBrightness - i * cfg.cometBrightnessStep;
uint8_t r = ((base >> 16) & 255) * bri / 255;
uint8_t g = ((base >> 8) & 255) * bri / 255;
uint8_t b = ( base & 255) * bri / 255;
stripRight.setPixelColor(px, r, g, b);
}
}
stripLeft.show();
stripRight.show();
headLeft += dirLeft;
headRight += dirRight;
if (headLeft <= 0) { dirLeft = 1; headLeft = 0; }
if (headLeft >= 15) { dirLeft = -1; headLeft = 15; }
if (headRight <= 0) { dirRight = 1; headRight = 0; }
if (headRight >= 15) { dirRight = -1; headRight = 15; }
}
// ────────────────────────────────────────────────
// SETUP
// ────────────────────────────────────────────────
void setup() {
pinMode(cfg.pinStartBtn, INPUT_PULLUP);
pinMode(cfg.pinStopBtn, INPUT_PULLUP);
pinMode(cfg.pinGo1, INPUT_PULLUP);
pinMode(cfg.pinGo2, INPUT_PULLUP);
pinMode(cfg.pinCloseBtn, INPUT_PULLUP);
pinMode(cfg.pinCom, OUTPUT);
pinMode(cfg.pinLedGo1, OUTPUT);
pinMode(cfg.pinLedGo2, OUTPUT);
pinMode(cfg.pinBuzzer, OUTPUT);
digitalWrite(cfg.pinCom, LOW);
digitalWrite(cfg.pinLedGo1, LOW);
digitalWrite(cfg.pinLedGo2, LOW);
digitalWrite(cfg.pinBuzzer, LOW);
stripLeft.begin(); stripLeft.setBrightness(cfg.brightnessStrip);
stripRight.begin(); stripRight.setBrightness(cfg.brightnessStrip);
fillStrips(stripLeft.Color(100, 0, 0));
stripLeft.show(); stripRight.show();
display.begin();
display.setFont(fontTimer);
display.setTextAlignment(PA_CENTER);
display.displayReset();
display.print("00:00");
clearStrips();
}
// ────────────────────────────────────────────────
// GESTORI
// ────────────────────────────────────────────────
void handleButtons(uint32_t now);
void handleGO(uint32_t now);
void updateGame(uint32_t now);
void loop() {
uint32_t now = millis();
handleButtons(now);
handleGO(now);
updateGame(now);
}
void handleButtons(uint32_t now) {
static uint32_t lastStart = 0;
static uint32_t lastStop = 0;
static uint32_t lastClose = 0;
// START
if (!digitalRead(cfg.pinStartBtn) && elapsed(lastStart, cfg.debounceMs, now)) {
lastStart = now;
if (game.state == GameState::IDLE) {
game.state = GameState::COUNTDOWN;
game.blinkCounter = 0;
game.lastBlinkMs = now;
game.bothGO_active = false;
game.stopEnabled = false;
game.lastDisplayedSec = ~0UL;
clearStrips();
}
}
// STOP (solo se abilitato)
if (game.stopEnabled && !digitalRead(cfg.pinStopBtn) && elapsed(lastStop, cfg.debounceMs, now)) {
lastStop = now;
if (game.state == GameState::RUNNING) {
game.frozenSeconds = secondsRemaining(now);
formatTime(game.frozenSeconds, game.timeBuf);
game.state = GameState::WON;
digitalWrite(cfg.pinCom, LOW);
digitalWrite(cfg.pinLedGo1, LOW);
digitalWrite(cfg.pinLedGo2, LOW);
playSound(Sound::Win);
clearStrips();
servo.detach();
}
}
// CLOSE (solo dopo vittoria)
if (game.state == GameState::WON &&
!digitalRead(cfg.pinCloseBtn) && elapsed(lastClose, cfg.debounceMs, now)) {
lastClose = now;
fillStrips(0); stripLeft.show(); stripRight.show();
digitalWrite(cfg.pinLedGo1, LOW);
digitalWrite(cfg.pinLedGo2, LOW);
for (int i = 5; i >= 0; i--) {
playSound(Sound::WinShort);
display.print(i);
delay(1000);
}
servo.attach(cfg.pinServo);
servo.write(cfg.servoClosed);
delay(cfg.servoMoveMs);
display.displayClear();
noTone(cfg.pinBuzzer);
servo.detach();
while (true) delay(1000);
}
}
void handleGO(uint32_t now) {
static uint32_t lastCheck = 0;
static uint32_t stableSince = 0;
static bool ledsOn = false;
static uint32_t lastReminder = 0;
static bool reminderActive = false;
if (game.state == GameState::RUNNING && game.stopEnabled) {
if (!elapsed(lastCheck, cfg.debounceMs, now)) return;
lastCheck = now;
digitalWrite(cfg.pinLedGo1, ledsOn);
digitalWrite(cfg.pinLedGo2, ledsOn);
ledsOn = !ledsOn;
return;
}
if (game.state != GameState::RUNNING) return;
if (!elapsed(lastCheck, cfg.checkGoIntervalMs, now)) return;
lastCheck = now;
bool go1 = !digitalRead(cfg.pinGo1);
bool go2 = !digitalRead(cfg.pinGo2);
digitalWrite(cfg.pinLedGo1, go1);
digitalWrite(cfg.pinLedGo2, go2);
bool needReminder = !go1 || !go2;
if (needReminder) {
if (elapsed(lastReminder, cfg.reminderIntervalMs, now)) {
lastReminder = now;
reminderActive = true;
if (!go1) digitalWrite(cfg.pinLedGo1, HIGH);
if (!go2) digitalWrite(cfg.pinLedGo2, HIGH);
}
if (reminderActive && elapsed(lastReminder, cfg.reminderFlashMs, now)) {
reminderActive = false;
digitalWrite(cfg.pinLedGo1, go1);
digitalWrite(cfg.pinLedGo2, go2);
}
} else {
lastReminder = 0;
reminderActive = false;
}
if (go1 && go2) {
if (stableSince == 0) stableSince = now;
if (elapsed(stableSince, cfg.goStabilityMs, now)) {
if (!game.bothGO_active && !game.stopEnabled) {
noTone(cfg.pinBuzzer);
servo.attach(cfg.pinServo);
servo.write(cfg.servoOpen);
game.stopEnabled = true;
playSound(Sound::CountdownEnd);
}
game.bothGO_active = true;
}
} else {
stableSince = 0;
}
}
void updateGame(uint32_t now) {
switch (game.state) {
case GameState::IDLE:
drawComets(styleIdle, now);
break;
case GameState::COUNTDOWN:
drawComets(styleCountdown, now);
if (!elapsed(game.lastBlinkMs, cfg.blinkPeriodMs, now)) break;
game.lastBlinkMs = now;
if (game.blinkCounter & 1) {
display.print(" : ");
} else {
display.print("60:00");
playSound(Sound::CountdownTick);
}
clearStrips();
if (++game.blinkCounter >= cfg.countdownBlinks) {
playSound(Sound::Start);
game.state = GameState::RUNNING;
game.startMillis = now;
game.lastBeepSecond = cfg.durationSeconds + 1;
game.lastDisplayedSec = ~0UL;
digitalWrite(cfg.pinCom, HIGH);
}
break;
case GameState::RUNNING: {
uint32_t secLeft = secondsRemaining(now);
uint32_t color = cometColor(secLeft);
uint32_t speed = cometSpeed(secLeft);
CometStyle dynamic = {color, (uint16_t)speed};
drawComets(dynamic, now);
if (secLeft == 0) {
game.state = GameState::TIMEOUT;
digitalWrite(cfg.pinCom, LOW);
playSound(Sound::Timeout);
display.print("00:00");
clearStrips();
break;
}
if (secLeft != game.lastDisplayedSec) {
formatTime(secLeft, game.timeBuf);
display.print(game.timeBuf);
game.lastDisplayedSec = secLeft;
if (secLeft <= 60 && secLeft != game.lastBeepSecond) {
game.lastBeepSecond = secLeft;
playSound(Sound::Last60s);
}
uint32_t minLeft = secLeft / 60;
if (minLeft != game.lastMinuteBeep && secLeft <= 600) {
game.lastMinuteBeep = minLeft;
playSound(Sound::LastMinute);
}
}
break;
}
case GameState::WON: {
uint16_t phase = (now / cfg.colorWinSpeed) % 256;
drawComets(styleWin, now, true, phase);
display.print((now % 1000 < 500) ? game.timeBuf : " ");
break;
}
case GameState::TIMEOUT:
for (int b = 255; b >= 0; b -= cfg.fadeStepTimeout) {
fillStrips(stripLeft.Color(b, 0, 0));
stripLeft.show(); stripRight.show();
delay(cfg.fadeDelayMs);
}
delay(cfg.fadePauseMs);
for (int b = 0; b <= 255; b += cfg.fadeStepTimeout) {
fillStrips(stripLeft.Color(b, 0, 0));
stripLeft.show(); stripRight.show();
delay(cfg.fadeDelayMs);
}
delay(cfg.fadePauseMs);
break;
}
}A0 Nano slaves
START
WIN
+5V 5A
GO1
GO2