W pełni grywalna wersja klasycznej gry zręcznościowej Breakout, zbudowana na mikrokontrolerze Arduino i dedykowanym shieldzie (plug-and-play). Zastosowanie zintegrowanej nakładki z ekranem OLED I2C (128x64 px) oraz wbudowanymi przyciskami.
1. Płytka Arduino UNO
2. Nakładka SIC shield
3. Przewód do programowania i zasilania płytki
Projekt to w pełni grywalna wersja kultowej gry zręcznościowej Breakout, zbudowana na bazie mikrokontrolera Arduino oraz dedykowanego modułu rozszerzeń (shielda). Zastosowanie gotowej nakładki typu plug-and-play, łączącej czytelny wyświetlacz OLED 128x64 piksele (komunikujący się po magistrali I2C) z zestawem zintegrowanych przycisków, pozwoliło nam wyeliminować plątaninę przewodów i skupić się w stu procentach na zaawansowanej architekturze oprogramowania. Do sterowania rozgrywką oraz intuicyjnej nawigacji po menu wykorzystaliśmy przyciski shielda, których stany odczytujemy dzięki programowej aktywacji wbudowanych w mikrokontroler rezystorów podciągających, korzystając z trybu INPUT_PULLUP. Za generowanie grafiki odpowiada biblioteka Adafruit GFX, która w oparciu o nasz autorski kod płynnie przerysowuje klatki gry, odświeżając pozycję dynamicznej piłki, ruchomej paletki i statycznych klocków. Rozgrywka opiera się na szybkiej pętli głównej, która w czasie rzeczywistym oblicza wektory prędkości piłki oraz na bieżąco sprawdza skomplikowane warunki kolizji z poszczególnymi obiektami na planszy. Aby uniknąć powszechnego w grach 2D błędu polegającego na wibrowaniu i zacinaniu się piłki w krawędziach ekranu, zaimplementowaliśmy specjalny algorytm fizyki wypychający obiekt ze strefy kolizji i sztucznie zaginający idealnie pionowe odbicia. Gra została wyposażona w górny pasek stanu (HUD), który wyświetla zdobyte punkty oraz odlicza czas rozgrywki przy użyciu asynchronicznej funkcji millis, nie blokując przy tym szybkiego odczytu wejść cyfrowych sterujących paletką. Użytkownicy mogą dostosować wyzwanie do swoich umiejętności, wybierając na ekranie startowym jeden z trzech poziomów trudności (Łatwy, Średni, Trudny), które bezpośrednio wpływają na bazową prędkość poruszania się piłki w układzie współrzędnych. Zaimplementowaliśmy również logiczną blokadę obszaru początkowego respawnu, dzięki czemu nawet na najtrudniejszym poziomie gracz ma zawsze fizyczną szansę na odbicie kulki po starcie. Całość wrażeń wizualnych dopełniają starannie zaprogramowane detale w stylu retro, takie jak pulsujące obwoluty napisów, funkcja aktywnej pauzy w trakcie gry oraz okno podsumowujące końcowy wynik z opcją błyskawicznego restartu planszy.
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SH110X.h>
// Ustawienia wyświetlacza
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
#define SCREEN_ADDRESS 0x3C
Adafruit_SH1106G display = Adafruit_SH1106G(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
// Piny przycisków
#define PIN_UP 7
#define PIN_RIGHT 8
#define PIN_DOWN 9
#define PIN_LEFT 10
#define PIN_BTN1 5
#define PIN_BTN2 4
// Parametry gry
#define PADDLE_WIDTH 24
#define PADDLE_HEIGHT 3
#define BALL_SIZE 3
#define BRICK_ROWS 4
#define BRICK_COLS 8
#define BRICK_WIDTH 14
#define BRICK_HEIGHT 5
#define BRICK_SPACING 2
#define STATUS_BAR_HEIGHT 10
// Zmienne gry
float ballX, ballY;
float ballDX, ballDY;
int paddleX;
int score = 0;
bool bricks[BRICK_ROWS][BRICK_COLS];
int gameState = 0;
int difficulty = 0; // 0 - Łatwy, 1 - Średni, 2 - Trudny
float currentSpeed = 1.2;
// Zmienne czasu i animacji
int gameSeconds = 0;
unsigned long lastMillis = 0;
unsigned long blinkTimer = 0;
bool blinkState = true;
void setup() {
Serial.begin(9600);
randomSeed(analogRead(A0));
pinMode(PIN_UP, INPUT_PULLUP);
pinMode(PIN_RIGHT, INPUT_PULLUP);
pinMode(PIN_DOWN, INPUT_PULLUP);
pinMode(PIN_LEFT, INPUT_PULLUP);
pinMode(PIN_BTN1, INPUT_PULLUP);
pinMode(PIN_BTN2, INPUT_PULLUP);
if(!display.begin(SCREEN_ADDRESS, true)) {
for(;;);
}
display.setRotation(2);
display.clearDisplay();
showStartScreen(blinkState);
}
void loop() {
if (gameState == 0) {
if (millis() - blinkTimer > 400) {
blinkTimer = millis();
blinkState = !blinkState;
showStartScreen(blinkState);
}
if (digitalRead(PIN_BTN1) == LOW || digitalRead(PIN_BTN2) == LOW) {
gameState = 5;
showDifficultyScreen();
delay(300);
}
}
else if (gameState == 5) {
bool changed = false;
if (digitalRead(PIN_LEFT) == LOW && difficulty > 0) {
difficulty--;
changed = true;
}
if (digitalRead(PIN_RIGHT) == LOW && difficulty < 2) {
difficulty++;
changed = true;
}
if (changed) {
showDifficultyScreen();
delay(200);
}
if (digitalRead(PIN_BTN1) == LOW) {
if (difficulty == 0) currentSpeed = 1.2;
else if (difficulty == 1) currentSpeed = 2.2;
else currentSpeed = 3.2;
resetGame();
gameState = 1;
delay(300);
}
}
else if (gameState == 1) {
if (digitalRead(PIN_BTN1) == LOW) {
gameState = 4;
showPauseScreen();
delay(300);
}
else if (digitalRead(PIN_BTN2) == LOW) {
gameState = 0;
blinkState = true;
showStartScreen(blinkState);
delay(300);
}
else {
if (millis() - lastMillis >= 1000) {
gameSeconds++;
lastMillis = millis();
}
updateGame();
if (gameState == 1) {
drawGame();
}
delay(15);
}
}
else if (gameState == 2 || gameState == 3) {
if (millis() - blinkTimer > 400) {
blinkTimer = millis();
blinkState = !blinkState;
if (gameState == 2) showEndScreen("KONIEC GRY", false, blinkState);
else showEndScreen("WYGRANA!", true, blinkState);
}
if (digitalRead(PIN_BTN1) == LOW) {
resetGame();
gameState = 1;
delay(300);
}
else if (digitalRead(PIN_BTN2) == LOW) {
gameState = 0;
blinkState = true;
showStartScreen(blinkState);
delay(300);
}
}
else if (gameState == 4) {
if (digitalRead(PIN_BTN1) == LOW) {
gameState = 1;
lastMillis = millis();
delay(300);
}
else if (digitalRead(PIN_BTN2) == LOW) {
gameState = 0;
blinkState = true;
showStartScreen(blinkState);
delay(300);
}
}
}
void resetGame() {
score = 0;
gameSeconds = 0;
lastMillis = millis();
paddleX = (SCREEN_WIDTH - PADDLE_WIDTH) / 2;
ballX = random(SCREEN_WIDTH / 2 - 20, SCREEN_WIDTH / 2 + 20);
ballY = SCREEN_HEIGHT - 18;
if (random(0, 2) == 0) ballDX = currentSpeed;
else ballDX = -currentSpeed;
ballDY = -currentSpeed;
for (int r = 0; r < BRICK_ROWS; r++) {
for (int c = 0; c < BRICK_COLS; c++) {
bricks[r][c] = true;
}
}
}
void updateGame() {
if (digitalRead(PIN_LEFT) == LOW) paddleX -= 3;
if (digitalRead(PIN_RIGHT) == LOW) paddleX += 3;
if (paddleX < 0) paddleX = 0;
if (paddleX > SCREEN_WIDTH - PADDLE_WIDTH) paddleX = SCREEN_WIDTH - PADDLE_WIDTH;
ballX += ballDX;
ballY += ballDY;
// -- POPRAWKA: Wypychanie ze ścian bocznych i anty-pion --
if (ballX <= 0) {
ballX = 0; // Wypchnięcie
if (ballDX < 0) ballDX = -ballDX; // Odbicie w prawo
if (ballDX < 0.3) ballDX = 0.3; // Uniemożliwienie idealnego pionu
}
else if (ballX >= SCREEN_WIDTH - BALL_SIZE) {
ballX = SCREEN_WIDTH - BALL_SIZE; // Wypchnięcie
if (ballDX > 0) ballDX = -ballDX; // Odbicie w lewo
if (ballDX > -0.3) ballDX = -0.3; // Uniemożliwienie idealnego pionu
}
// -- POPRAWKA: Wypychanie z sufitu --
if (ballY <= STATUS_BAR_HEIGHT) {
ballY = STATUS_BAR_HEIGHT;
if (ballDY < 0) ballDY = -ballDY; // Wymuszenie lotu w dół
}
if (ballY >= SCREEN_HEIGHT) {
gameState = 2;
blinkState = true;
blinkTimer = millis();
showEndScreen("KONIEC GRY", false, blinkState);
return;
}
// Kolizja z paletką
if (ballY + BALL_SIZE >= SCREEN_HEIGHT - PADDLE_HEIGHT - 2 &&
ballX + BALL_SIZE >= paddleX &&
ballX <= paddleX + PADDLE_WIDTH) {
ballY = SCREEN_HEIGHT - PADDLE_HEIGHT - BALL_SIZE - 2;
float hitPoint = (ballX - paddleX) / PADDLE_WIDTH;
ballDX = (hitPoint - 0.5) * (currentSpeed * 2.0);
// -- POPRAWKA: Jeśli piłka uderzy idealnie w środek paletki, lekko ją zaginamy --
if (ballDX >= 0 && ballDX < 0.3) ballDX = 0.3;
if (ballDX < 0 && ballDX > -0.3) ballDX = -0.3;
ballDY = -currentSpeed;
}
bool winCheck = true;
for (int r = 0; r < BRICK_ROWS; r++) {
for (int c = 0; c < BRICK_COLS; c++) {
if (bricks[r][c]) {
winCheck = false;
int brickX = c * (BRICK_WIDTH + BRICK_SPACING) + 2;
int brickY = r * (BRICK_HEIGHT + BRICK_SPACING) + STATUS_BAR_HEIGHT + 2;
if (ballX + BALL_SIZE >= brickX && ballX <= brickX + BRICK_WIDTH &&
ballY + BALL_SIZE >= brickY && ballY <= brickY + BRICK_HEIGHT) {
bricks[r][c] = false;
score += 10;
ballDY = -ballDY;
goto endCollision;
}
}
}
}
endCollision:
if (winCheck) {
gameState = 3;
blinkState = true;
blinkTimer = millis();
showEndScreen("WYGRANA!", true, blinkState);
}
}
void drawGame() {
display.clearDisplay();
display.drawLine(0, STATUS_BAR_HEIGHT, SCREEN_WIDTH, STATUS_BAR_HEIGHT, SH110X_WHITE);
display.setTextSize(1);
display.setCursor(2, 1);
display.print(F("Pkt:"));
display.print(score);
display.setCursor(85, 1);
int m = gameSeconds / 60;
int s = gameSeconds % 60;
if (m < 10) display.print("0");
display.print(m);
display.print(":");
if (s < 10) display.print("0");
display.print(s);
display.fillRect(paddleX, SCREEN_HEIGHT - PADDLE_HEIGHT - 2, PADDLE_WIDTH, PADDLE_HEIGHT, SH110X_WHITE);
display.fillRect((int)ballX, (int)ballY, BALL_SIZE, BALL_SIZE, SH110X_WHITE);
for (int r = 0; r < BRICK_ROWS; r++) {
for (int c = 0; c < BRICK_COLS; c++) {
if (bricks[r][c]) {
int brickX = c * (BRICK_WIDTH + BRICK_SPACING) + 2;
int brickY = r * (BRICK_HEIGHT + BRICK_SPACING) + STATUS_BAR_HEIGHT + 2;
display.fillRect(brickX, brickY, BRICK_WIDTH, BRICK_HEIGHT, SH110X_WHITE);
}
}
}
display.display();
}
void showStartScreen(bool glowText) {
display.clearDisplay();
display.setTextSize(2);
int x = 16;
int y = 10;
if (glowText) {
display.setTextColor(SH110X_WHITE);
display.setCursor(x - 1, y); display.print(F("BREAKOUT"));
display.setCursor(x + 1, y); display.print(F("BREAKOUT"));
display.setCursor(x, y - 1); display.print(F("BREAKOUT"));
display.setCursor(x, y + 1); display.print(F("BREAKOUT"));
display.setTextColor(SH110X_BLACK);
display.setCursor(x, y); display.print(F("BREAKOUT"));
} else {
display.setTextColor(SH110X_WHITE);
display.setCursor(x, y); display.print(F("BREAKOUT"));
}
display.setTextColor(SH110X_WHITE);
display.setTextSize(1);
display.setCursor(22, 38);
display.println(F("ARDUINO EDITION"));
display.setCursor(15, 52);
display.println(F("Wcisnij 5 aby grac"));
display.display();
}
void showDifficultyScreen() {
display.clearDisplay();
display.setTextColor(SH110X_WHITE);
display.setTextSize(1);
display.setCursor(20, 10);
display.println(F("Wybierz poziom:"));
// LATWY
if (difficulty == 0) {
display.fillRect(2, 30, 38, 15, SH110X_WHITE);
display.setTextColor(SH110X_BLACK);
} else {
display.drawRect(2, 30, 38, 15, SH110X_WHITE);
display.setTextColor(SH110X_WHITE);
}
display.setCursor(7, 34);
display.print(F("LATWY"));
// SREDNI
if (difficulty == 1) {
display.fillRect(42, 30, 42, 15, SH110X_WHITE);
display.setTextColor(SH110X_BLACK);
} else {
display.drawRect(42, 30, 42, 15, SH110X_WHITE);
display.setTextColor(SH110X_WHITE);
}
display.setCursor(46, 34);
display.print(F("SREDNI"));
// TRUDNY
if (difficulty == 2) {
display.fillRect(86, 30, 40, 15, SH110X_WHITE);
display.setTextColor(SH110X_BLACK);
} else {
display.drawRect(86, 30, 40, 15, SH110X_WHITE);
display.setTextColor(SH110X_WHITE);
}
display.setCursor(89, 34);
display.print(F("TRUDNY"));
display.setTextColor(SH110X_WHITE);
display.setCursor(15, 55);
display.print(F("<- L/P -> 5: Start"));
display.display();
}
void showEndScreen(const char* title, bool isWin, bool glowText) {
display.clearDisplay();
display.setTextSize(2);
int x = isWin ? 16 : 4;
int y = 10;
if (glowText) {
display.setTextColor(SH110X_WHITE);
display.setCursor(x - 1, y); display.print(title);
display.setCursor(x + 1, y); display.print(title);
display.setCursor(x, y - 1); display.print(title);
display.setCursor(x, y + 1); display.print(title);
display.setTextColor(SH110X_BLACK);
display.setCursor(x, y); display.print(title);
} else {
display.setTextColor(SH110X_WHITE);
display.setCursor(x, y); display.print(title);
}
display.setTextColor(SH110X_WHITE);
display.setTextSize(1);
display.setCursor(10, 38);
display.print(F("PKT: "));
display.print(score);
display.print(F(" CZAS: "));
display.print(gameSeconds);
display.print(F("s"));
display.setCursor(10, 55);
display.println(F("5: Ponow | 4: Menu"));
display.display();
}
void showPauseScreen() {
display.fillRect(34, 20, 60, 24, SH110X_BLACK);
display.drawRect(34, 20, 60, 24, SH110X_WHITE);
display.setTextColor(SH110X_WHITE);
display.setTextSize(1);
display.setCursor(48, 28);
display.println(F("PAUZA"));
display.display();
}