Metodologie di Programmazione: Lezione 22

Riccardo Silvestri

Game I

Iniziamo l'implementazione di un semplice gioco Find-Treasure: trovare un tesoro in un labirinto. Avremo modo di vedere parecchi aspetti riguardanti il disegno 2D in JavaFX, la gestione di eventi del mouse per muovere forme, la sincronizzazione di thread relativamente a una condizione e il loading dinamico di class file durante l'esecuzione di un programma. Alcuni di questi saranno trattati in questa lezione e gli altri in quella successiva in cui completeremo l'implementazione del gioco.

L'applicazione

Iniziamo definendo la classe dell'applicazione che chiameremo MazeApp in un nuovo package mp.game

package mp.game;

import javafx.application.Application;
import javafx.scene.*;
import javafx.scene.control.*;
import javafx.stage.Stage;

/** App per un gioco Find-Treasure: ricerca di un tesoro in un labirinto */
public class MazeApp extends Application {
    public static void main(String[] args) { launch(args); }

    @Override
    public void start(Stage primaryStage) {
        Parent root = createGUI();
        primaryStage.setScene(new Scene(root));
        primaryStage.setTitle("Find Treasure");
        primaryStage.show();
    }

    /** Crea la GUI e ne ritorna il nodo radice */
    private Parent createGUI() {
        MenuItem newGame = new MenuItem("New Game");
        Menu menu = new Menu("Game", null, newGame);
        MenuBar mBar = new MenuBar(menu); // Aggiunge il menu alla barra
        mBar.setUseSystemMenuBar(true);   // Imposta la barra di menu
        area = new Group(mBar);
        return area;
    }

    private Group area;     // Area in cui saranno visualizzati i giochi
} 

Per ora abbiamo solamente introdotto un area che conterrà il gioco e un menu per creare un nuovo gioco. Per creare una barra di menu (la cui posizione dipende dalla piattaforma) c'è il controllo MenuBar che contiene menu gestiti dai controlli Menu che a loro volta contengono le voci di menu gestite da MenuItem.

Il gioco, per quanto semplice, conviene definirlo in una classe separata. Definiamo una classe MazeGame che per il momento non fa un granché

package mp.game;

import javafx.scene.*;
import javafx.scene.canvas.*;
import javafx.scene.paint.Color;

/** Gestisce un gioco del tipo Find-Treasure */
public class MazeGame {
    public MazeGame() {
        int width = 600, height = 400;          // Dimensioni dell'area di gioco
        Canvas canvas = new Canvas(width, height); // Per disegnare il labirinto
        GraphicsContext gc = canvas.getGraphicsContext2D();
        gc.setFill(Color.FLORALWHITE);
        gc.fillRect(0, 0, width, height);
        arena = new Group(canvas);
    }

    /** @return il Node che contiene il labirinto del gioco */
    public Node getNode() { return arena; }


    private final Group arena;  // L'arena di gioco per contenere il
}                               // labirinto i giocatori

Usiamo un nodo Canvas che gestisce un'immagine sulla quale si può disegnare tramite i metodi offerti da un oggetto GraphicsContext. Per adesso disegnamo solamente un rettangolo.

Definiamo un metodo per creare un nuovo gioco che per adesso semplicemente crea un oggetto MazeGame e aggiunge il corrispondente nodo all'area della finestra principale. Inoltre impostiamo l'azione della voce di menu per creare un nuovo gioco e creiamo un gioco iniziale così che le dimensioni della finestra si adattino a quelle del gioco.

. . .
/** Crea la GUI e ne ritorna il nodo radice */
private Parent createGUI() {
    MenuItem newGame = new MenuItem("New Game");     // Crea un nuovo gioco
    newGame.setOnAction(e -> newGame());
    Menu game = new Menu("Game",null,newGame);
    MenuBar mBar = new MenuBar(game);  // Aggiunge il menu alla barra
    mBar.setUseSystemMenuBar(true);    // Imposta la barra di menu
    area = new Group(mBar);
    newGame();             // Il gioco iniziale
    return area;
}

/** Crea e inizia un nuovo gioco */
private void newGame() {
    if (game != null)
        area.getChildren().remove(game.getNode());
    game = new MazeGame();
    area.getChildren().add(game.getNode());
}

private Group area;      // Area in cui saranno visualizzati i giochi
private MazeGame game;   // Il gioco corrente
. . .

Introduciamo anche un campo game per mantenere il gioco corrente così che possiamo modificarne le caratteristiche e aggiungere i giocatori.

I primi giocatori

