Introducción a Flame con Flutter

1. Introducción

Flame es un motor de juego 2D basado en Flutter. En este codelab, compilarás un juego inspirado en uno de los clásicos de los videojuegos de la década de 1970, Breakout de Steve Wozniak. Usarás los componentes de Flame para dibujar el bate, la pelota y los ladrillos. Usará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 estados de Flutter.

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

Grabación de pantalla de un juego en ejecución. El juego se aceleró significativamente.

Qué aprenderás

  • Cómo funcionan los conceptos básicos de Flame, comenzando con GameWidget.
  • Cómo usar un bucle de juego
  • Cómo funcionan los Components de Flame Son similares a los Widget de Flutter.
  • Cómo controlar las colisiones.
  • Cómo usar Effects para animar Components
  • Cómo superponer Widgets de Flutter sobre un juego de Flame
  • Cómo integrar Flame con la administración de estados de Flutter

Qué compilarás

En este codelab, compilarás un juego en 2D con Flutter y Flame. Cuando esté completo, tu juego debe cumplir con los siguientes requisitos:

  • Funciona en las seis plataformas que admite Flutter: Android, iOS, Linux, macOS, Windows y la Web.
  • Mantén al menos 60 FPS con el bucle de juego de Flame.
  • Usa las capacidades 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, se supone que Visual Studio Code (VS Code) es tu entorno de desarrollo. VS Code es gratuito y funciona en todas las plataformas principales. Usamos VS Code para 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 "realiza la acción apropiada en tu editor para hacer X".

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

VS Code con algo de código de Flutter

Elige un segmento de desarrollo

Flutter produce apps para múltiples 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 tu segmento de desarrollo. Este es el sistema operativo en el que se ejecuta tu app durante el desarrollo.

Un dibujo que muestra una laptop y un teléfono conectados a la laptop con un cable. La laptop está etiquetada como

Por ejemplo, digamos que usas una laptop con Windows para desarrollar tu app de Flutter. Luego, eliges Android como tu segmento de desarrollo. Para obtener una vista previa de tu app, conecta 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, lo que ejecuta tu app en desarrollo como una app de Windows junto a tu editor.

Realiza tu elección antes de continuar. Podrás ejecutar tu app en otros sistemas operativos más adelante. Elegir un segmento de desarrollo simplifica los próximos pasos.

Instala Flutter

Podrás 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 y también los complementos y las herramientas relacionadas con el segmento de desarrollo. Para este codelab, instala el siguiente software:

  1. El SDK de Flutter
  2. Visual Studio Code con el complemento de Flutter
  3. Software del compilador para el destino de desarrollo que elegiste (Necesitas Visual Studio para segmentar 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 problemas, consulta estas preguntas y respuestas (de StackOverflow), que te resultarán útiles.

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

VS Code con

  1. Selecciona Empty Application. Elige un directorio en el que crear tu proyecto. Debe ser cualquier directorio que no requiera privilegios elevados ni tenga un espacio en su ruta. Por ejemplo, tu directorio principal o C:\src\.

VS Code con la aplicación vacía 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 supone que le pusiste brick_breaker a tu app.

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

Esto agrega el código de ejemplo proporcionado en este codelab a tu app.

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

dependencies:
  flutter:
    sdk: flutter
  flame: ^1.28.1
  flutter_animate: ^4.5.2
  google_fonts: ^6.2.1

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

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. Debería mostrar una ventana nueva con solo un fondo negro en blanco. El peor videojuego del mundo ahora se renderiza a 60 FPS.

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

4. Crea el juego

Analiza el juego

Un juego que se juega en dos dimensiones (2D) necesita un área de juego. Construirás un área de dimensiones específicas y, luego, usarás estas dimensiones para determinar el tamaño de otros aspectos del juego.

Hay varias formas de establecer las coordenadas en el área de juego. Según una 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, especialmente a los que involucran tres dimensiones.

La convención cuando se creó el juego original de Breakout era establecer el origen en la esquina superior izquierda. La dirección X positiva permaneció igual, pero la dirección Y se invirtió. La dirección positiva de X era hacia la derecha y la de Y, hacia abajo. Para ser fiel 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 contendrá 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 1,600 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 se ajustan a esta altura y ancho.

Crea un PlayArea

En el juego Breakout, la pelota rebota en las paredes del área de juego. Para tener en cuenta 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);
  }
}

Mientras que Flutter tiene Widgets, Flame tiene Components. Mientras que las apps de Flutter consisten en crear árboles de widgets, los juegos de Flame consisten en mantener árboles de componentes.

Ahí radica una diferencia interesante entre Flutter y Flame. El árbol de widgets de Flutter es una descripción efímera que se compila para actualizar la capa RenderObject persistente y mutable. Los componentes de Flame son persistentes y mutables, y se espera 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 muestra 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 tendrá más entradas a medida que agregues componentes nuevos en los siguientes pasos.

Crea un juego con Flame

Para quitar las líneas onduladas rojas del paso anterior, deriva una nueva subclase para 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 que use la renderización de resolución fija. El juego cambia de tamaño para llenar la pantalla que lo contiene y agrega letterboxing según sea necesario.

Expones el ancho y el alto del juego para que los componentes secundarios, como PlayArea, puedan establecerse en el tamaño adecuado.

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

  1. Configura la esquina superior izquierda como ancla del visor. De forma predeterminada, viewfinder 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 vista de CameraComponent.

Cómo mostrar el juego en la 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 imagen.

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

En el siguiente paso, agregarás una pelota al mundo y la harás moverse.

5. Cómo mostrar la pelota

Crea el componente de la pelota

Para poner una pelota en movimiento en la pantalla, debes crear otro componente y agregarlo al mundo del juego.

  1. Edita el contenido del 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;                            // Add this constant

El patrón de diseño de definir constantes con nombre como valores derivados se repetirá 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 el RectangleComponent, por lo que es lógico que existan más formas. CircleComponent, al igual que RectangleComponent, deriva de PositionedComponent, por lo que puedes posicionar la pelota en la pantalla. Lo más importante es que se puede actualizar su posición.

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

Presta mucha atención a la actualización de position += velocity * dt. Así es como se implementa la actualización de una simulación discreta del 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 de la siguiente manera.

lib/src/components/components.dart

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

Agrega la pelota al mundo

Tienes una pelota. Colócalo en el mundo y configúralo para que se mueva por el área de juego.

Edita el archivo lib/src/brick_breaker.dart de la siguiente manera.

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 al world. Para establecer el position de la pelota 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 operadores (* y /) para escalar un Vector2 por un valor escalar.

Establecer el velocity de la pelota implica más complejidad. La intención es mover la pelota hacia abajo en la pantalla en una dirección aleatoria a una velocidad razonable. La llamada al método normalized crea un objeto Vector2 establecido en la misma dirección que el Vector2 original, pero reducido a una distancia de 1. Esto mantiene la velocidad de la pelota constante, sin importar la dirección en la que vaya. Luego, la velocidad de la pelota se incrementa hasta alcanzar 1/4 de la altura del juego.

Obtener estos diversos valores correctamente implica cierta 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 como la siguiente pantalla.

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

Tanto el componente PlayArea como el componente Ball tienen información de depuración, pero los fondos mate recortan los números del componente PlayArea. El motivo por el que se muestra información de depuración para todo 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 como se espera. Para lograr ese efecto, debes agregar la detección de colisiones, lo que harás en el siguiente paso.

6. Rebotar

Cómo 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.

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 llamadas de colisión en cada tic del juego.

Para comenzar a completar las hitboxes 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);
  }
}

Agregar un componente RectangleHitbox como elemento secundario del componente RectangleComponent creará una caja de impacto para la detección de colisiones que coincida con el tamaño del componente principal. Hay un constructor de fábrica para RectangleHitbox llamado relative para los casos en los que quieras una hitbox más pequeña o más grande que el componente principal.

Rebota la pelota

Hasta el momento, agregar la detección de colisiones no hizo ninguna diferencia en la jugabilidad. Sin embargo, cambia una vez que modificas el componente Ball. El comportamiento de la pelota es lo que debe cambiar cuando choca con el PlayArea.

Modifica el componente Ball de la siguiente manera.

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 con la adición de 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 Ball chocó con PlayArea. Por el momento, esto parece redundante, ya que no hay otros componentes en el mundo del juego. Eso cambiará en el siguiente paso, cuando agregues un murciélago al mundo. Luego, también agrega una condición else para controlar cuando la pelota choca con objetos que no son el bate. Es un recordatorio para implementar la lógica restante, si lo deseas.

Cuando la pelota choca con la pared inferior, simplemente desaparece de la superficie de juego, aunque sigue siendo muy visible. Manejarás este artefacto en un paso posterior con el poder de los efectos de Flame.

Ahora que la pelota choca con las paredes del juego, sería útil darle al jugador un bate para golpearla…

7. Conectar la pelota con el bate

Crea el murciélago

Para agregar un bate y mantener la pelota en juego, haz lo siguiente:

  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 son evidentes. Por otro lado, la constante batStep necesita una 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 qué tan lejos se desplaza el murciélago con cada presión de la tecla de flecha hacia la izquierda o la derecha.

  1. Define la clase de 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),
      ),
    );
  }
}

Este componente presenta 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 de cerca la llamada canvas.drawRRect (dibuja un rectángulo redondeado), tal vez te preguntes: "¿Dónde está el rectángulo?". El Offset.zero & size.toSize() aprovecha una sobrecarga de operator & en la clase dart:ui Offset que crea Rects. Es posible que esta abreviatura te confunda 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 el mouse, según la plataforma. Para implementar esta funcionalidad, agrega el mixin 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 murciélago que se mueva hacia la izquierda o la derecha una cierta cantidad de píxeles virtuales. Esta función presenta una nueva capacidad del motor de juegos de Flame: los Effect. Si agregas el objeto MoveToEffect como secundario de este componente, el jugador verá el bate animado en una nueva posición. En Flame, hay una colección de Effects disponibles para realizar una variedad de efectos.

Los argumentos del constructor de Effect incluyen una referencia al getter game. Por eso, incluyes la combinación HasGameReference en esta clase. Esta combinación agrega un accesor game con seguridad de tipos 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 de la siguiente manera.

lib/src/components/components.dart

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

Agrega el murciélago 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(                                                  // Add from here...
      Bat(
        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 incorporación del mixin KeyboardEvents y el método onKeyEvent anulado controlan la entrada del teclado. Recuerda el código que agregaste antes para mover el murciélago según la cantidad de pasos adecuada.

El resto del código agregado añade el murciélago al mundo del juego en la posición adecuada y con las proporciones correctas. Tener todos estos parámetros de configuración expuestos en este archivo simplifica tu capacidad para ajustar el tamaño relativo del bate y la pelota para obtener la sensación correcta del juego.

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

Es hora de corregirlo. 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(delay: 0.35));                         // Modify from 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 {                                                    // To here.
      debugPrint('collision with $other');
    }
  }
}

Estos cambios de código solucionan dos problemas distintos.

Primero, corrige el problema de que la pelota desaparece en el momento en que toca la parte inferior de la pantalla. Para solucionar este problema, reemplaza la llamada a removeFromParent por RemoveEffect. El RemoveEffect quita la pelota del mundo del juego después de dejar que salga del área de juego 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, esta volverá a la parte superior de la pantalla. Si esto te parece demasiado indulgente y quieres algo más realista, cambia este manejo para que se ajuste mejor a cómo quieres que se sienta tu juego.

Vale la pena destacar la complejidad de la actualización de velocity. No solo invierte el componente y de la velocidad, como se hizo para las colisiones con 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 del contacto. Esto le da al jugador más control sobre lo que hace la pelota, pero no se le comunica de ninguna manera, excepto a través del juego.

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

8. Derriba el muro

Crea los ladrillos

Para agregar ladrillos al juego, haz lo siguiente:

  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 estas alturas, 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 con seguridad de tipos al juego BrickBreaker en la parte superior del árbol de componentes.

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

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

Para que el componente Brick sea accesible para BrickBreaker, 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.
    }
  }
}

Esto introduce el único aspecto nuevo, un modificador de dificultad que aumenta la velocidad de la pelota después de cada colisión con un ladrillo. Este parámetro ajustable debe someterse a pruebas de juego para encontrar la curva de dificultad adecuada 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, se mostrarán todas las mecánicas clave. Podrías desactivar la depuración y dar el trabajo por terminado, pero sentirías que falta algo.

Captura de pantalla que muestra brick_breaker con la pelota, el bate y la mayoría de los ladrillos en el área de juego. Cada uno de los componentes tiene etiquetas de depuración.

¿Qué tal una pantalla de bienvenida, una pantalla de fin del juego y, tal vez, una puntuación? Flutter puede agregar estas funciones al juego, y es a lo que le prestarás atención a continuación.

9. Cómo ganar el juego

Agrega estados de reproducción

En este paso, incorporarás el juego de Flame dentro de un wrapper de Flutter y, luego, agregarás superposiciones de Flutter para las pantallas de bienvenida, fin del juego y victoria.

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

  1. Modifica 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';

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 requiere mucho trabajo. Esto captura en qué punto se encuentra el jugador para ingresar, jugar y perder o ganar el juego. En la parte superior del archivo, defines la enumeración y, luego, la instancias como un estado oculto con los métodos get y set correspondientes. Estos métodos de obtención y configuración permiten modificar las superposiciones cuando las distintas partes del juego activan transiciones de estado de reproducción.

A continuación, divide el código en onLoad en onLoad y un nuevo método startGame. Antes de este cambio, solo podías iniciar un juego nuevo reiniciándolo. Con estas nuevas incorporaciones, el jugador ahora 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 toques y extendiste el controlador del teclado para permitir que el usuario inicie un juego nuevo en varias modalidades. Con el estado de reproducción modelado, tendría sentido actualizar los componentes para activar las transiciones de estado de reproducción cuando el jugador gana o pierde.

  1. Modifica 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';
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 ser correcto si el jugador permite que la pelota se salga de la parte inferior de la pantalla.

  1. Edita el componente Brick de la siguiente manera.

lib/src/components/brick.dart

impimport '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, aparecerá la pantalla de "juego ganado". ¡Bien hecho, jugador!

Agrega el wrapper de Flutter

Para proporcionar un lugar donde incorporar el juego y agregar superposiciones de estado de reproducción, agrega el 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(
        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 del árbol de widgets de Flutter. Las partes específicas de Flame incluyen el uso de GameWidget.controlled para construir y administrar la instancia del juego BrickBreaker y el nuevo argumento overlayBuilderMap para GameWidget.

Las claves de este overlayBuilderMap deben alinearse con las superposiciones que el configurador de playState en BrickBreaker agregó o quitó. Si intentas establecer una superposición que no se encuentra en este mapa, se mostrarán caras tristes por todas partes.

  1. Para que esta nueva funcionalidad aparezca en la pantalla, reemplaza el archivo lib/main.dart por 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 previsto se muestra en el juego. Si segmentas a macOS o Android, debes realizar un último ajuste para habilitar la visualización de google_fonts.

Habilita el acceso a las fuentes

Cómo agregar permiso de Internet para Android

En Android, debes agregar el 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 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 ejecutas el código tal como está, se mostrará una pantalla de bienvenida y una pantalla de fin de partida o de victoria en todas las plataformas. Esas pantallas pueden ser un poco simplistas, y sería bueno tener una puntuación. Así que adivina qué harás en el siguiente paso.

10. Cómo llevar el puntaje

Cómo agregar una puntuación al juego

En este paso, expones la puntuación del juego al contexto de Flutter circundante. En este paso, expondrás el estado del juego de Flame a la administración de estados de Flutter circundante. Esto permite que el código del juego actualice la puntuación cada vez que el jugador rompe un ladrillo.

  1. Modifica 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';

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

Si 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 rompa 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 el registro de la puntuación en Flutter, es momento de unir los widgets para que se vea 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.

Esto agrega más pulido a las superposiciones con la potencia del paquete flutter_animate para agregar movimiento y estilo a las pantallas de superposición.

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 visión más detallada del poder de flutter_animate, consulta el codelab Cómo compilar IU de próxima 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, se debe agregar un Column para apilar la puntuación sobre el juego.

En segundo lugar, para mejorar las experiencias de bienvenida, fin del juego y victoria, 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(
        textTheme: GoogleFonts.pressStart2pTextTheme().apply(
          bodyColor: const Color(0xff184e77),
          displayColor: const Color(0xff184e77),
        ),
      ),
      home: Scaffold(
        body: Container(
          decoration: const BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [Color(0xffa9d6e5), Color(0xfff2e8cf)],
            ),
          ),
          child: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Center(
                child: Column(                                  // Modify from here...
                  children: [
                    ScoreCard(score: game.score),
                    Expanded(
                      child: FittedBox(
                        child: SizedBox(
                          width: gameWidth,
                          height: gameHeight,
                          child: GameWidget(
                            game: game,
                            overlayBuilderMap: {
                              PlayState.welcome.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'TAP TO PLAY',
                                    subtitle: 'Use arrow keys or swipe',
                                  ),
                              PlayState.gameOver.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'G A M E   O V E R',
                                    subtitle: 'Tap to Play Again',
                                  ),
                              PlayState.won.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'Y O U   W O N ! ! !',
                                    subtitle: 'Tap to Play Again',
                                  ),
                            },
                          ),
                        ),
                      ),
                    ),
                  ],
                ),                                              // To here.
              ),
            ),
          ),
        ),
      ),
    );
  }
}

Con todo esto en su lugar, ahora deberías poder ejecutar este juego en cualquiera de las seis plataformas de destino de Flutter. El juego debería parecerse al siguiente.

Captura de pantalla de brick_breaker que muestra la pantalla previa al juego en la que se invita al usuario a presionar la pantalla para jugar

Captura de pantalla de brick_breaker en la que se muestra la pantalla de Game Over superpuesta 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 2D de Flame y lo incorporaste en un wrapper de Flutter. Usaste los efectos de Flame para animar y quitar componentes. Usaste los paquetes de Google Fonts y Flutter Animate para que todo el juego se viera bien diseñado.

Próximos pasos

Consulta algunos codelabs sobre los siguientes temas:

Lecturas adicionales