Flutter: un clone di Pong (senza AI)

In questo articolo vedremo come realizzare un clone del classico gioco Pong con Flutter. Vedremo come implementare un painter customizzato e come gestire il loop di controllo del gioco. Nessuna AI sarà maltrattata o utilizzata per simulare il secondo giocatore: sono un po' di semplici algoritmi.


Il progetto in Flutter

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


import 'package:flutter/material.dart';

void main() {
  runApp(const PongApp());
}

class PongApp extends StatelessWidget {
  const PongApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Pong Game',
      theme: ThemeData.dark(),
      home: const PongGame(),
      debugShowCheckedModeBanner: false,
    );
  }
}

Gli elementi del gioco

Nella cartella lib, creiamo un file pong.dart con una classe che ci permetterà di descrivere il gioco che vogliamo creare da importare nel file main.dart:


import 'package:flutter/material.dart';

class PongGame extends StatefulWidget {
  const PongGame({super.key});

  @override
  State<PongGame> createState() => _PongGameState();
}

class _PongGameState extends State<PongGame> with TickerProviderStateMixin {
  @override
  void initState() {
    super.initState();
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: SafeArea(
        child: GestureDetector(
          onTapDown: (details) {},
          onPanUpdate: (details) {},
          child: Stack(
            children: [
              // Game canvas
            ],
          ),
        ),
      ),
    );
  }
}


Per il momento, abbiamo definito solo un widget per l'area di gioco che risponde alle gesture (ci servirà per l'interazione con il giocatore).
Il ciclo di vita del gioco che vogliamo implementare è abbastanza semplice: dopo una inizializzazione  delle variabili di gioco, bisognerà creare un loop per gestire le azioni dell'utente e il movimento della pallina.

Aggiungiamo quindi un controller (per il momento fissiamo il frame rate a 60 FPS):


class _PongGameState extends State<PongGame> with TickerProviderStateMixin {
  late AnimationController _gameController;

  @override
  void initState() {
    super.initState();

    _gameController = AnimationController(
      duration: Duration(milliseconds: (1000 / 60).toInt()),
      vsync: this,
    )..addListener(_updateGame);
    WidgetsBinding.instance.addPostFrameCallback((_) {
      setState(() {
        _initializeGame();
      });
      _startGameLoop();
    });    
  }

  @override
  void dispose() {
    _gameController.dispose();
    super.dispose();
  }

  void _startGameLoop() {
    _gameController.repeat();
  }

  void _updateGame() {}


L'inizializzazione deve impostare le dimensioni dello spazio di gioco e l'azzeramento di alcun variabili:
Per semplicità, creiamo da subito un file globals.dart in cui aggiungere un po' di variabili e costanti globali:


// Game globals
import 'package:flutter/material.dart';

// global variables
Size GAME_SIZE = const Size(0, 0);


e quindi

  Offset ballVelocity = const Offset(0, 0);
  Offset ballPosition = const Offset(0, 0);
...
  void _initializeGame() {
    GAME_SIZE = const Size(0, 0);
    ballVelocity = const Offset(0, 0);
  }

Per memorizzare la posizione e la velocità della pallina, usiamo un Offset, un oggetto che espone una proprietà x e una proprietà y che ci tornano comode.

Per disegnare il campo di gioco, creiamo un file painter.dart in cui definire un custom painter che si occuperà di disegnare la pallina, i due paddle (quello superiore e quello inferiore) e la linea di metà campo:


import 'package:flutter/material.dart';
import 'globals.dart';

class PongPainter extends CustomPainter {
  final Offset ballPosition;
  final double bottomPaddleX;
  final double topPaddleX;
  final Size gameSize;

  PongPainter({
    required this.ballPosition,
    required this.bottomPaddleX,
    required this.topPaddleX,
    required this.gameSize,
  });

  @override
  void paint(Canvas canvas, Size size) {

    // Draw dashed middle line

    // Draw ball

    // Draw bottom paddle

    // Draw top paddle

  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}


Aggiorniamo il file globals.dart con alcune costanti:


// global consts
const double BALL_RADIUS = 8.0;
const double BALL_SPEED = 350.0;

const double PADDLE_WIDTH = 80.0;
const double PADDLE_HEIGHT = 12.0;

const Color BALL_PAINT = Colors.white;
const Color PADDLE_PAINT = Colors.white;
const Color LINE_PAINT = Colors.grey;
const double LINE_WIDTH = 4;


quindi nel file painter.dart


