Cómo compilar un juego con Flutter y Flame

1. Introducción

Descubre cómo compilar un juego de plataformas con Flutter y Flame. En el juego Doodle Dash, inspirado por Doodle Jump, jugarás como Dash (la mascota de Flutter) o su mejor amigo, Sparky (la mascota de Firebase), e intentarás saltar lo más alto posible sobre plataformas.

Qué aprenderás

  • Cómo compilar un juego multiplataforma en Flutter
  • Cómo crear componentes reutilizables de juego que se puedan renderizar y actualizar como parte del bucle de juego de Flame
  • Cómo controlar y animar los movimientos de tu personaje (lo que se conoce como objeto) mediante la física del juego
  • Cómo agregar y gestionar la detección de colisiones
  • Cómo agregar el teclado y la entrada táctil como controles del juego

Requisitos previos

En este codelab, se presupone que tienes experiencia en Flutter. Si no es así, puedes aprender los conceptos básicos con el codelab Tu primera app de Flutter.

Qué compilarás

En este codelab, te ayudaremos a compilar un juego llamado Doodle Dash: un juego de plataformas con Dash, la mascota de Flutter, o Sparky, la mascota de Firebase (el resto del codelab hace referencia a Dash, pero los pasos también aplican a Sparky). Tu juego tendrá los siguientes elementos:

  • Un objeto que se puede mover horizontal y verticalmente
  • Plataformas generadas de forma aleatoria
  • Un efecto gravitatorio que empuje tu objeto hacia abajo
  • Menús de juego
  • Controles integrados en el juego, como los de pausa y volver a reproducir
  • La habilidad de llevar la puntuación

Juego

Doodle Dash se juega moviendo a Dash hacia la izquierda y la derecha, saltando sobre plataformas y usando potenciadores para aumentar su habilidad a lo largo del juego. Comenzarás el juego eligiendo el nivel de dificultad (de 1 a 5) y haciendo clic en Start.

d1e75aa0e05c526.gif

Niveles

El juego tiene 5 niveles. Cada uno (luego del nivel 1) desbloquea nuevas funciones.

  • Nivel 1 (predeterminado): Este nivel genera las plataformas NormalPlatform y SpringBoard. Cuando se crea, hay un 20% de probabilidades de que esa plataforma sea móvil.
  • Nivel 2 (puntuación >= 20): Agrega BrokenPlatform, en la que solo se puede saltar una vez.
  • Nivel 3 (puntuación >= 40): Desbloquea el potenciador NooglerHat. Esta plataforma especial dura 5 segundos y aumenta la habilidad de salto de Dash a 2.5 veces su velocidad normal. También lleva un divertido gorro de empleado de Google nuevo durante esos 5 segundos.
  • Nivel 4 (puntuación >= 80): Desbloquea el potenciador Rocket. Esta plataforma especial, representada por un cohete, hace que Dash sea invencible. También aumenta su habilidad de salto a 3.5 veces su velocidad normal.
  • Nivel 5 (puntuación >= 100): Desbloquea las plataformas Enemy. Si Dash choca con un enemigo, perderás automáticamente el juego.

Tipos de plataforma según el nivel

Nivel 1 (predeterminado)

NormalPlatform

SpringBoard

Nivel 2 (puntuación >= 20)

Nivel 3 (puntuación >= 40)

Nivel 4 (puntuación >= 80)

Nivel 5 (puntuación >= 100)

BrokenPlatform

NooglerHat

Rocket

Enemy

Cómo perder en el juego

Hay dos maneras de perder en el juego:

  • Si Dash cae por debajo de la parte inferior de la pantalla
  • Si Dash choca con un enemigo (los enemigos se generan en el nivel 5)

Potenciadores

Los potenciadores aumentan la habilidad de juego del personaje; por ejemplo, aumentan su velocidad de salto, le permiten volverse "invencible" frente a los enemigos, o ambas cosas. Doodle Dash tiene dos opciones de potenciación. Solo se activan de a una por vez.

  • El potenciador del gorro de empleado de Google nuevo aumenta la habilidad de salto de Dash a 2.5 veces su altura de salto normal. Además, llevará el gorro durante la potenciación.
  • El potenciador de cohete hace que Dash sea invencible ante plataformas enemigas (chocar con un enemigo no tiene ningún efecto) y aumenta su habilidad de salto a 3.5 veces su altura de salto normal. Dash volará en un cohete hasta que la gravedad supere su velocidad y aterrizará en una plataforma.

2. Obtén el código de partida del codelab

a3c16fc17be25f6c.png Descarga la versión inicial de tu proyecto desde GitHub:

  1. Desde la línea de comandos, clona el repositorio de GitHub en un directorio flutter-codelabs:
git clone https://github.com/flutter/codelabs.git flutter-codelabs

El código de este codelab se encuentra en el directorio flutter-codelabs/flame-building-doodle-dash. El directorio contiene el código completo del proyecto para cada paso del codelab.

a3c16fc17be25f6c.png Importa la app de partida

  • Importa el directorio flutter-codelabs/flame-building-doodle-dash/step_02 al IDE que prefieras.

a3c16fc17be25f6c.png Instala los paquetes:

  • Todos los paquetes requeridos, como Flame, ya se encuentran en el archivo pubspec.yaml del proyecto. Si tu IDE no instala automáticamente las dependencias del proyecto, abre una terminal de la línea de comandos y, desde la raíz del proyecto de Flutter, ejecuta el siguiente comando para recuperarlas:
flutter pub get

Configura tu entorno de desarrollo de Flutter

Para completar este codelab, necesitarás lo siguiente:

3. Explora el código

A continuación, explora el código.

Revisa el archivo lib/game/doodle_dash.dart, que contiene el juego Doodle Dash que extiende FlameGame. Registrarás los componentes con la instancia FlameGame, el componente más básico de Flame (similar a Scaffold de Flutter). Durante el juego, se renderizarán y actualizarán todos los componentes registrados. Es como el sistema nervioso central de tu juego.

¿Qué son los componentes? Así como una app de Flutter se compone de Widgets, un FlameGame contiene Components, que son los componentes básicos que conforman el juego. De forma muy parecida a los widgets de Flutter, los componentes también pueden tener componentes secundarios. El objeto de un personaje, el fondo del juego y el objeto responsable de generar nuevos componentes del juego (como los enemigos) son todos ejemplos de componentes. De hecho, el FlameGame en sí mismo es un Component; Flame llama a esto el sistema de componentes de Flame.

Los componentes se heredan de una clase abstracta Component. Implementa los métodos abstractos de Component para crear la mecánica de la clase FlameGame. Por ejemplo, con frecuencia, verás los siguientes métodos implementados en Doodle Dash:

  • onLoad: Inicializa de forma asíncrona un componente (es similar al método initState de Flutter).
  • update: Actualiza un componente con cada marca del bucle de juego (es similar al método build de Flutter).

Además, el método add registra los componentes en el motor de Flame.

Por ejemplo, el archivo lib/game/world.dart contiene la clase World, que extiende ParallaxComponent para renderizar el fondo del juego. Esta clase toma una lista de recursos de imagen y los renderiza en capas, lo que hace que cada capa se mueva a una velocidad diferente para darle un aspecto más realista. La clase DoodleDash contiene una instancia de ParallaxComponent y la agrega al juego en el método onLoad de Doodle Dash:

lib/game/world.dart

class World extends ParallaxComponent<DoodleDash> {
 @override
 Future<void> onLoad() async {
   parallax = await gameRef.loadParallax(
     [
       ParallaxImageData('game/background/06_Background_Solid.png'),
       ParallaxImageData('game/background/05_Background_Small_Stars.png'),
       ParallaxImageData('game/background/04_Background_Big_Stars.png'),
       ParallaxImageData('game/background/02_Background_Orbs.png'),
       ParallaxImageData('game/background/03_Background_Block_Shapes.png'),
       ParallaxImageData('game/background/01_Background_Squiggles.png'),
     ],
     fill: LayerFill.width,
     repeat: ImageRepeat.repeat,
     baseVelocity: Vector2(0, -5),
     velocityMultiplierDelta: Vector2(0, 1.2),
   );
 }
}

lib/game/doodle_dash.dart

class DoodleDash extends FlameGame
   with HasKeyboardHandlerComponents, HasCollisionDetection {
 ...
 final World _world = World();
 ...

 @override
 Future<void> onLoad() async {
   await add(_world);
   ...
 }
}

Gestión del estado

El directorio lib/game/managers contiene tres archivos que controlan la gestión del estado para Doodle Dash: game_manager.dart, object_manager.dart y level_manager.dart.

