Metodologie di Programmazione: Lezione 23

Riccardo Silvestri

Game II

Completiamo l'implementazione del semplice gioco Find-Treasure iniziata nella precedente lezione. Sono illustrati due importanti aspetti del linguaggio Java. Il primo riguarda la sincronizzazione di più thread in modo tale che possano eseguire delle operazioni in modo sincronizzato, ovvero attendere che una certa condizione sia verificata prima di poter procedere. Nel caso del nostro gioco, le mosse dei giocatori devono essere sincronizzate per garantire che a tempi prestabiliti ogni giocatore possa eseguire al più una mossa. Per garantire ciò, ogni giocatore è eseguito in un thread esclusivamente dedicato ad esso e ogni volta che vuole eseguire una mossa invoca un metodo opportuno di un oggetto di controllo. Quando questo avviene, il metodo blocca fino a che il gestore del gioco non esegue l'aggiornamento di tutte le mosse dei giocatori e sblocca l'esecuzione dei thread dei giocatori. Per questa tipo di sincronizzazione, cioè i thread dei giocatori devono attendere che il (thread del) gestore del gioco esegue l'aggiornamento, è implementato tramite i metodi wait-notifyAll di Object.

Il secondo aspetto riguarda la possibilità di caricare nella JVM, durante l'esecuzione, nuove definizioni di classi ed istanziarle. Nell'esempio del gioco, questo è usato per permettere all'utente dell'applicazione JavaFX di poter aggiungere un nuovo giocatore, durante l'esecuzione del gioco, scegliendo il class file di una classe che implementa un giocatore. Questo è implementato usando un ClassLoader standard fornito da URLClassLoader in java.net.

Gestione delle mosse

Nel nostro Find-Treasure possono esserci molti giocatori e di tipi differenti. In un qualsiasi istante del gioco ogni giocatore si trova in una posizione del labirinto e può tentare di muoversi in una certa direzione. Le posizioni e le mosse, cioè i movimenti, dei giocatori devono essere gestiti dall'oggetto MazeGame. Quindi i giocatori devono comunicare a MazeGame la mossa che vogliono fare. Tenendo conto che i giocatori possono anche essere dei programmi, se non imponiamo alcuna disciplina i giocatori potrebbero comunicare le loro mosse rapidissimamente. Un giocatore programma potrebbe muoversi migliaia di volte più velocemente del più veloce dei giocatori umani. I giocatori più rapidi potrebbero esautorare le risorse computazionali lasciando scarsissimo tempo agli altri e potrebbero anche ridurre pesantemente la reattività della GUI. Così, in un tale contesto, una strategia vincente potrebbe essere quella di cercare di consumare il più possibile il tempo di esecuzione di MazeGame a scapito degli altri giocatori. Ovviamente non vogliamo questo e quindi MazeGame deve imporre una disciplina che dia a ogni giocatore le stesse possibilità di movimento indipendentemente dal comportamento degli altri giocatori.

Un modo semplice di garantire una tale disciplina paritaria è di suddividere il tempo in intervalli di tempo fissati, che chiamiamo frame, e in ogni frame un giocatore può fare al più una mossa. L'implementazione deve per ogni giocatore creare un thread in cui sono eseguite esclusivamente le computazioni del giocatore. Periodicamente per ogni frame, il gestore del gioco MazeGame aggiorna lo stato del gioco relativamente alle mosse richieste dai giocatori in quel frame. Quando un giocatore chiede di poter fare una mossa la sua esecuzione si blocca in attesa che MazeGame aggiorni lo stato del gioco e riprende non appena MazeGame termina l'aggiornamento. Siccome l'aggiornamento richiede la manipolazione della GUI, sarà eseguito nel JavaFX Thread. Per imporre questa sincronizzazione dei thread dei giocatori, cioè l'attesa fino al completamento del prossimo aggiornamento, si possono usare i metodi wait e notifyAll di Object. Quando in un thread è invocato obj.wait()1, su un certo oggetto obj, l'esecuzione del thread è bloccata ed entra in uno stato dormiente WAITING da cui si sveglierà quando in un altro thread è invocato obj.notifyAll(), vedremo i dettagli fra poco. L'esecuzione dei thread dei giocatori e degli aggiornamenti del gestore del gioco (nel JavaFX Thread) è illustrata nella figura sottostante

Come si vede anche nella figura, se un giocatore non riesce a decidere una mossa nel tempo di un frame, semplicemente salta quel frame di gioco. Comunque la disciplina garantisce che ogni giocatore può fare al più una mossa in ogni frame.

Le mosse dei giocatori

