diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..37f4d47 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 PPS-23-Spac-Man + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 5307648..4b64dd4 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,9 @@ Di seguito sono riassunti i principali elementi e funzionalità dell'applicazion Funzionalità addizionali, nel caso avanzi del tempo: -- [ ] Oggetti che permettono di mangiare i nemici (cambiamento di stato dei fantasmi) -- [ ] Vite del giocatore ed oggetti per recuperarle -- [ ] Comportamenti differenti per ciascun fantasma +- [x] Oggetti che permettono di mangiare i nemici (cambiamento di stato dei fantasmi) +- [x] Vite del giocatore ed oggetti per recuperarle +- [x] Comportamenti differenti per ciascun fantasma - [ ] Altre mappe ## Autori @@ -32,6 +32,8 @@ Funzionalità addizionali, nel caso avanzi del tempo: 3. [Design architetturale](docs/3-architettura.md) 4. [Design di dettaglio](docs/4-design-dettaglio.md) 5. [Implementazione](docs/5-implementazione.md) + - [Francesco Carlucci](./docs/implementazione/carlucci.md) + - [Marco Raggini](./docs/implementazione/raggini.md) 6. [Testing](docs/6-testing.md) 7. [Retrospettiva](docs/7-retrospettiva.md) diff --git a/build.sbt b/build.sbt index a0a84bc..2b99d71 100644 --- a/build.sbt +++ b/build.sbt @@ -3,8 +3,8 @@ ThisBuild / scalaVersion := "3.3.5" lazy val root = project .in(file(".")) .settings( - name := "spac-man", - version := "0.1.0-SNAPSHOT", + name := "PPS-23-Spac-Man", + version := "1.0.0", libraryDependencies += "com.github.sbt" % "junit-interface" % "0.13.2" % Test, libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.18" % Test, libraryDependencies += "org.scala-lang.modules" %% "scala-swing" % "3.0.0", diff --git a/docs/1-processo.md b/docs/1-processo.md index d07e3a0..36dbc0f 100644 --- a/docs/1-processo.md +++ b/docs/1-processo.md @@ -58,6 +58,7 @@ Questo approccio ha garantito un aggiornamento costante tra i membri e un ulteri Per il **testing automatico** è stato scelto **ScalaTest**, strumento noto e facilmente integrabile con l’ambiente di sviluppo. Come **build tool** è stato adottato **sbt**, pensato specificamente per progetti Scala. Per mantenere una formattazione coerente del codice è stato impiegato **scalafmt**, che assicura uno stile uniforme all’interno del team. +Per corregge possibili errori di stile e sintassi è stato integrato **scalafix**, che segnala come warning al programmatore problemi di sintassi, miglioramenti nella sicurezza del codice, cleanup del codice non utilizzato. L’intero progetto e la relativa relazione sono stati gestiti tramite **GitHub**. Per automatizzare i processi di test e controllo qualità, è stata configurata una pipeline di Continuous Integration mediante **GitHub Actions**, la quale si attiva automaticamente a ogni nuova *pull request* sul branch di sviluppo. @@ -67,7 +68,7 @@ I workflow configurati eseguono le seguenti azioni: - **Build e test**: compilazione e test automatici del codice su più piattaforme (Ubuntu, Windows, macOS) e versioni di Java (17 e 21), garantendo compatibilità cross-platform e prevenendo regressioni. Il processo viene eseguito a ogni push, assicurando un monitoraggio continuo dello stato del software. - **Controllo della formattazione**: verifica della conformità del codice agli standard di formattazione definiti dal team tramite il comando `scalafmtCheckAll`, per mantenere una codebase coerente e leggibile. - **Validazione dei commit**: implementazione di un controllo automatico sui messaggi di commit, secondo le specifiche di **Conventional Commits**, al fine di garantire chiarezza e coerenza nella cronologia del progetto. -- **Rilascio automatico**: al momento del merge di una *pull request* sul branch *main* viene eseguito un rilascio automatico tramite **Semantic Release** che aggiorna la versione del software e genera le note di rilascio, semplificando la gestione delle release e garantendo una documentazione accurata di ogni nuova versione +- **Rilascio automatico**: al momento del merge di una *pull request* sul branch *main* viene eseguito un rilascio automatico tramite **Semantic Release** che aggiorna la versione del software e genera le note di rilascio, semplificando la gestione delle release e garantendo una documentazione accurata di ogni nuova versione. --- @@ -77,5 +78,7 @@ I workflow configurati eseguono le seguenti azioni: 3. [Design architetturale](3-architettura.md) 4. [Design di dettaglio](4-design-dettaglio.md) 5. [Implementazione](5-implementazione.md) + - [Francesco Carlucci](./implementazione/carlucci.md) + - [Marco Raggini](./implementazione/raggini.md) 6. [Testing](6-testing.md) 7. [Retrospettiva](7-retrospettiva.md) diff --git a/docs/2-requisiti.md b/docs/2-requisiti.md index 65a89bb..0d91b4c 100644 --- a/docs/2-requisiti.md +++ b/docs/2-requisiti.md @@ -115,5 +115,7 @@ Il dominio del progetto si basa sui seguenti elementi principali: 3. [**Design architetturale (next)**](3-architettura.md) 4. [Design di dettaglio](4-design-dettaglio.md) 5. [Implementazione](5-implementazione.md) + - [Francesco Carlucci](./implementazione/carlucci.md) + - [Marco Raggini](./implementazione/raggini.md) 6. [Testing](6-testing.md) 7. [Retrospettiva](7-retrospettiva.md) diff --git a/docs/3-architettura.md b/docs/3-architettura.md index 94f8036..21e02db 100644 --- a/docs/3-architettura.md +++ b/docs/3-architettura.md @@ -15,7 +15,7 @@ Questa separazione facilita la manutenibilità e l'estensibilità del codice, co ## Struttura del progetto -Struttura del progetto +Struttura del progetto
*Struttura del progetto evidenziando Controller (blu); Model (rosso); View (giallo)* @@ -48,5 +48,7 @@ Come da pattern MVC, la struttura del progetto è divisa in 3 moduli principali: 3. [Design architetturale](3-architettura.md) 4. [**Design di dettaglio (next)**](4-design-dettaglio.md) 5. [Implementazione](5-implementazione.md) + - [Francesco Carlucci](./implementazione/carlucci.md) + - [Marco Raggini](./implementazione/raggini.md) 6. [Testing](6-testing.md) 7. [Retrospettiva](7-retrospettiva.md) diff --git a/docs/4-design-dettaglio.md b/docs/4-design-dettaglio.md index 7734750..6962940 100644 --- a/docs/4-design-dettaglio.md +++ b/docs/4-design-dettaglio.md @@ -7,7 +7,7 @@ Per la creazione della mappa si è optato per l'utilizzo dei **Factory Methods**, in particolare l'oggetto `GameMapFactory` permette la creazione di mappe vuote. Per agevolare e velocizzare il riempimento delle mappe(anche in funzione dei test), si è deciso di creare un **DSL**. Esso permette di posizionare le entità di dominio all'interno della mappa con un linguaggio naturale e più veloce. Inoltre offre la possibilità di creare e collocare nella mappa file di muri. -Struttura del progetto +Diagramma DSL ### Creazione entità del gioco @@ -15,29 +15,78 @@ Qualsiasi entità di gioco viene rappresentata nell'applicazione come un'interfa L'unica di queste entità per cui è stato utilizzato l'approccio dei **Factory Methods** è il `Wall`, grazie alla quale è possibile creare interi set di muri continui a partire solamente da due posizioni. -Struttura del progetto +Diagramma entità del gioco ### Game Manager -### Ghost Behavior +La logica principale del gioco è stata incapsulata nell'interfaccia `GameManager`, che definisce le operazioni disponibili: movimento dello SpacMan, movimento dei fantasmi, aggiornamento del tempo di inseguimento, verifica vittoria/sconfitta. + +L'implementazione `SimpleGameManager` funge da orchestratore della logica di gioco: mantiene internamente lo stato corrente tramite il `GameState` e coordina le interazioni tra mappa, entità e sistema di collisioni. + +Il `GameState` incapsula tutte le informazioni necessarie allo svolgimento della partita, come la posizione dello SpacMan, la mappa di gioco, lo stato di game over e il tempo rimanente della modalità chase. + +La gestione delle collisioni è stata separata all'interno dell'oggetto `CollisionsManager`, che funge da servizio stateless, cioè non mantiene alcuno stato interno, ma fornisce esclusivamente funzioni che operano sui dati che ricevono. + +Infine, le collisioni sono modellate tramite l'enum `CollisionType`, che rappresenta in modo esplicito e tipizzato tutte le possibili interazioni tra SpacMan e le entità presenti nella mappa. + +Diagramma di Game Manager e Collisions Manager + +### Ghosts + +I fantasmi sono modellati tramite la case class `GhostBasic` come oggetti immutabili che rappresentano una `MovableEntity` all'interno della mappa di gioco. + +L'identificatore (id) di ciascun fantasma viene utilizzato per determinare il comportamento del fantasma tramite l'oggetto `GhostBehavior`, che funge da registry dei comportamenti disponibili, associando a ciascun id una strategia specifica. +Questo approccio consente di aggiungere nuovi comportamenti senza modificare la logica dei fantasmi, migliorando l'estendibilità del sistema. + +Il comportamento dei fantasmi è modellato tramite il pattern **Strategy** che utilizza il sealed trait `GhostBehavior` per definire l'interfaccia comune a tutte le strategie di movimento. +Ogni comportamento concreto implementa il metodo `chooseDirection`, incapsulando così la propria logica decisionale. + +La classe `GhostContext` incapsula tutte le informazioni necessarie per scegliere il movimento successivo: lo stato del fantasma, la posizione e la direzione dello SpacMan e la mappa di gioco. +Inoltre fornisce metodi di utilità, come `canMove` e `validDirections`, che permettono alle strategie di interrogare la mappa senza accedervi direttamente. + +Le startegie di comportamento implementate sono: + +- **ChaseBehavior:** seleziona la direzione che minimizza la distanza rispetto alla posizione dello SpacMan; +- **PredictiveBehavior:** anticipa il movimento dello SpacMan calcolando una posizione futura come obiettivo; +- **RandomBehavior:** ogni volta in cui il fantasma è bloccato sceglie una direzione random tra quelle valide; +- **MixedBehavior:** combina inseguimento e fuga in base alla distanza dal giocatore. + +Diagramma di Ghost ## Controller -### Game Loop +Diagramma flusso di gioco + +### Flusso di gioco + +- `GameController`: Tiene traccia dello stato in cui si trova il gioco e chiama i metodi della `GameView` per cambiare la schermata. Quando inizia la partita crea un'istanza di `GameLoop` e anche di `InputManager` per l'interpretazione dei comandi dati dall'utente. +- `GameLoop`: Il suo scopo principale è quello di gestire il ciclo di vita di una partita implementando un'ordine temporale agli eventi che accadono. In particolare utilizza `GameManager` per controllare lo stato della partita(vittoria/sconfitta) e gestire i movimenti dei fantasmi e dello SpacMan che vengono eseguiti ogni x secondi, dove x è una costante. L'`InputManager` per muovere Spac-Man in base all'input del giocatore. Per ultimo, alla fine di ogni ciclo viene chiamata l'`update()` per aggiornare in modo continuo e costante l'interfaccia grafica. ### Input Manager -Responsabilità: Validare e interpretare l’input grezzo dell’utente +Responsabilità: Validare e interpretare l'input grezzo dell'utente Componenti: InputSystem: Riceve le coordinate grezze del mouse click dalla GameView (inoltrate tramite ViewController e GameController) -InputProcessor: Contiene la logica per verificare se un click (MouseClick) ricade all’interno dell’area valida della griglia (isInGridArea) -ClickResult: case class che rappresenta l’esito della validazione dell’input (posizione valida/invalida, eventuale messaggio di errore) -GridMapper: Utilizzato per convertire le coordinate fisiche (pixel) in coordinate logiche (riga/colonna) se il click è valido. L’EventHandler riceverà poi un GridClicked event con le coordinate logiche +InputProcessor: Contiene la logica per verificare se un click (MouseClick) ricade all'interno dell'area valida della griglia (isInGridArea) +ClickResult: case class che rappresenta l'esito della validazione dell'input (posizione valida/invalida, eventuale messaggio di errore) +GridMapper: Utilizzato per convertire le coordinate fisiche (pixel) in coordinate logiche (riga/colonna) se il click è valido. L'EventHandler riceverà poi un GridClicked event con le coordinate logiche ## View -### +### Schermate di gioco + +- `GameView`: è il componente principale della view. Si occupa di mostrare l'interfaccia grafica del gioco e presenta metodi che vengono utilizzati dal controller come `update`, utilizzato per aggiornare ciclicamente l'interfaccia, `displayWin` e `displayGameOver`, utilizzati per mostrare la schermata di fine gioco. +- `InfoPanel`, `GameMapPanel`: sono i pannelli utilizzati dalla `GameView`, ciascun pannello ha un ruolo grafico ben preciso, in questo modo si mantiene la separazione delle responsabilità tra le diverse componenti dell'interfaccia. +- `SpriteLoader`: si occupa di cercare e caricare tutti gli sprite che verranno poi visualizzati nell'interfaccia grafica. Questo oggetto non solo si occupa del caricamento, ma anche di **caching** per evitare rallentamenti grafici e rendere il gioco scalabile. + +### Creazione componenti UI + +- `ButtonFactory`: crea bottoni con stili predefiniti, è possibile specificare una dimensione al bottone che è sempre predefinita, ma possono essere aggiunte altre dimensioni. In questo momento sono presenti le dimensioni **Big** e **Normal**. +- `LabelFactory`: crea label con stili predefiniti. In questo caso non è possibile specificare una dimensione, ma sono stati creati più metodi per la creazione di label con diverso utilizzo. Questa decisione è stata presa per l'esigenza che le label di uno specifico tipo (ad es. `titleLabel` o `scoreLabel`) avessero tutte la stessa dimensione e lo stesso stile. + +Diagramma della View +--- 0. [Introduzione](../README.md) 1. [Processo di sviluppo](1-processo.md) @@ -45,5 +94,7 @@ GridMapper: Utilizzato per convertire le coordinate fisiche (pixel) in coordinat 3. [Design architetturale *(prev)*](3-architettura.md) 4. [Design di dettaglio](4-design-dettaglio.md) 5. [**Implementazione (next)**](5-implementazione.md) + - [Francesco Carlucci](./implementazione/carlucci.md) + - [Marco Raggini](./implementazione/raggini.md) 6. [Testing](6-testing.md) 7. [Retrospettiva](7-retrospettiva.md) diff --git a/docs/5-implementazione.md b/docs/5-implementazione.md index e69de29..1319072 100644 --- a/docs/5-implementazione.md +++ b/docs/5-implementazione.md @@ -0,0 +1,21 @@ +# Implementazione + +Questa sezione descrive le principali scelte tecniche e implementative adottate per trasformare il design architetturale in un prodotto funzionante. + +Il lavoro è stato suddiviso tra i membri del team in modo equo e di seguito sono presenti i contributi principali di ognuno: + +- [Francesco Carlucci](implementazione/carlucci.md) +- [Marco Raggini](implementazione/raggini.md) + +--- + +0. [Introduzione](../README.md) +1. [Processo di sviluppo](1-processo.md) +2. [Requisiti](2-requisiti.md) +3. [Design architetturale](3-architettura.md) +4. [Design di dettaglio *(prev)*](4-design-dettaglio.md) +5. [Implementazione](5-implementazione.md) + - [**Francesco Carlucci (next)**](./implementazione/carlucci.md) + - [Marco Raggini](./implementazione/raggini.md) +6. [Testing](6-testing.md) +7. [Retrospettiva](7-retrospettiva.md) \ No newline at end of file diff --git a/docs/6-testing.md b/docs/6-testing.md index 75eed15..4975991 100644 --- a/docs/6-testing.md +++ b/docs/6-testing.md @@ -1,9 +1,11 @@ # Testing ## Tecnologie utilizzate + Per quanto riguarda il testing è stata utilizzata la libreria `ScalaTest`, uno dei tool più popolari per il testing in Scala. I vantaggi che abbiamo riscontrato usando questa libreria sono la semplicità di utilizzo e la leggibilità del codice che viene prodotto. Inoltre, i `Matchers` hanno reso possibile un ulteriore miglioramento nella leggibilità dei test grazie a asserzioni come `shouldBe` e `should`. ## Metodologie di testing + Per la scrittura dei test ci siamo ispirati all'approccio Test Driven Development (TDD). L'approccio non è stato sempre seguito in modo rigido, ma si è cercato sempre di scrivere i test contemporaneamente all'implementazione. Sono state adottate diverse contromisure per essere sicuri che l'intera applicazione fosse ben testata e stabile: @@ -14,4 +16,21 @@ Sono state adottate diverse contromisure per essere sicuri che l'intera applicaz ## Risultati coverage Per quanto riguarda la coverage dell'applicazione si è cercato di dare enfasi al testing delle classi del model. Il controller e la view sono state testate attraverso test manuali e grafici, questo perchè i test servivano principalmente per feedback grafici. -// TODO mostrare i risultati della coverage attraverso file o immagini e dire la percentuale media di coverage del model. \ No newline at end of file +Qui è riportato il risultato di `sbt clean coverage test`. + +Coverage del model + +[Coverage del model](model.html) + +--- + +0. [Introduzione](../README.md) +1. [Processo di sviluppo](1-processo.md) +2. [Requisiti](2-requisiti.md) +3. [Design architetturale](3-architettura.md) +4. [Design di dettaglio](4-design-dettaglio.md) +5. [Implementazione](5-implementazione.md) + - [Francesco Carlucci](./implementazione/carlucci.md) + - [Marco Raggini *(prev)*](./implementazione/raggini.md) +6. [Testing](6-testing.md) +7. [**Retrospettiva (next)**](7-retrospettiva.md) diff --git a/docs/7-retrospettiva.md b/docs/7-retrospettiva.md index e69de29..1655360 100644 --- a/docs/7-retrospettiva.md +++ b/docs/7-retrospettiva.md @@ -0,0 +1,32 @@ +# Retrospettiva + +## Analisi del processo di sviluppo e dello stato attuale + +Il processo di sviluppo adottato ha garantito una buona organizzazione e un'efficace coordinazione tra i membri del team. Siamo soddisfatti della metodologia scelta, poiché ci ha permesso di rispettare tutte le scadenze previste. Inoltre, i frequenti confronti interni hanno assicurato un allineamento costante del team. + +Le criticità riscontrate maggiormente sono state: +- Suddividere il lavoro equamente +- Dover dipendere da un altro membro del team per poter proseguire il proprio lavoro + +## Migliorie e lavori futuri + +Le funzionalità principali del gioco e tutte le funzionalità opzionali, ad eccezione del sistema a livelli, sono state realizzate. In futuro si potrà migliorare la user interface,per rendere il gioco più accattivante, ed inserire più livelli con mappe differenti. + +## Conclusioni + +In conclusione, il progetto ha rappresentato un'ottima opportunità per mettere in pratica tecniche e processi di sviluppo affrontati durante il corso. Ha anche consentito di approcciarsi alla progettazione software in modo differente dal solito, dalle fasi iniziali fino alla fine del lavoro, focalizzando l'attenzione principalmente sulla metodologia e sulla qualità del codice. + +L'utilizzo dell'approccio TDD, pur non essendo stato applicato in maniera completamente rigorosa, ha contribuito a migliorare la correttezza del codice, a velocizzare il processo di sviluppo e a individuare più rapidamente eventuali errori. + +--- + +0. [Introduzione](../README.md) +1. [Processo di sviluppo](1-processo.md) +2. [Requisiti](2-requisiti.md) +3. [Design architetturale](3-architettura.md) +4. [Design di dettaglio](4-design-dettaglio.md) +5. [Implementazione](5-implementazione.md) + - [Francesco Carlucci](./implementazione/carlucci.md) + - [Marco Raggini](./implementazione/raggini.md) +6. [Testing *(prev)*](6-testing.md) +7. [Retrospettiva](7-retrospettiva.md) diff --git a/docs/img/Flusso.png b/docs/img/Flusso.png new file mode 100644 index 0000000..531e160 Binary files /dev/null and b/docs/img/Flusso.png differ diff --git a/docs/img/GameManager.png b/docs/img/GameManager.png new file mode 100644 index 0000000..94066eb Binary files /dev/null and b/docs/img/GameManager.png differ diff --git a/docs/img/Ghost.png b/docs/img/Ghost.png new file mode 100644 index 0000000..3564ac2 Binary files /dev/null and b/docs/img/Ghost.png differ diff --git a/docs/img/Model-coverage.png b/docs/img/Model-coverage.png new file mode 100644 index 0000000..019b533 Binary files /dev/null and b/docs/img/Model-coverage.png differ diff --git a/docs/img/View.png b/docs/img/View.png new file mode 100644 index 0000000..757ab88 Binary files /dev/null and b/docs/img/View.png differ diff --git a/docs/implementazione/carlucci.md b/docs/implementazione/carlucci.md new file mode 100644 index 0000000..0f1d853 --- /dev/null +++ b/docs/implementazione/carlucci.md @@ -0,0 +1,233 @@ +# Implementazione - Carlucci Francesco + +## Panoramica dei contributi + +Il mio contributo nel progetto si è focalizzato nelle seguenti aree: + +- **Game Manager:** implementazione del game manager per gestire i movimenti nella mappa di SpacMan e dei fantasmi. +- **Collisions Manager:** implementazione del collisions manager per gestire le collisioni tra entità. +- **Input Manager:** implementazione della gestione degli input da tastiera. +- **Game Entity:** implementazione di entità come `DotBasic`, `DotPower`, `GhostBasic`. +- **Ghost Behaviors:** implementazione dei vari comportamenti dei fantasmi. +- **Game Controller:** implementazione del controller del gioco. +- **Testing:** scrittura dei test per i sistemi implementati, come `GameManagerTest`, `CollisionsManagerTest`, `GhostTest`. + +## Game Manager + +Il `GameManager` permette di muovere lo SpacMan e i fantasmi, aggiornare lo stato del gioco, gestire le collisioni, sapere se la partita è vinta, persa o in modalità inseguimento (chase) ed aggiornare il tempo di inseguimento. + +Per mantenere lo stato del gioco è stata utilizzata la case class `GameState`, quindi ogni sua modifica, effettuata solamente dal `GameManager`, ne produce una nuova istanza tramite `copy`. + +I metodi principali del GameManager sono: + +- `moveGhosts()`: serve a muovere tutti i fantasmi e si articola così: + + 1. Calcolo del movimento di un singolo fantasma tramite `attemptMove`, il cui compito è determinare se e come un fantasma può muoversi e se il movimento è valido; + + 2. Applicazione del movimento sulla mappa tramite `applyMove`, che si occupa di sostituire il fantasma originale con quello spostato se l’aggiornamento va a buon fine; + + 3. Tutti i fantasmi presenti nella mappa vengono processati tramite una riduzione (foldLeft) che per ogni fantasma esegue la `attemptMove` ed in caso di successo l'`applyMove`; + + 4. Dopo aver completato i movimenti, il metodo verifica le collisioni tra ciascun fantasma e SpacMan tramite `checkGhostCollision` ed aggiorna, se necessario, lo stato del gioco; + +- `moveSpacMan(direction: Direction)`: muove lo SpacMan in una determinata direzione: + + 1. Viene controllato che SpacMan possa muoversi nella direzione richiesta; + + 2. Se il movimento è valido viene creato un nuovo SpacMan nella posizione aggiornata; + + 3. Dopo lo spostamento vengono recuperate tutte le entità presenti nella cella occupata da SpacMan per determinare se è avvenuta una collisione e di che tipo; + + 4. In caso di collisione questa viene gestita tramite `applyCollisionEffect` e se la collisione ha prodotto degli effetti validi, lo stato del gioco viene aggiornato. + +- `updateChaseTime(deltaTime: Long)`: aggiorna il tempo di inseguimento sottraendo il deltaTime passato dal `GameLoop`. + +Gli elementi di Scala più rilevanti utilizzati sono: + +- Uso di `Option` come controllo del flusso nel movimento dei fantasmi per evitare if/else annidati e compatibile naturalmente con il case matching: + + ```scala + Option.when(currentMap.canMove(ghost, nextDirection)) { + ghost.move(nextDirection).asInstanceOf[GhostBasic] + } + ``` +- uso di `foldLeft` + - per accumulare sia la mappa aggiornata che la lista dei ghost mossi: + + ```scala + val (updatedMap, movedGhosts) = + state.gameMap.getGhosts.foldLeft((state.gameMap, List.empty[GhostBasic])): + case ((currentMap, ghosts), ghost) => + ``` + - ogni iterazione può modificare mappa, SpacMan, vite e flag gameOver + + ```scala + val finalState = + movedGhosts.foldLeft(state.copy(gameMap = updatedMap)): + (currentState, ghost) => + ``` + +- pattern matching e gestione errori con `Either`: + + ```scala + currentMap.replaceEntityTo(ghost, movedGhost) match + case Right(updatedMap) => updatedMap + case Left(error) => + println(s"Warning: Could not move ghost ${ghost.id} - $error") + currentMap + ``` + ```scala + val updatedMapAfterMove = state.gameMap.replaceEntityTo(state.spacMan, movedSpacMan) match + case Right(updatedMap) => updatedMap + case Left(error) => + println(s"Warning: Could not move SpacMan - $error") + return + ``` +- companion object per factory method per istanziare il manager senza new, secondo le convenzioni di Scala. + + ```scala + object SimpleGameManager: + def apply(...): SimpleGameManager = + new SimpleGameManager(GameState(...)) + ``` + +## Collisions Manager + +Il `CollisionsManager` si occupa di gestire le collisioni tra le entità del gioco. + +I metodi principali del GameManager sono: + +- `detectCollision(entities: Set[GameEntity], direction: Direction)`: ha il compito di identificare se SpacMan entra in contatto con qualcosa nella cella in cui si è appena mosso, ritorna il tipo di collisione avvenuta. +- `applyCollisionEffect(...)`: ha il compito di gestire le collisioni tra le entità del gioco e ritorna la mappa aggiornata e lo SpacMan aggiornato. +- `handleGhostCollision(...)`: ha il compito di gestire le collisioni tra lo SpacMan e i fantasmi, distinguendo il caso in cui il gioco è in modalità inseguimento (chase) o normale. +- `checkGhostCollision(...)`: ha il compito di verificare se un fantasma ha colpito SpacMan, in caso affermativo chiama `handleGhostCollision`. + +Gli elementi di Scala più rilevanti utilizzati sono: + +- uso di `collectFirst` per rilevare le collisioni avvenute che combina filtro e mappatura in un’unica operazione: + ```scala + entities.collectFirst { case g: GhostBasic => GhostCollision(g) } + .orElse(entities.collectFirst { case f: DotFruit => DotFruitCollision(f) }) + .orElse(entities.collectFirst { case p: DotPower => DotPowerCollision(p) }) + .orElse(entities.collectFirst { case d: DotBasic => DotBasicCollision(d) }) + .orElse( + entities.collectFirst { + case t: Tunnel if t.canTeleport(direction) => TunnelCollision(t) + } + ) + .getOrElse(NoCollision) + ``` +- uso di `Option` per indicare se c'è stato un aggiornamento delle entità o meno: + ```scala + def applyCollisionEffect(...): Option[(GameMap, SpacManWithLife)] + ``` +- pattern matching per gestire le collisioni avvenute: + ```scala + collision match + case GhostCollision(ghost) => ... + case DotBasicCollision(dot) => ... + case DotPowerCollision(dot) => ... + case DotFruitCollision(fruit) => ... + case TunnelCollision(tunnel) => ... + case NoCollision => ... + ``` +## Ghost Behaviour + +Il `GhostBehaviour` si occupa di gestire il comportamento dei fantasmi. + +I metodi principali del GhostBehaviour sono: + +- `chooseDirection(context: GhostContext)`: è il metodo che ogni strategia concreta deve implementare. Il suo compito è decidere la direzione in cui il fantasma deve muoversi in un determinato istante di gioco, sulla base delle informazioni contenute nel GhostContext. + +- `selectDirection(context: GhostContext)`: il suo ruolo è scegliere, tra le direzioni valide, quella “migliore” secondo un criterio fornito dall’esterno. Il suo funzionamento è il seguente: + + - riceve l’insieme delle direzioni percorribili; + + - per ciascuna direzione simula la posizione successiva del fantasma; + + - calcola la distanza tra quella posizione e un obiettivo (target); + + - seleziona la direzione che ottimizza tale distanza, secondo un ordinamento specificato. + +Gli elementi di Scala più rilevanti utilizzati sono: + +- selectDirection accetta un Ordering come parametro implicito esplicito: + - consente di riutilizzare lo stesso algoritmo di selezione; + - il comportamento varia semplicemente cambiando l’ordinamento; + + ```scala + protected final def selectDirection(...)(ordering: Ordering[Int]): Direction = + validDirs + .map(dir => dir -> manhattanDistance(ghostPos.calculatePos(dir), targetPos)) + .minByOption(_._2)(ordering) + .map(_._1) + .getOrElse(currentDir) + ``` + +- uso di pattern matching per predirre la posizione del target: + ```scala + private def predictTarget(context: GhostContext): Position2D = + val offset = context.spacManDir match + case Direction.Right => (PredictionDistance, 0) + case Direction.Left => (-PredictionDistance, 0) + case Direction.Down => (0, PredictionDistance) + case Direction.Up => (0, -PredictionDistance) + Position2D(context.spacManPos.x + offset._1, context.spacManPos.y + offset._2) + ``` + +- companion object come registry per i comportamenti dei fantasmi, espandibile e centralizzato: + ``` + object GhostBehavior: + private val behaviorRegistry: Map[Int, GhostBehavior] = Map( + 1 -> ChaseBehavior, + 2 -> PredictiveBehavior, + 3 -> RandomBehavior, + 4 -> MixedBehavior + ) + + def forId(id: Int): GhostBehavior = + behaviorRegistry.getOrElse(id, ChaseBehavior) + ``` + +## Game Controller + +Il `GameController` si occupa di inizializzare e gestire il gioco per permettere l'interazione tra View e Model nel pattern architetturale MVC. Quando il giocatore clicca sul tasto Gioca, il `GameController` inizializza il gioco, fa partire l'`InputManager` e avvia il loop di gioco tramite un thread separato. + +Quando il `GameLoop` termina la sua esecuzione, il `GameController` notifica la `View` per aggiornare lo stato del gioco in base al risultato ottenuto. + +```scala +private def handleFinalState( + state: GameState, + gameManager: GameManager, + inputManager: InputManager, + view: GameView + ): Unit = + inputManager.stop() + state match + case GameState.Win => + Swing.onEDT: + view.displayWin(gameManager.getState.spacMan.score) + case GameState.GameOver => + Swing.onEDT: + view.displayGameOver(gameManager.getState.spacMan.score) + case _ => () +``` + +## Testing + +Per quanto riguarda i test è stato largamente utilizzato l'approccio TDD, in quanto ha permesso di velocizzare il debug del codice in seguito a modifiche o aggiunte di funzionalità. + +Inoltre la parte di model sviluppata è stata totalmente testata per raggiungere la più alta copertura del codice possibile, fatta eccezione dei rami di codice che riguardano la strategia di Programmazione Difensiva. + +--- + +0. [Introduzione](../../README.md) +1. [Processo di sviluppo](../1-processo.md) +2. [Requisiti](../2-requisiti.md) +3. [Design architetturale](../3-architettura.md) +4. [Design di dettaglio](../4-design-dettaglio.md) +5. [Implementazione *(prev)*](../5-implementazione.md) + - [Francesco Carlucci](./carlucci.md) + - [**Marco Raggini (next)**](./raggini.md) +6. [Testing](../6-testing.md) +7. [Retrospettiva](../7-retrospettiva.md) diff --git a/docs/implementazione/raggini.md b/docs/implementazione/raggini.md new file mode 100644 index 0000000..2df2527 --- /dev/null +++ b/docs/implementazione/raggini.md @@ -0,0 +1,236 @@ +# Implementazione - Raggini Marco + +## Panoramica dei contributi +Il mio contributo nel progetto si è focalizzato nelle seguenti aree: + +- **Creazione e implementazione della** `GameMap`: implementazione di un **DSL** per semplificare la creazione della mappa, implementazione della `GameMap` e creazione concreta della mappa di gioco. +- **Implementazione delle entità di gioco**: in particolare, il contributo principale riguarda la scelta architetturale di adottare i mixin per rendere estendibile lo `SpacMan`. Tra i contributi è presente anche la creazione del `Tunnel`, `Position2D`, `Direction`, `DotFruit` e il `WallBuilder` +- **Implementazione del** `GameLoop`: creazione di un loop per la gestione del flusso di gioco. +- **Sviluppo interfaccia utente**: implementazione della parte grafica del gioco. +- **Testing**: Scrittura dei test per i sistemi implementati, come `GameMapTest`, `TunnelTest`, `WallBuilderTest` e anche alcuni test presenti in altre classi. +## Mappa di gioco +### GameMap +La classe GameMap definisce le dimensioni dell’area di gioco, le posizioni di spawn e la disposizione degli oggetti presenti sulla griglia. + +L'interfaccia espone un insieme di operazioni fondamentali: +- **Manipolazione dello stato della mappa**: inserimento, sostituzione e rimozione di entità. +- **Accesso alla mappa**: recupero delle entità presenti in una determinata cella o il recupero di determinati tipi di entità (`Ghost`, `Wall`, `Dot`) +- **Verifica del movimento**: verifica se un’entità può muoversi in una direzione specifica. + +L’implementazione concreta GameMapImpl utilizza una struttura immutabile `Map[Position2D, Set[GameEntity]]` per rappresentare la griglia. Questo approccio è coerente con lo stile funzionale in cui ogni modifica restituisce una nuova versione della mappa. Questa scelta permette di tracciare facilmente gli stati e semplifica test e debugging. + +Tra gli aspetti rilevanti: +- **Largo uso di pattern matching**: quasi ogni metodo fa uso di pattern matching + +- **Uso di Either per la gestione degli errori**: l'utilizzo degli `Either` viene dall'esigenza di gestire gli errori che potrebbero esserci durante l'utilizzo dei metodi della mappa, se non gestiti, questi errori possono portare ad eccezioni. Usando gli `Either` viene forzata la gestione degli errori evitando malfunzionamenti dell'applicazione. +- **GameMapFactory**: per la creazione di mappe vuote creando già la griglia. + +```scala +// Esempio di uso di pattern matching con Either +override def remove(entity: GameEntity): Either[String, GameMap] = + grid.get(entity.position) match + case Some(entities) => entities.contains(entity) match + case true => + Right(copy(grid = grid.updated(entity.position, entities - entity))) + case false => Left("No entity found") + case None => Left("Invalid position" + entity.position) +``` + +### DSL +Il **DSL** proposto ha come obbiettivo quello di rendere il codice per la creazione della mappa molto più leggibile e al tempo stesso che facilitasse la creazione di entità da inserire nella mappa. + +Si è voluto dare particolare enfasi nel creare un DSL che a prima vista non sembrasse codice e nel renderlo simile ad un linguaggio naturale. +Le azioni possibili sono tre: + +- `place the`: permette di piazzare nella mappa oggetti già creati in precedenza, alcuni oggetti come ad esempio lo SpacMan molto spesso sono già creati in precedenza per via delle variabili che si possono settare al suo interno ed anche perché si vuole mantenere un riferimento ad esso. In questo caso non conviene creare semplicemente piazzare l'entità prendendo il riferimento all'oggetto. +```scala +// esempio di 'place the' +val dsl = MapDSL(board(5, 5)) +val pacMan = SpacManBasic(Position2D(3, 1), Direction.Right, 0) +import dsl.* +place the pacMan +``` +- `place multiple`: la funzione è simile alla precedente, ma questa volta è permesso il piazzamento di più entità contemporaneamente. Anche in questo caso si tratta di piazzare oggetti già creati in precedenza, può tornare utile, ad esempio, per piazzare dei fantasmi. +```scala +// esempio di 'place multiple' +val dsl = MapDSL(map) +val ghost1 = GhostBasic(Position2D(5, 1), Direction.Right, 1.0, 1) +val ghost2 = GhostBasic(Position2D(4, 1), Direction.Right, 1.0, 1) +val ghost3 = GhostBasic(Position2D(3, 1), Direction.Right, 1.0, 1) +val ghosts = Set(ghost1, ghost2, ghost3) + +import dsl.* + +place multiple ghosts +// oppure anche +place multiple Set(ghost1, ghost2, ghost3) +``` +- `place a genericEntity at position x`: questo metodo è stato pensato per tutte le entità in cui non c'è bisogno di una creazione precedente dell'oggetto e in cui il riferimento nella mappa è sufficiente. In questo modo attraverso questo metodo è possibile creare l'entità e piazzarla nello stesso momento. Per fare ciò è stato creato un enum che memorizza il tipo dell'entità, che poi verrà utilizzato dal DSL per la creazione dell'entità e il successivo piazzamento nella mappa. Per rendere il codice ancora più 'human-like', i casi dell'enum sono stati memorizzati in variabili. +```scala +val dsl = MapDSL(map) +import dsl.* +// crea un DotBasic alla posizione (1, 1) e lo piazza nella mappa +place a genericDot at position(1, 1) +``` +C'è un'ultima casistica disponibile in questo momento solo per i muri che serve a facilitare la creazione di più muri contemporaneamente ed è la seguente `place a genericWall from position x to position y`: +```scala +val dsl = MapDSL(map) +import dsl.* +// crea e piazza i muri: Wall(0, 0), Wall(0, 1), ..., Wall(0, 5) +place a genericWall from position(0, 0) to position(0, 5) +``` +### Creazione mappa senza DSL +```scala +val map = GameMapImpl(30, 30) +val ghost1 = GhostBasic(Position2D(3, 3), Direction.Down, 1.0, 1) +val ghost2 = GhostBasic(Position2D(25, 3), Direction.Up, 1.0, 2) +val ghost3 = GhostBasic(Position2D(3, 17), Direction.Left, 1.0, 3) +val ghost4 = GhostBasic(Position2D(25, 13), Direction.Right, 1.0, 4) +val spacman = SpacManWithLife(Position2D(1, 1), Direction.Left, 0) +val dot = DotBasic(Position2D(25, 18)) +val dp = DotPower(Position2D(2, 2)) +val fruit = DotFruit(Position2D(15, 12)) +val walls = WallBuilder.createWalls(Position2D(0, 0), Position2D(0, 10)) + +// meno leggibile, più lungo da scrivere +map = map.placeAll(Set(ghost1, ghost2, ghost3, ghost4)) +map = map.place(spacman) +map = map.place(dot) +map = map.place(dp) +map = map.place(fruit) +map = map.placeAll(walls) +``` +### Creazione mappa con DSL +```scala +val dsl = MapDSL(board(30, 30)) +val ghost1 = GhostBasic(Position2D(3, 3), Direction.Down, 1.0, 1) +val ghost2 = GhostBasic(Position2D(25, 3), Direction.Up, 1.0, 2) +val ghost3 = GhostBasic(Position2D(3, 17), Direction.Left, 1.0, 3) +val ghost4 = GhostBasic(Position2D(25, 13), Direction.Right, 1.0, 4) +val spacman = SpacManWithLife(Position2D(1, 1), Direction.Left, 0) +import dsl.* + +// più facile da scrivere e leggibile +place multiple Set(ghost1, ghost2, ghost3, ghost4) +place the spacman +place a genericDot at position(25, 18) +place a genericDotPower at position(2, 2) +place a genericDotFruit at position(15, 12) +place a genericWall from position(0, 0) to position(0, 10) +``` + +## Contributi nelle entità di gioco +I principali contributi riguardano le classi: `SpacMan`, `Tunnel`, `Position2D`, `Direction`, `DotFruit` e il `WallBuilder`. Non ci sono particolari note da fare, tranne per `SpacMan`, di cui parlerò nel sotto capitolo seguente e il `WallBuilder`. Questa factory permette la creazione di più muri partendo da una posizione iniziale ed una finale. Essa è in grado di riconoscere in autonomia quali muri devono essere creati e in che direzione. In particolare, le possibilità possono essere quattro: **Verticale**, **Orizzontale**, **Singolo** in caso di posizione iniziale e finale uguali, ed infine **Complesso**, che riguarda la creazione di quadrati o rettangoli quando la posizione iniziale differisce completamente con la posizione finale (es. posizione iniziale (0, 0) e posizione finale (5, 5)). + +```scala +object WallBuilder: + + def createWalls(startPos: Position2D, endPos: Position2D): Set[Wall] = + BuildDirection.understandBuildDirection(startPos, endPos) match + case BuildDirection.Horizontal => createHorizontalWall(startPos, endPos) + case BuildDirection.Vertical => createVerticalWall(startPos, endPos) + case BuildDirection.Complex => createComplexWall(startPos, endPos) + case BuildDirection.Single => Set(Wall(startPos)) +``` +Per ogni metodo di creazione di muri sono stati utilizzati i **for-comprehension**. +```scala +private def createComplexWall(startPos: Position2D, endPos: Position2D): Set[Wall] = + val (x1, x2) = orderPosition(startPos.x, endPos.x) + val (y1, y2) = orderPosition(startPos.y, endPos.y) + (for + x <- x1 to x2 + y <- y1 to y2 + yield Wall(Position2D(x, y))).toSet +``` +### SpacMan +Per l'implementazione dello SpacMan ho deciso di utilizzare un approccio basato su mixin, al fine di comporre l’entità di gioco combinando diversi comportamenti. Questa scelta consente di: +- separare le responsabilità (in questo caso movimento, vite e punteggio) +- favorisce il riuso di codice in quanto le interfacce sono già implementate +- classi e trait facilmente manutenibili ed estendibili + +In questo caso i trait implementati sono `Life` e `Score`, progettati in modo da rendere la classe che li utilizza **immutabile**, restituendo una nuova istanza dell’oggetto a ogni modifica di stato. +```scala +trait Life[E <: Life[E]]: + val lives: Int + def addLife(): E = + val newLives = lives + 1 + updateLife(newLives) + def removeLife(): E = + require(lives > 0) + val newLives = lives - 1 + updateLife(newLives) + protected def updateLife(newLives: Int): E + +trait Score[E <: Score[E]]: + val score: Int + def addScore(points: Int): E = + if points >= 0 then updateScore(score + points) + else updateScore(score) + protected def updateScore(points: Int): E + +case class SpacManWithLife( + position: Position2D, + direction: Direction, + score: Int, + val lives: Int = DEFAULT_LIVES +) extends MovableEntity with Life[SpacManWithLife] with Score[SpacManWithLife]: +``` +La scelta di utilizzare l’**F-bounded polymorphism** è stata adottata per garantire la type safety. +In assenza di questo vincolo, un tipo generico `E` non avrebbe garantito che i metodi restituissero il tipo concreto dell’oggetto, rendendo necessari cast espliciti e introducendo il rischio di errori a runtime. +Il vincolo `E <: Life[E]` (e analogamente per Score) assicura invece che le operazioni restituiscano sempre un’istanza del tipo corretto. + +## GameLoop +Nel progetto è stato implementato un game loop, ovvero il ciclo principale che gestisce l’intera esecuzione del gioco. Il suo scopo è mantenere un flusso continuo e controllato di aggiornamento dello stato di gioco e di rendering, permettendo così un comportamento fluido e costante. + +Il game loop nasce dall’esigenza di separare in modo chiaro due operazioni fondamentali: +- Aggiornare la logica del gioco (movimenti, controlli, collisioni, fine della partita). +- Aggiornare l'interfaccia grafica in modo coerente e stabile. + +Senza un loop dedicato, il gioco dipenderebbe direttamente dalla velocità di esecuzione della macchina che potrebbe essere diversa a seconda di essa, causando comportamenti imprevedibili, animazioni irregolari o rallentamenti. Inoltre questa classe consente di far comunicare l'`InputManager` con il `GameManager`, consentendo l'utilizzo della tastiera per il movimento dello SpacMan. + +Il `GameLoop` è stato implementato attraverso una funzione ricorsiva che ritorna lo stato del gioco. Quando la partita termina, che sia vittoria o sconfitta, il loop finisce la sua esecuzione e ritorna l'esito della partita. Sono state inserite delle costanti che rappresentano il periodo temporale che passa tra un'azione di movimento e l'altra, in questo modo il movimento risulta costante. + +```scala +// core della funzione loop +state match + case GameState.Running | GameState.Chase => + if isTimeToMove(now, lastGhostMove, currentGhostDelay) then + gameManager.moveGhosts() + leatestGhostMove = now + if isTimeToMove(now, lastPacmanMove, spacmanDelay) then + val directionToMove = calculateSpacManDirection() + gameManager.moveSpacMan(directionToMove) + leatestSpacManMove = now + updateView() + Thread.sleep(50) + val newState = checkGameState(gameManager) + loop(newState, leatestGhostMove, leatestSpacManMove, now) + case finalState: GameState => finalState +``` + +## Interfaccia utente +Per quanto riguarda l'interfaccia utente, ho lavorato su tutte le classi presenti che appartengono alla view. La libreria utilizzata per la rappresentazione grafica è **Scala Swing**, il noto framework di Java adattato a Scala, in modo da scrivere codice meno verboso e più funzionale. + +Come già specificato nel [design di dettaglio](4-design-dettaglio.md), `GameView` è il componente principale della GUI. Esso utilizza le classi `GameMapPanel` e `InfoPanel` per rappresentare l'interfaccia di gioco durante una partita e `ButtonFactory` e `LabelFactory` per costruire le schermate di vittoria/sconfitta. + +Viene anche utilizzato lo `SpriteLoader` per tenere in cache le immagini delle entità di gioco o caricarle in caso non siano ancora in cache. +## Testing +Come già accennato nella sezione precedente, i test sono stati fondamentali per garantire la qualità del codice e per facilitare il processo di sviluppo, specialmente durante la rifattorizzazione, e sono stati scritti con particolare attenzione alla leggibilità. + +È stato posto un impegno significativo nel raggiungere una percentuale di **code coverage** di almeno il **90%** per le classi appartenenti al model. Questo approccio ha permesso di individuare la maggior parte degli errori nelle prime fasi dell’implementazione e di garantire una maggiore stabilità del sistema anche in presenza di piccoli cambiamenti al codice. + +Il **DSL** è stato ampiamente utilizzato per il testing della mappa e, sebbene non riduca in modo significativo il numero di righe di codice, ha consentito una maggiore velocità di scrittura dei test e una migliore leggibilità degli stessi. + +--- + +0. [Introduzione](../../README.md) +1. [Processo di sviluppo](../1-processo.md) +2. [Requisiti](../2-requisiti.md) +3. [Design architetturale](../3-architettura.md) +4. [Design di dettaglio](../4-design-dettaglio.md) +5. [Implementazione](../5-implementazione.md) + - [Francesco Carlucci *(prev*)](./carlucci.md) + - [Marco Raggini](./raggini.md) +6. [**Testing (next)**](../6-testing.md) +7. [Retrospettiva](../7-retrospettiva.md) + diff --git a/docs/model.html b/docs/model.html new file mode 100644 index 0000000..e2a5f64 --- /dev/null +++ b/docs/model.html @@ -0,0 +1,1271 @@ + + + + Scoverage Code Coverage + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Class + + Source file + + Lines + + Methods + + Statements + + Invoked + + Coverage + + + Branches + + Invoked + + Coverage + +
+ + BuildDirection + + + WallBuilder.scala + + 15 + + 1 + + 7 + + 7 + +
+ +
+
+ 100.00 + % + + 6 + + 6 + +
+ +
+
+ 100.00 + % +
+ + ChaseBehavior + + + GhostBehavior.scala + + 53 + + 1 + + 4 + + 4 + +
+ +
+
+ 100.00 + % + + 0 + + 0 + +
+ +
+
+ 100.00 + % +
+ + CollisionsManager + + + CollisionsManager.scala + + 119 + + 6 + + 74 + + 72 + +
+ +
+
+ 97.30 + % + + 21 + + 19 + +
+ +
+
+ 90.48 + % +
+ + Direction + + + Direction.scala + + 10 + + 1 + + 2 + + 2 + +
+ +
+
+ 100.00 + % + + 0 + + 0 + +
+ +
+
+ 100.00 + % +
+ + GameEntityBuilder + + + MapDSL.scala + + 79 + + 6 + + 19 + + 17 + +
+ +
+
+ 89.47 + % + + 6 + + 5 + +
+ +
+
+ 83.33 + % +
+ + GameState + + + GameManager.scala + + 20 + + 5 + + 10 + + 7 + +
+ +
+
+ 70.00 + % + + 2 + + 1 + +
+ +
+
+ 50.00 + % +
+ + GhostBasic + + + GhostBasic.scala + + 23 + + 3 + + 5 + + 5 + +
+ +
+
+ 100.00 + % + + 0 + + 0 + +
+ +
+
+ 100.00 + % +
+ + GhostBehavior + + + GhostBehavior.scala + + 44 + + 5 + + 23 + + 23 + +
+ +
+
+ 100.00 + % + + 0 + + 0 + +
+ +
+
+ 100.00 + % +
+ + GhostContext + + + GhostBehavior.scala + + 15 + + 3 + + 6 + + 6 + +
+ +
+
+ 100.00 + % + + 0 + + 0 + +
+ +
+
+ 100.00 + % +
+ + Life + + + SpacManBasic.scala + + 14 + + 2 + + 5 + + 5 + +
+ +
+
+ 100.00 + % + + 0 + + 0 + +
+ +
+
+ 100.00 + % +
+ + MixedBehavior + + + GhostBehavior.scala + + 97 + + 1 + + 9 + + 9 + +
+ +
+
+ 100.00 + % + + 2 + + 2 + +
+ +
+
+ 100.00 + % +
+ + MovableEntity + + + GameEntity.scala + + 20 + + 2 + + 7 + + 7 + +
+ +
+
+ 100.00 + % + + 0 + + 0 + +
+ +
+
+ 100.00 + % +
+ + NoWallFoundBuilder + + + MapDSL.scala + + 96 + + 1 + + 1 + + 1 + +
+ +
+
+ 100.00 + % + + 0 + + 0 + +
+ +
+
+ 100.00 + % +
+ + Position2D + + + Position2D.scala + + 10 + + 2 + + 7 + + 7 + +
+ +
+
+ 100.00 + % + + 4 + + 4 + +
+ +
+
+ 100.00 + % +
+ + PredictiveBehavior + + + GhostBehavior.scala + + 72 + + 2 + + 12 + + 11 + +
+ +
+
+ 91.67 + % + + 4 + + 3 + +
+ +
+
+ 75.00 + % +
+ + RandomBehavior + + + GhostBehavior.scala + + 83 + + 1 + + 11 + + 11 + +
+ +
+
+ 100.00 + % + + 4 + + 4 + +
+ +
+
+ 100.00 + % +
+ + Score + + + SpacManBasic.scala + + 21 + + 1 + + 5 + + 3 + +
+ +
+
+ 60.00 + % + + 2 + + 1 + +
+ +
+
+ 50.00 + % +
+ + SimpleGameManager + + + GameManager.scala + + 160 + + 13 + + 61 + + 53 + +
+ +
+
+ 86.89 + % + + 10 + + 8 + +
+ +
+
+ 80.00 + % +
+ + SpacManBasic + + + SpacManBasic.scala + + 37 + + 3 + + 7 + + 6 + +
+ +
+
+ 85.71 + % + + 4 + + 3 + +
+ +
+
+ 75.00 + % +
+ + SpacManWithLife + + + SpacManBasic.scala + + 56 + + 5 + + 7 + + 7 + +
+ +
+
+ 100.00 + % + + 2 + + 2 + +
+ +
+
+ 100.00 + % +
+ + Tunnel + + + GameEntity.scala + + 37 + + 1 + + 1 + + 1 + +
+ +
+
+ 100.00 + % + + 0 + + 0 + +
+ +
+
+ 100.00 + % +
+ + WallBuilder + + + WallBuilder.scala + + 54 + + 6 + + 40 + + 40 + +
+ +
+
+ 100.00 + % + + 10 + + 10 + +
+ +
+
+ 100.00 + % +
+ + WallEntityBuilder + + + MapDSL.scala + + 87 + + 2 + + 5 + + 5 + +
+ +
+
+ 100.00 + % + + 0 + + 0 + +
+ +
+
+ 100.00 + % +
+ + board + + + MapDSL.scala + + 24 + + 3 + + 4 + + 4 + +
+ +
+
+ 100.00 + % + + 0 + + 0 + +
+ +
+
+ 100.00 + % +
+ + place + + + MapDSL.scala + + 50 + + 4 + + 10 + + 10 + +
+ +
+
+ 100.00 + % + + 0 + + 0 + +
+ +
+
+ 100.00 + % +
+ + position + + + MapDSL.scala + + 100 + + 1 + + 1 + + 1 + +
+ +
+
+ 100.00 + % + + 0 + + 0 + +
+ +
+
+ 100.00 + % +
+ + \ No newline at end of file diff --git a/docs/process/sprint3.md b/docs/process/sprint3.md index cec503b..383d998 100644 --- a/docs/process/sprint3.md +++ b/docs/process/sprint3.md @@ -1,4 +1,4 @@ -# Sprint 2 +# Sprint 3 ## Obiettivo diff --git a/docs/process/sprint4.md b/docs/process/sprint4.md new file mode 100644 index 0000000..188a2ca --- /dev/null +++ b/docs/process/sprint4.md @@ -0,0 +1,347 @@ +# Sprint 4 + +## Obiettivo + +L'obiettivo di questo quarto Sprint è di perfezionare il codice per renderlo il più facilmente estendibile e comprensibile. Inoltre si cercherà di aggiungere delle feature opzionali a seconda del tempo rimanente e delle priorità dello stakeholder. + +## Deadline + +La scadenza dello sprint è il 14/12/2025. + +## Backlog + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PrioritàNomeDescrizioneSprint TaskVolontarioStima inizialeStima Sprint 1Stima Sprint 2Stima Sprint 3Stima Sprint 4
1Organizzazione progettoCreare e configurare il repository GitHub, impostare il progetto ScalaGit Flow SetupFrancesco10000
Studio dell'architetturaMarco e Francesco40000
Semantic Release e PR rulesMarco20000
Setup documentazioneFrancesco20000
Setup progetto ScalaMarco10000
2Movimento nella mappaCome utente, vorrei muovere Pac-Man all'interno della mappaCreazione Pac-ManFrancesco40000
Implementazione posizione e direzioneMarco10000
Creazione mappaMarco30000
Input utenteFrancesco63300
DSL mappaMarco83000
3FantasmiIntroduzione dei fantasmi nella mappa con movimenti sempliciImplementazione fantasmiMarco11000
Creazione logica di movimento sempliceMarco33000
4Muri e tunnelCome utente, mi aspetto di non poter oltrepassare un muro e di poter utilizzare i tunnel presenti nella mappaCreazione muri e builderMarco44000
Gestione delle collisioniFrancesco74300
Creazione dei tunnelMarco33000
5PuntiIntroduzione dei punti e dello score che permettono di concludere il giocoCreazione punti e assegnazione scoreFrancesco11000
6Gestione generale del giocoCreazione di un manager che gestisce tutti gli elementi implementati in precedenzaImplementazione del game managerFrancesco1010000
Creazione di un game loopMarco33000
7DocumentazioneCreare una documentazione chiara ed esaustiva DocumentazioneFrancesco44320
DocumentazioneMarco44330
8ControllerImplementare il concetto di stato di gioco per la visualizzazione della vincita, perdita ecc..Implementazione stato di giocoFrancesco66600
9GraficaCome utente, vorrei poter avere una visualizzazione grafica del giocoVisualizzazione della mappaMarco88800
Visualizzazione pagina inizialeMarco33300
Schermata vincita/perditaMarco22200
10RefactorCome sviluppatore, vorrei avere un codice pulito e leggibileRefactor codiceFrancesco00530
Refactor codiceMarco00080
11OpzionaliCome utente, vorrei avere una versione più avanzata del giocoVite SpacmanMarco44440
Pallino mangia fantasmiFrancesco55550
Fantasmi: logiche di movimento avanzateFrancesco55550
Sistema a livelli con più mappeMarco55555
+ +## Sprint Review +Lo stakeholder e gli sviluppatori sono soddisfatti del prodotto finale. È stata presentata la versione completa e funzionante del gioco con alcune funzionalità aggiuntive che hanno migliorato la qualità del gioco. + +## Sprint Retrospective + +Il refactor svolto da tutto il team è stato molto importante e necessario. L'aggiunta delle feature opzionali è stata agevole grazie alla predisposizione del codice sviluppato in precedenza. + +--- +0. [Introduzione](../../README.md) +1. [Sprint 1](./sprint1.md) +2. [Sprint 2](./sprint2.md) +3. [Sprint 3](./sprint3.md) +4. [Sprint 4](./sprint4.md) diff --git a/project/plugin.sbt b/project/plugin.sbt index bfae02e..d3093b5 100644 --- a/project/plugin.sbt +++ b/project/plugin.sbt @@ -1,3 +1,4 @@ addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2") addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.8") -addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.14.5") \ No newline at end of file +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.14.5") +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.1.5") \ No newline at end of file diff --git a/src/main/scala/controller/InputManager.scala b/src/main/scala/controller/InputManager.scala index 101321f..40a3b80 100644 --- a/src/main/scala/controller/InputManager.scala +++ b/src/main/scala/controller/InputManager.scala @@ -20,6 +20,7 @@ class SwingInputManager(component: JComponent) extends InputManager: case KeyEvent.VK_S | KeyEvent.VK_DOWN => pendingMove = Some(Direction.Down) case KeyEvent.VK_D | KeyEvent.VK_RIGHT => pendingMove = Some(Direction.Right) case _ => + def processInput(): Option[Direction] = pendingMove match case Some(dir) => diff --git a/src/main/scala/model/CollisionsManager.scala b/src/main/scala/model/CollisionsManager.scala index a2e278b..9fc9f39 100644 --- a/src/main/scala/model/CollisionsManager.scala +++ b/src/main/scala/model/CollisionsManager.scala @@ -4,6 +4,9 @@ import model.* import model.map.GameMap import CollisionType.* +/** + * Enum representing the type of collision that occurred. + */ enum CollisionType: case GhostCollision(ghost: GhostBasic) case DotBasicCollision(dot: DotBasic) @@ -14,8 +17,14 @@ enum CollisionType: val CHASE_TIME_MS: Long = 10000L +/** + * Handles collisions between entities. + */ object CollisionsManager: + /** + * Detects the type of collision that occurred. + */ def detectCollision( entities: Set[GameEntity], direction: Direction @@ -31,6 +40,9 @@ object CollisionsManager: ) .getOrElse(NoCollision) + /** + * Applies the effect of a collision. + */ def applyCollisionEffect( collision: CollisionType, direction: Direction, @@ -75,6 +87,9 @@ object CollisionsManager: case NoCollision => Some((gameMap, spacMan)) + /** + * Handles a ghost collision with SpacMan. + */ private def handleGhostCollision( ghost: GhostBasic, gameMap: GameMap, @@ -101,14 +116,19 @@ object CollisionsManager: gameMap.replaceEntityTo(spacMan, respawned).getOrElse(gameMap) Some((updatedMap, respawned)) + /** + * Returns a random spawn position for a ghost after being eated by SpacMan. + */ private def getRandomSpawnPosition(gameMap: GameMap): Position2D = val spawnPoints = gameMap.ghostSpawnPoints.toSeq - val free = spawnPoints.filter { pos => + val free = spawnPoints.filter: pos => gameMap.entityAt(pos).toOption.forall(!_.exists(_.isInstanceOf[GhostBasic])) - } val available = if free.nonEmpty then free else spawnPoints available(scala.util.Random.nextInt(available.size)) + /** + * Checks if a ghost collides with SpacMan. + */ def checkGhostCollision( ghost: GhostBasic, spacMan: SpacManWithLife, diff --git a/src/main/scala/model/GameManager.scala b/src/main/scala/model/GameManager.scala index a6432de..7077402 100644 --- a/src/main/scala/model/GameManager.scala +++ b/src/main/scala/model/GameManager.scala @@ -24,10 +24,15 @@ trait GameManager: def isWin(): Boolean def isGameOver(): Boolean def isChaseMode: Boolean - def moveSpacMan(dir: Direction): GameState - def moveGhosts(): GameState - def updateChaseTime(deltaTime: Long): GameState - + def moveSpacMan(dir: Direction): Unit + def moveGhosts(): Unit + def updateChaseTime(deltaTime: Long): Unit + +/** + * Game manager implementation. + * + * @param state The current game state. + */ class SimpleGameManager(private var state: GameState) extends GameManager: def getState: GameState = state @@ -38,12 +43,20 @@ class SimpleGameManager(private var state: GameState) extends GameManager: override def isChaseMode: Boolean = state.isChaseMode - override def updateChaseTime(deltaTime: Long): GameState = + /** + * Updates the chase time remaining. + */ + override def updateChaseTime(deltaTime: Long): Unit = state = state.updateChaseTime(deltaTime) - state - override def moveGhosts(): GameState = + /** + * Moves all ghosts in the game. + */ + override def moveGhosts(): Unit = + /** + * Attempts to move a ghost to the next position. + */ def attemptMove( ghost: GhostBasic, currentMap: GameMap, @@ -54,22 +67,24 @@ class SimpleGameManager(private var state: GameState) extends GameManager: currentSpacMan.direction, currentMap ) - - Option.when(currentMap.canMove(ghost, nextDirection)) { + Option.when(currentMap.canMove(ghost, nextDirection)): ghost.move(nextDirection).asInstanceOf[GhostBasic] - } + /** + * Applies a move to a ghost. + */ def applyMove(ghost: GhostBasic, movedGhost: GhostBasic, currentMap: GameMap): GameMap = currentMap.replaceEntityTo(ghost, movedGhost) match - case Right(updatedMap) => - updatedMap + case Right(updatedMap) => updatedMap case Left(error) => println(s"Warning: Could not move ghost ${ghost.id} - $error") currentMap - // Move all ghosts + /** + * Moves all ghosts in the game. + */ val (updatedMap, movedGhosts) = - state.gameMap.getGhosts.foldLeft((state.gameMap, List.empty[GhostBasic])) { + state.gameMap.getGhosts.foldLeft((state.gameMap, List.empty[GhostBasic])): case ((currentMap, ghosts), ghost) => attemptMove(ghost, currentMap, state.spacMan) match case Some(movedGhost) => @@ -77,45 +92,45 @@ class SimpleGameManager(private var state: GameState) extends GameManager: (newMap, movedGhost :: ghosts) case None => (currentMap, ghost :: ghosts) - } - // Check collisions for all moved ghosts + /** + * Checks collisions for all moved ghosts. + */ val finalState = - movedGhosts.foldLeft(state.copy(gameMap = updatedMap)) { (currentState, ghost) => + movedGhosts.foldLeft(state.copy(gameMap = updatedMap)): (currentState, ghost) => CollisionsManager .checkGhostCollision( ghost, currentState.spacMan, currentState.gameMap, currentState.isChaseMode, - () => () + () => {} ) - .map { (newMap, newSpacMan) => + .map: (newMap, newSpacMan) => val gameOver = newSpacMan.lives <= 0 currentState.copy( gameMap = newMap, spacMan = newSpacMan, gameOver = gameOver ) - } .getOrElse(currentState) - } state = finalState - state - override def moveSpacMan(direction: Direction): GameState = + /** + * Moves the SpacMan in the given direction. + */ + override def moveSpacMan(direction: Direction): Unit = if !state.gameMap.canMove(state.spacMan, direction) then - return state + return val movedSpacMan = state.spacMan.move(direction).asInstanceOf[SpacManWithLife] val updatedMapAfterMove = state.gameMap.replaceEntityTo(state.spacMan, movedSpacMan) match - case Right(updatedMap) => - updatedMap + case Right(updatedMap) => updatedMap case Left(error) => println(s"Warning: Could not move SpacMan - $error") - return state + return val entities = updatedMapAfterMove .entityAt(movedSpacMan.position) @@ -137,19 +152,18 @@ class SimpleGameManager(private var state: GameState) extends GameManager: delta => chaseTimeDelta += delta, () => gameOverFlag = true ) - .map { (finalMap, finalSpacMan) => + .map: (finalMap, finalSpacMan) => state.copy( gameMap = finalMap, spacMan = finalSpacMan, chaseTimeRemaining = state.chaseTimeRemaining + chaseTimeDelta, gameOver = gameOverFlag || finalSpacMan.lives <= 0 ) - } .getOrElse(state.copy(gameMap = updatedMapAfterMove, spacMan = movedSpacMan)) state = finalState - state +/** Companion object for SimpleGameManager. */ object SimpleGameManager: def apply( spacMan: SpacManWithLife, diff --git a/src/main/scala/model/GhostBasic.scala b/src/main/scala/model/GhostBasic.scala index 0144389..9494e0d 100644 --- a/src/main/scala/model/GhostBasic.scala +++ b/src/main/scala/model/GhostBasic.scala @@ -14,6 +14,14 @@ case class GhostBasic( override def withPosAndDir(newPosition: Position2D, newDirection: Direction): GhostBasic = copy(position = newPosition, direction = newDirection) + /** + * Returns the next direction for the ghost. + * + * @param spacManPos the position of the SpacMan + * @param spacManDir the direction of the SpacMan + * @param gameMap the game map + * @return the next direction for the ghost + */ def nextMove( spacManPos: Position2D, spacManDir: Direction, diff --git a/src/main/scala/model/GhostBehavior.scala b/src/main/scala/model/GhostBehavior.scala index 54bdb9d..3ef322b 100644 --- a/src/main/scala/model/GhostBehavior.scala +++ b/src/main/scala/model/GhostBehavior.scala @@ -2,24 +2,50 @@ package model import model.map.GameMap +/** + * Context for ghost behavior. + * + * @param ghost the ghost + * @param spacManPos the position of the SpacMan + * @param spacManDir the direction of the SpacMan + * @param gameMap the game map + */ case class GhostContext( ghost: GhostBasic, spacManPos: Position2D, spacManDir: Direction, gameMap: GameMap ): + /** + * Returns true if the ghost can move in the given direction. + */ def canMove(direction: Direction): Boolean = gameMap.canMove(ghost, direction) + /** + * Returns the valid directions for the ghost to move. + */ def validDirections: Seq[Direction] = Direction.values.filter(canMove).toSeq +/** + * Define the behavior of a ghost. + */ sealed trait GhostBehavior: + /** + * Returns the direction for the ghost to move. + */ def chooseDirection(context: GhostContext): Direction + /** + * Returns the Manhattan distance between two positions. + */ protected final def manhattanDistance(pos1: Position2D, pos2: Position2D): Int = (pos1.x - pos2.x).abs + (pos1.y - pos2.y).abs + /** + * Returns the direction with the minimum or maximum Manhattan distance to the target position. + */ protected final def selectDirection( validDirs: Seq[Direction], ghostPos: Position2D, @@ -32,6 +58,9 @@ sealed trait GhostBehavior: .map(_._1) .getOrElse(currentDir) +/** + * Registry of ghost behaviors. + */ object GhostBehavior: private val behaviorRegistry: Map[Int, GhostBehavior] = Map( 1 -> ChaseBehavior, @@ -43,6 +72,9 @@ object GhostBehavior: def forId(id: Int): GhostBehavior = behaviorRegistry.getOrElse(id, ChaseBehavior) +/** + * Chase SpacMan. + */ case object ChaseBehavior extends GhostBehavior: override def chooseDirection(context: GhostContext): Direction = selectDirection( @@ -52,6 +84,9 @@ case object ChaseBehavior extends GhostBehavior: context.ghost.direction )(Ordering.Int) +/** + * Predict SpacMan's future position and chase it. + */ case object PredictiveBehavior extends GhostBehavior: private val PredictionDistance = 3 @@ -71,6 +106,9 @@ case object PredictiveBehavior extends GhostBehavior: context.ghost.direction )(Ordering.Int) +/** + * Move in a random direction when blocked. + */ case object RandomBehavior extends GhostBehavior: override def chooseDirection(context: GhostContext): Direction = val canContinue = context.canMove(context.ghost.direction) @@ -82,6 +120,9 @@ case object RandomBehavior extends GhostBehavior: if validDirs.isEmpty then context.ghost.direction else validDirs(scala.util.Random.nextInt(validDirs.size)) +/** + * Mix between chase and predictive behavior based on distance. + */ case object MixedBehavior extends GhostBehavior: private val DistanceThreshold = 3 diff --git a/src/main/scala/model/SpacManBasic.scala b/src/main/scala/model/SpacMan.scala similarity index 69% rename from src/main/scala/model/SpacManBasic.scala rename to src/main/scala/model/SpacMan.scala index ef80a5e..25719a4 100644 --- a/src/main/scala/model/SpacManBasic.scala +++ b/src/main/scala/model/SpacMan.scala @@ -5,20 +5,46 @@ private val DEFAULT_LIVES = 3 /** E <: Life[E] sarebbe un type check, altrimenti E potrebbe non aver implementato Life e in quel caso darebbe errori Runtime */ trait Life[E <: Life[E]]: val lives: Int + + /** Adds a life + * @return a new SpacManWithLife with one more life + */ def addLife(): E = val newLives = lives + 1 updateLife(newLives) + + /** Removes a life + * @return a new SpacManWithLife with one less life + */ def removeLife(): E = require(lives > 0) val newLives = lives - 1 updateLife(newLives) + + /** Updates the number of lives + * @param newLives new number of lives + * @return a new SpacManWithLife with updated lives + */ protected def updateLife(newLives: Int): E +/** Score trait + * @tparam E the type of the entity implementing the trait + */ trait Score[E <: Score[E]]: val score: Int + + /** Adds points to the score + * @param points points to add, must be non-negative + * @return a new entity with updated score + */ def addScore(points: Int): E = if points >= 0 then updateScore(score + points) else updateScore(score) + + /** Updates the score + * @param points new score + * @return a new SpacManWithLife with updated score + */ protected def updateScore(points: Int): E case class SpacManBasic(val position: Position2D, val direction: Direction, val score: Int) @@ -36,6 +62,12 @@ case class SpacManBasic(val position: Position2D, val direction: Direction, val this.position == that.position case _ => false +/** SpacMan with lives and score + * @param position current position + * @param direction current direction + * @param score current score + * @param lives current lives, default is 3 + */ case class SpacManWithLife( position: Position2D, direction: Direction, diff --git a/src/main/scala/view/SpriteLoader.scala b/src/main/scala/view/SpriteLoader.scala index f00bebb..bdc44e5 100644 --- a/src/main/scala/view/SpriteLoader.scala +++ b/src/main/scala/view/SpriteLoader.scala @@ -3,9 +3,14 @@ package view import java.awt.image.BufferedImage import javax.imageio.ImageIO +/** Loads and caches sprite images for the game. */ object SpriteLoader: private val cache = scala.collection.mutable.Map[String, BufferedImage]() + /** Loads a sprite image by name. + * @param name The name of the sprite (without file extension). + * @return An Option containing the BufferedImage if found, None otherwise. + */ def load(name: String): Option[BufferedImage] = cache.get(name).orElse { try