Introduzione a Flame con Flutter

1. Introduzione

Flame è un motore di gioco 2D basato su Flutter. In questo codelab, creerai un gioco ispirato a uno dei classici dei videogiochi degli anni '70, Breakout di Steve Wozniak. Utilizzerai i componenti di Flame per disegnare la mazza, la palla e i mattoni. Utilizzerai gli effetti di Flame per animare il movimento del pipistrello e vedrai come integrare Flame con il sistema di gestione dello stato di Flutter.

Al termine, il gioco dovrebbe essere simile a questa GIF animata, anche se un po' più lento.

Una registrazione dello schermo di un gioco in corso. La velocità del gioco è stata aumentata in modo significativo.

Obiettivi didattici

  • Come funzionano le basi di Flame, a partire da GameWidget.
  • Come utilizzare un ciclo di gioco.
  • Come funzionano i Component di Flame. Sono simili ai Widget di Flutter.
  • Come gestire le collisioni.
  • Come utilizzare i Effect per animare i Component.
  • Come sovrapporre le Widget di Flutter a un gioco Flame.
  • Come integrare Flame con la gestione dello stato di Flutter.

Cosa creerai

In questo codelab, creerai un gioco 2D utilizzando Flutter e Flame. Al termine, il gioco deve soddisfare i seguenti requisiti:

  • Funzionare su tutte e sei le piattaforme supportate da Flutter: Android, iOS, Linux, macOS, Windows e web
  • Mantieni almeno 60 fps utilizzando il ciclo di gioco di Flame.
  • Utilizza le funzionalità di Flutter, come il pacchetto google_fonts e flutter_animate, per ricreare l'atmosfera dei giochi arcade degli anni '80.

2. Configurare l'ambiente Flutter

Editor

Per semplificare questo codelab, si presume che Visual Studio Code (VS Code) sia il tuo ambiente di sviluppo. VS Code è senza costi e funziona su tutte le principali piattaforme. Utilizziamo VS Code per questo codelab perché le istruzioni utilizzano scorciatoie specifiche di VS Code per impostazione predefinita. Le attività diventano più semplici: "fai clic su questo pulsante" o "premi questo tasto per eseguire X" anziché "esegui l'azione appropriata nell'editor per eseguire X".

Puoi utilizzare qualsiasi editor: Android Studio, altri IDE IntelliJ, Emacs, Vim o Notepad++. Funzionano tutti con Flutter.

VS Code con codice Flutter

Scegliere un target di sviluppo

Flutter produce app per più piattaforme. La tua app può essere eseguita su uno dei seguenti sistemi operativi:

  • iOS
  • Android
  • Windows
  • macOS
  • Linux
  • web

È prassi comune scegliere un sistema operativo come target di sviluppo. Questo è il sistema operativo su cui viene eseguita la tua app durante lo sviluppo.

Un disegno che mostra un laptop e uno smartphone collegato al laptop tramite un cavo. Il laptop è etichettato come

Ad esempio, supponiamo che tu stia utilizzando un laptop Windows per sviluppare la tua app Flutter. Quindi scegli Android come target di sviluppo. Per visualizzare l'anteprima dell'app, collega un dispositivo Android al laptop Windows con un cavo USB e l'app in fase di sviluppo viene eseguita sul dispositivo Android collegato o in un emulatore Android. Avresti potuto scegliere Windows come target di sviluppo, in modo da eseguire l'app in fase di sviluppo come app Windows insieme all'editor.

Fai la tua scelta prima di continuare. Puoi sempre eseguire la tua app su altri sistemi operativi in un secondo momento. La scelta di un target di sviluppo semplifica il passaggio successivo.

Installare Flutter

Le istruzioni più aggiornate sull'installazione dell'SDK Flutter sono disponibili su docs.flutter.dev.

Le istruzioni sul sito web di Flutter riguardano l'installazione dell'SDK e degli strumenti correlati alla destinazione di sviluppo, nonché i plug-in dell'editor. Per questo codelab, installa il seguente software:

  1. SDK Flutter
  2. Visual Studio Code con il plug-in Flutter
  3. Software di compilazione per la destinazione di sviluppo scelta. (Devi utilizzare Visual Studio per Windows o Xcode per macOS o iOS)

Nella sezione successiva creerai il tuo primo progetto Flutter.

Se devi risolvere eventuali problemi, potresti trovare utili alcune di queste domande e risposte (di StackOverflow).

Domande frequenti

3. Crea un progetto

Crea il tuo primo progetto Flutter

Ciò comporta l'apertura di VS Code e la creazione del modello di app Flutter in una directory a tua scelta.

  1. Avvia Visual Studio Code.
  2. Apri la tavolozza dei comandi (F1 o Ctrl+Shift+P o Shift+Cmd+P), quindi digita "flutter new". Quando viene visualizzato, seleziona il comando Flutter: New Project.

VS Code con

  1. Seleziona Empty Application (Applicazione vuota). Scegli una directory in cui creare il progetto. Deve essere una directory che non richiede privilegi elevati o che non contiene spazi nel percorso. Ad esempio, la tua directory home o C:\src\.

VS Code con Empty Application mostrato come selezionato nell'ambito del nuovo flusso dell'applicazione

  1. Assegna un nome al progetto brick_breaker. Il resto di questo codelab presuppone che tu abbia chiamato la tua app brick_breaker.

VS Code con

Flutter ora crea la cartella del progetto e VS Code la apre. Ora sovrascriverai i contenuti di due file con una struttura di base dell'app.

Copiare e incollare l'app iniziale

In questo modo, il codice di esempio fornito in questo codelab viene aggiunto alla tua app.

  1. Nel riquadro a sinistra di VS Code, fai clic su Explorer e apri il file pubspec.yaml.

Uno screenshot parziale di VS Code con frecce che evidenziano la posizione del file pubspec.yaml

  1. Sostituisci i contenuti di questo file con i seguenti:

pubspec.yaml

name: brick_breaker
description: "Re-implementing Woz's Breakout"
publish_to: "none"
version: 0.1.0

environment:
  sdk: ^3.8.0

dependencies:
  flutter:
    sdk: flutter
  flame: ^1.28.1
  flutter_animate: ^4.5.2
  google_fonts: ^6.2.1

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true

Il file pubspec.yaml specifica le informazioni di base sulla tua app, come la versione attuale, le dipendenze e gli asset con cui verrà distribuita.

  1. Apri il file main.dart nella directory lib/.

Uno screenshot parziale di VS Code con una freccia che mostra la posizione del file main.dart

  1. Sostituisci i contenuti di questo file con i seguenti:

lib/main.dart