La clase GameManager (en game_manager.dart) hace un seguimiento del estado general del juego y de la puntuación.

La clase ObjectManager (en object_manager.dart) administra el lugar y el momento en el que las plataformas se generan y se quitan. Más adelante, agregarás contenido a esta clase.

Y, por último, la clase LevelManager (en level_manager.dart) administra el nivel de dificultad del juego junto con cualquier configuración relevante para cuando los jugadores suben de nivel. El juego ofrece cinco niveles de dificultad, y el jugador avanzará al próximo nivel cuando alcance el hito de puntuación correspondiente. Cada avance de nivel aumenta la dificultad y la distancia que Dash debe saltar. Dado que la gravedad es constante a lo largo del juego, la velocidad de salto aumenta de forma gradual para tener en cuenta la mayor distancia.

La puntuación del jugador aumentará cuando este pase una plataforma. Cuando el jugador alcanza ciertos umbrales de puntuación, el juego sube de nivel y desbloquea plataformas nuevas y especiales que hacen que el juego resulte más divertido y desafiante.

4. Agrega un jugador al juego

En este paso, agregarás un personaje al juego (en este caso, Dash). El jugador controlará el personaje, y toda la lógica residirá en la clase Player (en el archivo player.dart). La clase Player extiende la clase SpriteGroupComponent de Flame, que contiene métodos abstractos que anularás para implementar la lógica personalizada. Esto incluye cargar recursos y objetos, posicionar al jugador (horizontal y verticalmente), configurar la detección de colisiones y aceptar la entrada del usuario.

Cómo cargar recursos

Dash se muestra mediante diferentes objetos, que representan distintas versiones del personaje y de los potenciadores. Por ejemplo, los siguientes íconos muestran a Dash y a Sparky mirando al centro, a la izquierda y a la derecha.

El elemento SpriteGroupComponent de Flame te permite gestionar varios estados de objeto con la propiedad sprites, como verás en el método _loadCharacterSprites.

a3c16fc17be25f6c.png En la clase Player, agrega las siguientes líneas al método onLoad para cargar los recursos de objeto y establece el estado de objeto de Player de modo que mire hacia adelante:

lib/game/sprites/player.dart

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

  await _loadCharacterSprites();                                      // Add this line
  current = PlayerState.center;                                       // Add this line
}

Revisa el código para cargar los objetos y recursos en _loadCharacterSprites. Este código podría implementarse de forma directa en el método onLoad, pero ubicarlo en uno independiente organizará el código fuente y lo hará más legible. Este método asigna un mapa a la propiedad sprites que vincula el estado de cada personaje a un recurso de objeto cargado, como se muestra a continuación:

lib/game/sprites/player.dart

Future<void> _loadCharacterSprites() async {
   final left = await gameRef.loadSprite('game/${character.name}_left.png');
   final right = await gameRef.loadSprite('game/${character.name}_right.png');
   final center =
       await gameRef.loadSprite('game/${character.name}_center.png');
   final rocket = await gameRef.loadSprite('game/rocket_4.png');
   final nooglerCenter =
       await gameRef.loadSprite('game/${character.name}_hat_center.png');
   final nooglerLeft =
       await gameRef.loadSprite('game/${character.name}_hat_left.png');
   final nooglerRight =
       await gameRef.loadSprite('game/${character.name}_hat_right.png');

   sprites = <PlayerState, Sprite>{
     PlayerState.left: left,
     PlayerState.right: right,
     PlayerState.center: center,
     PlayerState.rocket: rocket,
     PlayerState.nooglerCenter: nooglerCenter,
     PlayerState.nooglerLeft: nooglerLeft,
     PlayerState.nooglerRight: nooglerRight,
   };
 }

Cómo actualizar el componente del jugador

Flame llama al método update de un componente una vez por cada marca (o fotograma) del bucle de evento para volver a dibujar cada componente del juego que haya cambiado (es similar al método build de Flutter). A continuación, agrega lógica en el método update de la clase Player para posicionar el personaje en pantalla.

a3c16fc17be25f6c.png Agrega el siguiente código al método update de la clase Player para calcular la velocidad y la posición actuales del personaje:

lib/game/sprites/player.dart

 void update(double dt) {
                                                             // Add lines from here...
   if (gameRef.gameManager.isIntro || gameRef.gameManager.isGameOver) return;

   _velocity.x = _hAxisInput * jumpSpeed;                              // ... to here.

   final double dashHorizontalCenter = size.x / 2;

   if (position.x < dashHorizontalCenter) {                  // Add lines from here...
     position.x = gameRef.size.x - (dashHorizontalCenter);
   }
   if (position.x > gameRef.size.x - (dashHorizontalCenter)) {
     position.x = dashHorizontalCenter;
   }                                                                   // ... to here.

   // Core gameplay: Add gravity

   position += _velocity * dt;                                       // Add this line
   super.update(dt);
 }

Antes de mover el jugador, el método update hace una verificación para asegurarse de que el juego no esté en un estado no jugable en el que el jugador no debería moverse, por ejemplo, durante el estado inicial (cuando el juego se carga por primera vez) o en el estado de fin del juego.

Si el juego está en un estado jugable, la posición de Dash se calcula usando la ecuación new_position = current_position + (velocity * time-elapsed-since-last-game-loop-tick) o como se ve en el código siguiente:

 position += _velocity * dt

Otro aspecto fundamental a la hora de compilar Doodle Dash es asegurarse de incluir límites laterales infinitos. De esta manera, Dash podrá saltar hacia afuera del borde izquierdo de la pantalla y volver a ingresar desde el derecho, y viceversa.

7068325e8b2f35fc.gif

Esto se implementa verificando si la posición de Dash sobrepasó el borde izquierdo o derecho de la pantalla y, de ser así, reposicionándola en el borde opuesto.

Eventos de teclas

Inicialmente, Doodle Dash se ejecutará en la Web y en computadoras de escritorio, por lo que necesita ser compatible con la entrada del teclado de modo que los jugadores puedan controlar el movimiento del personaje. El método onKeyEvent le permite al componente Player reconocer las presiones de las teclas de flecha para determinar si Dash debería orientarse o caminar hacia la izquierda o la derecha.

Dash se orienta a la izquierda cuando se mueve en esa dirección

Dash se orienta a la derecha cuando se mueve en esa dirección

A continuación, implementa la habilidad de Dash para moverse horizontalmente (como se definió en la variable _hAxisInput). También harás que Dash se oriente en la dirección en la que se esté moviendo.

a3c16fc17be25f6c.png Modifica los métodos moveLeft y moveRight de la clase Player para definir la dirección actual de Dash:

lib/game/sprites/player.dart

 void moveLeft() {
   _hAxisInput = 0;

   current = PlayerState.left;                                      // Add this line

   _hAxisInput += movingLeftInput;                                  // Add this line

 }

 void moveRight() {
   _hAxisInput = 0;

   current = PlayerState.right;                                     // Add this line

   _hAxisInput += movingRightInput;                                 // Add this line

 }

a3c16fc17be25f6c.png Modifica el método onKeyEvent de la clase Player para llamar a los métodos moveLeft o moveRight, respectivamente, cuando se presionen las teclas de flecha izquierda o derecha:

lib/game/sprites/player.dart

@override
 bool onKeyEvent(RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
   _hAxisInput = 0;

                                                             // Add lines from here...
   if (keysPressed.contains(LogicalKeyboardKey.arrowLeft)) {
     moveLeft();
   }

   if (keysPressed.contains(LogicalKeyboardKey.arrowRight)) {
     moveRight();
   }                                                                   // ... to here.

   // During development, it's useful to "cheat"
   if (keysPressed.contains(LogicalKeyboardKey.arrowUp)) {
     // jump();
   }

   return true;
 }

Ahora que la clase Player es funcional, el juego de Doodle Dash puede usarla.

a3c16fc17be25f6c.png En el archivo de Doodle Dash, importa sprites.dart, que hará que esté disponible la clase Player:

lib/game/doodle_dash.dart

import 'sprites/sprites.dart';                                       // Add this line

a3c16fc17be25f6c.png Crea una instancia de Player en la clase DoodleDash:

lib/game/doodle_dash.dart

class DoodleDash extends FlameGame
    with HasKeyboardHandlerComponents, HasCollisionDetection {
  DoodleDash({super.children});

  final World _world = World();
  LevelManager levelManager = LevelManager();
  GameManager gameManager = GameManager();
  int screenBufferSpace = 300;
  ObjectManager objectManager = ObjectManager();

  late Player player;                                                // Add this line
  ...
}

