o.O wtFAQ – We Tinker, Fix and Question
◀ 2.8. OLED ekran3. Arduino programiranje ▶

2.9. OLED i igra
Stvaranje igre sa fizikalnim zakonima

Igranje na OLED ekranu

OLED ekran
U ovoj radionici spajamo Arduino, OLED ekran 128 × 64 i tipku kako bismo napravili jednostavnu igricu inspiriranu naslovima poput Geometry Dasha, ali prilagođenu početnicima i raspoloživom hardveru.

Umjesto da odmah napravimo kompletnu igru, ići ćemo korak po korak i proučiti kako se crtaju objekti na ekranu, kako Arduino razmišlja u petlji programa i kako se ponašanje lika može opisati jednostavnom fizikom.

Prva vježba bavi se glavnim likom i njegovim kretanjem. Istražujemo tri osnovne ideje: lik stoji na tlu, gravitacija ga vuče prema dolje, a pritiskom na tipku dobiva početnu silu prema gore pa napravi skok. Takav model koristi i mnogo jednostavnih OLED igara s jednim gumbom, gdje je jezgra igrivosti upravo odnos između gravitacije, brzine i pravovremenog pritiska tipke.

Važno je razumjeti da Arduino ne zna što je skok sam od sebe. Mi u programu spremamo visinu lika i njegovu vertikalnu brzinu; u svakom prolazu kroz loop() brzinu malo povećamo prema dolje zbog gravitacije, a kad je tipka pritisnuta, brzinu postavimo u negativan smjer kako bi lik krenuo prema gore. To je vrlo sličan obrazac kao u jednostavnim jump igrama za SSD1306 OLED zaslone, gdje se položaj lika računa iz varijabli poput y, velocity i gravity.

Usput učimo i jednu važnu praktičnu stvar: tipke nisu idealne i često zatrepere pri pritisku, pa je dobro koristiti stabilno očitanje ili barem jednostavni debounce pristup. Za prototip s jednom tipkom često je dovoljno koristiti INPUT_PULLUP i jednostavnu logiku za detekciju pritiska, dok se za ozbiljnije verzije preporučuje millis() debounce umjesto blokirajućeg delay() pristupa.

Na kraju ove vježbe nećemo još imati punu igru, ali ćemo imati najvažniji temelj: lik koji miruje na podlozi, skače kad pritisnemo tipku i ponovno pada pod utjecajem gravitacije. Kad to radi stabilno, u sljedećim koracima lako dodajemo prepreke, bodove, sudare i brzinu igre.

Vježba 1: Fizikalne osnove glavnog lika

Spoji komponente prema ovoj shemi:

spajanje

Arduino program

Ovaj primjer koristi OLED 128 × 64 sa SSD1306 driverom, Adafruit_GFX stil crtanja i tipku spojenu na pin 2 s internim pull-up otpornikom, što znači da je tipka pritisnuta kad očitanje postane LOW.

// C++
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
#define OLED_ADDR 0x3C

#define BUTTON_PIN 2

Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

// Fizika lika
const int groundY = 54;      // gdje je "pod"
const int playerX = 18;
const int playerSize = 8;

float playerY = groundY - playerSize;
float velocityY = 0.0;

const float gravity = 0.45;
const float jumpStrength = -4.8;

// Za jednostavan debounce / detekciju novog pritiska
bool lastButtonState = HIGH;

unsigned long lastFrameTime = 0;
const unsigned long frameInterval = 20; // oko 50 FPS

void drawScene() {
  display.clearDisplay();

  // Naslov vježbe
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);
  display.setCursor(0, 0);
  display.print("Vjezba 1: Skok");

  // Tlo
  display.drawLine(0, groundY, SCREEN_WIDTH - 1, groundY, SSD1306_WHITE);

  // Lik
  display.fillRect(playerX, (int)playerY, playerSize, playerSize, SSD1306_WHITE);

  // Mala informacija o brzini
  display.setCursor(0, 10);
  display.print("vY: ");
  display.print(velocityY, 1);

  display.display();
}

