Skip to content

Implementazione - Babini Pier Costante

Il codice realizzato durante lo sviluppo del progetto interessa principalmente le seguenti componenti:

  • Cell: implementazione completa del package Cell, comprendente CellFactory pensata per la creazione semplificata delle celle di diverse tipologie ed il sottopackage della CellPhysics, 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 dei SimulationParams scelti dall'utente e la definizione dello stato della simulazione SimulationState.
  • 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 component della view e dei panel che 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.

scala
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.

scala
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.

scala
  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.

scala
@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.

scala
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:

  1. group: raggruppa le tuple per coordinata (_._1).
  2. map: estrae il valore del trasferimento (_._2) da ogni tupla.
  3. 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.

scala
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.

scala
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.

scala
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.

scala
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.

scala
// 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.

scala
extension (button: JButton)
  def withAction(action: => Unit): JButton =
    button.addActionListener(_ => action)
    button

Per 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.

scala
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.