import 'package:flame/game.dart';
import 'package:flutter/material.dart';

void main() {
  final game = FlameGame();
  runApp(GameWidget(game: game));
}
  1. Esegui questo codice per verificare che tutto funzioni. Dovrebbe essere visualizzata una nuova finestra con solo uno sfondo nero vuoto. Il peggior videogioco del mondo ora viene renderizzato a 60 FPS.

Uno screenshot che mostra una finestra dell'applicazione brick_breaker completamente nera.

4. Creare il gioco

Valutare la partita

Un gioco bidimensionale (2D) ha bisogno di un'area di gioco. Costruirai un'area di dimensioni specifiche e poi le utilizzerai per dimensionare altri aspetti del gioco.

Esistono vari modi per disporre le coordinate nell'area di gioco. Secondo una convenzione, puoi misurare la direzione dal centro dello schermo con l'origine (0,0)al centro dello schermo, i valori positivi spostano gli elementi a destra lungo l'asse x e verso l'alto lungo l'asse y. Questo standard si applica alla maggior parte dei giochi attuali, soprattutto a quelli che coinvolgono tre dimensioni.

Quando è stato creato il gioco Breakout originale, la convenzione prevedeva di impostare l'origine nell'angolo in alto a sinistra. La direzione x positiva è rimasta invariata, mentre y è stata invertita. La direzione x positiva era a destra e y era in basso. Per rimanere fedele all'epoca, questo gioco imposta l'origine nell'angolo in alto a sinistra.

Crea un file denominato config.dart in una nuova directory denominata lib/src. Questo file acquisirà altre costanti nei passaggi successivi.

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;

Questo gioco avrà una larghezza di 820 pixel e un'altezza di 1600 pixel. L'area di gioco viene scalata per adattarsi alla finestra in cui viene visualizzata, ma tutti i componenti aggiunti allo schermo rispettano questa altezza e larghezza.

Creare un'area di gioco

Nel gioco Breakout, la palla rimbalza sulle pareti dell'area di gioco. Per gestire le collisioni, devi prima avere un componente PlayArea.

  1. Crea un file denominato play_area.dart in una nuova directory denominata lib/src/components.
  2. Aggiungi quanto segue a questo file.

lib/src/components/play_area.dart

import 'dart:async';

import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';

class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
  PlayArea() : super(paint: Paint()..color = const Color(0xfff2e8cf));

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();
    size = Vector2(game.width, game.height);
  }
}

Dove Flutter ha Widget, Flame ha Component. Mentre le app Flutter consistono nella creazione di alberi di widget, i giochi Flame consistono nel mantenimento di alberi di componenti.

Qui sta una differenza interessante tra Flutter e Flame. L'albero dei widget di Flutter è una descrizione effimera creata per essere utilizzata per aggiornare il livello RenderObject persistente e modificabile. I componenti di Flame sono persistenti e modificabili, con l'aspettativa che lo sviluppatore li utilizzi come parte di un sistema di simulazione.

I componenti di Flame sono ottimizzati per esprimere le meccaniche di gioco. Questo codelab inizierà con il ciclo di gioco, illustrato nel passaggio successivo.

  1. Per controllare il disordine, aggiungi un file contenente tutti i componenti di questo progetto. Crea un file components.dart in lib/src/components e aggiungi i seguenti contenuti.

lib/src/components/components.dart

export 'play_area.dart';

L'istruzione export svolge il ruolo inverso di import. Dichiara la funzionalità esposta da questo file quando viene importato in un altro file. Questo file aumenterà il numero di voci man mano che aggiungi nuovi componenti nei passaggi successivi.

Creare un gioco Flame

Per eliminare le linee rosse del passaggio precedente, deriva una nuova sottoclasse per FlameGame di Flame.

  1. Crea un file denominato brick_breaker.dart in lib/src e aggiungi il seguente codice.

lib/src/brick_breaker.dart

import 'dart:async';

import 'package:flame/components.dart';
import 'package:flame/game.dart';

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame {
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  double get width => size.x;
  double get height => size.y;

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());
  }
}

Questo file coordina le azioni del gioco. Durante la creazione dell'istanza di gioco, questo codice configura il gioco in modo che utilizzi il rendering a risoluzione fissa. Il gioco viene ridimensionato per riempire lo schermo che lo contiene e aggiunge letterbox in base alle necessità.

Espone la larghezza e l'altezza del gioco in modo che i componenti secondari, come PlayArea, possano impostarsi sulla dimensione appropriata.

Nel metodo sottoposto a override onLoad, il codice esegue due azioni.

  1. Configura l'angolo in alto a sinistra come punto di ancoraggio per il mirino. Per impostazione predefinita, viewfinder utilizza il centro dell'area come punto di ancoraggio per (0,0).
  2. Aggiunge PlayArea a world. Il mondo rappresenta il mondo di gioco. Proietta tutti i suoi figli attraverso la trasformazione della visualizzazione CameraComponent.

Visualizzare la partita sullo schermo

Per visualizzare tutte le modifiche apportate in questo passaggio, aggiorna il file lib/main.dart con le seguenti modifiche.

lib/main.dart

import 'package:flame/game.dart';
import 'package:flutter/material.dart';

import 'src/brick_breaker.dart';                                // Add this import

void main() {
  final game = BrickBreaker();                                  // Modify this line
  runApp(GameWidget(game: game));
}

Dopo aver apportato queste modifiche, riavvia il gioco. Il gioco dovrebbe essere simile alla figura seguente.

Uno screenshot che mostra una finestra dell&#39;applicazione brick_breaker con un rettangolo color sabbia al centro della finestra dell&#39;app

Nel passaggio successivo, aggiungerai una palla al mondo e la farai muovere.

5. Visualizzare la palla

Crea il componente della palla

Per inserire una palla in movimento sullo schermo, devi creare un altro componente e aggiungerlo al mondo di gioco.

  1. Modifica i contenuti del file lib/src/config.dart come segue.

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;                            // Add this constant

Il pattern di progettazione per la definizione di costanti denominate come valori derivati verrà ripetuto più volte in questo codelab. In questo modo puoi modificare gameWidth e gameHeight di primo livello per esplorare come cambia l'aspetto del gioco di conseguenza.

  1. Crea il componente Ball in un file denominato ball.dart in lib/src/components.

lib/src/components/ball.dart

import 'package:flame/components.dart';
import 'package:flutter/material.dart';

class Ball extends CircleComponent {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
  }) : super(
         radius: radius,
         anchor: Anchor.center,
         paint: Paint()
           ..color = const Color(0xff1e6091)
           ..style = PaintingStyle.fill,
       );

  final Vector2 velocity;

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }
}