Per rappresentare i giocatori conviene definire un'opportuna interfaccia. Così potremo introdurre nel gioco diversi tipi di giocatori sia umani, le cui azioni sono controllate tramite eventi di input come i movimenti del mouse, che programmi, specie di robot le cui azioni sono controllate da algoritmi di ricerca.

package mp.game;

import javafx.scene.Node;

/** Interfaccia che deve essere implementata da un giocatore per il
 * gioco Find-Treasure */
public interface Player {
    /** Invocato solamente quando inizia il gioco */
    void play();

    /** Ritorna eventualmente un nodo che rappresenta il giocatore.
     * @return il nodo del giocatore o null */
    default Node getNode() { return null; }
}

L'interfaccia è minimale poi aggiungeremo altre funzionalità. Definiamo il primo tipo di giocatore controllato da eventi di input.

package mp.game;

import javafx.scene.Node;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;

/** Un giocatore per il gioco Find-Treasure che è comandato da eventi di
 * input come i movimenti del mouse. */
public class HPlayer implements Player {
    public HPlayer() {   // Per adesso un semplice cerchio rosso
        shape = new Circle(100, 100, 30, Color.RED);
    }

    @Override
    public Node getNode() { return shape; }

    @Override
    public void play() {
        shape.setOnMousePressed(e -> {
            x = e.getSceneX();     // Registra la posizione attuale del mouse
            y = e.getSceneY();
        });
        shape.setOnMouseDragged(e -> {        // La nuova posizione del mouse
            double mx = e.getSceneX(), my = e.getSceneY();
            double tx = shape.getTranslateX(), ty = shape.getTranslateY();
                // Aggiorna la posizione del giocatore tramite lo spostamento
            shape.setTranslateX(tx + mx - x);                    // del mouse
            shape.setTranslateY(ty + my - y);
            x = mx;                  // Registra la nuova posizione del mouse
            y = my;
        });
    }    
    private final Node shape;   // La forma del giocatore
    private double x, y;        // Mantiene l'ultima posizione del mouse
}

Per il momento la forma del giocatore è un semplice cerchio rosso. Nel metodo play impostiamo i gestori degli eventi relativi al mouse per far sì che la forma del giocatore si muova seguendo i suoi movimenti quando il mouse è premuto su di essa. Quello implementato è uno dei modi possibili per fare ciò. Il modo che abbiamo scelto si basa su una traslazione nel sistema di coordinate locali del nodo. I metodi setTranslateX e setTranslateY di Node, impostano la traslazione nel sistema di coordinate locali. Inizialmente le coordinate locali di un nodo sono nell'origine (0,0), cioè la posizione in tale sistema dell'angolo superiore sinistro del rettangolo che contiene il nodo è (0,0). Quindi se si esegue u.setTranslateX(x) e u.setTranslateY(y) la nuova posizione sarà (x,y). Per tener conto dei movimenti del mouse è sufficiente calcolare lo spostamento della posizione del mouse tra due eventi. Per questo registriamo la posizione iniziale del mouse quando viene premuto sul nodo tramite il gestore dell'evento MousePressed. Poi finché il mouse è mantenuto premuto e viene mosso, sono generati gli eventi MouseDragged e nel gestore di quest'ultimi ci calcoliamo lo spostamento del mouse e lo usiamo per aggiornare la posizione del nodo del giocatore. Per fare l'aggiornamento basta aggiungere all'attuale traslazione, che si ottiene con i metodi getTranslateX e getTranslateY, lo spostamento del mouse.

Rimane da definire un metodo nella classe MazeGame per aggiungere un giocatore al gioco.

. . .
/** Aggiunge il giocatore dato al gioco
 * @param p  un giocatore */
public void add(Player p) {
    Node u = p.getNode();
    if (u != null)
        arena.getChildren().add(u);
    p.play();
}
. . .

Dobbiamo anche introdurre una voce di menu per aggiungere giocatori modificando il metodo createGUI di MazeApp,

. . .
MenuItem addPlayer = new MenuItem("Add Player");
addPlayer.setOnAction(e -> game.add(new HPlayer()));
Menu menu = new Menu("Game", null, newGame, addPlayer);
. . .

Potremmo magari dare al giocatore una forma un po' meno anonima con ad esempio un'immagine come questa smiley.png. Basterà aggiungere il file dell'immagine in resources/mp/game e fare una semplice modifica al costruttore di HPlayer,

. . .
public HPlayer() {
    shape = new ImageView(getClass().getResource("smiley.png").toString());
}
. . .

Labirinti

Tra i tanti modi di generare labirinti ne consideriamo uno molto semplice basato su una visita. Partiamo da una griglia come in figura

