Tank Game è un piccolo gioco 2D sviluppato in Java, nato principalmente come progetto didattico per esercitarsi sui concetti di Analisi e Progettazione del Software.
L'obiettivo principale del progetto non era realizzare un videogioco completo, ma partire da un dominio relativamente semplice e far evolvere progressivamente il software applicando concetti come:
- modellazione del dominio;
- programmazione orientata agli oggetti;
- assegnazione delle responsabilità;
- principi e pattern GRASP;
- Information Expert;
- Controller;
- polimorfismo ed ereditarietà;
- composizione tra oggetti;
- refactoring;
- Command Pattern;
- gestione dello stato;
- game loop;
- persistenza su file.
Il gioco è stato quindi costruito per iterazioni successive, evitando di progettare fin dall'inizio un'architettura completa.
Ogni nuova funzionalità ha introdotto un nuovo problema da risolvere e, quando necessario, una modifica del modello o delle responsabilità delle classi.
Lo sviluppo è iniziato dal modello di dominio, prima ancora di realizzare l'interfaccia grafica.
L'idea è stata quella di descrivere inizialmente gli elementi fondamentali del gioco e le loro relazioni:
- una
Partita; - un
CampoDiBattaglia; - diversi
Tank; - un
TankPlayer; - dei Tank controllati dall'AI;
Proiettile;Ostacolo;Posizione;Direzione.
Solo successivamente è stata aggiunta la GUI.
Questo permette di mantenere separati:
Dominio del gioco
↓
logica e regole
Interfaccia Swing
↓
visualizzazione e input
La GUI non rappresenta quindi il gioco stesso: visualizza lo stato degli oggetti appartenenti al dominio.
Il modello di dominio utilizzato nel progetto è disponibile nella cartella docs.
Il modello è stato utilizzato come punto di partenza, ma non è rimasto necessariamente identico durante tutto lo sviluppo.
Alcune responsabilità e relazioni sono infatti cambiate quando sono emersi nuovi requisiti.
Questo è stato intenzionale: uno degli obiettivi dell'esercizio era osservare come un modello possa evolvere durante lo sviluppo.
Durante lo sviluppo si è cercato di evitare di concentrare tutta la logica in una singola classe.
Le responsabilità sono state assegnate cercando di seguire soprattutto il principio Information Expert:
una responsabilità dovrebbe essere assegnata all'oggetto che possiede le informazioni necessarie per svolgerla.
Ad esempio, le operazioni relative allo stato del campo di battaglia utilizzano le informazioni possedute dal CampoDiBattaglia, mentre i comportamenti specifici dei Tank sono mantenuti all'interno della gerarchia Tank.
La classe Partita svolge invece un ruolo di coordinamento delle principali operazioni del gioco.
Durante lo sviluppo sono stati quindi affrontati concretamente concetti come:
Chi deve conoscere questa informazione?
Quale classe dovrebbe eseguire questa operazione?
Questa responsabilità appartiene davvero a questa classe?
Sto introducendo una dipendenza necessaria oppure evitabile?
L'obiettivo non era applicare pattern in maniera forzata, ma introdurli quando un problema concreto ne rendeva evidente l'utilità.
Il progetto è stato sviluppato attraverso piccoli milestone.
La prima fase è stata dedicata alla definizione degli oggetti principali del gioco e delle loro relazioni.
Sono stati introdotti concetti come:
Partita
CampoDiBattaglia
Tank
TankPlayer
Tank controllati dall'AI
Posizione
Direzione
Ostacolo
Proiettile
In questa fase il gioco non aveva ancora una vera interfaccia grafica.
Lo scopo era far funzionare prima il modello.
È stato introdotto il movimento dei Tank.
Una direzione è rappresentata attraverso uno spostamento lungo gli assi X e Y.
Questo ha permesso di gestire sia movimenti cardinali sia diagonali.
Sono stati inoltre introdotti i limiti del campo di battaglia.
Sono stati aggiunti ostacoli generati all'interno del campo.
Successivamente è stato necessario impedire ai Tank di:
- uscire dai limiti;
- attraversare gli ostacoli;
- attraversare altri Tank.
Le prime verifiche basate esclusivamente sulle coordinate sono state progressivamente sostituite da collisioni tra aree rettangolari.
Questo ha portato alla creazione di una logica comune per verificare l'intersezione tra due entità.
È stata introdotta la classe Proiettile.
Ogni proiettile possiede informazioni come:
- posizione;
- direzione;
- Tank che lo ha sparato;
- eventuale Tank colpito.
Il proiettile viene aggiornato progressivamente durante il game loop fino a quando:
- colpisce un Tank;
- colpisce un ostacolo;
- raggiunge i limiti del campo.
Ai Tank sono state associate delle vite.
Quando un proiettile colpisce un Tank viene applicato il danno e, quando le vite raggiungono zero, il Tank viene eliminato.
Sono quindi state introdotte le condizioni di:
- vittoria;
- sconfitta;
- fine partita.
I Tank nemici sono stati dotati di un comportamento autonomo.
Inizialmente ogni aggiornamento poteva provocare direttamente una nuova azione.
Questo rendeva però il comportamento poco naturale.
La logica è stata quindi separata tra:
decisione dell'AI
↓
eseguita periodicamente
movimento corrente
↓
eseguito ad ogni tick
In questo modo un Tank può scegliere una direzione e continuare a muoversi fino alla decisione successiva.
È stato introdotto un ciclo di aggiornamento continuo della partita.
Ad ogni tick vengono aggiornati elementi come:
- movimento dei Tank nemici;
- movimento dei proiettili;
- collisioni;
- stato della partita.
La frequenza delle decisioni dell'AI è invece indipendente dalla frequenza generale degli aggiornamenti.
Solo dopo aver costruito gran parte della logica è stata aggiunta una GUI utilizzando Java Swing.
La GUI legge lo stato del dominio e lo rappresenta graficamente.
Sono stati aggiunti:
- sprite dei Tank;
- ostacoli;
- proiettili;
- vite del player;
- numero di nemici;
- schermata di vittoria;
- schermata di sconfitta.
Gli sprite vengono inoltre ruotati in base alla Direzione dell'entità.
Per evitare di legare direttamente gli eventi della tastiera alle operazioni della partita è stato introdotto il Command Pattern.
È presente un'interfaccia Comando, dalla quale derivano operazioni come:
- movimento;
- sparo.
Una FabbricaDiComandi interpreta i tasti premuti e genera i comandi appropriati.
La gestione di un insieme di tasti premuti permette inoltre di combinare più input contemporaneamente, ad esempio:
W + D
per il movimento diagonale, oppure:
W + SPACE
per muoversi e sparare.
Durante il refactoring, invece di mantenere una classe comando per ogni possibile direzione, è stato introdotto un unico comando di movimento parametrizzato da una Direzione.
Quando un oggetto viene rimosso dal campo di battaglia, alcune informazioni utili per analizzare la partita andrebbero perse.
Per questo sono state introdotte collezioni storiche.
Ad esempio:
proiettili attivi
+
storico dei proiettili
Tank attivi
+
storico dei Tank
Lo storico permette di mantenere informazioni anche sugli oggetti che non fanno più parte dello stato corrente del gioco.
Questo introduce una distinzione importante tra:
stato corrente
e:
storia della partita
Utilizzando lo storico vengono calcolate alcune statistiche della partita.
Sono state utilizzate anche strutture dati come Map per rappresentare informazioni quali:
Tank → numero di proiettili sparati
e:
Proiettile → Tank colpito
Le statistiche possono essere mostrate in console sotto forma di report.
Come ultimo passo è stata introdotta una forma molto semplice di persistenza.
Il report della partita può essere scritto su un file di testo:
statistiche.txt
utilizzando le API Java NIO:
Path
Files
Questo passaggio è volutamente semplice e rappresenta un possibile punto di partenza per sistemi di persistenza più complessi.
| Tasto | Azione |
|---|---|
W |
Movimento verso l'alto |
A |
Movimento verso sinistra |
S |
Movimento verso il basso |
D |
Movimento verso destra |
SPACE |
Sparo |
È possibile premere più tasti contemporaneamente.
- Java
- Java Swing
- Java Collections Framework
- Java NIO
- JUnit
- Git
Tank Game può essere utilizzato anche come esercizio da ricostruire da zero.
Il consiglio è di non partire immediatamente dalla GUI.
Una possibile strategia è:
1. definire il dominio;
2. disegnare il modello;
3. identificare responsabilità e relazioni;
4. implementare una funzionalità alla volta;
5. testare il dominio;
6. introdurre il game loop;
7. aggiungere la GUI solamente dopo;
8. rifattorizzare quando emergono nuovi problemi.
Partire dal modello permette di evitare che tutta la logica del gioco finisca direttamente negli eventi della GUI.
L'obiettivo dell'esercizio non dovrebbe essere semplicemente ottenere il gioco funzionante, ma chiedersi continuamente:
Perché questa responsabilità appartiene a questa classe?
Quale oggetto possiede le informazioni necessarie?
Questa relazione è davvero necessaria?
Posso estendere questa parte senza modificare tutto il resto?
Una volta completata la versione base, il progetto può essere utilizzato come punto di partenza per ulteriori esercizi di progettazione.
Attualmente esiste un tipo principale di proiettile.
Un possibile esercizio consiste nel progettare una gerarchia con più tipi, ad esempio:
Proiettile
├── ProiettileNormale
├── ProiettilePesante
└── ProiettileVeloce
con caratteristiche differenti come:
- danno;
- velocità;
- dimensione;
- comportamento.
Questo permette di esercitarsi con:
- ereditarietà;
- polimorfismo;
- overriding.
È possibile introdurre differenti categorie di nemici:
Tank
├── TankPlayer
├── TankNemicoBase
├── TankNemicoVeloce
└── TankNemicoPesante
Ogni tipo potrebbe implementare comportamenti differenti.
Questo può diventare un esercizio per ragionare sulla separazione tra:
tipo di Tank
e:
strategia utilizzata dall'AI
Un'estensione interessante consiste nel sostituire il comportamento casuale con strategie differenti.
Ad esempio:
- movimento casuale;
- inseguimento del player;
- fuga;
- mantenimento della distanza;
- pattugliamento;
- comportamento aggressivo.
Questo può diventare un buon punto di partenza per sperimentare ulteriori pattern di progettazione.
La persistenza su file può essere sostituita con un database.
Un possibile esercizio consiste nel salvare:
- data della partita;
- durata;
- esito;
- Tank partecipanti;
- proiettili sparati;
- colpi a segno;
- statistiche finali.
In questo modo il progetto può essere esteso introducendo:
- JDBC;
- SQL;
- progettazione dello schema relazionale;
- repository;
- persistenza delle statistiche.
Il database potrebbe inoltre permettere di visualizzare statistiche aggregate su più partite.
Utilizzando le informazioni già raccolte è possibile aggiungere:
- precisione di ogni Tank;
- numero di colpi ricevuti;
- danni inflitti;
- danni subiti;
- numero di eliminazioni;
- durata media delle partite;
- storico delle vittorie e delle sconfitte.
Il CampoDiBattaglia può essere evoluto per supportare:
- livelli differenti;
- mappe caricate da file;
- quantità differenti di nemici;
- ostacoli predefiniti;
- difficoltà crescente.
Un altro possibile esercizio consiste nel sostituire Swing mantenendo invariato il più possibile il dominio.
Ad esempio potrebbe essere realizzata:
- una nuova GUI;
- una versione console;
- una diversa tecnologia grafica.
Questo permette di verificare quanto il dominio sia realmente indipendente dalla rappresentazione grafica.
Questo repository non vuole rappresentare un motore di gioco completo né un esempio di architettura definitiva.
È principalmente una palestra di progettazione software.
L'idea è costruire una prima soluzione semplice, far emergere i problemi attraverso nuovi requisiti e successivamente rifattorizzare il modello.
Il valore del progetto non è quindi solamente nel risultato finale, ma soprattutto nel percorso:
problema
↓
prima soluzione
↓
nuovo requisito
↓
limite della soluzione
↓
refactoring
↓
nuova progettazione
È proprio questo processo che permette di esercitarsi concretamente nell'analisi, nella progettazione orientata agli oggetti e nell'assegnazione delle responsabilità.
