Skip to content

Implementazione - Morelli Marco

Il codice prodotto durante lo svolgimento del progetto riguarda principalmente le seguenti parti:

  • Grid: contiene l'astrazione di una griglia 2D immutabile e tipizzata Grid[T] e la sua estensione DistanceGrid[T] con supporto al calcolo delle distanze e alla ricerca dei vicini.
  • DistanceGrid: contiene l'implementazione concreta MapBasedDistanceGrid[T] che decora MapBasedGrid[T] con funzionalità aggiuntive.
  • WeatherService: contiene la logica che regola l'interazione tra la griglia delle celle DistanceGrid[Cell] e quella delle condizioni meteorologiche Grid[Weather].
  • Simulation Controller: è il controllore principale della simulazione. Gestisce l'avvio, la pausa, lo stop, la velocità, l'aggiornamento delle celle e la generazione di report o mappe casuali.
  • Simulation Listener: è un'interfaccia che rappresenta chi “ascolta” gli eventi della simulazione come l'interfaccia grafica. Riceve notifiche quando cambia lo stato, il timer, una cella o quando la simulazione termina.
  • Controller Factory: crea un nuovo SimulationController configurato con tutti i servizi e componenti necessari (engine, store, scheduler)
  • View: implementazione del package util.resource, navigationController e parte dei componenti del package component.

Model

Gli elementi appartenenti al model sono stati sviluppati in modo da essere immutabili, seguendo i principi della programmazione funzionale.

Grid

GridElement funge da marker trait, tipo base astratto senza metodi, per tutti gli elementi che possono occupare la griglia. Aderisce ai principi SOLID: Open/Closed Principle e Single Responsibility Principle.

scala
trait GridElement

L'intero sistema delle griglie è basato su un parametro di tipo che definisce la natura degli elementi contenuti. T rappresenta un parametro di tipo generico, la griglia non conosce a priori il tipo esatto degli elementi che conterrà, ma impone che essi derivino da GridElement. Il simbolo T <: GridElement è un upper type bound (vincolo di tipo superiore). Ogni tipo T che voglia essere utilizzato come elemento della griglia deve estendere GridElement; ciò consente di mantenere un contratto tipato e sicuro, senza sacrificare la flessibilità.

scala
trait Grid[T <: GridElement]:

Il trait Grid definisce l'interfaccia generale per una griglia, indipendentemente dal tipo di elementi T che contiene, garantendo astrazione e immutabilità.

scala
trait Grid[T <: GridElement]:
def width: Int
def height: Int
def getElementAt(coordinate: Coordinate): Option[T]

La companion object Grid fornisce metodi factory che creano istanze di Grid senza bisogno di un'istanza esistente. L'uso di Iterator.tabulate genera in modo funzionale tutte le coppie di coordinate senza mutazioni. Questo approccio è lazy ed efficiente in memoria, generando le coppie on-demand invece di materializzare immediatamente l'intera struttura.

scala
object Grid:
  def filledWith[T <: GridElement](width: Int, height: Int, element: T): Grid[T] =
    val elementsMap =
      Iterator.tabulate(width, height)((x, y) => Coordinate(x, y) -> element).flatten.toMap
    MapBasedGrid(width, height, elementsMap)

DistanceGrid

DistanceGrid estende la funzionalità della griglia introducendo operazioni avanzate per la gestione della simulazione di allagamenti: calcolo delle distanze tra celle, ricerca di celle adiacenti non completamente allagate, e trasformazioni funzionali. Si tratta di un'interfaccia funzionale dove ogni operazione restituisce una nuova istanza della griglia invece di modificare quella esistente.

scala
trait DistanceGrid[T <: GridElement] extends Grid[T]:
  def getNotFullNeighbors(coordinate: Coordinate): Seq[(Coordinate, T)]
  def overwrite(coordinate: Coordinate, element: T): DistanceGrid[T]
  def map(f: (Coordinate, T) => T): DistanceGrid[T]
  def getNotEmptyCells: Seq[(Coordinate, T)]

MapBasedDistanceGrid rappresenta l'implementazione concreta di DistanceGrid con supporto al calcolo delle distanze. L'architettura si basa su due pilastri fondamentali:

  1. Export clause: La clausola export è una funzionalità che implementa il pattern Delegation a livello sintattico, invece di ridefinire manualmente metodi wrapper. La dichiarazione export grid.{elementsMap, getElementAt, height, width} delega automaticamente questi membri, eliminando boilerplate e riducendo la superficie di errore.
  2. Composition Over Inheritance: MapBasedDistanceGrid non eredita da MapBasedGrid, ma lo contiene come dipendenza privata private val grid. Questo approccio favorisce flessibilità, l'implementazione interna può cambiare senza modificare l'interfaccia pubblica.
