Introduzione a Flame con Flutter

1. Introduzione

Flame è un motore grafico 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 Fiamma per disegnare la mazza, la palla e i mattoni. Utilizzerai gli effetti di Fiamma per animare il movimento della mazza e scoprirai come integrare Flame con il sistema di gestione degli stati di Flutter.

Al termine, il gioco dovrebbe avere l'aspetto di questa gif animata, anche se un po' più lento.

La registrazione dello schermo di un gioco in corso. Il gioco è stato notevolmente accelerato.

Obiettivi didattici

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

Cosa creerai

In questo codelab, creerai un gioco in 2D con Flutter e Flame. Al termine, il tuo gioco dovrebbe soddisfare i seguenti requisiti

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

2. Configurare l'ambiente Flutter

Editor

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

Puoi utilizzare qualsiasi editor tu voglia: Android Studio, altri IDE IntelliJ, Emacs, Vim o Notepad++. Tutti funzionano con Flutter.

Uno screenshot di VS Code con codice Flutter

Scegli un target di sviluppo

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

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

È pratica comune scegliere un sistema operativo come target di sviluppo. Si tratta del sistema operativo su cui viene eseguita la tua app durante lo sviluppo.

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

Ad esempio, supponiamo che tu stia utilizzando un laptop Windows per sviluppare l'app Flutter. Dopodiché 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. Potresti aver scelto Windows come destinazione di sviluppo, che esegue la tua app in fase di sviluppo come app Windows insieme al tuo editor.

Potresti avere la tentazione di scegliere il web come target per lo sviluppo. Questo presenta uno svantaggio durante lo sviluppo: perderai la funzionalità Stateful Hot Reload di Flutter. Al momento Flutter non può ricaricare le applicazioni web a caldo.

Scegli l'opzione che preferisci prima di continuare. Puoi sempre eseguire l'app su altri sistemi operativi in un secondo momento. La scelta di un target di sviluppo semplifica il passaggio successivo.

Installa Flutter

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

Le istruzioni sul sito web di Flutter riguardano l'installazione dell'SDK, gli strumenti relativi ai target di sviluppo e 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. Compilatore del software per il target di sviluppo scelto. (devi avere Visual Studio per scegliere Windows o Xcode come target per macOS o iOS)

Nella sezione successiva creerai il tuo primo progetto Flutter.

Se hai bisogno di risolvere dei problemi, potresti trovare utili alcune di queste domande e risposte (di StackOverflow).

Domande frequenti

3. Creare un progetto

Crea il tuo primo progetto Flutter

Dovrai aprire VS Code e creare il modello dell'app Flutter in una directory di tua scelta.

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

Uno screenshot di VS Code con

  1. Seleziona Vuota applicazione. Scegli una directory in cui creare il progetto. Deve essere qualsiasi directory che non richiede privilegi elevati o non abbia uno spazio nel percorso. Alcuni esempi sono la tua home directory o C:\src\.

Uno screenshot di VS Code con applicazione vuota mostrata come selezionato nell'ambito del flusso della nuova applicazione

  1. Assegna al progetto il nome brick_breaker. Nella parte restante di questo codelab si presuppone che tu abbia assegnato alla tua app il nome brick_breaker.

Uno screenshot di VS Code con

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

Copia e Incolla l'app iniziale

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

  1. Nel riquadro sinistro 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 il contenuto di questo file con il seguente:

pubspec.yaml

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

environment:
  sdk: '>=3.3.0 <4.0.0'

dependencies:
  flame: ^1.16.0
  flutter:
    sdk: flutter
  flutter_animate: ^4.5.0
  google_fonts: ^6.1.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^3.0.1

flutter:
  uses-material-design: true

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

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

  1. Sostituisci il contenuto di questo file con il seguente:

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 rendering del peggior videogioco al mondo è ora a 60 f/s.

Uno screenshot che mostra la finestra dell&#39;applicazione Brick_breaker completamente nera.

4. Crea il gioco

Misura il gioco

Un gioco in due dimensioni (2D) richiede un'area di gioco. Creerai un'area di dimensioni specifiche che potrai utilizzare per definire le dimensioni di altri aspetti del gioco.

Esistono diversi 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 verso destra lungo l'asse x e verso l'alto lungo l'asse y. Questo standard si applica alla maggior parte dei giochi attuali, in particolare quando si tratta di giochi che prevedono tre dimensioni.