a3c16fc17be25f6c.png A continuación, inicializa y configura la velocidad de salto de Player con base en el nivel de dificultad seleccionado por el jugador, y agrega el componente Player a FlameGame. Completa el método setCharacter con el siguiente código:

lib/game/doodle_dash.dart

void setCharacter() {
  player = Player(                                           // Add lines from here...
     character: gameManager.character,
     jumpSpeed: levelManager.startingJumpSpeed,
   );
  add(player);                                                         // ... to here.
}

a3c16fc17be25f6c.png Llama al método setCharacter al comienzo de initializeGameStart.

lib/game/doodle_dash.dart

void initializeGameStart() {
    setCharacter();                                                   // Add this line

    ...
}

a3c16fc17be25f6c.png Además, en initializeGameStart, llama a resetPosition en el jugador de modo que este se mueva a la posición inicial cada vez que comience un juego.

lib/game/doodle_dash.dart

void initializeGameStart() {
    ...

    levelManager.reset();

    player.resetPosition();                                           // Add this line

    objectManager = ObjectManager(
        minVerticalDistanceToNextPlatform: levelManager.minDistance,
        maxVerticalDistanceToNextPlatform: levelManager.maxDistance);

    ...
  }

a3c16fc17be25f6c.png Ejecuta la app. Comienza un juego, y Dash aparecerá en pantalla.

ed15a9c6762595c9.png

¿Tienes problemas?

Si la app no se ejecuta correctamente, comprueba que no haya errores ortográficos. De ser necesario, usa el código que aparece en los siguientes vínculos para volver a empezar.

5. Agrega plataformas

En este paso, agregarás plataformas (Dash aterrizará en ellas y las usará para saltar) y la lógica de detección de colisiones para determinar cuándo debe saltar Dash.

En primer lugar, revisa la clase abstracta Platform:

lib/game/sprites/platform.dart

abstract class Platform<T> extends SpriteGroupComponent<T>
    with HasGameRef<DoodleDash>, CollisionCallbacks {
  final hitbox = RectangleHitbox();
  bool isMoving = false;

  Platform({
    super.position,
  }) : super(
          size: Vector2.all(100),
          priority: 2,
        );

  @override
  Future<void>? onLoad() async {
    await super.onLoad();
    await add(hitbox);
  }
}

¿Qué es una caja de colisiones?

Cada componente de plataforma presentado en Doodle Dash extiende la clase abstracta Platform<T>, que es un SpriteComponent con una caja de colisiones. Esta caja le permite a un componente de objeto detectar cuándo colisiona con otros objetos con cajas de colisiones. Flame admite una variedad de formas de cajas de colisiones, como rectángulos, círculos y polígonos. Por ejemplo, Doodle Dash usa una caja de colisiones rectangular para una plataforma y una circular para Dash. Flame controla la matemática que calcula la colisión.

La clase Platform agrega una caja de colisiones y devoluciones de llamadas de colisión a todos los subtipos.

Agrega una plataforma estándar

La clase Platform agrega plataformas al juego. Una plataforma normal está representada por una de 4 imágenes elegidas aleatoriamente: un monitor, un teléfono, una terminal o una laptop. La elección de la imagen no afecta el comportamiento de la plataforma.

NormalPlatform

a3c16fc17be25f6c.png Agrega una plataforma estática y regular agregando un elemento NormalPlatformState de tipo enum y una clase NormalPlatform:

lib/game/sprites/platform.dart

enum NormalPlatformState { only }                            // Add lines from here...

class NormalPlatform extends Platform<NormalPlatformState> {
  NormalPlatform({super.position});

  final Map<String, Vector2> spriteOptions = {
    'platform_monitor': Vector2(115, 84),
    'platform_phone_center': Vector2(100, 55),
    'platform_terminal': Vector2(110, 83),
    'platform_laptop': Vector2(100, 63),
  };

  @override
  Future<void>? onLoad() async {
    var randSpriteIndex = Random().nextInt(spriteOptions.length);

    String randSprite = spriteOptions.keys.elementAt(randSpriteIndex);

    sprites = {
      NormalPlatformState.only: await gameRef.loadSprite('game/$randSprite.png')
    };

    current = NormalPlatformState.only;

    size = spriteOptions[randSprite]!;
    await super.onLoad();
  }
}                                                                      // ... to here.

A continuación, genera plataformas para que el personaje interactúe con ellas.

La clase ObjectManager extiende la clase Component de Flame y genera objetos Platform a lo largo del juego. Implementa la habilidad de generar plataformas en los métodos update y onMount de ObjectManager.

a3c16fc17be25f6c.png Genera plataformas en la clase ObjectManager creando un método nuevo llamado _semiRandomPlatform. Más adelante, actualizarás este método para mostrar diferentes tipos de plataformas, pero, por ahora, solo muestra una NormalPlatform:

lib/game/managers/object_manager.dart

Platform _semiRandomPlatform(Vector2 position) {             // Add lines from here...
    return NormalPlatform(position: position);
}                                                                      // ... to here.

a3c16fc17be25f6c.png Anula el método update de ObjectManager y usa el método _semiRandomPlatform para generar una plataforma y agregarla al juego:

lib/game/managers/object_manager.dart

 @override                                                   // Add lines from here...
 void update(double dt) {
   final topOfLowestPlatform =
       _platforms.first.position.y + _tallestPlatformHeight;

   final screenBottom = gameRef.player.position.y +
       (gameRef.size.x / 2) +
       gameRef.screenBufferSpace;

   if (topOfLowestPlatform > screenBottom) {
     var newPlatY = _generateNextY();
     var newPlatX = _generateNextX(100);
     final nextPlat = _semiRandomPlatform(Vector2(newPlatX, newPlatY));
     add(nextPlat);

     _platforms.add(nextPlat);

     gameRef.gameManager.increaseScore();

     _cleanupPlatforms();
     // Losing the game: Add call to _maybeAddEnemy()
     // Powerups: Add call to _maybeAddPowerup();
   }

   super.update(dt);
 }                                                                     // ... to here.

Haz lo mismo en el método onMount de ObjectManager de modo que, cuando el juego se ejecute por primera vez, el método _semiRandomPlatform genere una plataforma de inicio y la agregue al juego.

a3c16fc17be25f6c.png Agrega el método onMount con el siguiente código:

lib/game/managers/object_manager.dart

 @override                                                   // Add lines from here...
 void onMount() {
   super.onMount();

   var currentX = (gameRef.size.x.floor() / 2).toDouble() - 50;

   var currentY =
       gameRef.size.y - (_rand.nextInt(gameRef.size.y.floor()) / 3) - 50;

   for (var i = 0; i < 9; i++) {
     if (i != 0) {
       currentX = _generateNextX(100);
       currentY = _generateNextY();
     }
     _platforms.add(
       _semiRandomPlatform(
         Vector2(
           currentX,
           currentY,
         ),
       ),
     );

     add(_platforms[i]);
   }
 }                                                                     // ... to here.

Por ejemplo, como se muestra en el código siguiente, el método configure permite que el juego de Doodle Dash vuelva a configurar las distancias mínima y máxima entre las plataformas, y habilita las plataformas especiales cuando el nivel de dificultad aumenta:

lib/game/managers/object_manager.dart

 void configure(int nextLevel, Difficulty config) {
    minVerticalDistanceToNextPlatform = gameRef.levelManager.minDistance;
    maxVerticalDistanceToNextPlatform = gameRef.levelManager.maxDistance;

    for (int i = 1; i <= nextLevel; i++) {
      enableLevelSpecialty(i);
    }
  }

La instancia de DoodleDash (en el método initializeGameStart) crea un ObjectManager que se inicializa, se configura con base en el nivel de dificultad y se agrega al juego de Flame:

lib/game/doodle_dash.dart

  void initializeGameStart() {
    gameManager.reset();

    if (children.contains(objectManager)) objectManager.removeFromParent();

    levelManager.reset();

    player.resetPosition();

    objectManager = ObjectManager(
        minVerticalDistanceToNextPlatform: levelManager.minDistance,
        maxVerticalDistanceToNextPlatform: levelManager.maxDistance);

    add(objectManager);

    objectManager.configure(levelManager.level, levelManager.difficulty);
  }

El elemento ObjectManager aparece otra vez en el método checkLevelUp. Cuando el jugador sube de nivel, ObjectManager vuelve a configurar sus parámetros de generación de plataformas con base en el nivel de dificultad.