scala
final case class MapBasedDistanceGrid[T <: GridElement](
    private val grid: MapBasedGrid[T]
) extends DistanceGrid[T]:
  export grid.{elementsMap, getElementAt, height, width}

Calcolo delle distanze e ricerca dei vicini

Il metodo bfs implementa una ricerca in ampiezza (Breadth-First Search) per calcolare la distanza minima di ogni cella dalla lista di celle notFullCells. L'uso della ricorsione tail-recursive, annotata con @tailrec, garantisce che la funzione sia ricorsiva in coda e sia ottimizzata dal compilatore evitando l'overflow dello stack e rendendo l'algoritmo efficiente anche per griglie di grandi dimensioni.

scala
    @tailrec
    def bfs(
        queue: Queue[(Coordinate, Int)],
        visited: Set[Coordinate],
        distances: DistanceMap
    ): DistanceMap =
      if queue.isEmpty then distances
      else
        val ((current, dist), dequeued) = queue.dequeue
        val neighbors                   =
          for
            dx <- -1 to 1
            dy <- -1 to 1
            if dx != 0 || dy != 0
            nextCoord = Coordinate(current.x + dx, current.y + dy)
            if contains(nextCoord) && !visited.contains(nextCoord)
          yield nextCoord

        val newVisited   = visited ++ neighbors
        val newDistances = neighbors.foldLeft(distances)((acc, coord) => acc + (coord -> (dist + 1)))
        val newQueue     = dequeued ++ neighbors.map(c => (c, dist + 1))
        bfs(newQueue, newVisited, newDistances)

    val initialQueue     = Queue.from(notFullCells.map(_ -> 0))
    val initialDistances = notFullCells.map(_ -> 0).toMap
    val initialVisited   = notFullCells.toSet
    bfs(initialQueue, initialVisited, initialDistances)

Tutte le strutture dati utilizzate sono immutabili per default:

  • Set ++ Seq crea un nuovo Set con elementi aggiunti
  • Map + (key -> value) crea una nuova Map con l'entry aggiunta
  • foldLeft è una higher-order function ovvero una funzione che accetta altre funzioni come argomenti, applicata in modo funzionale per costruire nuove mappe senza mutazioni.

Il companion object implementa il Factory Method Pattern restituendo il tipo astratto DistanceGrid[T], non l'implementazione concreta MapBasedDistanceGrid[T]. L'uso di for-comprehension genera e trasforma collezioni in modo dichiarativo.

scala
object DistanceGrid:
  def filledWith[T <: GridElement](width: Int, height: Int, element: T): DistanceGrid[T] =
    val elementsMap =
      (for
        x <- 0 until width
        y <- 0 until height
      yield Coordinate(x, y) -> element).toMap
    MapBasedDistanceGrid(MapBasedGrid(width, height, elementsMap))

WeatherService

WeatherEffectService è definito come sealed trait, limita le implementazioni del trait allo stesso file, garantendo un controllo esaustivo di pattern matching.

scala
sealed trait WeatherEffectService:
  def applyWeatherEffects(
      cellGrid: DistanceGrid[Cell],
      weatherGrid: Grid[Weather]
  ): DistanceGrid[Cell]

applyCellWeatherEffect è una funzione curried che cattura il parametro weatherGrid in chiusura, producendo una funzione (Coordinate, Cell) => Cell compatibile con map. È uno degli elementi più idiomatici e rilevanti del codice, perché:

  • Permette di preconfigurare il parametro weatherGrid, riutilizzandolo per tutte le celle (grazie al currying);
  • Applica una trasformazione pura: la cella viene aggiornata solo se nella griglia meteorologica è presente un valore Some(weather), altrimenti resta invariata grazie a fold.
  • Evita ripetizioni, catturando weatherGrid una sola volta invece di passarlo a ogni chiamata.
scala
private def applyCellWeatherEffect(weatherGrid: Grid[Weather])(
    coord: Coordinate,
    cell: Cell
): Cell =
  weatherGrid
    .getElementAt(coord)
    .fold(cell)(weather => cell.updateWater(weather.waterDelta))

Controller

Il package controller rappresenta il livello di controllo e orchestrazione del sistema di simulazione. In questa sezione ho realizzato i seguenti componenti: ControllerFactory, SimulationController e SimulationListener.

ControllerFactory

La classe ControllerFactory funge da Abstract Factory Pattern per la creazione di oggetti SimulationController. DefaultControllerFactory applica il Factory Method Pattern per incapsulare la creazione di SimulationController e delle sue dipendenze.