Le celle in chiaro sono divise da muri più scuri. Si tratta di aprire un certo numero di muri che separano celle adiacenti in modo tale da realizzare un labirinto in cui i passaggi sono le celle collegate dai muri abbattuti. Per fare ciò scegliamo in modo random una cella e facciamo una visita in profondità (Depth First Search, in breve DFS) del grafo i cui nodi sono le celle e gli archi sono le adiacenze per lato delle celle. Introduciamo una classe MazeGen di metodi di utilità (statici) per labirinti. Il metodo principale genera un labirinto casuale rappresentato tramite una semplice matrice di booleani con valore true in corrispondenza ai passaggi e celle e false altrimenti.

package mp.game;

import java.util.*;

/** Classe di utilità per la costruzione di labirinti */
public class MazeGen {
    /** Ritorna un labirinto con le specificate dimensioni. Il labirinto è
     * rappresentato con una matrice di boolean. Ogni elemento della matrice
     * corrisponde a un rettangolo identificato dalle coordinate di riga e 
     * colonna e che può essere di uno dei seguenti tre tipi: bordo, muro e 
     * cella. La matrice ritornata m è tale che m[r][c] è true se e solo se il 
     * rettangolo di coordinate (r, c) è percorribile (o libero). Tutte le celle
     * sono percorribili. La disposizione iniziale dei vari tipi di rettangoli è
     * la seguente:
     * <pre>
     *     B B B B B . . . B B B B
     *     B C W C W . . . C W C B
     *     B W W W W . . . W W W B
     *     B C W C W . . . C W C B
     *     B W W W W . . . W W W B
     *     B C W C W . . . C W C B
     *     . . . . . . . . . . . .
     *     B W W W W . . . W W W B
     *     B C W C W . . . C W C B
     *     B B B B B . . . B B B B
     * </pre>
     * Dove B è un bordo, W un muro e C una cella. Per rispettare questo schema
     * sia il numero di righe che quello delle colonne deve essere un intero
     * dispari. Il labirinto è costruito tramite una visita in profondità (DFS)
     * random. Più precisamente, la visita inizia in una cella random e ad ogni
     * passo sceglie in modo random una delle celle vicine e se non è stata 
     * ancora visitata apre il muro tra le due celle e continua la visita da
     * quella cella. I labirinti costruiti in questo modo non hanno cicli.
     * @param nr  numero righe (intero dispari)
     * @param nc  numero colonne (intero dispari)
     * @return  un labirinto con le specificate dimensioni */
    public static boolean[][] genMaze(int nr, int nc) {
        boolean[][] maze = new boolean[nr][nc];
        class DFS {
            void visit(int r, int c) {
                maze[r][c] = true;
                int[] ind = rndIndices(4);
                for (int h = 0 ; h < 4 ; h++) {
                    int rr = movR(r, ind[h]), cc = movC(c, ind[h]);
                    if (inside(rr, cc, nr, nc) && !maze[rr][cc]) {
                        maze[passI(r, rr)][passI(c, cc)] = true;
                        new DFS().visit(rr, cc);
                    }
                }
            }
        }
        int row = 2*RND.nextInt(nr/2)+1, col = 2*RND.nextInt(nc/2)+1;
        new DFS().visit(row, col);
        return maze;
    }

    /** Ritorna la coordinata del rettangolo di passaggio tra due posizioni
     * adiacenti
     * @param k1  prima posizione
     * @param k2  seconda posizione
     * @return la coordinata del rettangolo di passaggio tra le due posizioni */
    static int passI(int k1, int k2) { return k1 + (k2 - k1)/2; }

    /** Ritorna la riga della cella adiacente in una data direzione
     * @param r  una coordinata di riga
     * @param i  una direzione
     * @return la riga della cella adiacente in una data direzione */
    static int movR(int r, int i) { return r + moves[i][0]; }

    /** Ritorna la colonna della cella adiacente in una data direzione
     * @param c  una coordinaya di colonna
     * @param i  una direzione
     * @return la colonna della cella adiacente in una data direzione */
    static int movC(int c, int i) { return c + moves[i][1]; }

    /** Ritorna true se le coordinate specificate sono all'interno del
     * labirinto di dimensioni date.
     * @param r  riga
     * @param c  colonna
     * @param nr  numero righe
     * @param nc  numero colonne
     * @return true se le coordinate specificate sono all'interno del
     * labirinto di dimensioni date */
    static boolean inside(int r, int c, int nr, int nc) {
        return r > 0 && r < nr && c > 0 && c < nc;
    }

