|
Java Tutorial - Parte 2 0.1
Un tutorial per esempi
|
Nella figura seguente potete vedere la applicazione in versione finale:
Nella immagine, vi è un giocatore umano (Lukas) che occupa il pannello di sinistra ed un giocatore I.A. (il computer) che occupa il pannello di destra. Il gioco del mastermind è un semplice gioco turn based (basato sui turni) in cui due giocatori scelgono una sequenza segreta di simboli (numeri, lettere o colori) e, a turno, ognuno tenta di indovinare la sequenza dell'avversario.
Nel mastermind una sequenza è un insieme di simboli di lunghezza prestabilita. Vi sono due tipi di sequenze:
La strategia con cui un giocatore riesce ad indovinare la sequenza segreta dell'avversario si basa sui risultati del confronto tra la solution e la guess:
solution -------> 1 2 3 4
guess ----------> 0 4 3 2
-------
risultati : 1red, 2white
I risultati vengono espressi per ogni simbolo di guess:
Pertanto, nell'esempio di cui sopra abbiamo un red per il simbolo "3" che appare sia in solution che in guess nella stessa posizione ed otteniamo due whites per i simboli "2" e"4" che appaiono entrambi sia in solution che in guess ma in posizione diversa.
La difficoltà nel trovare la soluzione sta nel fatto che il giocatore non sa quali siano i simboli esistenti nè a quale simboli si riferiscano i risultati red ed i risultati white.
Nel mastermind originale il gioco è unidirezionale: vi è un codificatore, colui che seglie la solution, ed un decodificatore, colui che tenta di indovinarla. Il decodificatore ha a disposizione 9 tentativi per indovinare la soluzione: in difetto, la vittoria va al codificatore.
In questa versione del mastermind, invece, il gioco è bidirezionale: entrambi i giocatori sono sia codificatori che decodificatori ed ognuno cerca di indovinare la solution dell'avversario.
Ne consegue che il gioco potrebbe protrarsi per più di nove turni dal momento che, prima o poi, uno dei due giocatori indovinerà la sequenza dell'avversario. Tuttavia, ho inserito comunque un numero massimo di tentativi per la soluzione: questo limite è ancora di nove tentativi (modificabile nelle opzioni) e, se nessuno dei giocatori riesce nel intento entro il limite massimo la partita è considerata "patta" (nessun vincitore) un pò come succede negli scacchi quando un giocatore si ritrova con il solo Re e l'avversario con il proprio Re ed un pezzo di valore minore come cavallo e alfiere; oppure quando un giocatore non ha alcuna mossa possibile ma il suo Re non è sotto scacco: il cosidetto stalemate (=stallo).
Allo scopo di rendere il gioco corretto per entrambi i giocatori è necessario stabilire delle regole che valgono per entrambi; chiamerò questo insieme di regole: "i parametri di gioco"
n e, per default, vi sono 10 simboli a disposizione k e può sssere 3 o 4; il valore di default è 3 repeat che può essere TRUE o FALSE; il valore di default è FALSE maxTries Per scrivere questa applicazione creeremo un progetto a cui dedichiamo una sottocartella specifica. Chiameremo la sottocartella javatutor2/projects/mastermind che sarà la directory radice del progetto. Come descritto in Una breve panoramica procederemo per gradi, implementando mano a mano le varie features (=funzionalità) della applicazione.
Le funzionalità della applicazione saranno inserite in packages che partono dalla directpory radice sopracitata; tutte le classi che fanno parte di una specifica funzionalità saranno raggruppate in un unico package. Il nome dei packages comincia sempre con mastermind. La gerarchia delle cartelle che rappresentano i packages è la seguente:
-> mastermind la directory radice del progetto | | -> docs la documentazione javadoc | | -> docfiles sorgenti necessari alla documentazione javadoc | | | -> mastermind primo nome dei packages | | | | | -> common le classi comuni | | | | | -> game la logica del gioco | | | | | -> gui la interfaccia grafica | | | | | -> net classi base per le connessioni remote | | | | | | | -> impl implementazioni delle connessioni remote | | | | | | | -> udp ricerca dei giocatori tramite protocollo UDP | | | | | | -> player classi dei tipi di giocatore | | | | | -> test classi varie di test
I giocatori vengono definiti nel package mastermind.player che contiene le classi che descrivono i vari tipi di giocatore; tutte le classi giocatore derivano dalla classe base astratta Player che contiene nei suoi membri dati le info che rappresentano il giocatore:
nickname: il nome del giocatore; esso ha senso solo per i giocatori umani; il giocatore A.I. ha il nickname predefinito "PlayerAI" mentre il giocatore di test ha il nome predefinito "PlayerTEST". Emtrambi sono seguiti da un numero che identifica il turno di gioco turn: il turno di gioco; nel mastermind vi sono due turni identificati con il valore ZERO (=giocatore di sinistra) e UNO (giocatore di destra) solution: la sequenza segreta scelta dal giocatore params: i parametri di gioco stabiliti all'inizio della partitaCome accennato, la classe Player è una classe base astratta che dichiara i seguenti metodi astratti, che vanno definiti nelle derivate:
getType: restituisce il tipo di giocatore: (HUMAN, TEST, AI, REMOTE) isConnected: stabilisce se il giocatore è connesso oppure no isRemote: stabilisce se il giocatore è locale oppure remoto getSolution: restituisce la sequenza segreta getGuess: restituisce il tentativo di indovinare la sequenza del avversarioLa classe Player riceve dal server di gioco le notifiche ogni qualvolta accade un evento di gioco come per esempio l'inzio di una nuova partita oppure i risultati di un confronto tra una guess ed una solution. Non tutti i giocatori sono interessati alle notifiche: per esempio un giocatore umano vede gli eventi della partita attraverso la GUI e quindi la classe che lo implementa semplicemente ignora le notifiche.
Altri giocatori, invece, possono essere interessati ad alcune di queste notifiche: per esempio il giocatore Artificial Intelligence (AI) è sicuramente interessato alla notifica dei risultati del confronto tra la propria guess e la solution dell'avversario poichè è proprio su questo confronto che si basa la sua strategia di gioco.
La classe PlayerLocal deriva direttamente da Player ed è una classe base astratta che rappresenta tutti i giocatori locali: l'umano, la AI ed il giocatore di test. Questa classe sovrascrive solo due due metodi astratti:
isConnected: restituisce sempre true isRemote: restituisce sempre false Il giocatore umano è rappresentato dalla classe PlayerHuman, derivata da PlayerLocal. Essa interagisce con la GUI e pertanto definisce i metodi per reagire agli eventi del mouse e/o della tastiera usati dall'utente per scegliere la solution e la guess. Inoltre, è interessata al evento di notifica swapTurn in quanto il pannello dei comandi dovrebbe essere abilitato solo quando l'utente umano è in turno.
Il giocatore di test è una specie di AI stupida: il suo compito è solo quello di fornire un avversario di test per le prime versioni della applicazione. Questo giocatore viene implementato dalla classe PlayerTest, derivata da PlayerLocal e sovrascrive i seguenti metodi astratti:
getType: restituisce il tipo di giocatore TEST getSolution: restituisce la sequenza segreta generandola casualmente getGuess: restituisce il tentativo di indovinare la sequenza del avversario (la guess) generandola casualmentePoichè questo giocatore cerca di indovinare la solution dell'avversario generando una sequenza casuale sarà piuttosto improbabile che riuscirà mai a vincere una singola partita; sarebbe davvero un caso più unico che raro.
Il giocatore di test può essere usato per esempio dai principianti per fare esperienza di gioco oppure anche da chi, come si suol dire, piace vincere facile. La implementazione del giocatore di test verrà descritta in dettaglio in Versione 0.3: il pannello del giocatore
La classe PlayerAI implementa il giocatore Artificial Intelligence che per certi aspetti è simile al giocatore di test: non sono veri giocatori in carne ed ossa poichè tutte le loro azioni si svolgono tramite il codice Java. Anche il giocatore AI sceglie una solution generandola casualmente ma, al contrario del giocatore di test, usa un algoritmo per cercare di indovinare la solution dell'avversario.
La classe PlayerAI è svincolata dalla classe che implementa l'algoritmo in modo che se, in futuro, saranno implementati nuovi e più efficienti algoritmi è più facile modificare il codice per poter usare questi nuovi algoritmi. L'algoritmo implementato in questo progetto è davvero molto semplice e viene descritto in L'algoritmo classico
La classe PlayerRemote deriva da Player e sovrascrive tutti i metodi astratti:
getType: restituisce il tipo di giocatore: REMOTE isConnected: stabilisce se il giocatore è connesso oppure no interrogando l'oggetto Connection che rappresenta una connessione di rete isRemote: restituisce true getSolution: inoltra la richiesta all'oggetto Connection getGuess: inoltra la richiesta all'oggetto Connection La classe PlayerRemote fà parte del package mastermind.net che include molte altre classi dedicata alla connessione di due giocatori umani attraverso la rete. La connessione remota verrà descritta in dettaglio nei capitoli finali di questo tutorial:
java.net specifico per il TCP/IP La gestione delle versioni di una applicazione è un argomento complesso e dipende in massima parte da quanti sviluppatori sono coinvolti nel progetto.
Per progetti medio/grandi nei quali sono coinvolti molti sviluppatori è necessario usare strumenti dedicati a questo scopo come per esempio Git oppure Subversion. Si tratta di strumenti potenti ma complessi che, oltre a tenere traccia delle versioni del progetto, sono in grado di gestire l'accesso simultaneo di due o più sviluppatori, di eseguire il rollback (=ripristino) delle modifiche, di creare degli snapshots (=istantanee), a volta giornalieri, dell'intero progetto e di gestire i rilasci della applicazione e gli eventuali branching (=ramificazioni) del progetto.
Per questo piccolo progetto che andremo a scrivere in questo tutorial non useremo tali strumenti anche perchè sarebbe necessario un tutorial dedicato solo per imparare ad usarli. Il tema delle gestione delle versioni sarà comunque affrontato in questa guida ma avrà un approccio elementare, giusto il minimo indispensabile per non incappare negli errori più assurdi (e purtroppo molto comuni).
Spesso i neofiti cominciano a scrivere il codice di getto cioè appena avuta una idea si buttano sul computer e cominciano a codificare. In seguito si accorgono che la applicazione può essere migliorata e allora modificano i sorgenti e cominciano a chiamare i files sorgente con nomi strani come versione_prova oppure versione_prima ed infine versione_finale quando pensano, a torto, che il programma è pronto e finito.
Sfatiamo subito in mito: la versione finale non esiste MAI! Un programma o applicazione può sembrare finito quando ha tutte le funzionalità che ci si aspetta oppure perchè chi lo ha sviluppato non lo mantiene più aggiornato: ebbene, non si può mai dire che, magari a distanza di anni, qualche altro sviluppatore non riprenda in mano il progetto per arricchirlo di funzionalità di cui in passato non si sentiva la necessità oppure per renderlo compatibile con i nuovi sistemi operativi e/o nuove piattaforme hardware/software disponibili.
Ma allora come possiamo chiamare le varie versioni? La risposta è semplicissima: numerandole! E poichè i numeri sono infiniti ... anche le versioni di una applicazione possono essere infinite!
Ogni progetto può avere un suo schema di versioning (=versionamento, che brutta parola). Per esempio Windows™ ha avuto diverse strategie di versionamento: è partito con i numeri (1, 2, 3 ) per poi arrivare ai nomi (XP, Vista) per poi tornare ai numeri (8, 10, 11 ... ).
Una altra strategia di versionamento è quella usata da Canonical per rilasciare la sua famosa distribuzione basata su Linux: usa l'anno ed il mese del rilascio come numero di versione. Per esempio, al momento in cui scrivo, l'ultima distribuzione rilasciata da Canonical è Ubuntu 24.10 rilasciata nel ottobre 2024.
Lo schema di versioning del progetto Mastermind segue le linee guida della maggior parte del software open source. Il numero di versione è formato da tre cifre decimali separate da un punto come per esempio 1.2.3 il cui formato è:
major_version.minor_version.bug_fix
dove:
major_version: il numero di versione maggiore cambia quando vengono inserite modifiche sostanziali al programma in modo tale da renderlo incompatibile con le versioni maggiori precedenti minor_version: il numero di versione minore cambia quando vengono inserite nuove features (funzionalità) che però non pregiudicano la piena compatibilità con le versioni minori precedenti bug_fix: questo numero cambia quando vengono introdotte modifiche di poco conto o quando vengono risolti gli errori logici, i cosidetti bugs In tutti i progetti, specialmente in quelli open source, la versione maggiore parte dal numero ZERO: questo perchè lo sviluppo della applicazione procede per gradi: non è necessario fornire la applicazione di tutte le features (=funzionalità) già dalla prima stesura del codice.
E' molto meglio scrivere una prima bozza (la versione 0.1 oppure anche 0.0) che implementa solo la interfaccia utente: le funzionalità saranno via via implementate nelle versioni successive.
Di consueto, quando una versione 0.x ha implementato tutte le funzionalità che ci si aspetta o meglio, che erano state definite all'inizio del progetto, si crea una specie di versione "definitiva" che prende il nome di release candidate (abbreviato in rc) e che diverrà la versione 1.0.
Per il progetto mastermind useremo proprio questo approccio ed useremo un trucchetto piuttosto banale ma efficace per tenere traccia delle varie versioni del progetto.
Una altra buona regola da seguire è quella di definire fin da subito una specie di roadmap del progetto definendo quante e quali funzionalità saranno implementate nelle varie versioni della applicazione. Un ulteriore dato ben accettato dagli utenti è quello di stabilire una data di rilascio ipotetica delle varie versioni, anche solo mese/anno (come per esempio: ottobre 2026) ma può andare bene anche stagione/anno (come per esempio: primavera 2027).
Ovviamente, per il progetto Mastermind che fa parte di questo tutorial le date di rilascio delle varie versioni non hanno alcun senso dal momento che la applicazione è già stata scritta interamente. La roadmap delle versioni di questo progetto è la seguente:
La strategia di versionamento che abbiamo scelto si basa quindi su tre numeri interi; versione maggiore, versione minore, versione di bug_fixing. Spesso, nel corso di esistenza di una applicazione, è necessario stabilire con quale particolare versione si sta operando; questo è particolarmente sentito nelle applicazioni client/server, come è il nostro Mastermind.
Per esempio, in un futuro potremmo avere la versione 2.4.4 che però ha un bug particolarmente grave e che è stato risolto nella versione 2.4.5. Prima di connettersi ad un giocatore remoto la applicazione locale dovrebbe stabilire se la versione della controparte è precedente a quella di risoluzione del bug.
In questo caso sarebbe necessario eseguire tre confronti: prima la versione maggiore, poi la versione minore ed infine il numero di bug_fix. Se però il numero di versione potesse essere espresso anche con un unico numero intero (per esempio il numero 245 allora il confronto sarebbe immediato.
Per ottenere il numero di versione come intero è necessario stabilire le regole di conversione. Per esempio, la versione
1.2.3
potrebbe essere convertita nel valore numerico 123 assegnando alla versione maggiore le centinaia, alla versione minore le decine ed al numero di bug_fix le unità.
Tuttavia, questo limita di molto il range dei numeri a disposizione: in effetti abbiamo che per ogni campo della versione possiamo usare solo i numeri da "0" a "9". Possiamo aumentare il range dei numeri a disposizione usando due cifre per ogni valore della versione in modo da disporre di 99 numeri.
Questo significa che la versione 1.2.3 avrà il valore intero di:
(1 x 10000) + (2 x 100) + 3 = 10203
Nella realtà, invece, si usa un approccio diverso: poichè un intero è formato da 32 bits (4 bytes) è usanza comune dedicare ai singoli valori della versione un intero byte; il range di valori possibili pertanto va da ZERO a 255, un range piuttosto ampio.
Non solo: questo range si applica solo alla versione minore ed al campo bug_fix: per il numero di versione maggiore ci restano 16 bits per un range che va da ZERO a 65535, una quantità fin troppo elevata considerando che il numero di versione maggiore cambia quando la applicazione rompe la compatibilità col passato.
Usando questa modalità, infine, i calcoli sono facilitati: per ottenere il valore numerico intero della versione maggiore basta moltiplicare per 65536 (vale a dire 216) mentre per la versione minore si moltiplica per 256 (che equivale a 28).
E poichè la moltiplicazione per le potenze del due è particolarmente facile in binario, il codice per ottenere il numero di versione come intero si riduce al seguente:
Per quanto riguarda l'operazione inversa, è sufficente azzerare i bits che non ci servono e dividere per le stesse potenze del due che abbiamo visto poc'anzi. Per ottenere il numero di bug_fix, azzeriamo tutti i bits diversi dal byte meno significativo e non occorre procedere ad alcuna divisione.
Il numero di versione minore si ottiene azzerando tutti i bits diversi da quelli del secondo byte meno significativo e dividendo per 256. Per il numero di versione maggiore non è nemmeno necessario azzerare i bits: basta dividere per 216.
Come avrete intuito, le strategie di versioning sono molteplici e a volte possono anche cambiare nel corso del tempo per la stessa applicazione o per lo stesso sistema.
Voglio concludere questa sezione illustrandovi una strategia molto usata nella programmazione reale: il cosidetto versionamento incrementale.
Questa strategia combina le due strategie di versionamento più comuni usate in programmazione:
In questo schema di versioning il numero di versione viene mantenuto in un singolo intero a 32 bits che viene suddiviso in tre campi:
31 23 15 0 --------------- --------------- -------------- | major_version | minor_version | build_number | --------------- --------------- --------------
major_version ha lo stesso significato di quello visto nelle sezioni precedenti: si tratta del numero di versione maggiore che cambia quando vengono introdotte modifiche che rompono la compatibilità col passato; questo campo occupa i bits da 24 a 31; 8 bits per un range di valori da ZERO a 255 minor_version ha lo stesso significato di quello visto nelle sezioni precedenti: si tratta del numero di versione minore che cambia quando vengono introdotte nuove features (=funzionalità) pur mantenendo la piena compatibilità con tutte le versioni maggiori che hanno lo stesso valore; questo campo occupa i bits da 16 a 23; 8 bits per un range di valori da ZERO a 255 build_number occupa i bits da 0 a 15 (16 bits per un range che va da ZERO a 65535) e viene usato per il bug fixing e le modifiche di poco conto ma si tratta di un campo incrementale; ogni nuova versione aumenta questo campo di una sola unità anche quando cambiano le versioni maggiori e/o minoriIl vantaggio di questa strategia è che il campo build_number è un contatore progressivo: oltre a rappresentare il numero di versione come intero fornisce anche il numero esatto di versioni rilasciate fino ad un determinato istante.
In questa sezione faccio una panoramica su come sarà organizzata la logica del gioco ed il diagramma dei flussi dati tra le varie componenti della app.
Dal momento che è prevista una connessione remota con un giocatore si presenta il problema di come organizzare la comunicazione. Ci sono sostanzialmente due approcci diversi:
Quale delle due soluzioni è la migliore? Diciamo che la soluzione migliore in assoluto non esiste, entrambe hanno punti di forza e punti di debolezza.
Tra i punti di forza della soluzione client/server vi è che essendoci un unico server, è facile stabilire in ogni istante in quale fase il gioco si trova; di contro, in una architettura p2p è necessario mantenere sincronizzate tutte le copie che implementano la logica del gioco.
Tra i punti di forza della soluzione p2p vi è che il traffico di rete risulta ridotto al minimo poichè è sufficente per le due applicazioni scambiarsi le sequenze (o un eventuale abbandono della partita) dal momento che entrambe le applicazioni sanno cosa fare con l'input dell'user; di contro, la soluzione client/server ha un traffico di rete molto più intenso perchè il server deve necessariamente inviare le notifiche sull'andamento del gioco al client (per esempio i risultati del confronto, il cambio turno, la fine della partita con il vincitore, etc).
Vi è da osservare che in un gioco basato sui turni e con due soli giocatori la soluzione p2p è facile da implementare e risulta anche piuttosto facile mantenere le due copie della applicazione sincronizzate.
In un gioco sempre basato sui turni ma con più giocatori come per esempio la briscola o il tre-sette, la sincronizzazione di quattro giocatori non sarebbe così facile: ogni giocatore dovrebbe inviare il proprio input a tutti gli altri giocatori per mantenere le app sincronizzate.
Pur essendo la soluzione p2p vantaggiosa per un gioco a due, in questo tutorial sarà implementata la architettura client/server che è forse più complessa ma che può insegnarvi molto di più su come si realizza un gioco in rete.
Il seguente schema illustra, a grandi linee, il flusso dei dati tra i componenti della applicazione:
Le linee rosse rappresentano i dati di input cioè le sequenze; una sequenza è la solution e le varie guess ma anche la resa di un giocatore. Come potete facilmente osservare dal diagramma, tutte le linee rosse convergono sul componente MasterMind Server proprio perchè questa è la architettura che ho scelto di implementare.
Le linee gialle rappresentano le notifiche sul andamento del gioco; tra le notifiche possiamo includere anche la richiesta da parte del server delle sequenze che i giocatori devono inviare.
Tutte le linee gialle partono sempre dal componente MasterMind Server e raggiungono:
Vi sembrerà strano che il giocatore umano non sia interessato alle notifiche: in realtà egli è ovviamente interessato ma l'umano viene a conoscenza dell'andamento del gioco attraverso la GUI che già riceve le notifiche.