Crea un gioco di fisica in 2D con Flutter e Flame

1. Prima di iniziare

Flame è un motore grafico 2D basato su Flutter. In questo codelab, creerai un gioco che utilizza una simulazione fisica 2D sulla linea di Box2D chiamato Forge2D. Puoi usare i componenti di Flame per dipingere la realtà fisica simulata sullo schermo in modo che gli utenti possano giocarci. Al termine, il gioco dovrebbe avere l'aspetto di questa gif animata:

Animazione del gameplay con questo gioco di fisica in 2D

Prerequisiti

Cosa imparerai

  • Come funzionano le basi di Forge2D, a partire dai diversi tipi di corpi fisici.
  • Come impostare una simulazione fisica in 2D.

Cosa serve

Compilatore del software per il target di sviluppo scelto. Questo codelab funziona per tutte e sei le piattaforme supportate da Flutter. È necessario Visual Studio per il targeting di Windows, Xcode per macOS o iOS e Android Studio per il targeting di Android.

2. Creare un progetto

Crea il tuo progetto Flutter

Esistono molti modi per creare un progetto Flutter. In questa sezione utilizzerai la riga di comando per brevità.

Per iniziare, procedi nel seguente modo:

  1. Su una riga di comando, crea un progetto Flutter:
$ flutter create --empty forge2d_game
Creating project forge2d_game...
Resolving dependencies in forge2d_game... (4.7s)
Got dependencies in forge2d_game.
Wrote 128 files.

All done!
You can find general documentation for Flutter at: https://docs.flutter.dev/
Detailed API documentation is available at: https://api.flutter.dev/
If you prefer video documentation, consider: https://www.youtube.com/c/flutterdev

In order to run your empty application, type:

  $ cd forge2d_game
  $ flutter run

Your empty application code is in forge2d_game/lib/main.dart.
  1. Modifica le dipendenze del progetto per aggiungere Flame e Forge2D:
$ cd forge2d_game
$ flutter pub add characters flame flame_forge2d flame_kenney_xml xml
Resolving dependencies... 
Downloading packages... 
  characters 1.3.0 (from transitive dependency to direct dependency)
  collection 1.18.0 (1.19.0 available)
+ flame 1.18.0
+ flame_forge2d 0.18.1
+ flame_kenney_xml 0.1.0
  flutter_lints 3.0.2 (4.0.0 available)
+ forge2d 0.13.0
  leak_tracker 10.0.4 (10.0.5 available)
  leak_tracker_flutter_testing 3.0.3 (3.0.5 available)
  lints 3.0.0 (4.0.0 available)
  material_color_utilities 0.8.0 (0.12.0 available)
  meta 1.12.0 (1.15.0 available)
+ ordered_set 5.0.3 (6.0.1 available)
+ petitparser 6.0.2
  test_api 0.7.0 (0.7.3 available)
  vm_service 14.2.1 (14.2.4 available)
+ xml 6.5.0
Changed 8 dependencies!
10 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

Il pacchetto flame ti è familiare, ma gli altri tre potrebbero avere bisogno di una spiegazione. Il pacchetto characters viene utilizzato per la manipolazione del percorso dei file in modo conforme alla specifica UTF8. Il pacchetto flame_forge2d espone la funzionalità di Forge2D in un modo che funziona bene con Flame. Infine, il pacchetto xml viene utilizzato in varie posizioni per utilizzare e modificare i contenuti XML.

Apri il progetto e sostituisci i contenuti del file lib/main.dart con quanto segue:

lib/main.dart

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

void main() {
  runApp(
    GameWidget.controlled(
      gameFactory: FlameGame.new,
    ),
  );
}

L'app viene avviata con un GameWidget che crea un'istanza dell'istanza FlameGame. In questo codelab non è presente codice Flutter che utilizza lo stato dell'istanza di gioco per visualizzare le informazioni sul gioco in esecuzione, quindi questo bootstrap semplificato funziona bene.

(Facoltativo) Completa una Quest secondaria solo per macOS

Gli screenshot di questo progetto provengono dal gioco come app desktop macOS. Per evitare che la barra del titolo dell'app distolga l'esperienza complessiva, puoi modificare la configurazione del progetto del runner macOS in modo da eliminare la barra del titolo.

Per farlo, segui questi passaggi:

  1. Crea un file bin/modify_macos_config.dart e aggiungi i seguenti contenuti:

bin/modify_macos_config.arrow

import 'dart:io';
import 'package:xml/xml.dart';
import 'package:xml/xpath.dart';

void main() {
  final file = File('macos/Runner/Base.lproj/MainMenu.xib');
  var document = XmlDocument.parse(file.readAsStringSync());
  document.xpath('//document/objects/window').first
    ..setAttribute('titlebarAppearsTransparent', 'YES')
    ..setAttribute('titleVisibility', 'hidden');
  document
      .xpath('//document/objects/window/windowStyleMask')
      .first
      .setAttribute('fullSizeContentView', 'YES');
  file.writeAsStringSync(document.toString());
}

Questo file non si trova nella directory lib perché non fa parte del codebase di runtime del gioco. È uno strumento a riga di comando usato per modificare il progetto.

  1. Dalla directory base del progetto, esegui lo strumento come segue:
$ dart bin/modify_macos_config.dart

Se tutto va pianificato, il programma non genererà alcun output nella riga di comando. Tuttavia, modificherà il file di configurazione di macos/Runner/Base.lproj/MainMenu.xib in modo che il gioco venga avviato senza una barra del titolo visibile e con il gioco Flame che occupa l'intera finestra.

Esegui il gioco per verificare che tutto funzioni. Dovrebbe essere visualizzata una nuova finestra con solo uno sfondo nero vuoto.

La finestra di un'app con uno sfondo nero e nulla in primo piano

3. Aggiungi asset immagine

Aggiungi le immagini

Qualsiasi gioco ha bisogno di risorse artistiche per dipingere uno schermo in modo da divertire. Questo codelab utilizzerà il pacchetto Risorse fisiche di Kenney.nl. Questi asset sono concessi in licenza Creative Commons CC0, ma consiglio comunque vivamente di fare una donazione al team di Kenney, in modo che possa continuare l'ottimo lavoro che sta facendo. L'ho fatto.

Dovrai modificare il file di configurazione pubspec.yaml per abilitare l'utilizzo degli asset di Kenney. Modificalo come segue:

pubspec.yaml

name: forge2d_game
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0

environment:
  sdk: '>=3.3.3 <4.0.0'

dependencies:
  characters: ^1.3.0
  flame: ^1.17.0
  flame_forge2d: ^0.18.0
  flutter:
    sdk: flutter
  xml: ^6.5.0

dev_dependencies:
  flutter_lints: ^3.0.0
  flutter_test:
    sdk: flutter

flutter:
  uses-material-design: true
  assets:                        # Add from here
    - assets/
    - assets/images/             # To here.

Flame prevede che gli asset immagine vengano posizionati in assets/images, anche se questo può essere configurato in modo diverso. Per ulteriori dettagli, consulta la documentazione relativa alle immagini di Flame. Ora che i percorsi sono stati configurati, devi aggiungerli al progetto stesso. Un modo per farlo è utilizzare la riga di comando come segue:

$ mkdir -p assets/images

Il comando mkdir non dovrebbe contenere output, ma la nuova directory dovrebbe essere visibile nell'editor o in Esplora file.

Espandi il file kenney_physics-assets.zip che hai scaricato. Dovresti vedere qualcosa di simile a questo:

Un elenco di file del pacchetto kenney_physics-assets espanso, con la directory PNG/Backgrounds evidenziata

Dalla directory PNG/Backgrounds, copia i file colored_desert.png, colored_grass.png, colored_land.png e colored_shroom.png nella directory assets/images del progetto.

Sono disponibili anche fogli sprite. Si tratta di una combinazione di un'immagine PNG e di un file XML che descrive dove si possono trovare immagini più piccole nell'immagine del foglio sprite. I fogli sprite sono una tecnica per ridurre i tempi di caricamento caricando un solo file, anziché decine, se non centinaia, di singoli file immagine.

Un elenco di file del pacchetto kenney_physics-assets espanso, con la directory Foglio sprite evidenziata

Copia i file spritesheet_aliens.png, spritesheet_elements.png e spritesheet_tiles.png nella directory assets/images del progetto. Mentre sei qui, copia anche i file spritesheet_aliens.xml, spritesheet_elements.xml e spritesheet_tiles.xml nella directory assets del progetto. Il tuo progetto dovrebbe essere simile al seguente.

Un elenco di file della directory del progetto forge2d_game, con la directory degli asset evidenziata

Colora lo sfondo

Ora che al tuo progetto sono stati aggiunti asset immagine, inseriscili sullo schermo. Beh, un'immagine sullo schermo. Ne riceverai altri nei seguenti passaggi.

Crea un file denominato background.dart in una nuova directory denominata lib/components e aggiungi i contenuti seguenti.

lib/components/background.dart

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

class Background extends SpriteComponent with HasGameReference<MyPhysicsGame> {
  Background({required super.sprite})
      : super(
          anchor: Anchor.center,
          position: Vector2(0, 0),
        );

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

    size = Vector2.all(max(
      game.camera.visibleWorldRect.width,
      game.camera.visibleWorldRect.height,
    ));
  }
}

Questo componente è un SpriteComponent specializzato. È responsabile della visualizzazione di una delle quattro immagini di sfondo di Kenney.nl. Il codice contiene alcuni presupposti semplificativi. La prima è che le immagini sono quadrate, come tutte e quattro le immagini di sfondo di Kenney. Il secondo è che le dimensioni del mondo visibile non cambieranno mai, altrimenti questo componente dovrebbe gestire gli eventi di ridimensionamento del gioco. Il terzo presupposto è che la posizione (0,0) sarà al centro dello schermo. Queste ipotesi richiedono una configurazione specifica del valore CameraComponent del gioco.

Crea un altro nuovo file, questo denominato game.dart, sempre nella directory lib/components.

lib/components/game.dart

import 'dart:async';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';

class MyPhysicsGame extends Forge2DGame {
  MyPhysicsGame()
      : super(
          gravity: Vector2(0, 10),
          camera: CameraComponent.withFixedResolution(width: 800, height: 600),
        );

  late final XmlSpriteSheet aliens;
  late final XmlSpriteSheet elements;
  late final XmlSpriteSheet tiles;

  @override
  FutureOr<void> onLoad() async {
    final backgroundImage = await images.load('colored_grass.png');
    final spriteSheets = await Future.wait([
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_aliens.png',
        xmlPath: 'spritesheet_aliens.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_elements.png',
        xmlPath: 'spritesheet_elements.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_tiles.png',
        xmlPath: 'spritesheet_tiles.xml',
      ),
    ]);

    aliens = spriteSheets[0];
    elements = spriteSheets[1];
    tiles = spriteSheets[2];

    await world.add(Background(sprite: Sprite(backgroundImage)));

    return super.onLoad();
  }
}

Sta succedendo molto qui. Iniziamo con il corso MyPhysicsGame. A differenza del codelab precedente, si estende Forge2DGame non FlameGame. Forge2DGame stesso estende FlameGame con alcune interessanti modifiche. La prima è che, per impostazione predefinita, il valore di zoom è 10. Questa impostazione zoom riguarda l'intervallo di valori utili con cui funzionano bene i motori di simulazione della fisica in stile Box2D. Il motore è scritto utilizzando il sistema MKS, in cui si presume che le unità siano espresse in metri, chilogrammi e secondi. L'intervallo rispetto al quale non vedi errori matematici rilevanti per gli oggetti va da 0,1 metri a 10 secondi di metri. Se inserisci le dimensioni in pixel direttamente senza un certo ridimensionamento verso il basso, Forge2D al di fuori del suo utile busta. Il riassunto utile è immaginare di simulare oggetti nella portata di una lattina di una bibita fino a un autobus.

Le ipotesi fatte nel componente Sfondo vengono soddisfatte qui fissando la risoluzione di CameraComponent a 800 x 600 pixel virtuali. Ciò significa che l'area di gioco sarà larga 80 unità e alta 60 unità, centrata su (0,0). Questo non influisce sulla risoluzione visualizzata, ma influisce sul posizionamento degli oggetti nella scena di gioco.

Accanto all'argomento del costruttore camera c'è un altro argomento più allineato alla fisica, chiamato gravity. La gravità è impostata su Vector2 con x pari a 0 e y di 10. Il valore 10 è un'approssimazione molto vicina del valore generalmente accettato di 9,81 metri al secondo al secondo per la gravità. Il fatto che la gravità sia impostata su un valore positivo di 10 indica che in questo sistema la direzione dell'asse Y è abbassata. che in genere è diverso da Box2D, ma è in linea con il modo in cui viene solitamente configurato Flame.

Il prossimo è il metodo onLoad. Questo metodo è asincrono, ed è appropriato perché è responsabile del caricamento degli asset immagine dal disco. Le chiamate a images.load restituiscono un Future<Image> e, come effetto collaterale, memorizza nella cache l'immagine caricata nell'oggetto Game. Questi future vengono raccolti e attesi come una singola unità utilizzando il metodo statico Futures.wait. L'elenco delle immagini restituite viene poi associato a pattern in singoli nomi.

Le immagini del foglio sprite vengono quindi inserite in una serie di oggetti XmlSpriteSheet, responsabili del recupero degli Sprites denominati singolarmente contenuti nel foglio sprite. La classe XmlSpriteSheet è definita nel pacchetto flame_kenney_xml.

Con tutto questo d'intralcio, hai bisogno solo di un paio di piccole modifiche a lib/main.dart per visualizzare un'immagine sullo schermo.

lib/main.dart

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

import 'components/game.dart';                             // Add this import

void main() {
  runApp(
    GameWidget.controlled(
      gameFactory: MyPhysicsGame.new,                      // Modify this line
    ),
  );
}

Con questa semplice modifica ora puoi riavviare il gioco per visualizzare lo sfondo sullo schermo. Tieni presente che l'istanza della videocamera CameraComponent.withFixedResolution() aggiungerà letterbox come necessario per far funzionare il rapporto 800 x 600 del gioco.

La finestra di un&#39;app con l&#39;immagine di sfondo di dolci colline verdi e strani alberi astratti.

