Taller de desenvolupament del joc Pong en Java

En aquesta entrada descrivim un taller per a la construcció del clàssic joc Pong. Hi ha cinc activitats guiades i incrementals, i es disposa de codi de referència i les solucions.

Instal·lació

Netbeans

  1. Vés a Netbeans (8.2 o més) i fes:
    File ⇒ Import Project ⇒ From ZIP…
  2. A ZIP File, introdueix l’arxiu PCambJocs-netbeans.zip que pots descarregar-te des d’aquí.
  3. Clica al botó “Import” I apareixerà un nou projecte PCAmbJocs.

Eclipse

  1. Vés a Eclipse (4.7 o més) i fes:
    File ⇒ Import… ⇒ Existing Projects into Workspace (Next) ⇒ Select archive file
  2. Al botó “Browse” selecciona l’arxiu PCambJocs-eclipse.zip que es descarrega des d’aquí.
  3. Clica al botó “Finish” i apareixerà un nou projecte PCAmbJocs.

Codi

El projecte importat conté:

  • 1 package amb la llibreria Micro-game (nom: lib)
  • 5 packages amb les activitats (nom: activitat1…5)
  • 5 packages amb les solucions (nom: solucio1…5)

El package lib conté quatre classes amb la implementació d’una llibreria molt senzilla amb un game engine: Game, GameApplication, GameCanvas i GameLoop.

La resta de packages contenen activitats i solucions a aquestes activitats, d’1 a 5. Per exemple: l’activitat 1 es correspon amb la solució 1. Com més alt és el nombre, més funcionalitats implementades.

A les activitats i a les solucions sempre hi ha la classe Pong, que és la que es pot executar. Si executeu la classe Pong de la solució 5, veureu el joc que volem desenvolupar.

Activitat 1: pala que rebota

Per fer un joc només cal estendre la classe Game. Això es fa amb la sintaxi “Pong extends Game”, que vol dir: Pong és un tipus de joc, i Game és la classe pare de la qual deriva Pong.

Això requereix utilitzar super al constructor de Pong per construir l’objecte pare amb els paràmetres corresponents: nom, amplada i alçària del joc i frame rate.

public class Pong extends Game {
    static final int FRAME_RATE = 100;  
    static final int GAME_WIDTH = 700;
    static final int GAME_HEIGHT = 600;
 
    public Pong() {
        super("activitat1", GAME_WIDTH, GAME_HEIGHT, FRAME_RATE);
    }
 
    @Override
    public void update() {
    }
 
    @Override
    public void draw(Graphics2D g) {
    }
  
    public static void main(String[] args) {
        GameApplication.start(new Game1());
    }
}

Si executeu aquesta classe ja funcionarà el joc, però que no dibuixa res. Cada frame s’executen els mètodes update i després draw. Cal escriure codi dins del mètode draw per veure alguna cosa dibuixada.

1. Dibuixa una pala estàtica

Afegeix aquest codi a draw():

g.setColor(Color.RED); // color de dibuix

g.fillRect(0, 0, 20, 80); // dibuixa un rectangle omplert

On 0 i 0 són les coordenades x i y, i 20 i 80 són l’amplada i l’alçària en píxels. Podeu mirar la documentació de Graphics2D aquí:

https://docs.oracle.com/javase/8/docs/api/java/awt/Graphics2D.html

Caldrà que importis la classe Color afegint aquest import al començament de la classe Pong:

import java.awt.Color;

2. Mou la pala

Per moure un objecte, cal calcular la seva velocitat en píxels per frame, és a dir, quants píxels haurà de moure’s cada cop que cridem a update/draw.

Si volem que la pala faci 600px en 2 segons la velocitat és de 300px/seg, i en frames, 300 dividit pels frames que hi ha a un segon, és a dir, 300 / 100 = 3 px/frame.

Com veieu al codi estem utilitzant una constant FRAME_RATE = 100 per construir l’objecte Pong cridant el constructor de la classe pare. Per tant, la velocitat per frame seria 300 / 100 = 3 píxels/frame.

Per moure la pala de dalt a baix, utilitzarem el mètodeupdate per canviar la posició de la pala, i el draw per dibuixar-la. Necessitem una variable: la coordenada vertical y, que cal definir a la classe Pong.

Haureu de canviar la crida a fillRect:

g.fillRect(0, y, 20, 80);

Cal inicialitzar la y al constructor i modificar-la per cada frame dins del mètode update.

