Implementazione - Babini Pier Costante
Il codice realizzato durante lo sviluppo del progetto interessa principalmente le seguenti componenti:
- Cell: implementazione completa del package
Cell, comprendenteCellFactorypensata per la creazione semplificata delle celle di diverse tipologie ed il sottopackage dellaCellPhysics, che definisce la fisica delle celle. - WaterFlowService: sviluppo del componente di servizio che gestisce la dinamica del flusso dell'acqua tra le celle.
- State: implementazione del package
State, che include la gestione deiSimulationParamsscelti dall'utente e la definizione dello stato della simulazioneSimulationState. - SimulationStore: realizzazione del componente incaricato della memorizzazione e gestione dello stato della simulazione.
- SimulationEngine: sviluppo del motore di simulazione che definisce la logica di terminazione della simulazione e di aggiornamento dello stato.
- SimulationScheduler: implementazione del componente che gestisce l'esecuzione dei task della simulazione ed abilita la modifiche della
SimulationSpeed. - View: sviluppo dei singoli
componentdella view e deipanelche ne fanno uso.
Model
Gli elementi appartenenti al model sono stati sviluppati in modo da essere immutabili, seguendo i principi della programmazione funzionale. Ciò ha anche permesso di creare componenti più semplici da testare e mantenere, riducendo la complessità legata alla gestione dello stato.
Cell
Nel package Cell è stato definito AbsorptionRate, un componente pensato per modellare la gestione del tasso di assorbimento delle celle che sfrutta un opaque type, feature che consente di non esporre direttamente la sua rappresentazione sottostante. Questo approccio migliora l'incapsulamento e la sicurezza del tipo, impedendo l'uso improprio del valore sottostante.
object AbsorptionRate:
opaque type AbsorptionRate = Double
def apply(value: Double): AbsorptionRate = value
extension (rate: AbsorptionRate)
def value: Double = rate
def decrease(amount: Double): AbsorptionRate =
AbsorptionRate(math.max(0.0, rate - amount))Sono stati implementati anche extension methods, tra i quali quello per definire il comportamento di degradazione del tasso di assorbimento, gestendo in maniera sicura e controllata la logica di aggiornamento del valore.
Nello stesso package, CellFactory implementa il Factory Pattern per disaccoppiare la logica di creazione delle celle dalla loro rappresentazione concreta. Ciò rispetta il Single Responsibility Principle (SRP), centralizzando la logica di costruzione delle celle in un unico punto.
enum CellType:
case HouseWithConcrete, HouseWithGrass, Square, Field
object CellFactory:
//...
def apply(cellType: CellType)(using physics: CellPhysics): Cell =
val config = configs(cellType)
val obstacles = Seq.fill(config.obstacleCount)(DefaultObstacle)
val altitude = rand.between(config.altitudeRange._1, config.altitudeRange._2)
Cell(
dimensions = DefaultCellDimension,
obstacles = obstacles,
altitude = altitude,
absorption = config.absorption,
cellType = cellType
)Per selezionare il tipo di cella desiderato è stata definta un'enumerazione CellType, utilizzata come chiave per recuperare le configurazioni specifiche.
Altro aspetto dell'implementazione è il Dependency Injection (DIP), infatti viene passata un'implementazione di CellPhysics, come parametro contestuale nella creazione delle celle. Ciò consente al compilatore di ottenere automaticamente un'istanza di CellPhysics definita con given, rendendo il codice più pulito e flessibile.
In Cell, come nel resto del model, è stata tenuta in considerazione l'immutabilità, un principio fondamentale della programmazione funzionale. Invece di modificare lo stato interno di una cella, ogni aggiornamento (es. updateWater) crea una nuova istanza della cella con i valori aggiornati. Questo previene side-effects e rende il comportamento del sistema prevedibile e più facile da testare.
def updateWater(incomingWater: Double): Cell =
val (absorbedFromIncoming, newAbsorption) = physics.calculateAbsorption(incomingWater, absorption)
val newVolume = waterVolume + incomingWater - absorbedFromIncoming
copy(waterVolume = math.max(0.0, newVolume), absorption = newAbsorption)Nell'esempio viene creata una nuova istanza di Cell con i valori aggiornati, sfruttando il metodo copy generato automaticamente dal case class.
Fisica delle celle
La fisica delle celle è stata definita in cell.physics, dove è stata implementata con l'ausilio dello Strategy Pattern. In particolare, sono stati creati diversi calcolatori specializzati per gestire aspetti specifici della fisica delle celle:
- CellPhysics: è l'interfaccia di strategia principale che definisce le operazioni per calcolare altezza dell'acqua, velocità ed assorbimento.
- DefaultCellPhysics: è l'implementazione concreta della strategia. Invece di implementare tutta la logica al suo interno, compone altre piccole strategie specializzate (
WaterHeightCalculator,WaterSpeedCalculator,AbsorptionCalculator).
Questo design segue i principi SOLID, in particolare il principio SRP, poiché ogni calcolatore è responsabile di un'unica funzione, e il principio OCP, in quanto il sistema è aperto all'estensione, essendo possibile aggiungere nuove strategie di calcolo, ma chiuso alle modifiche del codice esistente.
Tra i calcolatori presenti, quello per il calcolo dell'altezza dell'acqua in una cella è particolarmente interessante. Calcola l'altezza dell'acqua in una cella considerando gli ostacoli presenti come un problema di "riempimento a strati". L'algoritmo simula il modo in cui l'acqua si distribuisce in un contenitore con oggetti di diverse altezze, sfruttando la tail recursion ed il pattern-matching di Scala per gestire elegantemente i casi base e ricorsivi.
@tailrec
private def calculateLayeredWaterHeight(
remainingVolume: Double,
currentHeight: Double,
availableArea: Double,
previousHeight: Double,
remainingObstacles: Seq[Obstacle]
): Double =
remainingObstacles match
case Nil =>
if availableArea > 0.0 then currentHeight + (remainingVolume / availableArea)
else currentHeight
case obstacle +: tail =>
val layerHeight = obstacle.height - previousHeight
val volumeToFillLayer = layerHeight * availableArea
if remainingVolume <= volumeToFillLayer then
previousHeight + (remainingVolume / availableArea)
else
val newRemainingVolume = remainingVolume - volumeToFillLayer
val newAvailableArea = availableArea - obstacle.surface
if newAvailableArea <= 0.0 then obstacle.height
else
calculateLayeredWaterHeight(newRemainingVolume, obstacle.height, newAvailableArea, obstacle.height, tail)Ciò consente di calcolare in modo efficiente l'altezza dell'acqua, tenendo conto in maniera abbastanza simile alla realtà della presenza di ostacoli che riducono lo spazio disponibile.
WaterFlowService
Un ulteriore elemento degno di nota in questo package è il WaterFlowService. Come suggerisce il nome, si occupa del calcolo del flusso dell'acqua tra le celle. Al suo interno contiene inoltre la logica di calcolo del flusso, che è possibile ridefinire in base alle esigenze future, seguendo il principio OCP. Il processo è suddiviso in due fasi distinte:
- Calcolo dei Trasferimenti: il sistema itera su tutte le celle che contengono acqua (
getNotEmptyCells) e, per ogni cella, calcola la quantità di acqua da trasferire ai suoi vicini. Questa fase non modifica lo stato di alcuna cella, ma produce una mappa di "delta" (Map[Coordinate, Double]), dove ogni voce rappresenta la variazione di volume d'acqua per una data coordinata, positiva se riceve acqua, negativa se la cede. - Applicazione dei Trasferimenti: terminati i calcoli, il sistema applica i delta calcolati a ogni cella, creando una nuova grid con lo stato aggiornato.
Questo approccio garantisce che il calcolo del trasferimento da una cella A a una cella B non sia influenzato da un altro trasferimento (es. da C ad A) avvenuto nello stesso "tick". Tutti i calcoli si basano sullo stato iniziale della griglia, rendendo il risultato deterministico e indipendente dall'ordine di iterazione.
In questo contesto, il metodo calculateAllTransfers sfrutta in maniera efficace la collection API di Scala, in particolare groupMapReduce.
private def calculateAllTransfers(grid: DistanceGrid[Cell]): Map[Coordinate, Double] =
val transfers =
for
(sourceCoord, sourceCell) <- grid.getNotEmptyCells
neighborCells = grid.getNotFullNeighbors(sourceCoord)
transfer <- if sourceCell.residualCapacity == 0.0 then
distributeExcessWater(sourceCoord, sourceCell.waterVolume - sourceCell.maxAllowed, neighborCells)
else
calculateStandardTransfers(sourceCoord, sourceCell, neighborCells)
yield transfer
transfers.groupMapReduce(_._1)(_._2)(_ + _)Genera una sequenza di tuple (Coordinate, Double), dove possono esserci più voci per la stessa coordinata, ad esempio nel caso in cui una cella che riceve acqua da più vicini. In questo contesto groupMapReduce permette in maniera efficiente di eseguire tre operazioni in una sola passata:
group: raggruppa le tuple per coordinata (_._1).map: estrae il valore del trasferimento (_._2) da ogni tupla.reduce: somma (_ + _) tutti i trasferimenti per la stessa coordinata.
Il risultato è una Map[Coordinate, Double] che rappresenta il bilancio netto di acqua per ogni cella. Il codice mostra anche un esempio di metodo che fa uso del currying in Scala, definendo i parametri in maniera separata, al fine di migliorare la leggibilità del codice.
Un'altra caratteristica interessante di questo componenete è il sistema di distribuzione dell'acqua in eccesso distributeExcessWater, che viene utilizzato quando una cella ha superato la sua capacità massima.
private def distributeExcessWater(
sourceCoord: Coordinate,
amount: Double,
recipients: Seq[(Coordinate, Cell)]
): Seq[(Coordinate, Double)] =
if amount <= 0 || recipients.isEmpty then Seq.empty
else
val totalCapacity = recipients.map(_._2.residualCapacity).sum
val transfers = recipients.collect:
case (coord, cell) if cell.residualCapacity > 0 =>
val proportion = cell.residualCapacity / totalCapacity
val transfer = math.min(amount * proportion, cell.residualCapacity)
(coord, transfer)
val totalTransferred = transfers.map(_._2).sum
(sourceCoord, -totalTransferred) +: transfers.filter(_._2 > 0)Tale metodo entra in azione determinando la capacità residua totale dei vicini e calcolando la proporzione di acqua che ciascuno può ricevere. In questo modo, l'acqua in eccesso viene distribuita in modo equo tra le celle che possono ancora assorbirla, evitando di superare la loro capacità massima. L'output del metodo è una sequenza di tuple Seq[(Coordinate, Cell)] che indicano la quantità di acqua che ogni cella riceve, insieme alla quantità totale ceduta dalla cella sorgente.
SimulationStore
SimulationStore è il componente responsabile della gestione e memorizzazione dello stato globale della simulazione. Sebbene la programmazione funzionale incoraggi l'immutabilità, ogni applicazione interattiva ha bisogno di un "luogo" dove lo stato corrente risiede e può essere aggiornato. SimulationStore permette di confinare e controllare la mutabilità in un punto specifico, in modo robusto e testabile.
private trait Store[T]:
def update(f: T => T): Unit
private final class VarStore[T](initial: T) extends Store[T]:
private var state: T = initial
def update(f: T => T): Unit = state = f(state)
sealed trait SimulationStore:
def updateSimulationState(f: Option[SimulationState] => Option[SimulationState]): Unit
def updateTimer(f: Timer => Timer): Unit
private final class DefaultSimulationStore(
private val simulationState: Store[Option[SimulationState]],
private val timer: Store[Timer]
) extends SimulationStore:
def updateSimulationState(f: Option[SimulationState] => Option[SimulationState]): Unit =
simulationState.update(f)
def updateTimer(f: Timer => Timer): Unit =
timer.update(f)
object SimulationStore:
def apply(): SimulationStore =
DefaultSimulationStore(
VarStore[Option[SimulationState]](None),
VarStore[Timer](Timer())
)
given defaultStore: SimulationStore = apply()Se in futuro si volesse cambiare il meccanismo di storage (ad esempio, usando un AtomicReference per la concorrenza), basterebbe creare una nuova implementazione di Store[T] senza dover modificare DefaultSimulationStore. Altro aspetto di questa implementazione, viene definita un'istanza "canonica" di SimulationStore come valore contestuale, ciò permette ad altri componenti di ottenere un'istanza di SimulationStore in modo implicito e sicuro, semplicemente dichiarandola con using o summon.
Controller
In questo package mi sono occupato principalmente dell'implementazione del SimulationEngine e del SimulationScheduler, definendo anche classi di supporto come SimulationSpeed, necessaria a strutturare le diverse velocità di esecuzione della simulazione.
SimulationEngine
Il SimulationEngine rappresenta il "core" della logica di simulazione. La sua responsabilità è quella di calcolare l'evoluzione dello stato del mondo, tick dopo tick, senza però occuparsi di come questo stato viene memorizzato o visualizzato.
def initialState: SimulationState =
val cellGrid = MapGenerator.generateBasicGround(params.gridSize.value)
val weatherGrid = MapGenerator.generateSkyGrid(params.gridSize.value, params.weatherCellPercentage.asRatio)
SimulationState(cellGrid, weatherGrid, 0.0)
def computeNextState(currentState: SimulationState): SimulationState =
val weatherAffectedGrid = weatherEffectService.applyWeatherEffects(currentState.cellGrid, currentState.weatherGrid)
val updatedCellGrid = waterFlowService.calculateWaterFlow(weatherAffectedGrid)
val floodPercentage = DamageCalculator.floodedMapPercentage(updatedCellGrid, params.floodThreshold.value)
SimulationState(updatedCellGrid, currentState.weatherGrid, floodPercentage)
def isSimulationComplete(state: SimulationState): Boolean =
state.percentageFlooded >= params.terminationFloodPercentage.value
def regenerateGroundGrid(oldState: SimulationState): SimulationState =
val cellGrid = MapGenerator.generateGroundGrid(params.gridSize.value)
oldState.copy(cellGrid = cellGrid)Questo componente incapsula quindi la logica di calcolo dello stato della simulazione ed è progettato per essere facilmente testabile. È infatti pienamente immutabile, ogni metodo che modifica lo stato della simulazione ritorna una nuova istanza di SimulationState.
SimulationScheduler
Sebbene sia stata definita la logica di aggiornamento dello stato, il SimulationEngine non si occupa di quando o con quale frequenza questa logica viene eseguita. Questo compito è delegato al SimulationScheduler, che gestisce il timing e la frequenza degli aggiornamenti. Lo scheduler è stato implementato come wrapper di un ScheduledExecutorService di Java. Oltre alle normali funzionalità di scheduling, fornisce anche la possibilità di modificare la velocità di esecuzione dei task della simulazione in modo dinamico, anche durante la loro esecuzione.
enum SchedulerState:
case Idle, Running, Paused
//...
private def scheduleAllTasks(): Unit =
scheduledTasks = tasks.map:
case (task, baseInterval) =>
val adjustedInterval = FiniteDuration((baseInterval / speedMultiplier).toLong, TimeUnit.MILLISECONDS)
executor.scheduleAtFixedRate(
() => task(),
0,
adjustedInterval.toMillis,
TimeUnit.MILLISECONDS
)
def start(): Unit =
state match
case SchedulerState.Idle | SchedulerState.Paused =>
cancelAllTasks()
scheduleAllTasks()
state = SchedulerState.Running
case SchedulerState.Running =>
def pause(): Unit =
state match
case SchedulerState.Running =>
cancelAllTasks()
state = SchedulerState.Paused
case _ =>
//...
def addTask(task: () => Unit, speed: SimulationSpeed): Unit =
tasks = (task, speed.tickInterval) :: tasks
if state == SchedulerState.Running then
cancelAllTasks()
scheduleAllTasks()
//...Questo componente permette quindi di esporre all'esterno una comoda interfaccia per gestire l'esecuzione dei task e modificarne la velocità. Fa uso del pattern-matching per gestire i diversi stati dello scheduler (Idle, Running, Paused), rendendo il codice più leggibile e mantenibile.
Lo scheduler sfrutta anche la potenza di calcolo della macchina in maniera ottimizzata, allocando dinamicamente un numero di thread adeguato, in base ai task previsti dal programma ed alle risorse disponibili.
// SimulationConstants.scala
val minCoreRequired = 1
val coreDivisionFactor = 4
private val coreNumber = Runtime.getRuntime.availableProcessors() / coreDivisionFactor
private val executor: ScheduledExecutorService =
Executors.newScheduledThreadPool(math.max(minCoreRequired, coreNumber))View
La UI è costruita da un insieme di panels, creati assemblando piccoli componenti riutilizzabili, ognuno con una singola responsabilità (SRP). Per fare ciò un object UIComponent agisce come una Factory semplificata, consentendo di creare elementi di Swing standardizzati (panel, button, label), garantendo quindi uno stile coerente in tutto il programma, definito in UIConfig.
Per questi elementi base sono stati definiti degli extension methods di Scala 3, in modo da arricchire le classi Swing esistenti in modo funzionale.
extension (button: JButton)
def withAction(action: => Unit): JButton =
button.addActionListener(_ => action)
buttonPer quanto riguarda la definizione dei componenti più complessi, come CellTypeSelector, SpeedToggleButton o WeatherToggleButton, è stato introdotto un trait UIComponent che implementa un'interfaccia comune che i componenti devono avere. Come anticipato, questi componenti sono stati in seguito assemblati in pannelli più grandi, come SimulationPanel, che costituiscono la UI principale dell'applicazione.
Fra le caratteristiche salienti dei pannelli vi è l'uso di dependency injection per la gestione del caching delle immagini. Grazie a questa feature di Scala, è possibile ricevere automaticamente un'istanza di ImageCache per CellType e Weather, già definite in NavigationController.
class SimulationPanel(params: SimulationParams, navigationController: NavigationController)(using
imageCache: ImageCache[CellType],
weatherImageCache: ImageCache[Weather]
) extends UIComponent[JPanel] with SimulationListener:Ciò consente anche al programma di precaricare le immagini una sola volta all'avvio, migliorando le prestazioni.
Infine, in SimulationPanel viene implementato il pattern Observer, mediante il trait SimulationListener. Ciò permette a SimulationPanel di essere notificato dal SimulationController ogni volta che lo stato della simulazione cambia, aggiornando di conseguenza la visualizzazione mostrata all'utente.