4. Aggiungi il terreno

Qualcosa su cui sviluppare

In caso di gravità, abbiamo bisogno di qualcosa che catturi gli oggetti nel gioco prima che cadano dalla parte inferiore dello schermo. A meno che la caduta dallo schermo non faccia parte del design del gioco, ovviamente. Crea un nuovo file ground.dart nella directory lib/components e aggiungi quanto segue:

lib/components/ground.dart

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

const groundSize = 7.0;

class Ground extends BodyComponent {
  Ground(Vector2 position, Sprite sprite)
      : super(
          renderBody: false,
          bodyDef: BodyDef()
            ..position = position
            ..type = BodyType.static,
          fixtureDefs: [
            FixtureDef(
              PolygonShape()..setAsBoxXY(groundSize / 2, groundSize / 2),
              friction: 0.3,
            )
          ],
          children: [
            SpriteComponent(
              anchor: Anchor.center,
              sprite: sprite,
              size: Vector2.all(groundSize),
              position: Vector2(0, 0),
            ),
          ],
        );
}

Questo componente Ground deriva da BodyComponent. In Forge2D i corpi sono importanti, sono gli oggetti che fanno parte della simulazione fisica bidimensionale. Si specifica che BodyDef per questo componente ha un BodyType.static.

In Forge2D, i corpi hanno tre tipi diversi. I corpi statici non si muovono. In effetti hanno una massa zero (non reagiscono alla gravità) e una massa infinita; non si muovono quando colpiti da altri oggetti, a prescindere dalla loro pesantezza. Ciò rende i corpi statici perfetti per una superficie del suolo, poiché non si muove.

Gli altri due tipi di corpo sono cinematici e dinamici. I corpi dinamici sono corpi completamente simulati, che reagiscono alla gravità e agli oggetti in cui si imbattono. Nel resto di questo codelab vedrai molti corpi dinamici. I corpi cinematografici sono a metà strada tra statico e dinamico. Si muovono, ma non reagiscono alla gravità o ad altri oggetti che li colpiscono. Utile, ma al di fuori dell'ambito di questo codelab.

Il corpo stesso non fa molto. Un corpo ha bisogno di forme associate per avere una sostanza. In questo caso, al corpo è associata una forma, un PolygonShape impostato come BoxXY. Questo tipo di riquadro è l'asse allineato al mondo, a differenza di un PolygonShape impostato come BoxXY che può essere ruotato attorno a un punto di rotazione. Anche in questo caso è utile, ma non rientra nell'ambito di questo codelab. La forma e il corpo sono fissati insieme a un espositore, il che è utile per aggiungere elementi come friction al sistema.

Per impostazione predefinita, un corpo riproduce le forme collegate in un modo utile per il debug, ma non per un gameplay eccezionale. L'impostazione dell'argomento renderBody super su false disattiva questo rendering di debug. Concedere a questo organismo un rendering in-game è responsabilità del publisher secondario SpriteComponent.

Per aggiungere il componente Ground al gioco, modifica il file game.dart come segue.

lib/components/game.dart

import 'dart:async';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';
import 'ground.dart';                                      // Add this import

class MyPhysicsGame extends Forge2DGame {
  MyPhysicsGame()
      : super(
          gravity: Vector2(0, 10),
          camera: CameraComponent.withFixedResolution(width: 800, height: 600),
        );

  late final XmlSpriteSheet aliens;
  late final XmlSpriteSheet elements;
  late final XmlSpriteSheet tiles;

  @override
  FutureOr<void> onLoad() async {
    final backgroundImage = await images.load('colored_grass.png');
    final spriteSheets = await Future.wait([
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_aliens.png',
        xmlPath: 'spritesheet_aliens.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_elements.png',
        xmlPath: 'spritesheet_elements.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_tiles.png',
        xmlPath: 'spritesheet_tiles.xml',
      ),
    ]);

    aliens = spriteSheets[0];
    elements = spriteSheets[1];
    tiles = spriteSheets[2];

    await world.add(Background(sprite: Sprite(backgroundImage)));
    await addGround();                                     // Add this line

    return super.onLoad();
  }

  Future<void> addGround() {                               // Add from here...
    return world.addAll([
      for (var x = camera.visibleWorldRect.left;
          x < camera.visibleWorldRect.right + groundSize;
          x += groundSize)
        Ground(
          Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
          tiles.getSprite('grass.png'),
        ),
    ]);
  }                                                        // To here.
}

Questa modifica aggiunge al mondo una serie di componenti Ground utilizzando un loop for all'interno di un contesto List e passando l'elenco risultante dei componenti Ground al metodo addAll di world.

Ora quando si esegue il gioco vengono mostrati lo sfondo e il terreno.

Una finestra dell&#39;applicazione con uno sfondo e un livello del terreno.

5. Aggiungi i mattoncini

Costruire un muro

Il terreno ci ha dato un esempio di corpo statico. A questo punto occorre aggiungere il primo componente dinamico. I componenti dinamici di Forge2D sono la pietra miliare dell'esperienza del giocatore, sono gli elementi che si muovono e interagiscono con il mondo che lo circonda. In questo passaggio presenterai dei mattoncini che verranno scelti in modo casuale per apparire sullo schermo in un gruppo di mattoncini. Li vedrai cadere e l'uno contro l'altro.

I mattoncini saranno creati dagli elementi sprite Sheet. Se diamo un'occhiata alla descrizione del foglio sprite in assets/spritesheet_elements.xml, vedrai che abbiamo un problema interessante. I nomi non sembrano essere molto utili. Sarebbe utile selezionare un mattoncino in base al tipo di materiale, alle dimensioni e alla quantità di danni. Per fortuna, un utile elfo ha dedicato del tempo a capire lo schema di denominazione dei file e ha creato uno strumento per semplificare l'operazione. Crea un nuovo file generate_brick_file_names.dart nella directory bin e aggiungi il seguente contenuto:

bin/generate_brick_file_names.dart

import 'dart:io';
import 'package:equatable/equatable.dart';
import 'package:xml/xml.dart';
import 'package:xml/xpath.dart';

void main() {
  final file = File('assets/spritesheet_elements.xml');
  final rects = <String, Rect>{};
  final document = XmlDocument.parse(file.readAsStringSync());
  for (final node in document.xpath('//TextureAtlas/SubTexture')) {
    final name = node.getAttribute('name')!;
    rects[name] = Rect(
      x: int.parse(node.getAttribute('x')!),
      y: int.parse(node.getAttribute('y')!),
      width: int.parse(node.getAttribute('width')!),
      height: int.parse(node.getAttribute('height')!),
    );
  }
  print(generateBrickFileNames(rects));
}

class Rect extends Equatable {
  final int x;
  final int y;
  final int width;
  final int height;
  const Rect(
      {required this.x,
      required this.y,
      required this.width,
      required this.height});

  Size get size => Size(width, height);

  @override
  List<Object?> get props => [x, y, width, height];

  @override
  bool get stringify => true;
}

class Size extends Equatable {
  final int width;
  final int height;
  const Size(this.width, this.height);

  @override
  List<Object?> get props => [width, height];

  @override
  bool get stringify => true;
}

