Struttura delle classi per
NetGame
La volontà di inserire PingGame
come modulo all'interno di NetGame ha necessariamente portato
alla creazione delle classi necessarie ed alla loro strutturazione
come si può vedere dalla seguente figura :
Organizzazione e Tassonomia delle
classi
Le classi sono organizzate in tre packages
: shared , server e client .
Questa suddivisione consente di distribuire solo le classi
effettivamente necessarie. In particolare il package shared
sarà necessario a tutti, mentre server e client usaranno
i package omonimi.
Package Shared
Il package Shared contiene le
definizioni delle classi IPingGameServer e IPingGameClient ,
che specializzano rispettivamente IGameServer ed IGameClient
per questa applicazione.
In particolare IPingGameClient presenta 4 metodi aggiuntivi
:
InfoKey prepare(IPingGameServer pgServer) , invocato
dal server sul master per iniziare il gioco
void start(InfoKey infoKey, IPingGameServer pgServer) ,
invocato dal server sullo slave per iniziare il gioco
void setMasterName(String name) , imposta il nome
del giocatore master
void setSlaveName(String name) , imposta il nome
del giocatore slave
L'interfaccia IPingGameServer
non presenta invece nessun metodo aggiuntivo, limitandosi
semplicemente ad estendere IGameServer .
Nel package Shared si definisce
inoltre la gerarchia dei messaggi che si possono scambiare
i due clients:
La superclasse astratta
PingGameMessage presenta la variabile mode ,
che come si è detto, serve a distinguere alla ricezione
i tipo di messaggio. Ogni sottoclasse le assegnerà
un valore opportuno.
Per la scrittura e la lettura
di messaggi su socket si sono realizzate due classi : PingGameOutputStream
e PingGameInputStream . Esse ereditano rispettivamente
da java.io.DataOutputStream e java.io.DataInputStream .
PingGameOutputStream definisce il metodo
writeMessage(PingGameMessage)
Il quale non fa altro che invocare il
metodo writeYourself() sul messaggio passatogli come
parametro. Tale metodo provvederà a scrivere opportunamente
il messaggio sullo stream della socket.
PingGameInputStream definisce il metodo
PingGameMessage readMessage()
il quale legge un intero e quindi, avendo
riconosciuto il tipo di messaggio, ne legge tutti i campi
e lo ricostruisce, fornendolo quindi in uscita.
Nel package Shared viene definita
anche la classe InfoKey , più volte ricordata.
Package Server
Il package Server comprende semplicemente
la classe PingGameServer . Le funzioni di tale classe,
vista la struttura di comunicazione che si è data a
questa applicazione si riducono a :
- Implementare i metodi dell'interfaccia IPingGameServer
per potere essere ospitato in NetGame
- Realizzare il coordinamento iniziale dei clients : metterli
in contatto e dare via alla partita
Di fatto l'interfaccia IPingGameServer
non aggiunge nessun metodo a quelli di IGameServer .
Semplicemente nel metodo start() il PingGameServer
invocherà prima prepare() sul client-master,
ottenendo un oggetto InfoKey , quindi invocherò
start() sul client-slave, passando come parametro tale
oggetto.
Package Client
Struttura del package Client
Il package Client è formato
da due classi pubbliche PingGameClient e PingGameCLientUI .
La classe PingGameClientUI implementa l'interfaccia
grafica.
La classe PingGameClient è la principale del
gioco. Essa contiene tutta la logica necessaria ad interfacciarsi
col server e implementare tutti i comportamenti già
presentati nell'analisi e nel progetto.
In particolare presenterà i due metodi prepare()
e start() , invocati dal Server PingGame per stabilire
la connessione iniziale tra i due giocatori. Il client su
cui sarà invocato prepare() si imposterà
come master, e creerà una SocketServer su cui
attendere la connessione dello slave. Restituirà un
oggetto di classe InfoKey contenente tutte le informazioni
necessarie allo slave per effettuarla.
Una volta stabilita la connessione sarà possibile effettuare
le modifiche dei parametri : se un utente vuole settare un
particolare valore di un parametro lo fa agendo sul corrispondente
elemento nell'interfaccia grafica. A questo punto sarà
richiamato il metodo
boolean parameterChanged(int parameter, Object newValue)
sulla classe PingGameClient .
Tale metodo verifica che la variazione sia lecita (per esempio
che non ecceda il dominio del parametro e che il giocatore
possa farla - si ricordi al riguardo che lo slave può
modificare solo i suoi parametri personali). Se lo è
la effettua, la comunica all'altro e ritorna true, altrimenti
ritorna false.
I Thread
Si è visto come le operazioni
che e deve compiere contemporaneamente il client siano tre.
Esse vengono mappate in altrettante classi che ereditano da
java.Thread :
InputThread
SocketThread
UpdateThread
InputThread si occupa di acquisire
l'input dell'utente da tastiera durante una partita
SocketThread riceve i messaggi dall'avversario.
UpdateThread si occupa dell'aggiornamento dell'interfaccia
grafica durante la partita.
InputThread e UpdateThread devono effettuare
operazioni a intervalli di tempo costanti. InputThread
dovrà verificare ad intervalli regolari quali tasti
siano premuti, mentre UpdateThread dovrà modificare
posizione, velocità ed accelerazione degli elementi
del gioco.
Per questo entrambe queste classi presentano esternamente
la stessa struttura: viene definita una variabile delay
(=intervallo di tempo fra due istanti di valutazione) ed il
metodo run() ha la seguente struttura :
public void run() {
executing = true;
long t = System.currentTimeMillis();
while (executing) {
//Operazioni da compiere ogni delay msec.
try {
t += delay;
Thread.sleep(Math.max(0, Math.min(t - System.currentTimeMillis(), delay)));
} catch (InterruptedException e) {
}
}
In questo modo ci si cautela, almeno
in parte, riguardo al fatto che uno stesso blocco di codice
venga eseguito in tempi anche notevolmente diversi su macchine
diverse.
Se si fosse messo solo Thread.sleep(delay) , l'intervallo
di tempo tra due esecuzioni successive sarebbe stato uguale
a delay+tempo_di_esecuzione , introducendo quindi una
frequenza di aggiornamento o di acquisizione dell'input dipendente
dal tempo di esecuzione.
Teoricamente lo stesso risultato si sarebbe potuto ottenere
con la seguente istruzione
Thread.sleep(Math.max(0, t - System.currentTimeMillis()))
ma si è verificato che la chiamata
System.currentTimeMills() non aveva precisione abbastanza
accurata, e più chiamate successive potevano dare lo
stesso risultato, causando attese sempre maggiori ( e paradossalmente
anche maggiori a delay , che è per principio
l'estremo superiore)
Per rilevare quali pulsanti siano premuti
ad un certo istante, si è collegato un KeyAdapter
ad un elemento dell'interfaccia grafica (visibile durante
la partite). I metodi keyPressed() e keyReleased()
di tale classe settano opportunamente delle variabili booleane
: una per ogni tasto significativo (4 frecce e tasto esc).
Il thread InputThread andrà poi ad analizzare
tali valori.
Si è già detto in fase
di progetto come sia necessaria una sincronizzazioni tra i
thread.
UpdateThread e SocketThread si devono escludere
mutamente quando vanno ad operare sullo stato, cioè
quando devono valutare ed eventualmente eseguire una transizione
nel diagramma degli stati presentato nel progetto. Questo
viene fatto sincronizzandosi sulla variabile oggetto statusSem .
UpdateThread e InputThread si devono invece
escludere mutuamente quando vanno ad operare sulle variabili
che memorizzano l'input dell'utente. Lo fanno sincronizzandosi
sulla variabile oggetto semaphore .
La sincronizzazione si può ottenere semplicemente in
java mediante la seguente porzione di codice:
synchronized (semaforo) {
//codice da eseguire in muta esclusione
}
Implementazione e problemi della comunicazione
Per la comunicazione tra i client si
sono usate socket TCP. Si è scelto questo supporto
connection-oriented e più affidabile rispetto a UDP
perché è importante che i clients ricevano i
messaggi in ordine ed è importante che non se ne perdano,
in quanto l'evoluzione dello stato è basata anche su
di essi. Se andasse per esempio perso un messaggio del tipo
ChangeControlMessage i due giocatori si troverebbero
in due stati non più consistenti e non saprebbero interpretare
correttamente i messaggi successivi.
In particolare dopo aver connesso la socket, si estraggono
OutputStream ed InputStream e si generano oggetti
degli Stream propri dell'applicazione :
in = new PingGameInputStream(connection.getInputStream());
out = new PingGameOutputStream(connection.getOutputStream());
Data la relativa semplicità
della natura dei messaggi che si devono scambiare, la banda
richiesta dal gioco è piuttosto limitata. Considerando
che per la maggior parte del tempo un cliente sarà
nel primo stato e trasmetterà messaggi di tipo MyControlInfoMessage
(6 interi), mentre l'altro si troverà nel terzo stato
e trasmetterà messaggi YourControlInfoMessage (4 interi),
e considerando che questo avviene 20 volte al secondo, si
può fare una semplice approssimazione della banda complessiva
(in + out) richiesta :
(6+4)*20 = 200 bytes/sec
A cui naturalmente sono da aggiungere tutte le informazioni
(headers e footers) introdotte dai vari livelli di protocollo
TCP/IP. Ad ogni modo, facendo misurazioni di carico, anche
attraverso connessioni internet, si è rilevato una
quantità di byte trasmessi/ricevuti al secondo pari
a circa 3 KB.
Piuttosto il fattore limitante è la latenza; c'è
la necessità di tenere il più possibile sincronizzati
i due clients, di modo che abbiano visioni dello stato istante
per istante il più possibile consistenti. Si ottine
questo risultato mediante l'uso del campo currrentFrame
all'interno dei messaggi e mediante un accorgimento: si introduce
la costante maxFrameDelay , che, come del resto si può
facilmente intuire dal nome, esprime il massimo disallineamento
tra il frame corrente e quello remoto. Se durante l'esecuzione
di un update ci si accorge il current frame corrente locale
ha un indice superiore a quello remoto di maxFrameDelay ,
non si fanno più aggiornamenti finché non si
è rientrati in un gap accettabile. In sostanza si blocca
l'UpdateThread mentre si continua a fare lavorare il
SocketThread per ricevere messaggi sufficientemente
aggiornati:
boolean ritardo = true;
while (ritardo && executing) {
if (currentFrame == Integer.MAX_VALUE) {
while (executing && (rCurrentFrame!=Integer.MAX_VALUE)) {
try {
Thread.sleep(delay);
} catch (InterruptedException e) {
}
}
ritardo = false;
currentFrame = -1; //perché dopo faccio currentFrame++
}
else if (rCurrentFrame > currentFrame-maxFrameDelay) {
//Disegno
}
if (ritardo) {
try {
Thread.sleep(delay);
t+=delay;
} catch (InterruptedException e) {
}
}
}
Il tipo di variabile currentThread
è un intero; bisogna trattare il caso di overflow,
cioè quando il suo valore raggiunge il valore massimo
(una partita potrebbe durare più di Integer.MAX_VALUE
* 50 ms = 29826 ore ?!? Diciamo che si è trattato il
caso per scrupolo). Questo è ciò che viene controllato
come prima operazione : se siamo giunti a tale valore per
currentFrame , attendiamo l'avversario e ricominciamo
con frame 0.
La scelta di passare il controllo della pallina, unitamente
alla non perfetta sincronizzazione dei frames comporta un
leggero sfarfallio dell'immagine quando la pallina transita
fuori dall'area di un giocatore. Questo è comunque
accettabile, tanto più che il client non è coinvolto,
ficnhé la pallina non rientrerà nella sua area,
in scontri con essa.
Si è poi agito sulle opzioni
di socket modificabili in Java per ottenere un servizio maggiormente
aderente alle nostre necessità.
In particolare si è disabilitato l'algoritmo di Nagle
:
connection.setTcpNoDelay(true);
e si sono limitati i buffer di ricezione
e trasmissione
connection.setReceiveBufferSize(300);
connection.setSendBufferSize(300);
Questo impostazioni vanno a vantaggio
della latenza penalizzando invece l'occupazione di banda,
in quanto viene fortemente limitata la bufferizzazione e di
conseguenza viaggiano più pacchetti e con essi più
informazioni di overhead. Si è trattato di ottenere
un buon bilanciamento tra queste due esigenze.
Miglioramenti possibili
A livello implementativo si potrebbero
applicare dei miglioramenti nell'ottica di rendere l'applicazione
il più leggera possibile e di diminuire ulteriormente
la banda occupata.
Per quanti riguarda il primo punto, diciamo che il grosso
del carico computazionale che deve sostenere un client è
nel calcolo delle variabili di posizione e velocità
degli elementi coinvolti. Inoltre è necessario che
tale calcolo sia sufficientemente veloce perché è
inserito in una routine di refresh dell'immagine, invocata
periodicamente.
Si sono usate variabili posizione di tipo int e variabili
velocità ed accelerazione di tipo double, ottenendo
una precisione sovradimensionata rispetto alle effettive esigenze
dell'applicazione. Una soluzione potrebbe quindi essere quella
di utilizzare tipi di dato meno onerosi, come unsigned byte
e float (o eventualmente anche int per le velocità,
avendo cura di arrotondare i risultati delle operazioni che
potrebbero essere anche reali).
Analogamente per quanto riguarda il secondo punto: un uso
di tipi di dato più leggeri può diminuire anche
la banda richiesta dall'applicazione.
Tecniche di calcolo
Il fatto di aver discretizzato le leggi
che regolano il moto degli elementi coinvolti nel gioco e
di considerare di conseguenza solo istanti separati da un
intervallo di tempo può portare a situazioni come quella
raffigurata nella seguente figura :
Evidentemente in questo
caso bisognerà rilevare lo scontro, anche se, valutando
da sola la seconda immagine, sembrerebbe che lo scontro ci
sia già stato. Si rimanda al codice per vedere come
sono trattate tali situazioni.
Aggiornamento dell'interfaccia grafica
Per velocizzare l'aggiornamento
dell'interfaccia grafica si è proceduto (a livello
di MainClientUI ) nel seguente modo. Si opera con la
tecnica del double-buffering; l campo con l'indicazione delle
aree e delle porte viene disegnato una volta per tutte, all'inizio
della partita mediante l'invocazione del metodo drawGround() .
Per ogni aggiornamento poi vengono ridisegnate solo le aree
che hanno subito una modifica rispetto al frame precedente:
si tratterà di tre aree rettangolari, ciascuna per
ogni elemento (2 racchette e pallina), che comprendano vecchia
e nuova posizione dell'elemento :
La procedura MyFinish()
la procedura myFinish() si occupa
di libare le risorse allocate (chiudere le socket, fermare
i thread) e di comunicare al server che la partita è
finita. Tele procedura viene invocata quando un giocatore
subisce l'ultimo goal (e quindi la partita è terminata
in modo corretto) o quando si verificano dei problemi di comunicazione
tra i pari.
Interfaccia grafica
Cliccare sull'immagine per vedere le
versione ingrandita.
Le prime tra figure mostrano come appare
l'interfaccia utente del client durante la fase di impostazione
dei parametri.
In fig.1 è possibile settare i parametri geometrici
e dinamici, il numero di goals per vincere ed iniziare la
partita (da parte del master, naturalmente).
In fig.2 si modificano i valori del campo e della pallina
ed è visibile in basso un'indicazione di che aspetto
assumerà il campo durante la partita.
In fig.3 Ogni giocatore può impostare i suoi parametri
personali (colore e nome) ed allo stesso tempo vedere visualizzati
quelli dell'avversario.
La fig.4 invece rappresenta il campo di gioco vero, mentre
una partita è in esecuzione.
|