La convenzione in cui è stato creato il gioco Breakout originale era quella di impostare l'origine nell'angolo in alto a sinistra. La direzione x positiva è rimasta la stessa, ma la direzione y è stata invertita. La direzione x positiva per l'asse x era corretta, mentre la direzione 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 chiamata lib/src. Questo file acquisirà più costanti nei passaggi successivi.

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;

Le dimensioni del gioco saranno 820 pixel di larghezza per 1600 pixel di altezza. L'area di gioco viene ridimensionata per adattarsi alla finestra in cui viene visualizzata, ma tutti i componenti aggiunti allo schermo si adattano a questa altezza e larghezza.

Crea un'area giochi

Nel gioco Breakout, la palla rimbalza contro le pareti dell'area giochi. Per far fronte alle collisioni, è necessario prima un componente PlayArea.

  1. Crea un file denominato play_area.dart in una nuova directory chiamata 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, Fiamma ha Component. Le app Flutter consistono nella creazione di alberi di widget, mentre i giochi di fiamme consistono nel mantenere gli alberi di componenti.

Qui c'è una differenza interessante tra Flutter e Flame. La struttura di widget di Flutter è una descrizione temporanea creata per essere utilizzata per aggiornare il livello RenderObject permanente e modificabile. I componenti di Flame sono permanenti 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 inizia con il ciclo di gioco, descritto 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 contenuti seguenti.

lib/src/components/components.dart

export 'play_area.dart';

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

Crea un gioco Flame

Per spegnere gli scarafaggi rossi dal passaggio precedente, genera una nuova sottoclasse per FlameGame di Fiamma.

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

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 il letterbox come richiesto.

Puoi mostrare la larghezza e l'altezza del gioco in modo che i componenti secondari, come PlayArea, possano impostarsi sulle dimensioni appropriate.

Nel metodo con override onLoad, il codice esegue due azioni.

  1. Consente di configurare la parte in alto a sinistra come ancoraggio del mirino. Per impostazione predefinita, il mirino utilizza la parte centrale dell'area come ancoraggio per (0,0).
  2. Aggiunge PlayArea a world. Il mondo rappresenta il mondo del gioco. Proietta tutti gli elementi secondari tramite la trasformazione della vista di CameraComponent.

Scarica il gioco sullo schermo

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

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 seguente figura.

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

Nel prossimo passaggio, aggiungerai una palla al mondo e la farai muovere!

5. Mostra la palla

Creare il componente palla

Per inserire una palla in movimento sullo schermo è necessario 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 definizione di costanti denominate come valori derivati restituirà molte volte in questo codelab. In questo modo puoi modificare i gameWidth e le gameHeight di primo livello per scoprire come l'aspetto del gioco cambia 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 il PlayArea utilizzando il RectangleComponent, quindi è ovvio che esistono più forme. CircleComponent, come RectangleComponent, deriva da PositionedComponent, quindi puoi posizionare la palla sullo schermo. Ma soprattutto, la sua posizione può essere aggiornata.

Questo componente introduce il concetto di velocity o il cambiamento di posizione nel tempo. La velocità è un oggetto Vector2, in quanto la velocità è sia velocità che direzione. Per aggiornare la posizione, sostituisci il metodo update, che il motore grafico chiama per ogni frame. dt è la durata tra il frame precedente e questo frame. Ciò ti consente di adattarti a fattori quali frequenze fotogrammi diverse (60 Hz o 120 Hz) o frame lunghi a causa di calcoli eccessivi.

Presta particolare attenzione all'aggiornamento di position += velocity * dt. Questo è il modo in cui implementi 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 come segue.

lib/src/components/components.dart

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

Aggiungere la palla al mondo

Hai una palla. Colloca il dispositivo nel mondo reale e lo collochiamo 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 innanzitutto dimezza la dimensione della partita, poiché Vector2 ha sovraccarichi dell'operatore (* e /) per scalare un Vector2 in base a un valore scalare.

Impostare il velocity della palla richiede una maggiore complessità. L'intento è quello di far spostare la pallina verso il basso sullo schermo in una direzione casuale a una velocità ragionevole. La chiamata al metodo normalized crea un oggetto Vector2 impostato nella stessa direzione dell'elemento Vector2 originale, ma ridimensionato a una distanza pari a 1. In questo modo la velocità della palla rimane costante, indipendentemente dalla direzione in cui si muove. La velocità della palla viene poi scalata fino a 1/4 dell'altezza della partita.