In precedenza hai definito PlayArea utilizzando RectangleComponent, quindi è logico che esistano altre forme. CircleComponent, come RectangleComponent, deriva da PositionedComponent, quindi puoi posizionare la palla sullo schermo. Ancora più importante, la sua posizione può essere aggiornata.

Questo componente introduce il concetto di velocity, ovvero il cambiamento di posizione nel tempo. La velocità è un oggetto Vector2 in quanto velocità e direzione. Per aggiornare la posizione, esegui l'override del metodo update, che il motore grafico chiama per ogni frame. dt è la durata tra il frame precedente e questo frame. In questo modo, puoi adattarti a fattori come frame rate diversi (60 Hz o 120 Hz) o frame lunghi dovuti a un numero eccessivo di calcoli.

Presta molta attenzione all'aggiornamento di position += velocity * dt. Ecco come implementare l'aggiornamento di una simulazione discreta del movimento nel tempo.

  1. Per includere il componente Ball nell'elenco dei componenti, modifica il file lib/src/components/components.dart nel seguente modo.

lib/src/components/components.dart

export 'ball.dart';                           // Add this export
export 'play_area.dart';

Aggiungi la palla al mondo

Hai una palla. Posizionalo nel mondo e configuralo per spostarsi nell'area di gioco.

Modifica il file lib/src/brick_breaker.dart come segue.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;                                     // Add this import

import 'package:flame/components.dart';
import 'package:flame/game.dart';

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame {
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  final rand = math.Random();                                   // Add this variable
  double get width => size.x;
  double get height => size.y;

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(
      Ball(                                                     // Add from here...
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2(
          (rand.nextDouble() - 0.5) * width,
          height * 0.2,
        ).normalized()..scale(height / 4),
      ),
    );

    debugMode = true;                                           // To here.
  }
}

Questa modifica aggiunge il componente Ball a world. Per impostare il position della palla al centro dell'area di visualizzazione, il codice dimezza innanzitutto le dimensioni del gioco, poiché Vector2 ha sovraccarichi degli operatori (* e /) per scalare un Vector2 in base a un valore scalare.

Impostare velocity della palla è più complesso. L'obiettivo è spostare la palla verso il basso dello schermo in una direzione casuale a una velocità ragionevole. La chiamata al metodo normalized crea un oggetto Vector2 impostato nella stessa direzione dell'oggetto Vector2 originale, ma ridimensionato a una distanza di 1. In questo modo, la velocità della palla rimane costante indipendentemente dalla direzione in cui va. La velocità della palla viene quindi aumentata fino a raggiungere 1/4 dell'altezza del gioco.

Per ottenere i valori corretti è necessario eseguire alcune iterazioni, note anche come playtest nel settore.

L'ultima riga attiva la visualizzazione di debug, che aggiunge informazioni aggiuntive alla visualizzazione per facilitare il debug.

Quando esegui il gioco, dovrebbe essere simile alla seguente visualizzazione.

Uno screenshot che mostra una finestra dell&#39;applicazione brick_breaker con un cerchio blu sopra il rettangolo color sabbia. Il cerchio blu è annotato con numeri che indicano le dimensioni e la posizione sullo schermo

Sia il componente PlayArea che il componente Ball contengono informazioni di debug, ma le maschere di sfondo ritagliano i numeri di PlayArea. Il motivo per cui vengono visualizzate informazioni di debug è che hai attivato debugMode per l'intero albero dei componenti. Se lo ritieni più utile, puoi anche attivare il debug solo per i componenti selezionati.

Se riavvii il gioco più volte, potresti notare che la palla non interagisce con le pareti come previsto. Per ottenere questo effetto, devi aggiungere il rilevamento delle collisioni, che farai nel passaggio successivo.

6. Saltellare

Aggiungere il rilevamento delle collisioni

Il rilevamento delle collisioni aggiunge un comportamento in cui il gioco riconosce quando due oggetti sono entrati in contatto tra loro.

Per aggiungere il rilevamento delle collisioni al gioco, aggiungi il mixin HasCollisionDetection al gioco BrickBreaker come mostrato nel codice seguente.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flame/components.dart';
import 'package:flame/game.dart';

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame with HasCollisionDetection { // Modify this line
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(
      Ball(
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2(
          (rand.nextDouble() - 0.5) * width,
          height * 0.2,
        ).normalized()..scale(height / 4),
      ),
    );

    debugMode = true;
  }
}

Questo monitora le hitbox dei componenti e attiva i callback di collisione a ogni tick di gioco.

Per iniziare a popolare le hitbox del gioco, modifica il componente PlayArea come mostrato di seguito:

lib/src/components/play_area.dart

import 'dart:async';

import 'package:flame/collisions.dart';                         // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';

class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
  PlayArea()
    : super(
        paint: Paint()..color = const Color(0xfff2e8cf),
        children: [RectangleHitbox()],                          // Add this parameter
      );

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();
    size = Vector2(game.width, game.height);
  }
}

L'aggiunta di un componente RectangleHitbox come elemento secondario di RectangleComponent creerà un riquadro di selezione per il rilevamento delle collisioni che corrisponde alle dimensioni del componente principale. Esiste un costruttore di fabbrica per RectangleHitbox chiamato relative per i casi in cui vuoi una hitbox più piccola o più grande del componente principale.

Far rimbalzare la palla

Finora, l'aggiunta del rilevamento delle collisioni non ha fatto alcuna differenza nel gameplay. Cambia quando modifichi il componente Ball. È il comportamento della palla che deve cambiare quando entra in collisione con PlayArea.

Modifica il componente Ball come segue.

lib/src/components/ball.dart

import 'package:flame/collisions.dart';                         // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';                                 // And this import
import 'play_area.dart';                                        // And this one too

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {   // Add these mixins
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
  }) : super(
         radius: radius,
         anchor: Anchor.center,
         paint: Paint()
           ..color = const Color(0xff1e6091)
           ..style = PaintingStyle.fill,
         children: [CircleHitbox()],                            // Add this parameter
       );

  final Vector2 velocity;

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }

  @override                                                     // Add from here...
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        removeFromParent();
      }
    } else {
      debugPrint('collision with $other');
    }
  }                                                             // To here.
}

Questo esempio apporta una modifica importante con l'aggiunta del callback onCollisionStart. Il sistema di rilevamento delle collisioni aggiunto a BrickBreaker nell'esempio precedente chiama questo callback.