void setup() {
  pinMode(BUTTON_PIN, INPUT_PULLUP);

  if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
    while (true) {
      // ako OLED ne radi, ostani ovdje
    }
  }

  display.clearDisplay();
  display.display();
}

void loop() {
  unsigned long now = millis();
  if (now - lastFrameTime < frameInterval) return;
  lastFrameTime = now;

  bool buttonState = digitalRead(BUTTON_PIN);

  // Novi pritisak tipke: HIGH -> LOW
  bool newPress = (lastButtonState == HIGH && buttonState == LOW);

  // Skok samo ako je lik na tlu
  bool onGround = (playerY >= groundY - playerSize);

  if (newPress && onGround) {
    velocityY = jumpStrength;
  }

  // Gravitacija djeluje stalno
  velocityY += gravity;
  playerY += velocityY;

  // Sudar s tlom
  if (playerY > groundY - playerSize) {
    playerY = groundY - playerSize;
    velocityY = 0;
  }

  drawScene();

  lastButtonState = buttonState;
}

Vježba 2: Kretanje i prepreke

U drugom koraku naš lik više ne hoda po ekranu udesno tako da mijenjamo njegov x, nego ostaje na gotovo istom mjestu, dok se cijeli svijet pomiče ulijevo. To je vrlo čest trik u 2D igrama jer pojednostavljuje logiku: igrač ostaje pregledan, a osjećaj gibanja stvara se pomicanjem tla i prepreka.

Sada dodajemo prepreke na podu koje dolaze s desne strane ekrana. Zadatak igrača je pritisnuti tipku u pravom trenutku kako bi lik preskočio blok ili stupić prije nego što dođe do njega. Time prvi put spajamo tri važne cjeline igre:

  • fiziku skoka,
  • pomicanje svijeta i
  • provjeru sudara.

U programskom smislu svaka prepreka ima barem x koordinatu, širinu i visinu. U svakom prolazu kroz glavnu petlju njezin x smanjujemo za brzinu pomicanja, a kada prepreka izađe lijevo iz ekrana, vraćamo je na desni rub kako bi igra mogla trajati neprekidno.

Ova verzija je još uvijek namjerno jednostavna. Ne radimo još više različitih prepreka, promjenu težine ni napredni score sustav, nego prvo provjeravamo radi li jezgra igre stabilno: skok, scroll ulijevo i pouzdano prepoznavanje sudara.

Arduino program

Ovaj program nadograđuje prethodnu vježbu tako da dodaje jednu prepreku koja se kreće ulijevo, tlo s pomakom i jednostavan game over kad lik udari u prepreku.

// C++
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
#define OLED_ADDR 0x3C
#define BUTTON_PIN 2

Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

// Igrac
const int playerX = 18;
const int playerSize = 8;
const int groundY = 54;

float playerY = groundY - playerSize;
float velocityY = 0;

const float gravity = 0.45;
const float jumpStrength = -4.8;

// Prepreka
int obstacleX = 128;
int obstacleW = 8;
int obstacleH = 12;

// Scroll svijeta
int worldSpeed = 3;
int groundOffset = 0;

// Stanje igre
bool gameOver = false;
bool lastButtonState = HIGH;

unsigned long lastFrameTime = 0;
const unsigned long frameInterval = 20;

bool isNewPress(bool currentState) {
  return (lastButtonState == HIGH && currentState == LOW);
}

bool checkCollision() {
  int playerTop = (int)playerY;
  int playerBottom = playerTop + playerSize;
  int playerLeft = playerX;
  int playerRight = playerX + playerSize;

  int obstacleTop = groundY - obstacleH;
  int obstacleBottom = groundY;
  int obstacleLeft = obstacleX;
  int obstacleRight = obstacleX + obstacleW;

  bool overlapX = playerRight > obstacleLeft && playerLeft < obstacleRight;
  bool overlapY = playerBottom > obstacleTop && playerTop < obstacleBottom;

  return overlapX && overlapY;
}

void resetGame() {
  playerY = groundY - playerSize;
  velocityY = 0;
  obstacleX = SCREEN_WIDTH + 20;
  groundOffset = 0;
  gameOver = false;
}

