< Home
Stampa

Un gioco con OpenCV: Breakout

Sommario

In questa lezione vedremo come implementare una versione minimale del gioco “Breakout” della console Atari.

Leggiamo la descrizione del gioco (da Wikipedia): “In Breakout lo scopo del giocatore è abbattere un muro di mattoni posto nella parte superiore dello schermo, mentre in quella inferiore c’è solamente una piccola barra che può essere mossa a destra e sinistra: con questa bisogna colpire una palla che rimbalza, in modo che distrugga tutti i mattoni che compongono il muro. Se il giocatore non riesce a colpire la palla con la propria barra, questa esce dalla schermata ed è eliminata dal gruppo delle 3 a disposizione: una volta esaurite tutte le palle, la partita termina (“Game over”).

Nella nostra versione creiamo:

  • una sola fila di mattoni;
  • la barra/racchetta è divisa in 3 sezioni: quella centrale rossa rimbalza normalmente, nelle due laterali verdi, rimbalza tornando indietro dalla direzione in cui era venuta (è una variante presente in giochi successivi, come Arkanoid)
  • c’è una vita sola.

Naturalmente è possibile modificare il gioco a proprio piacimento.

La base del gioco è ovviamente la simulazione che abbiamo creato nella lezione precedente (moto uniforme) che è alla base di questo gioco.

La struct Ball resta quindi la stessa, come la meccanica di base del movimento, bisogna però introdurre:

  • la racchetta, controllabile con tasti cursore, che genera l’effetto del rimbalzo;
  • i mattoni, che devono non solo mostrati, ma devono gestire l’urto, e se colpiti essere cancellati dal canvas.
  • una nuova funzione di calcolo del movimento della pallina che dovrà tenere conto dei muri, dei mattoni e della racchetta con relativi rimbalzi.

Racchetta e Mattoni

Creiamo prima di tutto la funzione drawPadel:

#define PADEL_UY 780
#define PADEL_W 200

...

void drawPadel(Mat img, int padel_x)
{
  Rect rect1(padel_x, PADEL_UY, PADEL_W/4, 10);
  Rect rect2(padel_x+PADEL_W/4, PADEL_UY, PADEL_W/2, 10);
  Rect rect3(padel_x+PADEL_W*3/4, PADEL_UY, PADEL_W/4, 10);
  rectangle(img, rect1, Scalar(0, 255, 0), FILLED);
  rectangle(img, rect2, Scalar(0, 0, 255), FILLED);
  rectangle(img, rect3, Scalar(0, 255, 0), FILLED);
}

Le due costanti definiscono la larghezza della racchetta PADEL_W, mentre PADEL_UY indica la linea base della racchetta, cioè l’ordinata che indica il punto di contatto con la pallina. La variabile padel_x indica invece l’ordinata inferiore della raccheta (varia in base ai comandi utente). Come si vede la racchetta ha 3 sezioni, una rossa centrale e due verdi laterali.

Per i mattoni definiamo una Struct apposita. Usiamo delle costanti per le dimensioni:

struct Brick {
  bool visible;
  int x;
  int y;
};

Il booleano visible è di ovvia comprensione: se a true, il mattone sarà mostrato, altrimenti no.

A questo punto nel main generiamo un array di mattoni:

#define BRICKS_L 100
#define BRICKS_UY 40
#define BRICK_W 60
#define BRICK_H 30

int main() {
...  

  int len = 10;
  Brick bricks[len];
  for (int i=0; i<len; i++) {
    bricks[i].x = BRICKS_L+i*BRICK_W;
    bricks[i].y = BRICKS_UY;
    bricks[i].visible = true;
  }

...
}

I mattoni li mostreremo con la funzione seguente:

void drawBricks(Mat img, Brick bricks[], int len) {
  for (int i=0; i<len; i++) {
    if (bricks[i].visible) {
      Rect rect(bricks[i].x, bricks[i].y, BRICK_W-2, BRICK_H);
      rectangle(img, rect, Scalar(255, 0, 0), FILLED);
    }
  }
}

La funzione Rect di openCV genera un Rettangolo data posizione x,y iniziale, larghezza e altezza. E’ quindi possibile generare il rettangolo a partire dalla figura geometrica.

Movimento pallina

Il movimento della pallina è controllato dalla funzione calc():

void calc(Ball *ball, Brick bricks[], int len, int padel_x)
{
  int newx = ball->position.x + ball->vx;
  int newy = ball->position.y + ball->vy;
  if (!checkCollisionBricks(ball, bricks, len, &newx, &newy)) {
    if (!checkCollisionPadel(ball, padel_x, &newx, &newy)) {
      if (!checkCollisionWall(ball, &newx, &newy)) {
        ball->position.x = newx;
        ball->position.y = newy;
      };      
    } 
  }
}

Dopo il solito calcolo della nuova posizione, ci sono tre controlli di collisione: coi mattoni, con la racchetta, ed infine coi muri. Se non ci sono collisioni, la posizione della pallina è aggiornata.

Vediamo i tre controlli.

Collisione coi mattoni

