Implementazione - Giacomo Foschi
Panoramica dei Contributi
Il mio contributo al progetto si è concentrato sulla progettazione e implementazione di diversi aspetti chiave del sistema, con un focus particolare sull’architettura ECS (Entity-Component-System) e sul rendering grafico. Le aree principali di cui mi sono occupato sono:
- Architettura del
WorldECS: Progettazione delWorldcome contenitore immutabile per la gestione di entità e componenti, sfruttando lecase classe le collezioni immutabili di Scala. - Struttura della View: Implementazione del
ViewControllerper la gestione degli stati della UI e delGameViewper il rendering della scena di gioco. - Factory per la UI: Creazione di
ButtonFactoryeImageFactoryper standardizzare e ottimizzare la creazione di elementi grafici, con gestione funzionale degli errori tramiteOptionedEithere caching. - Logica di Combattimento e Collisioni: Sviluppo del
CombatSysteme delCollisionSystemcome funzioni pure (World => World) sullo stato del mondo, comunicando tramite l’aggiunta/rimozione di componenti immutabili. - Utility per la Griglia: Implementazione del
GridMapperper la conversione tra coordinate logiche e fisiche. - Movimento dei Proiettili: Definizione della logica di movimento specifica per i proiettili all’interno del
MovementSystem. - Sistema di Rendering: Creazione del
RenderSystemper la visualizzazione delle entità a schermo, con ottimizzazioni basate su hashing dello stato per evitare rendering ridondanti.
Architettura del World in ECS
Il World è il cuore del nostro pattern Entity-Component-System (ECS). È stato progettato come una case class immutabile che agisce da contenitore per tutte le entità di gioco e i loro componenti (case class immutabili per design). La sua immutabilità è fondamentale per aderire ai principi della programmazione funzionale: ogni aggiornamento (eseguito dai System) produce un nuovo stato del mondo senza modificare quello precedente (nessun side effect), semplificando il debugging, la gestione dello stato e garantendo la thread-safety intrinseca.
Le sue responsabilità principali sono:
- Gestione delle Entità: Fornisce metodi per creare (
createEntity) e distruggere (destroyEntity) entità in modo sicuro. Ogni entità è rappresentata da unEntityIdunivoco - Gestione dei Componenti: Permette di aggiungere (
addComponent), rimuovere (removeComponent) e aggiornare (updateComponent) componenti associati a un’entità. I componenti sono semplicicase classche contengono solo dati (es.PositionComponent,HealthComponent) - Querying: Offre API per interrogare lo stato del gioco, come ottenere tutte le entità con un certo componente (
getEntitiesWithComponent) o di un certo tipo (getEntitiesByType)
Un esempio di come un sistema interagisce con il World per aggiungere un componente a un’entità:
// Esempio di utilizzo del World
case class World(
private val entities: Set[EntityId] = Set.empty,
private val components: Map[Class[_], Map[EntityId, Component]] = Map.empty,
// ...
):
def addComponent[T <: Component](entity: EntityId, component: T): World =
if !entities.contains(entity) then
this
else
// ... logica per aggiungere il componente in modo immutabile
val componentClass = component.getClass
val updatedComponents = components.updatedWith(componentClass): opt =>
Some(opt.getOrElse(Map.empty) + (entity -> component))
val updatedEntitiesByType = updateEntityTypeMapping(entity, component)
copy(components = updatedComponents, entitiesByType = updatedEntitiesByType)
Questa architettura favorisce la composizione sull’ereditarietà (le entità sono definite dinamicamente dalla combinazione dei loro componenti) e garantisce una netta separazione tra dati (Componenti, case class immutabili) e logica (Sistemi, funzioni pure World => World), rendendo il codice modulare, testabile e manutenibile.
Struttura della View: ViewController e GameView
L’interfaccia utente (UI), basata su ScalaFX, è gestita principalmente da ViewController e GameView, seguendo i principi del pattern MVC, che collaborano per presentare lo stato del gioco all’utente e per gestire i suoi input.
ViewController
Il ViewController, implementato come object che estende JFXApp3, orchestra l’intera UI. È l’entry point dell’applicazione ScalaFX e gestisce le transizioni tra le diverse schermate del gioco (ViewState, un Algebraic Data Type definito con sealed trait) aggiornando la Scene principale della PrimaryStage.
Le sue responsabilità includono:
- Gestione degli Stati della UI: Il metodo
updateViewriceve un nuovoViewStatee, tramite pattern matching, seleziona e carica il layout corretto (MainMenu(),GameView(), etc.). Mantiene lo stato corrente in unacase classViewControllerState. - Inizializzazione e Pulizia: Si occupa di creare l’istanza del
GameControllerall’avvio e gestisce la pulizia (cleanup) delle risorse (es. cache immagini, statoGameView) quando si esce da una schermata complessa come quella di gioco verso il menù principale. - Inoltro degli Input: Fornisce metodi
requestXYZ(es.requestGameView,requestPlaceWizard) che traducono le azioni dell’utente inGameEventspecifici, inviandoli poi alGameControllertramitepostEvent. Questo disaccoppia la View dalla logica del Controller.
GameView
Il GameView, anch’esso un object, è il componente responsabile del rendering della schermata di gioco principale. Utilizza uno StackPane per sovrapporre diversi livelli (Pane): Sfondo, Griglia overlay, Entità (maghi/troll), Proiettili, Barre della Vita, e UI Overlay (Shop, Wave, Pausa).
- Sfondo: L’immagine di background della mappa di gioco
- Griglia: Un
Paneper disegnare overlay sulla griglia, come le celle valide per il posizionamento - Entità: Un
Panedove vengono renderizzati i maghi e i troll - Proiettili: Un
Paneseparato per i proiettili, per poterli gestire indipendentemente - Barre della Vita: Un
Paneper le barre della salute - UI Overlay: Il livello più alto che contiene elementi come lo
ShopPanel, ilWavePanele il pulsante di pausa
Il GameView espone metodi come renderEntities e renderHealthBars che vengono invocati dal RenderSystem (tramite il ViewController) per aggiornare la visualizzazione a schermo in modo efficiente, assicurando che le operazioni di rendering avvengano sul thread della UI di JavaFX tramite Platform.runLater.
Factory per la UI: ButtonFactory e ImageFactory
Per promuovere il riutilizzo del codice e garantire uno stile grafico coerente, ho implementato due factory object.
ImageFactory
ImageFactory centralizza la creazione e gestione di ImageView. La sua caratteristica principale è l’implementazione di un sistema di caching (mutable.Map) per ottimizzare performance e memoria.
object ImageFactory:
private val imageCache: mutable.Map[String, Image] = mutable.Map.empty
// Restituisce Option[Image], gestendo il fallimento del caricamento
private def loadImage(path: String): Option[Image] =
imageCache.get(path).orElse(loadAndCacheImage(path))
// Restituisce Either per una gestione errori più esplicita
def createImageView(imagePath: String, width: Int): Either[String, ImageView] =
loadImage(imagePath)
.toRight(s"Error loading image at path: $imagePath")
.map(image => createFixedWidthImageView(image, width))
Quando viene richiesta un’immagine (loadImage), la factory controlla la cache; se l’immagine non è presente, tenta di caricarla (loadAndCacheImage usa Option(getClass.getResourceAsStream(path)) per gestire resource non trovate) e la memorizza. Il metodo createImageView propaga l’eventuale fallimento usando Either[String, ImageView], permettendo al chiamante (GameView) di gestire l’errore in modo funzionale (es. con fold o pattern matching) invece di usare eccezioni. Questo approccio ottimizza le performance e riduce il consumo di memoria, evitando di ricaricare più volte la stessa immagine.
ButtonFactory
ButtonFactory standardizza la creazione dei bottoni. Utilizza una case class ButtonConfig per definire l’aspetto di un bottone (testo, dimensioni, font) e una serie di Presets per configurazioni comuni (es. mainMenuButtonPreset, shopButtonPreset).
Questo permette di creare bottoni con uno stile omogeneo in tutta l’applicazione con una sola riga di codice, associando direttamente un’azione che viene eseguita onAction.
def createStyledButton(config: ButtonConfig)(action: => Unit): Button =
createButton(config).withAction(action).withOverEffect().build()
Utilizza un ButtonBuilder interno (pattern Builder) per una configurazione fluente. Inoltre, il ButtonFactory gestisce anche l’associazione tra ButtonAction (un ADT che rappresenta le azioni possibili) e le chiamate al ViewController, tramite pattern matching, mantenendo la logica di navigazione disaccoppiata dalla definizione dei bottoni.
Logica di Combattimento e Collisioni
Il combattimento è gestito da CombatSystem e CollisionSystem, entrambi case class stateless che implementano il trait System, operando come funzioni pure World => (World, System). Queste classi collaborano tra loro per gestire i cicli di attacco e risoluzione delle collisioni.
CombatSystem
Questo sistema inizia gli attacchi a distanza. Il metodo update prende il World corrente e restituisce un nuovo World modificato. La logica interna utilizza ampiamente costrutti funzionali:
- Iterazione Funzionale: Usa
foldLeftsulle liste di entità (ottenute tramiteworld.getEntitiesByType) per processare attaccanti maghi e troll lanciatori in modo immutabile - Gestione dell’Assenza (Monadi): La ricerca del bersaglio (
findClosestTarget) usaflatMap,filter,minByOptionsuOptione collezioni per trovare il bersaglio più vicino sulla stessa riga, restituendoOption[EntityId]per gestire il caso in cui non ci siano bersagli validi
In particolare, il system si occupa di:
- Scansionare le entità: Itera su tutte le entità che possono attaccare (maghi e troll lanciatori)
- Ricercare dei bersagli: Per ogni attaccante, cerca un bersaglio valido all’interno del suo raggio d’attacco (
findClosestTarget). La ricerca è ottimizzata per controllare solo le entità sulla stessa riga - Gestire i Cooldown: Verifica che l’attaccante non sia in fase di cooldown
- Creare dei Proiettili: Se tutte le condizioni sono soddisfatte, utilizza l’
EntityFactoryper creare un’entità proiettile nella posizione dell’attaccante e aggiunge unCooldownComponentall’attaccante per prevenire attacchi troppo ravvicinati
// In CombatSystem.scala
private def spawnProjectileAndSetCooldown(
world: World,
entity: EntityId,
position: Position,
projectileType: ProjectileType,
cooldown: Long
): World =
val (world1, _) = EntityFactory.createProjectile(world, position, projectileType)
world1.addComponent(entity, CooldownComponent(cooldown))
CollisionSystem
Questo sistema risolve le collisioni, prendendo il World modificato dal CombatSystem e restituendo un nuovo World.
-
Iterazione Funzionale: Come
CombatSystem, usafoldLeftper processare proiettili e troll in mischia (processProjectileList,processMeleeList). -
Gestione dell’Assenza (Monadi): La logica per processare una singola collisione (
processProjectileCollision,processMeleeCollision) è incapsulata in for-comprehension suOptionper estrarre i componenti necessari (posizione, tipo, danno). Se un componente manca, il for-comprehension fallisce e restituisce ilWorldinvariato tramite.getOrElse(world). La ricerca dell’entità collidente (findCollidingEntity) usa anch’essaOptioneflatMap.- Collisioni dei Proiettili: Il sistema itera su tutti i proiettili attivi e controlla se la loro posizione (cella della griglia) coincide con quella di un’entità bersaglio. Se viene rilevata una collisione:
- Il proiettile viene distrutto
- Un
CollisionComponent, contenente l’ammontare del danno, viene aggiunto all’entità bersaglio - Se il proiettile è di tipo “ghiaccio”, viene aggiunto anche un
FreezedComponentper rallentare il bersaglio
- Collisioni in Mischia: Successivamente, il sistema gestisce gli attacchi in mischia dei troll. Se un troll si trova nella stessa cella di un mago, il troll viene bloccato (aggiungendo un
BlockedComponent) e, se non è in cooldown, infligge danno al mago aggiungendo unCollisionComponent
- Collisioni dei Proiettili: Il sistema itera su tutti i proiettili attivi e controlla se la loro posizione (cella della griglia) coincide con quella di un’entità bersaglio. Se viene rilevata una collisione:
Questa separazione di responsabilità permette di gestire in modo pulito e modulare i diversi tipi di interazione offensiva nel gioco. Il danno vero e proprio viene poi applicato dall’HealthSystem in una fase successiva del ciclo di gioco.
Utility e Sistemi di Supporto
GridMapper
Il GridMapper è un utility object fondamentale che funge da “traduttore” tra il sistema di coordinate logiche della griglia (righe e colonne) e il sistema di coordinate fisiche dello schermo (pixel x, y).
Fornisce metodi essenziali come:
logicalToPhysical: Converte una coppia(riga, colonna)nella posizione centrale in pixel di quella cellaphysicalToLogical: Converte coordinate(x, y)in pixel nella coppia(riga, colonna)corrispondente
Questo disaccoppia completamente la logica di gioco (che ragiona in termini di griglia) dalla rappresentazione grafica (che lavora con i pixel), rendendo il codice più pulito e manutenibile.
Movimento dei Proiettili
Il movimento di tutte le entità, inclusi i proiettili, è gestito dal MovementSystem. Per i proiettili, la logica è semplice e lineare:
- I proiettili dei maghi si muovono da sinistra verso destra (
projectileRightMovement) - I proiettili dei troll si muovono da destra verso sinistra (
linearLeftMovement)
Il sistema aggiorna la PositionComponent di ogni proiettile in base alla sua velocità e al tempo trascorso (deltaTime). Inoltre, il MovementSystem è anche responsabile di rimuovere i proiettili che escono dai confini dello schermo, evitando l’accumulo di entità inutili.
RenderSystem
Il RenderSystem orchestra il processo di visualizzazione delle entità. Ad ogni ciclo, non ridisegna ciecamente tutto, ma implementa un’ottimizzazione per migliorare le performance:
- Raccolta delle Entità: Colleziona tutte le entità che possiedono sia un
PositionComponentche unImageComponent - Creazione di un Hash di Stato:
generateStateHashè una funzione pura che prende le sequenze immutabili di entità e barre vita e produce una stringa hash deterministica, usandomapemkString. - Confronto: Confronta l’hash corrente con quello dell’ultimo frame renderizzato (
lastRenderedState).// In RenderSystem.scala private def shouldRender(currentState: String): Boolean = !lastRenderedState.contains(currentState)La decisione di renderizzare (
shouldRender) è una semplice comparazione tra l’hash corrente e quello precedente. Se gli hash non corrispondono, avviene il rendering tramite il metodoupdate, che restituisce una nuova istanza delRenderSystemconlastRenderedStateaggiornato, mantenendo l’immutabilità del sistema stesso. Questo approccio evita side effect (il rendering sulla UI) se non strettamente necessari.