Per ottenere i vari valori corretti è necessario un processo di iterazione, noto anche come test di gioco nel settore.

L'ultima riga attiva la visualizzazione di debug, che aggiunge ulteriori informazioni al display per facilitare il debug.

A questo punto, il gioco dovrebbe essere simile alla seguente.

Uno screenshot che mostra la finestra dell&#39;applicazione Brick_breaker con un cerchio blu sopra un rettangolo color sabbia. Sul cerchio blu sono annotati dei numeri che indicano le dimensioni e la posizione sullo schermo

Sia il componente PlayArea sia il componente Ball hanno informazioni di debug, ma i mattini di sfondo ritagliano i numeri di PlayArea. Sono visualizzate tutte le informazioni di debug perché hai attivato debugMode per l'intera struttura ad albero dei componenti. Puoi anche attivare il debug solo per determinati componenti, se questo è più utile.

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

6. Rimbalzo qua e là

Aggiungi rilevamento delle collisioni

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

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

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;
  }
}

Questa opzione tiene traccia delle hitbox dei componenti e attiva i callback di collisione a ogni tick di gioco.

Per iniziare a completare 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);
  }
}

Se aggiungi un componente RectangleHitbox come figlio di RectangleComponent, verrà creata una casella di hit per il rilevamento delle collisioni corrispondente alle dimensioni del componente principale. Esiste un costruttore di fabbrica per RectangleHitbox chiamato relative per le volte in cui vuoi una hitbox più piccola o più grande rispetto al componente principale.

Rimbalzo la pallina

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

In primo luogo, il codice verifica se Ball è in conflitto con PlayArea. Per il momento sembra ridondante, in quanto non ci sono altri componenti nel mondo del gioco. Questo cambierà nel passaggio successivo, quando aggiungerai una mazza al mondo. Inoltre, aggiunge anche una condizione else da gestire quando la pallina colpisce con oggetti che non sono la mazza. Un breve promemoria per ricordarti di implementare la logica rimanente, se vuoi.

Quando la palla si scontra con la parete inferiore scompare dalla superficie di gioco mentre è ancora molto visibile. Gestisci questo artefatto in un passaggio successivo, usando il potere degli effetti della Fiamma.

Ora che la palla si scontra con le pareti del gioco, sarebbe utile dare al giocatore una mazza per colpirla con...

7. Metti la mazza sulla palla

Crea la mazza

Per aggiungere una mazza per mantenere la palla in gioco durante la partita,

  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 sono autoesplicative. 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 l'ampiezza dei passi della mazza per ogni pressione dei tasti 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 Pipistrello è PositionComponent, non RectangleComponentCircleComponent. Questo codice deve eseguire il rendering di Bat sullo schermo. A questo scopo, esegue l'override del callback render.

Osservando attentamente la chiamata canvas.drawRRect (disegna un rettangolo arrotondato), potresti chiederti "dove si trova il rettangolo?" Offset.zero & size.toSize() sfrutta un sovraccarico di operator & sulla classe Offset dart:ui che crea Rect. Questa forma breve potrebbe confonderti all'inizio, ma la vedrai spesso nei codici Flutter e Flame di livello inferiore.

In secondo luogo, puoi trascinare questo componente Bat con un dito o con il mouse, a seconda della piattaforma. Per implementare questa funzionalità, aggiungi il mix DragCallbacks e sostituisci l'evento onDragUpdate.

Infine, il componente Bat deve rispondere al controllo da tastiera. La funzione moveBy consente a un altro codice di indicare alla mazza di spostarsi verso sinistra o verso 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. È disponibile una raccolta di Effect in Flame per eseguire una serie di effetti.

Gli argomenti del costruttore dell'effetto includono un riferimento al getter game. Ecco perché includi il mix HasGameReference in questo corso. Questo mixin aggiunge a questo componente una funzione di accesso game sicura per il tipo per accedere all'istanza BrickBreaker nella parte superiore della struttura ad albero dei componenti.

  1. Per rendere Bat disponibile 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';

Aggiungi la mazza al mondo