void updateGame(bool buttonState) {
  bool onGround = (playerY >= groundY - playerSize);

  if (isNewPress(buttonState)) {
    if (gameOver) {
      resetGame();
      return;
    }

    if (onGround) {
      velocityY = jumpStrength;
    }
  }

  // Fizika skoka
  velocityY += gravity;
  playerY += velocityY;

  if (playerY > groundY - playerSize) {
    playerY = groundY - playerSize;
    velocityY = 0;
  }

  // Pomicanje prepreke ulijevo
  obstacleX -= worldSpeed;

  // Pomicanje tla radi dojma kretanja
  groundOffset += worldSpeed;
  if (groundOffset >= 8) {
    groundOffset = 0;
  }

  // Kad prepreka izadje lijevo, vrati je desno
  if (obstacleX + obstacleW < 0) {
    obstacleX = SCREEN_WIDTH + random(20, 50);
  }

  // Sudar
  if (checkCollision()) {
    gameOver = true;
  }
}

void drawGround() {
  display.drawLine(0, groundY, SCREEN_WIDTH - 1, groundY, SSD1306_WHITE);

  for (int x = -groundOffset; x < SCREEN_WIDTH; x += 8) {
    display.drawPixel(x, groundY + 2, SSD1306_WHITE);
  }
}

void drawGame() {
  display.clearDisplay();

  // Naslov / status
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);
  display.setCursor(0, 0);
  display.print("Vjezba 2: Prepreke");

  // Lik
  display.fillRect(playerX, (int)playerY, playerSize, playerSize, SSD1306_WHITE);

  // Prepreka
  display.fillRect(obstacleX, groundY - obstacleH, obstacleW, obstacleH, SSD1306_WHITE);

  // Tlo
  drawGround();

  if (gameOver) {
    display.fillRect(18, 18, 92, 20, SSD1306_BLACK);
    display.drawRect(18, 18, 92, 20, SSD1306_WHITE);
    display.setCursor(28, 24);
    display.print("GAME OVER");
  }

  display.display();
}

void setup() {
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  randomSeed(analogRead(A0));

  if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
    while (true) { }
  }

  display.clearDisplay();
  display.display();

  resetGame();
}

void loop() {
  unsigned long now = millis();
  if (now - lastFrameTime < frameInterval) return;
  lastFrameTime = now;

  bool buttonState = digitalRead(BUTTON_PIN);

  updateGame(buttonState);
  drawGame();

  lastButtonState = buttonState;
}

Vježba 3: Skok sa prepreke

S logičke strane sada uvodimo dvije stvari:

  • onSurface mora značiti na tlu ili na vrhu prepreke, pa pritisak tipke na oba mjesta aktivira skok.
  • prepreka pri resetu dobiva nasumičnu širinu od normalne do maksimalno dvostruke širine, pa se sudar i slijetanje računaju prema trenutačnoj širini prepreke, a ne prema fiksnoj vrijednosti.

Arduino program

Sada igrač može skočiti i s tla i s vrha prepreke, a prepreka pri svakom ponovnom pojavljivanju dobiva širinu od normalObstacleW do 2 * normalObstacleW.

// C++
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
#define OLED_ADDR 0x3C
#define BUTTON_PIN 2

Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

// Igrac
const int playerX = 18;
const int playerSize = 8;
const int groundY = 54;

float playerY = groundY - playerSize;
float prevPlayerY = groundY - playerSize;
float velocityY = 0;

const float gravity = 0.65;
const float jumpStrength = -5.8;

// Prepreka
const int normalObstacleW = 10;
const int maxObstacleW = normalObstacleW * 2;
int obstacleX = 128;
int obstacleW = normalObstacleW;
int obstacleH = 12;

// Scroll svijeta
int worldSpeed = 3;
int groundOffset = 0;

// Stanje igre
bool gameOver = false;
bool lastButtonState = HIGH;
bool onSurface = true;

unsigned long lastFrameTime = 0;
const unsigned long frameInterval = 20;

bool isNewPress(bool currentState) {
  return (lastButtonState == HIGH && currentState == LOW);
}

void respawnObstacle() {
  obstacleW = random(normalObstacleW, maxObstacleW + 1);
  obstacleX = SCREEN_WIDTH + random(20, 55);
}

