Comunicazione client-server
Per implementare la comunicazione bidirezionale
tra client e server si sono usate le Socket di Java. In particolare
il server crea una ServerSocket con la quale accetta le connessioni
dei client, i quali ovviamente dovranno conoscere a priori la porta
del server. Per ogni connessione viene generato un nuovo thread che
si occuperà dell'interazione con quel client. Questo thread altro
non è che il PlayerAgent visto in precedenza, pertanto
si tratta di una classe che deriva dalla classe Thread e ne ridefinisce
il metodo run() .
Per gestire più ad alto livello la comunicazione,
cioè ragionando "a messaggi" e non "a byte"
come avviene per le socket, si fa uso degli di stream di Java, ed in
particolare sono state definite due nuove classi RisikoInputStream
e RisikoOutputStream che derivano rispettivamente dalle classi
DataInputStream e DataOutputStream di Java. Queste nuove
classi prevedono metodi per la gestione diretta dei messaggi visti in
precedenza, come readMessage() e writeMessage() . Per l'implementazione
dei messaggi si usa una classe RisikoMessage contenente tutti
i tipi di messaggio già descritti in precedenza.
Si ricorda che lo scambio di messaggi avviene
esclusivamente tra oggetti di classe PlayerAgent e RisikoClient ,
pertanto tali classi hanno la doppia di funzione di poter scrivere messaggi
su un RisikoOutputStream e di leggerli (e gestirli in modo opportuno)
da un RisikoInputStream .
Il RisikoClient ha quindi dei metodi
che vengono invocati tramite l'interfaccia grafica del gioco e che provvedono
ad inviare i relativi messaggi all'agente presente sul server (ad esempio
sendAttack , sendMoveArmies , ecc.), ed inoltre avrà
un meccanismo asincrono (realizzato quindi con un altro thread) per
leggere messaggi provenienti dall'agente. All'arrivo di ogni messaggio
il client gestisce opportunamente il messaggio tramite il meotdo handleMessage ,
modifica la sua visione dello stato del gioco ed aggiorna l'interfaccia
grafica, invocandone opportuni metodi. Il metodo handleMessage in
realtà non fa altro che riconoscere il tipo di messaggio appena
arrivato e quindi chiama un metodo opportuno che lo gestisce, come ad
esempio handleAttackResult , handlePlacedArmies , ecc.
Dall'altro lato, analogamente il PlayerAgent
ha dei metodi che sono invocati dalla classe RisikoGame (che
gestisce il gioco), come ad esempio sendAttack , sendPlacedArmies ,
ecc., e che spediscono messaggi diretti al client, ed altri metodi per
la gestione dei messaggi provenienti dal client, ad esempio handleAttack ,
handleEndTurn , ecc.
Per una visione più completa dei metodi
ora citati si rimanda al codice e
alla documentazione javadoc delle
classi.
Sincronizzazione
La gestione dello stato di una partita è
un aspetto abbastanza delicato dell'applicazione, in quanto, data la
presenza contemporanea di più giocatori, non si può escludere
a priori che i partecipanti possano effettuare nello stesso momento
delle operazioni incompatibili tra loro. E' quindi necessario introdurre
un meccanismo per evitare che si creino dei conflitti. Come detto in
precedenza, la classe che si occupa della gestione delle informazioni
relative a ogni singola partita è RisikoGame . Tale classe
è dotata di diversi metodi che permettono ai giocatori di modificare
lo stato del sistema, i quali metodi vengono invocati dai PlayerAgent
quando ricevono il corrispondente messaggio da parte del client.
Metodo |
Messaggio |
Azione corrispondente |
joinGame |
JOIN_GAME |
ingresso in partita di un giocatore |
leaveGame |
LEAVE_GAME |
abbandono (volontario) di un giocatore |
attack |
ATTACK |
attacco di un territorio, vengono specificati
i territori di arrivo e di partenza ed il numero di dadi usati |
multipleAttack |
M_ATTACK |
attacco multiplo,
analogo al precedente ma l'attacco procede fino a quando è
possibile |
numArmies |
NUM_ARMIES |
comunicazione del numero di armate da spostare in un
territorio appena conquistato |
placeArmies |
PLACE_ARMIES |
posizionamento di una o più armate in un determinato
territorio |
moveArmies |
MOVE_ARMIES |
spostamento di una o più armate tra due territori
adiacenti appartenenti allo stesso giocatore |
tradeCards |
TRADE_CARDS |
scambio di una combinazione di carte |
endTurn |
END_TURN |
passaggio del turno al giocatore successivo |
In considerazione del fatto che a ogni PlayerAgent
corrisponde un thread di esecuzione separato, bisogna fare in modo che
l'invocazione dei metodi sia mutuamente esclusiva, al fine di evitare
la possibilità di raggiungere stati inconsistenti. Si pensi ad
esempio al caso in cui, nella fase iniziale di disposizione delle armate,
siano rimasti due giocatori con un solo carro armato da posizionare
sulla mappa. Il metodo in questione (placeArmies ) dopo aver modificato
lo stato, controlla se tutto è pronto per cominciare e in caso
positivo dà il via alla partita. In questo modo però potrebbe
teoricamente capitare che a seguito dell'invocazione contemporanea dello
stesso metodo da parte di due PlayerAgent distinti, il controllo
della fine del posizionamento abbia successo in entrambe le invocazioni,
con una conseguente doppia chiamata al metodo che inizializza la partita.
In poche parole, si è in presenza di una sezione critica dovuta
all'accesso contemporaneo di due processi ad una stessa risorsa. Un'altra
corsa critica si può verificare ad esempio, durante il gioco,
se un giocatore tenta di uscire (leaveGame ) mentre il giocatore
che lo precede passa il turno (endTurn ). In questo caso potrebbe
capitare che il turno venga assegnato ad un giocatore che non è
più presente, con un conseguente blocco totale dello stato del
gioco.
La realizzazione della mutua esclusione in Java
è molto semplice, infatti è stato sufficiente implementare
tutti i metodi sopracitati come synchronized . In questo modo
è il runtime di Java che garantisce che ci sia al più
un solo thread alla volta che sta agendo sullo stato della partita.
Interfaccia utente
L'interfaccia grafica del client (classe RisikoUI )
è stata realizzata per mezzo del framework Swing di Java, il
quale presenta notevoli vantaggi, come la ricca disponibilità
di componenti e la semplicità di utilizzo. La finestra (realizzata
estendendo la classe JFrame di Swing) è suddivisa in diverse
parti contenenti differenti tipi di informazioni. In particolare è
presente ovviamente un pannello contenente la mappa del mondo virtuale
di Risiko con la relativa disposizione delle armate ed un pannello che
visualizza le carte in possesso del giocatore e permette eventualmente
lo scambio. Data la notevole dimensione di questi due componenti grafici,
essi sono visualizzati alternativamente e si può selezionare
quale vedere tramite le linguette di un componente JTabbedPane .
Oltre a questi è presente un primo pannello che visualizza i
messaggi provenienti dal server che aiutano il giocatore a seguire l'andamento
del gioco, un secondo pannello che mostra la lista dei giocatori che
partecipano alla partita con relative statistiche, un terzo pannello
contenente i dadi ed i punteggi dell'ultimo lancio ed infine un pannello
di controllo con il quale il giocatore può scegliere l'azione
da compiere (attacco, movimento armate, fine turno, ecc.).
Qualche parola in più va spesa relativamente
alla realizzazione della mappa, la quale non è soltanto un componente
che visualizza lo stato attuale del gioco, ma permette anche l'interazione
con il gioco da parte del giocatore. Questo perchè si vuole che
sia possibile durante la partita cliccare con il mouse sulla mappa per
eseguire varie azioni (posizionamento, movimento delle armate e attacchi).
Si introduce quindi la necessità di riuscire a risalire a partire
da un punto che è stato cliccato sulla mappa, al territorio ad
esso corrispondente. Per fare questo si è creata una classe apposita
XYMapper avente il compito di stabilire, date le coordinate di
un punto sulla mappa, a quale territorio tale punto appartiene, tramite
il metodo:
countryAt(int x,int y)
Ecco un paio di schermate dell'applicazione (cliccare sull'immagine
per vedere la versione ingrandita).
Funzionamento in ambiente NetGame
In considerazione del fatto che Risiko dovrà
essere inseribile come modulo nell'ambiente NetGame, risulta necessario
seguire alcune regole nella definizione di interfacce e classi che fanno
parte dell'applicazione. In particolare, come si può notare nella
figura sottostante, è necessario creare due interfacce remote
IRisikoServer e IRisikoClient , due classi RisikoServer
e RisikoClient ed un ultima classe Risiko .
L'interfaccia IRisikoServer contiene dei
metodi propri del gioco invocati dal client sul server, mentre IRisikoClient
invece contiente metodi invocati dal server sul client. In realtà,
nel caso di Risiko, dato che l'implementazione delle comunicazioni è
fatta usando le socket e non RMI, l'interfaccia IRisikoServer
non presenta alcun metodo, mentre IRisikoClient presenta l'unico
metodo
void start(String host, int port, String name,
String match)
che ha il compito di comunicare al client i dati necessari perchè
esso riesca a connettersi alla socket in ascolto sul Server e ad entrare
nella opportuna partita.
Le classi RisikoClient e RisikoServer
sono semplicemente le implementazioni delle interfacce appena viste
e costituiscono in pratica i due terminali per mezzo dei quali avviene
la comunicazione. La classe Risiko invece implementa tutti quei
metodi che forniscono informazioni generali sul gioco. Per una trattazione
più generale si veda la documentazione
di NetGame.
La classe RisikoClient viene caricata
ed inizializzata dal client di NetGame quando un utente entra nella
stanza di Risiko, dopodichè rimane in attesa fino a quando non
ha inizio una partita, momento nel quale viene aperta la finestra contenente
l'interfaccia utente ed in cui inizia lo scambio di messaggi.
La classe RisikoServer viene inizializzata
al momento del caricamento dei server di stanza da parte del server
di NetGame. Al momento dell'inizializzazione il server di Risiko non
fa altro che creare una ServerSocket (viene usata la prima porta libera
disponibile) sulla quale rimane in ascolto in attesa della connessione
da parte dei client. Chiaramente questo avviene usando un thread separato
apposito (RisikoServerThread ). Ogni volta che il server riceve
una connessione crea un nuovo thread (PlayerAgent ) che si occupa
completamente della gestione della comunicazione con il client. L'inizio
di una partita avviene quando il giocatore che l'ha creata, comunica
al RoomServer di NetGame di voler cominciare, il quale provvede
a sua volta a comunicare al RisikoServer che è ora di
cominciare. Il RisikoServer possiede dei riferimenti ai client che partecipano
ad una data partita e quindi invoca il metodo start (visto prima)
sui client, comunicando loro l'host e la porta a cui devono connettersi.
Da questo punto in poi ha inizio il protocollo
a scambio di messaggi illustrato in precedenza.
Funzionamento stand-alone
Nel caso di funzionamento come applicazione stand-alone,
cioè indipendente dalla piattaforma NetGame, ci sono alcune differenze
che bisogna mettere in conto se si desidera che l'applicazione sia in
grado di funzionare. La differenza principale è che in questo
caso il client deve conoscere a priori l'host e la porta a cui deve
connettersi, visto che queste informazioni normalmente sono fornite
dal framework di NetGame. Per rendere possibile ciò è
necessario che l'utente sia in grado di specificare l'host e la porta
(oltre al proprio) nick, e per questo è stata predisposta una
finestra che viene visualizzata all'avvio del client che richiede all'utente
queste informazioni.
Anche a livello di server è quindi necessaria
la possibilità di specificare il numero di porta su cui aprire
la socket, che di default è la 42042. Oltre alla porta si deve
anche specificare il numero di giocatori partecipanti a ciascuna partita,
cosa che invece non era necessaria in NetGame, dato che in quel caso
era il creatore che decideva quando iniziare, indipendentemente dal
numero di partecipanti. In modalità stand-alone invece, per semplificare
l'implementazione si è fatto in modo che il numero di giocatori
sia fissato dal server e al raggiungimento di questo numero la partita
abbia inizio automaticamente. In questo modo si è evitato di
dover reimplementare la parte preliminare di creazione della partita,
che è svolta normalmente da NetGame.
|