lib/game/doodle_dash.dart

  void checkLevelUp() {
    if (levelManager.shouldLevelUp(gameManager.score.value)) {
      levelManager.increaseLevel();

      objectManager.configure(levelManager.level, levelManager.difficulty);
    }
  }

a3c16fc17be25f6c.png Haz una recarga en caliente 7f9a9e103c7b5e5.png (o reinicia si estás probando en la Web) para activar los cambios. Guarda el archivo; usa el botón de tu IDE; o, desde la línea de comandos, ingresa r para hacer la recarga en caliente. Comienza un juego, y Dash y algunas plataformas aparecerán en pantalla:

7c6a6c6e630c42ce.png

¿Tienes problemas?

Si la app no se ejecuta correctamente, comprueba que no haya errores ortográficos. De ser necesario, usa el código que aparece en los siguientes vínculos para volver a empezar.

6. Juego principal

Ahora que implementaste los widgets individuales Player y Platform, puedes empezar a consolidar todo. En este paso, se implementará la funcionalidad principal, la detección de colisiones y el movimiento de la cámara.

Gravedad

Para que el juego resulte más realista, Dash necesita verse afectada por la gravedad, una fuerza que la empuja hacia abajo cuando ella salta. En nuestra versión de Doodle Dash, la gravedad es un valor constante y positivo que siempre empuja a Dash hacia abajo. En el futuro, sin embargo, podrías optar por cambiar la gravedad para crear otros efectos.

a3c16fc17be25f6c.png En la clase Player, agrega una propiedad _gravity con un valor de 9:

lib/game/sprites/player.dart

class Player extends SpriteGroupComponent<PlayerState>
    with HasGameRef<DoodleDash>, KeyboardHandler, CollisionCallbacks {

  ...

  Character character;
  double jumpSpeed;
  final double _gravity = 9;                                         // Add this line

  @override
  Future<void> onLoad() async {
    ...
  }
  ...
}

a3c16fc17be25f6c.png Modifica el método update de Player para agregar la variable _gravity y modificar la velocidad vertical de Dash:

lib/game/sprites/player.dart

 void update(double dt) {
   if (gameRef.gameManager.isIntro || gameRef.gameManager.isGameOver) return;

   _velocity.x = _hAxisInput * jumpSpeed;
   final double dashHorizontalCenter = size.x / 2;

   if (position.x < dashHorizontalCenter) {
     position.x = gameRef.size.x - (dashHorizontalCenter);
   }
   if (position.x > gameRef.size.x - (dashHorizontalCenter)) {
     position.x = dashHorizontalCenter;
   }

   _velocity.y += _gravity;                                          // Add this line

   position += _velocity * dt;
   super.update(dt);
 }

Detección de colisiones

Flame tiene compatibilidad de fábrica con la detección de colisiones. Para habilitarla en tu juego de Flame, agrega la combinación HasCollisionDetection. Si revisas la clase DoodleDash, verás que esta combinación ya se había agregado:

lib/game/doodle_dash.dart

class DoodleDash extends FlameGame
    with HasKeyboardHandlerComponents, HasCollisionDetection {
    ...
}

A continuación, agrega la detección de colisiones a los componentes independientes del juego usando la combinación CollisionCallbacks. Esta combinación le da al componente acceso a la devolución de llamada onCollision. Una colisión de dos objetos con cajas de colisiones activa la devolución de llamada onCollision y pasa una referencia al objeto con el que está colisionando, de modo que puedes implementar lógica para la forma en que debería reaccionar tu objeto.

Recuerda que, en el paso anterior, vimos que la clase abstracta Platform ya tiene la combinación CollisionCallbacks y una caja de colisiones. La clase Player también tiene la combinación CollisionCallbacks, de modo que solo tienes que agregar una CircleHitbox a la clase Player. La caja de colisiones de Dash es un círculo, dado que Dash tiene una forma más circular que rectangular.

a3c16fc17be25f6c.png En la clase Player, importa sprites.dart de modo que tenga acceso a las distintas clases Platform:

lib/game/sprites/player.dart

import 'sprites.dart';

a3c16fc17be25f6c.png Agrega una CircleHitbox al método onLoad de la clase Player:

lib/game/sprites/player.dart

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

  await add(CircleHitbox());                                         // Add this line

  await _loadCharacterSprites();
  current = PlayerState.center;
}

Dash necesita un método jump para poder saltar cuando colisione con una plataforma.

a3c16fc17be25f6c.png Agrega un método jump que tome un elemento opcional specialJumpSpeed:

lib/game/sprites/player.dart

void jump({double? specialJumpSpeed}) {
  _velocity.y = specialJumpSpeed != null ? -specialJumpSpeed : -jumpSpeed;
}

a3c16fc17be25f6c.png Anula el método onCollision de Player agregando el siguiente código:

lib/game/sprites/player.dart

@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
   super.onCollision(intersectionPoints, other);
   bool isCollidingVertically =
       (intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;

   if (isMovingDown && isCollidingVertically) {
     current = PlayerState.center;
     if (other is NormalPlatform) {
       jump();
       return;
     }
   }
 }

Esta devolución de llamada llama al método jump de Dash cuando esta se cae y colisiona con la parte superior de una NormalPlatform. La sentencia isMovingDown && isCollidingVertically garantiza que Dash se mueva hacia arriba por las plataformas sin activar un salto.

Movimiento de la cámara

La cámara debería seguir a Dash a medida que se mueva hacia arriba en el juego, pero debería quedarse estática cuando Dash se caiga.

En Flame, si el "mundo" es más grande que la pantalla, usa el elemento worldBounds de la cámara para agregar límites que le indiquen a Flame qué parte del mundo se debe mostrar. Para dar la apariencia de que la cámara se está moviendo hacia arriba mientras se mantiene horizontalmente fija, ajusta los límites de arriba y abajo del mundo en cada actualización basada en la posición del jugador, pero mantén los límites izquierdo y derecho sin cambios.

a3c16fc17be25f6c.png En la clase DoodleDash, agrega el siguiente código al método update para permitir que la cámara siga a Dash durante el juego:

lib/game/doodle_dash.dart

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

    if (gameManager.isIntro) {
      overlays.add('mainMenuOverlay');
      return;
    }

    if (gameManager.isPlaying) {
      checkLevelUp();

                                                            // Add lines from here...
      final Rect worldBounds = Rect.fromLTRB(
        0,
        camera.position.y - screenBufferSpace,
        camera.gameSize.x,
        camera.position.y + _world.size.y,
      );
      camera.worldBounds = worldBounds;

      if (player.isMovingDown) {
        camera.worldBounds = worldBounds;
      }

      var isInTopHalfOfScreen = player.position.y <= (_world.size.y / 2);
      if (!player.isMovingDown && isInTopHalfOfScreen) {
        camera.followComponent(player);
      }                                                               // ... to here.
    }
  }

A continuación, se deben restablecer la posición de Player y los límites de la cámara a sus valores iniciales siempre que se reinicie un juego.

a3c16fc17be25f6c.png Agrega el siguiente código en el método initializeGameStart:

lib/game/doodle_dash.dart

void initializeGameStart() {
    ...
    levelManager.reset();

                                                        // Add the lines from here...
    player.reset();
    camera.worldBounds = Rect.fromLTRB(
      0,
      -_world.size.y,
      camera.gameSize.x,
      _world.size.y +
          screenBufferSpace,
    );
    camera.followComponent(player);
                                                                      // ... to here.

   player.resetPosition();
    ...
  }

Aumenta la velocidad de salto cuando se suba de nivel

La última pieza del juego principal requiere que la velocidad de salto de Dash aumente cuando el nivel de dificultad lo haga y que se generen plataformas a distancias cada vez más grandes entre ellas.

a3c16fc17be25f6c.png Agrega una llamada al método setJumpSpeed y proporciona la velocidad de salto asociada al nivel actual:

lib/game/doodle_dash.dart

void checkLevelUp() {
    if (levelManager.shouldLevelUp(gameManager.score.value)) {
      levelManager.increaseLevel();

      objectManager.configure(levelManager.level, levelManager.difficulty);

      player.setJumpSpeed(levelManager.jumpSpeed);                   // Add this line
    }
  }

a3c16fc17be25f6c.png Haz una recarga en caliente 7f9a9e103c7b5e5.png (o reinicia si estás en la Web) para activar los cambios. Guarda el archivo; usa el botón de tu IDE; o, desde la línea de comandos, ingresa r para hacer la recarga en caliente:

2bc7c856064d74ca.gif

¿Tienes problemas?