void resetGame() {
  playerY = groundY - playerSize;
  prevPlayerY = playerY;
  velocityY = 0;
  groundOffset = 0;
  gameOver = false;
  onSurface = true;
  respawnObstacle();
}

bool overlapsObstacleX() {
  return (playerX + playerSize > obstacleX) && (playerX < obstacleX + obstacleW);
}

bool intersectsObstacle() {
  int playerTop = (int)playerY;
  int playerBottom = playerTop + playerSize;
  int obstacleTop = groundY - obstacleH;
  int obstacleBottom = groundY;

  bool overlapX = overlapsObstacleX();
  bool overlapY = playerBottom > obstacleTop && playerTop < obstacleBottom;

  return overlapX && overlapY;
}

void handleVerticalCollisions() {
  int playerBottomPrev = (int)prevPlayerY + playerSize;
  int playerBottomNow  = (int)playerY + playerSize;
  int obstacleTop = groundY - obstacleH;

  // Slijetanje na vrh prepreke
  if (velocityY >= 0 && overlapsObstacleX() &&
      playerBottomPrev <= obstacleTop &&
      playerBottomNow >= obstacleTop) {
    playerY = obstacleTop - playerSize;
    velocityY = 0;
    onSurface = true;
    return;
  }

  // Slijetanje na tlo
  if (playerY >= groundY - playerSize) {
    playerY = groundY - playerSize;
    velocityY = 0;
    onSurface = true;
    return;
  }

  onSurface = false;
}

void handleDangerCollision() {
  if (!intersectsObstacle()) return;

  int obstacleTop = groundY - obstacleH;
  int playerBottom = (int)playerY + playerSize;

  bool standingOnTop =
    (playerBottom == obstacleTop) &&
    overlapsObstacleX();

  if (!standingOnTop) {
    gameOver = true;
  }
}

void updateGame(bool buttonState) {
  if (isNewPress(buttonState)) {
    if (gameOver) {
      resetGame();
      return;
    }

    // Skok s tla ILI s vrha prepreke
    if (onSurface) {
      velocityY = jumpStrength;
      onSurface = false;
    }
  }

  prevPlayerY = playerY;

  velocityY += gravity;
  playerY += velocityY;

  obstacleX -= worldSpeed;

  groundOffset += worldSpeed;
  if (groundOffset >= 8) {
    groundOffset = 0;
  }

  if (obstacleX + obstacleW < 0) {
    respawnObstacle();
  }

  handleVerticalCollisions();
  handleDangerCollision();
}

void drawGround() {
  display.drawLine(0, groundY, SCREEN_WIDTH - 1, groundY, SSD1306_WHITE);

  for (int x = -groundOffset; x < SCREEN_WIDTH; x += 8) {
    display.drawPixel(x, groundY + 2, SSD1306_WHITE);
  }
}

void drawGame() {
  display.clearDisplay();

  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);
  display.setCursor(0, 0);
  display.print("Vjezba 3: Platforma");

  // Lik
  display.fillRect(playerX, (int)playerY, playerSize, playerSize, SSD1306_WHITE);

  // Prepreka s varijabilnom sirinom
  display.fillRect(obstacleX, groundY - obstacleH, obstacleW, obstacleH, SSD1306_WHITE);

  drawGround();

  if (gameOver) {
    display.fillRect(18, 18, 92, 20, SSD1306_BLACK);
    display.drawRect(18, 18, 92, 20, SSD1306_WHITE);
    display.setCursor(28, 24);
    display.print("GAME OVER");
  }

  display.display();
}

void setup() {
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  randomSeed(analogRead(A0));

  if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
    while (true) { }
  }

  resetGame();
}

void loop() {
  unsigned long now = millis();
  if (now - lastFrameTime < frameInterval) return;
  lastFrameTime = now;

  bool buttonState = digitalRead(BUTTON_PIN);

  updateGame(buttonState);
  drawGame();

  lastButtonState = buttonState;
}

Vježba 4: Više prepreka

