Le regole del gioco possono essere divise in due parti fondamentali:
Con mossa non si intende solo il pezzo che si vuole muovere ma anche la posizione e la rotazione con cui il pezzo deve essere posizionato sul tavolo. Le due classi Block, Map rappresentano due livelli di dettaglio con cui vengono implementate le regole del gioco e grazie alla programmazione ad oggetti possono essere realizzate aggiungendo funzioni nuove al livello immediatamente inferiore. Le tre classi sono quindi una l'estensione dell'altra.
Viene ora descritto il funzionamento di queste parti e le relazioni che intercorrono tra loro. Per una descrizione dettagliata si rimanda alla lettura del codice e dei suoi commenti.
Questa classe implementa la gestione dei pezzi. Fornisce le primitive per:
Il giocatore a cui viene assegnato il singolo pezzo viene memorizzato in una matrice di dimensione numblock*numblock, con numblock=7 ovvero il numero di figure che possono apparire in mezzo pezzo. Di questa matrice viene utilizzata solo la metà triangolare in basso a sinistra.
Un pezzo viene individuato da due numeri x,y che rappresentano le due figure, per rispettare il vincolo sopra esposto deve essere:
Se y>x le due coordinate vengono automaticamente scambiate.
Il numero di giocatore a cui può essere assegnato un pezzo appartiene all'insieme 0..numPlayer dove numPlayer rappresenta il numero totale di giocatori. 1..numPlayer sono giocatori ``veri'' mentre 0 è associato ad un giocatore ``fittizio''.
Il giocatore fittizio viene utilizzato nei client dove, i pezzi vengono distribuiti fra il giocatore assegnato al client e il giocatore fittizio. Il giocatore fittizio rappresenta l'insieme degli avversari; al giocatore fittizio vengono assegnati tutti i pezzi posseduti dagli avversari; quando un avversario muove, nei client e come se fosse il giocatore fittizio a muovere.
I tre metodi di gestione dei pezzi verificano la correttezza dei parametri. Se il pezzo non esiste restituiscono il codice -1. Se il giocatore non esiste il programma viene terminato. Questo diverso comportamento dipende dal fatto che il primo evento dipende da un errore del giocatore che può aver specificato in modo errato il pezzo ed è quindi ammissibile, il secondo dipende da un errore del server che ha specificato un giocatore non esistente. Dato che il server conosce quanti giocatori ci sono questo è un evento che può capitare solo se il codice è scorretto e non è quindi ammissibile.
Questa classe implementa la gestione del tavolo su cui si posizionano i pezzi. Fornisce le primitive per:
Lo stato della partita assume tre valori:
Prima di passare alla seconda fase è necessario definire il numero di giocatori ed aver distribuito i pezzi. Lo stato del tavolo è definito in una matrice di dimensione size.x*size.y, dove size viene specificato nel costruttore. Ogni punto della matrice assume il valore -1 che indica posizione vuota oppure il numero della figura che la occupa.
I metodi check e putmap rispettivamente verificano la correttezza di una mossa ed eseguono una mossa. Una mossa è individuata dal giocatore che la esegue, dal pezzo mosso e dalla posizione data da punto e rotazione.
Check restituisce:
PutMap restitisce un codice nell'intervallo (-8,0) con significato identico a quello restituito da Check.
Il server può essere diviso in quattro parti fondamentali:
Viene ora descritto il funzionamento di queste parti e le relazioni che intercorrono tra loro. Per una descrizione dettagliata si rimanda alla lettura del codice e dei suoi commenti.
Questa classe contiene il metodo main() del server, ed esegue alcune funzioni elementari tra cui:
L'unica condizione d'errore controllata è la creazione del ServerSocket. Un fallimento di questa provoca, ovviamente, la chiusura del programma. Altra condizione d'errore può accadere all'accettazione, ma questo provoca semplicemente una nuova accettazione. Tutte queste condizioni d'errore sull'IO dai socket, qui come in tutte le altre classi, vengono intercettate tramite la gestione delle eccezioni java.
La classe Map, che gestisce le regole e lo stato del gioco, è unica per server e client. Ma ovviamente il server ha bisogno di conoscere una serie di informazioni in piú rispetto al client, e quindi ServerMap non è altro che una estensione di Map che aggiunge le primitive per:
Questi metodi potevano ovviamente essere implementati in ServerGame, ma abbiamo preferito utilizzare la potenza della gestione ad oggetti e quindi l'estensione di Map.
E' la classe principale del programma, essa fornisce un interfaccia fra i messaggi dei client e l'effettivo aggiornamento dello stato del gioco. Esegue le seguenti funzioni:
Fornisce metodi per:
La filosofia di base di tutto è proprio nella gestione dell'accoppiata ServerGame/ServerThread; le istanze di ServerThread si preoccupano di leggere (tramite il loro metodo run) dal socket; appena ricevono un comando sintatticamente valido questo viene passato all'istanza di ServerGame chiamando un metodo synchronized della stessa. In questa maniera le istanze di ServerThread possono lavorare in maniera indipendente, salvo poi entrare in conflitto appena un comando valido è stato accettato. Questo conflitto è risolto ponendo sincronizzata la classe ServerGame. Di questo si discuterà abbondantemente alla sezione ``Sincronizzazioni''.
La comunicazione in uscita con i client avviene tramite i metodi write* di ServerThread, se vi sono degli errori, viene generato un IOException la cui gestione viene effettuata dal metodo doDEAD() in ServerGame.
Ora discutiamo brevemente dei metodi di ServerGame.
Lo stato di un client è determinato da un puntatore e da un intero.
Il puntatore punta a una istanza di ServerThread, e se questo è a null significa che il client non esiste (oppure è morto). L'intero rappresenta il numero di giocatore associato a un ServerThread, e se è a zero significa che il giocatore non esiste o non si è presentato. I due parametri sono indipendenti, e combinandoli opportunamente è possibile definire uno ``stato'' affidabile di un client, ovvero (considerando la coppia puntatore, numero di giocatore):
(stato 0) il client è vivo, ed ha assegnato un numero di giocatore (quindi ha dato un comando NAME valido)
(stato 1) il client è vivo, ma si deve ancora presentare. Di sicuro la partita non è iniziata, a partita iniziata questo stato non è possibile
(stato 2) il client è morto, ma stava giocando e la partita era iniziata; ci serve mantenere l'informazione sul suo numero di giocatore per i punteggi
(stato 3) il client è morto a partita non iniziata, o non si è mai presentato
La prima riga di ogni metodo do* di ServerGame verifica lo stato del client che ha eseguito il comando e si comporta di conseguenza. Questo permette di disabilitare tutte le funzioni a partita finita, ed ignorare comandi richiesti dal giocatore. Inoltre la chiusura di un socket da parte di un client non svuota il buffer di lettura dallo stesso socket dei server: senza questo controllo i comandi presenti nel buffer potrebbero essere interpretati ed eseguiti e generare errori runtime.
Cerca il prossimo thread che deve muovere tra quelli attivi.
Esegue dei controlli sul numero di giocatori (se è ==1 il gioco è da terminare) e sulla abilità o meno dei giocatori di muovere (se nessuno può muovere il gioco è da terminare).
Ritorna -1 se il gioco è da terminare.
Un ServerThread ha ricevuto da un client un comando NAME sintatticamente valido; il comando viene accettato se è stato fatto da un client in stato 1.
Se il comando viene accettato il nome del nuovo giocatore viene spedito a tutti i client abilitati (compreso l'autore del name). Se si è raggiunto il numero massimo di giocatori il gioco inizia automaticamente (chiamata esplicita a doSTART). Non serve un controllo sul stato della partita, se la partita è iniziata non esistono client in stato 1.
Un ServerThread ha ricevuto da un client un comando START sintatticamente valido; il comando viene accettato se è stato fatto da un client in stato 0 e se la partita non è già iniziata, oltre a controllare che ci siano almeno 2 giocatori attivi.
Se il comando viene accettato vengono avvisati i client collegati dell'inizio della partita e vengono distribuiti i pezzi; inoltre tutti i client in stato 1 vengono immediatamente posti in stato 3 (uccisi): questo garantisce che non esistano client in stato 1 a partita iniziata.
Un ServerThread ha ricevuto da un client un comando MOVE sintatticamente valido; il comando viene accettato se è stato fatto da un client in stato 0.
Se il comando viene accettato vengono avvisati i client collegati della mossa, compreso l'autore, e viene aggiornata l'informazione del thread attivo.
Il metodo doDEAD() disabilita il giocatore a muovere, chiude il socket e rilascia l'istanza di ClientThread ponendone il suo valore a null. Sarà compito del garbage collector recuperare le zone di memoria non piú utilizzate. Inoltre fa dei controlli sul numero di giocatori ancora vivi, e se ne è rimasto solo uno termina la partita, oltre ad modificare il thread attivo se è morto proprio questo.
Se è morto un thread attivo a partita iniziata, il suo stato viene posto a 2. Se la partita finisce, perché è morto il penultimo giocatore (doDEAD), o perché nessuno può muovere o un giocatore ha appena mosso l'ultimo suo pezzo (doMOVE), viene chiamato il metodo theEND che invia i punteggi ai client e chiude i socket. Inoltre se il gioco è terminato doDEAD ritorna un valore -1 che permette di uscire dai cicli.
In tutti questi metodi do* occorre, ad un certo punto, avvisare gli altri client di un evento, solitamente utilizzando le write* che possono quindi fallire; può accadere che all'interno di un ciclo for che compie queste operazioni muoia un giocatore e questo provochi la fine della partita: non è più necessario compiere altre operazioni e conviene uscire dal ciclo, perché lo stato di server e client è già stato ridefinito dalla theEND chiamata da doDEAD.
Chiamata da doDEAD e doMOVE nel caso che la partita sia finita, semplicemente invia i punteggi e chiude i thread in maniera diretta e brutale.
E' la casse che gestisce la comunicazione con i client.
Crea un istanza della classe Parser per l'interpretazione dei comandi.
Fornisce:
Un thread è un'istanza di una classe la cui definizione contiene un metodo run che viene eseguito parallelamente e indipendentemente dal programma principale e dagli altri thread.
Questa è una classe statica di utilità che serve a uniformare il logging e che, dato un livello di logging, scrive i messaggi di livello uguale o inferiore.
I livelli sono:
Visto che java tende a essere un divoratore di risorse (specialmente di memoria) è stata aggiunta questa classe statica e sincronizzata di utilità che semplicemente ``conta'' i giochi che sono contemporaneamente in esecuzione e impedisce di crearne di nuovi se si è raggiunto il tetto massimo, tetto definibile con una opzione in linea di comando.
Il client può essere diviso in quattro parti fondamentali:
Viene ora descritto il funzionamento di queste parti e le relazioni che intercorrono tra loro. Per una descrizione dettagliata si rimanda alla lettura del codice e dei suoi commenti.
È l'applet che deve essere inserito in una pagina HTML, esegue le seguenti funzioni:
Il client viene abilitato a verificare la correttezza delle mosse prima di comunicarle al server definendo nella pagina HTML la variabile check='ON' aggiungendo il comando:
param name=check value="ON"
Il collegamento con il server viene creato definendo una istanza della classe ClientGame. Eventuali errori vengono intercettati tramite la gestione delle eccezioni java.
E' la classe principale del programma, essa fornisce un interfaccia fra gli ordini del server e le operazioni richieste dal giocatore. Esegue le seguenti funzioni:
Fornisce metodi per:
Questi metodi vengono chiamati dalle due classi che gestiscono la comunicazione con il giocatore e con il server, ed effettuano le corrette chiamate alle due stesse classi e alla classe che gestisce le regolo del gioco.
Ad esempio la richiesta da parte del giocatore di muovere (pulsante Move) provoca la chiamata ClientGame.writeMOVE, che verificherà la correttezza della mossa e in caso affermativo chiama ClientThread.writeMove che comunicherà la mossa al server.
La presenza contemporanea di richieste dal giocatore e comandi trasmessi dal server potrebbe portare alla nascita di interferenza. Si veda la sezione ``Sincronizzazioni'' per ulteriori informazioni sulla gestione dell'accoppiata ClientGame/ClientThread.
La comunicazione in uscita con il server avviene tramite metodi di ClientThread, se vi sono degli errori, viene generato un IOException la cui gestione viene effettuata dal metodo doDEAD() in ClientGame. Il metodo doDEAD() termina la partita, disabilita il giocatore a muovere, chiude il socket e rilascia l'istanza di ClientThread ponendone il suo valore a null. Sarà compito del garbage collector recuperare le zone di memoria non piú utilizzate. Il puntatore di ClientThread a null indica che la partita è finita. La prima riga di ogni metodo verifica questa condizione, è solo nel caso che sia falsa effettua l'operazione richiesta. Questo permette di disabilitare tutte le funzioni a partita finita, ed ignorare comandi richiesti dal giocatore. Inoltre la chiusura di un socket non svuota il buffer di ingresso, senza questo controllo i comandi presenti nel buffer potrebbero essere letti ed eseguiti e generare errori runtime.
È la casse che gestisce la comunicazione con il server; crea gli stream di input ed output del socket e crea un istanza della classe Parser per interpretare i comandi in arrivo dal server. Fornisce:
Questa classe gestisce la parte grafica del client, è realizzata tramite le librerie AWT (Abstract Windowing Toolkit) di Java. Mette a disposizione primitive per:
Le operazioni richieste dal giocatori generano un evento che viene gestito nel metodo actionPerformed, per ogni evento viene richiamato il specifico metodo della classe ClientGame.
In un sistema dove evolvono più processi contemporaneamente nasce il problema della sincronizzazione.
Nel server esiste un thread per ogni giocatore collegato, questi potrebbero interferire fra di loro nel caso che arrivino contemporaneamente più comandi. Anche nel client l'arrivo di un comando dal server e una richiesta di un operazione da parte del giocatore potrebbe provocare un'interferenza.
Sono state introdotte le classi ServerGame e ClientGame. Un comando richiesto da uno dei thread in lettura o dal giocatore viene tradotto in una chiamata ad un specifico metodo di queste due classi. Questi metodi sono definiti synchronized. Ciò permette di consumare un comando alla volta all'interno di una sezione critica il cui accesso e mutuamente esclusivo risolvendo il problema della sincronizzazione. Il codice è protetto da condizioni di blocco critico in quanto:
I primi due punti sono caratteristici dei monitor.