String generateBrickFileNames(Map<String, Rect> rects) {
  final groups = <Size, List<String>>{};
  for (final entry in rects.entries) {
    groups.putIfAbsent(entry.value.size, () => []).add(entry.key);
  }
  final buff = StringBuffer();
  buff.writeln('''
Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
  return switch ((type, size)) {''');
  for (final entry in groups.entries) {
    final size = entry.key;
    final entries = entry.value;
    entries.sort();
    for (final type in ['Explosive', 'Glass', 'Metal', 'Stone', 'Wood']) {
      var filtered = entries.where((element) => element.contains(type));
      if (filtered.length == 5) {
        buff.writeln('''
    (BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
        BrickDamage.none: '${filtered.elementAt(0)}',
        BrickDamage.some: '${filtered.elementAt(1)}',
        BrickDamage.lots: '${filtered.elementAt(4)}',
      },''');
      } else if (filtered.length == 10) {
        buff.writeln('''
    (BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
        BrickDamage.none: '${filtered.elementAt(3)}',
        BrickDamage.some: '${filtered.elementAt(4)}',
        BrickDamage.lots: '${filtered.elementAt(9)}',
      },''');
      } else if (filtered.length == 15) {
        buff.writeln('''
    (BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
        BrickDamage.none: '${filtered.elementAt(7)}',
        BrickDamage.some: '${filtered.elementAt(8)}',
        BrickDamage.lots: '${filtered.elementAt(13)}',
      },''');
      }
    }
  }
  buff.writeln('''
  };
}''');
  return buff.toString();
}

L'editor dovrebbe visualizzare un avviso o un errore relativo a una dipendenza mancante. Aggiungilo come segue:

$ flutter pub add equatable

Ora dovresti essere in grado di eseguire il programma nel seguente modo:

$ dart run bin/generate_brick_file_names.dart
Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
  return switch ((type, size)) {
    (BrickType.explosive, BrickSize.size140x70) => {
        BrickDamage.none: 'elementExplosive009.png',
        BrickDamage.some: 'elementExplosive012.png',
        BrickDamage.lots: 'elementExplosive050.png',
      },
    (BrickType.glass, BrickSize.size140x70) => {
        BrickDamage.none: 'elementGlass010.png',
        BrickDamage.some: 'elementGlass013.png',
        BrickDamage.lots: 'elementGlass048.png',
      },
[Content elided...]
    (BrickType.wood, BrickSize.size140x220) => {
        BrickDamage.none: 'elementWood020.png',
        BrickDamage.some: 'elementWood025.png',
        BrickDamage.lots: 'elementWood052.png',
      },
  };
}

Questo strumento ha analizzato molto bene il file di descrizione del foglio sprite e lo ha convertito in codice Dart, utile per selezionare il file immagine corretto per ciascun mattoncino che vuoi posizionare sullo schermo. Utile!

Crea il file brick.dart con i seguenti contenuti:

lib/components/brick.dart

import 'dart:math';
import 'dart:ui' as ui;

import 'package:flame/components.dart';
import 'package:flame/extensions.dart';
import 'package:flame_forge2d/flame_forge2d.dart';

const brickScale = 0.5;

enum BrickType {
  explosive(density: 1, friction: 0.5),
  glass(density: 0.5, friction: 0.2),
  metal(density: 1, friction: 0.4),
  stone(density: 2, friction: 1),
  wood(density: 0.25, friction: 0.6);

  final double density;
  final double friction;

  const BrickType({required this.density, required this.friction});
  static BrickType get randomType => values[Random().nextInt(values.length)];
}

enum BrickSize {
  size70x70(ui.Size(70, 70)),
  size140x70(ui.Size(140, 70)),
  size220x70(ui.Size(220, 70)),
  size70x140(ui.Size(70, 140)),
  size140x140(ui.Size(140, 140)),
  size220x140(ui.Size(220, 140)),
  size140x220(ui.Size(140, 220)),
  size70x220(ui.Size(70, 220));

  final ui.Size size;

  const BrickSize(this.size);
  static BrickSize get randomSize => values[Random().nextInt(values.length)];
}