Innanzitutto, il codice verifica se Ball è entrato in collisione con PlayArea. Per ora sembra ridondante, in quanto non ci sono altri componenti nel mondo di gioco. Questo cambierà nel passaggio successivo, quando aggiungerai una mazza al mondo. Poi, aggiunge anche una condizione else per gestire i casi in cui la palla entra in collisione con oggetti diversi dalla mazza. Un promemoria amichevole per implementare la logica rimanente, se vuoi.

Quando la palla si scontra con la parete inferiore, scompare dalla superficie di gioco, ma rimane comunque ben visibile. Gestirai questo artefatto in un passaggio successivo, utilizzando la potenza degli effetti di Flame.

Ora che la palla entra in collisione con le pareti del gioco, sarebbe sicuramente utile dare al giocatore una mazza per colpirla…

7. Colpire la palla con la mazza

Crea il bat

Per aggiungere una mazza per mantenere la palla in gioco,

  1. Inserisci alcune costanti nel file lib/src/config.dart come segue.

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2;                               // Add from here...
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05;                               // To here.

Le costanti batHeight e batWidth non richiedono spiegazioni. La costante batStep, invece, richiede una spiegazione. Per interagire con la palla in questo gioco, il giocatore può trascinare la mazza con il mouse o il dito, a seconda della piattaforma, oppure utilizzare la tastiera. La costante batStep configura la distanza percorsa dal pipistrello a ogni pressione del tasto Freccia sinistra o Freccia destra.

  1. Definisci la classe del componente Bat come segue.

lib/src/components/bat.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';

class Bat extends PositionComponent
    with DragCallbacks, HasGameReference<BrickBreaker> {
  Bat({
    required this.cornerRadius,
    required super.position,
    required super.size,
  }) : super(anchor: Anchor.center, children: [RectangleHitbox()]);

  final Radius cornerRadius;

  final _paint = Paint()
    ..color = const Color(0xff1e6091)
    ..style = PaintingStyle.fill;

  @override
  void render(Canvas canvas) {
    super.render(canvas);
    canvas.drawRRect(
      RRect.fromRectAndRadius(Offset.zero & size.toSize(), cornerRadius),
      _paint,
    );
  }

  @override
  void onDragUpdate(DragUpdateEvent event) {
    super.onDragUpdate(event);
    position.x = (position.x + event.localDelta.x).clamp(0, game.width);
  }

  void moveBy(double dx) {
    add(
      MoveToEffect(
        Vector2((position.x + dx).clamp(0, game.width), position.y),
        EffectController(duration: 0.1),
      ),
    );
  }
}

Questo componente introduce alcune nuove funzionalità.

Innanzitutto, il componente Bat è un PositionComponent, non un RectangleComponent né un CircleComponent. Ciò significa che questo codice deve visualizzare Bat sullo schermo. Per farlo, esegue l'override del callback render.

Se osservi attentamente la chiamata canvas.drawRRect (disegna un rettangolo arrotondato), potresti chiederti: "Dov'è il rettangolo?" Offset.zero & size.toSize() sfrutta un sovraccarico di operator & nella classe dart:ui Offset che crea Rect. Questa abbreviazione potrebbe confonderti all'inizio, ma la vedrai spesso nel codice Flutter e Flame di livello inferiore.

In secondo luogo, questo componente Bat è trascinabile con il dito o il mouse, a seconda della piattaforma. Per implementare questa funzionalità, aggiungi il mixin DragCallbacks ed esegui l'override dell'evento onDragUpdate.

Infine, il componente Bat deve rispondere al controllo da tastiera. La funzione moveBy consente ad altro codice di indicare a questo pipistrello di spostarsi a sinistra o a destra di un determinato numero di pixel virtuali. Questa funzione introduce una nuova funzionalità del motore di gioco Flame: Effects. Se aggiungi l'oggetto MoveToEffect come elemento secondario di questo componente, il giocatore vede la mazza animata in una nuova posizione. In Flame è disponibile una raccolta di Effect per eseguire una serie di effetti.

Gli argomenti del costruttore di Effect includono un riferimento al getter game. Per questo motivo includi il mixin HasGameReference in questa classe. Questo mixin aggiunge un accessor game type-safe a questo componente per accedere all'istanza BrickBreaker nella parte superiore dell'albero dei componenti.

  1. Per rendere disponibile Bat per BrickBreaker, aggiorna il file lib/src/components/components.dart come segue.

lib/src/components/components.dart

export 'ball.dart';
export 'bat.dart';                                              // Add this export
export 'play_area.dart';

Aggiungere la mazza al mondo

Per aggiungere il componente Bat al mondo di gioco, aggiorna BrickBreaker nel seguente modo.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flame/components.dart';
import 'package:flame/events.dart';                             // Add this import
import 'package:flame/game.dart';
import 'package:flutter/material.dart';                         // And this import
import 'package:flutter/services.dart';                         // And this

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents {                // Modify this line
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(
      Ball(
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2(
          (rand.nextDouble() - 0.5) * width,
          height * 0.2,
        ).normalized()..scale(height / 4),
      ),
    );

    world.add(                                                  // Add from here...
      Bat(
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95),
      ),
    );                                                          // To here.

    debugMode = true;
  }

  @override                                                     // Add from here...
  KeyEventResult onKeyEvent(
    KeyEvent event,
    Set<LogicalKeyboardKey> keysPressed,
  ) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
    }
    return KeyEventResult.handled;
  }                                                             // To here.
}

L'aggiunta del mixin KeyboardEvents e del metodo onKeyEvent sottoposto a override gestiscono l'input da tastiera. Recupera il codice aggiunto in precedenza per spostare la mazza della quantità di passi appropriata.

Il blocco di codice rimanente aggiunge il pipistrello al mondo di gioco nella posizione appropriata e con le giuste proporzioni. La presenza di tutte queste impostazioni in questo file semplifica la possibilità di modificare le dimensioni relative della mazza e della palla per ottenere la giusta sensazione di gioco.

Se giochi a questo punto, vedrai che puoi spostare la mazza per intercettare la palla, ma non riceverai alcuna risposta visibile, a parte la registrazione di debug che hai lasciato nel codice di rilevamento delle collisioni di Ball.

È il momento di risolvere il problema. Modifica il componente Ball nel seguente modo.

lib/src/components/ball.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';                           // Add this import
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import 'bat.dart';                                             // And this import
import 'play_area.dart';

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
  }) : super(
         radius: radius,
         anchor: Anchor.center,
         paint: Paint()
           ..color = const Color(0xff1e6091)
           ..style = PaintingStyle.fill,
         children: [CircleHitbox()],
       );

  final Vector2 velocity;

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        add(RemoveEffect(delay: 0.35));                         // Modify from here...
      }
    } else if (other is Bat) {
      velocity.y = -velocity.y;
      velocity.x =
          velocity.x +
          (position.x - other.position.x) / other.size.x * game.width * 0.3;
    } else {                                                    // To here.
      debugPrint('collision with $other');
    }
  }
}

