Compila un juego de física en 2D con Flutter y Flame

Cómo compilar un juego de física en 2D con Flutter y Flame

Acerca de este codelab

subjectÚltima actualización: jun 23, 2025
account_circleEscrito por Brett Morgan

1. Antes de comenzar

Flame es un motor de juego 2D basado en Flutter. En este codelab, compilarás un juego que usa una simulación de física en 2D similar a Box2D llamado Forge2D. Usas los componentes de Flame para pintar la realidad física simulada en la pantalla para que tus usuarios jueguen con ella. Cuando termines, el juego debería verse como este GIF animado:

Animación del juego con este juego de física en 2D

Requisitos previos

Qué aprenderá

  • Cómo funcionan los conceptos básicos de Forge2D, comenzando por los diferentes tipos de cuerpos físicos.
  • Cómo configurar una simulación física en 2D

Requisitos

Software del compilador para el segmento de desarrollo que elegiste Este codelab funciona en las seis plataformas que admite Flutter. Necesitas Visual Studio para segmentar a Windows, Xcode para segmentar a macOS o iOS, y Android Studio para segmentar a Android.

2. Crea un proyecto

Crea tu proyecto de Flutter

Existen muchas formas de crear un proyecto de Flutter. En esta sección, usarás la línea de comandos para ahorrar espacio.

Para comenzar, sigue estos pasos:

  1. En una línea de comandos, crea un proyecto de 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.
    
  2. Modifica las dependencias del proyecto para agregar Flame y Forge2D:
    $ cd forge2d_game
    $ flutter pub add characters flame flame_forge2d flame_kenney_xml xml
    Resolving dependencies...
    Downloading packages...
      characters 1.4.0 (from transitive dependency to direct dependency)
    + flame 1.29.0
    + flame_forge2d 0.19.0+2
    + flame_kenney_xml 0.1.1+12
      flutter_lints 5.0.0 (6.0.0 available)
    + forge2d 0.14.0
      leak_tracker 10.0.9 (11.0.1 available)
      leak_tracker_flutter_testing 3.0.9 (3.0.10 available)
      leak_tracker_testing 3.0.1 (3.0.2 available)
      lints 5.1.1 (6.0.0 available)
      material_color_utilities 0.11.1 (0.13.0 available)
      meta 1.16.0 (1.17.0 available)
    + ordered_set 8.0.0
    + petitparser 6.1.0 (7.0.0 available)
      test_api 0.7.4 (0.7.6 available)
      vector_math 2.1.4 (2.2.0 available)
      vm_service 15.0.0 (15.0.2 available)
    + xml 6.5.0 (6.6.0 available)
    Changed 8 dependencies!
    12 packages have newer versions incompatible with dependency constraints.
    Try `flutter pub outdated` for more information.
    

Ya conoces el paquete flame, pero es posible que los otros tres requieran alguna explicación. El paquete characters se usa para manipular rutas de acceso de manera compatible con UTF8. El paquete flame_forge2d expone la funcionalidad de Forge2D de una manera que funciona bien con Flame. Por último, el paquete xml se usa en varios lugares para consumir y modificar contenido XML.

Abre el proyecto y, luego, reemplaza el contenido del archivo lib/main.dart por lo siguiente:

lib/main.dart

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

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

Esto inicia la app con un GameWidget que crea una instancia de la instancia FlameGame. En este codelab, no hay código de Flutter que use el estado de la instancia del juego para mostrar información sobre el juego en ejecución, por lo que este inicio simplificado funciona bien.

Opcional: Realiza una misión secundaria solo para macOS

Las capturas de pantalla de este proyecto son del juego como una app para computadoras de escritorio de macOS. Para evitar que la barra de título de la app le reste valor a la experiencia general, puedes modificar la configuración del proyecto del ejecutor de macOS para omitir la barra de título.

Para hacerlo, sigue estos pasos:

  1. Crea un archivo bin/modify_macos_config.dart y agrega el siguiente contenido:

bin/modify_macos_config.dart

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

Este archivo no está en el directorio lib porque no forma parte de la base de código del entorno de ejecución del juego. Es una herramienta de línea de comandos que se usa para modificar el proyecto.

  1. Desde el directorio base del proyecto, ejecuta la herramienta de la siguiente manera:
dart bin/modify_macos_config.dart

Si todo sale según lo previsto, el programa no generará ningún resultado en la línea de comandos. Sin embargo, modificará el archivo de configuración macos/Runner/Base.lproj/MainMenu.xib para ejecutar el juego sin una barra de título visible y con el juego Flame ocupando toda la ventana.