Prima di passare all'implementazione dobbiamo decidere quali mosse può fare un giocatore. In questa versione iniziale del gioco conviene introdurre solamente un insieme minimale di mosse2. Decidiamo quindi che un giocatore può, in ogni momento del gioco, scegliere di muoversi in una cella adiacente tra quelle nelle quattro direzioni UP, DOWN, LEFT e RIGHT. Nella direzione scelta potrebbe esserci un muro o una cella occupata da un altro giocatore. In tali casi il giocatore non si muove e gli sarà comunicata la collisione. Non prevediamo quindi che i giocatori possano avere una qualche forma di visione del labirinto e degli altri giocatori. Queste ed altre funzionalità potranno essere aggiunte in versioni successive. Nella versione iniziale i giocatori sono "ciechi" e si accorgono degli ostacoli solamente andandoci a sbattere contro. Farà eccezione il giocatore umano che ovviamente avrà invece il privilegio, rispetto ai giocatori-programma, di vedere tutto3.

Quando un giocatore decide una mossa, cioè la direzione in cui muoversi, l'esecuzione del suo thread deve bloccarsi in attesa che MazeGame faccia l'aggiornamento di quel frame di gioco. L'implementazione deve usare un singolo oggetto, che chiameremo non a caso boss, con il quale effettuare la sincronizzazione dei thread dei giocatori. Più precisamente quando un giocatore chiede, o comanda, una mossa invoca boss.wait(), che blocca il suo thread, e quando l'aggiornamento è completo il MazeGame invoca boss.notifyAll(), che risveglia tutti i thread dei giocatori che hanno chiesto una mossa in quel frame. Però questo meccanismo non dovrebbe essere implementato dai giocatori, dovrebbe essere una esclusiva responsabilità del gestore del gioco. I giocatori devono solamente "sapere" come comandare una mossa, la disciplina delle mosse e i suoi dettagli implementativi sono affari privati di MazeGame.

Quando il giocatore entra in gioco possiamo fornirgli, tramite il metodo play, un oggetto che rappresenta una console di comando. Il giocatore durante il gioco comanda le proprie mosse invocando opportuni metodi dell'oggetto console. Essendo tale oggetto creato è gestito da MazeGame, i metodi che comandano le mosse invocheranno boss.wait(). Così questo aspetto non sarà visibile ai giocatori e ne sarà garantita la corretta esecuzione.

Nuovi giocatori

Modifichiamo quindi l'interfaccia Player

package mp.game;

import javafx.scene.Node;

/** Interfaccia che deve essere implementata da un giocatore di Find-Treasure */
public interface Player {
    /** Le quattro direzioni di movimento */
    enum Dir { UP, RIGHT, DOWN, LEFT }

    /** Lo stato del gioco nel frame in cui il giocatore comanda una mossa.
     * Quando lo stato diventa {@link State#GAME_OVER} o {@link State#WIN},
     * il giocatore deve terminare la propria esecuzione. */
    enum State {
        /** Mossa eseguita, il gioco continua */
        GO,
        /** Mossa non eseguita causa collisione, il gioco continua */
        COLLISION,
        /** Gioco terminato e il giocatore non ha vinto */
        GAME_OVER,
        /** Gioco terminato con la vittoria del giocatore */
        WIN
    }

    /** Il tipo dell'oggetto per comandare le mosse che è passato al giocatore
     * quando inizia il gioco */
    interface Console {
        /** Comanda la mossa di muoversi nella direzione data e ritorna lo stato
         * del gioco del frame corrente. È bloccante fino al completo
         * aggiornamento del frame corrente.
         * @param d  una direzione
         * @return lo stato del gioco del frame corrente */
        State move(Dir d);
    }

    /** Invocato solamente quando inizia il gioco in un thread dedicato. Il
     * giocatore decide le sue mosse in questo metodo tramite un ciclo che
     * deve interrompersi non appena il comando di una mossa ritorna uno
     * {@link State} che segnala la fine del gioco.
     * @param c  l'oggetto per comandare le mosse */
    void play(Console c);

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

L'oggetto console è rappresentato tramite l'interfaccia Player.Console e permette di comandare solamente l'insieme minimale di mosse che abbiamo deciso. Anche i possibili valori, cioè gli stati del gioco, ritornati dal metodo move sono ridotti al minimo indispensabile4. Chiaramente con questa nuova interfaccia l'implementazione di HPlayer non è corretta. Inoltre nella versione precedente il metodo play era invocato nel JavaFX Application Thread mentre ora sarà invocato in un thread appositamente creato per il giocatore. L'implementazione corretta del HPlayer è un po' delicata e la posponiamo, modifichiamo solamente l'intestazione del metodo play per permettere la compilazione. Prima ci conviene considerare i giocatori-programma che in questo nuovo contesto sono molto più facili da implementare. Il più semplice di tutti è quello che fa mosse completamente casuali,

package game;

import javafx.scene.Node;
import javafx.scene.image.ImageView;
import java.util.Random;

/** Un giocatore per Find-Treasure che fa mosse random */
public class RPlayer implements Player {
    public RPlayer() {
        shape = new ImageView(getClass().getResource("ghostRed.png").toString());
    }
    @Override
    public void play(Console c) {
        Random rnd = new Random();
        while (true) {
            State s = c.move(Dir.values()[rnd.nextInt(Dir.values().length)]);
            if (State.GAME_OVER.equals(s) || State.WIN.equals(s)) break;
        }
    }

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