enum BrickDamage { none, some, lots }

Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
  return switch ((type, size)) {
    (BrickType.explosive, BrickSize.size140x70) => {
        BrickDamage.none: 'elementExplosive009.png',
        BrickDamage.some: 'elementExplosive012.png',
        BrickDamage.lots: 'elementExplosive050.png',
      },
    (BrickType.glass, BrickSize.size140x70) => {
        BrickDamage.none: 'elementGlass010.png',
        BrickDamage.some: 'elementGlass013.png',
        BrickDamage.lots: 'elementGlass048.png',
      },
    (BrickType.metal, BrickSize.size140x70) => {
        BrickDamage.none: 'elementMetal009.png',
        BrickDamage.some: 'elementMetal012.png',
        BrickDamage.lots: 'elementMetal050.png',
      },
    (BrickType.stone, BrickSize.size140x70) => {
        BrickDamage.none: 'elementStone009.png',
        BrickDamage.some: 'elementStone012.png',
        BrickDamage.lots: 'elementStone047.png',
      },
    (BrickType.wood, BrickSize.size140x70) => {
        BrickDamage.none: 'elementWood011.png',
        BrickDamage.some: 'elementWood014.png',
        BrickDamage.lots: 'elementWood054.png',
      },
    (BrickType.explosive, BrickSize.size70x70) => {
        BrickDamage.none: 'elementExplosive011.png',
        BrickDamage.some: 'elementExplosive014.png',
        BrickDamage.lots: 'elementExplosive049.png',
      },
    (BrickType.glass, BrickSize.size70x70) => {
        BrickDamage.none: 'elementGlass011.png',
        BrickDamage.some: 'elementGlass012.png',
        BrickDamage.lots: 'elementGlass046.png',
      },
    (BrickType.metal, BrickSize.size70x70) => {
        BrickDamage.none: 'elementMetal011.png',
        BrickDamage.some: 'elementMetal014.png',
        BrickDamage.lots: 'elementMetal049.png',
      },
    (BrickType.stone, BrickSize.size70x70) => {
        BrickDamage.none: 'elementStone011.png',
        BrickDamage.some: 'elementStone014.png',
        BrickDamage.lots: 'elementStone046.png',
      },
    (BrickType.wood, BrickSize.size70x70) => {
        BrickDamage.none: 'elementWood010.png',
        BrickDamage.some: 'elementWood013.png',
        BrickDamage.lots: 'elementWood045.png',
      },
    (BrickType.explosive, BrickSize.size220x70) => {
        BrickDamage.none: 'elementExplosive013.png',
        BrickDamage.some: 'elementExplosive016.png',
        BrickDamage.lots: 'elementExplosive051.png',
      },
    (BrickType.glass, BrickSize.size220x70) => {
        BrickDamage.none: 'elementGlass014.png',
        BrickDamage.some: 'elementGlass017.png',
        BrickDamage.lots: 'elementGlass049.png',
      },
    (BrickType.metal, BrickSize.size220x70) => {
        BrickDamage.none: 'elementMetal013.png',
        BrickDamage.some: 'elementMetal016.png',
        BrickDamage.lots: 'elementMetal051.png',
      },
    (BrickType.stone, BrickSize.size220x70) => {
        BrickDamage.none: 'elementStone013.png',
        BrickDamage.some: 'elementStone016.png',
        BrickDamage.lots: 'elementStone048.png',
      },
    (BrickType.wood, BrickSize.size220x70) => {
        BrickDamage.none: 'elementWood012.png',
        BrickDamage.some: 'elementWood015.png',
        BrickDamage.lots: 'elementWood047.png',
      },
    (BrickType.explosive, BrickSize.size70x140) => {
        BrickDamage.none: 'elementExplosive017.png',
        BrickDamage.some: 'elementExplosive022.png',
        BrickDamage.lots: 'elementExplosive052.png',
      },
    (BrickType.glass, BrickSize.size70x140) => {
        BrickDamage.none: 'elementGlass018.png',
        BrickDamage.some: 'elementGlass023.png',
        BrickDamage.lots: 'elementGlass050.png',
      },
    (BrickType.metal, BrickSize.size70x140) => {
        BrickDamage.none: 'elementMetal017.png',
        BrickDamage.some: 'elementMetal022.png',
        BrickDamage.lots: 'elementMetal052.png',
      },
    (BrickType.stone, BrickSize.size70x140) => {
        BrickDamage.none: 'elementStone017.png',
        BrickDamage.some: 'elementStone022.png',
        BrickDamage.lots: 'elementStone049.png',
      },
    (BrickType.wood, BrickSize.size70x140) => {
        BrickDamage.none: 'elementWood016.png',
        BrickDamage.some: 'elementWood021.png',
        BrickDamage.lots: 'elementWood048.png',
      },
    (BrickType.explosive, BrickSize.size140x140) => {
        BrickDamage.none: 'elementExplosive018.png',
        BrickDamage.some: 'elementExplosive023.png',
        BrickDamage.lots: 'elementExplosive053.png',
      },
    (BrickType.glass, BrickSize.size140x140) => {
        BrickDamage.none: 'elementGlass019.png',
        BrickDamage.some: 'elementGlass024.png',
        BrickDamage.lots: 'elementGlass051.png',
      },
    (BrickType.metal, BrickSize.size140x140) => {
        BrickDamage.none: 'elementMetal018.png',
        BrickDamage.some: 'elementMetal023.png',
        BrickDamage.lots: 'elementMetal053.png',
      },
    (BrickType.stone, BrickSize.size140x140) => {
        BrickDamage.none: 'elementStone018.png',
        BrickDamage.some: 'elementStone023.png',
        BrickDamage.lots: 'elementStone050.png',
      },
    (BrickType.wood, BrickSize.size140x140) => {
        BrickDamage.none: 'elementWood017.png',
        BrickDamage.some: 'elementWood022.png',
        BrickDamage.lots: 'elementWood049.png',
      },
    (BrickType.explosive, BrickSize.size220x140) => {
        BrickDamage.none: 'elementExplosive019.png',
        BrickDamage.some: 'elementExplosive024.png',
        BrickDamage.lots: 'elementExplosive054.png',
      },
    (BrickType.glass, BrickSize.size220x140) => {
        BrickDamage.none: 'elementGlass020.png',
        BrickDamage.some: 'elementGlass025.png',
        BrickDamage.lots: 'elementGlass052.png',
      },
    (BrickType.metal, BrickSize.size220x140) => {
        BrickDamage.none: 'elementMetal019.png',
        BrickDamage.some: 'elementMetal024.png',
        BrickDamage.lots: 'elementMetal054.png',
      },
    (BrickType.stone, BrickSize.size220x140) => {
        BrickDamage.none: 'elementStone019.png',
        BrickDamage.some: 'elementStone024.png',
        BrickDamage.lots: 'elementStone051.png',
      },
    (BrickType.wood, BrickSize.size220x140) => {
        BrickDamage.none: 'elementWood018.png',
        BrickDamage.some: 'elementWood023.png',
        BrickDamage.lots: 'elementWood050.png',
      },
    (BrickType.explosive, BrickSize.size70x220) => {
        BrickDamage.none: 'elementExplosive020.png',
        BrickDamage.some: 'elementExplosive025.png',
        BrickDamage.lots: 'elementExplosive055.png',
      },
    (BrickType.glass, BrickSize.size70x220) => {
        BrickDamage.none: 'elementGlass021.png',
        BrickDamage.some: 'elementGlass026.png',
        BrickDamage.lots: 'elementGlass053.png',
      },
    (BrickType.metal, BrickSize.size70x220) => {
        BrickDamage.none: 'elementMetal020.png',
        BrickDamage.some: 'elementMetal025.png',
        BrickDamage.lots: 'elementMetal055.png',
      },
    (BrickType.stone, BrickSize.size70x220) => {
        BrickDamage.none: 'elementStone020.png',
        BrickDamage.some: 'elementStone025.png',
        BrickDamage.lots: 'elementStone052.png',
      },
    (BrickType.wood, BrickSize.size70x220) => {
        BrickDamage.none: 'elementWood019.png',
        BrickDamage.some: 'elementWood024.png',
        BrickDamage.lots: 'elementWood051.png',
      },
    (BrickType.explosive, BrickSize.size140x220) => {
        BrickDamage.none: 'elementExplosive021.png',
        BrickDamage.some: 'elementExplosive026.png',
        BrickDamage.lots: 'elementExplosive056.png',
      },
    (BrickType.glass, BrickSize.size140x220) => {
        BrickDamage.none: 'elementGlass022.png',
        BrickDamage.some: 'elementGlass027.png',
        BrickDamage.lots: 'elementGlass054.png',
      },
    (BrickType.metal, BrickSize.size140x220) => {
        BrickDamage.none: 'elementMetal021.png',
        BrickDamage.some: 'elementMetal026.png',
        BrickDamage.lots: 'elementMetal056.png',
      },
    (BrickType.stone, BrickSize.size140x220) => {
        BrickDamage.none: 'elementStone021.png',
        BrickDamage.some: 'elementStone026.png',
        BrickDamage.lots: 'elementStone053.png',
      },
    (BrickType.wood, BrickSize.size140x220) => {
        BrickDamage.none: 'elementWood020.png',
        BrickDamage.some: 'elementWood025.png',
        BrickDamage.lots: 'elementWood052.png',
      },
  };
}

class Brick extends BodyComponent {
  Brick({
    required this.type,
    required this.size,
    required BrickDamage damage,
    required Vector2 position,
    required Map<BrickDamage, Sprite> sprites,
  })  : _damage = damage,
        _sprites = sprites,
        super(
            renderBody: false,
            bodyDef: BodyDef()
              ..position = position
              ..type = BodyType.dynamic,
            fixtureDefs: [
              FixtureDef(
                PolygonShape()
                  ..setAsBoxXY(
                    size.size.width / 20 * brickScale,
                    size.size.height / 20 * brickScale,
                  ),
              )
                ..restitution = 0.4
                ..density = type.density
                ..friction = type.friction
            ]);

  late final SpriteComponent _spriteComponent;

  final BrickType type;
  final BrickSize size;
  final Map<BrickDamage, Sprite> _sprites;