  void paint(Canvas canvas, Size size) {
    final ballPaint = Paint()..color = BALL_PAINT;
    final paddlePaint = Paint()..color = PADDLE_PAINT;
    final paint = Paint()
      ..color = LINE_PAINT
      ..strokeWidth = LINE_WIDTH;

    // Draw dashed middle line
    double dashWidth = gameSize.width / 41;
    double dashSpace = dashWidth;
    double startX = 0;
    double middleY = (gameSize.height - LINE_WIDTH) / 2;
    while (startX < gameSize.width) {
      canvas.drawLine(
        Offset(startX, middleY),
        Offset(startX + dashWidth, middleY),
        paint,
      );
      startX += dashWidth + dashSpace;
    }

    // Draw ball
    canvas.drawCircle(ballPosition, BALL_RADIUS, ballPaint);

    // Draw bottom paddle
    final bottomPaddleRect = Rect.fromLTWH(
      bottomPaddleX,
      gameSize.height - 60,
      PADDLE_WIDTH,
      PADDLE_HEIGHT,
    );
    canvas.drawRect(bottomPaddleRect, paddlePaint);

    // Draw top paddle
    final topPaddleRect = Rect.fromLTWH(
      topPaddleX,
      60,
      PADDLE_WIDTH,
      PADDLE_HEIGHT,
    );
    canvas.drawRect(topPaddleRect, paddlePaint);
  }


Aggiungiamo il custom painter nel file pong.dart:

  ...  
  double paddleBottomX = 0;
  double paddleTopX = 0;