    private Node shape;
}

Come forma usa l'immagine ghostRed.png che copiamo nella directory delle risorse resources/mp/game.

Gestore del gioco: sincronizzazione dei giocatori

Le maggiori modifiche dobbiamo farle nella classe MazeGame. Prima di tutto è necessaria un'implementazione per le console dei giocatori. Definiamo una classe statica annidata nella classe MazeGame che implementa l'interfaccia Player.Console,

import static game.Player.*;

public class MazeGame {
    . . .
    /** Implementa la console di un giocatore */
    private static class Control implements Console {
        Control(MazeGame boss, Player player, Node pn, Pos p) {
            this.boss = boss;
            node = pn;
            pos = p;
            thread = new Thread(() -> player.play(this));    // Crea il thread
            thread.setDaemon(true);                   // dedicato al giocatore
        }

        void start() { thread.start(); }  // Inizia l'esecuzione del giocatore

        @Override
        public State move(Dir d) {
            synchronized (boss) {    // Sincronizza sul gestore del gioco
                if (State.GAME_OVER.equals(state) || State.WIN.equals(state))
                    return state;    // Se il gioco è terminato
                move = d;
                long frame = boss.frameCounter;       // Il frame corrente
                while (frame == boss.frameCounter) {  // Contro spurious wakeup
                    try {
                        // Aspetta per l'aggiornamento del frame corrente, il
                        // thread rilascia il lock su boss e rimane dormiente
                        boss.wait();  // fino a che è eseguito notifyAll su
                        // boss e il thread riprende il lock su boss.
                    } catch (InterruptedException e) { }
                }
                return state;
            }
        }

        Pos getPos() { return pos; }      // Ritorna la posizione corrente

        Node getNode() { return node; }   // Ritorna il Node del giocatore

        Player.Dir consumeMove() {  // Ritorna la mossa comandata e la consuma
            synchronized (boss) {
                Dir d = move;       // Salva la mossa comandata,
                move = null;        // la consuma e
                return d;           // la ritorna
            }
        }

        void setState(State s, Pos p) {    // Imposta lo stato, che sarà
            synchronized (boss) {          // ritornato dal metodo move,
                state = s;                 // e l'eventuale nuova
                if (p != null) pos = p;    // posizione
            }
        }