Necessitareu una altra variable: la velocitat en píxels per frame. Llavors, al mètode Update tindrem aquest càlcul:

y = y + velocitat;

Per tant, per cada frame, la y sumarà la velocitat. Per exemple, 3 píxels per frame, segons hem calculat abans.

Quin seria el valor inicial si la pala està a dalt? Positiu o negatiu? Utilitzeu aquesta fórmula per calcular la velocitat inicial:

velocitat = 300 / FRAME_RATE;

Aquesta fórmula us permetrà modificar posteriorment el FRAME_RATE i mantenir la velocitat de la pala igual (ho veurem).

3. Rebota la pala a dalt i baix

Si executeu el vostre codi, la pala desapareixerà per sota i no tornarà. Cal fer-la rebotar a dalt i baix, modificant el mètode update.

Quines són les condicions per comprovar si el canvi de la variable y fa que hagi arribat al cantó inferior de la pantalla?

En pseudocodi:

si (y + alçaria_de_la_pala > GAME_HEIGHT) ⇒ fer_que_torni_a_dalt

si (y < 0) ⇒ fer_que_torni_a_baix

I com fer que la pala torni a dalt o a baix? Molt senzill! Només cal invertir el signe de la variable velocitat. Si estava baixant, la velocitat passarà a ser negativa, i començarà a pujar. I si estava pujant, la velocitat passarà a ser positiva, i començarà a baixar.

4. Canvia el frame rate de 100 a 20

Si canvies la constant FRAME_RATE, el teu càlcul de la variable velocitat canviarà, i la teva pala seguirà movent-se a la mateixa velocitat per segon. Llavors, què passa visualment quan feu aquest canvi?

5. Fer que la pala es torni prement l’espai

Ara farem que el joc sigui una mica més interactiu: prement la tecla espai farem que la pala canviï el sentit. Ja sabeu com canviar el sentit de la pala! Ho hem vist abans. Ara cal saber quan s’ha premut l’espai.

Això introdueix el concepte de Callbacks a la programació. És un mecanisme per gestionar esdeveniments. Un programa es registra per rebre crides quan hi ha un esdeveniment, i aquestes crides es reben a un mètode que el programa implementa.

En el nostre cas, Game es registra com KeyListener per rebre notificacions del teclat. Això ho fa la nostra petita game engine: si teniu curiositat, podeu mirar els detalls dins del codi (cerqueu els termes KeyListener i addKeyListener).

El resultat és que podem afegir tres mètodes al nostre Pong: keyTyped, keyPressed, keyReleased. A nosaltres ens interessen els dos darrers, per saber si s’ha premut o alliberat una tecla. Mireu:

https://docs.oracle.com/javase/8/docs/api/java/awt/event/KeyListener.html
i
https://docs.oracle.com/javase/8/docs/api/java/awt/event/KeyEvent.html

A aquesta segona veureu totes les tecles que es poden detectar, amb el codi VK_… com per exemple, VK_SPACE.

Si creem el mètode keyPressed a Pong, podem comprovar si s’ha premut la tecla espai així:

public void keyPressed(KeyEvent e) {

    if (e.getKeyCode() == KeyEvent.VK_SPACE) {
        // fer alguna cosa
    }
}

Haureu d’importar la classe KeyEvent amb

import java.awt.event.KeyEvent;

Quina cosa podem fer dins de la condició?

  1. directament canviar el signe de la velocitat
  2. indicar amb una variable booleana que s’ha premut la tecla espai i després llegir-la al mètode update.

Les dues funcionen, en principi. Però hi ha una solució millor que l’altra: la segona. Per què? Bàsicament perquè és millor gestionar tots els canvis de les variables de l’objecte des d’un mateix punt, el mètode update. No volem que dos fills d’execució vulguin canviar alhora la variable velocitat!

Prova d’implementar la segona opció. Crea una variable que es digui: espaiPremut, i llavors, a update, comprova si espaiPremut és true ⇒

  1. canvia el signe de la velocitat
  2. fes espaiPremut = false (vol dir que ja has atés el canvi de sentit)

Ja tens una pala que puja i baixa, i que canvia de sentit si prems l’espai!

Activitat 2: dues pales

Nou codi

Si executeu el Pong de la solució 2, veureu que hi ha dues pales que es mouen, i si premeu la tecla espai o retorn, feu voltar cada pala.

