Introducción a Flame con Flutter

1. Introducción

Flame es un motor de juego 2D basado en Flutter. En este codelab, crearás un juego inspirado en uno de los clásicos de los videojuegos de los 70, Breakout de Steve Wozniak. Usarás los componentes de Flame para dibujar el bate, la pelota y los ladrillos. Utilizarás los efectos de Flame para animar el movimiento del murciélago y verás cómo integrar Flame con el sistema de administración de estado de Flutter.

Cuando esté completo, tu juego debería verse como este GIF animado, aunque un poco más lento.

Una grabación de pantalla de un juego en curso El juego se aceleró bastante.

Qué aprenderás

  • Cómo funcionan los conceptos básicos de Flame a partir de GameWidget
  • Cómo usar un bucle de juego
  • Cómo funcionan los elementos Component de Flame Son similares a los Widget de Flutter.
  • Cómo controlar las colisiones
  • Cómo usar objetos Effect para animar Component
  • Cómo superponer elementos Widget de Flutter sobre un juego de Flame
  • Cómo integrar Flame con la administración del estado de Flutter

Qué compilarás

En este codelab, compilarás un juego en 2D con Flutter y Flame. Cuando se complete, el juego debe cumplir con los siguientes requisitos

  • Funciona en las seis plataformas que Flutter admite: Android, iOS, Linux, macOS, Windows y la Web.
  • Mantén al menos 60 FPS con el bucle de juego de Flame.
  • Usa funciones de Flutter, como el paquete google_fonts y flutter_animate, para recrear la sensación de los juegos de arcade de los años 80.

2. Configura tu entorno de Flutter

Editor

Para simplificar este codelab, supone que Visual Studio Code (VS Code) es tu entorno de desarrollo. VS Code es gratuito y funciona en las principales plataformas. Usamos VS Code en este codelab porque las instrucciones predeterminadas indican combinaciones de teclas específicas de VS Code. Las tareas se vuelven más sencillas: "Haz clic en este botón" o "presiona esta tecla para hacer X" en lugar de "realizar la acción adecuada en tu editor para hacer X".

Puedes usar el editor que quieras: Android Studio, otros IDE de IntelliJ, Emacs, Vim o Notepad++. Todas funcionan con Flutter.

Una captura de pantalla de VS Code con parte del código de Flutter

Elige un segmento de desarrollo

Flutter produce apps para varias plataformas. Tu app puede ejecutarse en cualquiera de los siguientes sistemas operativos:

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

Es una práctica común elegir un sistema operativo como el segmento de desarrollo. Este es el sistema operativo en el que se ejecuta tu app durante el desarrollo.

Un dibujo que representa una laptop y un teléfono conectados a ella por un cable. La laptop está etiquetada como

Por ejemplo, supongamos que usas una laptop con Windows para desarrollar tu app de Flutter. Luego, eliges Android como tu objetivo de desarrollo. Para obtener una vista previa de tu app, debes conectar un dispositivo Android a tu laptop con Windows con un cable USB y tu app en desarrollo se ejecutará en ese dispositivo Android conectado o en un emulador de Android. Podrías haber elegido Windows como segmento de desarrollo, que ejecuta tu app en desarrollo como una app de Windows junto con tu editor.

Es posible que sientas la tentación de elegir la Web como el segmento de desarrollo. Esto tiene una desventaja durante el desarrollo: pierdes la función de Recarga en caliente con estado de Flutter. Actualmente, Flutter no puede hacer recargas en caliente de aplicaciones web.

Elige tu opción antes de continuar. Podrás ejecutar tu app en otros sistemas operativos más adelante. Elegir un destino de desarrollo simplifica el siguiente paso.

Instala Flutter

Puedes encontrar las instrucciones más actualizadas para instalar el SDK de Flutter en docs.flutter.dev.

Las instrucciones del sitio web de Flutter abarcan la instalación del SDK, las herramientas relacionadas con el segmento de desarrollo y los complementos del editor. Para este codelab, instala el siguiente software:

  1. El SDK de Flutter
  2. Visual Studio Code con el complemento de Flutter
  3. Software de compilación para tu objetivo de desarrollo elegido. (necesitas Visual Studio para orientar a Windows o Xcode para segmentar a macOS o iOS)

En la siguiente sección, crearás tu primer proyecto de Flutter.

Si necesitas solucionar algún problema, algunas de estas preguntas y respuestas (de StackOverflow) podrían resultarte útiles para solucionarlo.

Preguntas frecuentes

3. Crea un proyecto

Crea tu primer proyecto de Flutter

Esto implica abrir VS Code y crear la plantilla de la app de Flutter en el directorio que elijas.

  1. Inicia Visual Studio Code.
  2. Abre la paleta de comandos (F1, Ctrl+Shift+P o Shift+Cmd+P) y, luego, escribe "flutter new". Cuando aparezca, selecciona el comando Flutter: New Project.

Una captura de pantalla de VS Code con

  1. Selecciona Empty Application. Elige un directorio para crear tu proyecto. Debería ser cualquier directorio que no requiera privilegios elevados ni tenga un espacio en su ruta de acceso. Algunos ejemplos incluyen tu directorio principal o C:\src\.

Captura de pantalla de VS Code con Empty Application que se muestra seleccionada como parte del flujo de la nueva aplicación

  1. Asigna el nombre brick_breaker a tu proyecto. En el resto de este codelab, se presupone que asignaste el nombre brick_breaker a tu app.

Una captura de pantalla de VS Code con

Flutter ahora creará la carpeta del proyecto y VS Code lo abrirá. Ahora, reemplazarás el contenido de dos archivos con un andamiaje básico de la app.

Copia y pega la app inicial

De esta manera, se agrega a tu app el código de ejemplo que se proporciona en este codelab.

  1. En el panel izquierdo de VS Code, haz clic en Explorer y abre el archivo pubspec.yaml.

Una captura de pantalla parcial de VS Code con flechas que destacan la ubicación del archivo pubspec.yaml

  1. Reemplaza el contenido de este archivo con lo siguiente:

pubspec.yaml

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

environment:
  sdk: '>=3.3.0 <4.0.0'

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

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^3.0.1

flutter:
  uses-material-design: true

El archivo pubspec.yaml especifica la información básica de tu app, como la versión actual, las dependencias y los recursos con los que se enviará.

  1. Abre el archivo main.dart en el directorio lib/.

Una captura de pantalla parcial de VS Code con una flecha que muestra la ubicación del archivo main.dart

  1. Reemplaza el contenido de este archivo con lo siguiente:

lib/main.dart

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

void main() {
  final game = FlameGame();
  runApp(GameWidget(game: game));
}
  1. Ejecuta este código para verificar que todo funcione correctamente. Se debería mostrar una ventana nueva con un fondo negro en blanco. El peor videojuego del mundo ahora se está renderizando a 60 FPS.

Captura de pantalla que muestra una ventana de la aplicación de ladrillo_breaker que está completamente negra.

4. Crea el juego

Mejora el juego

Un juego que se juega en dos dimensiones (2D) necesita un área de juego. Crearás un área de dimensiones específicas y, luego, las usarás para dimensionar otros aspectos del juego.

Hay varias formas de distribuir las coordenadas en el área de juego. Por convención, puedes medir la dirección desde el centro de la pantalla con el origen (0,0) en el centro de la pantalla. Los valores positivos mueven los elementos hacia la derecha a lo largo del eje x y hacia arriba a lo largo del eje y. Este estándar se aplica a la mayoría de los juegos actuales en la actualidad, especialmente en los juegos que involucran tres dimensiones.

La convención cuando se creó el juego Breakout original era establecer el origen en la esquina superior izquierda. La dirección positiva de la X sigue siendo la misma, pero se invirtió el valor de Y. La dirección de x positivo x era correcta e y era baja. Para ser fieles a la época, este juego establece el origen en la esquina superior izquierda.

Crea un archivo llamado config.dart en un directorio nuevo llamado lib/src. Este archivo obtendrá más constantes en los siguientes pasos.

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;

Este juego tendrá 820 píxeles de ancho y 1600 píxeles de alto. El área de juego se ajusta para adaptarse a la ventana en la que se muestra, pero todos los componentes agregados a la pantalla cumplen con esta altura y este ancho.

Crear un área de juego

En el juego de Breakout, la pelota rebota en las paredes del área de juego. Para adaptarse a las colisiones, primero necesitas un componente PlayArea.

  1. Crea un archivo llamado play_area.dart en un directorio nuevo llamado lib/src/components.
  2. Agrega lo siguiente a este archivo.

lib/src/components/play_area.dart

import 'dart:async';

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

import '../brick_breaker.dart';

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

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

donde Flutter tiene Widget y Flame tiene Component. Mientras que las apps de Flutter consisten en crear árboles de widgets, los juegos de Flame consisten en mantener árboles de componentes.

Ahí se encuentra una interesante diferencia entre Flutter y Flame. El árbol de widgets de Flutter es una descripción efímera que se diseñó para actualizar la capa RenderObject persistente y mutable. Los componentes de Flame son persistentes y mutables, con la expectativa de que el desarrollador los use como parte de un sistema de simulación.

Los componentes de Flame están optimizados para expresar la mecánica del juego. Este codelab comenzará con el bucle de juego, que se presenta en el siguiente paso.

  1. Para controlar el desorden, agrega un archivo que contenga todos los componentes de este proyecto. Crea un archivo components.dart en lib/src/components y agrega el siguiente contenido.

lib/src/components/components.dart

export 'play_area.dart';

La directiva export desempeña el rol inverso de import. Declara qué funcionalidad expone este archivo cuando se importa a otro archivo. Este archivo aumentará más entradas a medida que agregues nuevos componentes en los siguientes pasos.

Crea un juego de Flame

Para excluir los garabatos rojos del paso anterior, obtén una nueva subclase para el FlameGame de Flame.

  1. Crea un archivo llamado brick_breaker.dart en lib/src y agrega el siguiente código.

lib/src/brick_breaker.dart

import 'dart:async';

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

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

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

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

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());
  }
}

Este archivo coordina las acciones del juego. Durante la construcción de la instancia del juego, este código configura el juego para usar una renderización con resolución fija. El juego cambia de tamaño para ocupar la pantalla que lo contiene y agrega letterbox según sea necesario.

Debes exponer el ancho y la altura del juego para que los componentes secundarios, como PlayArea, puedan establecerse con el tamaño adecuado.

En el método onLoad anulado, tu código realiza dos acciones.

  1. Configura la esquina superior izquierda como el anclaje del visor. De forma predeterminada, el visor usa el centro del área como ancla para (0,0).
  2. Agrega PlayArea a world. El mundo representa el mundo del juego. Proyecta todos sus elementos secundarios a través de la transformación de vistas de CameraComponent.

Haz que el juego aparezca en pantalla

Para ver todos los cambios que realizaste en este paso, actualiza tu archivo lib/main.dart con los siguientes cambios.

lib/main.dart

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

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

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

Después de realizar estos cambios, reinicia el juego. El juego debería parecerse a la siguiente figura.

Captura de pantalla que muestra una ventana de la aplicación block_breaker con un rectángulo de color arena en el centro de la ventana de la aplicación

En el siguiente paso, agregarás una pelota al mundo y lo harás en movimiento.

5. Muestra la pelota

Cómo crear el componente bola

Poner una bola en movimiento en la pantalla implica crear otro componente y agregarlo al mundo del juego.

  1. Edita el contenido del archivo lib/src/config.dart como se indica a continuación.

lib/src/config.dart

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

El patrón de diseño que consiste en definir constantes con nombre como valores derivados se devolverá muchas veces en este codelab. Esto te permite modificar los elementos gameWidth y gameHeight de nivel superior para explorar cómo cambia la apariencia del juego como resultado.

  1. Crea el componente Ball en un archivo llamado ball.dart en lib/src/components.

lib/src/components/ball.dart

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

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

  final Vector2 velocity;

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

Anteriormente, definiste el PlayArea con RectangleComponent, por lo que es evidente que existen más formas. CircleComponent, al igual que RectangleComponent, deriva de PositionedComponent, de modo que puedes posicionar la pelota en la pantalla. Lo más importante es que su posición se puede actualizar.

En este componente, se introduce el concepto de velocity o el cambio de posición con el tiempo. La velocidad es un objeto Vector2, ya que la velocidad es tanto de velocidad como de dirección. Para actualizar la posición, anula el método update, que el motor de juego llama para cada fotograma. dt es la duración entre el fotograma anterior y este. Esto te permite adaptarte a factores como diferentes velocidades de fotogramas (60 Hz o 120 Hz) o fotogramas largos debido al procesamiento excesivo.

Presta mucha atención a la actualización de position += velocity * dt. Así es como implementas la actualización de una simulación discreta de movimiento a lo largo del tiempo.

  1. Para incluir el componente Ball en la lista de componentes, edita el archivo lib/src/components/components.dart como se indica a continuación.

lib/src/components/components.dart

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

Lleva el balón al mundo

Tienes una pelota. Ubícalo en el mundo y configúralo para que se mueva por el área de juegos.

Edita el archivo lib/src/brick_breaker.dart como se indica a continuación.

lib/src/brick_breaker.dart

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

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

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

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

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

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

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

    debugMode = true;                                           // To here.
  }
}

Este cambio agrega el componente Ball a world. Para establecer el position de la bola en el centro del área de visualización, el código primero reduce a la mitad el tamaño del juego, ya que Vector2 tiene sobrecargas de operador (* y /) para escalar Vector2 según un valor escalar.

Para configurar el velocity de la bola, se requiere más complejidad. La intención es mover la bola hacia abajo en la pantalla en una dirección aleatoria a una velocidad razonable. La llamada al método normalized crea un objeto Vector2 configurado en la misma dirección que el Vector2 original, pero reducido a una distancia de 1. Esto mantiene constante la velocidad de la pelota, independientemente de la dirección en la que vaya. La velocidad de la pelota se escala verticalmente hasta alcanzar 1/4 de la altura del juego.

Hacer bien estos diversos valores implica un poco de iteración, también conocida como prueba de juego en la industria.

La última línea activa la pantalla de depuración, que agrega información adicional para ayudar con la depuración.

Cuando ejecutes el juego, debería verse en la siguiente pantalla.

Captura de pantalla que muestra una ventana de la aplicación block_breaker con un círculo azul sobre un rectángulo de color arena. El círculo azul tiene anotaciones con números que indican su tamaño y ubicación en la pantalla.

Tanto los componentes PlayArea como Ball tienen información de depuración, pero el fondo recorta los números de PlayArea. El motivo por el que se muestra información de depuración es que activaste debugMode para todo el árbol de componentes. También puedes activar la depuración solo para los componentes seleccionados, si eso te resulta más útil.

Si reinicias el juego varias veces, es posible que notes que la pelota no interactúa con las paredes de la manera esperada. Para lograr ese efecto, debes agregar la detección de colisiones, y lo harás en el siguiente paso.

6. Salta alrededor

Agregar detección de colisiones

La detección de colisiones agrega un comportamiento en el que el juego reconoce cuando dos objetos entraron en contacto entre sí.

Para agregar la detección de colisiones al juego, agrega la combinación HasCollisionDetection al juego BrickBreaker, como se muestra en el siguiente código.

lib/src/brick_breaker.dart

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

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

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

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

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

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

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

    debugMode = true;
  }
}

Esto hace un seguimiento de las cajas de colisiones de los componentes y activa devoluciones de llamada de colisión en cada marca del juego.

Para comenzar a propagar las cajas de colisiones del juego, modifica el componente PlayArea como se muestra a continuación.

lib/src/components/play_area.dart

import 'dart:async';

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

import '../brick_breaker.dart';

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

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

Si agregas un componente RectangleHitbox como elemento secundario de RectangleComponent, se construirá un cuadro de hit para la detección de colisiones que coincida con el tamaño del componente superior. Hay un constructor de fábrica para RectangleHitbox llamado relative para las ocasiones en las que deseas una caja de colisiones que sea más pequeña o más grande que el componente superior.

Haz rebotar la pelota

Hasta ahora, agregar la detección de colisiones no ha hecho ninguna diferencia en el juego. Sin embargo, sí cambia una vez que modificas el componente Ball. Es el comportamiento de la pelota el que debe cambiar cuando colisiona con el PlayArea.

Modifica el componente Ball como se indica a continuación.

lib/src/components/ball.dart

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

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

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

  final Vector2 velocity;

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

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

En este ejemplo, se realiza un cambio importante cuando se agrega la devolución de llamada onCollisionStart. El sistema de detección de colisiones que se agregó a BrickBreaker en el ejemplo anterior llama a esta devolución de llamada.

Primero, el código prueba si el Ball colisionó con PlayArea. Por ahora, esto parece redundante, ya que no hay otros componentes en el mundo del juego. Eso cambiará en el siguiente paso, cuando agregues un bate al mundo. Luego, también agrega una condición else para controlar cuando la pelota colisione con objetos que no son el bate. Recuerda implementar la lógica restante, si quieres.

Cuando la pelota colisiona con la pared inferior, simplemente desaparece de la superficie de juego mientras permanece a la vista. Controlarás este artefacto en un paso futuro, con el poder de los efectos de Flame.

Ahora que la pelota choca con las paredes del juego, sin duda sería útil darle al jugador un bate para que golpee la pelota con...

7. Bate contra la pelota

Crea el bate

Para agregar un bate y mantener la pelota en funcionamiento dentro del juego,

  1. Inserta algunas constantes en el archivo lib/src/config.dart de la siguiente manera.

lib/src/config.dart

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

Las constantes batHeight y batWidth no requieren explicación. Por otro lado, la constante batStep necesita un poco de explicación. Para interactuar con la pelota en este juego, el jugador puede arrastrar el bate con el mouse o el dedo, según la plataforma, o usar el teclado. La constante batStep configura cuánto avanza el bate cuando presiona las teclas de flecha izquierda o derecha.

  1. Define la clase del componente Bat de la siguiente manera.

lib/src/components/bat.dart

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

import '../brick_breaker.dart';

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

  final Radius cornerRadius;

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

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

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

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

En este componente, se presentan algunas capacidades nuevas.

En primer lugar, el componente Bat es un PositionComponent, no un RectangleComponent ni un CircleComponent. Esto significa que este código debe renderizar el Bat en la pantalla. Para ello, anula la devolución de llamada render.

Si observas con atención la llamada canvas.drawRRect (dibuja un rectángulo redondeado), podrías preguntarte: "¿Dónde está el rectángulo?". El Offset.zero & size.toSize() aprovecha una sobrecarga de operator & en la clase dart:ui Offset que crea Rect. Esta abreviatura puede confundirte al principio, pero la verás con frecuencia en el código de Flutter y Flame de nivel inferior.

En segundo lugar, este componente Bat se puede arrastrar con el dedo o un mouse, según la plataforma. Para implementar esta funcionalidad, agrega la combinación DragCallbacks y anula el evento onDragUpdate.

Por último, el componente Bat debe responder al control del teclado. La función moveBy permite que otro código le indique a este bate que se mueva hacia la izquierda o la derecha una determinada cantidad de píxeles virtuales. Esta función introduce una nueva capacidad del motor de juego de Flame: Effect. Si agregas el objeto MoveToEffect como elemento secundario de este componente, el jugador ve el bate animado en una nueva posición. Hay una colección de elementos Effect disponibles en Flame para realizar una variedad de efectos.

Los argumentos del constructor de Effect incluyen una referencia al método get game. Por eso, incluyes la combinación HasGameReference en esta clase. Esta combinación agrega un descriptor de acceso game de tipo seguro a este componente para acceder a la instancia de BrickBreaker en la parte superior del árbol de componentes.

  1. Para que Bat esté disponible para BrickBreaker, actualiza el archivo lib/src/components/components.dart como se indica a continuación.

lib/src/components/components.dart

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

Agrega un bate al mundo

Para agregar el componente Bat al mundo del juego, actualiza BrickBreaker de la siguiente manera.

lib/src/brick_breaker.dart

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

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

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

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

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

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

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

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

    debugMode = true;
  }

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

La adición de la combinación KeyboardEvents y el método onKeyEvent anulado controla la entrada del teclado. Recuerda el código que agregaste antes para mover el bate según la cantidad de pasos adecuada.

El fragmento restante del código agregado agrega el bate al mundo del juego en la posición correcta y con las proporciones correctas. Tener todas estas configuraciones expuestas en este archivo simplifica la capacidad de ajustar el tamaño relativo del bate y la pelota para lograr la sensación adecuada para el juego.

Si juegas en este punto, verás que puedes mover el bate para interceptar la pelota, pero no obtendrás una respuesta visible, excepto el registro de depuración que dejaste en el código de detección de colisiones de Ball.

Es hora de corregir eso. Edita el componente Ball de la siguiente manera.

lib/src/components/ball.dart

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

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

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

  final Vector2 velocity;

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

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

Con estos cambios de código, se solucionan dos problemas diferentes.

Primero, arregla la bola desapareciendo apenas toca la parte inferior de la pantalla. Para solucionar este problema, reemplaza la llamada a removeFromParent por RemoveEffect. El elemento RemoveEffect quita la pelota del mundo del juego después de dejar que esta salga del área visible.

En segundo lugar, estos cambios corrigen el manejo de la colisión entre el bate y la pelota. Este código de control funciona mucho a favor del jugador. Mientras el jugador toque la pelota con el bate, la pelota regresará a la parte superior de la pantalla. Si esto te parece demasiado tolerante y quieres algo más realista, cambia este manejo para que se adapte mejor a cómo deseas que se sienta el juego.

Vale la pena señalar la complejidad de la actualización de velocity. No solo revierte el componente y de la velocidad, como se hizo con las colisiones de la pared. También actualiza el componente x de una manera que depende de la posición relativa del bate y la pelota en el momento de contacto. Esto le da al jugador más control sobre lo que hace la pelota, pero exactamente cómo no se comunica al jugador de ninguna manera, excepto a través del juego.

Ahora que tienes un bate para golpear la pelota, sería mejor tener algunos ladrillos para romper con la pelota.

8. Derriba el muro

Crear los ladrillos

Para agregar ladrillos al juego,

  1. Inserta algunas constantes en el archivo lib/src/config.dart de la siguiente manera.

lib/src/config.dart

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

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

const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2;
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05;
const brickGutter = gameWidth * 0.015;                          // Add from here...
final brickWidth =
    (gameWidth - (brickGutter * (brickColors.length + 1)))
    / brickColors.length;
const brickHeight = gameHeight * 0.03;
const difficultyModifier = 1.03;                                // To here.
  1. Inserta el componente Brick de la siguiente manera.

lib/src/components/brick.dart

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

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

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

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

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

A esta altura, la mayor parte de este código debería resultarte familiar. Este código usa un RectangleComponent, con detección de colisiones y una referencia de tipo seguro al juego BrickBreaker en la parte superior del árbol de componentes.

El nuevo concepto más importante que introduce este código es la forma en que el jugador logra la condición de victoria. La comprobación de condición de ganancia consulta al mundo en busca de ladrillos y confirma que solo queda uno. Esto puede ser un poco confuso, porque la línea anterior quita este ladrillo de su elemento superior.

El punto clave que se debe comprender es que la eliminación de componentes es un comando en cola. Quita el ladrillo después de que se ejecuta el código, pero antes de la siguiente marca del mundo del juego.

Para que BrickBreaker pueda acceder al componente Brick, edita lib/src/components/components.dart de la siguiente manera.

lib/src/components/components.dart

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

Agrega ladrillos al mundo

Actualiza el componente Ball de la siguiente manera.

lib/src/components/ball.dart

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

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

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

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

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

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

Este introduce el único aspecto nuevo, un modificador de dificultad que aumenta la velocidad de la pelota después de cada colisión de ladrillos. Es necesario probar este parámetro ajustable para encontrar la curva de dificultad adecuada que sea apropiada para tu juego.

Edita el juego BrickBreaker de la siguiente manera.

lib/src/brick_breaker.dart

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

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

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

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

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

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

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

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

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

    debugMode = true;
  }

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

Si ejecutas el juego tal como está actualmente, se mostrarán todas las mecánicas clave. Podrías desactivar la depuración y completarla, pero parece que falta algo.

Una captura de pantalla que muestra ladrillo_breaker con una pelota, un bate y la mayoría de los ladrillos en el área de juego. Cada componente tiene etiquetas de depuración

¿Qué tal una pantalla de bienvenida, un juego en pantalla y tal vez una puntuación? Flutter puede agregar estas funciones al juego, y ahí es donde volverás a dirigir tu atención.

9. Gana el juego

Agregar estados de juego

En este paso, incorporas el juego de Flame dentro de un wrapper de Flutter y, luego, agregas superposiciones de Flutter para las pantallas de bienvenida, fin del juego y adjudicados.

Primero, modificas los archivos del juego y los componentes para implementar un estado de juego que refleje si se debe mostrar una superposición y, de ser así, cuál.

  1. Modifica el juego BrickBreaker como se indica a continuación.

lib/src/brick_breaker.dart

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

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

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

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

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

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

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

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

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

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

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

    playState = PlayState.playing;                              // To here.

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

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

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

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

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

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

Este código cambia gran parte del juego BrickBreaker. Agregar la enumeración playState lleva mucho trabajo. Captura el punto en el que el jugador ingresa, juega y pierde o gana en el juego. En la parte superior del archivo, definirás la enumeración y, luego, crearás una instancia como un estado oculto con métodos get y set coincidentes. Estos métodos get y set permiten modificar las superposiciones cuando las distintas partes del juego activan transiciones de estado de juego.

A continuación, dividiste el código de onLoad en onLoad y en un nuevo método startGame. Antes de este cambio, solo podías iniciar un juego nuevo reiniciando el juego. Con estas nuevas incorporaciones, el jugador puede comenzar un nuevo juego sin medidas tan drásticas.

Para permitir que el jugador inicie un juego nuevo, configuraste dos controladores nuevos para el juego. Agregaste un controlador de toque y extendiste el controlador de teclado para permitir que el usuario inicie un juego nuevo de varias modalidades. Con el estado de juego modelado, tendría sentido actualizar los componentes para activar las transiciones del estado de juego cuando el jugador gane o pierda.

  1. Modifica el componente Ball como se indica a continuación.

lib/src/components/ball.dart

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

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

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

  final Vector2 velocity;
  final double difficultyModifier;

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

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

Este pequeño cambio agrega una devolución de llamada onComplete a RemoveEffect, que activa el estado de reproducción gameOver. Esto debería sentirse bien si el jugador permite que la pelota salga de la parte inferior de la pantalla.

  1. Edita el componente Brick de la siguiente manera.

lib/src/components/brick.dart

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

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

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

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

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

Por otro lado, si el jugador puede romper todos los ladrillos, habrá ganado un "juego ganado". en la pantalla. Buen trabajo, ¡bien hecho!

Agrega el wrapper de Flutter

Para proporcionar un lugar para incorporar el juego y agregar superposiciones de estado de juego, agrega la shell de Flutter.

  1. Crea un directorio widgets en lib/src.
  2. Agrega un archivo game_app.dart y, luego, inserta el siguiente contenido en él.

lib/src/widgets/game_app.dart

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

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

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

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

La mayor parte del contenido de este archivo sigue una compilación estándar de árbol de widgets de Flutter. Las partes específicas de Flame incluyen el uso de GameWidget.controlled para construir y administrar la instancia de juego de BrickBreaker y el nuevo argumento de overlayBuilderMap para GameWidget.

Las claves de este overlayBuilderMap deben alinearse con las superposiciones que el método set playState de BrickBreaker agregó o quitó. Si intentas establecer una superposición que no esté en este mapa, verás rostros descontentos en todas partes.

  1. Para mostrar esta nueva funcionalidad en la pantalla, reemplaza el archivo lib/main.dart con el siguiente contenido.

lib/main.dart

import 'package:flutter/material.dart';

import 'src/widgets/game_app.dart';

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

Si ejecutas este código en iOS, Linux, Windows o la Web, el resultado deseado se mostrará en el juego. Si segmentas a macOS o Android, debes realizar un último ajuste para permitir que se muestre google_fonts.

Habilita el acceso a las fuentes

Agrega permiso de Internet para Android

Para Android, debes agregar permiso de Internet. Edita tu AndroidManifest.xml de la siguiente manera.

android/app/src/main/AndroidManifest.xml

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

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

Cómo editar archivos de derechos para macOS

En el caso de macOS, tienes dos archivos para editar.

  1. Edita el archivo DebugProfile.entitlements para que coincida con el siguiente código.

macos/Runner/DebugProfile.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <key>com.apple.security.cs.allow-jit</key>
        <true/>
        <key>com.apple.security.network.server</key>
        <true/>
        <!-- Add from here... -->
        <key>com.apple.security.network.client</key>
        <true/>
        <!-- to here. -->
</dict>
</plist>
  1. Edita el archivo Release.entitlements para que coincida con el siguiente código

macos/Runner/Release.entitlements

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

Si lo ejecutas como está, debería aparecer una pantalla de bienvenida y un juego de fin o una pantalla ganada en todas las plataformas. Esas pantallas pueden ser un poco simplista y sería bueno tener una puntuación. Así que, adivina lo que harás en el siguiente paso.

10. Lleva la cuenta

Agrega un resultado a un partido

En este paso, expondrás la puntuación del juego al contexto de Flutter que lo rodea. En este paso, expones el estado del juego de Flame a la administración del estado de Flutter que lo rodea. De esta manera, el código del juego puede actualizar la puntuación cada vez que el jugador rompe un ladrillo.

  1. Modifica el juego BrickBreaker como se indica a continuación.

lib/src/brick_breaker.dart

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

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

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

enum PlayState { welcome, playing, gameOver, won }

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

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

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

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    playState = PlayState.welcome;
  }

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

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

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

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

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

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

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

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

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

Cuando agregas score al juego, vinculas el estado del juego a la administración de estado de Flutter.

  1. Modifica la clase Brick para agregar un punto a la puntuación cuando el jugador rompe ladrillos.

lib/src/components/brick.dart

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

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

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

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

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

Crea un juego atractivo

Ahora que puedes llevar la puntuación en Flutter, es hora de organizar los widgets para que se vean bien.

  1. Crea score_card.dart en lib/src/widgets y agrega lo siguiente:

lib/src/widgets/score_card.dart

import 'package:flutter/material.dart';

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

  final ValueNotifier<int> score;

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<int>(
      valueListenable: score,
      builder: (context, score, child) {
        return Padding(
          padding: const EdgeInsets.fromLTRB(12, 6, 12, 18),
          child: Text(
            'Score: $score'.toUpperCase(),
            style: Theme.of(context).textTheme.titleLarge!,
          ),
        );
      },
    );
  }
}
  1. Crea overlay_screen.dart en lib/src/widgets y agrega el siguiente código.

De esta manera, se perfeccionan las superposiciones gracias a la potencia del paquete flutter_animate para agregar movimiento y estilo a las pantallas superpuestas.

lib/src/widgets/overlay_screen.dart

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

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

  final String title;
  final String subtitle;

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

Para obtener una descripción más detallada de la potencia de flutter_animate, consulta el codelab Cómo compilar IUs de nueva generación en Flutter.

Este código cambió mucho en el componente GameApp. Primero, para permitir que ScoreCard acceda a score , debes convertirlo de StatelessWidget a StatefulWidget. Para agregar la tarjeta de puntuación, también se debe agregar un Column que apile la puntuación encima del juego.

En segundo lugar, para mejorar la bienvenida, el fin del juego y las experiencias ganadas, agregaste el nuevo widget OverlayScreen.

lib/src/widgets/game_app.dart

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

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

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

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

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

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

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

Una vez hecho esto, deberías poder ejecutar este juego en cualquiera de las seis plataformas de destino de Flutter. El juego debería verse de la siguiente manera:

Una captura de pantalla de ladrillo_breaker que muestra la pantalla previa al juego invitando al usuario a presionar la pantalla para jugar

Una captura de pantalla de ladrillo_breaker que muestra el juego en pantalla superpuesto sobre un bate y algunos de los ladrillos

11. 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:

Lecturas adicionales