        private final MazeGame boss;    // Il gestore del gioco usato per
        private final Node node;        // sincronizzare le mosse dei giocatori
        private final Thread thread;    // Il thread del giocatore
        private volatile Pos pos;       // La posizione, la mossa e lo stato
        private volatile Dir move;      // correnti
        private volatile State state;
    }
    . . .
    private volatile long frameCounter;    // Contatore dei frame
    . . .

Per ogni nuovo giocatore sarà creato un oggetto Control che gestisce il thread dedicato al giocatore. Il costruttore crea il thread per il giocatore e lo predispone per l'esecuzione del metodo play. L'esecuzione sarà iniziata invocando il metodo start. La parte più importante è l'implementazione del metodo move che deve gestire la sincronizzazione del thread del giocatore. Come oggetto boss usiamo proprio l'oggetto MazeGame che gestisce il gioco così è garantito essere lo stesso per tutti i giocatori.

wait-notify

Prima di continuare con la classe Control, diamo una spiegazione generale circa l'uso dei metodi wait, notify e notifyAll di Object. Questi metodi dovrebbero essere invocati su un oggetto obj solamente se il thread è possessore del monitor dell'oggetto obj (owner of the object's monitor). Un thread diventa possessore del monitor di un oggetto obj in uno dei seguenti tre modi:

Chiaramente, solamente un thread può possedere il monitor di un oggetto in ogni dato momento. Quando un thread T esegue obj.wait() rilascia il monitor dell'oggetto obj (e quindi anche il lock su obj), entra nello stato WAITING e la sua esecuzione smette di essere pianificata. Si può dire che T entra in uno stato dormiente. Quando successivamente un altro thread esegue obj.notifyAll(), il thread T è risvegliato entra nello stato RUNNING e compete per riottenere il monitor dell'oggetto obj, non appena lo ottiene riprende l'esecuzione. Il thread T può anche essere risvegliato se viene interrotto. Inoltre, anche se in pratica è molto raro, può accadere che il thread T venga risvegliato senza che si sia verificata nessuna delle condizioni sopra menzionate. Si tratta di un cosiddetto risveglio spurio (spurious wakeup). Per questo si consiglia di invocare il metodo wait sempre all'interno di un ciclo che controlla se la condizione di risveglio è soddisfatta, e nel caso non lo sia si rimette a "dormire". Quindi un ciclo del seguente tipo

 synchronized (obj) {
     while (la condizione per il risveglio non è soddisfatta)
         obj.wait();
         . . .
     }
 }

Proprio per far fronte ad eventuali risvegli spuri, l'invocazione boss.wait() è eseguita all'interno di un while che controlla che il frame non sia lo stesso in cui è stato invocato il metodo move, cioè il frame corrente. Infatti l'aggiornamento relativo al frame corrente è seguito all'inizio del frame successivo, come vedremo fra poco. Quindi abbiamo introdotto il campo frameCounter di MazeGame per mantenere il conteggio dei frame. Gli altri metodi di Control li vedremo quando saranno usati.

Passiamo a modificare il metodo add di MazeGame,

. . .
/** Aggiunge il giocatore dato al gioco
 * @param p  un giocatore */
public void add(Player p) {
    Node u = p.getNode();   // Il Node che rappresenta il nuovo giocatore
    if (u == null) {
        // TODO  se il giocatore non ha una forma...
    }
    scale(u);                       // Ridimensiona il Node del giocatore
    arena.getChildren().add(u);     // Lo aggiunge all'arena di gioco
    Set<Pos> pp = controls.stream().map(Control::getPos)    // Posizioni
            .collect(Collectors.toSet());      // correnti dei giocatori
    pp.add(treasure);                          // La posizione del tesoro
    Pos rp = maze.rndFreeCell(pp);  // Sceglie una posizione libera random
    move(u, rp);                    // in cui posizionare il giocatore
    Control c = new Control(this, p, u, rp);  // Crea il controllo per il
    controls.add(c);                          // nuovo giocatore e
    c.start();                                // inizia l'esecuzione
}
. . .
private final List<Control> controls = new ArrayList<>(); // I controlli dei giocatori
. . .

Abbiamo aggiunto il campo controls per la lista dei controlli di tutti i giocatori. Ogni controllo mantiene anche la posizione corrente del giocatore, ritornata dal metodo getPos. Ed è usata per determinare le posizioni libere del labirinto e per sceglierne una per il nuovo giocatore.

Gestore del gioco: aggiornamento dei frame

Prima di introdurre il metodo che esegue l'aggiornamento di un frame, introduciamo due metodi di utilità. Uno nella classe MazeGen.Pos,