La nostra classe Pong ha canviat, ara tenim dues noves classes:

  • GameObject, un interface de Java que defineix una categoria d’objectes visuals al joc que tenen dos mètodes: update i draw, exactament els mateixos que tenim al joc (Pong).
  • Racket, una classe que implementa GameObject (Racket implements GameObject), o sigui, un tipus d’objecte del joc. Aquesta classe vol implementar tota la lògica que requereix una pala.

Per tant, GameObject és una abstracció de tots els objectes visuals que pot haver-hi al joc. I hem canviat els nostres mètodes update i draw de la classe Pong:

@Override
public void update() {
    for (GameObject object: objects)
        object.update();
}

@Override
public void draw(Graphics2D g) {
    for (GameObject object: objects)
        object.draw(g);
}

Com veieu, ara update itera sobre un array de GameObject per actualitzar-los, i draw itera sobre el mateix array per dibuixar-los. Podem dir que ara la nostra classe Pong és una abstracció més general, que permet treballar amb tota mena de GameObjects.

L’array objects es crea al constructor de Pong:

public Pong() {
    super("activitat2", GAME_WIDTH, GAME_HEIGHT, FRAME_RATE);
    
    this.racket = new Racket(this);
    this.objects = new GameObject[] {
        this.racket
    };
}

Si mireu el nou mètode keyPressed de Pong, detecta la tecla espai que gira la pala, i criden el mètode turnBack de l’objecte Racket per fer-ho:

@Override
public void keyPressed(KeyEvent e) {
    if (e.getKeyCode() == KeyEvent.VK_SPACE) {
        this.racket1.turnBack();
    }
}

Mireu el mètode turnBack de Racket, i tracteu d’entendre com funciona la variable booleana turnBack.

Ara faràs que hi hagi una segona pala amb molt poc esforç, fent una abstracció de la pala. Fins ara una pala era un objecte que hi havia a l’esquerra de la pantalla: ara podrà ser tant a l’esquerra com a la dreta.

1. Afegeix una segona pala

Modifica la classe Racket amb un nou paràmetre al constructor que indiqui si està situada a l’esquerra o a la dreta i una tecla dedicada.

En funció d’aquest paràmetre, que pot ser booleà, dibuixa la pala a l’esquerra (com fins ara) o a la dreta.

Caldrà que afegeixis un nou GameObject a l’array objects de Pong amb aquesta segona pala.

2. Captura la tecla VK_ENTER per fer tornar enrere la segona pala

Captura aquesta segona tecla dins el mètode keyPressed de la classe Pong. Caldrà que cridis al mètode turnBack de la Racket.

Activitat 3: col·lisió de pilota

En aquesta activitat parlarem de col·lisions.

Tenim un objecte al pla, amb una x i una y. La seva velocitat és v i es mou en un angle θ. Llavors, tindrem els components de la velocitat vx i vy :

vx = v * cosinus (θ)

vy = v * sinus (θ)

Per fer rebotar l’objecte a l’eix x:

vy es manté igual i vx = -vx

vx es manté igual i vy = -vy

Per saber si dos objectes topen, hi ha el mètode AABB (axis-aligned bounding box).

Si tenim dos objectes a i b, amb coordenades inicials x1,y1 i finals x2,y2, hi haurà col·lisió si:

a.x1 < b.x2 AND a.x2 > b.x1 AND a.y1 < b.y2 AND a.y2 > b.y1

Nou codi

Ha canviat la interface GameObject, i s’han afegit quatre nous mètodes:
double x(), double y(), double w() i double h().

Aquests mètodes defineixen la posició i mida de l’objecte (w=amplada, h=alçaria). Ens servirà per gestionar les col·lisions.

També tenim un nou GameObject: Ball, que representa la pilota del Pong. Aquest objecte té un mètode serve que la fa aparèixer al joc.

Si et mires el mètode update de Ball, veuràs el càlcul de les noves x, y i com es fa per fer rebotar la pilota a les quatre parets.

public void update() {
    this.x += this.xSpeed;
    this.y += this.ySpeed;

    if (this.y < 0 || this.y + SIDE > game.getHeight()) {
        this.ySpeed *= -1.0;
        this.y = this.y < 0? 0 : game.getHeight() - SIDE;
    }

    if (this.x < 0 || this.x + SIDE > game.getWidth()) {
        this.xSpeed *= -1.0;
        this.x = this.x < 0? 0 : game.getWidth() - SIDE;
    }
}

Les velocitats xSpeed i ySpeed es calculen al mètode serve. Revisa’l si vols entendre com es crea una pilota a posició i angle aleatoris.

