diff --git a/.scalafix.conf b/.scalafix.conf
new file mode 100644
index 0000000..02c2a05
--- /dev/null
+++ b/.scalafix.conf
@@ -0,0 +1,6 @@
+rules = [
+ DisableSyntax,
+ LeakingImplicitClassVal,
+ ProcedureSyntax,
+ NoValInForComprehension
+]
\ No newline at end of file
diff --git a/.scalafmt.conf b/.scalafmt.conf
index 0a555ff..339872d 100644
--- a/.scalafmt.conf
+++ b/.scalafmt.conf
@@ -5,6 +5,7 @@ rewrite.rules = [SortImports]
newlines.source = keep
maxColumn = 100
align.multiline = false
+lineEndings = windows
indent {
main = 4
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 f3be5af..2b99d71 100644
--- a/build.sbt
+++ b/build.sbt
@@ -3,10 +3,20 @@ 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",
libraryDependencies += "org.jline" % "jline" % "3.25.1"
)
+
+scalacOptions ++= Seq("-Wunused:all", "-Werror")
+
+inThisBuild(
+ List(
+ scalaVersion := "3.3.5",
+ semanticdbEnabled := true,
+ semanticdbVersion := scalafixSemanticdb.revision
+ )
+)
\ No newline at end of file
diff --git a/docs/1-processo.md b/docs/1-processo.md
index 5b664ba..36dbc0f 100644
--- a/docs/1-processo.md
+++ b/docs/1-processo.md
@@ -40,17 +40,17 @@ Al termine di ogni sprint, vengono inoltre redatti una **Sprint Review** e una *
In una prima fase di analisi e progettazione, il gruppo ha svolto un meeting iniziale finalizzato alla definizione dell’architettura generale del progetto, durante il quale sono stati stabiliti anche la durata degli sprint e le modalità delle iterazioni successive.
-Il team ha deciso di adottare *sprint settimanali*, ad eccezione del primo, della durata di due settimane, necessario per le configurazioni iniziali del progetto.
+Il team ha deciso di adottare *sprint settimanali/bisettimanali* in base alle disponibilità del team.
-La scelta di organizzare sprint brevi è stata motivata dall’obiettivo di sviluppare funzionalità in tempi ridotti e ottenere feedback frequenti dallo stakeholder.
+La scelta di organizzare sprint di lunghezza moderata è stata motivata dall’obiettivo di sviluppare funzionalità in tempi ridotti e ottenere feedback frequenti dallo stakeholder, mantenendo un equilibrio tra la qualità del prodotto e la velocità di sviluppo.
## Modalità di revisione in itinere dei task
La revisione del lavoro è stata gestita tramite un meccanismo basato sulle *pull request*.
Ogni funzionalità è stata sviluppata in un branch dedicato e successivamente integrata nel branch *develop*, consentendo ai membri del team di lavorare in parallelo senza interferire con il flusso principale.
-Al termine di ciascuno sprint, le modifiche consolidate in *develop* sono state unite nel branch *main* mediante *pull request*.
-Per essere approvata, ogni *pull request* ha dovuto ricevere l’approvazione di tutti i membri del gruppo.
+Al termine di ciascuno sprint, le modifiche consolidate in *develop* sono state unite nel branch *main* mediante *pull request*.
+Per essere approvata, ogni *pull request* su develop ha dovuto ricevere l’approvazione di tutti i membri del gruppo.
Questo approccio ha garantito un aggiornamento costante tra i membri e un ulteriore livello di controllo e validazione del codice prima della sua integrazione definitiva.
## Scelta degli strumenti di test, build e Continuous Integration (CI)
@@ -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, genera le note di rilascio e pubblica il pacchetto, 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 3564c36..0d91b4c 100644
--- a/docs/2-requisiti.md
+++ b/docs/2-requisiti.md
@@ -66,12 +66,12 @@ Il dominio del progetto si basa sui seguenti elementi principali:
- Renderizzare graficamente il labirinto, Pac-Man, i fantasmi e gli elementi collezionabili
- Aggiornare la visualizzazione del punteggio
- - Mostrare il menu iniziale con le opzioni "Gioca" ed "Esci"
+ - Mostrare il menu iniziale con il pulsante "Gioca"
## Requisiti non funzionali
- **RNF1 - Prestazioni**
- - Il tempo di risposta ai comandi dell’utente deve essere inferiore a 200 ms.
+ - Il tempo di risposta ai comandi dell’utente deve essere sufficientemente rapido.
- **RNF2 - Usabilità**
- I controlli devono essere intuitivi.
@@ -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 62ccbd7..21e02db 100644
--- a/docs/3-architettura.md
+++ b/docs/3-architettura.md
@@ -15,23 +15,30 @@ Questa separazione facilita la manutenibilità e l'estensibilità del codice, co
## Struttura del progetto
+
+
+
+*Struttura del progetto evidenziando Controller (blu); Model (rosso); View (giallo)*
+
+
+---
+
Come da pattern MVC, la struttura del progetto è divisa in 3 moduli principali:
-- Model: contiene le parti logiche del gioco.
- - `GameMap`: rappresenta la mappa di gioco e si occupa di mantenerla aggiornata, fornendo anche metodi utili per posizionare/prendere/spostare gli elementi al suo interno.
+- **Model**: contiene le parti logiche del gioco.
- `GameManager`: si occupa di gestire i movimenti all'interno del gioco (sfruttando la `GameMap`) e l'eventuale vittoria/sconfitta.
+ - `GameMap`: rappresenta la mappa di gioco e si occupa di mantenerla aggiornata, fornendo anche metodi utili per posizionare/prendere/spostare gli elementi al suo interno.
- `GhostBasic`: rappresenta i fantasmi all'interno del gioco.
- `SpacMan`: rappresenta il Pacman all'interno del gioco.
- - `Position2D` e `Direction`: rappresentano la posizione e la direzione di un'entità di gioco.
-- Controller: coordina la comunicazione tra Model e View.
- - `GameController`: si occupa di gestire le varie schermate di gioco, comunicando con la view per la rappresentazione grafica.
- - `GameLoop`: si occupa di far comunicare il `GameManager` e la `GameView`, viene chiamata dal `GameController` quando inizia una nuova partita, permettendo una rappresentazione fluida del gioco.
- - `InputManager`: si occupa di raccogliere l'input del giocatore e trasformarlo in un movimento effettivo.
+- **Controller**: coordina la comunicazione tra Model e View.
+ - `GameController`: si occupa di gestire le varie schermate di gioco, comunicando con la view per la rappresentazione grafica e con il `GameManager` per la logica di gioco.
+ - `GameLoop`: si occupa di gestire l'esecuzione del gioco in tempo reale.
+ - `InputManager`: si occupa di prendere l'input del giocatore e mapparlo nella direzione corrispondente.
-- View: contiente i componenti per la rappresentazione grafica dell'applicazione:
+- **View**: contiente i componenti per la rappresentazione grafica dell'applicazione:
- `GameView`: è il componente principale della view, contiene la rappresentazione grafica del gioco.
- - `SpriteLoader`: reperisce le immagini per gli sprites
+ - `SpriteLoader`: reperisce le immagini per gli sprites.
---
@@ -41,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 5069e53..6962940 100644
--- a/docs/4-design-dettaglio.md
+++ b/docs/4-design-dettaglio.md
@@ -3,10 +3,98 @@
## Model
### Creazione mappa
+
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 piazzare le entità di dominio all'interno della mappa con un linguaggio naturale e più veloce. Inoltre offre la possibilità di creare e piazzare nella mappa file di muri.
+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.
+
+
### Creazione entità del gioco
-Qualsiasi entità di gioco viene rappresentata nell'applicazione come un'interfaccia di tipo `GameEntity`. Essa viene poi estesa dall'interfaccia `MovableEntity` per tutte le entità in grado di muoversi all'interno della mappa. Essendo i componenti di gioco molto semplici è stato deciso di definirli come case class, quindi dotate in automatico di metodi come **apply** per la creazione dell'oggetto. L'unica classe al quale è stato utilizzato l'approccio dei **Factory Methods** è il `Wall`, per la necessità di creare set di muri utilizzando un singolo metodo.
+Qualsiasi entità di gioco viene rappresentata nell'applicazione come un'interfaccia di tipo `GameEntity`. Essa viene poi estesa dall'interfaccia `MovableEntity` per tutte le entità in grado di muoversi all'interno della mappa. Essendo i componenti di gioco molto semplici è stato deciso di definirli come case class, quindi dotate in automatico di metodi (come **apply**) per la creazione dell'oggetto.
+
+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.
+
+
+
+### Game Manager
+
+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.
+
+
+
+### 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.
+
+
+
+## Controller
+
+
+
+### 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
+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
+
+## 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.
+
+
+
+---
+0. [Introduzione](../README.md)
+1. [Processo di sviluppo](1-processo.md)
+2. [Requisiti](2-requisiti.md)
+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](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/DSL.png b/docs/img/DSL.png
new file mode 100644
index 0000000..400090c
Binary files /dev/null and b/docs/img/DSL.png differ
diff --git a/docs/img/Design_architetturale.png b/docs/img/Design_architetturale.png
new file mode 100644
index 0000000..ed33e86
Binary files /dev/null and b/docs/img/Design_architetturale.png differ
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/GameEntity.png b/docs/img/GameEntity.png
new file mode 100644
index 0000000..1087012
Binary files /dev/null and b/docs/img/GameEntity.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
+
+
+
+
+
+
\ 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à
+
Nome
+
Descrizione
+
Sprint Task
+
Volontario
+
Stima iniziale
+
Stima Sprint 1
+
Stima Sprint 2
+
Stima Sprint 3
+
Stima Sprint 4
+
+
+
+
+
1
+
Organizzazione progetto
+
Creare e configurare il repository GitHub, impostare il progetto Scala
+
Git Flow Setup
+
Francesco
+
1
+
0
+
0
+
0
+
0
+
+
+
Studio dell'architettura
+
Marco e Francesco
+
4
+
0
+
0
+
0
+
0
+
+
+
Semantic Release e PR rules
+
Marco
+
2
+
0
+
0
+
0
+
0
+
+
+
Setup documentazione
+
Francesco
+
2
+
0
+
0
+
0
+
0
+
+
+
Setup progetto Scala
+
Marco
+
1
+
0
+
0
+
0
+
0
+
+
+
2
+
Movimento nella mappa
+
Come utente, vorrei muovere Pac-Man all'interno della mappa
+
Creazione Pac-Man
+
Francesco
+
4
+
0
+
0
+
0
+
0
+
+
+
Implementazione posizione e direzione
+
Marco
+
1
+
0
+
0
+
0
+
0
+
+
+
Creazione mappa
+
Marco
+
3
+
0
+
0
+
0
+
0
+
+
+
Input utente
+
Francesco
+
6
+
3
+
3
+
0
+
0
+
+
+
DSL mappa
+
Marco
+
8
+
3
+
0
+
0
+
0
+
+
+
3
+
Fantasmi
+
Introduzione dei fantasmi nella mappa con movimenti semplici
+
Implementazione fantasmi
+
Marco
+
1
+
1
+
0
+
0
+
0
+
+
+
Creazione logica di movimento semplice
+
Marco
+
3
+
3
+
0
+
0
+
0
+
+
+
4
+
Muri e tunnel
+
Come utente, mi aspetto di non poter oltrepassare un muro e di poter utilizzare i tunnel presenti nella mappa
+
Creazione muri e builder
+
Marco
+
4
+
4
+
0
+
0
+
0
+
+
+
Gestione delle collisioni
+
Francesco
+
7
+
4
+
3
+
0
+
0
+
+
+
Creazione dei tunnel
+
Marco
+
3
+
3
+
0
+
0
+
0
+
+
+
5
+
Punti
+
Introduzione dei punti e dello score che permettono di concludere il gioco
+
Creazione punti e assegnazione score
+
Francesco
+
1
+
1
+
0
+
0
+
0
+
+
+
6
+
Gestione generale del gioco
+
Creazione di un manager che gestisce tutti gli elementi implementati in precedenza
+
Implementazione del game manager
+
Francesco
+
10
+
10
+
0
+
0
+
0
+
+
+
Creazione di un game loop
+
Marco
+
3
+
3
+
0
+
0
+
0
+
+
+
7
+
Documentazione
+
Creare una documentazione chiara ed esaustiva
+
Documentazione
+
Francesco
+
4
+
4
+
3
+
2
+
0
+
+
+
Documentazione
+
Marco
+
4
+
4
+
3
+
3
+
0
+
+
+
8
+
Controller
+
Implementare il concetto di stato di gioco per la visualizzazione della vincita, perdita ecc..
+
Implementazione stato di gioco
+
Francesco
+
6
+
6
+
6
+
0
+
0
+
+
+
9
+
Grafica
+
Come utente, vorrei poter avere una visualizzazione grafica del gioco
+
Visualizzazione della mappa
+
Marco
+
8
+
8
+
8
+
0
+
0
+
+
+
Visualizzazione pagina iniziale
+
Marco
+
3
+
3
+
3
+
0
+
0
+
+
+
Schermata vincita/perdita
+
Marco
+
2
+
2
+
2
+
0
+
0
+
+
+
10
+
Refactor
+
Come sviluppatore, vorrei avere un codice pulito e leggibile
+
Refactor codice
+
Francesco
+
0
+
0
+
5
+
3
+
0
+
+
+
Refactor codice
+
Marco
+
0
+
0
+
0
+
8
+
0
+
+
+
11
+
Opzionali
+
Come utente, vorrei avere una versione più avanzata del gioco
+
Vite Spacman
+
Marco
+
4
+
4
+
4
+
4
+
0
+
+
+
Pallino mangia fantasmi
+
Francesco
+
5
+
5
+
5
+
5
+
0
+
+
+
Fantasmi: logiche di movimento avanzate
+
Francesco
+
5
+
5
+
5
+
5
+
0
+
+
+
Sistema a livelli con più mappe
+
Marco
+
5
+
5
+
5
+
5
+
5
+
+
+
+
+## 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/metals.sbt b/project/metals.sbt
index da486d8..9ed12d1 100644
--- a/project/metals.sbt
+++ b/project/metals.sbt
@@ -3,6 +3,6 @@
// This file enables sbt-bloop to create bloop config files.
-addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "2.0.16")
+addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "2.0.17")
// format: on
diff --git a/project/plugin.sbt b/project/plugin.sbt
index db22838..d3093b5 100644
--- a/project/plugin.sbt
+++ b/project/plugin.sbt
@@ -1,2 +1,4 @@
addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2")
-addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.8")
\ No newline at end of file
+addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.8")
+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/resources/sprites/dot_power.png b/src/main/resources/sprites/dot_power.png
new file mode 100644
index 0000000..0e436ba
Binary files /dev/null and b/src/main/resources/sprites/dot_power.png differ
diff --git a/src/main/resources/sprites/fruit.png b/src/main/resources/sprites/fruit.png
new file mode 100644
index 0000000..dc5de0c
Binary files /dev/null and b/src/main/resources/sprites/fruit.png differ
diff --git a/src/main/resources/sprites/life.png b/src/main/resources/sprites/life.png
new file mode 100644
index 0000000..083dee2
Binary files /dev/null and b/src/main/resources/sprites/life.png differ
diff --git a/src/main/scala/controller/GameController.scala b/src/main/scala/controller/GameController.scala
index cec4d69..7c6db65 100644
--- a/src/main/scala/controller/GameController.scala
+++ b/src/main/scala/controller/GameController.scala
@@ -1,30 +1,17 @@
package controller
-import model.MapDSL
-import model.board
-import model.genericWall
-import model.DotBasic
-import model.Position2D
-import model.GhostBasic
-import model.Direction
import model.map.GameMap
import model.GameManager
import model.SimpleGameManager
-import model.InputManager
-import model.SwingInputManager
-import model.SpacManBasic
-import view.GameView
-import view.SimpleSwingApp
-import scala.swing.Frame
+import model.SpacManWithLife
import view.{GameView, SimpleSwingApp}
import scala.swing.Swing
-import java.awt.event.{KeyAdapter, KeyEvent}
-import controller.GameLoop
-import model.Tunnel
+import model.map.MapLevel1
enum GameState:
case Menu
case Running
+ case Chase
case Win
case GameOver
@@ -34,9 +21,9 @@ object GameController:
homeView.visible = true
def startGame(): Unit =
- val (spacman, map) = createMap()
+ val (spacman, map) = MapLevel1.getMap()
val gameManager = SimpleGameManager(spacman, map)
- val view = SimpleSwingApp.create(gameManager.getGameMap)
+ val view = SimpleSwingApp.create(gameManager.getState.gameMap)
val inputManager = SwingInputManager(view.getGamePanel)
inputManager.start()
@@ -48,60 +35,6 @@ object GameController:
handleFinalState(finalState, gameManager, inputManager, view)
}).start()
- private def createMap(): (SpacManBasic, GameMap) =
- val dsl = MapDSL(board(11, 11))
- val ghost1 = GhostBasic(Position2D(7, 7), Direction.Down, 1.0, 1)
- val ghost2 = GhostBasic(Position2D(2, 2), Direction.Up, 1.0, 2)
- val spacman = SpacManBasic(Position2D(9, 9), Direction.Right, 0)
- val tunnel1 = Tunnel(Position2D(10, 5), Position2D(0, 5), Direction.Right)
- val tunnel2 = Tunnel(Position2D(0, 5), Position2D(10, 5), Direction.Left)
-
- import dsl.*
-
- place a genericWall() from position(4, 4) to position(6, 6)
- place a genericWall() from position(0, 0) to position(10, 0)
- place a genericWall() from position(0, 1) to position(0, 4)
- place a genericWall() from position(0, 6) to position(0, 10)
- place a genericWall() from position(10, 1) to position(10, 4)
- place a genericWall() from position(10, 6) to position(10, 10)
- place a genericWall() from position(0, 10) to position(9, 10)
-
- val wallPositions = getWallPositions()
-
- // Place dots in every cell that doesn't contain a wall
- for
- x <- 1 until 10
- y <- 1 until 10
- pos = Position2D(x, y)
- if !wallPositions.contains(pos) && pos != spacman.position
- do
- place a DotBasic(pos) at position(x, y)
-
- place a ghost1 at position(7, 7)
- place a ghost2 at position(2, 2)
- place a spacman at position(9, 9)
- place a tunnel1 at position(10, 5)
- place a tunnel2 at position(0, 5)
-
- (spacman, dsl.map)
-
- private def getWallPositions(): Set[Position2D] =
- val positions = Set.newBuilder[Position2D]
-
- for x <- 0 to 10 do positions += Position2D(x, 0)
- for y <- 1 to 4 do positions += Position2D(0, y)
- for y <- 6 to 10 do positions += Position2D(0, y)
- for y <- 1 to 4 do positions += Position2D(10, y)
- for y <- 6 to 10 do positions += Position2D(10, y)
- for x <- 0 to 9 do positions += Position2D(x, 10)
-
- for
- x <- 4 to 6
- y <- 4 to 6
- do positions += Position2D(x, y)
-
- positions.result()
-
private def handleFinalState(
state: GameState,
gameManager: GameManager,
@@ -112,8 +45,8 @@ object GameController:
state match
case GameState.Win =>
Swing.onEDT:
- view.displayWin(gameManager.getSpacMan.score)
+ view.displayWin(gameManager.getState.spacMan.score)
case GameState.GameOver =>
Swing.onEDT:
- view.displayGameOver(gameManager.getSpacMan.score)
+ view.displayGameOver(gameManager.getState.spacMan.score)
case _ => ()
diff --git a/src/main/scala/controller/GameLoop.scala b/src/main/scala/controller/GameLoop.scala
index 986fbb4..5fc20d9 100644
--- a/src/main/scala/controller/GameLoop.scala
+++ b/src/main/scala/controller/GameLoop.scala
@@ -1,47 +1,100 @@
package controller
import model.GameManager
-import model.InputManager
import view.GameView
import scala.swing.Swing
-import controller.GameState
+import model.Direction
+private final val DEFAULT_GHOST_DELAY_MS = 350
+private final val DEFAULT_GHOST_DELAY_CHASE_MS = 500
+private final val DEFAULT_SPACMAN_DELAY_MS = 250
+
+/** Represents the main game loop
+ *
+ * @param gameManager
+ * @param inputManager
+ * @param view
+ */
case class GameLoop(gameManager: GameManager, inputManager: InputManager, view: GameView):
- val ghostDelay = 2000
- val spacmanDelay = 500
+ /** Saved delays in case we want to customize them in the future */
+ val ghostDelayNormal = DEFAULT_GHOST_DELAY_MS
+ val ghostDelayChase = DEFAULT_GHOST_DELAY_CHASE_MS
+ val spacmanDelay = DEFAULT_SPACMAN_DELAY_MS
+ /** Main game loop method
+ * @param state Current game state
+ * @param lastGhostMove Timestamp of the last ghost move
+ * @param lastPacmanMove Timestamp of the last SpacMan move
+ * @param lastUpdateTime Timestamp of the last update
+ * @return Final game state when the game ends
+ */
def loop(
state: GameState = GameState.Running,
lastGhostMove: Long = System.currentTimeMillis(),
- lastPacmanMove: Long = System.currentTimeMillis()
+ lastPacmanMove: Long = System.currentTimeMillis(),
+ lastUpdateTime: Long = System.currentTimeMillis()
): GameState =
var leatestGhostMove = lastGhostMove
var leatestSpacManMove = lastPacmanMove
val now = System.currentTimeMillis()
+ val deltaTime = now - lastUpdateTime
+
+ gameManager.updateChaseTime(deltaTime)
+
+ val currentGhostDelay =
+ if gameManager.isChaseMode then ghostDelayChase else ghostDelayNormal
+
state match
- case GameState.Running =>
- if isTimeToMove(now, lastGhostMove, ghostDelay) then
+ case GameState.Running | GameState.Chase =>
+ if isTimeToMove(now, lastGhostMove, currentGhostDelay) then
gameManager.moveGhosts()
leatestGhostMove = now
if isTimeToMove(now, lastPacmanMove, spacmanDelay) then
- inputManager.processInput() match
- case Some(dir) => gameManager.moveSpacManAndCheck(dir)
- case None => // do nothing
+ val directionToMove = calculateSpacManDirection()
+ gameManager.moveSpacMan(directionToMove)
leatestSpacManMove = now
- Swing.onEDT:
- view.update(gameManager.getGameMap)
+ updateView()
Thread.sleep(50)
val newState = checkGameState(gameManager)
- loop(newState, leatestGhostMove, leatestSpacManMove)
- case finalState => finalState
+ loop(newState, leatestGhostMove, leatestSpacManMove, now)
+ case finalState: GameState => finalState
- def checkGameState(gameManager: GameManager): GameState =
- if gameManager.isWin() then GameState.Win
- else if gameManager.isGameOver() then GameState.GameOver
- else GameState.Running
+ /** Controls the current game state
+ * @param gameManager Current game manager
+ * @return Current game state
+ */
+ def checkGameState(gameManager: GameManager): GameState = gameManager match
+ case gm if gm.isWin() => GameState.Win
+ case gm if gm.isGameOver() => GameState.GameOver
+ case gm if gm.isChaseMode => GameState.Chase
+ case _ => GameState.Running
+ /** Checks if it's time for an entity to move based on its delay
+ * @param currTime Current timestamp
+ * @param lastMovableEntityMove Timestamp of the last move
+ * @param entityDelay Delay of the entity
+ * @return True if it's time to move, false otherwise
+ */
private def isTimeToMove(
currTime: Long,
lastMovableEntityMove: Long,
entityDelay: Long
): Boolean = currTime - lastMovableEntityMove >= entityDelay
+
+ /** Calculates the next direction for SpacMan based on user input
+ * @return Next direction for SpacMan
+ */
+ private def calculateSpacManDirection(): Direction =
+ inputManager.processInput() match
+ case Some(dir) => dir
+ case None => gameManager.getState.spacMan.direction
+
+ /** Updates the game view */
+ private def updateView(): Unit =
+ val game = gameManager.getState
+ Swing.onEDT:
+ view.update(
+ game.gameMap,
+ game.spacMan.lives,
+ game.spacMan.score
+ )
diff --git a/src/main/scala/model/InputManager.scala b/src/main/scala/controller/InputManager.scala
similarity index 97%
rename from src/main/scala/model/InputManager.scala
rename to src/main/scala/controller/InputManager.scala
index a052ad7..40a3b80 100644
--- a/src/main/scala/model/InputManager.scala
+++ b/src/main/scala/controller/InputManager.scala
@@ -1,7 +1,8 @@
-package model
+package controller
import java.awt.event.{KeyAdapter, KeyEvent}
import javax.swing.JComponent
+import model.Direction
trait InputManager:
def start(): Unit
@@ -19,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
new file mode 100644
index 0000000..9fc9f39
--- /dev/null
+++ b/src/main/scala/model/CollisionsManager.scala
@@ -0,0 +1,147 @@
+package model
+
+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)
+ case DotPowerCollision(dot: DotPower)
+ case DotFruitCollision(fruit: DotFruit)
+ case TunnelCollision(tunnel: Tunnel)
+ case NoCollision
+
+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
+ ): CollisionType =
+ 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)
+
+ /**
+ * Applies the effect of a collision.
+ */
+ def applyCollisionEffect(
+ collision: CollisionType,
+ direction: Direction,
+ gameMap: GameMap,
+ spacMan: SpacManWithLife,
+ isChaseMode: Boolean,
+ addChaseTime: Long => Unit,
+ onGameOver: () => Unit
+ ): Option[(GameMap, SpacManWithLife)] =
+ collision match
+ case GhostCollision(ghost) =>
+ handleGhostCollision(
+ ghost,
+ gameMap,
+ spacMan,
+ isChaseMode,
+ onGameOver
+ )
+
+ case DotBasicCollision(dot) =>
+ val updatedMap = gameMap.remove(dot).getOrElse(gameMap)
+ val updatedSp = spacMan.addScore(dot.score)
+ Some((updatedMap, updatedSp))
+
+ case DotPowerCollision(dot) =>
+ val updatedMap = gameMap.remove(dot).getOrElse(gameMap)
+ val updatedSp = spacMan.addScore(dot.score)
+ addChaseTime(CHASE_TIME_MS)
+ Some((updatedMap, updatedSp))
+
+ case DotFruitCollision(fruit) =>
+ val updatedMap = gameMap.remove(fruit).getOrElse(gameMap)
+ val updatedSp = spacMan.addScore(fruit.score).addLife()
+ Some((updatedMap, updatedSp))
+
+ case TunnelCollision(tunnel) =>
+ val teleported = spacMan.teleport(tunnel.toPos).asInstanceOf[SpacManWithLife]
+ gameMap.replaceEntityTo(spacMan, teleported) match
+ case Right(updated) => Some((updated, teleported))
+ case Left(_) => None
+
+ case NoCollision =>
+ Some((gameMap, spacMan))
+
+ /**
+ * Handles a ghost collision with SpacMan.
+ */
+ private def handleGhostCollision(
+ ghost: GhostBasic,
+ gameMap: GameMap,
+ spacMan: SpacManWithLife,
+ isChaseMode: Boolean,
+ onGameOver: () => Unit
+ ): Option[(GameMap, SpacManWithLife)] =
+ if isChaseMode then
+ val updatedSp = spacMan.addScore(ghost.score)
+ val pos = getRandomSpawnPosition(gameMap)
+ val teleported = ghost.teleport(pos).asInstanceOf[GhostBasic]
+ val updatedMap =
+ gameMap.replaceEntityTo(ghost, teleported).getOrElse(gameMap)
+ Some((updatedMap, updatedSp))
+ else
+ val damaged = spacMan.removeLife()
+ if damaged.lives <= 0 then
+ onGameOver()
+ Some((gameMap.remove(spacMan).getOrElse(gameMap), damaged))
+ else
+ val respawned =
+ damaged.teleport(gameMap.spawnPoint).asInstanceOf[SpacManWithLife]
+ val updatedMap =
+ 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 =>
+ 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,
+ gameMap: GameMap,
+ isChaseMode: Boolean,
+ onGameOver: () => Unit
+ ): Option[(GameMap, SpacManWithLife)] =
+ if ghost.position != spacMan.position then None
+ else
+ handleGhostCollision(
+ ghost,
+ gameMap,
+ spacMan,
+ isChaseMode,
+ onGameOver
+ )
diff --git a/src/main/scala/model/Direction.scala b/src/main/scala/model/Direction.scala
index 3ccde5f..e1b2652 100644
--- a/src/main/scala/model/Direction.scala
+++ b/src/main/scala/model/Direction.scala
@@ -2,9 +2,9 @@ package model
sealed trait Direction
object Direction:
- case object Up extends Direction
- case object Down extends Direction
- case object Left extends Direction
+ case object Up extends Direction
+ case object Down extends Direction
+ case object Left extends Direction
case object Right extends Direction
- val values: Set[Direction] = Set(Up, Down, Left, Right)
\ No newline at end of file
+ val values: Set[Direction] = Set(Up, Down, Left, Right)
diff --git a/src/main/scala/model/GameEntity.scala b/src/main/scala/model/GameEntity.scala
index ddcbc23..31c66a9 100644
--- a/src/main/scala/model/GameEntity.scala
+++ b/src/main/scala/model/GameEntity.scala
@@ -1,6 +1,8 @@
package model
val DOT_BASIC_SCORE = 10
+val DOT_POWER_SCORE = 50
+val DOT_FRUIT_SCORE = 100
trait GameEntity:
def position: Position2D
@@ -9,17 +11,28 @@ trait MovableEntity extends GameEntity:
def direction: Direction
- def withPosAndDir(newPosition: Position2D, newDirection: Direction): MovableEntity
+ protected def withPosAndDir(newPosition: Position2D, newDirection: Direction): MovableEntity
def move(newDirection: Direction): MovableEntity =
- val newPosition = newDirection match
- case Direction.Up => Position2D(position.x, position.y - 1)
- case Direction.Down => Position2D(position.x, position.y + 1)
- case Direction.Left => Position2D(position.x - 1, position.y)
- case Direction.Right => Position2D(position.x + 1, position.y)
+ val newPosition = position.calculatePos(newDirection)
withPosAndDir(newPosition, newDirection)
+ def teleport(destination: Position2D): MovableEntity = withPosAndDir(destination, direction)
+
+sealed trait Dot extends GameEntity:
+ def score: Int
+
+case class DotBasic(position: Position2D) extends Dot:
+ val score: Int = DOT_BASIC_SCORE
+
+case class DotPower(position: Position2D) extends Dot:
+ val score: Int = DOT_POWER_SCORE
+
+case class DotFruit(position: Position2D) extends Dot:
+ val score: Int = DOT_FRUIT_SCORE
+
case class Wall(position: Position2D) extends GameEntity
-case class DotBasic(position: Position2D, score: Int = DOT_BASIC_SCORE) extends GameEntity
-case class Tunnel(position: Position2D, toPos: Position2D, correctDirection: Direction) extends GameEntity:
+
+case class Tunnel(position: Position2D, toPos: Position2D, correctDirection: Direction)
+ extends GameEntity:
def canTeleport(dir: Direction): Boolean = dir == correctDirection
diff --git a/src/main/scala/model/GameManager.scala b/src/main/scala/model/GameManager.scala
index cabcb7d..7077402 100644
--- a/src/main/scala/model/GameManager.scala
+++ b/src/main/scala/model/GameManager.scala
@@ -1,124 +1,174 @@
package model
import model.map.GameMap
-import scala.annotation.tailrec
+
+/** Immutable game state */
+case class GameState(
+ spacMan: SpacManWithLife,
+ gameMap: GameMap,
+ gameOver: Boolean = false,
+ chaseTimeRemaining: Long = 0
+):
+ def isWin: Boolean = gameMap.getDots.isEmpty
+
+ def isChaseMode: Boolean = chaseTimeRemaining > 0
+
+ def updateChaseTime(deltaTime: Long): GameState =
+ if chaseTimeRemaining > 0 then
+ copy(chaseTimeRemaining = Math.max(0, chaseTimeRemaining - deltaTime))
+ else
+ this
trait GameManager:
- def getGameMap: GameMap
- def getSpacMan: SpacManBasic
+ def getState: GameState
def isWin(): Boolean
def isGameOver(): Boolean
- def moveSpacManAndCheck(newDirection: Direction): Option[SpacManBasic]
- def moveGhosts(): Set[GhostBasic]
-
-case class SimpleGameManager(
- private var _spacMan: SpacManBasic,
- private var _gameMap: GameMap,
- private var _gameOver: Boolean = false
-) extends GameManager:
-
- def getSpacMan: SpacManBasic = _spacMan
-
- def getGameMap: GameMap = _gameMap
-
- override def isWin(): Boolean = _gameMap.getDots.isEmpty
-
- override def isGameOver(): Boolean = _gameOver
-
- override def moveGhosts(): Set[GhostBasic] =
- @tailrec
- def findValidMove(ghost: GhostBasic, attempts: Int = 10): GhostBasic =
- if attempts <= 0 then ghost
- else
- val nextDirection = ghost.nextMove()
- if _gameMap.canMove(ghost, nextDirection) then
- val movedGhost = ghost.move(nextDirection).asInstanceOf[GhostBasic]
- _gameMap.replaceEntityTo(ghost, movedGhost) match
- case Right(updatedMap) =>
- _gameMap = updatedMap
- checkCollisionWithGhost(movedGhost)
- movedGhost
- case Left(error) =>
- println(s"Error moving Ghost: $error")
- ghost
- else
- findValidMove(ghost, attempts - 1)
-
- _gameMap.getGhosts.map(findValidMove(_))
-
- private def moveSpacMan(newDirection: Direction): SpacManBasic =
- if _gameMap.canMove(_spacMan, newDirection) then
- val movedSpacMan = _spacMan.move(newDirection).asInstanceOf[SpacManBasic]
- _gameMap.replaceEntityTo(_spacMan, movedSpacMan) match
- case Right(updatedMap) =>
- _gameMap = updatedMap
- _spacMan = movedSpacMan
+ def isChaseMode: Boolean
+ 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
+
+ override def isWin(): Boolean = state.isWin
+
+ override def isGameOver(): Boolean = state.gameOver
+
+ override def isChaseMode: Boolean = state.isChaseMode
+
+ /**
+ * Updates the chase time remaining.
+ */
+ override def updateChaseTime(deltaTime: Long): Unit =
+ state = state.updateChaseTime(deltaTime)
+
+ /**
+ * 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,
+ currentSpacMan: SpacManWithLife
+ ): Option[GhostBasic] =
+ val nextDirection = ghost.nextMove(
+ currentSpacMan.position,
+ currentSpacMan.direction,
+ currentMap
+ )
+ 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 Left(error) =>
- println(s"Error moving SpacMan: $error")
- _spacMan
-
- private enum CollisionResult:
- case GhostCollision
- case DotCollision(dot: DotBasic)
- case TunnelCollision(tunnel: Tunnel)
- case NoCollision
-
- private def detectCollision(
- entities: Set[GameEntity],
- direction: Direction
- ): CollisionResult =
- import CollisionResult.*
- entities.collectFirst { case ghost: GhostBasic => GhostCollision }
- .orElse(entities.collectFirst { case dot: DotBasic => DotCollision(dot) })
- .orElse(
- entities.collectFirst { case tunnel: Tunnel => tunnel }
- .filter(_.canTeleport(direction))
- .map(TunnelCollision.apply)
+ println(s"Warning: Could not move ghost ${ghost.id} - $error")
+ currentMap
+
+ /**
+ * Moves all ghosts in the game.
+ */
+ val (updatedMap, movedGhosts) =
+ state.gameMap.getGhosts.foldLeft((state.gameMap, List.empty[GhostBasic])):
+ case ((currentMap, ghosts), ghost) =>
+ attemptMove(ghost, currentMap, state.spacMan) match
+ case Some(movedGhost) =>
+ val newMap = applyMove(ghost, movedGhost, currentMap)
+ (newMap, movedGhost :: ghosts)
+ case None =>
+ (currentMap, ghost :: ghosts)
+
+ /**
+ * Checks collisions for all moved ghosts.
+ */
+ val finalState =
+ movedGhosts.foldLeft(state.copy(gameMap = updatedMap)): (currentState, ghost) =>
+ CollisionsManager
+ .checkGhostCollision(
+ ghost,
+ currentState.spacMan,
+ currentState.gameMap,
+ currentState.isChaseMode,
+ () => {}
+ )
+ .map: (newMap, newSpacMan) =>
+ val gameOver = newSpacMan.lives <= 0
+ currentState.copy(
+ gameMap = newMap,
+ spacMan = newSpacMan,
+ gameOver = gameOver
+ )
+ .getOrElse(currentState)
+
+ state = finalState
+
+ /**
+ * Moves the SpacMan in the given direction.
+ */
+ override def moveSpacMan(direction: Direction): Unit =
+ if !state.gameMap.canMove(state.spacMan, direction) then
+ return
+
+ val movedSpacMan = state.spacMan.move(direction).asInstanceOf[SpacManWithLife]
+
+ 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
+
+ val entities = updatedMapAfterMove
+ .entityAt(movedSpacMan.position)
+ .toOption
+ .getOrElse(Set.empty)
+
+ val collision = CollisionsManager.detectCollision(entities, direction)
+
+ var chaseTimeDelta = 0L
+ var gameOverFlag = false
+
+ val finalState = CollisionsManager
+ .applyCollisionEffect(
+ collision,
+ direction,
+ updatedMapAfterMove,
+ movedSpacMan,
+ state.isChaseMode,
+ delta => chaseTimeDelta += delta,
+ () => gameOverFlag = true
)
- .getOrElse(NoCollision)
-
- private def applyCollisionEffect(
- spacMan: SpacManBasic,
- collision: CollisionResult
- ): Option[SpacManBasic] =
- import CollisionResult.*
- collision match
- case GhostCollision =>
- handleGhostCollision()
- None
- case DotCollision(dot) =>
- _gameMap = _gameMap.remove(dot).getOrElse(_gameMap)
- _spacMan = _spacMan.addScore(dot.score)
- Some(_spacMan)
- case TunnelCollision(tunnel) =>
- val teleportedSpacMan = spacMan.teleport(tunnel.toPos)
- _gameMap.replaceEntityTo(spacMan, teleportedSpacMan) match
- case Right(updatedMap) =>
- _gameMap = updatedMap
- _spacMan = teleportedSpacMan
- Some(_spacMan)
- case Left(err) =>
- println(s"Error teleporting SpacMan: $err")
- None
- case NoCollision =>
- Some(_spacMan)
-
- private def handleGhostCollision(): Unit =
- _gameMap = _gameMap.remove(_spacMan).getOrElse(_gameMap)
- _gameOver = true
-
- private def checkCollisionWithGhost(ghost: GhostBasic): Unit =
- if ghost.position == _spacMan.position then
- handleGhostCollision()
-
- private def checkCollisions(
- spacMan: SpacManBasic,
- direction: Direction
- ): Option[SpacManBasic] =
- _gameMap.entityAt(spacMan.position).toOption
- .map(entities => detectCollision(entities, direction))
- .map(collision => applyCollisionEffect(spacMan, collision))
- .getOrElse(Some(_spacMan))
-
- def moveSpacManAndCheck(dir: Direction): Option[SpacManBasic] =
- checkCollisions(moveSpacMan(dir), dir)
+ .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
+
+/** Companion object for SimpleGameManager. */
+object SimpleGameManager:
+ def apply(
+ spacMan: SpacManWithLife,
+ gameMap: GameMap,
+ gameOver: Boolean = false,
+ chaseTimeRemaining: Long = 0
+ ): SimpleGameManager =
+ new SimpleGameManager(GameState(spacMan, gameMap, gameOver, chaseTimeRemaining))
diff --git a/src/main/scala/model/GhostBasic.scala b/src/main/scala/model/GhostBasic.scala
index f6ec708..9494e0d 100644
--- a/src/main/scala/model/GhostBasic.scala
+++ b/src/main/scala/model/GhostBasic.scala
@@ -1,9 +1,31 @@
package model
-case class GhostBasic(val position: Position2D, val direction: Direction, val speed: Double, val id: Int) extends MovableEntity:
-
+import model.map.GameMap
+
+val GHOST_BASIC_SCORE = 100
+
+case class GhostBasic(
+ position: Position2D,
+ direction: Direction,
+ id: Int,
+ score: Int = GHOST_BASIC_SCORE
+) extends MovableEntity:
+
override def withPosAndDir(newPosition: Position2D, newDirection: Direction): GhostBasic =
- this.copy(position = newPosition, direction = newDirection)
+ copy(position = newPosition, direction = newDirection)
- def nextMove(): Direction =
- Direction.values.toSeq(scala.util.Random.nextInt(Direction.values.size))
+ /**
+ * 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,
+ gameMap: GameMap
+ ): Direction =
+ val context = GhostContext(this, spacManPos, spacManDir, gameMap)
+ GhostBehavior.forId(id).chooseDirection(context)
diff --git a/src/main/scala/model/GhostBehavior.scala b/src/main/scala/model/GhostBehavior.scala
new file mode 100644
index 0000000..3ef322b
--- /dev/null
+++ b/src/main/scala/model/GhostBehavior.scala
@@ -0,0 +1,138 @@
+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,
+ targetPos: Position2D,
+ currentDir: Direction
+ )(ordering: Ordering[Int]): Direction =
+ validDirs
+ .map(dir => dir -> manhattanDistance(ghostPos.calculatePos(dir), targetPos))
+ .minByOption(_._2)(ordering)
+ .map(_._1)
+ .getOrElse(currentDir)
+
+/**
+ * Registry of ghost behaviors.
+ */
+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)
+
+/**
+ * Chase SpacMan.
+ */
+case object ChaseBehavior extends GhostBehavior:
+ override def chooseDirection(context: GhostContext): Direction =
+ selectDirection(
+ context.validDirections,
+ context.ghost.position,
+ context.spacManPos,
+ context.ghost.direction
+ )(Ordering.Int)
+
+/**
+ * Predict SpacMan's future position and chase it.
+ */
+case object PredictiveBehavior extends GhostBehavior:
+ private val PredictionDistance = 3
+
+ 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)
+
+ override def chooseDirection(context: GhostContext): Direction =
+ selectDirection(
+ context.validDirections,
+ context.ghost.position,
+ predictTarget(context),
+ 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)
+
+ if canContinue then
+ context.ghost.direction
+ else
+ val validDirs = context.validDirections
+ 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
+
+ override def chooseDirection(context: GhostContext): Direction =
+ val distance = manhattanDistance(context.ghost.position, context.spacManPos)
+ val ordering = if distance > DistanceThreshold then Ordering.Int else Ordering.Int.reverse
+
+ selectDirection(
+ context.validDirections,
+ context.ghost.position,
+ context.spacManPos,
+ context.ghost.direction
+ )(ordering)
diff --git a/src/main/scala/model/Position2D.scala b/src/main/scala/model/Position2D.scala
index 927ce95..6df9603 100644
--- a/src/main/scala/model/Position2D.scala
+++ b/src/main/scala/model/Position2D.scala
@@ -1,6 +1,6 @@
package model
-case class Position2D(x: Int, y: Int):
+case class Position2D(x: Int, y: Int):
def calculatePos(dir: Direction): Position2D = dir match
case Direction.Up => Position2D(x, y - 1)
case Direction.Down => Position2D(x, y + 1)
@@ -8,4 +8,3 @@ case class Position2D(x: Int, y: Int):
case Direction.Right => Position2D(x + 1, y)
override def toString(): String = s"($x, $y)"
-
\ No newline at end of file
diff --git a/src/main/scala/model/SpacMan.scala b/src/main/scala/model/SpacMan.scala
new file mode 100644
index 0000000..25719a4
--- /dev/null
+++ b/src/main/scala/model/SpacMan.scala
@@ -0,0 +1,88 @@
+package model
+
+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)
+ extends MovableEntity:
+
+ override def withPosAndDir(newPosition: Position2D, newDirection: Direction): SpacManBasic =
+ this.copy(position = newPosition, direction = newDirection)
+
+ def addScore(points: Int): SpacManBasic =
+ if points < 0 then this
+ else this.copy(score = this.score + points)
+
+ override def equals(obj: Any): Boolean = obj match
+ case that: SpacManBasic =>
+ 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,
+ score: Int,
+ val lives: Int = DEFAULT_LIVES
+) extends MovableEntity with Life[SpacManWithLife] with Score[SpacManWithLife]:
+
+ override def withPosAndDir(newPosition: Position2D, newDirection: Direction): SpacManWithLife =
+ this.copy(position = newPosition, direction = newDirection)
+
+ protected def updateLife(newLives: Int): SpacManWithLife = this.copy(lives = newLives)
+
+ protected def updateScore(points: Int): SpacManWithLife = this.copy(score = points)
+
+ override def equals(obj: Any): Boolean = obj match
+ case that: SpacManWithLife =>
+ this.position == that.position
+ case _ => false
diff --git a/src/main/scala/model/SpacManBasic.scala b/src/main/scala/model/SpacManBasic.scala
deleted file mode 100644
index b683975..0000000
--- a/src/main/scala/model/SpacManBasic.scala
+++ /dev/null
@@ -1,19 +0,0 @@
-package model
-
-case class SpacManBasic(val position: Position2D, val direction: Direction, val score: Int)
- extends MovableEntity:
-
- override def withPosAndDir(newPosition: Position2D, newDirection: Direction): SpacManBasic =
- this.copy(position = newPosition, direction = newDirection)
-
- def addScore(points: Int): SpacManBasic =
- if points < 0 then this
- else this.copy(score = this.score + points)
-
- def teleport(destination: Position2D): SpacManBasic =
- this.copy(position = destination)
-
- override def equals(obj: Any): Boolean = obj match
- case that: SpacManBasic =>
- this.position == that.position
- case _ => false
diff --git a/src/main/scala/model/WallBuilder.scala b/src/main/scala/model/WallBuilder.scala
index c4faf64..e3af6ae 100644
--- a/src/main/scala/model/WallBuilder.scala
+++ b/src/main/scala/model/WallBuilder.scala
@@ -1,51 +1,54 @@
package model
enum BuildDirection:
- case Single, Horizontal, Vertical, Complex
+ case Single
+ case Horizontal
+ case Vertical
+ case Complex
+/** Object to understand the build direction between two positions */
object BuildDirection:
- def understandBuildDirection(start: Position2D, end: Position2D): BuildDirection =
- if (start.y == end.y && start.x != end.x) Horizontal
- else if (start.x == end.x && start.y != end.y) Vertical
- else if (start.x == end.x && start.y == end.y) Single
- else Complex
+ def understandBuildDirection(start: Position2D, end: Position2D): BuildDirection =
+ if (start.y == end.y && start.x != end.x) Horizontal
+ else if (start.x == end.x && start.y != end.y) Vertical
+ else if (start.x == end.x && start.y == end.y) Single
+ else Complex
+/** Object to build multiple walls between two positions */
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))
-
- private def createHorizontalWall(startPos: Position2D, endPos: Position2D): Set[Wall] =
- val (x1, x2) =
- if (startPos.x <= endPos.x) (startPos.x, endPos.x)
- else (endPos.x, startPos.x)
- (for
- x <- x1 to x2
- yield Wall(Position2D(x, startPos.y))).toSet
-
- private def createVerticalWall(startPos: Position2D, endPos: Position2D): Set[Wall] =
- val (y1, y2) =
- if (startPos.y <= endPos.y) (startPos.y, endPos.y)
- else (endPos.y, startPos.y)
-
- (for
- y <- y1 to y2
- yield Wall(Position2D(startPos.x, y))).toSet
-
- private def createComplexWall(startPos: Position2D, endPos: Position2D): Set[Wall] =
- val (x1, x2) =
- if (startPos.x <= endPos.x) (startPos.x, endPos.x)
- else (endPos.x, startPos.x)
-
- val (y1, y2) =
- if (startPos.y <= endPos.y) (startPos.y, endPos.y)
- else (endPos.y, startPos.y)
-
- (for
- x <- x1 to x2
- y <- y1 to y2
- yield Wall(Position2D(x, y))).toSet
+ 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))
+
+ private def createHorizontalWall(startPos: Position2D, endPos: Position2D): Set[Wall] =
+ val (x1, x2) = orderPosition(startPos.x, endPos.x)
+ (for
+ x <- x1 to x2
+ yield Wall(Position2D(x, startPos.y))).toSet
+
+ private def createVerticalWall(startPos: Position2D, endPos: Position2D): Set[Wall] =
+ val (y1, y2) = orderPosition(startPos.y, endPos.y)
+ (for
+ y <- y1 to y2
+ yield Wall(Position2D(startPos.x, y))).toSet
+
+ 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
+
+ /** Orders two integers in ascending order
+ * @param x1 First integer
+ * @param x2 Second integer
+ * @return A tuple containing the two integers in ascending order
+ */
+ private def orderPosition(x1: Int, x2: Int): (Int, Int) =
+ if (x1 <= x2) (x1, x2)
+ else (x2, x1)
diff --git a/src/main/scala/model/map/GameMap.scala b/src/main/scala/model/map/GameMap.scala
index a0b20b9..3dd841e 100644
--- a/src/main/scala/model/map/GameMap.scala
+++ b/src/main/scala/model/map/GameMap.scala
@@ -6,35 +6,75 @@ import model.MovableEntity
import model.Direction
import model.Wall
import model.GhostBasic
-import model.DotBasic
-import model.SpacManBasic
+import model.Dot
+/** It represents the game map with its entities. */
trait GameMap:
- def width: Int
- def height: Int
+ val width: Int
+ val height: Int
+
+ /** Spacman spawnPoint */
+ val spawnPoint: Position2D
+ val ghostSpawnPoints: Set[Position2D]
def getWalls: Set[Wall]
def getGhosts: Set[GhostBasic]
- def getDots: Set[DotBasic]
+ def getDots: Set[Dot]
+
+ /** @param pos Position to check
+ * @return Either an error message or the set of entities at that position
+ */
def entityAt(pos: Position2D): Either[String, Set[GameEntity]]
- def place(pos: Position2D, entity: GameEntity): Either[String, GameMap]
+
+ /** @param entity Entity to place
+ * @return Either an error message or the new map with the entity placed
+ */
+ def place(entity: GameEntity): Either[String, GameMap]
+
+ /** @param entities Entities to place, polymorphic in type because of Scala's type inference limitations
+ * @return Either an error message or the new map with the entities placed
+ */
def placeAll[E <: GameEntity](entities: Set[E]): Either[String, GameMap]
- def remove(entity: GameEntity): Either[String, GameMap]
+
+ /** @param entity Entity to replace
+ * @param movedEntity New entity to place
+ * @return Either an error message or the new map with the entity replaced
+ */
def replaceEntityTo(entity: GameEntity, movedEntity: GameEntity): Either[String, GameMap]
+
+ /** @param entity Entity to remove
+ * @return Either an error message or the new map with the entity removed
+ */
+ def remove(entity: GameEntity): Either[String, GameMap]
+
+ /** @param entity Movable entity to check
+ * @param dir Direction to move
+ * @return true if the entity can move in the given direction, false otherwise
+ */
def canMove(entity: MovableEntity, dir: Direction): Boolean
case class GameMapImpl(
width: Int,
height: Int,
+ spawnPoint: Position2D,
+ private val ghostSpawnPointStartingCell: Position2D,
grid: Map[Position2D, Set[GameEntity]]
) extends GameMap:
+ /** Ghost spawn points are the 4 cells starting from the ghostSpawnPointStartingCell */
+ val ghostSpawnPoints: Set[Position2D] = Set(
+ ghostSpawnPointStartingCell,
+ Position2D(ghostSpawnPointStartingCell.x + 1, ghostSpawnPointStartingCell.y),
+ Position2D(ghostSpawnPointStartingCell.x, ghostSpawnPointStartingCell.y + 1),
+ Position2D(ghostSpawnPointStartingCell.x + 1, ghostSpawnPointStartingCell.y + 1)
+ )
+
override def getWalls: Set[Wall] = grid.values.flatten.collect { case w: Wall => w }.toSet
override def getGhosts: Set[GhostBasic] = grid.values.flatten.collect { case g: GhostBasic =>
g
}.toSet
- override def getDots: Set[DotBasic] = grid.values.flatten.collect { case d: DotBasic =>
+ override def getDots: Set[Dot] = grid.values.flatten.collect { case d: Dot =>
d
}.toSet
@@ -43,16 +83,16 @@ case class GameMapImpl(
case Some(value) => Right(value)
case None => Left("Invalid position " + pos)
- override def place(pos: Position2D, entity: GameEntity): Either[String, GameMap] =
- grid.get(pos) match
+ override def place(entity: GameEntity): Either[String, GameMap] =
+ grid.get(entity.position) match
case Some(entities) =>
- Right(copy(grid = grid.updated(pos, entities + entity)))
+ Right(copy(grid = grid.updated(entity.position, entities + entity)))
case None =>
- Left("Invalid position" + pos)
+ Left("Invalid position" + entity.position)
override def placeAll[E <: GameEntity](entities: Set[E]): Either[String, GameMap] =
entities.foldLeft[Either[String, GameMap]](Right(this)) { (result, entity) =>
- result.flatMap(_.place(entity.position, entity))
+ result.flatMap(_.place(entity))
}
override def remove(entity: GameEntity): Either[String, GameMap] =
@@ -63,9 +103,12 @@ case class GameMapImpl(
case false => Left("No entity found")
case None => Left("Invalid position" + entity.position)
- override def replaceEntityTo(entity: GameEntity, movedEntity: GameEntity): Either[String, GameMap] =
+ override def replaceEntityTo(
+ entity: GameEntity,
+ movedEntity: GameEntity
+ ): Either[String, GameMap] =
remove(entity) match
- case Right(map) => map.place(movedEntity.position, movedEntity)
+ case Right(map) => map.place(movedEntity)
case Left(err) => Left(err)
override def canMove(entity: MovableEntity, dir: Direction): Boolean =
@@ -73,21 +116,32 @@ case class GameMapImpl(
entityAt(nextPos) match
case Right(set) if set.exists(_.isInstanceOf[Wall]) => false
case Right(set) if set.exists {
- case _: GhostBasic => entity.isInstanceOf[GhostBasic]
- case _ => false
- } => false
+ case _: GhostBasic => entity.isInstanceOf[GhostBasic]
+ case _ => false
+ } => false
case _ if isOutOfMap(nextPos) => false
case _ => true
private def isOutOfMap(p: Position2D): Boolean =
p.x >= width || p.x < 0 || p.y >= height || p.y < 0
+/** Factory object for creating GameMap instances with the right grids but without entities. */
object GameMapFactory:
- def apply(width: Int, height: Int): GameMap =
- GameMapImpl(width, height, createEmptyMap(width, height))
+ def apply(
+ width: Int,
+ height: Int,
+ spawnPoint: Position2D,
+ ghostSpawnPoint: Position2D = Position2D(1, 1)
+ ): GameMap =
+ require(isSpawnPointInsideMap(width, height, spawnPoint))
+ require(isSpawnPointInsideMap(width, height, ghostSpawnPoint))
+ GameMapImpl(width, height, spawnPoint, ghostSpawnPoint, createEmptyMap(width, height))
private def createEmptyMap(w: Int, h: Int): Map[Position2D, Set[GameEntity]] =
(for
x <- 0 until w
y <- 0 until h
yield Position2D(x, y) -> Set.empty[GameEntity]).toMap
+
+ private def isSpawnPointInsideMap(width: Int, height: Int, spawnPoint: Position2D): Boolean =
+ (spawnPoint.x >= 0 && spawnPoint.x <= width) && (spawnPoint.y >= 0 && spawnPoint.y <= height)
diff --git a/src/main/scala/model/map/MapDSL.scala b/src/main/scala/model/map/MapDSL.scala
index 207e4b5..2ae8a2f 100644
--- a/src/main/scala/model/map/MapDSL.scala
+++ b/src/main/scala/model/map/MapDSL.scala
@@ -1,37 +1,98 @@
package model
import model.map.GameMap
-import model.map.GameMapImpl
import model.map.GameMapFactory
+/** DSL builder trait for walls */
trait WallEntityBuilderLike:
- infix def to(endPos: Position2D): Unit
+ infix def to(endPos: Position2D): Unit
-object board:
- def apply(width: Int, height: Int): GameMap = GameMapFactory.apply(width, height)
+/** Generic entity types for DSL */
+enum GenericEntity:
+ case GenericWall
+ case GenericDot
+ case GenericDotPower
+ case GenericDotFruit
-object genericWall:
- def apply(): Wall = Wall.apply(Position2D(0, 0))
+/** DSL helper for creating empty game boards */
+object board:
+ def apply(
+ width: Int,
+ height: Int,
+ spawnPoint: Position2D = Position2D(0, 0),
+ ghostSpawnPoint: Position2D = Position2D(1, 1)
+ ): GameMap = GameMapFactory.apply(width, height, spawnPoint, ghostSpawnPoint)
+/** DSL for building and modifying GameMap instances */
class MapDSL(var map: GameMap):
- object place:
- def a(e: GameEntity): GameEntityBuilder = GameEntityBuilder(e)
+ /** Val is created for writing codes 'human-like' */
+ val genericWall = GenericEntity.GenericWall
+ val genericDot = GenericEntity.GenericDot
+ val genericDotPower = GenericEntity.GenericDotPower
+ val genericDotFruit = GenericEntity.GenericDotFruit
+
+ object place:
+ /** DSL helper for writing 'a entity from/to position' or 'the entity at position'
+ * useful with generic entities
+ * @param e Generic entity to place
+ */
+ def a(e: GenericEntity): GameEntityBuilder = GameEntityBuilder(e)
+
+ /** DSL helper for writing 'the entity at position'
+ * useful with already created entities
+ * @param e Game entity to place
+ */
+ def the(e: GameEntity): Unit =
+ map = map.place(e).fold(_ => map, identity)
+
+ /** DSL helper for writing 'multiple entities'
+ * @param gameEntitySet Set of game entities to place
+ */
+ def multiple[E <: GameEntity](gameEntitySet: Set[E]): Unit =
+ map = map.placeAll(gameEntitySet).fold(_ => map, identity)
+
+ class GameEntityBuilder(e: GenericEntity = null):
+ /** DSL helper for writing 'from position' for walls
+ * @param pos Position where the wall starts
+ * @return WallEntityBuilderLike for chaining 'to position'
+ */
+ infix def from(pos: Position2D): WallEntityBuilderLike = createGenericEntity(e) match
+ case Wall(_) => WallEntityBuilder(pos)
+ case _ => NoWallFoundBuilder()
- class GameEntityBuilder(e: GameEntity = null):
- infix def from(pos: Position2D): WallEntityBuilderLike = e match
- case Wall(_) => WallEntityBuilder(pos)
- case _ => NoWallFoundBuilder()
+ /** DSL helper for writing 'at position'
+ * @param pos Position where to place the entity
+ * @return The created GameEntity
+ */
+ infix def at(pos: Position2D): GameEntity =
+ val gameEntity = createGenericEntity(e, pos)
+ map = map.place(gameEntity).fold(_ => map, identity)
+ gameEntity
- infix def at(pos: Position2D): Unit =
- map = map.place(pos, e).fold(_ => map, identity)
+ private def createGenericEntity(
+ e: GenericEntity,
+ pos: Position2D = Position2D(0, 0)
+ ): GameEntity = e match
+ case GenericEntity.GenericWall => Wall(pos)
+ case GenericEntity.GenericDot => DotBasic(pos)
+ case GenericEntity.GenericDotPower => DotPower(pos)
+ case GenericEntity.GenericDotFruit => DotFruit(pos)
- class WallEntityBuilder(startPos: Position2D) extends WallEntityBuilderLike:
- infix def to(endPos: Position2D): Unit =
- map = map.placeAll(WallBuilder.createWalls(startPos, endPos)).fold(_ => map, identity)
+ class WallEntityBuilder(startPos: Position2D) extends WallEntityBuilderLike:
+ /** DSL helper for writing 'to position' for walls
+ * @param endPos Position where the wall ends
+ */
+ infix def to(endPos: Position2D): Unit =
+ map = map.placeAll(WallBuilder.createWalls(startPos, endPos)).fold(_ => map, identity)
- class NoWallFoundBuilder() extends WallEntityBuilderLike:
- infix def to(pos: Position2D): Unit = ()
+ class NoWallFoundBuilder() extends WallEntityBuilderLike:
+ /** DSL helper for enabling write 'to position' when no wall is found
+ * No exception is thrown, but nothing is done
+ * @param pos
+ * @return Nothing
+ */
+ infix def to(pos: Position2D): Unit = ()
- /** DSL helper per scrivere at position (x, y) */
- object position:
- def apply(x: Int, y: Int): Position2D = Position2D(x, y)
+ /** DSL helper for writing 'position(x, y)' */
+ object position:
+ def apply(x: Int, y: Int): Position2D = Position2D(x, y)
diff --git a/src/main/scala/model/map/MapLevel1.scala b/src/main/scala/model/map/MapLevel1.scala
new file mode 100644
index 0000000..60ba847
--- /dev/null
+++ b/src/main/scala/model/map/MapLevel1.scala
@@ -0,0 +1,85 @@
+package model.map
+
+import model.SpacManWithLife
+import model.Position2D
+import model.MapDSL
+import model.GhostBasic
+import model.Direction
+import model.Tunnel
+import model.DotPower
+import model.DotFruit
+import model.board
+
+object MapLevel1 {
+ def getMap(): (SpacManWithLife, GameMap) = createMap()
+
+ private def createMap(): (SpacManWithLife, GameMap) =
+ val ghostSpawnPoint = Position2D(1, 1)
+ val spacmanSpawn = Position2D(25, 17)
+
+ val dsl = MapDSL(board(30, 20, spacmanSpawn, ghostSpawnPoint))
+
+ val ghost1 = GhostBasic(Position2D(3, 3), Direction.Down, 1)
+ val ghost2 = GhostBasic(Position2D(25, 3), Direction.Up, 2)
+ val ghost3 = GhostBasic(Position2D(3, 17), Direction.Left, 3)
+ val ghost4 = GhostBasic(Position2D(25, 13), Direction.Right, 4)
+
+ val spacman = SpacManWithLife(spacmanSpawn, Direction.Left, 0)
+
+ val tunnelL = Tunnel(Position2D(0, 12), Position2D(29, 12), Direction.Left)
+ val tunnelR = Tunnel(Position2D(29, 12), Position2D(0, 12), Direction.Right)
+ val dp1 = DotPower(Position2D(25, 18))
+ val dp2 = DotPower(Position2D(2, 2))
+ val fruit = DotFruit(Position2D(15, 12))
+
+ import dsl.*
+
+ // MURI ESTERNI
+ place a genericWall from position(0, 0) to position(29, 0)
+ place a genericWall from position(0, 19) to position(29, 19)
+ place a genericWall from position(0, 0) to position(0, 11)
+ place a genericWall from position(0, 13) to position(0, 19)
+ place a genericWall from position(29, 0) to position(29, 11)
+ place a genericWall from position(29, 13) to position(29, 19)
+
+ // MURI INTERNI
+ // blocco centrale
+ place a genericWall from position(10, 8) to position(19, 8)
+ place a genericWall from position(10, 9) to position(10, 10)
+ place a genericWall from position(10, 13) to position(10, 15)
+ place a genericWall from position(19, 9) to position(19, 15)
+ place a genericWall from position(10, 15) to position(19, 15)
+
+ // corridoio alto
+ place a genericWall from position(5, 4) to position(24, 4)
+
+ // blocchi laterali
+ place a genericWall from position(3, 10) to position(5, 12)
+ place a genericWall from position(24, 10) to position(26, 12)
+
+ // POSIZIONI MURI PER EVITARE DOT
+ val wallPositions = dsl.map.getWalls.map(_.position)
+
+ // PALLINI BASIC
+ for
+ x <- 1 until 29
+ y <- 1 until 19
+ pos = position(x, y)
+ if (x + y) % 2 == 0 // piazza un dot ogni 2 caselle
+ && !wallPositions.contains(pos)
+ && pos != spacman.position
+ && pos != dp1.position
+ && pos != dp2.position
+ && pos != fruit.position
+ && !dsl.map.ghostSpawnPoints.contains(pos)
+ do
+ place a genericDot at pos
+
+ place multiple Set(ghost1, ghost2, ghost3, ghost4)
+ place multiple Set(tunnelL, tunnelR)
+ place multiple Set(dp1, dp2)
+ place the fruit
+ place the spacman
+
+ (spacman, dsl.map)
+}
diff --git a/src/main/scala/view/ButtonFactory.scala b/src/main/scala/view/ButtonFactory.scala
index dbda69f..3585149 100644
--- a/src/main/scala/view/ButtonFactory.scala
+++ b/src/main/scala/view/ButtonFactory.scala
@@ -4,6 +4,10 @@ import scala.swing.Button
import scala.swing.Dimension
import java.awt.Font
+val BUTTON_BIG_HORIZONTAL_SIZE = 300
+val BUTTON_NORMAL_HORIZONTAL_SIZE = 100
+val BUTTON_VERTICAL_SIZE = 50
+
enum ButtonSize:
case Big
case Normal
@@ -19,5 +23,5 @@ object ButtonFactory:
font = fontType
def dimension(size: ButtonSize): Dimension = size match
- case ButtonSize.Big => new Dimension(300, 50)
- case ButtonSize.Normal => new Dimension(100, 50)
+ case ButtonSize.Big => new Dimension(BUTTON_BIG_HORIZONTAL_SIZE, BUTTON_VERTICAL_SIZE)
+ case ButtonSize.Normal => new Dimension(BUTTON_NORMAL_HORIZONTAL_SIZE, BUTTON_VERTICAL_SIZE)
diff --git a/src/main/scala/view/GameView.scala b/src/main/scala/view/GameView.scala
index d57014f..8410820 100644
--- a/src/main/scala/view/GameView.scala
+++ b/src/main/scala/view/GameView.scala
@@ -1,36 +1,29 @@
package view
-import scala.swing.SimpleSwingApplication
import scala.swing.MainFrame
-import scala.swing.Frame
import scala.swing.BoxPanel
import scala.swing.Orientation
-import scala.swing.Label
-import scala.swing.Button
import scala.swing.Swing
import scala.swing.Dimension
import scala.swing.FlowPanel
import scala.swing.event.ButtonClicked
import scala.swing.BorderPanel
-import scala.swing.Font
-import scala.swing.Alignment
-import javax.swing.JPanel
-import java.awt.CardLayout
-import javax.swing.JFrame
import model.map.GameMap
import scala.swing.Panel
import scala.swing.Graphics2D
import model.Position2D
import model.DotBasic
import model.Wall
-import model.SpacManBasic
import model.Direction
import model.GhostBasic
import java.awt.image.BufferedImage
-import javax.imageio.ImageIO
import java.awt.Color
+import java.awt.Font
import controller.GameController
import model.Tunnel
+import model.SpacManWithLife
+import model.DotPower
+import model.DotFruit
object SimpleSwingApp {
def create(map: GameMap): GameView =
@@ -70,16 +63,19 @@ class HomeView() extends MainFrame:
class GameView(gameMap: GameMap) extends MainFrame:
private val gamePanel = GameMapPanel(gameMap)
+ private val infoPanel = new InfoPanel(3, 0)
private val SIZE = 1000
def initialize(): Unit =
title = "SpacMan"
- preferredSize = new Dimension(SIZE, SIZE)
+ preferredSize = new Dimension(SIZE, SIZE + infoPanel.INFO_VERTICAL_SIZE)
contents = new BorderPanel {
+ layout(infoPanel) = BorderPanel.Position.North
layout(gamePanel) = BorderPanel.Position.Center
}
- def update(map: GameMap): Unit =
+ def update(map: GameMap, lives: Int, score: Int): Unit =
+ infoPanel.updateInfo(lives, score)
gamePanel.updateMap(map)
def getGamePanel: javax.swing.JComponent = gamePanel.peer
@@ -114,6 +110,46 @@ class GameView(gameMap: GameMap) extends MainFrame:
layout(endingPanel) = BorderPanel.Position.Center
}
+class InfoPanel(private var lives: Int, private var score: Int) extends Panel:
+ final val INFO_SIZE = 1000
+ final val INFO_VERTICAL_SIZE = 60
+ private final val HEART_SIZE = 32
+ private final val PADDING = 10
+
+ preferredSize = new Dimension(INFO_SIZE, INFO_VERTICAL_SIZE)
+ background = Color.BLACK
+
+ override def paintComponent(g: Graphics2D): Unit =
+ super.paintComponent(g)
+
+ g.setColor(Color.BLACK)
+ g.fillRect(0, 0, size.width, size.height)
+
+ for i <- 0 until lives do
+ SpriteLoader.load("life") match
+ case Some(sprite) =>
+ g.drawImage(
+ sprite,
+ PADDING + (i * (HEART_SIZE + 5)),
+ PADDING,
+ HEART_SIZE,
+ HEART_SIZE,
+ null
+ )
+ case None =>
+ g.setColor(Color.RED)
+ g.fillOval(PADDING + (i * (HEART_SIZE + 5)), PADDING, HEART_SIZE, HEART_SIZE)
+
+ g.setColor(Color.WHITE)
+ g.setFont(new java.awt.Font("Arial", java.awt.Font.BOLD, 24))
+ g.drawString(s"Score: $score", size.width - 200, 35)
+ g.drawString(s"Score: $score", size.width - 200, 35)
+
+ def updateInfo(newLives: Int, newScore: Int): Unit =
+ lives = newLives
+ score = newScore
+ repaint()
+
class GameMapPanel(private var gameMap: GameMap) extends Panel:
private val CELL_SIZE = 32
@@ -148,9 +184,13 @@ class GameMapPanel(private var gameMap: GameMap) extends Panel:
entities.foreach {
case dot: DotBasic =>
drawSprite(g, "dot", x, y)
+ case dotPower: DotPower =>
+ drawSprite(g, "dot_power", x, y)
+ case fruit: DotFruit =>
+ drawSprite(g, "apple", x, y)
case wall: Wall =>
drawSprite(g, "wall", x, y)
- case pacman: SpacManBasic =>
+ case pacman: SpacManWithLife =>
drawSprite(g, drawPacman(pacman.direction), x, y)
case tunnel: Tunnel =>
drawSprite(g, drawTunnel(tunnel.correctDirection), x, y)
diff --git a/src/main/scala/view/LabelFactory.scala b/src/main/scala/view/LabelFactory.scala
index 60b1be5..ab4f8ba 100644
--- a/src/main/scala/view/LabelFactory.scala
+++ b/src/main/scala/view/LabelFactory.scala
@@ -1,17 +1,19 @@
package view
-import scala.swing.Dimension
import scala.swing.Label
import scala.swing.Alignment
import scala.swing.Font
+private final val TITLE_SIZE = 36
+private final val NORMAL_SIZE = 25
+
object LabelFactory:
def createTitleLabel(text: String): Label =
new Label(text):
horizontalAlignment = Alignment.Center
- font = new Font("Arial", java.awt.Font.BOLD, 36)
+ font = new Font("Arial", java.awt.Font.BOLD, TITLE_SIZE)
def createScoreLabel(score: Int): Label =
new Label("Score: " + score):
horizontalAlignment = Alignment.Center
- font = new Font("Arial", java.awt.Font.PLAIN, 25)
+ font = new Font("Arial", java.awt.Font.PLAIN, NORMAL_SIZE)
diff --git a/src/main/scala/view/SpriteLoader.scala b/src/main/scala/view/SpriteLoader.scala
index 0256f4f..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
@@ -40,7 +45,9 @@ object SpriteLoader:
"arrow_up",
"wall",
"dot",
+ "dot_power",
"apple",
- "strawberry"
+ "strawberry",
+ "fruit"
)
sprites.foreach(load)
diff --git a/src/test/scala/model/CollisionsManagerTest.scala b/src/test/scala/model/CollisionsManagerTest.scala
new file mode 100644
index 0000000..c943a58
--- /dev/null
+++ b/src/test/scala/model/CollisionsManagerTest.scala
@@ -0,0 +1,219 @@
+package model
+
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+class CollisionsManagerTest extends AnyFlatSpec with Matchers:
+ def createBasicTestSetup(): SimpleGameManager =
+ val spacMan = SpacManWithLife(Position2D(1, 1), Direction.Right, 0, 1)
+ val ghost1 = GhostBasic(Position2D(1, 2), Direction.Right, 1)
+ val ghost2 = GhostBasic(Position2D(4, 2), Direction.Left, 2)
+ val dotBasic1 = DotBasic(Position2D(2, 1))
+ val dotBasic2 = DotBasic(Position2D(3, 2))
+ val dotPower1 = DotPower(Position2D(2, 2))
+ val fruit = DotFruit(Position2D(3, 1))
+ val tunnel1 = Tunnel(Position2D(2, 0), Position2D(1, 9), Direction.Up)
+ val tunnel2 = Tunnel(Position2D(1, 9), Position2D(2, 0), Direction.Down)
+ val wall = Wall(Position2D(0, 1))
+ val map = board(10, 10)
+ val dsl = MapDSL(map)
+
+ import dsl.*
+
+ place the spacMan
+ place the ghost1
+ place the ghost2
+ place the dotBasic1
+ place the dotBasic2
+ place the dotPower1
+ place the fruit
+ place the tunnel1
+ place the tunnel2
+ place the wall
+
+ SimpleGameManager(spacMan, dsl.map)
+
+ def createEdgeTestSetup1(): SimpleGameManager =
+ val spacMan = SpacManWithLife(Position2D(2, 3), Direction.Right, 0, 1)
+ val ghost1 = GhostBasic(Position2D(2, 2), Direction.Right, 1)
+ val wall1 = Wall(Position2D(1, 2))
+ val wall2 = Wall(Position2D(2, 1))
+ val wall3 = Wall(Position2D(3, 2))
+ val map = board(10, 10)
+ val dsl = MapDSL(map)
+
+ import dsl.*
+
+ place the spacMan
+ place the ghost1
+ place the wall1
+ place the wall2
+ place the wall3
+
+ SimpleGameManager(spacMan, dsl.map)
+
+ def createEdgeTestSetup2(): SimpleGameManager =
+ val spacMan = SpacManWithLife(Position2D(2, 1), Direction.Right, 0, 3)
+ val ghost1 = GhostBasic(Position2D(2, 2), Direction.Right, 1)
+ val wall1 = Wall(Position2D(1, 2))
+ val wall2 = Wall(Position2D(2, 1))
+ val wall3 = Wall(Position2D(3, 2))
+ val map = board(10, 10, Position2D(9, 9))
+ val dsl = MapDSL(map)
+
+ import dsl.*
+
+ place the spacMan
+ place the ghost1
+ place the wall1
+ place the wall2
+ place the wall3
+
+ SimpleGameManager(spacMan, dsl.map)
+
+ "CollisionsManager" should "eat basicDot and increase score" in:
+ val gameManager = createBasicTestSetup()
+ gameManager.moveSpacMan(Direction.Right)
+
+ assert(gameManager.getState.spacMan.position == Position2D(2, 1))
+ assert(gameManager.getState.gameMap.entityAt(Position2D(2, 1)).getOrElse(Set()).exists(
+ _.isInstanceOf[SpacManWithLife]
+ ))
+ assert(gameManager.getState.spacMan.score == DOT_BASIC_SCORE)
+
+ it should "eat powerDot and increase score" in:
+ val gameManager = createBasicTestSetup()
+ gameManager.moveSpacMan(Direction.Right)
+
+ assert(gameManager.getState.spacMan.position == Position2D(2, 1))
+ assert(gameManager.getState.gameMap.entityAt(Position2D(1, 1)).getOrElse(Set()).isEmpty)
+ assert(gameManager.getState.gameMap.entityAt(Position2D(2, 1)).getOrElse(Set()).exists(
+ _.isInstanceOf[SpacManWithLife]
+ ))
+ assert(gameManager.getState.spacMan.score == DOT_BASIC_SCORE)
+
+ gameManager.moveSpacMan(Direction.Down)
+
+ assert(gameManager.getState.spacMan.position == Position2D(2, 2))
+ assert(gameManager.getState.gameMap.entityAt(Position2D(2, 1)).getOrElse(Set()).isEmpty)
+ assert(gameManager.getState.gameMap.entityAt(Position2D(2, 2)).getOrElse(Set()).exists(
+ _.isInstanceOf[SpacManWithLife]
+ ))
+ assert(gameManager.getState.spacMan.score == DOT_BASIC_SCORE + DOT_POWER_SCORE)
+
+ it should "eat dots, fruit, increase score and add life" in:
+ val gameManager = createBasicTestSetup()
+ gameManager.moveSpacMan(Direction.Right)
+
+ assert(gameManager.getState.spacMan.position == Position2D(2, 1))
+ assert(gameManager.getState.spacMan.score == DOT_BASIC_SCORE)
+ gameManager.moveSpacMan(Direction.Right)
+
+ assert(gameManager.getState.spacMan.position == Position2D(3, 1))
+ assert(
+ gameManager.getState.spacMan.lives == 2
+ ) // Because for this test Spacman has 1 life on default
+ assert(gameManager.getState.spacMan.score == DOT_BASIC_SCORE + DOT_FRUIT_SCORE)
+
+ it should "set game over when colliding with a ghost" in:
+ val gameManager = createBasicTestSetup()
+ gameManager.moveSpacMan(Direction.Down)
+
+ assert(gameManager.getState.gameMap.entityAt(Position2D(1, 1)).getOrElse(Set()).isEmpty)
+ assert(gameManager.getState.gameMap.entityAt(Position2D(1, 2)).getOrElse(Set()).exists(
+ !_.isInstanceOf[SpacManWithLife]
+ ))
+ assert(gameManager.getState.spacMan.lives == 0)
+ assert(gameManager.isGameOver())
+
+ it should "teleport SpacMan through tunnel with correct direction" in:
+ val gameManager = createBasicTestSetup()
+ gameManager.moveSpacMan(Direction.Right)
+ gameManager.moveSpacMan(Direction.Up)
+
+ assert(gameManager.getState.spacMan.position == Position2D(
+ 1,
+ 9
+ ) && gameManager.getState.spacMan.direction == Direction.Up)
+ assert(gameManager.getState.gameMap.entityAt(Position2D(1, 9)).getOrElse(Set()).exists(
+ _.isInstanceOf[SpacManWithLife]
+ ))
+
+ it should "not teleport SpacMan through tunnel with wrong direction" in:
+ val gameManager = createBasicTestSetup()
+
+ gameManager.moveSpacMan(Direction.Up)
+ gameManager.moveSpacMan(Direction.Right)
+
+ assert(gameManager.getState.spacMan.position == Position2D(2, 0) &&
+ gameManager.getState.spacMan.direction == Direction.Right)
+ assert(gameManager.getState.gameMap.entityAt(Position2D(2, 0)).getOrElse(Set()).exists(
+ _.isInstanceOf[SpacManWithLife]
+ ))
+
+ it should "set game over when ghost moves into SpacMan position" in:
+ val gameManager = createEdgeTestSetup1()
+
+ assert(!gameManager.isGameOver())
+ var attempts = 0
+ while !gameManager.isGameOver() && attempts < 10 do
+ gameManager.moveGhosts()
+ attempts += 1
+
+ assert(gameManager.isGameOver())
+
+ it should "lose a life and teleport Spacman in spawnPoint" in:
+ val gameManager = createEdgeTestSetup2()
+ assert(!gameManager.isGameOver())
+
+ gameManager.moveSpacMan(Direction.Down)
+
+ assert(gameManager.getState.spacMan.lives == 2)
+ assert(gameManager.getState.spacMan.position == Position2D(9, 9))
+ assert(!gameManager.isGameOver())
+
+ it should "eat dotPower, eat ghost and finish chase mode" in:
+ val gameManager = createBasicTestSetup()
+ val chaseTimeDuration = 10000
+
+ assert(!gameManager.isChaseMode)
+
+ assert(gameManager.getState.gameMap.entityAt(Position2D(1, 1)).getOrElse(Set()).exists(
+ _.isInstanceOf[SpacManWithLife]
+ ))
+
+ gameManager.moveSpacMan(Direction.Right)
+
+ assert(gameManager.getState.gameMap.entityAt(Position2D(2, 1)).getOrElse(Set()).exists(
+ _.isInstanceOf[SpacManWithLife]
+ ))
+ assert(gameManager.getState.spacMan.score == DOT_BASIC_SCORE)
+
+ gameManager.moveSpacMan(Direction.Down)
+
+ assert(gameManager.getState.gameMap.entityAt(Position2D(2, 2)).getOrElse(Set()).exists(
+ _.isInstanceOf[SpacManWithLife]
+ ))
+ assert(gameManager.getState.spacMan.score == DOT_BASIC_SCORE + DOT_POWER_SCORE)
+
+ gameManager.moveSpacMan(Direction.Left)
+
+ assert(gameManager.getState.gameMap.entityAt(Position2D(1, 2)).getOrElse(Set()).exists(
+ _.isInstanceOf[SpacManWithLife]
+ ))
+ assert(
+ gameManager.getState.spacMan.score == DOT_BASIC_SCORE + DOT_POWER_SCORE + GHOST_BASIC_SCORE
+ )
+
+ assert(gameManager.getState.gameMap.ghostSpawnPoints.exists(spawnPos =>
+ gameManager.getState.gameMap.entityAt(spawnPos).getOrElse(Set()).exists(
+ _.isInstanceOf[GhostBasic]
+ )
+ ))
+ assert(gameManager.isChaseMode)
+
+ // Finish chase mode
+ gameManager.updateChaseTime(chaseTimeDuration)
+
+ assert(!gameManager.isChaseMode)
+ assert(!gameManager.isGameOver())
diff --git a/src/test/scala/model/DotTest.scala b/src/test/scala/model/DotTest.scala
new file mode 100644
index 0000000..843a81f
--- /dev/null
+++ b/src/test/scala/model/DotTest.scala
@@ -0,0 +1,20 @@
+package model
+
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+class DotTest extends AnyFlatSpec with Matchers:
+ val dotBasic = DotBasic(Position2D(0, 0))
+ val dotPower = DotPower(Position2D(1, 1))
+
+ "DotBasic" should "be created correctly" in:
+ dotBasic shouldBe DotBasic(Position2D(0, 0))
+
+ it should "have the correct score" in:
+ dotBasic.score shouldBe 10
+
+ "DotPower" should "be created correctly" in:
+ dotPower shouldBe DotPower(Position2D(1, 1))
+
+ it should "have the correct score" in:
+ dotPower.score shouldBe 50
diff --git a/src/test/scala/model/GameManagerTest.scala b/src/test/scala/model/GameManagerTest.scala
index 89226bb..4b7f57f 100644
--- a/src/test/scala/model/GameManagerTest.scala
+++ b/src/test/scala/model/GameManagerTest.scala
@@ -1,48 +1,41 @@
package model
import org.scalatest.flatspec.AnyFlatSpec
-import model.map.GameMapFactory
import org.scalatest.matchers.should.Matchers
-import model.MapDSL
-import model.Position2D
-import model.SpacManBasic
-import model.GhostBasic
-import model.Direction
-import model.GameEntity
-import model.DotBasic
-import model.board
-import model.Wall
-import model.Tunnel
class GameManagerTest extends AnyFlatSpec with Matchers:
def createBasicTestSetup(): SimpleGameManager =
- val spacMan = SpacManBasic(Position2D(1, 1), Direction.Right, 0)
- val ghost1 = GhostBasic(Position2D(1, 2), Direction.Right, 1.0, 1)
- val ghost2 = GhostBasic(Position2D(4, 2), Direction.Left, 1.0, 2)
- val dot1 = DotBasic(Position2D(2, 1))
- val dot2 = DotBasic(Position2D(3, 1))
- val tunnel1 = Tunnel(Position2D(2, 0), Position2D(1, 9), Direction.Up)
- val tunnel2 = Tunnel(Position2D(1, 9), Position2D(2, 0), Direction.Down)
- val wall = Wall(Position2D(0, 1))
- val map = board(10, 10)
- val dsl = MapDSL(map)
+ val spacMan = SpacManWithLife(Position2D(1, 1), Direction.Right, 0, 1)
+ val ghost1 = GhostBasic(Position2D(1, 2), Direction.Right, 1)
+ val ghost2 = GhostBasic(Position2D(4, 2), Direction.Left, 2)
+ val dotBasic1 = DotBasic(Position2D(2, 1))
+ val dotBasic2 = DotBasic(Position2D(3, 2))
+ val dotPower1 = DotPower(Position2D(2, 2))
+ val fruit = DotFruit(Position2D(3, 1))
+ val tunnel1 = Tunnel(Position2D(2, 0), Position2D(1, 9), Direction.Up)
+ val tunnel2 = Tunnel(Position2D(1, 9), Position2D(2, 0), Direction.Down)
+ val wall = Wall(Position2D(0, 1))
+ val map = board(10, 10)
+ val dsl = MapDSL(map)
import dsl.*
- place a spacMan at position(1, 1)
- place a ghost1 at position(1, 2)
- place a ghost2 at position(4, 2)
- place a dot1 at position(2, 1)
- place a dot2 at position(3, 1)
- place a tunnel1 at position(2, 0)
- place a tunnel2 at position(1, 9)
- place a wall at position(0, 1)
+ place the spacMan
+ place the ghost1
+ place the ghost2
+ place the dotBasic1
+ place the dotBasic2
+ place the dotPower1
+ place the fruit
+ place the tunnel1
+ place the tunnel2
+ place the wall
SimpleGameManager(spacMan, dsl.map)
def createEdgeTestSetup(): SimpleGameManager =
- val spacMan = SpacManBasic(Position2D(1, 1), Direction.Right, 0)
- val ghost1 = GhostBasic(Position2D(2, 2), Direction.Right, 1.0, 1)
+ val spacMan = SpacManWithLife(Position2D(1, 1), Direction.Right, 0, 1)
+ val ghost1 = GhostBasic(Position2D(2, 2), Direction.Right, 1)
val wall1 = Wall(Position2D(1, 2))
val wall2 = Wall(Position2D(2, 1))
val wall3 = Wall(Position2D(3, 2))
@@ -52,198 +45,133 @@ class GameManagerTest extends AnyFlatSpec with Matchers:
import dsl.*
- place a spacMan at position(1, 1)
- place a ghost1 at position(2, 2)
- place a wall1 at position(1, 2)
- place a wall2 at position(2, 1)
- place a wall3 at position(3, 2)
- place a wall4 at position(2, 3)
-
- SimpleGameManager(spacMan, dsl.map)
-
- def createEdgeTestSetup2(): SimpleGameManager =
- val spacMan = SpacManBasic(Position2D(2, 3), Direction.Right, 0)
- val ghost1 = GhostBasic(Position2D(2, 2), Direction.Right, 1.0, 1)
- val wall1 = Wall(Position2D(1, 2))
- val wall2 = Wall(Position2D(2, 1))
- val wall3 = Wall(Position2D(3, 2))
- val map = board(10, 10)
- val dsl = MapDSL(map)
-
- import dsl.*
-
- place a spacMan at position(2, 3)
- place a ghost1 at position(2, 2)
- place a wall1 at position(1, 2)
- place a wall2 at position(2, 1)
- place a wall3 at position(3, 2)
+ place the spacMan
+ place the ghost1
+ place the wall1
+ place the wall2
+ place the wall3
+ place the wall4
SimpleGameManager(spacMan, dsl.map)
- it should "create a GameManager" in:
- val spacMan = SpacManBasic(Position2D(1, 1), Direction.Right, 0)
+ "GameManager" should "create a GameManager" in:
+ val spacMan = SpacManWithLife(Position2D(1, 1), Direction.Right, 0, 1)
val map = board(10, 10)
val gameManager = SimpleGameManager(spacMan, map)
assert(
- gameManager.getSpacMan == spacMan &&
- gameManager.getGameMap == map &&
+ gameManager.getState.spacMan == spacMan &&
+ gameManager.getState.gameMap == map &&
gameManager.isGameOver() == false
)
it should "move SpacMan" in:
- val gameManager = createBasicTestSetup()
- val movedSpacManOpt = gameManager.moveSpacManAndCheck(Direction.Up)
- assert(movedSpacManOpt.isDefined)
- val movedSpacMan = movedSpacManOpt.get
- assert(movedSpacMan.position == Position2D(1, 0))
- assert(gameManager.getGameMap.entityAt(Position2D(1, 1)).getOrElse(Set()).isEmpty)
- assert(gameManager.getGameMap.entityAt(Position2D(1, 0)).getOrElse(Set()).exists(
- _.isInstanceOf[SpacManBasic]
+ val gameManager = createBasicTestSetup()
+ gameManager.moveSpacMan(Direction.Up)
+
+ assert(gameManager.getState.spacMan.position == Position2D(1, 0))
+ assert(gameManager.getState.gameMap.entityAt(Position2D(1, 1)).getOrElse(Set()).isEmpty)
+ assert(gameManager.getState.gameMap.entityAt(Position2D(1, 0)).getOrElse(Set()).exists(
+ _.isInstanceOf[SpacManWithLife]
))
it should "move SpacMan multiple times" in:
- val gameManager = createBasicTestSetup()
- var movedSpacManOpt = gameManager.moveSpacManAndCheck(Direction.Up)
- assert(movedSpacManOpt.isDefined)
- var movedSpacMan = movedSpacManOpt.get
- assert(movedSpacMan.position == Position2D(1, 0))
- assert(gameManager.getGameMap.entityAt(Position2D(1, 1)).getOrElse(Set()).isEmpty)
- assert(gameManager.getGameMap.entityAt(Position2D(1, 0)).getOrElse(Set()).exists(
- _.isInstanceOf[SpacManBasic]
+ val gameManager = createBasicTestSetup()
+ gameManager.moveSpacMan(Direction.Up)
+
+ assert(gameManager.getState.spacMan.position == Position2D(1, 0))
+ assert(gameManager.getState.gameMap.entityAt(Position2D(1, 1)).getOrElse(Set()).isEmpty)
+ assert(gameManager.getState.gameMap.entityAt(Position2D(1, 0)).getOrElse(Set()).exists(
+ _.isInstanceOf[SpacManWithLife]
))
- movedSpacManOpt = gameManager.moveSpacManAndCheck(Direction.Right)
- assert(movedSpacManOpt.isDefined)
- movedSpacMan = movedSpacManOpt.get
- assert(movedSpacMan.position == Position2D(2, 0))
- assert(gameManager.getGameMap.entityAt(Position2D(1, 0)).getOrElse(Set()).isEmpty)
- assert(gameManager.getGameMap.entityAt(Position2D(2, 0)).getOrElse(Set()).exists(
- _.isInstanceOf[SpacManBasic]
+ gameManager.moveSpacMan(Direction.Right)
+
+ assert(gameManager.getState.spacMan.position == Position2D(2, 0))
+ assert(gameManager.getState.gameMap.entityAt(Position2D(1, 0)).getOrElse(Set()).isEmpty)
+ assert(gameManager.getState.gameMap.entityAt(Position2D(2, 0)).getOrElse(Set()).exists(
+ _.isInstanceOf[SpacManWithLife]
))
it should "not move SpacMan if blocked by a wall" in:
- val gameManager = createBasicTestSetup()
- val movedSpacManOpt = gameManager.moveSpacManAndCheck(Direction.Left)
- assert(movedSpacManOpt.isDefined)
- val movedSpacMan = movedSpacManOpt.get
- assert(movedSpacMan.position == Position2D(1, 1))
- assert(gameManager.getGameMap.entityAt(Position2D(1, 1)).getOrElse(Set()).exists(
- _.isInstanceOf[SpacManBasic]
+ val gameManager = createBasicTestSetup()
+ gameManager.moveSpacMan(Direction.Left)
+
+ assert(gameManager.getState.spacMan.position == Position2D(1, 1))
+ assert(gameManager.getState.gameMap.entityAt(Position2D(1, 1)).getOrElse(Set()).exists(
+ _.isInstanceOf[SpacManWithLife]
))
it should "not move SpacMan outside map boundaries" in:
- val gameManager = createBasicTestSetup()
- var movedSpacManOpt = gameManager.moveSpacManAndCheck(Direction.Up)
- assert(movedSpacManOpt.isDefined)
- var movedSpacMan = movedSpacManOpt.get
- assert(movedSpacMan.position == Position2D(1, 0))
- assert(gameManager.getGameMap.entityAt(Position2D(1, 0)).getOrElse(Set()).exists(
- _.isInstanceOf[SpacManBasic]
+ val gameManager = createBasicTestSetup()
+ gameManager.moveSpacMan(Direction.Up)
+
+ assert(gameManager.getState.spacMan.position == Position2D(1, 0))
+ assert(gameManager.getState.gameMap.entityAt(Position2D(1, 0)).getOrElse(Set()).exists(
+ _.isInstanceOf[SpacManWithLife]
))
- movedSpacManOpt = gameManager.moveSpacManAndCheck(Direction.Up)
- assert(movedSpacManOpt.isDefined)
- movedSpacMan = movedSpacManOpt.get
- assert(movedSpacMan.position == Position2D(1, 0))
- assert(gameManager.getGameMap.entityAt(Position2D(1, 0)).getOrElse(Set()).exists(
- _.isInstanceOf[SpacManBasic]
+ gameManager.moveSpacMan(Direction.Up)
+
+ assert(gameManager.getState.spacMan.position == Position2D(1, 0))
+ assert(gameManager.getState.gameMap.entityAt(Position2D(1, 0)).getOrElse(Set()).exists(
+ _.isInstanceOf[SpacManWithLife]
))
it should "move Ghosts" in:
val gameManager = createBasicTestSetup()
- val movedGhosts = gameManager.moveGhosts()
- assert(movedGhosts.head.position != Position2D(1, 2))
- assert(gameManager.getGameMap.entityAt(Position2D(1, 2)).getOrElse(Set()).isEmpty)
- assert(movedGhosts.head.position != Position2D(4, 2))
- assert(gameManager.getGameMap.entityAt(Position2D(4, 2)).getOrElse(Set()).isEmpty)
+ gameManager.moveGhosts()
+
+ assert(gameManager.getState.gameMap.getGhosts.head.position != Position2D(1, 2))
+ assert(gameManager.getState.gameMap.entityAt(Position2D(1, 2)).getOrElse(Set()).isEmpty)
+ assert(gameManager.getState.gameMap.getGhosts.head.position != Position2D(4, 2))
+ assert(gameManager.getState.gameMap.entityAt(Position2D(4, 2)).getOrElse(Set()).isEmpty)
it should "not move Ghosts if it is blocked" in:
val gameManager = createEdgeTestSetup()
+ gameManager.moveGhosts()
- val movedGhosts = gameManager.moveGhosts()
- assert(movedGhosts.exists(_.position == Position2D(2, 2)))
- assert(gameManager.getGameMap.entityAt(Position2D(2, 2)).getOrElse(Set()).exists(
+ assert(gameManager.getState.gameMap.getGhosts.exists(_.position == Position2D(2, 2)))
+ assert(gameManager.getState.gameMap.entityAt(Position2D(2, 2)).getOrElse(Set()).exists(
_.isInstanceOf[GhostBasic]
))
- it should "eat dot and increase score" in:
- val gameManager = createBasicTestSetup()
- val movedSpacManOpt = gameManager.moveSpacManAndCheck(Direction.Right)
- assert(movedSpacManOpt.isDefined)
- val movedSpacMan = movedSpacManOpt.get
- assert(movedSpacMan.position == Position2D(2, 1))
- assert(gameManager.getGameMap.entityAt(Position2D(2, 1)).getOrElse(Set()).exists(
- _.isInstanceOf[SpacManBasic]
- ))
- assert(movedSpacMan.score == DOT_BASIC_SCORE)
-
it should "detect win condition" in:
val gameManager = createBasicTestSetup()
- assert(!gameManager.isWin())
+ gameManager.moveSpacMan(Direction.Right)
- val movedSpacManOpt = gameManager.moveSpacManAndCheck(Direction.Right)
- assert(movedSpacManOpt.isDefined)
- val movedSpacMan = movedSpacManOpt.get
- assert(movedSpacMan.position == Position2D(2, 1))
- assert(gameManager.getGameMap.entityAt(Position2D(1, 1)).getOrElse(Set()).isEmpty)
- assert(gameManager.getGameMap.entityAt(Position2D(2, 1)).getOrElse(Set()).exists(
- _.isInstanceOf[SpacManBasic]
+ assert(gameManager.getState.spacMan.position == Position2D(2, 1))
+ assert(gameManager.getState.gameMap.entityAt(Position2D(1, 1)).getOrElse(Set()).isEmpty)
+ assert(gameManager.getState.gameMap.entityAt(Position2D(2, 1)).getOrElse(Set()).exists(
+ _.isInstanceOf[SpacManWithLife]
))
- assert(movedSpacMan.score == DOT_BASIC_SCORE)
assert(!gameManager.isWin())
- val movedSpacManOpt2 = gameManager.moveSpacManAndCheck(Direction.Right)
- assert(movedSpacManOpt2.isDefined)
- val updatedSpacMan = movedSpacManOpt2.get
- assert(updatedSpacMan.position == Position2D(3, 1))
- assert(gameManager.getGameMap.entityAt(Position2D(2, 1)).getOrElse(Set()).isEmpty)
- assert(gameManager.getGameMap.entityAt(Position2D(3, 1)).getOrElse(Set()).exists(
- _.isInstanceOf[SpacManBasic]
- ))
- assert(updatedSpacMan.score == 2 * DOT_BASIC_SCORE)
- assert(gameManager.isWin())
+ gameManager.moveSpacMan(Direction.Down)
- it should "set game over when colliding with a ghost" in:
- val gameManager = createBasicTestSetup()
- val movedSpacManOpt = gameManager.moveSpacManAndCheck(Direction.Down)
- assert(gameManager.getGameMap.entityAt(Position2D(1, 1)).getOrElse(Set()).isEmpty)
- assert(gameManager.getGameMap.entityAt(Position2D(1, 2)).getOrElse(Set()).exists(
- !_.isInstanceOf[SpacManBasic]
- ))
- assert(!movedSpacManOpt.isDefined)
- assert(gameManager.isGameOver())
-
- it should "teleport SpacMan through tunnel with correct direction" in:
- val gameManager = createBasicTestSetup()
- gameManager.moveSpacManAndCheck(Direction.Right)
- val movedSpacManOpt = gameManager.moveSpacManAndCheck(Direction.Up)
- assert(movedSpacManOpt.isDefined)
- val movedSpacMan = movedSpacManOpt.get
- assert(movedSpacMan.position == Position2D(1, 9) && movedSpacMan.direction == Direction.Up)
- assert(gameManager.getGameMap.entityAt(Position2D(1, 9)).getOrElse(Set()).exists(
- _.isInstanceOf[SpacManBasic]
+ assert(gameManager.getState.spacMan.position == Position2D(2, 2))
+ assert(gameManager.getState.gameMap.entityAt(Position2D(2, 1)).getOrElse(Set()).isEmpty)
+ assert(gameManager.getState.gameMap.entityAt(Position2D(2, 2)).getOrElse(Set()).exists(
+ _.isInstanceOf[SpacManWithLife]
))
+ assert(!gameManager.isWin())
- it should "not teleport SpacMan through tunnel with wrong direction" in:
- val gameManager = createBasicTestSetup()
-
- gameManager.moveSpacManAndCheck(Direction.Up)
- val movedSpacManOpt = gameManager.moveSpacManAndCheck(Direction.Right)
+ gameManager.moveSpacMan(Direction.Right)
- assert(movedSpacManOpt.isDefined)
- val movedSpacMan = movedSpacManOpt.get
- assert(movedSpacMan.position == Position2D(2,0) && movedSpacMan.direction == Direction.Right)
- assert(gameManager.getGameMap.entityAt(Position2D(2,0)).getOrElse(Set()).exists(
- _.isInstanceOf[SpacManBasic]
+ assert(gameManager.getState.spacMan.position == Position2D(3, 2))
+ assert(gameManager.getState.gameMap.entityAt(Position2D(2, 2)).getOrElse(Set()).isEmpty)
+ assert(gameManager.getState.gameMap.entityAt(Position2D(3, 2)).getOrElse(Set()).exists(
+ _.isInstanceOf[SpacManWithLife]
))
+ assert(!gameManager.isWin())
- it should "set game over when ghost moves into SpacMan position" in:
- val gameManager = createEdgeTestSetup2()
-
- assert(!gameManager.isGameOver())
- var attempts = 0
- while !gameManager.isGameOver() && attempts < 10 do
- gameManager.moveGhosts()
- attempts += 1
+ gameManager.moveSpacMan(Direction.Up)
- assert(gameManager.isGameOver())
+ assert(gameManager.getState.spacMan.position == Position2D(3, 1))
+ assert(gameManager.getState.gameMap.entityAt(Position2D(3, 2)).getOrElse(Set()).isEmpty)
+ assert(gameManager.getState.gameMap.entityAt(Position2D(3, 1)).getOrElse(Set()).exists(
+ _.isInstanceOf[SpacManWithLife]
+ ))
+ assert(
+ gameManager.getState.spacMan.score == 2 * DOT_BASIC_SCORE + DOT_POWER_SCORE + DOT_FRUIT_SCORE
+ )
+ assert(gameManager.isWin())
diff --git a/src/test/scala/model/GhostTest.scala b/src/test/scala/model/GhostTest.scala
index 623334c..430c100 100644
--- a/src/test/scala/model/GhostTest.scala
+++ b/src/test/scala/model/GhostTest.scala
@@ -1,17 +1,134 @@
package model
import org.scalatest.flatspec.AnyFlatSpec
+import model.map.GameMapImpl
class GhostTest extends AnyFlatSpec:
- val startposition = Position2D(0, 0)
+ val startposition = Position2D(0, 0)
val startDirection = Direction.Right
- val speed = 1.0
- val id = 1
- val ghost = GhostBasic(startposition, startDirection,speed, id)
+ val id = 1
+ val ghost = GhostBasic(startposition, startDirection, id)
- it should "create a ghost" in:
- assert(ghost.position == startposition && ghost.direction == startDirection && ghost.speed == speed && ghost.id == id)
+ "GhostBasic" should "create a ghost with correct properties" in:
+ assert(
+ ghost.position == startposition &&
+ ghost.direction == startDirection &&
+ ghost.id == id &&
+ ghost.score == 100
+ )
- it should "give next move direction" in:
- val newDirection = ghost.nextMove()
- assert(newDirection.isInstanceOf[Direction])
\ No newline at end of file
+ it should "update position and direction with withPosAndDir" in:
+ val newPos = Position2D(5, 5)
+ val newDir = Direction.Left
+ val updatedGhost = ghost.withPosAndDir(newPos, newDir)
+
+ assert(updatedGhost.position == newPos)
+ assert(updatedGhost.direction == newDir)
+ assert(updatedGhost.id == id)
+ assert(updatedGhost.score == 100)
+
+ it should "always return a Direction from nextMove" in:
+ val gameMap = GameMapImpl(10, 10, Position2D(0, 0), Position2D(1, 1), Map.empty)
+ val spacManPos = Position2D(5, 5)
+ val spacManDir = Direction.Up
+
+ val ghost1 = GhostBasic(Position2D(3, 3), Direction.Right, 1)
+ val ghost2 = GhostBasic(Position2D(3, 3), Direction.Right, 2)
+ val ghost3 = GhostBasic(Position2D(3, 3), Direction.Right, 3)
+ val ghost4 = GhostBasic(Position2D(3, 3), Direction.Right, 4)
+
+ assert(ghost1.nextMove(spacManPos, spacManDir, gameMap).isInstanceOf[Direction])
+ assert(ghost2.nextMove(spacManPos, spacManDir, gameMap).isInstanceOf[Direction])
+ assert(ghost3.nextMove(spacManPos, spacManDir, gameMap).isInstanceOf[Direction])
+ assert(ghost4.nextMove(spacManPos, spacManDir, gameMap).isInstanceOf[Direction])
+
+ "GhostBehavior.forId" should "return ChaseBehavior for id 1" in:
+ assert(GhostBehavior.forId(1) == ChaseBehavior)
+
+ it should "return PredictiveBehavior for id 2" in:
+ assert(GhostBehavior.forId(2) == PredictiveBehavior)
+
+ it should "return RandomBehavior for id 3" in:
+ assert(GhostBehavior.forId(3) == RandomBehavior)
+
+ it should "return MixedBehavior for id 4" in:
+ assert(GhostBehavior.forId(4) == MixedBehavior)
+
+ it should "return ChaseBehavior for any other id" in:
+ assert(GhostBehavior.forId(100) == ChaseBehavior)
+ assert(GhostBehavior.forId(0) == ChaseBehavior)
+ assert(GhostBehavior.forId(-1) == ChaseBehavior)
+
+ // Behaviours tests
+ "Behaviours" should "return the current direction when the ghost is completely blocked" in:
+ val gameMap = GameMapImpl(1, 1, Position2D(1, 1), Position2D(0, 0), Map.empty)
+ val ghost = GhostBasic(Position2D(1, 1), Direction.Right, 1)
+
+ var direction =
+ ChaseBehavior.chooseDirection(GhostContext(ghost, Position2D(2, 2), Direction.Right, gameMap))
+ assert(direction == Direction.Right)
+
+ direction =
+ PredictiveBehavior.chooseDirection(GhostContext(ghost, Position2D(2, 2), Direction.Right, gameMap))
+ assert(direction == Direction.Right)
+
+ direction =
+ RandomBehavior.chooseDirection(GhostContext(ghost, Position2D(2, 2), Direction.Right, gameMap))
+ assert(direction == Direction.Right)
+
+ direction =
+ MixedBehavior.chooseDirection(GhostContext(ghost, Position2D(2, 2), Direction.Right, gameMap))
+ assert(direction == Direction.Right)
+
+ "ChaseBehavior" should "choose the right direction to go to SpacMan" in:
+ val gameMap = GameMapImpl(10, 10, Position2D(0, 0), Position2D(1, 1), Map.empty)
+ val ghost = GhostBasic(Position2D(0, 0), Direction.Right, 1)
+ val spacManPos = Position2D(5, 0)
+
+ val direction =
+ ChaseBehavior.chooseDirection(GhostContext(ghost, spacManPos, Direction.Right, gameMap))
+ assert(direction == Direction.Right)
+
+ "PredictiveBehavior" should "choose the right direction to go to the predicted position of SpacMan" in:
+ val gameMap = GameMapImpl(10, 10, Position2D(0, 0), Position2D(1, 1), Map.empty)
+ val ghost = GhostBasic(Position2D(0, 0), Direction.Right, 2)
+ val spacManPos = Position2D(9, 1)
+ val spacManDir = Direction.Left
+
+ val direction =
+ PredictiveBehavior.chooseDirection(GhostContext(ghost, spacManPos, spacManDir, gameMap))
+ assert(direction == Direction.Left || direction == Direction.Down)
+
+ "RandomBehavior" should "continue in same direction if it's not blocked" in:
+ val gameMap = GameMapImpl(10, 10, Position2D(0, 0), Position2D(1, 1), Map.empty)
+ val ghost = GhostBasic(Position2D(5, 5), Direction.Right, 3)
+
+ val direction =
+ RandomBehavior.chooseDirection(GhostContext(ghost, Position2D(0, 0), Direction.Up, gameMap))
+ assert(direction == Direction.Right)
+
+ it should "choose a random direction if it's blocked" in:
+ val gameMap = GameMapImpl(10, 10, Position2D(0, 0), Position2D(1, 1), Map.empty)
+ val ghost = GhostBasic(Position2D(9, 5), Direction.Right, 3)
+
+ val direction =
+ RandomBehavior.chooseDirection(GhostContext(ghost, Position2D(0, 0), Direction.Up, gameMap))
+ assert(direction != Direction.Right)
+
+ "MixedBehavior" should "chase when distance is greater than threshold" in:
+ val gameMap = GameMapImpl(10, 10, Position2D(0, 0), Position2D(1, 1), Map.empty)
+ val ghost = GhostBasic(Position2D(0, 0), Direction.Right, 4)
+ val spacManPos = Position2D(9, 0)
+
+ val direction =
+ MixedBehavior.chooseDirection(GhostContext(ghost, spacManPos, Direction.Right, gameMap))
+ assert(direction == Direction.Right)
+
+ it should "flee when distance is less than or equal to threshold" in:
+ val gameMap = GameMapImpl(10, 10, Position2D(0, 0), Position2D(1, 1), Map.empty)
+ val ghost = GhostBasic(Position2D(0, 0), Direction.Right, 4)
+ val spacManPos = Position2D(3, 0)
+
+ val direction =
+ MixedBehavior.chooseDirection(GhostContext(ghost, spacManPos, Direction.Right, gameMap))
+ assert(direction == Direction.Down)
diff --git a/src/test/scala/model/SpacManTest.scala b/src/test/scala/model/SpacManTest.scala
index 40472f9..5928663 100644
--- a/src/test/scala/model/SpacManTest.scala
+++ b/src/test/scala/model/SpacManTest.scala
@@ -1,8 +1,9 @@
package model
import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
-class SpacManTest extends AnyFlatSpec:
+class SpacManTest extends AnyFlatSpec with Matchers:
val startposition = Position2D(0, 0)
val startDirection = Direction.Right
val score = 0
@@ -11,7 +12,7 @@ class SpacManTest extends AnyFlatSpec:
it should "create a SpacManBasic" in:
assert(spacMan.position == startposition && spacMan.direction == startDirection && spacMan.score == score)
- it should "add score correctly" in:
+ "SpacManBasic" should "add score correctly" in:
val pointsToAdd = 10
val newSpacMan = spacMan.addScore(pointsToAdd)
assert(newSpacMan.score == score + pointsToAdd)
@@ -20,3 +21,17 @@ class SpacManTest extends AnyFlatSpec:
val pointsToAdd = -10
val newSpacMan = spacMan.addScore(pointsToAdd)
assert(newSpacMan.score == score)
+
+ val spacManWithLife = SpacManWithLife(startposition, startDirection, score)
+
+ "SpacManWithLife" should "has a default life" in:
+ spacManWithLife.lives shouldBe 3
+
+ it should "add correctly a life" in:
+ val updatedSpacMan = spacManWithLife.addLife()
+ updatedSpacMan.lives shouldBe 4
+
+ it should "remove correctly a life" in:
+ val updatedSpacMan = spacManWithLife.removeLife()
+ updatedSpacMan.lives shouldBe 2
+
diff --git a/src/test/scala/model/WallBuilderTest.scala b/src/test/scala/model/WallBuilderTest.scala
index 0b28627..0d357d7 100644
--- a/src/test/scala/model/WallBuilderTest.scala
+++ b/src/test/scala/model/WallBuilderTest.scala
@@ -28,6 +28,8 @@ class WallBuilderTest extends AnyFlatSpec with Matchers:
Wall(Position2D(-3, 0))
)
+ walls shouldBe assertWalls
+
it should "create an vertical Walls" in:
val endPos = Position2D(0, 3)
val walls = WallBuilder.createWalls(startPosition, endPos)
diff --git a/src/test/scala/model/map/MapTest.scala b/src/test/scala/model/map/MapTest.scala
index 880134a..79ed3e7 100644
--- a/src/test/scala/model/map/MapTest.scala
+++ b/src/test/scala/model/map/MapTest.scala
@@ -6,15 +6,13 @@ import model.MapDSL
import model.Wall
import model.Position2D
import model.board
-import org.scalactic.anyvals.PosInt
-import model.Position2DTest
import model.SpacManBasic
import model.GhostBasic
import model.Direction
import model.GameEntity
import model.DotBasic
import model.WallBuilder
-import model.genericWall
+import model.DotFruit
class MapTest extends AnyFlatSpec with Matchers:
@@ -30,43 +28,49 @@ class MapTest extends AnyFlatSpec with Matchers:
val dsl = MapDSL(board(5, 5))
val wall = Wall(Position2D(2, 1))
val pacMan = SpacManBasic(Position2D(3, 1), Direction.Right, 0)
- val ghost = GhostBasic(Position2D(4, 1), Direction.Right, 1.0, 1)
+ val ghost = GhostBasic(Position2D(4, 1), Direction.Right, 1)
import dsl.*
- place a wall at position(2, 1)
- place a pacMan at position(3, 1)
- place a ghost at position(4, 1)
+ place the wall
+ place the pacMan
+ place the ghost
+
+ it should "create a generic entity" in:
+ val dsl = MapDSL(board(5, 5))
+ import dsl.*
+
+ place a genericDotFruit at position(2, 1)
+ dsl.map.entityAt(position(2, 1)) shouldBe Right(Set(DotFruit(Position2D(2, 1))))
it should "create and place a set of Wall" in:
val dsl = MapDSL(map)
import dsl.*
- place a genericWall() from position(0, 0) to position(0, 5)
+ place a genericWall from position(0, 0) to position(0, 5)
dsl.map.getWalls shouldBe (WallBuilder.createWalls(position(0, 0), position(0, 5)))
it should "create nothing because entities are not walls" in:
val dsl = MapDSL(map)
- val dot = DotBasic(Position2D(0, 0))
import dsl.*
- place a dot from position(0, 0) to position(0, 5)
+ place a genericDot from position(0, 0) to position(0, 5)
dsl.map.getDots shouldBe Set.empty
"Map" should "get entity of a position" in:
val dsl = MapDSL(map)
val wall = Wall(Position2D(2, 1))
- val pacMan = SpacManBasic(Position2D(3, 1), Direction.Right, 0)
- val ghost = GhostBasic(Position2D(4, 1), Direction.Right, 1.0, 1)
+ val pacMan = SpacManBasic(Position2D(4, 1), Direction.Right, 0)
+ val ghost = GhostBasic(Position2D(4, 1), Direction.Right, 1)
import dsl.*
- place a wall at position(2, 1)
- place a pacMan at position(4, 1)
- place a ghost at position(4, 1)
+ place the wall
+ place the pacMan
+ place the ghost
dsl.map.entityAt(position(2, 1)) shouldBe Right(Set(wall))
dsl.map.entityAt(position(4, 1)) shouldBe Right(Set(ghost, pacMan))
@@ -79,7 +83,7 @@ class MapTest extends AnyFlatSpec with Matchers:
it should "not place a game entity for invalid position" in:
val wall = Wall(Position2D(-1, -1))
- val result = map.place(Position2D(-1, -1), wall)
+ val result = map.place(wall)
result.isLeft shouldBe true
it should "place all the entities in the map" in:
@@ -103,26 +107,24 @@ class MapTest extends AnyFlatSpec with Matchers:
val wall1 = Wall(Position2D(5, 1))
val wall2 = Wall(Position2D(4, 1))
val wall3 = Wall(Position2D(3, 1))
+ val walls = Set(wall1, wall2, wall3)
import dsl.*
- place a wall1 at position(5, 1)
- place a wall2 at position(4, 1)
- place a wall3 at position(3, 1)
+ place multiple walls
dsl.map.getWalls shouldBe Set(wall1, wall2, wall3)
it should "return the set of ghosts" in:
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 ghost1 = GhostBasic(Position2D(5, 1), Direction.Right, 1)
+ val ghost2 = GhostBasic(Position2D(4, 1), Direction.Right, 2)
+ val ghost3 = GhostBasic(Position2D(3, 1), Direction.Right, 3)
+ val ghosts = Set(ghost1, ghost2, ghost3)
import dsl.*
- place a ghost1 at position(5, 1)
- place a ghost2 at position(4, 1)
- place a ghost3 at position(3, 1)
+ place multiple ghosts
dsl.map.getGhosts shouldBe Set(ghost1, ghost2, ghost3)
@@ -131,12 +133,11 @@ class MapTest extends AnyFlatSpec with Matchers:
val dot1 = DotBasic(Position2D(5, 1))
val dot2 = DotBasic(Position2D(4, 1))
val dot3 = DotBasic(Position2D(3, 1))
+ val dots = Set(dot1, dot2, dot3)
import dsl.*
- place a dot1 at position(5, 1)
- place a dot2 at position(4, 1)
- place a dot3 at position(3, 1)
+ place multiple dots
dsl.map.getDots shouldBe Set(dot1, dot2, dot3)
@@ -152,8 +153,8 @@ class MapTest extends AnyFlatSpec with Matchers:
import dsl.*
- place a wall at position(2, 1)
- place a pacMan at position(3, 1)
+ place the wall
+ place the pacMan
dsl.map.canMove(pacMan, Direction.Right) shouldBe true
@@ -164,8 +165,8 @@ class MapTest extends AnyFlatSpec with Matchers:
import dsl.*
- place a wall at position(2, 1)
- place a pacMan at position(3, 1)
+ place the wall
+ place the pacMan
dsl.map.canMove(pacMan, Direction.Left) shouldBe false
@@ -176,8 +177,8 @@ class MapTest extends AnyFlatSpec with Matchers:
import dsl.*
- place a wall at position(2, 1)
- place a pacMan at position(0, 0)
+ place the wall
+ place the pacMan
dsl.map.canMove(pacMan, Direction.Left) shouldBe false
@@ -186,7 +187,7 @@ class MapTest extends AnyFlatSpec with Matchers:
import dsl.*
- place a genericWall() from position(0, 0) to position(5, 0)
+ place a genericWall from position(0, 0) to position(5, 0)
dsl.map.remove(Wall(position(1, 0)))
it should "return an invalid position" in:
@@ -194,7 +195,7 @@ class MapTest extends AnyFlatSpec with Matchers:
import dsl.*
- place a genericWall() from position(0, 0) to position(5, 0)
+ place a genericWall from position(0, 0) to position(5, 0)
dsl.map.remove(Wall(position(-1, 0))) shouldBe Left("Invalid position" + position(
-1,
0
@@ -205,31 +206,61 @@ class MapTest extends AnyFlatSpec with Matchers:
import dsl.*
- place a genericWall() from position(0, 0) to position(5, 0)
+ place a genericWall from position(0, 0) to position(5, 0)
dsl.map.remove(DotBasic(position(0, 0))) shouldBe Left("No entity found")
it should "return replace the pacman to the new position" in:
- val dsl = MapDSL(map)
- val pacMan = SpacManBasic(Position2D(0, 0), Direction.Right, 0)
+ val dsl = MapDSL(map)
+ val pacMan = SpacManBasic(Position2D(0, 0), Direction.Right, 0)
val movedSpacMan = SpacManBasic(Position2D(2, 1), Direction.Right, 0)
import dsl.*
- place a pacMan at position(0, 0)
- place a genericWall() from position(0, 0) to position(5, 0)
+ place the pacMan
+ place a genericWall from position(0, 0) to position(5, 0)
dsl.map.replaceEntityTo(pacMan, movedSpacMan).isRight shouldBe true
it should "return an invalid position while trying to replace an entity" in:
- val dsl = MapDSL(map)
- val pacMan = SpacManBasic(Position2D(0, 0), Direction.Right, 0)
+ val dsl = MapDSL(map)
+ val pacMan = SpacManBasic(Position2D(0, 0), Direction.Right, 0)
val movedSpacMan = SpacManBasic(Position2D(-1, 0), Direction.Right, 0)
import dsl.*
- place a pacMan at position(0, 0)
- place a genericWall() from position(0, 0) to position(5, 0)
+ place the pacMan
+ place a genericWall from position(0, 0) to position(5, 0)
dsl.map.replaceEntityTo(pacMan, movedSpacMan) shouldBe Left(
"Invalid position" + position(-1, 0)
)
+
+ it should "have a default SpacMan spawnPoint" in:
+ map.spawnPoint shouldBe Position2D(0, 0)
+
+ it should "be possible to create a map with a SpacMan spawnPoint" in:
+ val newMap = board(10, 10, Position2D(2, 2))
+ newMap.spawnPoint shouldBe Position2D(2, 2)
+
+ it should "throw and IllegalArgumentException because of SpacMan spawnPoint out of map" in:
+ intercept[IllegalArgumentException]
+
+ it should "have a default Ghost spawnPoint" in:
+ map.ghostSpawnPoints shouldBe Set(
+ Position2D(1, 1),
+ Position2D(2, 1),
+ Position2D(1, 2),
+ Position2D(2, 2)
+ )
+
+ it should "be possible to create a map with a Ghost spawnPoint" in:
+ val newMap = board(10, 10, Position2D(2, 2), Position2D(3, 3))
+ newMap.ghostSpawnPoints shouldBe Set(
+ Position2D(3, 3),
+ Position2D(4, 3),
+ Position2D(3, 4),
+ Position2D(4, 4)
+ )
+
+ it should "throw and IllegalArgumentException because of Ghost spawnPoint out of map" in:
+ intercept[IllegalArgumentException]