Per aggiungere il componente Bat al mondo di gioco, aggiorna BrickBreaker come segue.

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(Bat(                                              // Add from here...
        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 con override gestiscono l'input da tastiera. Ricorda il codice che hai aggiunto in precedenza per spostare la mazza del numero di passi appropriato.

La porzione rimanente di codice aggiunto aggiunge la mazza al mondo di gioco nella posizione appropriata e con le giuste proporzioni. Avendo tutte queste impostazioni esposte in questo file, semplifichi la tua capacità di modificare la dimensione relativa della mazza e della pallina per ottenere la sensazione di gioco giusta.

Se giochi a questo punto, vedrai che puoi muovere la mazza per intercettare la palla, ma non ricevi alcuna risposta visibile, a parte il logging di debug lasciato nel codice di rilevamento collisioni di Ball.

È ora di risolvere il problema. 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';                           // 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(                                       // Modify from here...
          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 {                                                    // To here.
      debugPrint('collision with $other');
    }
  }
}

Queste modifiche al codice risolvono due problemi distinti.

In primo luogo, fissa la pallina che salta fuori dal momento in cui tocca la parte inferiore dello schermo. Per risolvere il problema, sostituisci la chiamata removeFromParent con RemoveEffect. La RemoveEffect rimuove la palla dal mondo di gioco dopo averla lasciata uscire dall'area di gioco visibile.

In secondo luogo, queste modifiche migliorano la gestione degli scontri tra mazza e pallina. Questa gestione del codice è molto a favore del giocatore. Finché il giocatore tocca la palla con la mazza, la palla torna nella parte superiore dello schermo. Se ti sembra troppo permissivo e vuoi qualcosa di più realistico, modifica questa modalità di gestione per adattarla meglio a come vuoi che appaia il gioco.

Vale la pena sottolineare la complessità dell'aggiornamento di velocity. Non inverte semplicemente il componente y della velocità, come è stato fatto per le collisioni delle pareti. Inoltre, aggiorna il componente x in modo che dipenda dalla posizione relativa della mazza e della pallina al momento del contatto. Ciò offre al giocatore un maggiore controllo su ciò che fa la palla, ma esattamente come non viene comunicato al giocatore in alcun modo se non attraverso il gioco.

Ora che hai una mazza con cui colpire la palla, sarebbe bello avere dei mattoncini da rompere con la pallina!

8. Abbatti il muro

Costruire i mattoni

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 come segue.

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, gran parte di questo codice dovrebbe essere familiare. Questo codice utilizza un'istruzione RectangleComponent, con rilevamento delle collisioni e riferimento sicuro per tipo 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 interroga il mondo in cerca di mattoncini e conferma che ne rimane solo uno. Questo potrebbe creare confusione, perché la riga precedente rimuove questo mattoncino da quello principale.

Il punto chiave da capire è che la rimozione dei componenti è un comando in coda. Il mattoncino viene rimosso dopo l'esecuzione del codice, ma prima del prossimo segno di spunta nel mondo del gioco.

Per rendere il componente Brick accessibile a BrickBreaker, modifica lib/src/components/components.dart come segue.

lib/src/components/components.dart

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

Aggiungi 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.
    }
  }
}

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

Modifica la partita di BrickBreaker come segue.

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 così com'è, verranno visualizzate tutte le meccaniche principali del gioco. Potresti disattivare il debug e terminarlo, ma sembra che manchi qualcosa.

Uno screenshot che mostra il gioco per rompere i mattoncini con la palla, la mazza e la maggior parte dei mattoncini nell&#39;area di gioco. Ogni componente ha etichette di debug

Che ne dici di una schermata di benvenuto, di una partita fuori schermo o di un punteggio? Flutter può aggiungere queste funzionalità al gioco ed è qui che rivolgerai la tua attenzione.

9. Vinci la partita

Aggiungi stati di riproduzione

In questo passaggio devi incorporare il gioco Fiamma all'interno di un wrapper Flutter e quindi aggiungere gli overlay Flutter per le schermate di benvenuto, game over e vinte.