Ejecuta el juego para verificar que todo funcione correctamente. Se debería mostrar una ventana nueva con solo un fondo negro en blanco.

Ventana de una app con fondo negro y nada en primer plano

3. Agrega recursos de imagen

Agregar imágenes

Cualquier juego necesita recursos de arte para poder pintar una pantalla de una manera que sea divertida. En este codelab, se usará el paquete de recursos de física de Kenney.nl. Estos recursos tienen la licencia Creative Commons CC0, pero te recomiendo que realices una donación al equipo de Kenney para que puedan seguir haciendo el gran trabajo que realizan. Listo.

Deberás modificar el archivo de configuración pubspec.yaml para habilitar el uso de los recursos de Kenney. Modifica el archivo de la siguiente manera:

pubspec.yaml

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

environment:
  sdk: ^3.8.1

dependencies:
  flutter:
    sdk: flutter
  characters: ^1.4.0
  flame: ^1.29.0
  flame_forge2d: ^0.19.0+2
  flame_kenney_xml: ^0.1.1+12
  xml: ^6.5.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

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

Flame espera que los recursos de imagen se encuentren en assets/images, aunque esto se puede configurar de manera diferente. Consulta la documentación de imágenes de Flame para obtener más detalles. Ahora que tienes las rutas configuradas, debes agregarlas al proyecto. Una forma de hacerlo es usar la línea de comandos de la siguiente manera:

mkdir -p assets/images

No debería haber ningún resultado del comando mkdir, pero el directorio nuevo debería ser visible en tu editor o en un explorador de archivos.

Expande el archivo kenney_physics-assets.zip que descargaste y deberías ver algo como lo siguiente:

Una lista de archivos del paquete kenney_physics-assets expandida, con el directorio PNG/Backgrounds destacado

Desde el directorio PNG/Backgrounds, copia los archivos colored_desert.png, colored_grass.png, colored_land.png y colored_shroom.png en el directorio assets/images de tu proyecto.

También hay hojas de sprites. Son una combinación de una imagen PNG y un archivo en formato XML que describe dónde se pueden encontrar imágenes más pequeñas en la imagen de la hoja de sprites. Las hojas de sprites son una técnica para reducir el tiempo de carga, ya que solo cargan un solo archivo en lugar de decenas, o incluso cientos, de archivos de imagen individuales.

Una lista de archivos del paquete kenney_physics-assets expandida, con el directorio Spritesheet destacado

Copia spritesheet_aliens.png, spritesheet_elements.png y spritesheet_tiles.png en el directorio assets/images de tu proyecto. Mientras estás aquí, también copia los archivos spritesheet_aliens.xml, spritesheet_elements.xml y spritesheet_tiles.xml en el directorio assets de tu proyecto. Tu proyecto debería verse de la siguiente manera.

Una lista de archivos del directorio del proyecto forge2d_game, con el directorio de recursos destacado

Pinta el fondo

Ahora que tu proyecto tiene recursos de imagen agregados, es hora de colocarlos en la pantalla. Bueno, una imagen en pantalla. Obtendrás más información en los próximos pasos.

Crea un archivo llamado background.dart en un directorio nuevo llamado lib/components y agrega el siguiente contenido.

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

Este componente es un SpriteComponent especializado. Es responsable de mostrar una de las cuatro imágenes de fondo de Kenney.nl. Hay algunas suposiciones simplificadoras en este código. La primera es que las imágenes son cuadradas, como lo son las cuatro imágenes de fondo de Kenney. El segundo es que el tamaño del mundo visible nunca cambiará; de lo contrario, este componente tendría que controlar los eventos de cambio de tamaño del juego. La tercera suposición es que la posición (0,0) estará en el centro de la pantalla. Estas suposiciones requieren una configuración específica del CameraComponent del juego.

Crea otro archivo nuevo, este llamado game.dart, nuevamente en el directorio 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();
 
}
}

