Flutter: il gioco della vita

Il Gioco della vita (Game of Life o noto anche solo come Life) è un esempio di automa cellulare sviluppato dal matematico inglese John Conway sul finire degli anni sessanta. In questo articolo vedremo come realizzare una sua implementazione in Flutter.


Le regole di base

Si tratta di un gioco senza giocatori e la sua evoluzione è determinata solo dallo stato iniziale, senza necessità di alcun input da parte di giocatori umani. Si svolge su una griglia (mondo) di caselle quadrate (celle) che si estende all'infinito in tutte le direzioni. Ogni cella ha 8 vicini, che sono le celle ad essa adiacenti, includendo anche quelle lungo le diagonali. Ogni cella può trovarsi in due stati: viva o morta ( accesa e spenta, on e off, vero e falso, 0 e 1). Gli stati di tutte le celle in un dato istante sono usati per calcolare lo stato delle celle all'istante successivo. Tutte le celle del mondo vengono quindi aggiornate simultaneamente nel passaggio da un istante a quello successivo (generazioni).

Lo stato successivo di ogni cella dipende unicamente dallo stato delle celle vicine nella generazione corrente, secondo queste 4 semplici regole di base:
  • qualsiasi cella viva con meno di due celle vive adiacenti muore, come per effetto d'isolamento;
  • qualsiasi cella viva con due o tre celle vive adiacenti sopravvive alla generazione successiva;
  • qualsiasi cella viva con più di tre celle vive adiacenti muore, come per effetto di sovrappopolazione;
  • qualsiasi cella morta con esattamente tre celle vive adiacenti diventa una cella viva, come per effetto di riproduzione.

Il progetto in Flutter

Il progetto dovrà prevedere una sezione relativa alla logica, che si occuperà tra l'altro di calcolare lo stato di ogni cella e una sezione di visualizzazione che si occuperà di disegnare sullo schermo lo stato delle cellule.

Partiamo da un nuovo progetto e sostituiamo il codice standard nel file main.dart con questo.


import 'package:flutter/material.dart';

void main() {
  runApp(MyGOL(title: 'Flutter Game of Life'));
}

class MyGOL extends StatefulWidget {
  final String title;
  const MyGOL({Key? key, required this.title}) : super(key: key);

  @override
  State<MyGOL> createState() => _MyGOLState();
}

class _MyGOLState extends State<MyGOL> {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
          appBar: AppBar(
            title: Text(widget.title),
          ),
          body: Container()),
    );
  }
}


La logica delle regole

Nella cartella lib, creiamo una sottocartella logic e dentro questa un file gol_logic.dart con questo codice:


import 'dart:math';

class GameOfLife {
  // dimensione della griglia
  static const size = 30;
  // griglia di cellule 0,1
  late List<List<int>> world;
  // numero di cellule attive
  int population = 0;
  // generazione corrente
  int generation = 0;

  // altri metodi qui ...  

}


La classe GameOfLife definisce la dimensione delle griglia, la matrice numerica che permette di rappresentare l'insieme delle cellule, una variabile per contare il numero delle cellule attive in ogni generazione e il numero di generazione.  

Poi aggiungiamo un metodo per azzerare lo stato di tutte le cellule:


  // azzera lo stato delle cellule
  void clearWorld() {
    population = 0;
    generation = 0;
    world = List.generate(size, (_) => List<int>.filled(size, 0));
  }


una funzione che ritorna la dimensione della griglia:


  // ritorna la dimensione della griglia
  int getSize() {
    return size;
  }


una funzione che ritorna lo stato di una singola cella:


  // ritorna lo stato di una cella
  bool isAlive(int row, int col) {
    return (world[row][col] == 1);
  }


un metodo per assegnare uno stato iniziale random della griglia (che attiva un numero casuale di celle tra il 20% e il 40%):


  // genera uno stato random
  void randomWorld() {
    clearWorld();

    final random = Random();
    final totalCells = random.nextInt((size * size * 0.2).round()) +
        (size * size * 0.2).round();
    int i = 0;
    do {
      int r = random.nextInt(size);
      int c = random.nextInt(size);
      // se la cellula non è stata già attivata
      if (!isAlive(r, c)) {
        world[r][c] = 1;
        i++;
      }
    } while (i < totalCells);
    population = totalCells;
  }