Queste modifiche al codice risolvono due problemi distinti.

Innanzitutto, risolve il problema della scomparsa della palla nel momento in cui tocca la parte inferiore dello schermo. Per risolvere il problema, sostituisci la chiamata removeFromParent con RemoveEffect. Il RemoveEffect rimuove la palla dal mondo di gioco dopo averla lasciata uscire dall'area di gioco visualizzabile.

In secondo luogo, queste modifiche correggono la gestione della collisione tra mazza e palla. Questo codice di gestione è molto favorevole al giocatore. Se il giocatore tocca la palla con la mazza, la palla torna nella parte superiore dello schermo. Se ti sembra troppo indulgente e vuoi qualcosa di più realistico, modifica la manovrabilità per adattarla meglio a come vuoi che sia il tuo gioco.

È importante sottolineare la complessità dell'aggiornamento di velocity. Non inverte solo la componente y della velocità, come avveniva per le collisioni con le pareti. Aggiorna anche il componente x in modo che dipenda dalla posizione relativa della mazza e della palla al momento del contatto. In questo modo, il giocatore ha un maggiore controllo su ciò che fa la palla, ma il modo esatto non viene comunicato in alcun modo al giocatore, se non tramite il gioco.

Ora che hai una mazza con cui colpire la palla, sarebbe bello avere dei mattoni da rompere con la palla.

8. Break down the wall

Crea i mattoncini

Per aggiungere mattoncini al gioco:

  1. Inserisci alcune costanti nel file lib/src/config.dart come segue.

lib/src/config.dart

import 'package:flutter/material.dart';                         // Add this import

const brickColors = [                                           // Add this const
  Color(0xfff94144),
  Color(0xfff3722c),
  Color(0xfff8961e),
  Color(0xfff9844a),
  Color(0xfff9c74f),
  Color(0xff90be6d),
  Color(0xff43aa8b),
  Color(0xff4d908e),
  Color(0xff277da1),
  Color(0xff577590),
];

const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2;
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05;
const brickGutter = gameWidth * 0.015;                          // Add from here...
final brickWidth =
    (gameWidth - (brickGutter * (brickColors.length + 1))) / brickColors.length;
const brickHeight = gameHeight * 0.03;
const difficultyModifier = 1.03;                                // To here.
  1. Inserisci il componente Brick nel seguente modo.

lib/src/components/brick.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';

class Brick extends RectangleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Brick({required super.position, required Color color})
    : super(
        size: Vector2(brickWidth, brickHeight),
        anchor: Anchor.center,
        paint: Paint()
          ..color = color
          ..style = PaintingStyle.fill,
        children: [RectangleHitbox()],
      );

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    removeFromParent();

    if (game.world.children.query<Brick>().length == 1) {
      game.world.removeAll(game.world.children.query<Ball>());
      game.world.removeAll(game.world.children.query<Bat>());
    }
  }
}

A questo punto, la maggior parte di questo codice dovrebbe esserti familiare. Questo codice utilizza un RectangleComponent, con rilevamento delle collisioni e un riferimento type-safe al gioco BrickBreaker nella parte superiore dell'albero dei componenti.

Il nuovo concetto più importante introdotto da questo codice è il modo in cui il giocatore raggiunge la condizione di vittoria. Il controllo delle condizioni di vittoria esegue una query nel mondo per i mattoncini e conferma che ne rimane solo uno. Potrebbe essere un po' complicato, perché la riga precedente rimuove questo blocco dal relativo elemento principale.

Il punto fondamentale da capire è che la rimozione dei componenti è un comando in coda. Rimuove il blocco dopo l'esecuzione di questo codice, ma prima del successivo tick del mondo di gioco.

Per rendere il componente Brick accessibile a BrickBreaker, modifica lib/src/components/components.dart nel seguente modo.

lib/src/components/components.dart

export 'ball.dart';
export 'bat.dart';
export 'brick.dart';                                            // Add this export
export 'play_area.dart';

Aggiungere mattoncini al mondo

Aggiorna il componente Ball come segue.

lib/src/components/ball.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart';                                            // Add this import
import 'play_area.dart';

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
    required this.difficultyModifier,                           // Add this parameter
  }) : super(
         radius: radius,
         anchor: Anchor.center,
         paint: Paint()
           ..color = const Color(0xff1e6091)
           ..style = PaintingStyle.fill,
         children: [CircleHitbox()],
       );

  final Vector2 velocity;
  final double difficultyModifier;                              // Add this member

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        add(RemoveEffect(delay: 0.35));
      }
    } else if (other is Bat) {
      velocity.y = -velocity.y;
      velocity.x =
          velocity.x +
          (position.x - other.position.x) / other.size.x * game.width * 0.3;
    } else if (other is Brick) {                                // Modify from here...
      if (position.y < other.position.y - other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.y > other.position.y + other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.x < other.position.x) {
        velocity.x = -velocity.x;
      } else if (position.x > other.position.x) {
        velocity.x = -velocity.x;
      }
      velocity.setFrom(velocity * difficultyModifier);          // To here.
    }
  }
}

Viene introdotto l'unico nuovo aspetto, un modificatore di difficoltà che aumenta la velocità della palla dopo ogni collisione con un mattone. Questo parametro regolabile deve essere testato per trovare la curva di difficoltà appropriata per il tuo gioco.

Modifica il gioco BrickBreaker nel seguente modo.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents {
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(
      Ball(
        difficultyModifier: difficultyModifier,                 // Add this argument
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2(
          (rand.nextDouble() - 0.5) * width,
          height * 0.2,
        ).normalized()..scale(height / 4),
      ),
    );

    world.add(
      Bat(
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95),
      ),
    );

    await world.addAll([                                        // Add from here...
      for (var i = 0; i < brickColors.length; i++)
        for (var j = 1; j <= 5; j++)
          Brick(
            position: Vector2(
              (i + 0.5) * brickWidth + (i + 1) * brickGutter,
              (j + 2.0) * brickHeight + j * brickGutter,
            ),
            color: brickColors[i],
          ),
    ]);                                                         // To here.

    debugMode = true;
  }

  @override
  KeyEventResult onKeyEvent(
    KeyEvent event,
    Set<LogicalKeyboardKey> keysPressed,
  ) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
    }
    return KeyEventResult.handled;
  }
}

Se esegui il gioco, vengono visualizzate tutte le meccaniche di gioco principali. Potresti disattivare il debug e considerare il problema risolto, ma sembra che manchi qualcosa.