Per prima cosa, modifica i file del gioco e dei componenti per implementare uno stato di riproduzione che indichi se mostrare o meno un overlay e, in caso affermativo, quale.

  1. Modifica il gioco BrickBreaker come segue.

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 modifica gran parte del gioco BrickBreaker. L'aggiunta dell'enumerazione playState richiede molto lavoro. Indica il punto in cui il giocatore entra, gioca, perde o vince la partita. Nella parte superiore del file, definisci l'enumerazione, quindi crei un'istanza come stato nascosto con getter e setter corrispondenti. Questi getter e setter consentono di modificare gli overlay quando le varie parti del gioco attivano le transizioni dello stato di gioco.

Successivamente, suddividi il codice in onLoad in onLoad e in un nuovo metodo startGame. Prima di questo cambiamento, potevi iniziare un nuovo gioco soltanto riavviandolo. Grazie a queste nuove aggiunte, il giocatore può iniziare un nuovo gioco 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 del tocco e hai esteso il gestore della tastiera per consentire all'utente di avviare una nuova partita in diverse modalità. Con lo stato di riproduzione modellato, avrebbe senso aggiornare i componenti per attivare le transizioni dello stato di riproduzione quando il player 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. Questo approccio dovrebbe essere corretto se il giocatore consente alla pallina di uscire dal fondo dello schermo.

  1. Modifica il componente Brick come segue.

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.playState = PlayState.won;                          // Add this line
      game.world.removeAll(game.world.children.query<Ball>());
      game.world.removeAll(game.world.children.query<Bat>());
    }
  }
}

D'altra parte, se il giocatore riesce a rompere tutti i mattoncini, si è guadagnato una "partita vinta" schermo. Complimenti, giocatore, ben fatto!

Aggiungi il wrapper Flutter

Se vuoi incorporare il gioco e aggiungere overlay relativi allo stato di gioco, aggiungi la shell di Flutter.

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

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(
        useMaterial3: true,
        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 build di widget Flutter standard. Le parti specifiche di Flame includono l'uso di GameWidget.controlled per creare e gestire l'istanza del gioco BrickBreaker e il nuovo argomento overlayBuilderMap per GameWidget.

Le chiavi di questo overlayBuilderMap devono essere allineate agli overlay aggiunti o rimossi dal setter playState in BrickBreaker. Il tentativo di impostare un overlay che non è in questa mappa porta a volti infelici intorno a te.

  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, nel gioco viene visualizzato l'output previsto. Se scegli come target macOS o Android, devi apportare un'ultima modifica per consentire la visualizzazione di google_fonts.

Attivazione dell'accesso ai caratteri

Aggiungere l'autorizzazione di accesso a internet per Android

Per Android, devi aggiungere l'autorizzazione di accesso a internet. Modifica 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 dei diritti per macOS

Per macOS, hai due file da modificare.

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

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 codice seguente

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>

Questa operazione dovrebbe mostrare una schermata di benvenuto e una schermata di game over o vinto su tutte le piattaforme. Questi schermi potrebbero essere un po' semplici e sarebbe bello avere un punteggio. Quindi, indovina cosa farai nel prossimo passaggio!

10. Mantieni punteggio

Aggiungi punteggio alla partita

In questo passaggio, esponi il punteggio del gioco al contesto di 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 mattoncino.

  1. Modifica il gioco BrickBreaker come segue.

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, lo stato del gioco viene associato 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>());
    }
  }
}

Crea un gioco bello

Ora che puoi tenere il punteggio in Flutter, è il momento di mettere insieme i widget per farlo stare bene.

  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 codice seguente.

L'overlay rende gli overlay più nitidi grazie alla potenza del pacchetto flutter_animate, che aggiunge movimento e stile alle schermate.

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 uno sguardo più approfondito sulla potenza di flutter_animate, consulta il codelab Creazione di UI di nuova generazione 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 punteggi 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 vinte, 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(
        useMaterial3: true,
        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, ora dovresti essere in grado di eseguire il gioco su una qualsiasi delle sei piattaforme target 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 partita sullo schermo sovrapposto a una mazza e ad alcuni mattoncini

11. Complimenti

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

Hai creato un gioco usando il motore di gioco Flame 2D e lo hai incorporato in un wrapper Flutter. Hai utilizzato gli effetti della fiamma per animare e rimuovere i componenti. Hai utilizzato i pacchetti Google Fonts e Flutter Animate per conferire un aspetto ottimale al gioco.

Passaggi successivi

Dai un'occhiata ad alcuni di questi codelab...

Per approfondire