Umjesto jedne prepreke, napravi mali niz prepreka, primjerice 3 komada, i svaka ima svoj x, w i h. Kad prepreka izađe s lijeve strane, ne stvaraš novu potpuno neovisno, nego je respawnaš desno od trenutno najdesnije prepreke, uz razmak koji ovisi o širini prethodne prepreke.

Arduino program

Verzija s 3 prepreke. Igrač može stajati i skakati s vrha bilo koje prepreke, a nova prepreka se respawna desno od zadnje postojeće, s kraćim razmakom kad je prethodna dovoljno široka.

// C++
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
#define OLED_ADDR 0x3C
#define BUTTON_PIN 2

Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

// Igrac
const int playerX = 18;
const int playerSize = 8;
const int groundY = 54;

float playerY = groundY - playerSize;
float prevPlayerY = groundY - playerSize;
float velocityY = 0;

const float gravity = 0.65;
const float jumpStrength = -5.8;

// Prepreke
const int OBSTACLE_COUNT = 3;
const int normalObstacleW = 10;
const int maxObstacleW = normalObstacleW * 2;
const int obstacleH = 12;

struct Obstacle {
  int x;
  int w;
};

Obstacle obstacles[OBSTACLE_COUNT];

// Spawn pravila
const int minGapNormal = 36;
const int maxGapNormal = 52;
const int minGapClose = 24;
const int maxGapClose = 34;

// Scroll svijeta
int worldSpeed = 3;
int groundOffset = 0;

// Stanje igre
bool gameOver = false;
bool lastButtonState = HIGH;
bool onSurface = true;

unsigned long lastFrameTime = 0;
const unsigned long frameInterval = 20;

bool isNewPress(bool currentState) {
  return (lastButtonState == HIGH && currentState == LOW);
}

bool isWideObstacle(int w) {
  return w >= (normalObstacleW * 3) / 2;  // >= 1.5x
}

int randomObstacleWidth() {
  return random(normalObstacleW, maxObstacleW + 1);
}

int findRightmostObstacleX() {
  int rightmost = obstacles[0].x + obstacles[0].w;
  for (int i = 1; i < OBSTACLE_COUNT; i++) {
    int rightEdge = obstacles[i].x + obstacles[i].w;
    if (rightEdge > rightmost) rightmost = rightEdge;
  }
  return rightmost;
}

int findPreviousWidthForRespawn() {
  int rightmost = -10000;
  int width = normalObstacleW;

  for (int i = 0; i < OBSTACLE_COUNT; i++) {
    int rightEdge = obstacles[i].x + obstacles[i].w;
    if (rightEdge > rightmost) {
      rightmost = rightEdge;
      width = obstacles[i].w;
    }
  }

  return width;
}

void respawnObstacle(int idx) {
  int previousWidth = findPreviousWidthForRespawn();
  int baseX = findRightmostObstacleX();

  int gap;
  if (isWideObstacle(previousWidth)) {
    gap = random(minGapClose, maxGapClose + 1);
  } else {
    gap = random(minGapNormal, maxGapNormal + 1);
  }

  obstacles[idx].w = randomObstacleWidth();
  obstacles[idx].x = baseX + gap;
}

bool overlapsObstacleX(const Obstacle &o) {
  return (playerX + playerSize > o.x) && (playerX < o.x + o.w);
}

bool intersectsObstacle(const Obstacle &o) {
  int playerTop = (int)playerY;
  int playerBottom = playerTop + playerSize;
  int obstacleTop = groundY - obstacleH;
  int obstacleBottom = groundY;

  bool overlapX = overlapsObstacleX(o);
  bool overlapY = playerBottom > obstacleTop && playerTop < obstacleBottom;

  return overlapX && overlapY;
}

void setupObstacles() {
  int x = SCREEN_WIDTH + 20;

  for (int i = 0; i < OBSTACLE_COUNT; i++) {
    obstacles[i].w = randomObstacleWidth();
    obstacles[i].x = x;
    x += obstacles[i].w + random(minGapNormal, maxGapNormal + 1);
  }
}

void resetGame() {
  playerY = groundY - playerSize;
  prevPlayerY = playerY;
  velocityY = 0;
  groundOffset = 0;
  gameOver = false;
  onSurface = true;
  setupObstacles();
}