Uno screenshot che mostra brick_breaker con la palla, la mazza e la maggior parte dei mattoni nell&#39;area di gioco. Ciascun componente ha etichette di debug

Che ne dici di una schermata di benvenuto, una schermata di game over e magari un punteggio? Flutter può aggiungere queste funzionalità al gioco, quindi è qui che dovrai concentrare la tua attenzione.

9. Vincere la partita

Aggiungere stati di riproduzione

In questo passaggio, incorpori il gioco Flame all'interno di un wrapper Flutter e poi aggiungi overlay Flutter per le schermate di benvenuto, fine partita e vittoria.

Innanzitutto, modifichi i file di gioco e dei componenti per implementare uno stato di riproduzione che rifletta se mostrare una sovrapposizione e, in caso affermativo, quale.

  1. Modifica il gioco BrickBreaker nel seguente modo.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import 'components/components.dart';
import 'config.dart';

enum PlayState { welcome, playing, gameOver, won }              // Add this enumeration

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents, TapDetector {   // Modify this line
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

  late PlayState _playState;                                    // Add from here...
  PlayState get playState => _playState;
  set playState(PlayState playState) {
    _playState = playState;
    switch (playState) {
      case PlayState.welcome:
      case PlayState.gameOver:
      case PlayState.won:
        overlays.add(playState.name);
      case PlayState.playing:
        overlays.remove(PlayState.welcome.name);
        overlays.remove(PlayState.gameOver.name);
        overlays.remove(PlayState.won.name);
    }
  }                                                             // To here.

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    playState = PlayState.welcome;                              // Add from here...
  }

  void startGame() {
    if (playState == PlayState.playing) return;

    world.removeAll(world.children.query<Ball>());
    world.removeAll(world.children.query<Bat>());
    world.removeAll(world.children.query<Brick>());

    playState = PlayState.playing;                              // To here.

    world.add(
      Ball(
        difficultyModifier: difficultyModifier,
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2(
          (rand.nextDouble() - 0.5) * width,
          height * 0.2,
        ).normalized()..scale(height / 4),
      ),
    );

    world.add(
      Bat(
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95),
      ),
    );

    world.addAll([                                              // Drop the await
      for (var i = 0; i < brickColors.length; i++)
        for (var j = 1; j <= 5; j++)
          Brick(
            position: Vector2(
              (i + 0.5) * brickWidth + (i + 1) * brickGutter,
              (j + 2.0) * brickHeight + j * brickGutter,
            ),
            color: brickColors[i],
          ),
    ]);
  }                                                             // Drop the debugMode

  @override                                                     // Add from here...
  void onTap() {
    super.onTap();
    startGame();
  }                                                             // To here.

  @override
  KeyEventResult onKeyEvent(
    KeyEvent event,
    Set<LogicalKeyboardKey> keysPressed,
  ) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
      case LogicalKeyboardKey.space:                            // Add from here...
      case LogicalKeyboardKey.enter:
        startGame();                                            // To here.
    }
    return KeyEventResult.handled;
  }

  @override
  Color backgroundColor() => const Color(0xfff2e8cf);          // Add this override
}

Questo codice cambia gran parte del gioco BrickBreaker. L'aggiunta dell'enumerazione playState richiede molto lavoro. Viene acquisito il punto in cui il giocatore si trova durante l'inserimento, la partita e la sconfitta o la vittoria. Nella parte superiore del file, definisci l'enumerazione, quindi la istanzi come stato nascosto con getter e setter corrispondenti. Questi getter e setter consentono di modificare le sovrapposizioni quando le varie parti del gioco attivano le transizioni dello stato di riproduzione.

Successivamente, dividi il codice in onLoad in onLoad e in un nuovo metodo startGame. Prima di questa modifica, potevi iniziare una nuova partita solo riavviando il gioco. Con queste nuove aggiunte, il giocatore può ora iniziare una nuova partita senza misure così drastiche.

Per consentire al giocatore di iniziare una nuova partita, hai configurato due nuovi gestori per il gioco. Hai aggiunto un gestore di tocco ed esteso il gestore della tastiera per consentire all'utente di iniziare una nuova partita in più modalità. Con lo stato di gioco modellato, avrebbe senso aggiornare i componenti per attivare le transizioni dello stato di gioco quando il giocatore vince o perde.

  1. Modifica il componente Ball come segue.

lib/src/components/ball.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart';
import 'play_area.dart';

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
    required this.difficultyModifier,
  }) : super(
         radius: radius,
         anchor: Anchor.center,
         paint: Paint()
           ..color = const Color(0xff1e6091)
           ..style = PaintingStyle.fill,
         children: [CircleHitbox()],
       );

  final Vector2 velocity;
  final double difficultyModifier;

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        add(
          RemoveEffect(
            delay: 0.35,
            onComplete: () {                                    // Modify from here
              game.playState = PlayState.gameOver;
            },
          ),
        );                                                      // To here.
      }
    } else if (other is Bat) {
      velocity.y = -velocity.y;
      velocity.x =
          velocity.x +
          (position.x - other.position.x) / other.size.x * game.width * 0.3;
    } else if (other is Brick) {
      if (position.y < other.position.y - other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.y > other.position.y + other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.x < other.position.x) {
        velocity.x = -velocity.x;
      } else if (position.x > other.position.x) {
        velocity.x = -velocity.x;
      }
      velocity.setFrom(velocity * difficultyModifier);
    }
  }
}

Questa piccola modifica aggiunge un callback onComplete a RemoveEffect che attiva lo stato di riproduzione gameOver. Dovrebbe essere corretto se il giocatore lascia uscire la palla dalla parte inferiore dello schermo.

  1. Modifica il componente Brick nel seguente modo.

lib/src/components/brick.dart

impimport 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';

class Brick extends RectangleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Brick({required super.position, required Color color})
    : super(
        size: Vector2(brickWidth, brickHeight),
        anchor: Anchor.center,
        paint: Paint()
          ..color = color
          ..style = PaintingStyle.fill,
        children: [RectangleHitbox()],
      );

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    removeFromParent();

    if (game.world.children.query<Brick>().length == 1) {
      game.playState = PlayState.won;                          // Add this line
      game.world.removeAll(game.world.children.query<Ball>());
      game.world.removeAll(game.world.children.query<Bat>());
    }
  }
}

Se invece il giocatore riesce a rompere tutti i mattoni, viene visualizzata la schermata "Partita vinta". Ben fatto, giocatore, ben fatto!

Aggiungi il wrapper Flutter

Per fornire un punto in cui incorporare il gioco e aggiungere overlay dello stato di gioco, aggiungi la shell Flutter.

  1. Crea una directory widgets in lib/src.
  2. Aggiungi un file game_app.dart e inserisci i seguenti contenuti.