Si la app no se ejecuta correctamente, comprueba que no haya errores ortográficos. De ser necesario, usa el código que aparece en los siguientes vínculos para volver a empezar.

7. Más información sobre plataformas

Ahora que el elemento ObjectManager genera plataformas sobre las que Dash puede saltar, puedes crear para ella algunas plataformas especiales y emocionantes.

A continuación, agrega las clases BrokenPlatform y SpringBoard. Como el nombre lo indica, una BrokenPlatform se rompe luego de un salto, y una SpringBoard ofrece un trampolín que permite que Dash rebote más alto y más rápido.

BrokenPlatform

SpringBoard

Como la clase Player, cada una de estas clases de plataformas dependen de enums para representar su estado actual.

lib/game/sprites/platform.dart

enum BrokenPlatformState { cracked, broken }

Un cambio en el estado current de la plataforma también cambia el objeto que aparece dentro del juego. Define la asignación entre el State de tipo enum y los recursos de imagen en la propiedad sprites para correlacionar cuál objeto se asigna a cada estado.

a3c16fc17be25f6c.png Agrega un BrokenPlatformState de tipo enum y la clase BrokenPlatform:

lib/game/sprites/platform.dart

enum BrokenPlatformState { cracked, broken }                // Add lines from here...

class BrokenPlatform extends Platform<BrokenPlatformState> {
  BrokenPlatform({super.position});

  @override
  Future<void>? onLoad() async {
    await super.onLoad();

    sprites = <BrokenPlatformState, Sprite>{
      BrokenPlatformState.cracked:
          await gameRef.loadSprite('game/platform_cracked_monitor.png'),
      BrokenPlatformState.broken:
          await gameRef.loadSprite('game/platform_monitor_broken.png'),
    };

    current = BrokenPlatformState.cracked;
    size = Vector2(115, 84);
  }

  void breakPlatform() {
    current = BrokenPlatformState.broken;
  }
}                                                                     // ... to here.

a3c16fc17be25f6c.png Agrega un SpringState de tipo enum y la clase SpringBoard:

lib/game/sprites/platform.dart

enum SpringState { down, up }                                // Add lines from here...

class SpringBoard extends Platform<SpringState> {
  SpringBoard({
    super.position,
  });

  @override
  Future<void>? onLoad() async {
    await super.onLoad();

    sprites = <SpringState, Sprite>{
      SpringState.down:
          await gameRef.loadSprite('game/platform_trampoline_down.png'),
      SpringState.up:
          await gameRef.loadSprite('game/platform_trampoline_up.png'),
    };

    current = SpringState.up;

    size = Vector2(100, 45);
  }

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

    bool isCollidingVertically =
        (intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;

    if (isCollidingVertically) {
      current = SpringState.down;
    }
  }

  @override
  void onCollisionEnd(PositionComponent other) {
    super.onCollisionEnd(other);

    current = SpringState.up;
  }
}                                                                      // ... to here.

A continuación, habilita estas plataformas especiales en ObjectManager. Como son especiales, no querrás que estas plataformas aparezcan en el juego todo el tiempo, así que genéralas con base en la probabilidad: 15% para SpringBoard y 10% para BrokenPlatform.

a3c16fc17be25f6c.png En ObjectManager, dentro del método _semiRandomPlatform, antes de la sentencia que muestra una NormalPlatform, agrega el siguiente código para mostrar de forma condicional una plataforma especial:

lib/game/managers/object_manager.dart

Platform _semiRandomPlatform(Vector2 position) {
   if (specialPlatforms['spring'] == true &&                 // Add lines from here...
       probGen.generateWithProbability(15)) {
     return SpringBoard(position: position);
   }

   if (specialPlatforms['broken'] == true &&
       probGen.generateWithProbability(10)) {
     return BrokenPlatform(position: position);
   }                                                                   // ... to here.

   return NormalPlatform(position: position);
}

Parte de la diversión de jugar un juego consiste en desbloquear nuevos desafíos y funciones a medida que subes de nivel.

Deberías completar el trampolín desde el principio del nivel 1; pero, una vez que Dash alcance el nivel 2, desbloqueará la BrokenPlatform, lo que hará un poco más difícil el juego.

a3c16fc17be25f6c.png En la clase ObjectManager, modifica el método enableLevelSpecialty (que actualmente es un stub) agregando una sentencia switch que habilite las plataformas SpringBoard para el nivel 1 y las BrokenPlatform para el nivel 2:

lib/game/managers/object_manager.dart

void enableLevelSpecialty(int level) {
  switch (level) {                                           // Add lines from here...
    case 1:
      enableSpecialty('spring');
      break;
    case 2:
      enableSpecialty('broken');
      break;
  }                                                                    // ... to here.
}

a3c16fc17be25f6c.png A continuación, permite que las plataformas se puedan mover de forma horizontal hacia adelante y hacia atrás. En la clase abstracta Platform**,** agrega el siguiente método _move:

lib/game/sprites/platform.dart

void _move(double dt) {
    if (!isMoving) return;

    final double gameWidth = gameRef.size.x;

    if (position.x <= 0) {
      direction = 1;
    } else if (position.x >= gameWidth - size.x) {
      direction = -1;
    }

    _velocity.x = direction * speed;

    position += _velocity * dt;
}

Si la plataforma se está moviendo, cambiará su movimiento a la dirección opuesta cuando alcance el borde de la pantalla de juego. Como Dash, la posición de la plataforma se determina multiplicando _direction por la speed de la plataforma para obtener la velocidad. Luego, multiplica la velocidad por time-elapsed y agrega la distancia resultante a la position actual de la plataforma.

a3c16fc17be25f6c.png Anula el método update de la clase Platform para llamar al método _move:

lib/game/sprites/platform.dart

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

a3c16fc17be25f6c.png Para activar el movimiento de Platform, en el método onLoad, establece de forma aleatoria el elemento booleano isMoving en true el 20% del tiempo.

lib/game/sprites/platform.dart

@override
Future<void>? onLoad() async {
  await super.onLoad();

  await add(hitbox);

  final int rand = Random().nextInt(100);                            // Add this line
  if (rand > 80) isMoving = true;                                    // Add this line
}

a3c16fc17be25f6c.png Finalmente, en Player, modifica el método onCollision de la clase Player para detectar una colisión con una Springboard o una BrokenPlatform. Observa que una SpringBoard llama a jump con un duplicador de velocidad, y BrokenPlatform solo llama a jump si su estado es .cracked y no .broken (que significa que Dash ya saltó en ella):

lib/game/sprites/player.dart

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

   bool isCollidingVertically =
       (intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;

   if (isMovingDown && isCollidingVertically) {
     current = PlayerState.center;
     if (other is NormalPlatform) {
       jump();
       return;
     } else if (other is SpringBoard) {                      // Add lines from here...
       jump(specialJumpSpeed: jumpSpeed * 2);
       return;
     } else if (other is BrokenPlatform &&
         other.current == BrokenPlatformState.cracked) {
       jump();
       other.breakPlatform();
       return;
     }                                                                 // ... to here.
   }
 }

a3c16fc17be25f6c.png Reinicia la app. Comienza un juego para ver las plataformas que se mueven, SpringBoard y BrokenPlatform.

d4949925e897f665.gif

¿Tienes problemas?

Si la app no se ejecuta correctamente, comprueba que no haya errores ortográficos. De ser necesario, usa el código que aparece en los siguientes vínculos para volver a empezar.

8. Cómo perder en el juego

En este paso, se agregarán condiciones a Doodle Dash que, cuando se cumplan, harán que el jugador pierda el juego. Existen dos maneras de perder el juego:

  1. Si Dash no le acierta a una plataforma y se cae por debajo de la parte inferior de la pantalla
  2. Si Dash colisiona con una plataforma Enemy

Antes de que implementes la condición de "fin del juego", debes agregar lógica que establezca el estado del juego de Doodle Dash en gameOver.

a3c16fc17be25f6c.png En la clase DoodleDash**,** agrega un método onLose que se llame cada vez que debería terminar el juego. Establecerá el estado del juego, quitará el jugador de la pantalla y activará el menú o una superposición de **Fin del juego**.

lib/game/sprites/doodle_dash.dart

 void onLose() {                                             // Add lines from here...
    gameManager.state = GameState.gameOver;
    player.removeFromParent();
    overlays.add('gameOverOverlay');
  }                                                                    // ... to here.

Menú de Fin del juego:

6a79b43f4a1f780d.png

a3c16fc17be25f6c.png En la parte superior del método update de DoodleDash, agrega el siguiente código para que el juego no se actualice cuando su estado sea GameOver:

lib/game/sprites/doodle_dash.dart

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

   if (gameManager.isGameOver) {                             // Add lines from here...
     return;
   }                                                                   // ... to here.
   ...
}

a3c16fc17be25f6c.png Además, en el método update, llama a onLose cuando el jugador haya caído por debajo de la parte inferior de la pantalla.

lib/game/sprites/doodle_dash.dart

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

   if (gameManager.isPlaying) {
     checkLevelUp();

     final Rect worldBounds = Rect.fromLTRB(
       0,
       camera.position.y - screenBufferSpace,
       camera.gameSize.x,
       camera.position.y + _world.size.y,
     );
     camera.worldBounds = worldBounds;
     if (player.isMovingDown) {
       camera.worldBounds = worldBounds;
     }

     var isInTopHalfOfScreen = player.position.y <= (_world.size.y / 2);
     if (!player.isMovingDown && isInTopHalfOfScreen) {
       camera.followComponent(player);
     }

                                                             // Add lines from here...
     if (player.position.y >
         camera.position.y +
             _world.size.y +
             player.size.y +
             screenBufferSpace) {
       onLose();
     }                                                                 // ... to here.
   }
 }

Los enemigos pueden tener varias formas y tamaños. En Doodle Dash, se indican con un ícono de papelera o de carpeta con error. Los jugadores deben evitar colisionar con ellos, ya que eso provocaría un fin del juego inmediato.

Enemy

a3c16fc17be25f6c.png Crea un tipo de plataforma enemiga agregando un EnemyPlatformState de tipo enum y la clase EnemyPlatform:

lib/game/sprites/platform.dart

enum EnemyPlatformState { only }                             // Add lines from here...

class EnemyPlatform extends Platform<EnemyPlatformState> {
  EnemyPlatform({super.position});

  @override
  Future<void>? onLoad() async {
    var randBool = Random().nextBool();
    var enemySprite = randBool ? 'enemy_trash_can' : 'enemy_error';

    sprites = <EnemyPlatformState, Sprite>{
      EnemyPlatformState.only:
          await gameRef.loadSprite('game/$enemySprite.png'),
    };

    current = EnemyPlatformState.only;

    return super.onLoad();
  }
}                                                                      // ... to here.

La clase EnemyPlatform extiende el supertipo Platform. El elemento ObjectManager genera y administra las plataformas enemigas tal como lo hace para el resto de las plataformas.

a3c16fc17be25f6c.png En ObjectManager, agrega el siguiente código para generar y administrar las plataformas enemigas:

lib/game/managers/object_manager.dart

final List<EnemyPlatform> _enemies = [];                    // Add lines from here...
void _maybeAddEnemy() {
  if (specialPlatforms['enemy'] != true) {
    return;
  }
  if (probGen.generateWithProbability(20)) {
    var enemy = EnemyPlatform(
      position: Vector2(_generateNextX(100), _generateNextY()),
    );
    add(enemy);
    _enemies.add(enemy);
    _cleanupEnemies();
  }
}

void _cleanupEnemies() {
  final screenBottom = gameRef.player.position.y +
      (gameRef.size.x / 2) +
      gameRef.screenBufferSpace;

  while (_enemies.isNotEmpty && _enemies.first.position.y > screenBottom) {
    remove(_enemies.first);
    _enemies.removeAt(0);
  }
}                                                                      // ... to here.

ObjectManager mantiene una lista de objetos enemigos, _enemies. El elemento _maybeAddEnemy genera enemigos con un 20 por ciento de probabilidad y agrega el objeto a la lista de enemigos. El método _cleanupEnemies() quita los objetos EnemyPlatform inactivos que ya no son visibles.

a3c16fc17be25f6c.png En ObjectManager, genera plataformas enemigas llamando a _maybeAddEnemy() en el método update:

lib/game/managers/object_manager.dart

@override
void update(double dt) {
  final topOfLowestPlatform =
      _platforms.first.position.y + _tallestPlatformHeight;

  final screenBottom = gameRef.player.position.y +
      (gameRef.size.x / 2) +
      gameRef.screenBufferSpace;
  if (topOfLowestPlatform > screenBottom) {
    var newPlatY = _generateNextY();
    var newPlatX = _generateNextX(100);
    final nextPlat = _semiRandomPlatform(Vector2(newPlatX, newPlatY));
    add(nextPlat);

    _platforms.add(nextPlat);
    gameRef.gameManager.increaseScore();

    _cleanupPlatforms();
    _maybeAddEnemy();                                                 // Add this line
  }

  super.update(dt);
}

a3c16fc17be25f6c.png Haz agregados al método onCollision de Player para verificar si el jugador está colisionando con una EnemyPlatform. De ser así, llama al método onLose().

lib/game/sprites/player.dart

@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollision(intersectionPoints, other);
    if (other is EnemyPlatform) {                           // Add lines from here...
      gameRef.onLose();
      return;
    }                                                                 // ... to here.

    bool isCollidingVertically =
        (intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;

    if (isMovingDown && isCollidingVertically) {
      current = PlayerState.center;
      if (other is NormalPlatform) {
        jump();
        return;
      } else if (other is SpringBoard) {
        jump(specialJumpSpeed: jumpSpeed * 2);
        return;
      } else if (other is BrokenPlatform &&
          other.current == BrokenPlatformState.cracked) {
        jump();
        other.breakPlatform();
        return;
      }
    }
  }

a3c16fc17be25f6c.png Finalmente, modifica el método enableLevelSpecialty de ObjectManager para agregar el nivel 5 a la sentencia switch:

lib/game/managers/object_manager.dart

void enableLevelSpecialty(int level) {
  switch (level) {
    case 1:
      enableSpecialty('spring');
      break;
    case 2:
      enableSpecialty('broken');
      break;
    case 5:                                                  // Add lines from here...
      enableSpecialty('enemy');
      break;                                                           // ... to here.
  }
}

a3c16fc17be25f6c.png Ahora que hiciste que el juego resulte más desafiante, haz una recarga en caliente 7f9a9e103c7b5e5.png para activar los cambios. Guarda los archivos; usa el botón de tu IDE; o, desde la línea de comandos, ingresa r para hacer la recarga en caliente:

Cuídate de los enemigos con forma de carpeta rota. Son engañosos. ¡Se mezclan con el fondo!

¿Tienes problemas?

Si la app no se ejecuta correctamente, comprueba que no haya errores ortográficos. De ser necesario, usa el código que aparece en los siguientes vínculos para volver a empezar.

9. Potenciadores

En este paso, agregarás funciones mejoradas para potenciar a Dash a lo largo del juego. Doodle Dash tiene dos opciones de potenciación: un gorro de empleado de Google nuevo y un cohete. Puedes considerar que estos potenciadores son otro tipo de plataforma especial. A medida que Dash salte durante el juego, su velocidad aumentará cuando colisione con un potenciador de gorro de empleado de Google nuevo o con uno de cohete, y cuando posea uno de ellos.

NooglerHat

Rocket

El gorro de empleado de Google nuevo se genera en el nivel 3, una vez que el jugador alcanza una puntuación >= 40. Cuando Dash colisiona con el gorro, comienza a llevarlo puesto y recibe un aumento de aceleración de 2.5 veces su velocidad normal. Esto dura 5 segundos.

El cohete se genera en el nivel 4, cuando el jugador alcanza una puntuación >= 80. Cuando Dash colisiona con el cohete, su objeto se reemplaza por un cohete y ella recibe un aumento de aceleración de 3.5 veces su velocidad normal hasta que aterriza en una plataforma. Adicionalmente, también será invencible ante enemigos cuando cuente con el potenciador de cohete.

Los objetos de gorro de empleado de Google nuevo y de cohete extienden la clase abstracta PowerUp. Como la clase abstracta Platform, PowerUp, que mostramos a continuación, también incluye el cambio de tamaño y una caja de colisiones.

lib/game/sprites/powerup.dart

abstract class PowerUp extends SpriteComponent
    with HasGameRef<DoodleDash>, CollisionCallbacks {
  final hitbox = RectangleHitbox();
  double get jumpSpeedMultiplier;

  PowerUp({
    super.position,
  }) : super(
          size: Vector2.all(50),
          priority: 2,
        );

  @override
  Future<void>? onLoad() async {
    await super.onLoad();

    await add(hitbox);
  }
}

a3c16fc17be25f6c.png Crea una clase Rocket que extienda la clase abstracta PowerUp. Cuando Dash colisiona con el cohete, recibe un aumento de aceleración de 3.5 veces su velocidad normal.

