Indice - Progetto di Reti di Calcolatori - Fabio Adani e Marco Chiesi
Implementazione

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

fig.1
fig.2
fig.3
 
fig. 4
 

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.

Indietro Inizio pagina Avanti
Indice   Fabio Adani e Marco Chiesi