lib/src/widgets/game_app.dart

import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';

import '../brick_breaker.dart';
import '../config.dart';

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        textTheme: GoogleFonts.pressStart2pTextTheme().apply(
          bodyColor: const Color(0xff184e77),
          displayColor: const Color(0xff184e77),
        ),
      ),
      home: Scaffold(
        body: Container(
          decoration: const BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [Color(0xffa9d6e5), Color(0xfff2e8cf)],
            ),
          ),
          child: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Center(
                child: FittedBox(
                  child: SizedBox(
                    width: gameWidth,
                    height: gameHeight,
                    child: GameWidget.controlled(
                      gameFactory: BrickBreaker.new,
                      overlayBuilderMap: {
                        PlayState.welcome.name: (context, game) => Center(
                          child: Text(
                            'TAP TO PLAY',
                            style: Theme.of(context).textTheme.headlineLarge,
                          ),
                        ),
                        PlayState.gameOver.name: (context, game) => Center(
                          child: Text(
                            'G A M E   O V E R',
                            style: Theme.of(context).textTheme.headlineLarge,
                          ),
                        ),
                        PlayState.won.name: (context, game) => Center(
                          child: Text(
                            'Y O U   W O N ! ! !',
                            style: Theme.of(context).textTheme.headlineLarge,
                          ),
                        ),
                      },
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

La maggior parte dei contenuti di questo file segue una creazione standard dell'albero dei widget di Flutter. Le parti specifiche di Flame includono l'utilizzo di GameWidget.controlled per creare e gestire l'istanza di gioco BrickBreaker e il nuovo argomento overlayBuilderMap per GameWidget.

Le chiavi di questo overlayBuilderMap devono essere allineate alle sovrapposizioni aggiunte o rimosse dall'impostatore playState in BrickBreaker. Il tentativo di impostare una sovrapposizione non presente in questa mappa porta a faccine tristi ovunque.

  1. Per visualizzare questa nuova funzionalità sullo schermo, sostituisci il file lib/main.dart con i seguenti contenuti.

lib/main.dart

import 'package:flutter/material.dart';

import 'src/widgets/game_app.dart';

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

Se esegui questo codice su iOS, Linux, Windows o sul web, l'output previsto viene visualizzato nel gioco. Se scegli come target macOS o Android, devi apportare un'ultima modifica per attivare la visualizzazione di google_fonts.

Abilitare l'accesso ai caratteri

Aggiungere l'autorizzazione internet per Android

Per Android, devi aggiungere l'autorizzazione Internet. Modifica il tuo AndroidManifest.xml come segue.

android/app/src/main/AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- Add the following line -->
    <uses-permission android:name="android.permission.INTERNET" />
    <application
        android:label="brick_breaker"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher">
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:launchMode="singleTop"
            android:taskAffinity=""
            android:theme="@style/LaunchTheme"
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize">
            <!-- Specifies an Android theme to apply to this Activity as soon as
                 the Android process has started. This theme is visible to the user
                 while the Flutter UI initializes. After that, this theme continues
                 to determine the Window background behind the Flutter UI. -->
            <meta-data
              android:name="io.flutter.embedding.android.NormalTheme"
              android:resource="@style/NormalTheme"
              />
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
        <!-- Don't delete the meta-data below.
             This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
        <meta-data
            android:name="flutterEmbedding"
            android:value="2" />
    </application>
    <!-- Required to query activities that can process text, see:
         https://developer.android.com/training/package-visibility and
         https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.

         In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
    <queries>
        <intent>
            <action android:name="android.intent.action.PROCESS_TEXT"/>
            <data android:mimeType="text/plain"/>
        </intent>
    </queries>
</manifest>

Modificare i file di diritti per macOS

Per macOS, devi modificare due file.

  1. Modifica il file DebugProfile.entitlements in modo che corrisponda al seguente codice.

macos/Runner/DebugProfile.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <key>com.apple.security.cs.allow-jit</key>
        <true/>
        <key>com.apple.security.network.server</key>
        <true/>
        <!-- Add from here... -->
        <key>com.apple.security.network.client</key>
        <true/>
        <!-- to here. -->
</dict>
</plist>
  1. Modifica il file Release.entitlements in modo che corrisponda al seguente codice

macos/Runner/Release.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <!-- Add from here... -->
        <key>com.apple.security.network.client</key>
        <true/>
        <!-- to here. -->
</dict>
</plist>

Se lo esegui così com'è, dovrebbe visualizzare una schermata di benvenuto e una schermata di fine partita o di vittoria su tutte le piattaforme. Queste schermate potrebbero essere un po' semplicistiche e sarebbe bello avere un punteggio. Indovina cosa farai nel passaggio successivo.

10. Tenere il punteggio

Aggiungere il punteggio alla partita

In questo passaggio, esponi il punteggio della partita al contesto Flutter circostante. In questo passaggio esponi lo stato del gioco Flame alla gestione dello stato di Flutter circostante. In questo modo, il codice del gioco può aggiornare il punteggio ogni volta che il giocatore rompe un blocco.

  1. Modifica il gioco BrickBreaker nel seguente modo.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import 'components/components.dart';
import 'config.dart';

enum PlayState { welcome, playing, gameOver, won }

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents, TapDetector {
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  final ValueNotifier<int> score = ValueNotifier(0);            // Add this line
  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

  late PlayState _playState;
  PlayState get playState => _playState;
  set playState(PlayState playState) {
    _playState = playState;
    switch (playState) {
      case PlayState.welcome:
      case PlayState.gameOver:
      case PlayState.won:
        overlays.add(playState.name);
      case PlayState.playing:
        overlays.remove(PlayState.welcome.name);
        overlays.remove(PlayState.gameOver.name);
        overlays.remove(PlayState.won.name);
    }
  }

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    playState = PlayState.welcome;
  }

  void startGame() {
    if (playState == PlayState.playing) return;

    world.removeAll(world.children.query<Ball>());
    world.removeAll(world.children.query<Bat>());
    world.removeAll(world.children.query<Brick>());

    playState = PlayState.playing;
    score.value = 0;                                            // Add this line

    world.add(
      Ball(
        difficultyModifier: difficultyModifier,
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2(
          (rand.nextDouble() - 0.5) * width,
          height * 0.2,
        ).normalized()..scale(height / 4),
      ),
    );

    world.add(
      Bat(
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95),
      ),
    );

    world.addAll([
      for (var i = 0; i < brickColors.length; i++)
        for (var j = 1; j <= 5; j++)
          Brick(
            position: Vector2(
              (i + 0.5) * brickWidth + (i + 1) * brickGutter,
              (j + 2.0) * brickHeight + j * brickGutter,
            ),
            color: brickColors[i],
          ),
    ]);
  }

  @override
  void onTap() {
    super.onTap();
    startGame();
  }

  @override
  KeyEventResult onKeyEvent(
    KeyEvent event,
    Set<LogicalKeyboardKey> keysPressed,
  ) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
      case LogicalKeyboardKey.space:
      case LogicalKeyboardKey.enter:
        startGame();
    }
    return KeyEventResult.handled;
  }

  @override
  Color backgroundColor() => const Color(0xfff2e8cf);
}