    /** Ritorna un array di interi di lunghezza n che contiene gli indici
     * 0,1,...n-1 disposti in modo random.
     * @param n  la lunghezza dell'array
     * @return un array contenente gli indici 0,1,...n-1 in ordine random */
    private static int[] rndIndices(int n) {
        int[] indices = new int[n];
        List<Integer> indList = new ArrayList<>();
        for (int i = 0 ; i < n ; i++) indList.add(i);
        for (int i = 0 ; i < n ; i++)
            indices[i] = indList.remove(RND.nextInt(indList.size()));
        return indices;
    }


    private static final int[][] moves = {{-2, 0}, {0, 2}, {2, 0}, {0, -2}};
    private static final Random RND = new Random();
}

Abbiamo anche introdotto alcuni piccoli metodi che potrebbero essere utili nell'uso del labirinto.

Dopo aver generato un labirinto dobbiamo disegnarlo. A questo scopo definiamo un'altra classe Maze che si occuperà di disegnare i labirinti generati da MazeGen,

package mp.game;

import javafx.geometry.Dimension2D;
import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;

/** Un labirinto */
public class Maze {
    /** Crea un labirinto con le date dimensioni (vedi
     * {@link MazeGen#genMaze(int, int)}).
     * @param nr  numero righe (intero dispari)
     * @param nc  numero colonne (intero dispari)
     * @param wW  larghezza muri
     * @param cW  larghezza celle (o passaggi)
     * @param bW  larghezza bordi */
    public Maze(int nr, int nc, int wW, int cW, int bW) {
        maze = MazeGen.genMaze(nr, nc);
        this.nr = nr;
        this.nc = nc;
        wallW = wW;
        cellW = cW;
        bordW = bW;
        wallP = Color.SADDLEBROWN;
        cellP = Color.FLORALWHITE;
        bordP = Color.BLACK;
    }

    /** @return il Node che contiene l'immagine del labirinto */
    public Node draw() {
        int width = 2*bordW + ((nc - 2)/2)*(wallW + cellW)+cellW;
        int height = 2*bordW + ((nr - 2)/2)*(wallW + cellW)+cellW;
        Canvas canvas = new Canvas(width, height);
        GraphicsContext gc = canvas.getGraphicsContext2D();
        gc.setFill(wallP);
        gc.fillRect(0, 0, width, height);
        gc.setFill(bordP);
        gc.fillRect(0, 0, width, bordW);
        gc.fillRect(0, height - bordW, width, bordW);
        gc.fillRect(0, 0, bordW, height);
        gc.fillRect(width - bordW, 0, bordW, height);
        gc.setFill(cellP);
        for (int r = 0 ; r < nr ; r++)
            for (int c = 0 ; c < nc ; c++)
                if (maze[r][c]) {
                    Point2D p = rectUpperLeft(r, c);
                    Dimension2D d = rectSize(r, c);
                    gc.fillRect(p.getX(), p.getY(), d.getWidth(), d.getHeight());
                }
        return canvas;
    }

    /** Ritorna le coordinate rispetto all'immagine del labirinto dell'angolo
     * superiore sinistro del rettangolo con la riga e la colonna specificate.
     * @param r  numero di riga
     * @param c  numero di colonna
     * @return le coordinate dell'angolo superiore sinistro del rettangolo */
    public Point2D rectUpperLeft(int r, int c) {
        int x = (c > 0 ? bordW + (c/2)*(cellW+wallW) + (c % 2 == 0 ? -wallW : 0) : 0);
        int y = (r > 0 ? bordW + (r/2)*(cellW+wallW) + (r % 2 == 0 ? -wallW : 0) : 0);
        return new Point2D(x, y);
    }

    /** Ritorna le dimensioni del rettangolo del labirinto con le specificate
     * riga e colonna.
     * @param r  numero di riga
     * @param c  numero di colonna
     * @return le dimensioni del rettangolo con le date riga e colonna */
    private Dimension2D rectSize(int r, int c) {
        int w, h;
        if (r == 0 || r == nr - 1) h = bordW;
        else if (r % 2 == 1) h = cellW;
        else h = wallW;
        if (c == 0 || c == nc - 1) w = bordW;
        else if (c % 2 == 1) w = cellW;
        else w = wallW;
        return new Dimension2D(w, h);
    }

    private final boolean[][] maze;
    private final int nr, nc, wallW, cellW, bordW;
    private final Paint wallP, cellP, bordP;
}

Ovviamente il disegno dipende da vari parametri come le larghezze delle celle/passaggi, muri, bordi e i relativi colori.

Per mostrare i labirinti dobbiamo solamente modificare il costruttore di MazeGame, e aggiungere un campo per mantenere l'oggetto Maze

