#include // ====================================================== // 1D PONG dla ESP32 + FastLED + WS2812B // Wersja 2.0 — poprawiona i rozszerzona // ====================================================== // // Sprzęt: // - ESP32 (testowane na ESP32-WROOM-32) // - Taśma WS2812B, 137 LED // - 2 przyciski NO podłączone do GND (z PULLUP wewnętrznym) // - Buzzer pasywny (sterowany LEDC / PWM) // // Zmiany względem wersji 1.0: // [FIX 1] resetMatch() wywoływane przed nową grą po WIN // [FIX 2] Pauza w melodii poprawnie wycisza buzzer // [FIX 3] API LEDC zaktualizowane do ESP32 Arduino Core ≥ 3.x // (ledcAttachChannel zamiast ledcSetup + ledcAttachPin) // [FIX 4] stateEnteredAt używane do animacji ostrzeżenia w strefie // [FIX 5] frameAt — dodano flagę frameScheduled, by uniknąć // fałszywego trafienia gdy millis() == 0 po restarcie // [FIX 6] Gracz prawy może teraz przewinąć animację punktu // (updatePointLeft obsługuje też rightPress dla skrótu) // [ULP 1] Timeout powrotu do IDLE z ekranu WIN (WIN_IDLE_TIMEOUT_MS) // [ULP 2] Wizualne ostrzeżenie (miganie strefy) gdy piłka dochodzi // do końca taśmy bez odbicia // [ULP 3] Kolory stref różnicowane kolorami gracza L/R // [ULP 4] Lepszy ogon piłki — nie renderuje fragmentów poza taśmą // [ULP 5] Komentarze i organizacja kodu // ====================================================== // ========================= // Piny sprzętowe // ========================= #define LED_PIN 23 // DIN taśmy WS2812B #define BTN_LEFT_PIN 18 // przycisk gracza lewego (LOW = wciśnięty) #define BTN_RIGHT_PIN 19 // przycisk gracza prawego #define BUZZER_PIN 25 // buzzer pasywny (PWM) // ========================= // Parametry taśmy LED // ========================= #define LED_COUNT 137 // liczba diod w taśmie #define CHIPSET WS2812B #define COLOR_ORDER GRB #define BRIGHTNESS 96 // 0–255, ogranicz jeśli zasilacz słaby CRGB leds[LED_COUNT]; // Opcjonalne ograniczenie poboru prądu (mA przy 5 V). // FastLED automatycznie skaluje jasność jeśli limit zostanie przekroczony. #define LIMIT_CURRENT_MA 2500 // ========================= // Parametry rozgrywki // ========================= #define WIN_POINTS 10 // ile punktów do wygrania #define SCORE_LEDS_PER_POINT 2 // ile LED-ów zajmuje 1 punkt na wyświetlaczu #define ZONE_SIZE 14 // szerokość strefy odbicia po każdej stronie #define START_SPEED_MS 24 // czas między klatkami na starcie [ms] #define MIN_SPEED_MS 6 // maksymalna prędkość (minimum delay) #define RALLY_ACCEL_EVERY 2 // co ile wymian wzrasta prędkość bazowo // Czasy animacji [ms] #define IDLE_FRAME_MS 35 #define SERVE_BLINK_MS 140 #define POINT_BLINK_MS 170 #define WIN_FRAME_MS 60 // Timeouty #define START_TIMEOUT_MS 20000UL // czas na serwis na starcie meczu #define RESUME_TIMEOUT_MS 7000UL // czas na serwis po punkcie #define WIN_IDLE_TIMEOUT_MS 30000UL // [ULP 1] po wygranej wróć do IDLE // Próg klatki ostrzeżenia w strefie (ile ms po wejściu piłki do strefy // zaczyna migać podświetlenie — wizualne ostrzeżenie dla gracza) #define ZONE_WARN_AFTER_MS 600 // [ULP 2] // Debouncing przycisków #define DEBOUNCE_MS 18 // ========================= // Buzzer / audio // ========================= #define SOUND_ENABLED 1 #define BUZZER_CHANNEL 0 #define BUZZER_RESOLUTION 10 // bity rozdzielczości (0..1023) #define BUZZER_DUTY 512 // 50% wypełnienia = czysty ton #define BUZZER_BASE_FREQ 1000 // częstotliwość inicjalizacji kanału [Hz] // Nuty (częstotliwości w Hz) #define NOTE_C4 262 #define NOTE_D4 294 #define NOTE_E4 330 #define NOTE_F4 349 #define NOTE_G4 392 #define NOTE_A4 440 #define NOTE_B4 494 #define NOTE_C5 523 #define NOTE_D5 587 #define NOTE_E5 659 #define NOTE_F5 698 #define NOTE_G5 784 #define NOTE_A5 880 // ====================================================== // Narzędzia pomocnicze // ====================================================== /** * Sprawdza czy 'now' osiągnęło lub przekroczyło 'target'. * Obsługuje poprawnie przepełnienie uint32_t (po ~49 dniach). */ static inline bool timeReached(uint32_t now, uint32_t target) { return (int32_t)(now - target) >= 0; } /** * Clamp — ogranicza wartość v do przedziału [lo, hi]. */ template static inline T clampValue(T v, T lo, T hi) { if (v < lo) return lo; if (v > hi) return hi; return v; } // ====================================================== // Obsługa przycisku z debouncingiem programowym // ====================================================== struct DebouncedButton { uint8_t pin; bool stableState = HIGH; bool lastReading = HIGH; bool pressedEvent = false; uint32_t lastChangeMs = 0; void begin(uint8_t p) { pin = p; pinMode(pin, INPUT_PULLUP); stableState = digitalRead(pin); lastReading = stableState; pressedEvent = false; lastChangeMs = millis(); } /** Wywołaj raz na iterację loop() z aktualnym millis(). */ void update(uint32_t now) { bool reading = digitalRead(pin); if (reading != lastReading) { lastReading = reading; lastChangeMs = now; } if ((uint32_t)(now - lastChangeMs) >= DEBOUNCE_MS) { if (stableState != reading) { stableState = reading; if (stableState == LOW) { pressedEvent = true; // naciśnięcie wykryte } } } } /** Zwraca true jeśli od ostatniego wywołania był press. Kasuje flagę. */ bool consumePress() { bool ev = pressedEvent; pressedEvent = false; return ev; } }; DebouncedButton btnLeft; DebouncedButton btnRight; // ====================================================== // System audio (buzzer pasywny przez LEDC/PWM) // ====================================================== uint32_t toneOffAt = 0; // kiedy wyłączyć bieżący ton bool toneActive = false; // czy buzzer gra bool melodyPlaying = false; // czy trwa odtwarzanie melodii uint8_t melodyIndex = 0; // indeks aktualnej nuty // Melodia zwycięstwa const uint16_t winMelodyFreq[] = { NOTE_G4, NOTE_C5, NOTE_E5, NOTE_G5, 0, NOTE_E5, NOTE_G5, NOTE_A5 }; const uint16_t winMelodyDur[] = { 90, 90, 90, 180, 70, 120, 180, 220 }; const uint8_t WIN_MELODY_LEN = sizeof(winMelodyFreq) / sizeof(winMelodyFreq[0]); /** Natychmiastowe wyciszenie buzzera. */ void stopToneNow() { #if SOUND_ENABLED ledcWrite(BUZZER_CHANNEL, 0); #endif toneOffAt = 0; toneActive = false; } /** * Uruchamia ton o zadanej częstotliwości na durationMs milisekund. * freq == 0 → pauza (cisza przez durationMs). */ void playToneMs(uint16_t freq, uint16_t durationMs) { #if SOUND_ENABLED if (durationMs == 0) return; if (freq == 0) { // [FIX 2] Pauza — wycisz buzzer i odczekaj ledcWrite(BUZZER_CHANNEL, 0); toneActive = false; } else { ledcWriteTone(BUZZER_CHANNEL, freq); ledcWrite(BUZZER_CHANNEL, BUZZER_DUTY); toneActive = true; } toneOffAt = millis() + durationMs; #else (void)freq; (void)durationMs; #endif } /** Startuje melodię zwycięstwa od początku. */ void startWinMelody() { melodyPlaying = true; melodyIndex = 0; stopToneNow(); } /** Zatrzymuje melodię i cisza. */ void stopWinMelody() { melodyPlaying = false; melodyIndex = 0; stopToneNow(); } /** * Aktualizacja silnika audio — musi być wywoływana na początku każdego loop(). * Obsługuje wyłączanie tonu po czasie i sekwencyjne odtwarzanie melodii. */ void updateAudio(uint32_t now) { #if SOUND_ENABLED if (toneOffAt == 0) return; if (!timeReached(now, toneOffAt)) return; // Czas bieżącej nuty minął stopToneNow(); if (!melodyPlaying) return; // Odtwarzamy kolejną nutę melodii if (melodyIndex < WIN_MELODY_LEN) { uint16_t f = winMelodyFreq[melodyIndex]; uint16_t d = winMelodyDur[melodyIndex]; melodyIndex++; playToneMs(f, d); // playToneMs obsługuje f==0 (pauza) poprawnie [FIX 2] } else { melodyPlaying = false; // melodia zakończona } #else (void)now; #endif } // ====================================================== // Stany maszyny stanów gry // ====================================================== enum GameState : uint8_t { GS_IDLE = 0, // ekran tytułowy / demo GS_START_LEFT, // serwis gracza lewego (start meczu) GS_START_RIGHT, // serwis gracza prawego (start meczu) GS_MOVE_LR, // piłka leci w prawo GS_MOVE_RL, // piłka leci w lewo GS_ZONE_LEFT, // piłka w strefie gracza lewego — czeka na odbicie GS_ZONE_RIGHT, // piłka w strefie gracza prawego — czeka na odbicie GS_POINT_LEFT, // gracz lewy zdobył punkt — animacja GS_POINT_RIGHT, // gracz prawy zdobył punkt — animacja GS_RESUME_LEFT, // serwis gracza lewego (po punkcie) GS_RESUME_RIGHT,// serwis gracza prawego (po punkcie) GS_WIN_LEFT, // gracz lewy wygrał mecz GS_WIN_RIGHT // gracz prawy wygrał mecz }; GameState state = GS_IDLE; // ====================================================== // Zmienne stanu gry // ====================================================== // Wyniki uint8_t pointsLeft = 0; uint8_t pointsRight = 0; // Piłka int ballPos = 0; // pozycja piłki [0 .. LED_COUNT-1] // Prędkość i wymiana uint16_t stepDelayMs = START_SPEED_MS; uint16_t rallyCount = 0; // Scheduler klatek — [FIX 5] osobna flaga zamiast testowania frameAt==0 uint32_t frameAt = 0; bool frameScheduled = false; // Timeouty uint32_t timeoutAt = 0; bool timeoutActive = false; // [FIX 4] Czas wejścia w bieżący stan — używany do animacji ostrzeżenia uint32_t stateEnteredAt = 0; // Animacja migania bool blinkOn = true; uint8_t pointBlinkRemaining = 0; // Animacja IDLE uint16_t idleHue = 0; int idleBallPos = 0; int idleBallDir = 1; // Animacja WIN uint8_t winAnimPhase = 0; // Jakość odbicia — wpływa na kolor piłki i przyspieszenie enum HitQuality : uint8_t { HIT_NORMAL = 0, // zwykłe odbicie w strefie HIT_GOOD, // blisko krawędzi HIT_PERFECT // tuż przy krawędzi }; HitQuality currentHitQuality = HIT_NORMAL; // ====================================================== // Kolory // ====================================================== // [ULP 3] Kolory stref różnicowane kolorami gracza CRGB colorZoneLeft = CRGB(40, 0, 0); // czerwonawy = gracz lewy CRGB colorZoneRight = CRGB(0, 40, 0); // zielonkawy = gracz prawy // Kolory pasków wyniku CRGB colorScoreLeft = CRGB(220, 0, 0); CRGB colorScoreRight = CRGB(0, 220, 0); /** Kolor głowy piłki zależny od jakości ostatniego odbicia. */ CRGB getBallHeadColor() { switch (currentHitQuality) { case HIT_PERFECT: return CRGB::White; case HIT_GOOD: return CRGB(80, 200, 255); default: return CRGB(255, 170, 0); } } // ====================================================== // Scheduler klatek i timeoutów // ====================================================== /** Zaplanuj wyrenderowanie kolejnej klatki za 'ms' milisekund. */ void scheduleFrame(uint16_t ms) { frameAt = millis() + ms; frameScheduled = true; } /** Anuluj zaplanowaną klatkę. */ void cancelFrame() { frameScheduled = false; frameAt = 0; } /** Sprawdź czy czas klatki nadszedł. */ bool isFrameReady(uint32_t now) { return frameScheduled && timeReached(now, frameAt); } /** Zaplanuj timeout (np. powrót do IDLE). */ void setTimeoutMs(uint32_t ms) { timeoutAt = millis() + ms; timeoutActive = true; } /** Anuluj timeout. */ void clearTimeout() { timeoutActive = false; timeoutAt = 0; } /** Sprawdź czy timeout nadszedł. */ bool isTimeoutReached(uint32_t now) { return timeoutActive && timeReached(now, timeoutAt); } // ====================================================== // Renderowanie // ====================================================== void clearLeds() { fill_solid(leds, LED_COUNT, CRGB::Black); } /** * Rysuje strefy odbicia po lewej i prawej stronie taśmy. * [ULP 2] Gdy piłka jest w strefie i gracz zwleka, strefa zaczyna migać. * * @param warnLeft czy migać lewą strefę * @param warnRight czy migać prawą strefę * @param blinkPhase faza migania (true = widoczna, false = ciemna) */ void drawZones(bool warnLeft = false, bool warnRight = false, bool blinkPhase = true) { for (int i = 0; i < ZONE_SIZE; i++) { // delikatny gradient: jaśniej przy krawędzi, ciemniej przy środku uint8_t brightness = map(i, 0, ZONE_SIZE - 1, 60, 18); // Lewa strefa CRGB leftColor = colorZoneLeft; leftColor.nscale8_video(brightness); if (warnLeft && !blinkPhase) leftColor = CRGB::Black; leds[i] = leftColor; // Prawa strefa (lustrzanie) CRGB rightColor = colorZoneRight; rightColor.nscale8_video(brightness); if (warnRight && !blinkPhase) rightColor = CRGB::Black; leds[LED_COUNT - 1 - i] = rightColor; } } /** * Rysuje paski wyników w centrum taśmy. * * @param blinkLastPoint czy ostatni punkt ma migać * @param blinkSide który gracz (0=lewy, 1=prawy, -1=nikt) * @param blinkVisible czy aktualnie widoczny (faza migania) */ void drawScore(bool blinkLastPoint = false, int blinkSide = -1, bool blinkVisible = true) { int centerLeft = LED_COUNT / 2 - 1; // skrajna lewa pozycja wyniku int centerRight = LED_COUNT / 2; // skrajna prawa pozycja wyniku // Wynik gracza lewego — rośnie w lewo od środka for (int p = 0; p < pointsLeft; p++) { bool visible = !(blinkLastPoint && blinkSide == 0 && p == pointsLeft - 1 && !blinkVisible); if (!visible) continue; for (int k = 0; k < SCORE_LEDS_PER_POINT; k++) { int idx = centerLeft - (p * SCORE_LEDS_PER_POINT + k); if (idx >= 0 && idx < LED_COUNT) { leds[idx] = colorScoreLeft; } } } // Wynik gracza prawego — rośnie w prawo od środka for (int p = 0; p < pointsRight; p++) { bool visible = !(blinkLastPoint && blinkSide == 1 && p == pointsRight - 1 && !blinkVisible); if (!visible) continue; for (int k = 0; k < SCORE_LEDS_PER_POINT; k++) { int idx = centerRight + (p * SCORE_LEDS_PER_POINT + k); if (idx >= 0 && idx < LED_COUNT) { leds[idx] = colorScoreRight; } } } } /** * Rysuje piłkę z ogonem świetlnym. * [ULP 4] Ogon nie wychodzi poza tablicę — sprawdzamy zakres przed zapisem. * * @param dir kierunek ruchu: +1 = prawo, -1 = lewo * @param pos aktualna pozycja głowy piłki */ void drawBallWithTrail(int dir, int pos) { CRGB head = getBallHeadColor(); // Długość ogona zależna od prędkości int tailLen = 5; if (stepDelayMs <= 10) tailLen = 7; else if (stepDelayMs <= 14) tailLen = 6; // Współczynniki jasności kolejnych diod ogona (indeks 0 = głowa) static const uint8_t tailScale[] = {255, 160, 96, 52, 28, 14, 8}; for (int i = 0; i < tailLen; i++) { int p = pos - dir * i; // ogon rośnie w kierunku przeciwnym do ruchu // [ULP 4] Pomiń pozycje poza taśmą if (p < 0 || p >= LED_COUNT) continue; CRGB c = head; c.nscale8_video(tailScale[i]); leds[p] += c; // addytywne mieszanie — ogon nie kasuje wyniku } } /** Rysuje tylko głowę piłki (do animacji podania). */ void drawBallHeadOnly(int pos) { if (pos >= 0 && pos < LED_COUNT) { leds[pos] = getBallHeadColor(); } } /** Zatwierdza bufor i wyświetla klatkę na taśmie. */ void showFrame() { FastLED.show(); } // ===== Kompletne klatki renderowania ===== /** Ekran serwisu: strefy + wynik + opcjonalna piłka (miga). */ void renderServe(bool showBall) { clearLeds(); drawZones(); drawScore(); if (showBall) drawBallHeadOnly(ballPos); showFrame(); } /** Klatka gry w ruchu. */ void renderGameField(int dir, bool warnLeft = false, bool warnRight = false, bool warnBlink = true) { clearLeds(); drawZones(warnLeft, warnRight, warnBlink); drawScore(); drawBallWithTrail(dir, ballPos); showFrame(); } /** Animacja zdobycia punktu — migający wynik. */ void renderPointBlink(int side, bool visible) { clearLeds(); drawZones(); drawScore(true, side, visible); showFrame(); } /** Animacja IDLE / demo. */ void renderIdle() { // Tło: tęczowe przesuniecie o małej jasności for (int i = 0; i < LED_COUNT; i++) { leds[i] = CHSV((uint8_t)(idleHue >> 2) + (uint8_t)(i * 2), 255, 18); } // Wynik (zerowy, ale miejsce jest oznaczone) drawScore(); // Demo-piłka z małym ogonem if (idleBallPos >= 0 && idleBallPos < LED_COUNT) leds[idleBallPos] += CRGB(255, 120, 0); int p1 = idleBallPos - idleBallDir; int p2 = idleBallPos - 2 * idleBallDir; if (p1 >= 0 && p1 < LED_COUNT) leds[p1] += CRGB(80, 40, 0); if (p2 >= 0 && p2 < LED_COUNT) leds[p2] += CRGB(25, 10, 0); showFrame(); } /** Animacja ekranu wygranej — pulsujące wypełnienie połowy taśmy. */ void renderWin(bool rightSideWins) { clearLeds(); int start = rightSideWins ? LED_COUNT / 2 : 0; int end = rightSideWins ? LED_COUNT : LED_COUNT / 2; uint8_t pulse = sin8(winAnimPhase); CRGB base = rightSideWins ? CRGB(0, 255, 0) : CRGB(255, 0, 0); for (int i = start; i < end; i++) { int center = (start + end) / 2; int dist = abs(i - center); int v = (int)pulse - dist * 6; v = clampValue(v, 10, 255); CRGB c = base; c.nscale8_video((uint8_t)v); leds[i] = c; } drawScore(); showFrame(); } // ====================================================== // Logika gry — mechanika // ====================================================== /** Pełny reset meczu (wyniki, prędkość, combo). */ void resetMatch() { pointsLeft = 0; pointsRight = 0; rallyCount = 0; stepDelayMs = START_SPEED_MS; currentHitQuality = HIT_NORMAL; } /** Reset prędkości i combo po punkcie (wyniki zachowane). */ void resetRallySpeed() { rallyCount = 0; stepDelayMs = START_SPEED_MS; currentHitQuality = HIT_NORMAL; } /** Ocena jakości odbicia gracza lewego (pozycja 0 = ideał). */ HitQuality evaluateHitQualityLeft() { if (ballPos <= 1) return HIT_PERFECT; if (ballPos <= ZONE_SIZE / 3) return HIT_GOOD; return HIT_NORMAL; } /** Ocena jakości odbicia gracza prawego (pozycja LED_COUNT-1 = ideał). */ HitQuality evaluateHitQualityRight() { int distFromEdge = (LED_COUNT - 1) - ballPos; if (distFromEdge <= 1) return HIT_PERFECT; if (distFromEdge <= ZONE_SIZE / 3) return HIT_GOOD; return HIT_NORMAL; } /** * Oblicza i aplikuje przyspieszenie po odbiciu. * Prędkość rośnie wraz z ilością wymian i jakością odbicia. */ void applyReturnSpeedBonus(HitQuality quality) { rallyCount++; // Bazowe przyspieszenie co RALLY_ACCEL_EVERY wymian int accel = 1; if ((rallyCount % RALLY_ACCEL_EVERY) == 0) accel++; // Bonus za jakość odbicia switch (quality) { case HIT_PERFECT: accel += 3; break; case HIT_GOOD: accel += 2; break; case HIT_NORMAL: accel += 1; break; } // stepDelayMs maleje → piłka szybsza int newDelay = (int)stepDelayMs - accel; stepDelayMs = (uint16_t)clampValue(newDelay, (int)MIN_SPEED_MS, (int)START_SPEED_MS); currentHitQuality = quality; } /** Obsługa prawidłowego odbicia przez gracza lewego. */ void handleLeftHit() { HitQuality q = evaluateHitQualityLeft(); applyReturnSpeedBonus(q); if (q == HIT_PERFECT) playToneMs(NOTE_A5, 65); else if (q == HIT_GOOD) playToneMs(NOTE_D5, 55); else playToneMs(NOTE_G4, 45); } /** Obsługa prawidłowego odbicia przez gracza prawego. */ void handleRightHit() { HitQuality q = evaluateHitQualityRight(); applyReturnSpeedBonus(q); if (q == HIT_PERFECT) playToneMs(NOTE_A5, 65); else if (q == HIT_GOOD) playToneMs(NOTE_D5, 55); else playToneMs(NOTE_G4, 45); } // ====================================================== // Maszyna stanów — przejścia // ====================================================== /** * Wejście w nowy stan: inicjalizuje zmienne, renderuje pierwszą klatkę * i planuje kolejną. */ void enterState(GameState newState) { state = newState; stateEnteredAt = millis(); cancelFrame(); clearTimeout(); switch (state) { // ---- IDLE ---- case GS_IDLE: resetMatch(); stopWinMelody(); idleHue = 0; idleBallPos = 0; idleBallDir = 1; renderIdle(); scheduleFrame(IDLE_FRAME_MS); break; // ---- SERWIS na starcie (gracz lewy) ---- case GS_START_LEFT: resetRallySpeed(); ballPos = 0; blinkOn = true; renderServe(true); scheduleFrame(SERVE_BLINK_MS); setTimeoutMs(START_TIMEOUT_MS); playToneMs(NOTE_C4, 50); break; // ---- SERWIS na starcie (gracz prawy) ---- case GS_START_RIGHT: resetRallySpeed(); ballPos = LED_COUNT - 1; blinkOn = true; renderServe(true); scheduleFrame(SERVE_BLINK_MS); setTimeoutMs(START_TIMEOUT_MS); playToneMs(NOTE_C4, 50); break; // ---- RUCH: lewo → prawo ---- case GS_MOVE_LR: renderGameField(+1); scheduleFrame(stepDelayMs); break; // ---- RUCH: prawo → lewo ---- case GS_MOVE_RL: renderGameField(-1); scheduleFrame(stepDelayMs); break; // ---- STREFA LEWA (piłka leci w lewo, gracz lewy musi odbić) ---- case GS_ZONE_LEFT: renderGameField(-1, true, false); // podświetl lewą strefę scheduleFrame(stepDelayMs); break; // ---- STREFA PRAWA (piłka leci w prawo, gracz prawy musi odbić) ---- case GS_ZONE_RIGHT: renderGameField(+1, false, true); // podświetl prawą strefę scheduleFrame(stepDelayMs); break; // ---- PUNKT dla gracza lewego ---- case GS_POINT_LEFT: pointBlinkRemaining = 8; blinkOn = false; renderPointBlink(0, blinkOn); scheduleFrame(POINT_BLINK_MS); playToneMs(NOTE_C5, 120); break; // ---- PUNKT dla gracza prawego ---- case GS_POINT_RIGHT: pointBlinkRemaining = 8; blinkOn = false; renderPointBlink(1, blinkOn); scheduleFrame(POINT_BLINK_MS); playToneMs(NOTE_C5, 120); break; // ---- SERWIS po punkcie (gracz lewy) ---- case GS_RESUME_LEFT: resetRallySpeed(); ballPos = 0; blinkOn = true; renderServe(true); scheduleFrame(SERVE_BLINK_MS); setTimeoutMs(RESUME_TIMEOUT_MS); playToneMs(NOTE_F4, 60); break; // ---- SERWIS po punkcie (gracz prawy) ---- case GS_RESUME_RIGHT: resetRallySpeed(); ballPos = LED_COUNT - 1; blinkOn = true; renderServe(true); scheduleFrame(SERVE_BLINK_MS); setTimeoutMs(RESUME_TIMEOUT_MS); playToneMs(NOTE_F4, 60); break; // ---- WYGRANA gracza lewego ---- case GS_WIN_LEFT: winAnimPhase = 0; renderWin(false); scheduleFrame(WIN_FRAME_MS); setTimeoutMs(WIN_IDLE_TIMEOUT_MS); // [ULP 1] startWinMelody(); break; // ---- WYGRANA gracza prawego ---- case GS_WIN_RIGHT: winAnimPhase = 0; renderWin(true); scheduleFrame(WIN_FRAME_MS); setTimeoutMs(WIN_IDLE_TIMEOUT_MS); // [ULP 1] startWinMelody(); break; } } /** Gracz lewy zdobył punkt — aktualizuje wynik i przechodzi do właściwego stanu. */ void scoreLeft() { pointsLeft++; enterState(pointsLeft >= WIN_POINTS ? GS_WIN_LEFT : GS_POINT_LEFT); } /** Gracz prawy zdobył punkt. */ void scoreRight() { pointsRight++; enterState(pointsRight >= WIN_POINTS ? GS_WIN_RIGHT : GS_POINT_RIGHT); } // ====================================================== // Maszyna stanów — aktualizacje (update) // Każda funkcja obsługuje jeden stan: przyciski + klatki. // ====================================================== void updateIdle(uint32_t now, bool leftPress, bool rightPress) { if (leftPress) { enterState(GS_START_LEFT); return; } if (rightPress) { enterState(GS_START_RIGHT); return; } if (isFrameReady(now)) { idleHue += 24; idleBallPos += idleBallDir; // Odbicia od ścian w animacji demo if (idleBallPos <= 0) { idleBallPos = 0; idleBallDir = 1; } else if (idleBallPos >= LED_COUNT-1) { idleBallPos = LED_COUNT-1; idleBallDir = -1; } renderIdle(); scheduleFrame(IDLE_FRAME_MS); } } void updateStartLeft(uint32_t now, bool leftPress) { if (leftPress) { currentHitQuality = HIT_NORMAL; playToneMs(NOTE_F4, 60); enterState(GS_MOVE_LR); return; } if (isTimeoutReached(now)) { enterState(GS_IDLE); return; } if (isFrameReady(now)) { blinkOn = !blinkOn; renderServe(blinkOn); scheduleFrame(SERVE_BLINK_MS); } } void updateStartRight(uint32_t now, bool rightPress) { if (rightPress) { currentHitQuality = HIT_NORMAL; playToneMs(NOTE_F4, 60); enterState(GS_MOVE_RL); return; } if (isTimeoutReached(now)) { enterState(GS_IDLE); return; } if (isFrameReady(now)) { blinkOn = !blinkOn; renderServe(blinkOn); scheduleFrame(SERVE_BLINK_MS); } } void updateMoveLR(uint32_t now) { if (isFrameReady(now)) { ballPos++; if (ballPos >= LED_COUNT - ZONE_SIZE) { enterState(GS_ZONE_RIGHT); return; } renderGameField(+1); scheduleFrame(stepDelayMs); } } void updateMoveRL(uint32_t now) { if (isFrameReady(now)) { ballPos--; if (ballPos < ZONE_SIZE) { enterState(GS_ZONE_LEFT); return; } renderGameField(-1); scheduleFrame(stepDelayMs); } } /** * Strefa lewa — piłka dochodzi do lewej ściany. * Gracz lewy musi nacisnąć przycisk zanim piłka "wypadnie". * [ULP 2] Strefa miga, gdy piłka jest w strefie już > ZONE_WARN_AFTER_MS. */ void updateZoneLeft(uint32_t now, bool leftPress) { if (leftPress) { handleLeftHit(); enterState(GS_MOVE_LR); return; } if (isFrameReady(now)) { // Czy strefa powinna migać (ostrzeżenie)? bool warn = ((now - stateEnteredAt) > ZONE_WARN_AFTER_MS); if (ballPos <= 0) { // Piłka dotarła do ściany bez odbicia — punkt dla prawego scoreRight(); return; } ballPos--; // Miga strefa co 2 klatki (na bazie parzystości stepDelayMs licznika) // Prosty efekt: blinkOn oparty na aktualnym czasie bool warnBlink = (now / (stepDelayMs * 2)) % 2 == 0; renderGameField(-1, warn, false, warnBlink); scheduleFrame(stepDelayMs); } } /** * Strefa prawa — analogicznie jak lewa. */ void updateZoneRight(uint32_t now, bool rightPress) { if (rightPress) { handleRightHit(); enterState(GS_MOVE_RL); return; } if (isFrameReady(now)) { bool warn = ((now - stateEnteredAt) > ZONE_WARN_AFTER_MS); if (ballPos >= LED_COUNT - 1) { scoreLeft(); return; } ballPos++; bool warnBlink = (now / (stepDelayMs * 2)) % 2 == 0; renderGameField(+1, false, warn, warnBlink); scheduleFrame(stepDelayMs); } } /** * Animacja punktu lewego. * [FIX 6] Oba przyciski mogą przyspieszyć przejście do serwisu. */ void updatePointLeft(uint32_t now, bool leftPress, bool rightPress) { // [FIX 6] Dowolny przycisk może pominąć animację if (leftPress || rightPress) { enterState(GS_RESUME_LEFT); return; } if (isFrameReady(now)) { blinkOn = !blinkOn; renderPointBlink(0, blinkOn); if (pointBlinkRemaining > 0) pointBlinkRemaining--; if (pointBlinkRemaining == 0) { enterState(GS_RESUME_LEFT); } else { scheduleFrame(POINT_BLINK_MS); } } } /** * Animacja punktu prawego. * [FIX 6] Oba przyciski mogą przyspieszyć przejście. */ void updatePointRight(uint32_t now, bool leftPress, bool rightPress) { if (leftPress || rightPress) { enterState(GS_RESUME_RIGHT); return; } if (isFrameReady(now)) { blinkOn = !blinkOn; renderPointBlink(1, blinkOn); if (pointBlinkRemaining > 0) pointBlinkRemaining--; if (pointBlinkRemaining == 0) { enterState(GS_RESUME_RIGHT); } else { scheduleFrame(POINT_BLINK_MS); } } } void updateResumeLeft(uint32_t now, bool leftPress) { // Timeout = automatyczny serwis jeśli gracz nie reaguje if (leftPress || isTimeoutReached(now)) { currentHitQuality = HIT_NORMAL; playToneMs(NOTE_F4, 60); enterState(GS_MOVE_LR); return; } if (isFrameReady(now)) { blinkOn = !blinkOn; renderServe(blinkOn); scheduleFrame(SERVE_BLINK_MS); } } void updateResumeRight(uint32_t now, bool rightPress) { if (rightPress || isTimeoutReached(now)) { currentHitQuality = HIT_NORMAL; playToneMs(NOTE_F4, 60); enterState(GS_MOVE_RL); return; } if (isFrameReady(now)) { blinkOn = !blinkOn; renderServe(blinkOn); scheduleFrame(SERVE_BLINK_MS); } } /** * Ekran wygranej gracza lewego. * [FIX 1] Przed nową grą wywoływane jest resetMatch(). * [ULP 1] Po WIN_IDLE_TIMEOUT_MS wraca automatycznie do IDLE. */ void updateWinLeft(uint32_t now, bool leftPress, bool rightPress) { // [ULP 1] Timeout = powrót do IDLE if (isTimeoutReached(now)) { enterState(GS_IDLE); return; } // Nowa gra — [FIX 1] reset wyniku przed przejściem if (leftPress || rightPress) { resetMatch(); enterState(leftPress ? GS_START_LEFT : GS_START_RIGHT); return; } if (isFrameReady(now)) { winAnimPhase += 10; renderWin(false); scheduleFrame(WIN_FRAME_MS); } } /** * Ekran wygranej gracza prawego (analogicznie jak lewy). */ void updateWinRight(uint32_t now, bool leftPress, bool rightPress) { if (isTimeoutReached(now)) { enterState(GS_IDLE); return; } if (leftPress || rightPress) { resetMatch(); // [FIX 1] enterState(leftPress ? GS_START_LEFT : GS_START_RIGHT); return; } if (isFrameReady(now)) { winAnimPhase += 10; renderWin(true); scheduleFrame(WIN_FRAME_MS); } } // ====================================================== // Setup i pętla główna // ====================================================== void setup() { Serial.begin(115200); delay(100); // Przyciski btnLeft.begin(BTN_LEFT_PIN); btnRight.begin(BTN_RIGHT_PIN); // Taśma LED FastLED.addLeds(leds, LED_COUNT); FastLED.setBrightness(BRIGHTNESS); FastLED.setMaxPowerInVoltsAndMilliamps(5, LIMIT_CURRENT_MA); FastLED.clear(true); // Buzzer — [FIX 3] nowe API dla ESP32 Arduino Core ≥ 3.x #if SOUND_ENABLED // Jeśli używasz starszego Core (< 3.0), zamień na: // ledcSetup(BUZZER_CHANNEL, BUZZER_BASE_FREQ, BUZZER_RESOLUTION); // ledcAttachPin(BUZZER_PIN, BUZZER_CHANNEL); ledcAttachChannel(BUZZER_PIN, BUZZER_BASE_FREQ, BUZZER_RESOLUTION, BUZZER_CHANNEL); ledcWrite(BUZZER_CHANNEL, 0); // wycisz na starcie #endif Serial.println(F("1D Pong v2.0 — gotowy!")); enterState(GS_IDLE); } void loop() { uint32_t now = millis(); // 1. Odczyt i debouncing przycisków btnLeft.update(now); btnRight.update(now); bool leftPress = btnLeft.consumePress(); bool rightPress = btnRight.consumePress(); // 2. Obsługa audio (sequencer melodii i timing tonów) updateAudio(now); // 3. Maszyna stanów gry switch (state) { case GS_IDLE: updateIdle(now, leftPress, rightPress); break; case GS_START_LEFT: updateStartLeft(now, leftPress); break; case GS_START_RIGHT: updateStartRight(now, rightPress); break; case GS_MOVE_LR: updateMoveLR(now); break; case GS_MOVE_RL: updateMoveRL(now); break; case GS_ZONE_LEFT: updateZoneLeft(now, leftPress); break; case GS_ZONE_RIGHT: updateZoneRight(now, rightPress); break; case GS_POINT_LEFT: updatePointLeft(now, leftPress, rightPress); break; case GS_POINT_RIGHT: updatePointRight(now, leftPress, rightPress); break; case GS_RESUME_LEFT: updateResumeLeft(now, leftPress); break; case GS_RESUME_RIGHT: updateResumeRight(now, rightPress); break; case GS_WIN_LEFT: updateWinLeft(now, leftPress, rightPress); break; case GS_WIN_RIGHT: updateWinRight(now, leftPress, rightPress); break; default: enterState(GS_IDLE); break; } }