1. Fes rebotar la pilota a les pales

Hi ha dos mètodes que pots utilitzar, i que ja estan implementats (podeu suposar que funcionen perfectament). Són:

  • boolean collision(GameObject a, GameObject b) ⇒ utilitza el mètode AABB per comprovar si dos objectes han col·lisionat. Ho fa gràcies als nous quatre mètodes que hem implementat: x(), y(), w() i h().
  • void bounce(Racket racket) ⇒ canvia les velocitats xSpeed i ySpeed de la pilota rebotant a la pala “racket”.

Utilitza’ls a la funció update de la pilota. Hauràs de comprovar si la pilota topa amb cadascuna de les pales, i fer-la rebotar.

2. Afegir una segona pilota

Ara, afegir una segona pilota és molt senzill:

  1. Afegeix la segona pilota a la llista d’objectes.
  2. Necessitaràs fer aparèixer la segona bola amb el mètode serve. Dona una ullada al mètode init de la classe Pong: serveix per inicialitzar qualsevol objecte abans d’iniciar el joc. Aquí hauràs d’afegir una línia de codi.

Activitat 4: comptar punts

Nou codi

Fins ara tenim objectes del joc GameObject que s’actualitzen (update) i es dibuixen ( draw). Ara crearem un nou tipus d’objecte que només es dibuixen, ja que no interactuen amb la resta d’objectes.

GameDraw és un interface, un nou tipus d’objecte. El farem servir per crear el marcador del joc: dos nombres de dues xifres que apareixen a cada banda del camp. Aquest tipus d’objecte només té un mètode:

void draw(Graphics2D g)

Aquest mètode el cridarem des del mètode draw de Pong, que ha estat modificat:

@Override
public void draw(Graphics2D g) {
    for (GameDraw draw: draws)
        draw.draw(g);        
    for (GameObject object: objects)
        object.draw(g);
}

Com veus, primer es dibuixen els GameDraw utilitzant un array, draws, i després els objects que ja teníem (pales i pilota).

Al constructor de Pong podem veure la inicialització de draws:

this.draws = new GameDraw[] {
    new Score(this, true), new Score(this, false)
};

Com veus, es tracta de dos objectes de tipus Score. Score estén GameDraw. Mireu el seu codi i podreu entendre com dibuixa el marcador. Bàsicament, utilitza una classe, Digit, que dibuixa els nombres utilitzant segments rectangulars.

1. Compta punts

La classe Pong té dos mètodes que heu de completar:

  • void addPoint(boolean left) ⇒ haurà de comptar un punt per al jugador de l’esquerra o la dreta.
  • int getPoints(boolean left) ⇒ haurà d’obtenir el total de punts comptabilitzat per al jugador de l’esquerra o la dreta.

Per tant:

  1. Necessiteu un parell de variables senceres a la classe Pong.
  2. Necessiteu fer una crida a addPoint des del mètode update de Ball. Ara mateix s’està rebotant als límits esquerre i dret de la pista, però el punt ha d’acabar.
  3. Quan es comptabilitza el punt cal tornar a servir una pilota des del camp del guanyador del darrer punt. O sigui, has de cridar serve de Ball.

2. Game over

Quan s’arribi a 11 punts, crida el mètode setOver() des de Pong per acabar el joc.

3. Bug al dígit 5

Corregeix el dibuix del nombre 5: li falta un segment.

Activitat 5: oponent intel·ligent

Per fer un oponent intel·ligent implementarem el següent comportament:

  • Trobarem la coordenada y ideal perquè la pala es mogui.
  • Farem que tingui una velocitat màxima com la de la pala del jugador real.

Nou codi

Ara hi ha un nou GameObject: RacketAI. Aquesta classe es deriva mitjançant herència de Racket. Bàsicament, el que fa és reescriure el mètode update i conservar la resta. El nou update implementa el comportament descrit abans.

1. Utilitza la classe RacketAI

La classe RacketAI implementa a l’oponent intel·ligent. Estudia el seu codi. Reemplaça la pala dreta per aquest nou GameObject. Només has de canviar la variable racket2 de Pong.

2. Dibuixa una xarxa

Crea un nou GameDraw, anomenat Net, que dibuixi la xarxa. I afegeix l’objecte a l’array draws de Pong per veure-la.

Deixa un comentari

L'adreça electrònica no es publicarà Els camps necessaris estan marcats amb *