void handleVerticalCollisions() {
  int playerBottomPrev = (int)prevPlayerY + playerSize;
  int playerBottomNow  = (int)playerY + playerSize;
  int obstacleTop = groundY - obstacleH;

  bool landed = false;

  for (int i = 0; i < OBSTACLE_COUNT; i++) {
    if (velocityY >= 0 &&
        overlapsObstacleX(obstacles[i]) &&
        playerBottomPrev <= obstacleTop &&
        playerBottomNow >= obstacleTop) {
      playerY = obstacleTop - playerSize;
      velocityY = 0;
      onSurface = true;
      landed = true;
      break;
    }
  }

  if (landed) return;

  if (playerY >= groundY - playerSize) {
    playerY = groundY - playerSize;
    velocityY = 0;
    onSurface = true;
    return;
  }

  onSurface = false;
}

void handleDangerCollision() {
  for (int i = 0; i < OBSTACLE_COUNT; i++) {
    if (!intersectsObstacle(obstacles[i])) continue;

    int obstacleTop = groundY - obstacleH;
    int playerBottom = (int)playerY + playerSize;

    bool standingOnTop =
      (playerBottom == obstacleTop) &&
      overlapsObstacleX(obstacles[i]);

    if (!standingOnTop) {
      gameOver = true;
      return;
    }
  }
}

void updateObstacles() {
  for (int i = 0; i < OBSTACLE_COUNT; i++) {
    obstacles[i].x -= worldSpeed;

    if (obstacles[i].x + obstacles[i].w < 0) {
      respawnObstacle(i);
    }
  }
}

void updateGame(bool buttonState) {
  if (isNewPress(buttonState)) {
    if (gameOver) {
      resetGame();
      return;
    }

    if (onSurface) {
      velocityY = jumpStrength;
      onSurface = false;
    }
  }

  prevPlayerY = playerY;

  velocityY += gravity;
  playerY += velocityY;

  updateObstacles();

  groundOffset += worldSpeed;
  if (groundOffset >= 8) groundOffset = 0;

  handleVerticalCollisions();
  handleDangerCollision();
}

void drawGround() {
  display.drawLine(0, groundY, SCREEN_WIDTH - 1, groundY, SSD1306_WHITE);

  for (int x = -groundOffset; x < SCREEN_WIDTH; x += 8) {
    display.drawPixel(x, groundY + 2, SSD1306_WHITE);
  }
}

void drawGame() {
  display.clearDisplay();

  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);
  display.setCursor(0, 0);
  display.print("Vjezba 4: Vise prepreka");

  display.fillRect(playerX, (int)playerY, playerSize, playerSize, SSD1306_WHITE);

  for (int i = 0; i < OBSTACLE_COUNT; i++) {
    display.fillRect(obstacles[i].x, groundY - obstacleH, obstacles[i].w, obstacleH, SSD1306_WHITE);
  }

  drawGround();

  if (gameOver) {
    display.fillRect(18, 18, 92, 20, SSD1306_BLACK);
    display.drawRect(18, 18, 92, 20, SSD1306_WHITE);
    display.setCursor(28, 24);
    display.print("GAME OVER");
  }

  display.display();
}

void setup() {
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  randomSeed(analogRead(A0));

  if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
    while (true) { }
  }

  resetGame();
}

void loop() {
  unsigned long now = millis();
  if (now - lastFrameTime < frameInterval) return;
  lastFrameTime = now;

  bool buttonState = digitalRead(BUTTON_PIN);

  updateGame(buttonState);
  drawGame();

  lastButtonState = buttonState;
}