    ...
          child: Stack(
            children: [
              // Game canvas
              CustomPaint(
                size: Size.infinite,
                painter: PongPainter(
                  ballPosition: ballPosition,
                  bottomPaddleX: paddleBottomX,
                  topPaddleX: paddleTopX,
                  gameSize: GAME_SIZE,
                ),
              ),



aggiungiamo il punteggio, le vite a disposizione, le info iniziali e il testo di game over:


  // Game state
  bool gameStarted = false;
  bool gameOver = false;
  int score = 0;
  int lives = MAX_LIVES;

...

  void _restartGame() {
    setState(() {
      gameStarted = false;
      gameOver = false;
      score = 0;
      lives = 3;
    });
    _initializeGame();
  }

...
          child: Stack(
            children: [

              ...  

              // UI overlay
              Positioned(
                top: 20,
                left: 20,
                right: 20,
                child: Row(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text('Lives: ${'●' * lives}'),
                    const Spacer(),
                    Text('Score: $score'),
                  ],
                ),
              ),

              // Instructions/Game Over overlay
              if (!gameStarted && !gameOver)
                Center(
                  child: Text(
                    'Tap to start game!\n\nDrag to move paddle',
                    textAlign: TextAlign.center,
                  ),
                ),

              // Game Over
              if (gameOver)
                Center(
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      Text('Game Over!'),
                      const SizedBox(height: 20),
                      ElevatedButton(
                        onPressed: _restartGame,
                        style: ElevatedButton.styleFrom(
                          backgroundColor: Colors.blue,
                          padding: const EdgeInsets.symmetric(
                            horizontal: 30,
                            vertical: 15,
                          ),
                        ),
                        child: Text(
                          'Restart Game',
                        ),
                      ),
                    ],
                  ),
                ),


La logica del gioco

Non ci resta che definire la logica del gioco, cioè l'insieme di istruzioni da eseguire ad ogni iterazione prima di aggiornare lo stato:
  • aggiornare le dimensioni della canvas se cambiano
  • aggiornamento della posizione della pallina
  • controllo delle collisioni: con il muro o con i paddle
  • aggiornamento punteggio e vite disponibili
  • aggiornamento posizione dei due paddle
Per l'aggiornamento della pallina usiamo la formula della velocità s = v * t, dove t è il nostro intervallo (1/60) e v è la velocità della pallina lungo l'asse x o y:


      // Update ball position
      // using velocity => s = v * t, where t = 1 / GAME_FPS
      ballPosition = Offset(
        ballPosition.dx + ballVelocity.dx / GAME_FPS,
        ballPosition.dy + ballVelocity.dy / GAME_FPS,
      );


Se la pallina va contro il muro, deve invertire la sua velocità orizzontale:


      if (ballPosition.dx <= BALL_RADIUS ||
          ballPosition.dx >= GAME_SIZE.width - BALL_RADIUS) {
        // Ball collision with walls
        ballVelocity = Offset(-ballVelocity.dx, ballVelocity.dy);
        ballPosition = Offset(
          ballPosition.dx <= BALL_RADIUS
              ? BALL_RADIUS
              : GAME_SIZE.width - BALL_RADIUS,
          ballPosition.dy,
        );
      }

Se la pallina incontra uno dei due paddle, deve invertire la velocità verticale e modificare quella orizzontale in base alla distanza del punto colpito rispetto al centro del paddle (limitando comunque ad un range massimo):


      // Ball collision with bottom paddle
      double paddleY = GAME_SIZE.height - 60;
      if (ballPosition.dx >= paddleBottomX - BALL_RADIUS &&
          ballPosition.dx <= paddleBottomX + PADDLE_WIDTH + BALL_RADIUS &&
          ballPosition.dy >= paddleY - BALL_RADIUS &&
          ballPosition.dy <= paddleY + PADDLE_HEIGHT + BALL_RADIUS) {
        ballVelocity = Offset(ballVelocity.dx, -ballVelocity.dy.abs());

        // Add angle based on where ball hits paddle
        double difference =
            ballPosition.dx - (paddleBottomX + PADDLE_WIDTH / 2);
        ballVelocity = Offset(
          ballVelocity.dx + difference * 3,
          ballVelocity.dy,
        );
        // Limit velocity
        ballVelocity = Offset(
          ballVelocity.dx.clamp(-BALL_SPEED, BALL_SPEED),
          ballVelocity.dy,
        );
      }

      // Ball collision with top paddle
      paddleY = 60;
      if (ballPosition.dx >= paddleBottomX - BALL_RADIUS &&
          ballPosition.dx <= paddleBottomX + PADDLE_WIDTH + BALL_RADIUS &&
          ballPosition.dy >= paddleY - BALL_RADIUS &&
          ballPosition.dy <= paddleY + PADDLE_HEIGHT + BALL_RADIUS) {
        ballVelocity = Offset(ballVelocity.dx, ballVelocity.dy.abs());

        // Add angle based on where ball hits paddle
        double difference = ballPosition.dx - (paddleTopX + PADDLE_WIDTH / 2);
        ballVelocity = Offset(
          ballVelocity.dx + difference * 3,
          ballVelocity.dy,
        );
        // Limit velocity
        ballVelocity = Offset(
          ballVelocity.dx.clamp(-BALL_SPEED, BALL_SPEED),
          ballVelocity.dy,
        );
      }

Per l'aggiornamento delle vite e del punteggio, aggiungiamo delle funzioni specifiche:


  void _loseLife() {
    lives--;
    if (lives <= 0) {
      gameOver = true;
    } else {
      _resetBall();
    }
  }

  void _updateScore() {
    score++;
    _resetBall();
  }

  void _resetBall() {
    // Position ball on top center of bottom paddle
    ballPosition = Offset(
      paddleBottomX + PADDLE_WIDTH / 2,
      GAME_SIZE.height - 60 - BALL_RADIUS - 2,
    );
    ballVelocity = const Offset(0, 0);
    gameStarted = false;
  }

e quindi il nostro controllo:


      // Ball goes off bottom
      if (ballPosition.dy >= GAME_SIZE.height) {
        _loseLife();
      }

      // Ball goes off top
      if (ballPosition.dy <= 0) {
        _updateScore();
      }

Per l'aggiornamento del paddle utente, definiamo una funzione:


  void _movePaddle(Offset tapPosition) {
    setState(() {
      paddleBottomX = (tapPosition.dx - PADDLE_WIDTH / 2).clamp(
        0,
        GAME_SIZE.width - PADDLE_WIDTH,
      );

      // If game hasn't started, move ball with paddle
      if (!gameStarted) {
        ballPosition = Offset(
          paddleBottomX + PADDLE_WIDTH / 2,
          GAME_SIZE.height - 60 - BALL_RADIUS - 2,
        );
      }
    });
  }


e definiamo pure una funzione di inizio gioco che assegna una velocità e una direzione casuale alla pallina:


  void _startGame() {
    if (!gameStarted && !gameOver) {
      setState(() {
        gameStarted = true;
        ballVelocity = Offset(
          (Random().nextBool() ? 1 : -1) * BALL_SPEED * 0.5,
          -BALL_SPEED,
        );
      });
    }
  }

e quindi aggiorniamo al nostro GestureDetector :


        child: GestureDetector(
          onTapDown: (details) {
            if (!gameStarted && !gameOver) {
              _startGame();
            } else if (!gameOver) {
              _movePaddle(details.localPosition);
            }
          },
          onPanUpdate: (details) {
            if (!gameOver) {
              _movePaddle(details.localPosition);
            }
          },
          child: Stack(


La risposta automatica

Per la risposta automatica e quindi per il movimento del paddle superiore, possiamo implementare questa logica che non sia un semplice inseguimento della pallina (cosa possibile ma che renderebbe il computer imbattibile) :

  • definire un ritardo nella risposta (ad esempio il paddle superiore inizia a reazione solo se la pallina ha già percorso il 25% del campo)
  • attribuire una velocità di inseguimento in modo casuale diversa da quella della pallina
  • determinare se la pallina si trova a destra o sinistra del centro del paddle e spostare il paddle nella direzione corretta 
Con queste indicazioni, la funzione di aggiornamento del paddle superiore diventa:


      if (ballPosition.dy < 0.75 * GAME_SIZE.height) {
        int space = (2 * BALL_SPEED / 60).toInt();
        int rnd = Random().nextInt(space);
        double newPosition = paddleTopX;

        if (ballPosition.dx < paddleTopX) {
          newPosition = paddleTopX - rnd;
        } else if (ballPosition.dx > (paddleTopX + PADDLE_WIDTH)) {
          newPosition = paddleTopX + rnd;
        }
        paddleTopX = newPosition;
      }


Abbiamo terminato il nostro pong e possiamo finalmente giocare 


Conclusioni

Come per gli articoli precedenti potete trovare il codice completo tra le mie repo GitHub: https://github.com/luigimicco/flutter_pong.

Nel codice troverete anche piccole modifiche di ottimizzazione e l'utilizzo di un font Google per avere un effetto retro.

Commenti