Utilizza summon per recuperare istanze implicite given di SimulationStore e SimulationScheduler dal contesto, implementando così un pattern di Dependency Injection dichiarativo. Questo approccio evita di passare esplicitamente le dipendenze, migliorando la leggibilità e permettendo di sostituire facilmente le implementazioni nei diversi contesti.

scala
private sealed class DefaultControllerFactory extends ControllerFactory:
  def createController(params: SimulationParams): SimulationController =
    val waterFlowService     = WaterFlowService()
    val weatherEffectService = WeatherEffectService()
    val engine               = DefaultSimulationEngine(params, waterFlowService, weatherEffectService)
    val store                = summon[SimulationStore]
    val scheduler            = summon[SimulationScheduler]
    SimulationController(engine, store, scheduler)

SimulationController

Il trait SimulationController definisce l'interfaccia pubblica del controller della simulazione. L'Observer Pattern viene realizzato tramite SimulationListener, che riceve aggiornamenti sugli eventi della simulazione. L'operatore :: (cons) prepende un elemento a una lista in tempo O(1), idiomatico in Scala per costruire liste immutabili.

scala
  def addListener(listener: SimulationListener): Unit =
  listeners = listener :: listeners

SimulationListener

Il trait SimulationListener definisce il contratto di notifica per i componenti che devono reagire ai cambiamenti della simulazione, rappresenta l'implementazione diretta dell'Observer Pattern.

View

Il package util.resource costituisce il modulo di gestione delle risorse grafiche dell'applicazione, responsabile del caricamento, caching e scalatura delle immagini associate a elementi della simulazione.

Cache

Cache rappresenta un meccanismo di caching generico che permette di caricare un insieme di oggetti una sola volta e di riutilizzarli senza accessi ripetuti al file system. Funge da infrastruttura base per componenti specializzati come ImageCache. L' implementazione PreloadedCache carica tutte le risorse all'inizializzazione (eager loading), evitando latenze durante l'esecuzione.

scala
sealed trait Cache[K, A]:
  def get(key: K): LoadResult[A]

private final class PreloadedCache[K, A](contents: Map[K, LoadResult[A]]) extends Cache[K, A]:
  def get(key: K): LoadResult[A] =
    contents.getOrElse(key, Left(ResourceError.InvalidKey(s"Resource not found: $key")))

L' uso dello using nel metodo factory Cache.preloaded inietta implicitamente un ResourceLoader, rispettando il Dependency Inversion Principle.

scala
object Cache:
  def preloaded[K, A](keys: Iterable[K])(using loader: ResourceLoader[K, A]): Cache[K, A] =
    val preloadedMap = keys.map(k => k -> loader.load(k)).toMap
    PreloadedCache(preloadedMap)

ImageCache

ImageCache implementa una cache a due livelli per ottimizzare l'accesso alle immagini: una cache base per le immagini originali precaricate all'inizializzazione (eager loading) e una cache secondaria per le versioni scalate calcolate on-demand (lazy loading).

scala
private def forType[K](mapping: Map[K, String]): ImageCache[K] =
  given baseLoader: ResourceLoader[K, BufferedImage] = PathBasedImageLoader(mapping)
  val baseCache = Cache.preloaded(mapping.keys)
  new ImageCache(mapping, baseCache)

L'implementazione utilizza una Map immutabile per memorizzare le immagini scalate, aggiornata funzionalmente a ogni richiesta (lazy loading). Le immagini originali sono precaricate tramite Cache.preloaded, mentre le versioni scalate vengono generate solo al primo accesso, utilizzando un ResourceLoader implicito given che inietta automaticamente lo ImageScaler configurato.

ResourceLoader

ResourceLoader definisce il contratto per il caricamento di risorse usando due implementazioni concrete:

  • PathBasedImageLoader carica immagini originali dal classpath utilizzando ImageIO.
  • ScaledImageLoader applica scalatura bilineare alle immagini base per adattarle dinamicamente alle dimensioni richieste dall'interfaccia grafica.

Either[ResourceError, A] rappresenta il successo Right o l'errore tipizzato Left in modo funzionale.

Resource

Resource definisce l'astrazione per il caricamento di risorse e la gestione degli errori associati.

scala
type LoadResult[A] = Either[ResourceError, A]

Il type alias LoadResult[A] rappresenta il risultato di un'operazione di caricamento:

  • Right(A): risorsa caricata con successo
  • Left(ResourceError): errore tipizzato che descrive il fallimento

Il NavigationController implementa il controllo della navigazione grafica tra le schermate principali dell'applicazione senza conoscere la logica interna delle view o del modello. Ogni pannello riceve il controller come parametro e può richiedere un cambio di schermata senza manipolare direttamente la finestra.