e una funziona che determina lo stato successivo di una cellula in base allo stato corrente dei suoi vicini applicando le 4 regole definite in precedenza:


  // determina lo stato successivo della cellula
  // in base all'attuale stato dei suoi vicini
  int newState(int y, int x) {
    //
    // se [y,x] è la posizione della cella da valutare
    // i suoi vicini sono:
    //
    // [t,l] [t,c] [t,r]
    // [r,l] [y,x] [r,r]
    // [b,l] [b,c] [b,r]
    //

    // effetto PacMan
    int t = (y > 0) ? (y - 1) : (size - 1);
    int b = (y < (size - 1)) ? (y + 1) : 0;

    int l = (x > 0) ? (x - 1) : (size - 1);
    int r = (x < (size - 1)) ? (x + 1) : 0;

    int neighborsAlive = world[t][l] +
        world[t][x] +
        world[t][r] +
        world[y][l] +
        world[y][r] +
        world[b][l] +
        world[b][x] +
        world[b][r];

    int newState = 0;
    // Qualsiasi cella viva con meno di due celle vive adiacenti muore;
    if (isAlive(y, x) && (neighborsAlive < 2)) {
      newState = 0;
    }
    // Qualsiasi cella viva con due o tre celle vive adiacenti sopravvive;
    if (isAlive(y, x) && (neighborsAlive == 2 || neighborsAlive == 3)) {
      newState = 1;
    }
    // Qualsiasi cella viva con più di tre celle vive adiacenti muore;
    if (isAlive(y, x) && (neighborsAlive > 3)) {
      newState = 0;
    }
    // Qualsiasi cella morta con esattamente tre celle vive adiacenti diventa una cella viva;
    if (!isAlive(y, x) && (neighborsAlive == 3)) {
      newState = 1;
    }
    return newState;
  }


La griglia si considera infinita, quindi per le cellule presenti sul bordo destro il vicino di destra è una cellula presente nella prima colonna di sinistra e così via per tutti i bordi (cd. effetto PacMan).

Definiamo inoltre un metodo che ci permetta di aggiornare, ad ogni iterazione, lo stato delle cellule e il conteggio della popolazione attiva:


  // aggiorna lo stato per tutte le cellule
  // a passa alla generazione successiva
  void nextWorld() {
    // crea un mondo nuovo vuoto
    List<List<int>> newWorld =
        List.generate(size, (_) => List<int>.filled(size, 0));

    population = 0;
    for (int row = 0; row < size; ++row) {
      for (int col = 0; col < size; ++col) {
        newWorld[row][col] = newState(row, col);
        // conta le celle attive
        population += newWorld[row][col];
      }
    }
    generation++;
    world = newWorld;
  }

Visualizzazione delle cellule e della matrice

Nella cartella lib, creiamo una sottocartella widgets e dentro questa un file gol_cell.dart con questo codice:


import 'package:flutter/material.dart';

class CellPainter extends CustomPainter {
  bool cellIsAlive = false;
  double left;
  double top;
  final Paint paintSetting = Paint();

  CellPainter(
      {required this.cellIsAlive, required this.left, required this.top});

  @override
  void paint(Canvas canvas, Size size) {
    paintSetting.color = cellIsAlive ? Colors.green : Colors.white;
    canvas.drawRect(
        Rect.fromLTWH(left, top, size.width, size.height), paintSetting);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    var cellPainter = (oldDelegate as CellPainter);
    return cellPainter.cellIsAlive != cellIsAlive ||
        cellPainter.left != left ||
        cellPainter.top != top;
  }
}


Questa classe fa uso di un CustomPainter per disegnare la singola cella, alle coordinate indicate, come un quadrato di colore verde o bianco in base allo stato. 

Sempre nella stessa cartella creiamo un nuovo file gol_board.dart con questo codice:


import 'package:flutter/material.dart';
import '../logic/gol_logic.dart';
import 'gol_cell.dart';

class GolBoard extends StatelessWidget {
  final GameOfLife gameOfLife;
  final double cellSize;