Aquí suceden muchas cosas. Comienza con la clase MyPhysicsGame. A diferencia del codelab anterior, este extiende Forge2DGame, no FlameGame. Forge2DGame extiende FlameGame con algunos ajustes interesantes. El primero es que, de forma predeterminada, zoom se establece en 10. Este parámetro de configuración zoom está relacionado con el rango de valores útiles con los que funcionan bien los motores de simulación de física de estilo Box2D. El motor se escribe con el sistema MKS, en el que se supone que las unidades están en metros, kilogramos y segundos. El rango en el que no se ven errores matemáticos notables para los objetos es de 0.1 metros a 10 metros. Si se ingresan dimensiones de píxeles directamente sin algún nivel de reducción, Forge2D se ubicaría fuera de su envolvente útil. El resumen útil es pensar en simular objetos en el rango de una lata de refresco hasta un autobús.

Las suposiciones que se hacen en el componente Background se satisfacen aquí corrigiendo la resolución de CameraComponent a 800 por 600 píxeles virtuales. Esto significa que el área de juego tendrá 80 unidades de ancho y 60 unidades de alto, centrada en (0,0). Esto no tiene efecto en la resolución que se muestra, pero sí en la ubicación de los objetos en la escena del juego.

Junto con el argumento del constructor camera, hay otro argumento más alineado con la física llamado gravity. La gravedad se establece en un Vector2 con un x de 0 y un y de 10. 10 es una aproximación cercana al valor generalmente aceptado de 9.81 metros por segundo por segundo para la gravedad. El hecho de que la gravedad esté configurada en 10 positivo muestra que, en este sistema, la dirección del eje Y es hacia abajo. Esto es diferente de Box2D en general, pero está alineado con la forma en que se suele configurar Flame.

A continuación, se muestra el método onLoad. Este método es asíncrono, lo cual es adecuado porque es responsable de cargar recursos de imagen desde el disco. Las llamadas a images.load muestran un Future<Image> y, como efecto secundario, almacenan en caché la imagen cargada en el objeto Game. Estos futuros se reúnen y se esperan como una sola unidad con el método estático Futures.wait. Luego, la lista de imágenes que se muestran se agrupa en nombres individuales según el patrón.

Luego, las imágenes de la hoja de sprites se envían a una serie de objetos XmlSpriteSheet que se encargan de recuperar los sprites con nombres individuales que contiene la hoja de sprites. La clase XmlSpriteSheet se define en el paquete flame_kenney_xml.

Con todo esto resuelto, solo necesitas hacer un par de ediciones menores en lib/main.dart para obtener una imagen en la pantalla.

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 este cambio, ahora puedes volver a ejecutar el juego para ver el fondo en la pantalla. Ten en cuenta que la instancia de la cámara CameraComponent.withFixedResolution() agregará el formato letterbox según sea necesario para que funcione la relación de aspecto de 800 por 600 del juego.

Una app con colinas verdes ondulantes y árboles extrañamente abstractos.

4. Agrega el suelo

Algo en lo que basarse

Si tenemos gravedad, necesitamos algo que atrape los objetos del juego antes de que caigan de la parte inferior de la pantalla. A menos que caerse de la pantalla sea parte del diseño del juego, por supuesto. Crea un nuevo archivo ground.dart en el directorio lib/components y agrega lo siguiente:

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),
         
),
       
],
     
);
}

Este componente Ground proviene de BodyComponent. En Forge2D, los cuerpos son importantes, ya que son los objetos que forman parte de la simulación física en dos dimensiones. Se especifica que el BodyDef de este componente tenga un BodyType.static.

En Forge2D, los cuerpos tienen tres tipos diferentes. Los cuerpos estáticos no se mueven. En realidad, tienen masa cero (no reaccionan a la gravedad) y masa infinita (no se mueven cuando otros objetos los golpean, sin importar lo pesados que sean). Esto hace que los cuerpos estáticos sean perfectos para una superficie terrestre, ya que no se mueven.

Los otros dos tipos de cuerpos son cinemáticos y dinámicos. Los cuerpos dinámicos son cuerpos que se simulan por completo y reaccionan a la gravedad y a los objetos con los que chocan. Verás muchos cuerpos dinámicos en el resto de este codelab. Los cuerpos cinemáticos son una solución intermedia entre los estáticos y los dinámicos. Se mueven, pero no reaccionan a la gravedad ni a otros objetos que los golpean. Es útil, pero está fuera del alcance de este codelab.

El cuerpo en sí no hace mucho. Un cuerpo necesita formas asociadas para tener sustancia. En este caso, este cuerpo tiene una forma asociada, un PolygonShape configurado como BoxXY. Este tipo de cuadro está alineado con el eje del mundo, a diferencia de un PolygonShape configurado como BoxXY, que se puede rotar alrededor de un punto de rotación. Una vez más, es útil, pero también está fuera del alcance de este codelab. La forma y el cuerpo se unen con un accesorio, que es útil para agregar elementos como friction al sistema.