Se aggiungi score al gioco, colleghi lo stato del gioco alla gestione dello stato di Flutter.

  1. Modifica la classe Brick per aggiungere un punto al punteggio quando il giocatore rompe i mattoncini.

lib/src/components/brick.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';

class Brick extends RectangleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Brick({required super.position, required Color color})
    : super(
        size: Vector2(brickWidth, brickHeight),
        anchor: Anchor.center,
        paint: Paint()
          ..color = color
          ..style = PaintingStyle.fill,
        children: [RectangleHitbox()],
      );

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    removeFromParent();
    game.score.value++;                                         // Add this line

    if (game.world.children.query<Brick>().length == 1) {
      game.playState = PlayState.won;
      game.world.removeAll(game.world.children.query<Ball>());
      game.world.removeAll(game.world.children.query<Bat>());
    }
  }
}

Creare un gioco accattivante

Ora che puoi tenere il punteggio in Flutter, è il momento di assemblare i widget per renderlo accattivante.

  1. Crea score_card.dart in lib/src/widgets e aggiungi quanto segue.

lib/src/widgets/score_card.dart

import 'package:flutter/material.dart';

class ScoreCard extends StatelessWidget {
  const ScoreCard({super.key, required this.score});

  final ValueNotifier<int> score;

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<int>(
      valueListenable: score,
      builder: (context, score, child) {
        return Padding(
          padding: const EdgeInsets.fromLTRB(12, 6, 12, 18),
          child: Text(
            'Score: $score'.toUpperCase(),
            style: Theme.of(context).textTheme.titleLarge!,
          ),
        );
      },
    );
  }
}
  1. Crea overlay_screen.dart in lib/src/widgets e aggiungi il seguente codice.

In questo modo, gli overlay vengono perfezionati utilizzando la potenza del pacchetto flutter_animate per aggiungere movimento e stile alle schermate di overlay.

lib/src/widgets/overlay_screen.dart

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

class OverlayScreen extends StatelessWidget {
  const OverlayScreen({super.key, required this.title, required this.subtitle});

  final String title;
  final String subtitle;

  @override
  Widget build(BuildContext context) {
    return Container(
      alignment: const Alignment(0, -0.15),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Text(
            title,
            style: Theme.of(context).textTheme.headlineLarge,
          ).animate().slideY(duration: 750.ms, begin: -3, end: 0),
          const SizedBox(height: 16),
          Text(subtitle, style: Theme.of(context).textTheme.headlineSmall)
              .animate(onPlay: (controller) => controller.repeat())
              .fadeIn(duration: 1.seconds)
              .then()
              .fadeOut(duration: 1.seconds),
        ],
      ),
    );
  }
}

Per scoprire in modo più approfondito la potenza di flutter_animate, consulta il codelab Building next generation UIs in Flutter.

Questo codice è cambiato molto nel componente GameApp. Innanzitutto, per consentire a ScoreCard di accedere a score , devi convertirlo da StatelessWidget a StatefulWidget. L'aggiunta della scheda del punteggio richiede l'aggiunta di un Column per impilare il punteggio sopra la partita.

In secondo luogo, per migliorare le esperienze di benvenuto, game over e vittoria, hai aggiunto il nuovo widget OverlayScreen.

lib/src/widgets/game_app.dart

import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';

import '../brick_breaker.dart';
import '../config.dart';
import 'overlay_screen.dart';                                   // Add this import
import 'score_card.dart';                                       // And this one too

class GameApp extends StatefulWidget {                          // Modify this line
  const GameApp({super.key});

  @override                                                     // Add from here...
  State<GameApp> createState() => _GameAppState();
}

class _GameAppState extends State<GameApp> {
  late final BrickBreaker game;

  @override
  void initState() {
    super.initState();
    game = BrickBreaker();
  }                                                             // To here.

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        textTheme: GoogleFonts.pressStart2pTextTheme().apply(
          bodyColor: const Color(0xff184e77),
          displayColor: const Color(0xff184e77),
        ),
      ),
      home: Scaffold(
        body: Container(
          decoration: const BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [Color(0xffa9d6e5), Color(0xfff2e8cf)],
            ),
          ),
          child: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Center(
                child: Column(                                  // Modify from here...
                  children: [
                    ScoreCard(score: game.score),
                    Expanded(
                      child: FittedBox(
                        child: SizedBox(
                          width: gameWidth,
                          height: gameHeight,
                          child: GameWidget(
                            game: game,
                            overlayBuilderMap: {
                              PlayState.welcome.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'TAP TO PLAY',
                                    subtitle: 'Use arrow keys or swipe',
                                  ),
                              PlayState.gameOver.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'G A M E   O V E R',
                                    subtitle: 'Tap to Play Again',
                                  ),
                              PlayState.won.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'Y O U   W O N ! ! !',
                                    subtitle: 'Tap to Play Again',
                                  ),
                            },
                          ),
                        ),
                      ),
                    ),
                  ],
                ),                                              // To here.
              ),
            ),
          ),
        ),
      ),
    );
  }
}

Con tutto questo a posto, ora dovresti essere in grado di eseguire questo gioco su una qualsiasi delle sei piattaforme di destinazione Flutter. Il gioco dovrebbe essere simile al seguente.

Uno screenshot di brick_breaker che mostra la schermata pre-partita che invita l&#39;utente a toccare lo schermo per giocare

Uno screenshot di brick_breaker che mostra la schermata di game over sovrapposta a una mazza e ad alcuni mattoni

11. Complimenti

Congratulazioni, sei riuscito a creare un gioco con Flutter e Flame.

Hai creato un gioco utilizzando il motore di gioco 2D Flame e lo hai incorporato in un wrapper Flutter. Hai utilizzato gli effetti di Flame per animare e rimuovere i componenti. Hai utilizzato i pacchetti Google Fonts e Flutter Animate per dare un aspetto ben progettato all'intero gioco.

Passaggi successivi

Dai un'occhiata ad alcuni di questi codelab…

Per approfondire