   . . .
   /** La posizione della cella in cui si arriva se da questa posizione ci
     * si muove di un passo nella direzione specificata. Si assume che
     * questa posizione sia quella di una cella.
     * @param d  una direzione
     * @return la posizione della cella di arrivo */
    public Pos go(Dir d) {
        for (int i = 0 ; i < 4 ; i++)
            if (Dir.values()[i].equals(d))
                return new Pos(movR(row, i), movC(col, i));
        return null;
    }
    . . .

e l'altro nella classe Maze,

. . .
/** Ritorna true se la posizione della cella a cui si arriva dalla cella p
 * con un passo nella direzione d è libera e il passaggio tra le due celle
 * è aperto, altrimenti ritorna false.
 * @param p  la posizione di una cella
 * @param d  una direzione
 * @return true se si può andare dalla cella in posizione p alla cella
 * adiacente nella direzione d */
public boolean pass(Pos p, Dir d) {
    Pos q = p.go(d);
    if (!inside(q.row, q.col, nr, nc) || !maze[q.row][q.col]) return false;
    int r = passI(p.row, q.row), c = passI(p.col, q.col);
    return maze[r][c];
}
. . .

Adesso possiamo introdurre il metodo che aggiorna un frame del gioco

. . .
/** Aggiornamento effettuato ad ogni frame, eseguito nel JavaFX Thread */
private synchronized void update() {
    Set<Pos> pp = controls.stream().map(Control::getPos)    // Posizioni
            .collect(Collectors.toSet());      // correnti dei giocatori
    Control winner = null;
    for (Control c : controls) {
        Dir d = c.consumeMove(); // La mossa comandata
        if (d == null) continue; // Se non ha comandato una mossa, ignoralo
        Pos p = c.getPos();  // Posizione corrente
        Pos q = p.go(d);     // la nuova posizione
        if (!pp.contains(q) && maze.pass(p, d)) {  // Se non c'è collisione
            move(c.getNode(), q); // Muove il giocatore nella nuova posizione
            pp.remove(p);         // Rimuove la vecchia posizione e
            pp.add(q);            // aggiunge quella nuova
            c.setState(State.GO, q);   // Aggiorna lo stato e la posizione
            if (q.equals(treasure)) winner = c;  // Ha trovato il tesoro
        } else              // Se è in collisione
            c.setState(State.COLLISION, null);
    }
    if (winner != null || quit) {  // Se c'è un vincitore o il gioco è chiuso,
        for (Control c : controls) // comunica la fine del gioco ai giocatori
            c.setState(c == winner ? State.WIN : State.GAME_OVER, null);
        anim.stop();       // Ferma l'esecuzione degli aggiornamenti
    }
    frameCounter++;   // Incrementa il conteggio dei frame
    // Notifica che il frame è stato eseguito. Ogni thread di giocatore
    // che era in attesa è risvegliato e riprenderà l'esecuzione non
    notifyAll();    // appena riotterrà il lock su questo oggetto.
}
. . .

Si noti che il metodo è synchronized quindi essendo un metodo dell'oggetto MazeGame richiede il lock sullo stesso oggetto chiamato boss sul quale sono sincronizzati i thread dei giocatori. Così può invocare notifyAll() su tale oggetto e siamo garantiti che i metodi move delle console dei giocatori non possono essere eseguiti durante l'esecuzione dell'aggiornamento da parte del metodo update. Più precisamente, la loro esecuzione inizia o prima o dopo ma non può iniziare durante l'aggiornamento. Quando un giocatore trova il tesoro, il gioco termina e deve essere fermato l'aggiornamento periodico dei frame e questo è effettuato con anim.stop(), dove anim è l'oggetto che si occupa di invocare periodicamente il metodo update nel JavaFX Thread. Vediamo ora infatti la modifica del costruttore di MazeGame che crea e inizia l'oggetto anim,

. . .
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);
    anim = new Timeline(new KeyFrame(new Duration(FRAME_MS), e -> update()));
    anim.setCycleCount(Timeline.INDEFINITE);
    anim.play();         
}
. . .
public synchronized void quit() { quit = true; }
. . .
private volatile boolean quit = false;
private final long FRAME_MS = 40;      // Durata in millisecondi di un frame
private final Timeline anim;    // Esecutore periodico per gli aggiornamenti
. . .

Per eseguire periodicamente, cioè all'inizio di ogni frame, l'aggiornamento relativo alle mosse comandate dai giocatori nel frame precedente, usiamo una Timeline del package javafx.animation. Una Timeline può essere usata in generale per eseguire animazioni anche piuttosto complesse. Nel nostro caso la usiamo semplicemente come esecutore periodico nel JavaFX Thread. Infatti creiamo un singolo KeyFrame con una durata pari alla durata in millisecondi di un frame e che alla fine di ogni KeyFrame esegue il metodo update. Poi impostiamo con il metodo setCycleCount il numero di cicli tramite la costante Timeline.INDEFINITE che significa che il numero è illimitato e quindi continua ad eseguire il KeyFrame indefinitamente fino a che non è fermata esplicitamente con il metodo stop. Chiaramente, con il metodo play si dà inizio all'esecuzione.

L'applicazione

Per poter provare la nuova versione manca solamente la modifica della voce di menu in MazeApp per creare nuovi giocatori. Sostituiamo nel metodo createGUI la creazione del MenuItem addPlayer con il seguente Menu per creare anche giocatori RPlayer

private Parent createGUI() {
    . . .
    Menu addPlayer = new Menu("Add Player");
    MenuItem mi = new MenuItem("HPlayer");
    mi.setOnAction(e -> game.add(new HPlayer()));
    addPlayer.getItems().add(mi);
    mi = new MenuItem("RPlayer");
    mi.setOnAction(e -> game.add(new RPlayer()));
    addPlayer.getItems().add(mi);
    . . .
}

/** Crea e inizia un nuovo gioco. Se c'era un gioco in esecuzione lo termina */
private void newGame() {
    if (game != null) {
        game.quit();
        area.getChildren().remove(game.getNode());
    }
    game = new MazeGame();
    area.getChildren().add(game.getNode());
}

Provandolo e introducendo parecchi giocatori random,

Si noterà che i movimenti sono poco fluidi, un po' a scatti. Questo perché ad ogni frame la posizione di un giocatore è aggiornata passando bruscamente da una posizione a quella nuova. Per rendere i movimenti un po' più fluidi modifichiamo il metodo move di MazeGame in modo tale che lo spostamento di posizione sia effettuato tramite una transizione graduale (anche se piuttosto veloce)

