Implementazione - Giovanni Pisoni
Panoramica dei contributi
Il mio contributo al progetto si è focalizzato sulle seguenti aree:
-
Architettura del
GameEngine: Definizione delGameEnginee delGameStateper la gestione dello stato di gioco -
Implementazione del
GameLoop: Creazione di un game loop a timestep fisso per garantire aggiornamenti consistenti -
Implementazione del
GameControllere gestione degli eventi: Sviluppo di un sistema di gestione degli eventi per disaccoppiare i componenti del sistema e implementazione delGameControllerper coordinare le interazioni tra i vari sistemi di gioco -
Logica delle entità: Implementazione del
MovementSysteme delloSpawnSystemper il comportamento dei nemici, e dell’HealthBarRenderSystemper il rendering delle barre della salute delle entità di gioco -
Interfaccia utente: Sviluppo dei menu di pausa e delle schermate di vittoria/sconfitta
-
Testing: Scrittura di test per i sistemi implementati, come
MovementSystemTesteSpawnSystemTest
Principali sfide implementative
Durante lo sviluppo del progetto, ho affrontato diverse sfide tecniche significative che hanno richiesto un’attenta analisi e soluzioni innovative.
-
Gestione della Concorrenza e Thread Safety
La prima sfida importante è stata garantire la thread-safety tra il game loop, che opera su un thread dedicato, e l’interfaccia ScalaFX, che richiede aggiornamenti sul JavaFX Application Thread. Ho risolto questa problematica utilizzando
AtomicReferenceper lo stato dell’engine e del game loop, e unaConcurrentLinkedQueueper le azioni pendenti nelGameController. Questo approccio ha permesso di mantenere la separazione tra la logica di gioco e il rendering, evitando race conditions e deadlock. -
Transizione da movimento a celle a movimento continuo
Inizialmente, il
MovementSystemutilizzava un approccio basato su celle discrete, dove le entità si teletrasportavano istantaneamente tra posizioni della griglia. Questo metodo, sebbene semplice da implementare, produceva un’esperienza visiva poco fluida. La transizione a un sistema di movimento basato su pixel ha richiesto una completa riprogettazione: ho ristrutturato la strutturaPositioncon coordinateDouble, implementato l’interpolazione del movimento basata suldeltaTime, e gestito le transizioni graduali per il movimento zigzag dei Troll Assassini. Questa modifica ha migliorato significativamente la qualità visiva del gioco, ma ha anche introdotto nuove complessità nella gestione delle collisioni e nel mapping tra coordinate logiche e fisiche. -
Complessità del sistema di spawn procedurale
Lo
SpawnSystemha presentato sfide nella sincronizzazione tra il timing di gioco e il tempo reale. La gestione della pausa richiedeva che gli eventi di spawn schedulati venissero “spostati nel tempo” di una durata pari alla pausa stessa. Ho implementato un meccanismo che traccia il momento della pausa (pausedAt) e, alla ripresa, ricalcola tutti i timestamp degli spawn pendenti.
Implementazione - GameEngine e GameLoop
Il cuore del gioco è rappresentato dal GameEngine e dal GameLoop, componenti che ho sviluppato per orchestrare l’intero flusso di gioco.
GameEngine
Il GameEngine è stato progettato come una macchina a stati finiti che gestisce le fasi principali del gioco (MainMenu, Playing, Paused, GameOver). L’engine è responsabile di:
-
Inizializzare e arrestare il gioco: Avvia e ferma il
GameLoope gestisce il ciclo di vita delGameController -
Gestire lo stato del gioco: Mantiene il
GameStatecorrente, che include la fase di gioco, il tempo trascorso e lo stato di pausa -
Coordinare gli aggiornamenti: Durante la fase
Playing, invoca il metodoupdatedelGameControllerper far avanzare la logica di gioco
L’immutabilità è un principio chiave: ogni modifica dello stato non altera l’oggetto corrente, ma ne crea una nuova istanza. Questo approccio funzionale previene effetti collaterali e semplifica la gestione dello stato.
case class GameState(
phase: GamePhase = GamePhase.MainMenu,
isPaused: Boolean = false,
elapsedTime: Long = 0L,
fps: Int = 0
):
def transitionTo(newPhase: GamePhase): GameState = newPhase match
case Paused => copy(phase = newPhase, isPaused = true)
case Playing => copy(phase = newPhase, isPaused = false)
case other => copy(phase = other)
Per garantire la thread-safety nelle operazioni concorrenti, l’aggiornamento dello stato del GameEngine utilizza un pattern atomico con compare-and-set e tail recursion.
GameLoop
Per garantire un’esperienza di gioco fluida e un comportamento deterministico, ho implementato un game loop a timestep fisso.Questo approccio disaccoppia la logica di gioco dalla velocità di rendering, assicurando che il gioco si comporti allo stesso modo su hardware diversi.
Il game loop a timestep fisso garantisce:
- Determinismo: il gioco si comporta in maniera identica su hardware diversi
- Stabilità fisica: la simulazione rimane coerente indipendentemente dal frame rate
- Prevedibilità: facilita il testing e il debugging del comportamento di gioco
Il GameLoop utilizza un accumulator per gestire il tempo trascorso tra i fotogrammi. La logica di gioco viene aggiornata in passi discreti di tempo fisso (FRAME_TIME_MILLIS), garantendo che, anche in caso di cali di frame rate, la simulazione di gioco progredisca correttamente.
La gestione del ciclo di aggiornamenti avviene attraverso il metodo processAccumulatedFrames. Questo metodo verifica se il tempo accumulato è sufficiente per eseguire un passo di aggiornamento della logica di gioco. In caso affermativo, invoca il metodo update del GameEngine e sottrae il tempo fisso dall’accumulatore.
@tailrec
private def processAccumulatedFrames(): Unit =
val state = readState
if state.hasAccumulatedTime(fixedTimeStep) && !engine.isPaused then
engine.update(fixedTimeStep)
updateState(_.consumeTimeStep(fixedTimeStep))
processAccumulatedFrames()
Implementazione - GameController e gestione degli eventi
GameController e gestione degli Stati
Il GameController agisce come il principale orchestratore del gioco, facendo da ponte tra l’input dell’utente, la logica di gioco (i sistemi ECS) e il GameEngine. La sua responsabilità è quella di tradurre le azioni del giocatore e gli eventi di sistema in aggiornamenti dello stato del mondo di gioco.
Per gestire la complessità dei numerosi sistemi che compongono la logica del gioco (movimento, combattimento, generazione di elisir, ecc.), ho introdotto la case class GameSystemsState. Questa classe incapsula lo stato di tutti i sistemi, garantendo che vengano aggiornati in un ordine predicibile e coerente.
Il metodo updateAll all’interno di GameSystemsState è cruciale: definisce la pipeline di esecuzione dei sistemi ad ogni ciclo di gioco. L’ordine di esecuzione è fondamentale: ad esempio, il MovementSystem viene eseguito prima del CollisionSystem per garantire che le collisioni vengano rilevate sulle nuove posizioni, e l’HealthSystem viene eseguito dopo, per applicare i danni risultanti. Questa struttura garantisce che le interdipendenze tra i sistemi siano gestite correttamente.
// in GameSystemsState.scala
def updateAll(world: World): (World, GameSystemsState) =
val (world1, updatedElixir) = elixir.update(world)
val (world2, updatedMovement) = movement.update(world1)
val (world3, updatedCombat) = combat.update(world2)
val (world4, updatedCollision) = collision.update(world3)
// ... e così via per gli altri sistemi
Il GameController mantiene un’istanza di GameSystemsState e la utilizza per evolvere lo stato del gioco. Inoltre, gestisce le azioni del giocatore, come il posizionamento dei maghi, verificando le condizioni necessarie (es. elisir sufficiente) e aggiornando lo stato di conseguenza in modo asincrono tramite una coda di azioni (pendingActions), per evitare problemi di concorrenza con il game loop.
Gestione degli Eventi
Per disaccoppiare i vari componenti del gioco e gestire le transizioni di stato in modo pulito e centralizzato, ho implementato un sistema di eventi. Questo sistema si basa su due componenti principali: EventSystem e EventHandler.
EventSystem definisce la gerarchia degli eventi di gioco (GameEvent) e una EventQueue immutabile. Gli eventi sono stati suddivisi per priorità, per garantire che le operazioni critiche (come ExitGame) vengano processate prima di altre.
// in EventSystem.scala
trait GameEvent:
def priority: Int
object GameEvent:
// System events
case object ExitGame extends GameEvent:
override def priority: Int = 0
// Game State events
case object GameWon extends GameEvent:
override def priority: Int = 1
// Menu events
case object ShowMainMenu extends GameEvent:
override def priority: Int = 2
// Input events
case class GridClicked(logicalPos: LogicalCoords, screenX: Int, screenY: Int) extends GameEvent:
override def priority: Int = 3
L’EventHandler è il cuore del sistema di eventi. È responsabile della gestione della coda di eventi e del dispatching degli stessi ai gestori appropriati. Utilizza un AtomicReference per gestire il suo stato (EventHandlerState) in modo thread-safe, un aspetto cruciale dato che gli eventi possono essere generati da thread diversi (es. il thread del game loop e il thread della UI).
Il metodo processEvent dell’EventHandler funge da macchina a stati finiti, gestendo le transizioni tra le varie fasi del gioco (GamePhase) in risposta a eventi specifici. Ad esempio, quando riceve un evento ShowGameView, non solo cambia la vista, ma avvia anche il GameEngine se non è già in esecuzione.
// in EventHandler.scala
private def handleEvent(event: GameEvent): Unit =
val state = stateRef.get()
event match
case ShowMainMenu =>
handleMenuTransition(MainMenu, Some(ViewState.MainMenu))
Option.when(isGameActive)(stopEngine())
case ShowGameView =>
handleMenuTransition(Playing, Some(ViewState.GameView))
Option.when(!engine.isRunning)(startEngine())
case Pause if state.currentPhase == Playing =>
pauseEngine()
handleMenuTransition(Paused, Some(ViewState.PauseMenu))
// ... altri casi di eventi
Inoltre, la EventQueue è stata progettata per supportare operazioni composizionali. L’implementazione di metodi come map e flatMap permette di trasformare il flusso di eventi in modo dichiarativo, preservando l’immutabilità della coda e migliorando l’espressività del codice. Questo approccio funzionale si manifesta anche in metodi come prioritize, che ordina gli eventi in base alla loro criticità. Anziché iterare e riordinare manualmente la coda, questa operazione viene espressa come una singola trasformazione funzionale sulla collezione sottostante, garantendo che le operazioni più urgenti vengano eseguite per prime.
// Operazioni monadiche sulla coda di eventi
def map(f: GameEvent => GameEvent): EventQueue =
copy(queue = queue.map(f))
def flatMap(f: GameEvent => Queue[GameEvent]): EventQueue =
copy(queue = queue.flatMap(f))
def fold[B](initial: B)(f: (B, GameEvent) => B): B =
queue.foldLeft(initial)(f)
// Prioritizzazione degli eventi
def prioritize(): EventQueue =
copy(queue = Queue.from(queue.toList.sortBy(_.priority)))
Queste operazioni permettono di trasformare e comporre eventi in modo dichiarativo, mantenendo l’immutabilità della coda.
Questa architettura a eventi permette di avere un controllo centralizzato e prevedibile sul flusso del gioco, rendendo il sistema più robusto e facile da estendere con nuove funzionalità e interazioni.
Implementazione - Logica delle entità
La logica comportamentale delle entità nemiche, i troll, è stata implementata attraverso due sistemi dedicati all’interno dell’architettura Entity-Component-System (ECS): lo SpawnSystem e il MovementSystem. Questi moduli sono responsabili, rispettivamente, della generazione procedurale delle ondate di nemici e della gestione del loro comportamento di movimento sulla plancia di gioco.
SpawnSystem: Generazione Procedurale delle orde
Lo SpawnSystem orchestra la comparsa dei troll, introducendo una curva di difficoltà progressiva e un elemento di imprevedibilità. Una scelta progettuale chiave è stata quella di attivare il sistema solo dopo il posizionamento del primo mago da parte del giocatore. Questa decisione conferisce al giocatore il controllo sull’inizio effettivo della partita, permettendogli di stabilire una difesa iniziale prima di affrontare la prima ondata.
La generazione dei nemici è un processo dinamico e parametrico, governato da diverse logiche:
-
Difficoltà progressiva: La sfida si intensifica con l’avanzare delle ondate. Lo
SpawnSystemsi interfaccia con il modulo di configurazioneWaveLevelper applicare moltiplicatori alle statistiche base dei troll (salute, velocità, danno). Questo scaling assicura che la difficoltà aumenti in modo controllato e predicibile -
Distribuzione dinamica dei nemici: Per evitare la monotonia, la composizione delle ondate varia nel tempo. Le ondate iniziali sono dominate da troll di base, ma con il progredire della partita, il sistema introduce gradualmente tipologie di nemici più specializzate e complesse, come i
Warrioro gliAssassin, seguendo una distribuzione di probabilità che si evolve a ogni nuova ondata -
Generazione a “batch”: Anziché generare i troll a intervalli perfettamente regolari, è stata implementata una logica di “batch”. I nemici vengono generati in piccoli gruppi con intervalli temporali leggermente randomizzati. Questo approccio crea un flusso di avversari più organico e meno prevedibile, costringendo il giocatore ad adattare costantemente le proprie strategie difensive
private def generateSpawnBatch(currentTime: Long, firstRow: Option[Int], numOfSpawns: Int): List[SpawnEvent] = val isFirstBatch = pendingSpawns.isEmpty && firstRow.isDefined List.tabulate(numOfSpawns): index => val useFirstRow = isFirstBatch && index == 0 generateSingleSpawn(currentTime + index * BATCH_INTERVAL, useFirstRow, firstRow)
Come si evince dal codice, il primo troll di un’ondata viene sempre generato sulla stessa riga del primo mago posizionato, una scelta implementativa per focalizzare l’azione iniziale nel punto in cui il giocatore ha deciso di stabilire la sua prima linea di difesa.
MovementSystem: Strategie di movimento dei Troll
Una volta che un’entità è stata generata, il suo comportamento spaziale è governato dal MovementSystem. Durante lo sviluppo, questo sistema ha subito un’importante evoluzione. Inizialmente, il movimento era basato su una logica a celle, dove le entità si spostavano istantaneamente da una cella della griglia all’altra. Questo approccio, sebbene semplice da implementare, risultava visivamente “scattoso” e poco realistico.
Per migliorare la fluidità e la qualità visiva del gioco, si è deciso di passare a un sistema di movimento basato su pixel. Questa transizione ha richiesto la definizione di una struttura dati Position che rappresenta le coordinate (x, y) nello spazio di gioco con valori Double, consentendo spostamenti frazionari e quindi animazioni più fluide.
Il MovementSystem è stato quindi riprogettato per aggiornare la PositionComponent di ogni entità mobile a ogni ciclo del game loop, calcolando lo spostamento in base alla velocità dell’entità e al deltaTime (il tempo trascorso dall’ultimo frame). Questo sistema applica diverse strategie di movimento in base alla tipologia dell’entità, secondo un’implementazione del Strategy Pattern:
- Movimento lineare: La maggior parte dei troll implementa una strategia di movimento lineare, avanzando da destra verso sinistra con una velocità definita nel loro
MovementComponent. Questo comportamento costituisce il fondamento della sfida tattica del gioco, richiedendo un posizionamento strategico delle entità difensive per intercettare l’avanzata nemicaprivate val linearLeftMovement: MovementStrategy = (pos, movement, _, _, dt) => val pixelsPerSecond = movement.speed * CELL_WIDTH val minY = GRID_OFFSET_Y val maxY = GRID_OFFSET_Y + GRID_ROWS * CELL_HEIGHT - CELL_HEIGHT / 2 Position( pos.x - pixelsPerSecond * dt, pos.y ) - Movimento a zigzag: Per introdurre una maggiore complessità tattica, è stata implementata una strategia di movimento non lineare per il
Troll Assassino. Questa entità alterna il proprio percorso tra la corsia di generazione e una corsia adiacente, scelta in modo pseudocasuale. Questo comportamento a zigzag lo rende un bersaglio più elusivo, obbligando il giocatore a considerare un posizionamento difensivo più flessibile. Per implementare questo comportamentostateful(il troll deve “ricordare” in quale fase del movimento si trova e da quanto tempo) mantenendo ilMovementSystemcompletamentestatelessè stato introdotto loZigZagStateComponent. Questo componente agisce come una piccola macchina a stati associata a ogni singolo Troll Assassino, contenendo informazioni come la riga di spawn, la riga alternativa, la fase corrente del movimento (OnSpawnRowoOnAlternateRow) e il timestamp di inizio della fase. In questo modo, ilMovementSystemnon deve mantenere alcuno stato interno; ad ogni update, legge semplicemente loZigZagStateComponentdell’entità per calcolarne la nuova posizione, garantendo che ogni assassino gestisca il proprio ciclo di zigzag in modo indipendente e che la logica di movimento rimanga pura e disaccoppiataprivate val zigzagMovement: MovementStrategy = (pos, movement, entity, world, dt) => world.getComponent[ZigZagStateComponent](entity) match case Some(state) => val currentTime = System.currentTimeMillis() val updatedPos = calculateZigZagPosition(pos, movement.speed, dt, state, currentTime) updatedPos case None => pos
Il MovementSystem gestisce anche l’interazione con altri sistemi attraverso il sistema a componenti. Ad esempio, la presenza di un FreezedComponent su un troll, applicato dal CollisionSystem a seguito di un attacco di ghiaccio, viene rilevata dal MovementSystem per modificare dinamicamente la velocità dell’entità. Questo disaccoppiamento tra la logica del movimento e gli effetti di stato, facilitato dall’architettura ECS, ha permesso di implementare interazioni complesse tra entità in modo modulare e manutenibile.
HealthBarRenderSystem: Feedback visivo sullo stato di salute delle entità
Per fornire al giocatore un feedback visivo immediato sullo stato di salute delle entità in gioco, ho implementato l’HealthBarRenderSystem. Questo sistema si integra nel ciclo di rendering principale e ha la responsabilità di disegnare le barre della vita sopra le entità che hanno subito danni.
L’implementazione segue un approccio orientato all’efficienza e alla separazione delle responsabilità, operando in diverse fasi all’interno del suo metodo update:
-
Raccolta dati: Il sistema per prima cosa interroga il
Worldper identificare tutte le entità che possiedono unHealthComponent. Per ciascuna di queste entità, raccoglie le informazioni necessarie per il rendering: la posizione, la percentuale di salute corrente e ilHealthBarComponentassociato. Una scelta implementativa importante è stata quella di fornire un comportamento di default: se un’entità con salute non ha unHealthBarComponentesplicito, il sistema ne crea uno al volo, differenziando il colore della barra in base al tipo di entità (verde per i maghi, rosso per i troll). Questo garantisce la coerenza visiva e semplifica la creazione delle entità, che non devono necessariamente essere definite con un componente per la barra della vita -
Calcolo dei parametri di Rendering: Successivamente, i dati raccolti vengono elaborati per calcolare i parametri esatti per il rendering. Questo include l’aggiornamento del colore della barra in base alla percentuale di salute (verde per salute alta, giallo per media, rosso per bassa), una logica incapsulata all’interno del
HealthBarComponentstesso per mantenere il componente coeso e responsabile del proprio stato visivo - Filtro di visibilità: Una delle ottimizzazioni chiave del sistema è il filtraggio delle barre della vita. Per evitare di disegnare elementi non necessari e ridurre l’overhead di rendering, vengono renderizzate solo le barre delle entità la cui salute è compresa tra 0% e 100% (esclusi). Le entità con salute piena o quelle sconfitte non mostrano la barra della vita, mantenendo l’interfaccia pulita e focalizzata sulle informazioni rilevanti per il giocatore
// in HealthBarRenderSystem.scala private def filterVisibleBars(bars: Map[EntityId, RenderableHealthBar]): Map[EntityId, RenderableHealthBar] = bars.filter { case (_, (_, percentage, _, _, _, _)) => percentage < 1.0 && percentage > 0.0 } - Caching e Rendering: Infine, i dati delle barre visibili vengono memorizzati in una cache (
healthBarCache) all’interno dello stato del sistema. Questa cache viene poi passata alRenderSystemprincipale, che si occupa del disegno effettivo degli elementi a schermo. L’uso di una cache interna permette di disaccoppiare la logica di calcolo delle barre dalla loro effettiva visualizzazione
Questo approccio garantisce che il feedback visivo sullo stato di salute sia non solo informativo, ma anche performante, contribuendo a un’esperienza di gioco fluida anche in presenza di un numero elevato di entità a schermo.
Interfaccia Utente
Oltre alla logica di gioco, il mio contributo si è esteso all’implementazione di componenti cruciali dell’interfaccia utente, in particolare i menu di overlay che gestiscono le interruzioni del flusso di gioco. Questi elementi sono stati sviluppati utilizzando ScalaFX, adottando un approccio funzionale e dichiarativo per la costruzione della UI.
Menu di Pausa e Schermate di Vittoria/Sconfitta
Ho sviluppato i pannelli PauseMenu e GameResultPanel per gestire, rispettivamente, la messa in pausa del gioco da parte dell’utente e la conclusione di un’ondata o della partita.
La progettazione di questi componenti si è basata su alcuni principi chiave:
-
Componibilità e riuso: Entrambi i pannelli condividono una struttura simile, basata su uno
StackPaneche sovrappone un layout di controlli a un’immagine di sfondo. La creazione dei bottoni e la gestione delle loro azioni sono state delegate a unButtonFactorycentralizzato, che traduce le interazioni dell’utente inGameEventspecifici (es.ResumeGame,ContinueBattle,NewGame). Questo approccio ha permesso di ridurre la duplicazione del codice e di mantenere una netta separazione tra la vista e la logica di controllo -
Gestione dichiarativa degli stati: Per il
GameResultPanel, ho utilizzato un Algebraic Data Type (ADT), definito tramite unasealed trait, per modellare i due possibili esiti della partita:VictoryeDefeat. Questa scelta progettuale permette di rappresentare gli stati in modo type-safe ed estensibile. Ognicase objectincapsula le informazioni specifiche per quel determinato stato, come l’immagine del titolo da mostrare e l’azione da associare al pulsante di continuazionesealed trait ResultType: def titleImagePath: String def continueButtonText: String def continueAction: ButtonAction case object Victory extends ResultType: val titleImagePath = "/victory.png" val continueButtonText = "Next wave" val continueAction: ButtonAction = ContinueBattle case object Defeat extends ResultType: val titleImagePath = "/defeat.png" val continueButtonText = "New game" val continueAction: ButtonAction = NewGameQuesto pattern non solo rende il codice più leggibile e manutenibile, ma garantisce anche che il pannello si adatti correttamente al contesto, offrendo azioni pertinenti all’utente (ad esempio, “Prossima ondata” dopo una vittoria e “Nuova partita” dopo una sconfitta)
Per migliorare la robustezza dell’interfaccia in queste schermate, è stata implementata una logica di debouncing per i pulsanti “Next wave” e “New game”. Questa modifica previene comportamenti anomali o race condition che potrebbero verificarsi a causa di click ripetuti e rapidi da parte dell’utente. La soluzione, implementata nel GameResultPanel attraverso il metodo createDebouncedButton, utilizza un AtomicBoolean per garantire che l’azione associata al pulsante venga eseguita una sola volta. Una volta premuto, il pulsante viene temporaneamente disabilitato per una breve durata (definita dalla costante DEBOUNCE_MS), impedendo l’invio di eventi ContinueBattle o NewGame duplicati all’EventHandler. Questo accorgimento assicura una transizione di stato pulita e controllata, gestita dal GameController.
Per ottimizzare le performance, il caricamento delle immagini di sfondo e dei titoli è stato implementato utilizzando lazy val. In questo modo, le risorse grafiche vengono caricate dal disco solo al momento del loro primo utilizzo effettivo, riducendo il tempo di avvio e il consumo di memoria dell’applicazione. La logica di caricamento e caching è stata incapsulata nell’ImageFactory, promuovendo ulteriormente il riuso del codice.
In sintesi, l’implementazione di questi componenti dell’interfaccia utente ha seguito i principi della programmazione funzionale e della separazione delle responsabilità, portando a un codice modulare, efficiente e facilmente estensibile.
Testing e Validazione
La validazione della correttezza e della robustezza del software è stata una componente integrante del processo di sviluppo. Sebbene non sia stata adottata una metodologia strettamente Test-Driven Development (TDD), i test sono stati scritti in modo sistematico parallelamente o immediatamente dopo l’implementazione di ogni funzionalità. Questo approccio ha permesso di garantire la stabilità del codice, facilitare le fasi di refactoring e prevenire l’introduzione di regressioni.
Per la stesura e l’esecuzione dei test è stato utilizzato ScalaTest, un framework ampiamente diffuso nell’ecosistema Scala, che ha permesso di scrivere test chiari e leggibili.
Domain-Specific Language (DSL) per Scenari di Test
Una delle sfide principali nel testare un’applicazione complessa come un videogioco, specialmente uno basato sull’architettura ECS, è la configurazione dello stato iniziale per ogni scenario di test. La creazione manuale di entità, l’aggiunta di componenti e l’impostazione dei parametri di gioco possono risultare verbose, ripetitive e difficili da leggere, oscurando l’intento effettivo del test.
Per superare questa difficoltà, è stato progettato e implementato un Domain-Specific Language (DSL) interno, specifico per la creazione di scenari di gioco.
Il DSL si basa su un ScenarioBuilder che offre una serie di metodi concatenabili per definire lo stato del gioco in modo fluido:
// in GameScenarioDSL.scala
def scenario(setup: ScenarioBuilder => Unit): (World, GameSystemsState) =
val builder = ScenarioBuilder()
setup(builder)
builder.build()
class ScenarioBuilder:
// ...
def withWizard(wizardType: WizardType): WizardPlacer = ...
def withTroll(trollType: TrollType): TrollPlacer = ...
def withElixir(amount: Int): this.type = ...
def atWave(waveNumber: Int): this.type = ...
// ...
Questo permette di scrivere test estremamente concisi e focalizzati sul comportamento da verificare, come dimostrato nell’esempio seguente:
Senza DSL (verboso e poco leggibile):
val world = World.empty
val (world1, wizard) = world.createEntity()
val world2 = world1.addComponent(wizard, WizardTypeComponent(WizardType.Fire))
val world3 = world2.addComponent(wizard, PositionComponent(pos))
val world4 = world3.addComponent(wizard, HealthComponent(100, 100))
// ... 10+ linee simili
Con DSL (dichiarativo e chiaro):
val (testWorld, _) = scenario: builder =>
builder
.withWizard(WizardType.Fire).at(GRID_ROW_MID, GRID_COL_START)
.withTroll(TrollType.Basic).at(GRID_ROW_MID, GRID_COL_END)
.withElixir(ELIXIR_START)
Copertura dei Test
Sono state create suite di test per tutti i principali moduli logici del gioco, garantendo una solida copertura delle funzionalità critiche.
I test coprono circa il 75% del codice implementato, con una copertura del 90% per i componenti core del GameEngine e dei sistemi ECS. I componenti UI non sono stati testati in quanto basati su ScalaFX, framework che richiede test di integrazione complessi. In particolare, sono stati testati:
-
GameEngineTesteGameLoopTest: Verificano la corretta gestione del ciclo di vita del gioco (avvio, arresto, pausa, ripresa) e la stabilità del ciclo di aggiornamento a timestep fisso -
MovementSystemTest: Assicura che le diverse strategie di movimento (lineare, zigzag) vengano applicate correttamente e che gli effetti di stato (come il rallentamento) modifichino il comportamento delle entità come previsto -
SpawnSystemTest: Valida la logica di generazione dei nemici, controllando il rispetto dei tempi, il numero massimo di troll per ondata e l’applicazione corretta dello scaling di difficoltà -
GameSystemsStateTest: Verifica le transizioni di stato del gioco e la corretta rilevazione delle condizioni di vittoria e sconfitta
L’approccio al testing adottato si è dimostrato efficace nel garantire la qualità e la robustezza del codice, costituendo una rete di sicurezza indispensabile durante l’intero ciclo di sviluppo del progetto. Anche se riflettendo sul processo, un’adozione del Test-Driven Development (TDD) avrebbe potuto portare ulteriori benefici. Questo avrebbe potuto ridurre alcune delle sessioni di refactoring e portare a un design ancora più pulito e disaccoppiato. Inoltre, avrebbe fornito una guida più strutturata per l’implementazione, trasformando i requisiti in casi di test eseguibili che avrebbero definito in modo inequivocabile il comportamento atteso di ogni modulo