bool checkCollisionBricks(Ball *ball, Brick bricks[], int len, int *newx, int *newy)
{
  bool result = false;
  for (int i=0; i<len && !result; i++) {
    if (bricks[i].visible) {
      if (*newx >= bricks[i].x && *newx <= bricks[i].x + BRICK_W-2 &&
          *newy >= bricks[i].y && *newy <= bricks[i].y + BRICK_H) {
          bricks[i].visible = false;
          result = true;
          if (ball->position.x < bricks[i].x || ball->position.x > bricks[i].x + BRICK_W-2) {
            ball->vx = -ball->vx;
            cout << "Colpito orizzontalmente" << endl;
          } 
          else if (ball->position.y < bricks[i].y || ball->position.y > bricks[i].y + BRICK_H) {
            ball->vy = -ball->vy;
            cout << "Colpito verticalmente" << endl;            
          }          
        }
    }    
  }  
  return result;
}

Si esegue un ciclo su ogni mattone, e si verifica se la futura posizione della pallina è all’interno del rettangolo costituito dall’i-esimo mattone. Se è dentro, il mattone viene reso invisibile. Inoltre se la collisione avviene in modo orizzontale (la posizione x attuale è a destra o a sinistra del mattone) si inverte la direzione orizzontale, altrimenti si inverte quella verticale. La funzione restituisce true se un mattone viene colpito. Nota: nella condizione del ciclo è messo il controllo che result sia false: al primo mattone colpito è inutile andare avanti.

Collisione con la racchetta

bool checkCollisionPadel(Ball *ball, int x, int *newx, int *newy)
{
  bool result = false;
  if (*newx >= x && *newx <= x + PADEL_W && *newy + ball->size > PADEL_UY)
  {
    if (*newx < x + PADEL_W/4 || *newx > x + PADEL_W*3/4) { // primo quarto o ultimo quarto
      ball->vx = -ball->vx;
      ball->vy = -ball->vy;
    } else {
      ball->vy = -ball->vy;
    }    
    result = true;
  }
  return result;
}

La collisione con la racchetta prima di tutto fa un controllo analogo a quanto visto coi mattoni. Successivamente verifica quale parte della racchetta è stata colpita: se una delle due parti laterali vengono invertite sia la velocità orizzontale che quella verticale (la pallina torna da dove era venuta) altrimenti solo la componente verticale (classico urto elastico).

Questo permette al giocatore di poter scegliere come colpire la pallina in base all’angolo di arrivo.

Collisione con le pareti

bool checkCollisionWall(Ball *ball, int *newx, int *newy)
{
  if (*newx >= SIZE - ball->size || *newx <= ball->size)
  {
    ball->vx = -ball->vx; 
    return true;
  }
  else if (*newy >= SIZE - ball->size || *newy < ball->size)
  {
    ball->vy = -ball->vy;
    return true;
  } else {
    return false;
  }
}

Questa parte l’abbiamo già vista nella lezione precedente.

Ciclo principale

Adesso che siamo pronti scriviamo il ciclo principale di gioco:

while (ball.position.y <= PADEL_UY)
  {
    calc(&ball, bricks, len, padel_x);
    clear(img);
    drawPadel(img, padel_x);
    drawBall(img, ball);
    drawBricks(img, bricks, len);
    imshow("Movimento", img);
    int c = waitKey(30);
    if (c == 81 && padel_x > 0)
    {
      padel_x -= PADEL_W / 20;
    }
    if (c == 83 && padel_x < SIZE - PADEL_W)
    {
      padel_x += PADEL_W / 20;
    }
  }

  waitKey(0);
  return (0);

Analizziamolo:

  • il ciclo while si interrompe se la pallina va oltre l’ordinata che corrisponde al limite superiore della racchetta. Vuol dire che il giocatore ha perso.
  • c’è poi la funzione di movimento della pallina (vedi sotto).
  • seguono le funzioni di pulizia del canvas, e disegno di racchetta, pallina e mattoni.
  • dopo la visualizzazione usiamo waitkey per intercettare le azioni utente:
    • i codici 81 ed 83 corrispondono ai tasti cursore (sx e dx): se premuti la variabile padel_x viene modificata di conseguenza;
    • se padel_x supera i limiti del canvas non viene modificata;
    • PADEL_W/20 indica di quando spostare la racchetta ad ogni pressione del tasto (valore più alto = velocità maggiore)

Conclusioni

Ecco il risultato:

In questo gioco si vede come sfruttando poche funzioni di OpenCV è possibile creare videogiochi. Rivediamo le meccaniche del gioco:

  • il gioco è controllato da un ciclo principale. Ad ogni iterazione:
    • aggiorniamo la posizione e la velocità della pallina;
    • gestiamo la collisione con pareti, racchetta e mattoni;
    • gestiamo l’input utente per lo spostamento della racchetta
  • racchetta, mattoni e pallina sono memorizzati in apposite strutture dati che si aggiornano ad ogni ciclo. Il loro aggiornamento è parte della logica applicativa (detta anche “logica di business”) ed è separata dalla rappresentazione grafica.
  • la fase di rappresentazione a schermo (detta anche “rendering”) è successiva e dipende dalle sole strutture dati. In pratica quindi ad ogni ciclo prima si esegue la logica di business, poi si esegue il rendering ed infine si gestisce l’input utente. Le tre fasi sono rigidamente separate per ridurre la complessità.

E’ ora possibile modificare il gioco in molti modi diversi:

  • modificare struttura del muro di mattoni, eventualmente introducendo anche livelli;
  • gestire più “vite” per il giocatore;
  • gestire e mostare i punti dell’utente;
  • modificare velocità e volendo anche traiettorie della pallina per rendere il gioco più vivace;
  • intervenire sulla grafica (poligonale) per esempio introducendo delle immagini.

Queste modifiche sono lasciate per esercizio.