lib/game/sprites/powerup.dart

class Rocket extends PowerUp {                               // Add lines from here...
  @override
  double get jumpSpeedMultiplier => 3.5;

  Rocket({
    super.position,
  });

  @override
  Future<void>? onLoad() async {
    await super.onLoad();
    sprite = await gameRef.loadSprite('game/rocket_1.png');
    size = Vector2(50, 70);
  }
}                                                                      // ... to here.

a3c16fc17be25f6c.png Crea una clase NooglerHat que extienda la clase abstracta PowerUp. Cuando Dash colisiona con el NooglerHat, recibe un aumento de aceleración de 2.5 veces su velocidad normal. Esta aceleración aumentada dura 5 segundos.

lib/game/sprites/powerup.dart

class NooglerHat extends PowerUp {                          // Add lines from here...
  @override
  double get jumpSpeedMultiplier => 2.5;

  NooglerHat({
    super.position,
  });

  final int activeLengthInMS = 5000;

  @override
  Future<void>? onLoad() async {
    await super.onLoad();
    sprite = await gameRef.loadSprite('game/noogler_hat.png');
    size = Vector2(75, 50);
  }
}                                                                      // ... to here.

Ahora que implementaste los potenciadores NooglerHat y Rocket, actualiza ObjectManager para generarlos en el juego.

a3c16fc17be25f6c.png Modifica la clase ObjectManger para agregar una lista que realice un seguimiento de los potenciadores generados, junto con dos métodos nuevos, _maybePowerup y _cleanupPowerups, para generar y quitar las plataformas potenciadoras nuevas.

lib/game/managers/object_manager.dart

final List<PowerUp> _powerups = [];                          // Add lines from here...

 void _maybeAddPowerup() {
   if (specialPlatforms['noogler'] == true &&
       probGen.generateWithProbability(20)) {
     var nooglerHat = NooglerHat(
       position: Vector2(_generateNextX(75), _generateNextY()),
     );
     add(nooglerHat);
     _powerups.add(nooglerHat);
   } else if (specialPlatforms['rocket'] == true &&
       probGen.generateWithProbability(15)) {
     var rocket = Rocket(
       position: Vector2(_generateNextX(50), _generateNextY()),
     );
     add(rocket);
     _powerups.add(rocket);
   }

   _cleanupPowerups();
 }

 void _cleanupPowerups() {
   final screenBottom = gameRef.player.position.y +
       (gameRef.size.x / 2) +
       gameRef.screenBufferSpace;
   while (_powerups.isNotEmpty && _powerups.first.position.y > screenBottom) {
     if (_powerups.first.parent != null) {
       remove(_powerups.first);
     }
     _powerups.removeAt(0);
   }
 }                                                                     // ... to here.

El método _maybeAddPowerup genera un gorro de empleado de Google nuevo el 20% de las veces y un cohete el 15%. El método _cleanupPowerups se llama para quitar los potenciadores que estén debajo de los límites inferiores de la pantalla.

a3c16fc17be25f6c.png Modifica el método update de ObjectManager para llamar a _maybePowerup en cada marca del bucle de juego.

lib/game/managers/object_manager.dart

@override
  void update(double dt) {
    final topOfLowestPlatform =
        _platforms.first.position.y + _tallestPlatformHeight;

    final screenBottom = gameRef.player.position.y +
        (gameRef.size.x / 2) +
        gameRef.screenBufferSpace;

    if (topOfLowestPlatform > screenBottom) {
      var newPlatY = _generateNextY();
      var newPlatX = _generateNextX(100);
      final nextPlat = _semiRandomPlatform(Vector2(newPlatX, newPlatY));
      add(nextPlat);

      _platforms.add(nextPlat);

      gameRef.gameManager.increaseScore();

      _cleanupPlatforms();
      _maybeAddEnemy();
      _maybeAddPowerup();                                            // Add this line
    }

    super.update(dt);
  }

a3c16fc17be25f6c.png Modifica el método enableLevelSpecialty para agregar dos casos nuevos en la sentencia switch: uno para habilitar NooglerHat en el nivel 3 y otro para habilitar Rocket en el nivel 4:

lib/game/managers/object_manager.dart

void enableLevelSpecialty(int level) {
    switch (level) {
      case 1:
        enableSpecialty('spring');
        break;
      case 2:
        enableSpecialty('broken');
        break;
      case 3:                                               // Add lines from here...
        enableSpecialty('noogler');
        break;
      case 4:
        enableSpecialty('rocket');
        break;                                                        // ... to here.
      case 5:
        enableSpecialty('enemy');
        break;
    }
  }

a3c16fc17be25f6c.png Agrega los siguientes métodos get booleanos a la clase Player. Si Dash tiene un potenciador activo, esto se puede representar con varios estados distintos. Estos métodos get facilitan la verificación del potenciador activo.

lib/game/sprites/player.dart

 bool get hasPowerup =>                                      // Add lines from here...
     current == PlayerState.rocket ||
     current == PlayerState.nooglerLeft ||
     current == PlayerState.nooglerRight ||
     current == PlayerState.nooglerCenter;

 bool get isInvincible => current == PlayerState.rocket;

 bool get isWearingHat =>
     current == PlayerState.nooglerLeft ||
     current == PlayerState.nooglerRight ||
     current == PlayerState.nooglerCenter;                             // ... to here.

a3c16fc17be25f6c.png Modifica el método onCollision de Player para reaccionar a una colisión con NooglerHat o Rocket. Con este código, también te aseguras de que Dash solo active un potenciador nuevo si no tiene ya uno activo.

lib/game/sprites/player.dart

@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollision(intersectionPoints, other);
    if (other is EnemyPlatform && !isInvincible) {
      gameRef.onLose();
      return;
    }

    bool isCollidingVertically =
        (intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;

    if (isMovingDown && isCollidingVertically) {
      current = PlayerState.center;
      if (other is NormalPlatform) {
        jump();
        return;
      } else if (other is SpringBoard) {
        jump(specialJumpSpeed: jumpSpeed * 2);
        return;
      } else if (other is BrokenPlatform &&
          other.current == BrokenPlatformState.cracked) {
        jump();
        other.breakPlatform();
        return;
      }
    }

    if (!hasPowerup && other is Rocket) {                    // Add lines from here...
      current = PlayerState.rocket;
      other.removeFromParent();
      jump(specialJumpSpeed: jumpSpeed * other.jumpSpeedMultiplier);
      return;
    } else if (!hasPowerup && other is NooglerHat) {
      if (current == PlayerState.center) current = PlayerState.nooglerCenter;
      if (current == PlayerState.left) current = PlayerState.nooglerLeft;
      if (current == PlayerState.right) current = PlayerState.nooglerRight;
      other.removeFromParent();
      _removePowerupAfterTime(other.activeLengthInMS);
      jump(specialJumpSpeed: jumpSpeed * other.jumpSpeedMultiplier);
      return;
    }                                                                  // ... to here.
  }

Si Dash colisiona con un cohete, el PlayerState cambia a Rocket y le permite a Dash saltar con un jumpSpeedMultiplier de 3.5 veces.

Si Dash colisiona con un gorro de empleado de Google nuevo, según la dirección actual de PlayerState (.center, .left o .right), el PlayerState cambia al PlayerState del gorro correspondiente y Dash comienza a llevar el gorro y recibe un jumpSpeedMultiplier de 2.5 veces. El método _removePowerupAfterTime quita el potenciador al cabo de 5 segundos y cambia el PlayerState de los estados del potenciador de nuevo a center.

La llamada a other.removeFromParent quita de la pantalla las plataformas de objeto de cohete o de gorro de empleado de Google nuevo para mostrar que Dash adquirió el potenciador.

ede04fdfe074f471.gif

a3c16fc17be25f6c.png Modifica los métodos moveLeft y moveRight de la clase Player para incluir el objeto NooglerHat. No necesitas tener en cuenta el potenciador Rocket, ya que ese objeto apunta en la misma dirección independientemente de la dirección de movimiento.

lib/game/sprites/player.dart

 void moveLeft() {
   _hAxisInput = 0;
   if (isWearingHat) {                                       // Add lines from here...
     current = PlayerState.nooglerLeft;
   } else if (!hasPowerup) {                                           // ... to here.
     current = PlayerState.left;
   }                                                                  // Add this line
   _hAxisInput += movingLeftInput;
 }

 void moveRight() {
   _hAxisInput = 0;
   if (isWearingHat) {                                       // Add lines from here...
     current = PlayerState.nooglerRight;
   } else if (!hasPowerup) {                                            //... to here.
     current = PlayerState.right;
   }                                                                  // Add this line
   _hAxisInput += movingRightInput;
 }