  BrickDamage _damage;
  BrickDamage get damage => _damage;
  set damage(BrickDamage value) {
    _damage = value;
    _spriteComponent.sprite = _sprites[value];
  }

  @override
  Future<void> onLoad() {
    _spriteComponent = SpriteComponent(
      anchor: Anchor.center,
      scale: Vector2.all(1),
      sprite: _sprites[_damage],
      size: size.size.toVector2() / 10 * brickScale,
      position: Vector2(0, 0),
    );
    add(_spriteComponent);
    return super.onLoad();
  }
}

Ora è possibile vedere come il codice Dart generato sopra è integrato in questo codebase per rendere più facile e veloce selezionare le immagini dei mattoncini in base al materiale, alle dimensioni e alle condizioni. Se guardi oltre i enum e il componente Brick stesso, dovresti notare che la maggior parte di questo codice sembra abbastanza familiare dal componente Ground del passaggio precedente. Qui c'è uno stato modificabile per consentire il danneggiamento del mattoncino, anche se l'uso di questo può essere lasciato come esercizio per il lettore.

È ora di mettere i mattoncini sullo schermo. Modifica il file game.dart come segue:

lib/components/game.dart

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

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';
import 'brick.dart';                                       // Add this import
import 'ground.dart';

class MyPhysicsGame extends Forge2DGame {
  MyPhysicsGame()
      : super(
          gravity: Vector2(0, 10),
          camera: CameraComponent.withFixedResolution(width: 800, height: 600),
        );

  late final XmlSpriteSheet aliens;
  late final XmlSpriteSheet elements;
  late final XmlSpriteSheet tiles;

  @override
  FutureOr<void> onLoad() async {
    final backgroundImage = await images.load('colored_grass.png');
    final spriteSheets = await Future.wait([
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_aliens.png',
        xmlPath: 'spritesheet_aliens.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_elements.png',
        xmlPath: 'spritesheet_elements.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_tiles.png',
        xmlPath: 'spritesheet_tiles.xml',
      ),
    ]);

    aliens = spriteSheets[0];
    elements = spriteSheets[1];
    tiles = spriteSheets[2];

    await world.add(Background(sprite: Sprite(backgroundImage)));
    await addGround();
    unawaited(addBricks());                                // Add this line

    return super.onLoad();
  }

  Future<void> addGround() {
    return world.addAll([
      for (var x = camera.visibleWorldRect.left;
          x < camera.visibleWorldRect.right + groundSize;
          x += groundSize)
        Ground(
          Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
          tiles.getSprite('grass.png'),
        ),
    ]);
  }

  final _random = Random();                                // Add from here...

  Future<void> addBricks() async {
    for (var i = 0; i < 5; i++) {
      final type = BrickType.randomType;
      final size = BrickSize.randomSize;
      await world.add(
        Brick(
          type: type,
          size: size,
          damage: BrickDamage.some,
          position: Vector2(
              camera.visibleWorldRect.right / 3 +
                  (_random.nextDouble() * 5 - 2.5),
              0),
          sprites: brickFileNames(type, size).map(
            (key, filename) => MapEntry(
              key,
              elements.getSprite(filename),
            ),
          ),
        ),
      );
      await Future<void>.delayed(const Duration(milliseconds: 500));
    }
  }                                                        // To here.
}

Questa aggiunta è leggermente diversa da quella utilizzata per aggiungere i componenti Ground. Questa volta gli Brick vengono aggiunti in un cluster casuale, nel tempo. Ci sono due parti, la prima è che il metodo che aggiunge gli await di Brick a un Future.delayed, che è l'equivalente asincrono di una chiamata sleep(). Tuttavia, esiste una seconda parte per far funzionare questa operazione: la chiamata a addBricks nel metodo onLoad non è await. Se lo fosse, il metodo onLoad non verrebbe completato finché tutti i mattoncini non fossero sullo schermo. Aggregare la chiamata a addBricks in una chiamata unawaited rende felici i linter e rende evidente il nostro intento ai programmatori futuri. Non è nostra intenzione attendere la restituzione di questo metodo.

Avvia il gioco e vedrai apparire dei mattoncini che si scontrano e cadere per terra.

Una finestra dell&#39;app con colline verdi sullo sfondo, il livello del suolo e blocchi che atterrano a terra.

6. Aggiungi il giocatore

Lanciare alieni contro i mattoni

Guardare i mattoncini cadere è divertente il primo paio di volte, ma immagino che questo gioco sarà più divertente se diamo al giocatore un avatar da usare per interagire con il mondo. Che ne dici di un alieno che può scagliare contro i mattoni?

Crea un nuovo file player.dart nella directory lib/components e aggiungi quanto segue:

lib/components/player.dart

import 'dart:math';

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

const playerSize = 5.0;

enum PlayerColor {
  pink,
  blue,
  green,
  yellow;

  static PlayerColor get randomColor =>
      PlayerColor.values[Random().nextInt(PlayerColor.values.length)];

  String get fileName =>
      'alien${toString().split('.').last.capitalize}_round.png';
}

class Player extends BodyComponent with DragCallbacks {
  Player(Vector2 position, Sprite sprite)
      : _sprite = sprite,
        super(
          renderBody: false,
          bodyDef: BodyDef()
            ..position = position
            ..type = BodyType.static
            ..angularDamping = 0.1
            ..linearDamping = 0.1,
          fixtureDefs: [
            FixtureDef(CircleShape()..radius = playerSize / 2)
              ..restitution = 0.4
              ..density = 0.75
              ..friction = 0.5
          ],
        );

  final Sprite _sprite;

  @override
  Future<void> onLoad() {
    addAll([
      CustomPainterComponent(
        painter: _DragPainter(this),
        anchor: Anchor.center,
        size: Vector2(playerSize, playerSize),
        position: Vector2(0, 0),
      ),
      SpriteComponent(
        anchor: Anchor.center,
        sprite: _sprite,
        size: Vector2(playerSize, playerSize),
        position: Vector2(0, 0),
      )
    ]);
    return super.onLoad();
  }

  @override
  void update(double dt) {
    super.update(dt);

    if (!body.isAwake) {
      removeFromParent();
    }

    if (position.x > camera.visibleWorldRect.right + 10 ||
        position.x < camera.visibleWorldRect.left - 10) {
      removeFromParent();
    }
  }

  Vector2 _dragStart = Vector2.zero();
  Vector2 _dragDelta = Vector2.zero();
  Vector2 get dragDelta => _dragDelta;

  @override
  void onDragStart(DragStartEvent event) {
    super.onDragStart(event);
    if (body.bodyType == BodyType.static) {
      _dragStart = event.localPosition;
    }
  }

  @override
  void onDragUpdate(DragUpdateEvent event) {
    if (body.bodyType == BodyType.static) {
      _dragDelta = event.localEndPosition - _dragStart;
    }
  }

  @override
  void onDragEnd(DragEndEvent event) {
    super.onDragEnd(event);
    if (body.bodyType == BodyType.static) {
      children
          .whereType<CustomPainterComponent>()
          .firstOrNull
          ?.removeFromParent();
      body.setType(BodyType.dynamic);
      body.applyLinearImpulse(_dragDelta * -50);
      add(RemoveEffect(
        delay: 5.0,
      ));
    }
  }
}