. . .
/** Sposta un Node nella posizione specificata del labirinto con una
 * transizione per rendere fluido il movimento.
 * @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();
    TranslateTransition t = new TranslateTransition(Duration.millis(FRAME_MS),node);
    t.setToX(tx+dx);
    t.setToY(ty+dy);
    t.play();
}
. . .

Abbiamo usato una TranslateTransition con una durata uguale a quella di un frame. La differenza tra le due versioni si nota sopratutto se la durata dei frame è aumentata ad esempio fino a 100ms.

Il giocatore controllato dalla GUI

Veniamo ora al HPlayer. L'implementazione precedente non va bene sia perché invoca metodi per impostare i gestori degli eventi del mouse direttamente nel metodo play che adesso è eseguito in un thread diverso dal JavaFX Thread e sia perché il movimento non può essere effettuato dal giocatore ma da MazeGame per il tramite della console. Già che ci siamo implementiamo anche la possibilità di controllare il movimento tramite i tasti freccia che corrispondono perfettamente alle quattro direzioni di movimento.

package mp.game;

import javafx.application.Platform;
import javafx.scene.Node;
import javafx.scene.image.ImageView;
import javafx.scene.input.KeyCode;

/** Un giocatore per il gioco Find-Treasure che può essere controllato sia con
 * il mouse che con i tasti freccia. */
public class HPlayer implements Player {
    public HPlayer() {
        shape = new ImageView(getClass().getResource("smiley.png").toString());
    }

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

    @Override
    public void play(Console c) {
        Platform.runLater(() -> {
            shape.setOnMousePressed(e -> {
                x = e.getSceneX();     // Registra la posizione attuale del mouse
                y = e.getSceneY();
                disp.reset();          // Lo spostamento è inizializzato a zero
                shape.requestFocus();  // Richiede il focus per i tasti
            });
            shape.setOnMouseDragged(e -> {        // La nuova posizione del mouse
                double mx = e.getSceneX(), my = e.getSceneY();
                disp.update(mx - x, my - y);  // Aggiorna lo spostamento corrente
                x = mx;                  // Registra la nuova posizione del mouse
                y = my;
            });
            shape.setOnKeyPressed(e -> key = e.getCode());
            length = Math.max(shape.getBoundsInParent().getWidth(),
                    shape.getBoundsInParent().getHeight());
        });
        while (true) {
            State s = null;
            if (disp.gte(length)) {     // Se lo spostamento supera la unghezza
                Dir d = disp.getDir();     // della forma, calcola la direzione
                s = c.move(d);
                switch (s) {            // Aggiorna lo spostamento dopo la mossa
                    case GO: disp.update(d, length); break;
                    case COLLISION: disp.reset(); break;
                }
            } else {          // Se non c'è movimento con il mouse, controlla se
                try {         // è premuto un tasto freccia
                    Dir d = Dir.valueOf(key.toString());  // I tasti freccia
                    s = c.move(d);                        // hanno i nomi delle
                    key = null;                           // direzioni
                } catch (Exception e) {}
            }
            if (State.GAME_OVER.equals(s) || State.WIN.equals(s))
                break;
        } 
    }

    private static class Disp {       // Gestisce lo spostamento del mouse
        synchronized void reset() { dx = 0; dy = 0; }
        synchronized void update(double sx, double sy) { dx += sx; dy += sy; }
        synchronized void update(Dir d, double len) {
            switch (d) {
                case UP: dy += len; dx = 0; break;
                case RIGHT: dx -= len; dy = 0; break;
                case DOWN: dy -= len; dx = 0; break;
                case LEFT: dx += len; dy = 0; break;
            }
        }
        synchronized boolean gte(double len) {  // Ritorna true se una delle due
            return Math.abs(dx) >= len || Math.abs(dy) >= len;     // componenti
        }                                           // è maggiore o uguale a len
        synchronized Dir getDir() {      // Ritorna la direzione che corrisponde
            double[] dd = {-dy, dx, dy, -dx};       // allo spostamento, cioè la
            int indMax = 0;                       // direzione lungo la quale lo
            for (int i = 0 ; i < dd.length ; i++)   // spostamento ha la massima
                if (dd[i] > dd[indMax]) indMax = i;                // proiezione
            return Dir.values()[indMax];
        }

        private volatile double dx = 0, dy = 0;
    }

    private final Node shape;        // La forma del giocatore
    private volatile double x, y;    // L'ultima posizione del mouse
    private volatile double length;  // Lunghezza della forma nel labirinto
    private final Disp disp = new Disp();  // Lo spostamento del mouse
    private volatile KeyCode key;    // L'eventuale tasto premuto
}