Dash es invencible frente a enemigos cuando tiene el potenciador Rocket, de modo que no debes finalizar el juego durante este período.

a3c16fc17be25f6c.png Modifica la devolución de llamada a onCollision para verificar si Dash cumple con isInvincible antes de activar un fin del juego tras colisionar con una EnemyPlatform:

lib/game/sprites/player.dart

   if (other is EnemyPlatform && !isInvincible) {                 // Modify this line
     gameRef.onLose();
     return;
   }

a3c16fc17be25f6c.png Reinicia la app y juega para ver los potenciadores en acción.

e1fece51429dae55.gif

¿Tienes problemas?

Si la app no se ejecuta correctamente, comprueba que no haya errores ortográficos. De ser necesario, usa el código que aparece en los siguientes vínculos para volver a empezar.

10. Superposiciones

Un juego de Flame se puede unir en un widget, lo que facilita su integración junto con otros widgets en una app de Flutter. También puedes mostrar los widgets de Flutter como superposiciones sobre tu juego de Flame. Esto resulta conveniente para los componentes que no son del juego y que no dependen del bucle de juego, como los menús, la pantalla de pausa, los botones y los controles deslizantes.

La pantalla de la puntuación que se ve en el juego y todos los menús de Doodle Dash son widgets normales de Flutter, no componentes de Flame. Todo el código de los widgets se ubica en lib/game/widgets, por ejemplo, el menú de Fin del juego es una columna que contiene otros widgets como Text y ElevatedButton, como se muestra en el siguiente código:

lib/game/widgets/game_over_overlay.dart

class GameOverOverlay extends StatelessWidget {
 const GameOverOverlay(this.game, {super.key});

 final Game game;

 @override
 Widget build(BuildContext context) {
   return Material(
     color: Theme.of(context).colorScheme.background,
     child: Center(
       child: Padding(
         padding: const EdgeInsets.all(48.0),
         child: Column(
           mainAxisAlignment: MainAxisAlignment.center,
           crossAxisAlignment: CrossAxisAlignment.center,
           children: [
             Text(
               'Game Over',
               style: Theme.of(context).textTheme.displayMedium!.copyWith(),
             ),
             const WhiteSpace(height: 50),
             ScoreDisplay(
               game: game,
               isLight: true,
             ),
             const WhiteSpace(
               height: 50,
             ),
             ElevatedButton(
               onPressed: () {
                 (game as DoodleDash).resetGame();
               },
               style: ButtonStyle(
                 minimumSize: MaterialStateProperty.all(
                   const Size(200, 75),
                 ),
                 textStyle: MaterialStateProperty.all(
                     Theme.of(context).textTheme.titleLarge),
               ),
               child: const Text('Play Again'),
             ),
           ],
         ),
       ),
     ),
   );
 }
}

Para usar un widget como una superposición en un juego de Flame, define una propiedad overlayBuilderMap en GameWidget con una key que represente la superposición (como una String) y el value de una función de widgets que muestre un widget, como se ve en el siguiente código:

lib/main.dart

GameWidget(
  game: game,
  overlayBuilderMap: <String, Widget Function(BuildContext, Game)>{
    'gameOverlay': (context, game) => GameOverlay(game),
    'mainMenuOverlay': (context, game) => MainMenuOverlay(game),
    'gameOverOverlay': (context, game) => GameOverOverlay(game),
  },
)

Una vez que se agrega, se puede usar la superposición en cualquier parte del juego. Muestra una superposición usando overlays.add y ocúltala usando overlays.remove, como se muestra en el siguiente código:

lib/game/doodle_dash.dart

void resetGame() {
   startGame();
   overlays.remove('gameOverOverlay');
 }

 void onLose() {
   gameManager.state = GameState.gameOver;
   player.removeFromParent();
   overlays.add('gameOverOverlay');
 }

11. Compatibilidad con dispositivos móviles

Doodle Dash se compila en Flutter y Flame, de modo que ya se ejecuta en todas las plataformas compatibles con Flutter. Sin embargo, hasta el momento, Doodle Dash solo es compatible con la entrada basada en teclados. Para dispositivos que no cuenten con un teclado, como los teléfonos celulares, podremos agregar fácilmente los botones de controles de tacto en pantalla a la superposición.

a3c16fc17be25f6c.png Agrega una variable de estado booleano a la GameOverlay que determina si el juego se ejecuta en una plataforma móvil:

lib/game/widgets/game_overlay.dart

class GameOverlayState extends State<GameOverlay> {
 bool isPaused = false;

                                                                      // Add this line
 final bool isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS);

 @override
 Widget build(BuildContext context) {
   ...
 }
}

Ahora, muestra botones direccionales de izquierda y derecha en la superposición cuando el juego se ejecute en dispositivos móviles. De manera similar a la lógica de los "eventos de teclas" del paso 4, presionar el botón izquierdo mueve a Dash en esa dirección. Presionar el botón derecho la moverá en esa dirección también.

a3c16fc17be25f6c.png En el método build de GameOverlay, agrega una sección isMobile que siga el mismo comportamiento descrito en el paso 4: presionar el botón izquierdo invoca a moveLeft y presionar el derecho invoca a moveRight. Dejar de presionar cualquiera de los dos botones llama a resetDirection y hace que Dash quede horizontalmente inactiva.

lib/game/widgets/game_overlay.dart

@override
 Widget build(BuildContext context) {
   return Material(
     color: Colors.transparent,
     child: Stack(
       children: [
         Positioned(... child: ScoreDisplay(...)),
         Positioned(... child: ElevatedButton(...)),
         if (isMobile)                                       // Add lines from here...
           Positioned(
             bottom: MediaQuery.of(context).size.height / 4,
             child: SizedBox(
               width: MediaQuery.of(context).size.width,
               child: Row(
                 mainAxisAlignment: MainAxisAlignment.spaceBetween,
                 children: [
                   Padding(
                     padding: const EdgeInsets.only(left: 24),
                     child: GestureDetector(
                       onTapDown: (details) {
                         (widget.game as DoodleDash).player.moveLeft();
                       },
                       onTapUp: (details) {
                         (widget.game as DoodleDash).player.resetDirection();
                       },
                       child: Material(
                         color: Colors.transparent,
                         elevation: 3.0,
                         shadowColor: Theme.of(context).colorScheme.background,
                         child: const Icon(Icons.arrow_left, size: 64),
                       ),
                     ),
                   ),
                   Padding(
                     padding: const EdgeInsets.only(right: 24),
                     child: GestureDetector(
                       onTapDown: (details) {
                         (widget.game as DoodleDash).player.moveRight();
                       },
                       onTapUp: (details) {
                         (widget.game as DoodleDash).player.resetDirection();
                       },
                       child: Material(
                         color: Colors.transparent,
                         elevation: 3.0,
                         shadowColor: Theme.of(context).colorScheme.background,
                         child: const Icon(Icons.arrow_right, size: 64),
                       ),
                     ),
                   ),
                 ],
               ),
             ),
           ),                                                          // ... to here.
         if (isPaused)
           ...
       ],
     ),
   );
 }

Eso es todo. Ahora, la app de Doodle Dash detecta automáticamente en qué tipo de plataforma se está ejecutando y cambia las entradas en consecuencia.

a3c16fc17be25f6c.png Ejecuta la app en iOS o Android para ver los botones direccionales en acción.

7b0cac5fb69bc89.gif

¿Tienes problemas?

Si la app no se ejecuta correctamente, comprueba que no haya errores ortográficos. De ser necesario, usa el código que aparece en los siguientes vínculos para volver a empezar.

12. Próximos pasos

¡Felicitaciones!

Completaste este codelab y aprendiste a compilar un juego en Flutter usando el motor de juego de Flame.

Temas abordados:

  • Cómo usar el paquete de Flame para crear un juego de plataformas, que incluye lo siguiente:
  • Cómo agregar un personaje
  • Cómo agregar varios tipos de plataformas
  • Cómo implementar la detección de colisiones
  • Cómo agregar un componente gravitatorio
  • Cómo definir el movimiento de la cámara
  • Cómo crear enemigos
  • Cómo crear potenciadores
  • Cómo detectar la plataforma en la que se ejecuta el juego
  • Cómo usar la información anterior para cambiar entre controles de entrada por teclado o táctil

Recursos

Esperamos que hayas aprendido más sobre la creación de juegos en Flutter.

Los siguientes recursos también pueden resultarte útiles e incluso inspiradores: