1. Antes de comenzar
Flame es un motor de juego 2D basado en Flutter. En este codelab, crearás un juego que usa una simulación física en 2D similar a la de Box2D llamada Forge2D. Usarás los componentes de Flame para pintar la realidad física simulada en la pantalla para que tus usuarios jueguen con ella. Cuando esté completo, tu juego debería verse como este GIF animado:
Requisitos previos
- Haber completado el codelab Introducción a Flame con Flutter
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
- El SDK de Flutter
- Visual Studio Code (VS Code) con los complementos de Flutter y Dart
Software de compilación para tu objetivo de desarrollo elegido. Este codelab funciona para las seis plataformas que Flutter admite. Necesitas Visual Studio para orientar a Windows, Xcode para segmentar a macOS o iOS y Android Studio para orientar 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 abreviar.
Para comenzar, siga estos pasos:
- 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.
- 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.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.
Ya conoces el paquete flame
, pero los otros tres pueden requerir una explicación. El paquete characters
se usa para manipular la ruta de acceso al archivo de una manera que cumpla 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
con 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 de FlameGame
. En este codelab, no hay un 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 arranque simplificado funciona bien.
Realiza una Quest complementaria exclusiva para macOS (opcional)
Las capturas de pantalla de este proyecto son del juego como una app de escritorio para macOS. Para evitar que la barra de título de la app distraiga la experiencia general, puedes modificar la configuración del proyecto del ejecutor de macOS para eludir la barra de título.
Para hacerlo, sigue estos pasos:
- 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());
}
El archivo no se encuentra 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.
- En el directorio base del proyecto, ejecuta la herramienta de la siguiente manera:
$ dart bin/modify_macos_config.dart
Si todo sale según lo planeado, el programa no generará resultados 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 de Flame ocupando toda la ventana.
Ejecuta el juego para verificar que todo funcione correctamente. Se debería mostrar una ventana nueva con un fondo negro en blanco.
3. Agrega recursos de imagen
Agregar imágenes
Cualquier juego necesita recursos artísticos para poder pintar una pantalla de una manera que utilice la búsqueda de diversión. En este codelab, se usará el paquete Physics Assets de Kenney.nl. Estos activos tienen licencia Creative Commons CC0, pero de todas formas te sugiero que des una donación al equipo de Kenney para que puedan continuar con el gran trabajo que están haciendo. Listo.
Deberás modificar el archivo de configuración pubspec.yaml
para habilitar el uso de los recursos de Kenney. Modifícalo de la siguiente manera:
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 espera que los recursos de imagen se ubiquen en assets/images
, aunque esto se puede configurar de manera diferente. Consulta la documentación sobre imágenes de Flame para obtener más información. Ahora que configuraste las rutas de acceso, debes agregarlas al proyecto. Una forma de hacerlo es usar la línea de comandos de la siguiente manera:
$ mkdir -p assets/images
El comando mkdir
no debería generar resultados, pero el directorio nuevo debería estar visible en el editor o en el explorador de archivos.
Expande el archivo kenney_physics-assets.zip
que descargaste. Deberías ver algo como esto:
Desde el directorio PNG/Backgrounds
, copia los archivos colored_desert.png
, colored_grass.png
, colored_land.png
y colored_shroom.png
al directorio assets/images
de tu proyecto.
También hay hojas de objetos. Se trata de 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 Sprite. Las hojas de objeto son una técnica para reducir el tiempo de carga mediante la carga de un solo archivo, en lugar de decenas, o incluso cientos, de archivos de imagen individuales.
Copia spritesheet_aliens.png
, spritesheet_elements.png
y spritesheet_tiles.png
en el directorio assets/images
de tu proyecto. Desde 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.
Pinta el fondo
Ahora que tu proyecto tiene recursos de imagen agregados, es hora de ponerlos en la pantalla. Bueno, una imagen en pantalla. Se incluirán más en los siguientes 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 algunos supuestos simplificados en este código. La primera es que las imágenes son cuadradas, al igual que las cuatro imágenes de fondo de Kenney. La segunda es que el tamaño del mundo visible nunca cambiará; de lo contrario, este componente deberá 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, llamado game.dart
, 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í están ocurriendo muchas cosas. Comencemos con la clase MyPhysicsGame
. A diferencia del codelab anterior, esto extiende Forge2DGame
, no FlameGame
. Forge2DGame
extiende FlameGame
con algunos ajustes interesantes. La primera es que, de forma predeterminada, zoom
se establece en 10. Este parámetro de configuración de zoom
se relaciona 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 por el que no se ven errores matemáticos notables para los objetos es de 0.1 metros a 10 s de metros. Ingresar dimensiones en píxeles directamente sin cierto nivel de reducción de escala llevaría a Forge2D fuera de su útil envolvente. Un resumen útil es simular objetos en el alcance de una botella de refresco hasta un autobús.
Para cumplir con las suposiciones realizadas en el componente en segundo plano, se fija la resolución de CameraComponent
en 800 x 600 píxeles virtuales. Esto significa que el área de juego tendrá 80 unidades de ancho y 60 unidades de alto, centradas en (0,0). Esto no tiene ningún efecto en la resolución que se muestra, pero sí afectará el lugar en el que colocamos los objetos en la escena del juego.
Junto al argumento del constructor camera
hay otro argumento más alineado con la física llamado gravity
. La gravedad se establece en Vector2
, con un x
de 0 y un y
de 10. El valor 10 es una aproximación cercana del valor de gravedad generalmente aceptado de 9.81 metros por segundo por segundo. El hecho de que la gravedad esté configurada en un valor de 10 positivo indica que, en este sistema, la dirección del eje Y es hacia abajo. Por lo general, es diferente de Box2D, pero concuerda con la configuración habitual de Flame.
A continuación, se encuentra 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 recopilan y se esperan como una sola unidad a través del método estático Futures.wait
. Luego, la lista de imágenes mostradas establece coincidencias con nombres individuales.
Las imágenes de la hoja de objeto se envían a una serie de objetos XmlSpriteSheet
que se encargan de recuperar los objetos nombrados individualmente que se encuentran en la hoja de objeto. La clase XmlSpriteSheet
se define en el paquete flame_kenney_xml
.
Después de todo esto, solo necesitas realizar unas pequeñas ediciones en lib/main.dart
para que una imagen aparezca 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 simple cambio, ahora puedes volver a ejecutar el juego para ver el fondo en la pantalla. Ten en cuenta que la instancia de cámara CameraComponent.withFixedResolution()
agregará formato letterbox según sea necesario para que la relación de aspecto de 800 por 600 del juego funcione.
4. Agrega la tierra
Algo para desarrollar
Si tenemos gravedad, necesitamos algo para atrapar objetos en el juego antes de que caigan de la parte inferior de la pantalla. A menos que salirse de la pantalla sea parte del diseño del juego. Crea un archivo ground.dart
nuevo en el directorio lib/components
y agrégale 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
deriva de BodyComponent
. En Forge2D, los cuerpos son importantes porque son los objetos que forman parte de la simulación física bidimensional. Se especificó el BodyDef
de este componente para que tenga un BodyType.static
.
En Forge2D, los cuerpos tienen tres tipos diferentes. Los cuerpos estáticos no se mueven. Efectivamente, tienen una masa cero (no reaccionan a la gravedad) y una masa infinita: no se mueven cuando los golpean otros objetos, sin importar lo pesadas que sean. Esto hace que los cuerpos estáticos sean perfectos para una superficie terrestre, ya que esta no se mueve.
Los otros dos tipos de cuerpos son cinemáticos y dinámicos. Los cuerpos dinámicos son cuerpos completamente simulados, reaccionan a la gravedad y a los objetos con los que chocan. En el resto de este codelab, verás muchos cuerpos dinámicos. Los cuerpos cinemáticos son un punto medio entre la estática y la dinámica. 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 es un eje alineado con el mundo, a diferencia de un PolygonShape
establecido como BoxXY
que se puede rotar alrededor de un punto de rotación. De nuevo es útil, pero también está fuera del alcance de este codelab. La forma y el cuerpo están unidos por un accesorio, lo que es útil para agregar elementos como friction
al sistema.
De forma predeterminada, un cuerpo renderizará las formas adjuntas de una manera útil para la depuración, pero que no ofrece una experiencia de juego excelente. Si estableces el argumento renderBody
de super
en false
, se inhabilita esta renderización de depuración. El elemento secundario SpriteComponent
se encarga de darle a este cuerpo una renderización en el juego.
Para agregar el componente Ground
al juego, edita el archivo game.dart
como se indica a continuación.
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 usando un bucle for
dentro de un contexto List
y pasando 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.
5. Agrega los ladrillos.
La construcción de un muro
El suelo nos dio un ejemplo de un cuerpo estático. Llegó el momento del primer componente dinámico. Los componentes dinámicos de Forge2D son la piedra angular de la experiencia del jugador, ya que son elementos que se mueven e interactúan con el mundo que los rodea. En este paso, introducirás ladrillos, que se elegirán de forma aleatoria para que aparezcan en pantalla en un grupo de ladrillos. Verás que se caen y chocan entre sí mientras lo hacen.
Se crearán ladrillos a partir de la hoja de objeto de elementos. Si observas la descripción de la hoja de objeto en assets/spritesheet_elements.xml
, verás que tenemos un problema interesante. Los nombres no parecen ser muy útiles. Lo que sería útil sería seleccionar un ladrillo por tipo de material, tamaño y cantidad de daños. Afortunadamente, un duende útil se dedicó a identificar el patrón en la nomenclatura de los archivos y creó una herramienta para que les resultara más fácil. Crea un archivo nuevo 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();
}
Tu editor debería darte una advertencia o un error sobre una dependencia faltante. Agrégala de la siguiente manera:
$ flutter pub add equatable
Ahora, deberías poder ejecutar el 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ó útil el archivo de descripción de la hoja de objeto y lo convirtió en código Dart que podemos usar para seleccionar el archivo de imagen correcto para cada ladrillo que desees colocar 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 se integra el código Dart generado anteriormente a esta base de código para facilitar y agilizar la selección de imágenes de ladrillo en función del material, el tamaño y el estado. Si miras más allá de los enum
y el componente Brick
, la mayor parte de este código te parecerá bastante familiar con el componente Ground
del paso anterior. Aquí hay un estado mutable que permite que el ladrillo se dañe, aunque el uso de esto se deja como un ejercicio para el lector.
Es hora de tener 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 al código que usaste para agregar los componentes Ground
. Esta vez, se agregan los objetos Brick
en un clúster aleatorio, con el tiempo. Esto tiene dos partes: la primera es que el método que agrega los await
de los Brick
a Future.delayed
, que es el equivalente asíncrono de una llamada a sleep()
. Sin embargo, existe una segunda parte para hacer esto, la llamada a addBricks
en el método onLoad
no tiene await
. Si así fuera, el método onLoad
no se completaría hasta que todos los ladrillos estuvieran en la pantalla. Unir la llamada a addBricks
en una llamada a unawaited
hace que los linters estén felices y hace que nuestro propósito sea obvio para los futuros programadores. No esperar a que se muestre este método es intencional.
Ejecuta el juego y verás ladrillos que chocan entre sí y se derraman por el suelo.
6. Agrega al jugador
Lanza alienígenas contra ladrillos
Ver cómo caen ladrillos es divertido las primeras veces, pero supongo 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 puedan arrojar a los ladrillos?
Crea un archivo player.dart
nuevo en el directorio lib/components
y agrégale 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.withOpacity(0.7)
..strokeWidth = 0.4
..strokeCap = StrokeCap.round);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
Este es un paso adelante de los componentes de Brick
del paso anterior. Este componente Player
tiene dos componentes secundarios, un SpriteComponent
que debes reconocer y un CustomPainterComponent
que es nuevo. El concepto CustomPainter
es de Flutter y te permite pintar en un lienzo. Se usa aquí para que los jugadores sepan a dónde volará el extraterrestre redondo cuando lo arrojen.
¿Cómo inicia el jugador el lanzamiento del alienígena? Mediante un gesto de arrastre, que el componente del reproductor detecta con las devoluciones de llamada DragCallbacks
El águila observada entre ustedes habrá notado algo más aquí.
Mientras que los componentes Ground
eran cuerpos estáticos, los componentes Brick eran cuerpos dinámicos. En este caso, el jugador es una combinación de ambos. El jugador comienza como estático, esperando que el jugador lo arrastre y, al soltarlo, se convierte a sí mismo de estático a dinámico, agrega un impulso lineal en proporción al arrastre y deja volar al avatar alienígena.
También hay código en el componente Player
para quitarlo de la pantalla si sale de los límites, se suspende o se agota el tiempo de espera. El objetivo aquí es permitir que el jugador huya 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 al de los componentes anteriores, con una complejidad adicional. El extraterrestre del jugador está diseñado para quitarse del juego en ciertas condiciones, por lo que hay un controlador de actualización aquí que verifica si no hay un componente Player
en el juego y, de ser así, agrega uno nuevamente. Ejecutar el juego tiene el siguiente aspecto.
7. Reacciona al impacto
Agregar a los enemigos
Viste objetos estáticos y dinámicos que interactúan entre sí. Sin embargo, para realmente llegar a algún lugar, debes obtener devoluciones de llamada en el código cuando se produzcan conflictos. Veamos cómo se hace. Presentarás algunos enemigos para que el jugador se enfrente. Esto te dará un camino hacia una condición ganadora: quita a 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 de Player y Brick, la mayor parte de este archivo debería resultarte familiar. Sin embargo, habrá un par de texto subrayado en rojo en tu editor debido a una nueva clase base desconocida. Para agregar esta clase ahora, agrega 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 manera programática sobre los impactos entre los cuerpos. De hecho, deberás editar todos 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 esos componentes usan actualmente. 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 contactos.
Cómo ganar en el juego
Ahora que tienes enemigos y tienes una forma de quitarlos del mundo, hay una manera simple de convertir esta simulación en un juego. Procura eliminar 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, en caso de que decidas aceptarlo, es ejecutar el juego y pasar a esta pantalla.
8. Felicitaciones
¡Felicitaciones! Lograste compilar un juego con Flutter y Flame.
Compilaste un juego con el motor de juego de Flame 2D y lo incorporaste en un wrapper de Flutter. Usaste los efectos de Flame para animar y quitar componentes. Usaste paquetes de Google Fonts y Flutter Animation para que todo el juego se viera bien diseñado.
Próximos pasos
Consulta algunos codelabs sobre los siguientes temas:
- Cómo compilar IUs de nueva generación en Flutter
- Cómo hacer que tu app de Flutter pase de aburrida a atractiva
- Cómo agregar compras directas desde la aplicación a tu app de Flutter