Metodologie di Programmazione: Lezione 23
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
.
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.
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.
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
.
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:
synchronized
di obj
synchronized
sull'oggetto obj
obj
e di tipo Class
, eseguendo un metodo statico synchronized
della classe.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.
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.
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.
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.
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?
[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
È necessario che prima dell'invocazione di obj.wait()
si sia acquisito il lock su obj
.↩
L'insieme delle mosse possibili potrà essere esteso successivamente, si veda ad es. l'esercizio [Moves].↩
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.↩
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].↩