. . .
public MazeGame() {
    maze = new Maze(NR, NC, WALL, CELL, BORDER);
    arena = new Group(maze.draw());
}
. . .
private final int NR = 23, NC = 31, WALL = 6, CELL = 42, BORDER = 10;
private final Maze maze;
. . .

Il prossimo passo è posizionare in modo random ogni nuovo giocatore e fare in modo che la forma del giocatore entri nei passaggi del labirinto. Prima di tutto conviene introdurre una piccola classe in MazeGen per rappresentare le posizioni nel labirinto. Potremmo usare una delle classi di JavaFX per rappresentare punti ma queste hanno coordinate double mentre le posizioni nel labirinto sono in termini di righe e colonne e sono quindi intere.

. . .
/** Una posizione all'interno di un labirinto */
public static class Pos {
    public final int row, col;  // Riga e colonna della posizione

    /** Crea una posizione con la riga e la colonna specificate.
     * @param r  numero di riga
     * @param c  numero di colonna */
    public Pos(int r, int c) {
        row = r;
        col = c;
    }

    @Override
    public boolean equals(Object o) {
        if (o == null || getClass() != o.getClass()) return false;
        return row == ((Pos)o).row && col == ((Pos)o).col;
    }

    @Override
    public int hashCode() { return Objects.hash(row, col); }
}
. . .

Inoltre definiamo in Maze un metodo per ottenere una posizione random di una cella libera del labirinto

. . .
/** Ritorna la posizione di una cella scelta in modo random tra quelle
 * libere e la cui posizione non è tra quelle date.
 * @param taken  una collezione di posizioni di celle o null
 * @return la poszione di una cella libera random */
public Pos rndFreeCell(Collection<Pos> taken) {
    Random rnd = new Random();
    for (int t = 0 ; t < 100 ; t++) {
        Pos p = new Pos(2*rnd.nextInt(nr/2)+1, 2*rnd.nextInt(nc/2)+1);
        if (maze[p.row][p.col] && (taken == null || !taken.contains(p)))
            return p;
    }
    return null;
}
. . .

Quando aggiungiamo un nuovo giocatore dobbiamo scalare le sue dimensioni in modo che entri nei passaggi del labirinto e posizionarlo in una cella libera. Introduciamo quindi i seguenti metodi in MazeGame

. . .
/** Scala le dimensioni del nodo per entrare nei passaggi del labirinto
 * @param u  un nodo
 * @return il nodo stesso */
private Node scale(Node u) {
    double z = CELL/Math.max(u.getBoundsInParent().getWidth(),
            u.getBoundsInParent().getHeight());
    u.setScaleX(z);
    u.setScaleY(z);
    return u;
}

/** Sposta un Node nella posizione specificata del labirinto.
 * @param node  un Node
 * @param to  una posizione */
private void move(Node node, Pos to) {
    Point2D p2D = maze.rectUpperLeft(to.row, to.col);
    double tx = node.getTranslateX(), ty = node.getTranslateY();
    double dx = p2D.getX() - node.getBoundsInParent().getMinX();
    double dy = p2D.getY() - node.getBoundsInParent().getMinY();
    node.setTranslateX(tx + dx);
    node.setTranslateY(ty + dy);
}
. . .

Adesso possiamo modificare il metodo che aggiunge un giocatore in MazeGame. Introduciamo un campo takenPos per mantenere le posizioni occupate,

. . .
public void add(Player p) {
    Node u = p.getNode();
    if (u != null) {
        scale(u);
        arena.getChildren().add(u);
        Pos rp = maze.rndFreeCell(takenPos);
        move(u, rp);
        takenPos.add(rp);
    } else {
        // TODO  se il giocatore non ha una forma...
    }
    p.play();
}
. . .
private final List<Pos> takenPos = new ArrayList<>();
. . .

Dopo aver creato il labirinto dobbiamo aggiungere il tesoro. Usiamo ad esempio la seguente immagine treasure.png che come al solito mettiamo in resources/mp/game. E poi basta modificare il costruttore di MazeGame,

. . .
public MazeGame() {
    maze = new Maze(NR, NC, WALL, CELL, BORDER);
    arena = new Group(maze.draw());
    Node tImg = scale(new ImageView(getClass()
            .getResource("treasure.png").toString()));
    arena.getChildren().add(tImg);
    treasure = maze.rndFreeCell(null);
    move(tImg, treasure);
    takenPos.add(treasure);
}
. . .

Mancano ancora parecchie cose, tra cui permettere solamente movimenti all'interno dei passaggi percorribili e gestire anche giocatori che sono controllati da programmi. Vedremo tutto ciò nella prossima lezione.

20 Mag 2016