Prima di tutto le impostazioni dei gestori degli eventi, mouse e tasti, sono eseguite in modo asincrono nel JavaFX Thread tramite Platfom.runlater. Quando il mouse è premuto sulla forma del giocatore, evento MousePressed, è richiesto il focus tramite il metodo requestFocus di Node. Questo per far sì che gli eventi relativi ai tasti siano inviati al nodo del giocatore. Il gestore dell'evento MouseDragged non effettua più il movimento della forma ma aggiorna semplicemente lo spostamento che è gestito tramite l'oggetto disp della classe Disp. Quest'ultima è sincronizzata perché lo spostamento sarà modificato da due thread diversi, il JavaFX Thread e il thread del giocatore. Quando un tasto è premuto, evento KeyPressed, il codice del tasto è ottenuto con il metodo getCode di KeyEvent, e registrato nel campo key. Si noti che key è dichiarato volatile perché deve essere letto anche dal thread del giocatore.

Il controllo tramite il mouse non è molto facile perché i movimenti sono vincolati al passaggio da una cella a una adiacente e quindi sono delle specie di salti. Per cercare di tradurre i spostamenti fluidi del mouse nelle mosse nel labirinto, ci calcoliamo la dimensione length della forma del giocatore nel labirinto. Quest'ultima è approssimativamente uguale alla lunghezza del "salto" da una cella a una adiacente e così possiamo aggiornare lo spostamento a seguito di una mossa effettuata con successo, metodo update(Dir d, double len) di Disp. Inoltre per tradurre lo spostamento del mouse in una delle quattro direzioni ci calcoliamo la direzione lungo la quale lo spostamento ha la massima proiezione, metodo getDir di Disp.

Il controllo tramite i tasti freccia è molto più facile. La direzione della mossa è data proprio del nome del codice del tasto freccia premuto.

Provandolo,

si noterà che il controllo tramite il mouse non è molto agevole, per le ragioni sopraddette, mentre quello tramite i tasti risulta più naturale.

Class loading

Java permette di fare il caricamento (loading) di nuove classi al runtime, cioè durante l'esecuzione del programma. Tale meccanismo si chiama class loading e rende disponibile al programma in esecuzione la definizione di una nuova classe dati i class file, cioè file con estensione .class, che contengono la definizione compilata della classe. Generalmente la nuova classe implementa un'interfaccia o estende una classe astratta che è già presente nel programma. Questo per far sì che il programma possa "conoscere" i membri della nuova classe e poterli così usare. Inoltre la nuova classe deve avere un costruttore senza parametri perché altrimenti non si saprebbe come creare oggetti della nuova classe.

Vediamo questo meccanismo all'opera per caricare al runtime nuovi tipi di giocatori. Imponiamo che le nuove definizioni appartengano a un package con lo stesso nome, cioè mp.game.

Introduciamo in MazeApp una nuova voce di menu App Player... per permettere all'utente di scegliere il class file di un nuovo giocatore. L'azione collegata invocherà il metodo addCPlayer,

private Parent createGUI() {
    . . .
    // Permette di caricare un nuovo giocatore scegliendo un class file con
    // l'implementazione di un giocatore (cioè, una classe che implementa
    // PlayerC). Si assume che la classe appartenga al package mp.game.
    MenuItem addCPlayer = new MenuItem("Add Player...");
    addCPlayer.setOnAction(e -> addCPlayer());
    Menu menu = new Menu("Game", null, newGame, addPlayer, addCPlayer);
    . . .
}

/** Permette all'utente di scegliere un class file che implementa un
 * giocatore e di aggiungerlo al gioco. Assume che la classe appartenga a
 * un package mp.game e che abbia un costruttore senza parametri. */
private void addCPlayer() {
    Window owner = area.getScene().getWindow();
    FileChooser fc = new FileChooser();
    fc.setTitle("Load Player Class");
    fc.getExtensionFilters().add(new FileChooser
            .ExtensionFilter("Class Files", "*.class"));
    File selectedFile = fc.showOpenDialog(owner);
    if (selectedFile == null) return;
    try {
        String fn = selectedFile.getName();
        String className = "mp.game." + fn.substring(0, fn.indexOf("."));
        URL url = selectedFile.getParentFile().getParentFile()
                .getParentFile().toURI().toURL();
        // Crea un loader di class file che usa lo specificato URL per
        // trovare la classe da caricare e le eventuali altre classi o
        // risorse. L'URL specificato deve puntare alla directory che
        // contiene la directory del package di base della classe che
        // si vuole caricare.
        URLClassLoader classLoader = new URLClassLoader(new URL[]{url});
        // Usa il caricatore di class file per caricare il class file
        // scelto dall'utente. È necessario che il class file sia
        // effettivamente un'implementazione di Player.
        Class<? extends Player> playerClass =
                Class.forName(className, true, classLoader)
                        .asSubclass(Player.class);
        if (game != null)
            game.add(playerClass.newInstance());
    } catch (Exception e) { e.printStackTrace(); }
}