De forma predeterminada, un cuerpo renderizará sus formas adjuntas de una manera que es útil para la depuración, pero no es ideal para el juego. Si estableces el argumento super renderBody en false, se inhabilita esta renderización de depuración. Es responsabilidad del SpriteComponent secundario proporcionar una renderización en el juego para este cuerpo.

Para agregar el componente Ground al juego, edita el archivo game.dart de la siguiente manera.

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

Esta edición agrega una serie de componentes Ground al mundo mediante un bucle for dentro de un contexto List y pasa la lista resultante de componentes Ground al método addAll de world.

Cuando se ejecuta el juego, ahora se muestran el fondo y el suelo.

Ventana de la aplicación con fondo y una capa de fondo.

5. Agrega los ladrillos

Cómo construir un muro

El suelo nos dio un ejemplo de un cuerpo estático. Ahora es el momento de crear tu primer componente dinámico. Los componentes dinámicos en Forge2D son la piedra angular de la experiencia del jugador, ya que son los elementos que se mueven y que interactúan con el mundo que los rodea. En este paso, agregarás ladrillos, que se elegirán de forma aleatoria para que aparezcan en la pantalla en un clúster de ladrillos. Verás que se caen y chocan entre sí mientras lo hacen.

Los ladrillos se crearán a partir de la hoja de sprites de los elementos. Si observas la descripción de la hoja de sprites en assets/spritesheet_elements.xml, verás que tenemos un problema interesante. Los nombres no parecen ser muy útiles. Sería útil poder seleccionar un ladrillo por tipo de material, tamaño y cantidad de daños. Por suerte, un elfo útil dedicó tiempo a descubrir el patrón en los nombres de los archivos y creó una herramienta para facilitarles el trabajo. Crea un nuevo archivo generate_brick_file_names.dart en el directorio bin y agrega el siguiente contenido:

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

El editor debería mostrarte una advertencia o un error sobre una dependencia faltante. Agrega el siguiente comando:

flutter pub add equatable

Ahora deberías poder ejecutar este programa de la siguiente manera:

$ 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',
      },
  };
}

Esta herramienta analizó de forma útil el archivo de descripción de la hoja de sprites y lo convirtió en código Dart que podemos usar para seleccionar el archivo de imagen correcto para cada ladrillo que quieras mostrar en la pantalla. ¡Es útil!

Crea el archivo brick.dart con el siguiente contenido:

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

Ahora puedes ver cómo el código de Dart generado anteriormente se integra en esta base de código para que sea rápido seleccionar imágenes de ladrillos en función del material, el tamaño y el estado. Si miras más allá de los enum y te fijas en el componente Brick, deberías ver que la mayor parte de este código te resulta bastante familiar del componente Ground del paso anterior. Aquí hay un estado mutable para permitir que el ladrillo se dañe, aunque usar esto se deja como un ejercicio para el lector.

Es hora de mostrar los ladrillos en la pantalla. Edita el archivo game.dart de la siguiente manera:

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

Esta adición de código es un poco diferente del código que usaste para agregar los componentes Ground. Esta vez, los Brick se agregan en un clúster aleatorio con el tiempo. Esto tiene dos partes: la primera es que el método que agrega los Brick awaits un Future.delayed, que es el equivalente asíncrono de una llamada sleep(). Sin embargo, hay una segunda parte para que esto funcione: la llamada a addBricks en el método onLoad no se await. Si lo fuera, el método onLoad no se completaría hasta que todos los ladrillos estuvieran en pantalla. Unir la llamada a addBricks en una llamada a unawaited hace que los linters estén contentos y que nuestro intent sea obvio para los programadores futuros. No esperar a que se devuelva este método es intencional.

Ejecuta el juego y verás que aparecen ladrillos, se chocan entre sí y se derraman en el suelo.

Una ventana de la app con colinas verdes en el fondo, una capa de suelo y bloques que aterrizan en el suelo.

6. Agrega el jugador

Lanza alienígenas contra ladrillos

Ver cómo se derrumban los ladrillos es divertido las primeras veces, pero creo que este juego será más divertido si le damos al jugador un avatar que pueda usar para interactuar con el mundo. ¿Qué tal un alienígena que pueda lanzar contra los ladrillos?

Crea un nuevo archivo player.dart en el directorio lib/components y agrega lo siguiente:

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.withAlpha(180)
         