extension on String {
  String get capitalize =>
      characters.first.toUpperCase() + characters.skip(1).toLowerCase().join();
}

class _DragPainter extends CustomPainter {
  _DragPainter(this.player);

  final Player player;

  @override
  void paint(Canvas canvas, Size size) {
    if (player.dragDelta != Vector2.zero()) {
      var center = size.center(Offset.zero);
      canvas.drawLine(
          center,
          center + (player.dragDelta * -1).toOffset(),
          Paint()
            ..color = Colors.orange.withOpacity(0.7)
            ..strokeWidth = 0.4
            ..strokeCap = StrokeCap.round);
    }
  }

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

Si tratta di un passaggio rispetto ai componenti Brick del passaggio precedente. Questo componente Player ha due componenti secondari: un SpriteComponent che dovresti riconoscere e un CustomPainterComponent nuovo. Il concetto di CustomPainter è di Flutter e ti consente di dipingere su tela. Viene utilizzato qui per dare al giocatore un feedback sulla posizione in cui volerà l'alieno quando verrà lanciato.

In che modo il giocatore avvia la fuga dell'alieno? Utilizzo di un gesto di trascinamento, rilevato dal componente Player con i callback DragCallbacks. L'aquila che guarda in mezzo avrà notato qualcos'altro qui.

Quando i componenti di Ground erano corpi statici, i componenti dei mattoncini erano corpi dinamici. Il player qui è una combinazione di entrambi. Il giocatore inizia in modo statico, attendendo che lo trascini, poi, al rilascio tramite trascinamento, si converte da statico a dinamico, aggiunge un impulso lineare in proporzione alla resistenza e fa volare l'avatar alieno.

Il componente Player c'è anche del codice per rimuoverlo dallo schermo se supera i limiti, si addormenta o si verifica il timeout. Lo scopo è consentire al giocatore di lanciare l'alieno, vedere cosa succede e poi fare un altro tentativo.

Integra il componente Player nel gioco modificando game.dart in questo modo:

lib/components/game.dart

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

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';
import 'brick.dart';
import 'ground.dart';
import 'player.dart';                                      // Add this import

class MyPhysicsGame extends Forge2DGame {
  MyPhysicsGame()
      : super(
          gravity: Vector2(0, 10),
          camera: CameraComponent.withFixedResolution(width: 800, height: 600),
        );

  late final XmlSpriteSheet aliens;
  late final XmlSpriteSheet elements;
  late final XmlSpriteSheet tiles;

  @override
  FutureOr<void> onLoad() async {
    final backgroundImage = await images.load('colored_grass.png');
    final spriteSheets = await Future.wait([
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_aliens.png',
        xmlPath: 'spritesheet_aliens.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_elements.png',
        xmlPath: 'spritesheet_elements.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_tiles.png',
        xmlPath: 'spritesheet_tiles.xml',
      ),
    ]);

    aliens = spriteSheets[0];
    elements = spriteSheets[1];
    tiles = spriteSheets[2];

    await world.add(Background(sprite: Sprite(backgroundImage)));
    await addGround();
    unawaited(addBricks());
    await addPlayer();                                     // Add this line

    return super.onLoad();
  }

  Future<void> addGround() {
    return world.addAll([
      for (var x = camera.visibleWorldRect.left;
          x < camera.visibleWorldRect.right + groundSize;
          x += groundSize)
        Ground(
          Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
          tiles.getSprite('grass.png'),
        ),
    ]);
  }

  final _random = Random();

  Future<void> addBricks() async {
    for (var i = 0; i < 5; i++) {
      final type = BrickType.randomType;
      final size = BrickSize.randomSize;
      await world.add(
        Brick(
          type: type,
          size: size,
          damage: BrickDamage.some,
          position: Vector2(
              camera.visibleWorldRect.right / 3 +
                  (_random.nextDouble() * 5 - 2.5),
              0),
          sprites: brickFileNames(type, size).map(
            (key, filename) => MapEntry(
              key,
              elements.getSprite(filename),
            ),
          ),
        ),
      );
      await Future<void>.delayed(const Duration(milliseconds: 500));
    }
  }

  Future<void> addPlayer() async => world.add(             // Add from here...
        Player(
          Vector2(camera.visibleWorldRect.left * 2 / 3, 0),
          aliens.getSprite(PlayerColor.randomColor.fileName),
        ),
      );

  @override
  void update(double dt) {
    super.update(dt);
    if (isMounted && world.children.whereType<Player>().isEmpty) {
      addPlayer();
    }
  }                                                        // To here.
}

L'aggiunta del giocatore al gioco è simile ai componenti precedenti, con una pieghe in più. L'alieno del giocatore è progettato per rimuovere se stesso dal gioco in determinate condizioni, quindi qui è presente un gestore degli aggiornamenti che controlla se non ci sono componenti Player nel gioco e, in tal caso, ne aggiunge uno di nuovo. La gestione del gioco è simile a quella indicata qui.

Una finestra dell&#39;app con colline verdi sullo sfondo, il livello del suolo, blocchi a terra e un avatar del giocatore in volo.

7. Reagisci all'impatto

Aggiungere i nemici

Hai visto oggetti statici e dinamici interagire tra loro. Tuttavia, per arrivare veramente da qualche parte, devi ottenere dei callback nel codice quando gli oggetti si scontrano. Vediamo come si fa. Presenterai alcuni nemici che il giocatore dovrà affrontare. In questo modo otterrai un percorso verso una condizione vincente: rimuovi tutti i nemici dal gioco.

Crea un file enemy.dart nella directory lib/components e aggiungi quanto segue:

lib/components/enemy.dart

import 'dart:math';

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

import 'body_component_with_user_data.dart';

const enemySize = 5.0;

enum EnemyColor {
  pink(color: 'pink', boss: false),
  blue(color: 'blue', boss: false),
  green(color: 'green', boss: false),
  yellow(color: 'yellow', boss: false),
  pinkBoss(color: 'pink', boss: true),
  blueBoss(color: 'blue', boss: true),
  greenBoss(color: 'green', boss: true),
  yellowBoss(color: 'yellow', boss: true);

  final bool boss;
  final String color;

  const EnemyColor({required this.color, required this.boss});

  static EnemyColor get randomColor =>
      EnemyColor.values[Random().nextInt(EnemyColor.values.length)];

  String get fileName =>
      'alien${color.capitalize}_${boss ? 'suit' : 'square'}.png';
}

class Enemy extends BodyComponentWithUserData with ContactCallbacks {
  Enemy(Vector2 position, Sprite sprite)
      : super(
          renderBody: false,
          bodyDef: BodyDef()
            ..position = position
            ..type = BodyType.dynamic,
          fixtureDefs: [
            FixtureDef(
              PolygonShape()..setAsBoxXY(enemySize / 2, enemySize / 2),
              friction: 0.3,
            )
          ],
          children: [
            SpriteComponent(
              anchor: Anchor.center,
              sprite: sprite,
              size: Vector2.all(enemySize),
              position: Vector2(0, 0),
            ),
          ],
        );