Il metodo addCPlayer prima di tutto mostra una finestra di dialogo per scegliere un file, fornita da FileChooser in javafx.stage. Creando un filtro per l'estensione .class con la classe statica di FileChooser ExtensionFilter e aggiungendolo alla lista dei filtri getExtensionFilters, si vincola la scelta solamente a class file. Se l'utente sceglie un file, cerchiamo di fare il loading della classe contenuta nel file. Il modo più semplice è usare un class loader già pronto URLClassLoader in java.net, che carica class file a partire da uno o più URL dati. L'URL (o gli URL) è usato come class-path e quindi deve permettere di trovare tutte le eventuali altre classi, interfacce e risorse richieste per il funzionamento della classe che si vuole caricare. Nel nostro caso è bene che l'URL punti alla directory che contiene la directory del package di base mp. Tramite il class loader ottenuto possiamo tentare di caricare la classe usando il metodo statico forName(String name, boolean initialize, ClassLoader loader) di Class, che prende in input il nome name completo della classe, cioè comprensivo del nome del package, ad es. mp.game.NewPlayer, un booleano initialize che se true significa che la classe sarà inizializzata e infine un class loader. Se ha successo, ritorna l'oggetto Class<?> della nuova classe. Per poter usare la nuova classe dobbiamo fare il cast a Player con il metodo asSubclass passandogli Player.class. Infine, se anche quest'ultima operazione va a buon fine, invochiamo sull'oggetto classe ottenuto (di tipo Class<? extends Player>) il metodo newInstance che crea un'istanza della classe, nel nostro caso un giocatore del nuovo tipo. Affinché quest'ultimo metodo riesca è necessario che la classe abbia un costruttore senza parametri.

Per mettere alla prova ciò, creiamo un progetto per nuovi giocatori, nel quale definiamo un package mp.game al cui interno definiamo una copia dell'interfaccia Player. Poi definiamo un nuovo tipo di giocatore,

package mp.game;

import javafx.scene.Node;
import javafx.scene.image.ImageView;

/** Un giocatore per Find-Treasure che usa la strategia della mano destra */
public class FirstRight implements Player {
    public FirstRight() {
        shape = new ImageView(getClass().getResource("ghostBlue.png").toString());
    }

    @Override
    public void play(Console c) {
        int dir = 0;
        while (true) {
            dir = (dir + 1) % 4;
            State s = c.move(Dir.values()[dir]);
            while (State.COLLISION.equals(s)) {
                dir = (dir + 3) % 4;
                s = c.move(Dir.values()[dir]);
            }
            if (State.GAME_OVER.equals(s) || State.WIN.equals(s)) break;
        }
    }

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

    private Node shape;
}

Compiliamo il progetto e per far sì che l'immagine ghostBlue.png che rappresenta il giocatore sia recuperabile dal class loader, la mettiamo nella stessa directory che contiene i class file del progetto compilato, ad es. in ...out/mp/game.

Per provarlo basterà scegliere Add Player... dal menu e poi con il dialogo basterà selezionare il file FirstRight.class.

Il nuovo giocatore è capace di visitare efficientemente l'intero labirinto. Ma cosa succede se il labirinto ha dei cicli?

Esercizi

[Cicli]    Definire in MazeGen un metodo che crea cicli in un labirinto.

[Config]    Permettere la configurazione tramite la GUI di vari parametri del gioco come le dimensioni del labirinto, i colori, la velocità (durata dei frame), ecc.

[Players]    Aggiungere altri giocatori al gioco implementando altre classi di tipo mp.game.Player.

[Win]    Modificare MazeGame in modo che quando un giocatore trova il tesoro inizia un'animazione della forma del giocatore che mostra la gioia della vittoria.

[Moves]    Aggiungere altre mosse al gioco e dare al giocatore la possibilità di avere informazioni locali circa la posizione in cui si trova. Ad esempio, possibilità di "vedere" fino ad alcune celle di distanza, distinguere le collisioni con i muri da quelle con altri giocatori.

24 Mag 2016


  1. È necessario che prima dell'invocazione di obj.wait() si sia acquisito il lock su obj.

  2. L'insieme delle mosse possibili potrà essere esteso successivamente, si veda ad es. l'esercizio [Moves].

  3. Si potrebbe pensare a una versione di giocatore umano, cioè controllato da una UI, che non vede il labirinto e che quindi è alla pari dei giocatori-programma in relazione a questo aspetto. Per esempio, può solamente decidere la direzione di movimento con i tasti freccia e gli viene comunicato quando urta contro un ostacolo. Ma il labirinto non è mostrato.

  4. Possibili estensioni possono prevedere che in caso di collisione il giocatore possa sapere se ha urtato contro un muro o un altro giocatore ed eventualmente avere informazioni circa la geometria locale del labirinto, si veda ad es. l'esercizio [Moves].