..strokeWidth = 0.4
         
..strokeCap = StrokeCap.round,
     
);
   
}
 
}

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

Este es un paso adelante de los componentes Brick del paso anterior. Este componente Player tiene dos componentes secundarios: un SpriteComponent que deberías reconocer y un CustomPainterComponent que es nuevo. El concepto de CustomPainter es de Flutter y te permite pintar en un lienzo. Aquí se usa para brindarle al jugador comentarios sobre dónde volará el alienígena redondo cuando se lance.

¿Cómo inicia el jugador el lanzamiento del alienígena? Con un gesto de arrastre, que el componente Player detecta con las devoluciones de llamada de DragCallbacks. Los más observadores habrán notado algo más.

Mientras que los componentes Ground eran cuerpos estáticos, los componentes de Brick eran cuerpos dinámicos. El jugador aquí es una combinación de ambos. El jugador comienza como estático, esperando a que el jugador lo arrastre, y cuando se suelta el arrastre, se convierte de estático a dinámico, agrega un impulso lineal en proporción al arrastre y permite que el avatar alienígena vuele.

También hay código en el componente Player para quitarlo de la pantalla si sale de los límites, se queda inactivo o se agota el tiempo de espera. El objetivo aquí es permitir que el jugador lance al alienígena, vea qué sucede y, luego, vuelva a intentarlo.

Para integrar el componente Player en el juego, edita game.dart de la siguiente manera:

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

Agregar el jugador al juego es similar a los componentes anteriores, con una complicación adicional. El alienígena del jugador está diseñado para quitarse del juego en determinadas condiciones, por lo que hay un controlador de actualización que verifica si no hay un componente Player en el juego y, de ser así, vuelve a agregar uno. La ejecución del juego se ve de la siguiente manera.

Ventana de una app con colinas verdes en el fondo, capa de suelo, bloques en el suelo y un avatar de jugador en vuelo.

7. Cómo reaccionar al impacto

Agrega los enemigos

Ya viste objetos estáticos y dinámicos que interactúan entre sí. Sin embargo, para llegar a algún lugar, debes obtener devoluciones de llamada en el código cuando se producen colisiones. Agregarás algunos enemigos para que el jugador luche contra ellos. Esto proporciona una ruta hacia una condición de victoria: quitar todos los enemigos del juego.

Crea un archivo enemy.dart en el directorio lib/components y agrega lo siguiente:

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

A partir de tus interacciones anteriores con los componentes Player y Brick, la mayor parte de este archivo debería resultarte familiar. Sin embargo, habrá un par de subrayados en rojo en el editor debido a una nueva clase base desconocida. Agrega esta clase ahora agregando un archivo llamado body_component_with_user_data.dart a lib/components con el siguiente contenido:

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

Esta clase base, combinada con la nueva devolución de llamada beginContact en el componente Enemy, forma la base para recibir notificaciones de forma programática sobre los impactos entre cuerpos. De hecho, deberás editar los componentes entre los que deseas recibir notificaciones de impacto. Por lo tanto, edita los componentes Brick, Ground y Player para usar este BodyComponentWithUserData en lugar de la clase base BodyComponent que usan esos componentes. Por ejemplo, aquí se muestra cómo editar el 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),
         
),
       
],
     
);
}

Para obtener más información sobre cómo Forge2d controla los contactos, consulta la documentación de Forge2D sobre las devoluciones de llamada de contacto.

Gana el partido

Ahora que tienes enemigos y una forma de quitarlos del mundo, hay una forma sencilla de convertir esta simulación en un juego. El objetivo es quitar a todos los enemigos. Es hora de editar el archivo game.dart de la siguiente manera:

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

Tu desafío, si decides aceptarlo, es ejecutar el juego y llegar a esta pantalla.

Una ventana de la app con colinas verdes en el fondo, una capa de suelo, bloques en el suelo y una superposición de texto que dice &quot;¡Ganaste!&quot;

8. Felicitaciones

¡Felicitaciones! Compilaste un juego con Flutter y Flame.

Compilaste un juego con el motor de juego 2D de Flame y lo incorporaste en un wrapper de Flutter. Usaste los efectos de Flame para animar y quitar componentes. Usaste los paquetes de Google Fonts y Flutter Animate para que todo el juego se vea bien diseñado.

Próximos pasos

Consulta algunos codelabs sobre los siguientes temas:

Lecturas adicionales