Napredniji zadaci (za one koji žele još više):

  • Dodaj score koji raste s vremenom preživljavanja ili brojem preskočenih prepreka; to je najprirodnija nadogradnja endless runnera i odmah daje motivaciju za novo igranje.
  • Dodaj high score koji se prikazuje nakon game over; čak i bez trajnog spremanja, već i high score za jedno paljenje uređaja pojačava natjecateljski element.
  • Postupno povećavaj brzinu igre svakih nekoliko sekundi ili svakih nekoliko bodova, ali s gornjom granicom, jer endless runneri obično postaju zanimljiviji kroz kontrolirani rast težine, a ne kroz nagli skok težine.
  • Dodaj kratki zvuk skoka i drugačiji zvuk za sudar ako imaš buzzer; audio feedback jako pomaže da igra djeluje živa i da akcije budu jasnije.
  • Uvedi dvije vrste prepreka, npr. niska i šira platforma te uska i viša prepreka; raznolikost prepreka često je zanimljivija od pukog stalnog ubrzavanja igre.
  • Dodaj novčiće ili točke iznad prepreka, tako da igrač mora odlučiti ostati na tlu ili skočiti ranije; one-button igre najbolje rade kad je izazov u tajmingu, a ne u mnogo različitih komandi.
  • Dodaj safe intro na početku svake igre, npr. prvih 2–3 sekunde bez teških rasporeda; proceduralni runneri su bolji kad rano ne kazne igrača nepravednom sekvencom.
  • Dodaj nekoliko unaprijed definiranih uzoraka prepreka umjesto čistog randoma, npr. jedna široka, dvije bliske, niska pa daleka; dobra praksa je kombinirati kontrolirane patternse i malo slučajnosti kako bi igra ostala fer.
  • Dodaj početni ekran s porukom Pritisni tipku za start; i vrlo jednostavna igra je jasnija kad ima početno stanje, igranje i game over stanje.
  • Dodaj kratku animaciju sudara, npr. lik nestane, trepne ili se na ekranu pojavi mali efekt; game feel često više ovisi o feedbacku nego o količini funkcionalnosti.
  • Prikaži trenutni score gore desno i high score gore lijevo, ali pazi da tekst ne zatrpa mali 128 × 64 ekran.
  • Dodaj kratku vizualnu animaciju skoka, npr. lik se pri skoku crta malo drugačije ili dobije trag od jednog piksela; vizualni feedback pomaže čitljivosti i na vrlo skromnim ekranima.
  • Refaktoriraj kod tako da imaš odvojene funkcije: updatePlayer(), updateObstacles(), checkCollisions(), drawGame(); to je dobra praksa jer se lakše vidi što radi fizika, što crtanje, a što logika igre.
  • Napravi niz prepreka kao pravu listu objekata sa svojstvima x, w, h, možda i type; to je prvi korak prema ozbiljnijem proceduralnom sustavu.
  • Dodaj ograničenja generatoru prepreka, npr. ne više od jedne vrlo široke prepreke zaredom ili ne više od dvije bliske prepreke; proceduralna težina najbolje radi kad je slučajnost kontrolirana pravilima.
  • Uvedi jednostavni state machine: START, PLAYING, GAME_OVER```; to je mala, ali vrlo korisna arhitekturna nadogradnja za svaku igru.

ligtbulb Što smo naučili?

  • Naučili smo kako na Arduino spojiti OLED ekran 128×64 i tipku te koristiti biblioteku Adafruit_GFX za crtanje jednostavnih oblika, teksta i linija. Vidjeli smo kako se kretanje lika može opisati varijablama za položaj i brzinu te kako jednostavan model gravitacije i skoka nastaje iz ponavljanog povećavanja brzine i ažuriranja položaja u loop().
  • Istražili smo kako je lakše pomicati svijet ulijevo dok lik ostaje gotovo na istom mjestu, umjesto da stalno mijenjamo njegovu X-koordinatu. Dodali smo prepreke koje se pomiču, naučili razlikovati vrste sudara (pad na vrh prepreke naspram bočnog udara) i pretvorili prepreku u platformu s koje se može ponovno odskočiti.
  • Korak po korak uveli smo više prepreka, varijabilnu širinu i jednostavna pravila razmaka kako bi igra ostala igriva i fer. Uz to smo vidjeli zašto je važno postupno podešavati gravitaciju, snagu skoka i brzinu, te kako male promjene tih vrijednosti jako mijenjaju osjećaj kontrole. Na kraju smo dotaknuli i ideje za score, high score, zvuk i različite uzorke prepreka, kao prirodne smjerove daljnjeg razvoja ove male igre.
◀ 2.8. OLED ekran3. Arduino programiranje ▶