  const GolBoard({Key? key, required this.gameOfLife, required this.cellSize})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Stack(children: _buildCells());
  }

  List<Widget> _buildCells() {
    final List<Widget> items = [];
    int size = gameOfLife.getSize();

    // cells
    for (int r = 0; r < size; ++r) {
      for (int c = 0; c < size; ++c) {
        final left = c * cellSize;
        final top = r * cellSize;

        items.add(
          CustomPaint(
            size: Size.square(cellSize),
            painter: CellPainter(
              cellIsAlive: (gameOfLife.isAlive(r, c)),
              left: left,
              top: top,
            ),
          ),
        );
      }
    }

    // border
    items.add(
      Container(
        width: cellSize * size,
        height: cellSize * size,
        decoration: BoxDecoration(border: Border.all(color: Colors.black)),
      ),
    );

    return items;
  }
}


Questo widget, restituisce una lista di widget (le singole celle generate con CellPainter, posizionate ognuna in base agli indici della matrice) e il bordo, utilizzando una struttura Stack.

A questo punto occorre modificare il metodo build del file main.dart inziale per inserire i widget creati:


  GameOfLife gameOfLife = GameOfLife();

  @override
  Widget build(BuildContext context) {
    const double paddingSize = 8;
    final screenSize = MediaQuery.of(context).size;

    double cellWidth = (screenSize.width - paddingSize * 2) / GameOfLife.size;
    double cellHeight = (screenSize.height - kToolbarHeight - paddingSize * 2) /
        GameOfLife.size;

    if (cellWidth > cellHeight) {
      cellWidth = cellHeight;
    }

    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text(widget.title),
        ),
        body: Padding(
          padding: const EdgeInsets.all(paddingSize),
          child: Column(
            children: [
              GolBoard(
                gameOfLife: gameOfLife,
                cellSize: cellWidth,
              ),
              Text("Generazione: " + gameOfLife.generation.toString()),
              Text("Popolazione: " + gameOfLife.population.toString()),
            ],
          ),
        ),
      ),
    );
  }


Le righe di codice indicate, rilevano la dimensione dello schermo e determinano la massima grandezza possibile delle celle, tenendo conto dello spazio occupato dalla barra del titolo e dal padding inserito.

Modificando anche i restanti metodi, avremo che all'avvio dell'applicazione la matrice sarà popolata con una configurazione casuale:


  @override
  void initState() {
    super.initState();
    gameOfLife.randomWorld();
  }


e si otterrà un risultato simile a questo:



L'animazione e le generazioni

Per animare la popolazione e ottenere le generazioni successive, ci occorre lanciare ad intervalli regolari la funzione nextWorld() che aggiorna lo stato di tutte le cellule. Per questo motivo, sempre nel file main.dart, aggiungiamo un timer:


  late Timer timer;


modifichiamo il metodo initState() per inizializzarlo (ad esempio con una periodicità di 500 millisecondi), il metodo dispose() per liberare l'istanza e aggiungiamo il metodo next() da lanciare ad ogni tick. 


  @override
  void initState() {
    super.initState();
    gameOfLife.randomWorld();
    timer =
        Timer.periodic(const Duration(milliseconds: 500), (Timer t) => next());
  }

  @override
  void dispose() {
    timer.cancel();
    super.dispose();
  }

  void next() {
    setState(() {
      gameOfLife.nextWorld();
    });
  }

Non ci resta che aggiungere un pulsante per avviare o fermare l'animazione:


        floatingActionButton: FloatingActionButton(
          onPressed: playStop,
          tooltip: 'Start/Stop',
          child: gameIsRunning
              ? const Icon(Icons.pause)
              : const Icon(Icons.play_arrow),
        ),

e aggiungere la logica necessaria:


  bool gameIsRunning = false;

  void next() {
    if (gameIsRunning) {
      setState(() {
        gameOfLife.nextWorld();
      });
    }
  }

  void playStop() {
    setState(() {
      gameIsRunning = !gameIsRunning;
    });
  }

Il risultato finale dovrebbe essere simile a questo:


Conclusioni    

Il progetto è terminato e la nostra applicazione di esempio è completa. Sarebbe utile inserire la possibilità di riavviare l'animazione o dare la possibilità all'utente di attivare e disattivare singolarmente le cellule per verificare il comportamento di schemi particolari, ma magari questo sarà materiale per un altro articolo. Come per gli articoli precedenti potete trovare questo codice tra le mie repo GitHub: https://github.com/luigimicco/flutter_gameoflife.

Commenti