  @override
  void beginContact(Object other, Contact contact) {
    var interceptVelocity =
        (contact.bodyA.linearVelocity - contact.bodyB.linearVelocity)
            .length
            .abs();
    if (interceptVelocity > 35) {
      removeFromParent();
    }

    super.beginContact(other, contact);
  }

  @override
  void update(double dt) {
    super.update(dt);

    if (position.x > camera.visibleWorldRect.right + 10 ||
        position.x < camera.visibleWorldRect.left - 10) {
      removeFromParent();
    }
  }
}

extension on String {
  String get capitalize =>
      characters.first.toUpperCase() + characters.skip(1).toLowerCase().join();
}

Dalle tue precedenti interazioni con i componenti Player e Brick, gran parte di questo file dovrebbe essere familiare. Tuttavia, nell'editor saranno presenti un paio di sottolineature rosse a causa di una nuova classe base sconosciuta. Aggiungi ora questa classe aggiungendo a lib/components un file denominato body_component_with_user_data.dart con i seguenti contenuti:

lib/components/body_component_with_user_data.dart

import 'package:flame_forge2d/flame_forge2d.dart';

class BodyComponentWithUserData extends BodyComponent {
  BodyComponentWithUserData({
    super.key,
    super.bodyDef,
    super.children,
    super.fixtureDefs,
    super.paint,
    super.priority,
    super.renderBody,
  });

  @override
  Body createBody() {
    final body = world.createBody(super.bodyDef!)..userData = this;
    fixtureDefs?.forEach(body.createFixture);
    return body;
  }
}

Questa classe base, combinata con il nuovo callback beginContact nel componente Enemy, costituisce la base per ricevere notifiche programmatiche sugli effetti tra i corpi. Dovrai infatti modificare tutti i componenti tra i quali vuoi ricevere notifiche sull'impatto. Puoi quindi modificare i componenti Brick, Ground e Player per utilizzare BodyComponentWithUserData al posto della classe base BodyComponent attualmente in uso. Ad esempio, ecco come modificare il componente Ground:

lib/components/ground.dart

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

import 'body_component_with_user_data.dart';               // Add this import

const groundSize = 7.0;

class Ground extends BodyComponentWithUserData {           // Edit this line
  Ground(Vector2 position, Sprite sprite)
      : super(
          renderBody: false,
          bodyDef: BodyDef()
            ..position = position
            ..type = BodyType.static,
          fixtureDefs: [
            FixtureDef(
              PolygonShape()..setAsBoxXY(groundSize / 2, groundSize / 2),
              friction: 0.3,
            )
          ],
          children: [
            SpriteComponent(
              anchor: Anchor.center,
              sprite: sprite,
              size: Vector2.all(groundSize),
              position: Vector2(0, 0),
            ),
          ],
        );
}

Per ulteriori informazioni su come Forge2d gestisce i contatti, consulta la documentazione di Forge2D sulle richiamate dei contatti.

Vincere la partita

Ora che hai dei nemici e un modo per rimuoverli dal mondo, esiste un modo semplicistico per trasformare questa simulazione in un gioco. Scegli l'obiettivo di rimuovere tutti i nemici! È il momento di modificare il file game.dart come segue:

lib/components/game.dart

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

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

import 'background.dart';
import 'brick.dart';
import 'enemy.dart';                                       // Add this import
import 'ground.dart';
import 'player.dart';

class MyPhysicsGame extends Forge2DGame {
  MyPhysicsGame()
      : super(
          gravity: Vector2(0, 10),
          camera: CameraComponent.withFixedResolution(width: 800, height: 600),
        );

  late final XmlSpriteSheet aliens;
  late final XmlSpriteSheet elements;
  late final XmlSpriteSheet tiles;

  @override
  FutureOr<void> onLoad() async {
    final backgroundImage = await images.load('colored_grass.png');
    final spriteSheets = await Future.wait([
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_aliens.png',
        xmlPath: 'spritesheet_aliens.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_elements.png',
        xmlPath: 'spritesheet_elements.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_tiles.png',
        xmlPath: 'spritesheet_tiles.xml',
      ),
    ]);

    aliens = spriteSheets[0];
    elements = spriteSheets[1];
    tiles = spriteSheets[2];

    await world.add(Background(sprite: Sprite(backgroundImage)));
    await addGround();
    unawaited(addBricks().then((_) => addEnemies()));      // Modify this line
    await addPlayer();

    return super.onLoad();
  }

  Future<void> addGround() {
    return world.addAll([
      for (var x = camera.visibleWorldRect.left;
          x < camera.visibleWorldRect.right + groundSize;
          x += groundSize)
        Ground(
          Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
          tiles.getSprite('grass.png'),
        ),
    ]);
  }

  final _random = Random();

  Future<void> addBricks() async {
    for (var i = 0; i < 5; i++) {
      final type = BrickType.randomType;
      final size = BrickSize.randomSize;
      await world.add(
        Brick(
          type: type,
          size: size,
          damage: BrickDamage.some,
          position: Vector2(
              camera.visibleWorldRect.right / 3 +
                  (_random.nextDouble() * 5 - 2.5),
              0),
          sprites: brickFileNames(type, size).map(
            (key, filename) => MapEntry(
              key,
              elements.getSprite(filename),
            ),
          ),
        ),
      );
      await Future<void>.delayed(const Duration(milliseconds: 500));
    }
  }

  Future<void> addPlayer() async => world.add(
        Player(
          Vector2(camera.visibleWorldRect.left * 2 / 3, 0),
          aliens.getSprite(PlayerColor.randomColor.fileName),
        ),
      );

  @override
  void update(double dt) {
    super.update(dt);
    if (isMounted &&                                       // Modify from here...
        world.children.whereType<Player>().isEmpty &&
        world.children.whereType<Enemy>().isNotEmpty) {
      addPlayer();
    }
    if (isMounted &&
        enemiesFullyAdded &&
        world.children.whereType<Enemy>().isEmpty &&
        world.children.whereType<TextComponent>().isEmpty) {
      world.addAll(
        [
          (position: Vector2(0.5, 0.5), color: Colors.white),
          (position: Vector2.zero(), color: Colors.orangeAccent),
        ].map(
          (e) => TextComponent(
            text: 'You win!',
            anchor: Anchor.center,
            position: e.position,
            textRenderer: TextPaint(
              style: TextStyle(color: e.color, fontSize: 16),
            ),
          ),
        ),
      );
    }
  }

  var enemiesFullyAdded = false;

  Future<void> addEnemies() async {
    await Future<void>.delayed(const Duration(seconds: 2));
    for (var i = 0; i < 3; i++) {
      await world.add(
        Enemy(
          Vector2(
              camera.visibleWorldRect.right / 3 +
                  (_random.nextDouble() * 7 - 3.5),
              (_random.nextDouble() * 3)),
          aliens.getSprite(EnemyColor.randomColor.fileName),
        ),
      );
      await Future<void>.delayed(const Duration(seconds: 1));
    }
    enemiesFullyAdded = true;                              // To here.
  }
}

La tua sfida, se decidi di accettarla, è eseguire il gioco e visualizzare questo schermo.

Una finestra dell&#39;app con delle colline verdi sullo sfondo, il livello del suolo, blocchi sul terreno e un overlay di testo &quot;Hai vinto!&quot;

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