Como criar um jogo com o Flutter e o Flame

1. Introdução

Aprenda a criar um jogo de plataforma com o Flutter e o Flame. No jogo Doodle Dash, inspirado em Doodle Jump, você joga como Dash (mascote do Flutter) ou o melhor amigo dela, Sparky (mascote do Firebase), e tenta chegar o mais alto possível pulando em plataformas.

O que você vai aprender

  • Como construir um jogo multiplataforma no Flutter.
  • Como criar componentes de jogo reutilizáveis que podem ser renderizados e atualizados como parte do loop de jogo do Flame.
  • Como controlar e animar os movimentos do seu personagem (chamados de sprite) com a física do jogo.
  • Como adicionar e gerenciar a detecção de colisão.
  • Como adicionar entrada de teclado e por toque como controles para o jogo.

Pré-requisitos

Este codelab presume que você tem alguma experiência com o Flutter. Caso contrário, você pode aprender as noções básicas no codelab Seu primeiro app do Flutter.

O que você vai criar

Este codelab orienta você sobre a criação de um jogo chamado Doodle Dash: um jogo de plataforma com a Dash, mascote do Flutter, ou o Sparky, mascote do Firebase. Este codelab foca na Dash, mas as etapas também se aplicam ao Sparky. O jogo terá os recursos abaixo:

  • Um sprite que pode se mover na horizontal e vertical.
  • Plataformas geradas aleatoriamente.
  • Um efeito de gravidade que puxa o sprite para baixo.
  • Menus do jogo.
  • Controles no jogo, como para pausar e jogar de novo.
  • Funcionalidade para salvar a pontuação.

Jogabilidade

Para jogar Doodle Dash, é preciso mover a Dash para a esquerda e para a direita, pulando em plataformas e usando poderes para aumentar as habilidades dela ao longo do jogo. Inicie o jogo escolhendo o nível de dificuldade (1 a 5) e clicando em Start (iniciar).

d1e75aa0e05c526.gif

Níveis

Há cinco níveis no jogo. Cada nível (após o nível 1) desbloqueia novos recursos.

  • Nível 1 (padrão): este nível usa as plataformas NormalPlatform e SpringBoard. Quando criada, toda plataforma tem 20% de chance de ser móvel.
  • Nível 2 (pontuação >= 20): adiciona a BrokenPlatform, que só pode ser tocada uma vez.
  • Nível 3 (pontuação >= 40): desbloqueia o poder NooglerHat. Essa plataforma especial dura 5 segundos e aumenta a capacidade de salto de Dash em 2,5x da velocidade normal. Ela também usa um chapéu de Noogler legal durante esses 5 segundos.
  • Nível 4 (pontuação >=80): desbloqueia o poder Rocket. Essa plataforma especial, representada por um foguete, torna Dash invencível. Também aumenta a capacidade de salto de Dash em 3,5x da velocidade normal.
  • Nível 5 (pontuação >= 100): desbloqueia plataformas Enemy. Se a Dash encostar em um inimigo, o jogo vai terminar automaticamente.

Tipos de plataforma por nível

Nível 1 (padrão)

NormalPlatform

SpringBoard

Nível 2 (pontuação >= 20)

Nível 3 (pontuação >= 40)

Nível 4 (pontuação >= 80)

Nível 5 (pontuação >= 100)

BrokenPlatform

NooglerHat

Rocket

Enemy

Perder o jogo

Há duas maneiras de perder o jogo:

  • Dash cai pela parte de baixo da tela.
  • Dash encosta em um inimigo (inimigos aparecem no nível 5).

Poderes

Os poderes melhoram as habilidades de jogo da personagem, como aumentar a velocidade de salto, permitir que ela se torne "invencível" contra inimigos ou ambos. O Doodle Dash tem duas opções de poder. Somente um poder fica ativo por vez.

  • O poder do chapéu de Noogler aumenta a capacidade de salto de Dash em 2,5x da altura de salto normal. Além disso, ela usa um chapéu de Noogler quando está com esse poder.
  • O poder de foguete torna Dash invencível contra inimigos, ou seja, a colisão com um inimigo não tem efeito. Também aumenta em 3,5x a altura do salto. Ela voa em um foguete até que o combustível acaba e ela cai em uma plataforma.

2. Fazer o download do código inicial do codelab

a3c16fc17be25f6c.pngFaça o download da versão inicial do seu projeto no GitHub:

  1. Na linha de comando, clone o repositório do GitHub (link em inglês) em um diretório flutter-codelabs:
git clone https://github.com/flutter/codelabs.git flutter-codelabs

O código para este codelab está no diretório flutter-codelabs/flame-building-doodle-dash. O diretório contém o código do projeto concluído para cada etapa do codelab.

a3c16fc17be25f6c.pngImportar o app inicial

  • Importe o diretório flutter-codelabs/flame-building-doodle-dash/step_02 no ambiente de desenvolvimento integrado de sua preferência.

a3c16fc17be25f6c.pngInstalar pacotes:

  • Todos os pacotes necessários, como o Flame, já foram adicionados ao arquivo pubspec.yaml do projeto. Se o ambiente de desenvolvimento integrado não instalar dependências automaticamente, abra um terminal de linha de comando e, na raiz do projeto Flutter, execute o comando abaixo para extrair as dependências do projeto:
flutter pub get

Configurar o ambiente de desenvolvimento do Flutter

Para concluir este codelab, você precisa do seguinte:

3. Tour pelo código

Em seguida, faça um tour pelo código.

Consulte o arquivo lib/game/doodle_dash.dart, que contém o jogo DoodleDash que estende o FlameGame. Você registra seus componentes com a instância FlameGame, o componente mais básico do Flame (semelhante a um Scaffold do Flutter), e ele renderiza e atualiza todos os componentes registrados durante o jogo. Pense nisso como o sistema nervoso central do jogo.

O que são componentes? Da mesma forma que um app do Flutter é composto de Widgets, um FlameGame é constituído por Components: todos os elementos básicos que compõem o jogo. Assim como os widgets do Flutter, os componentes também podem ter filhos. O sprite de um personagem, o segundo plano do jogo e o objeto responsável por gerar novos componentes (como os inimigos) são exemplos de componentes. Na verdade, o FlameGame em si é um Component. O Flame chama isso de Flame Component System.

Os componentes herdam de uma classe Component (link em inglês) abstrata. Implemente os métodos abstratos do Component para criar a mecânica da classe FlameGame. Por exemplo, você vai encontrar os métodos abaixo implementados com frequência no DoodleDash:

  • onLoad: inicializa um componente de forma assíncrona, de modo semelhante ao método initState do Flutter.
  • update: atualiza um componente a cada marcação do loop do jogo, de modo semelhante ao método build do Flutter.

Além disso, o método add registra componentes com o mecanismo do Flame.

Por exemplo, o arquivo lib/game/world.dart contém a classe World, que estende ParallaxComponent (link em inglês) para renderizar o segundo plano do jogo. Essa classe recebe uma lista de recursos de imagem e os renderiza em camadas, fazendo com que cada camada se mova em uma velocidade diferente para torná-la mais realista. A classe DoodleDash contém uma instância ParallaxComponent e a adiciona ao jogo no método onLoad do DoodleDash:

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

Gerenciamento do estado

O diretório lib/game/managers contém arquivos que lidam com o gerenciamento do estado do Doodle Dash: game_manager.dart, object_manager.dart e level_manager.dart.

A classe GameManager (em game_manager.dart) monitora o estado geral do jogo e a pontuação.

A classe ObjectManager (em object_manager.dart) gerencia onde e quando as plataformas são criadas e removidas. Você vai adicionar elementos a essa classe mais tarde.

E, por fim, a classe LevelManager (em level_manager.dart) gerencia o nível de dificuldade do jogo e qualquer configuração relevante para quando os jogadores avançam de nível. O jogo oferece cinco níveis de dificuldade: o jogador passa para o próximo nível ao atingir um dos marcos de pontuação. Cada avanço de nível aumenta a dificuldade e o quanto a Dash precisa saltar. Como a gravidade é constante ao longo do jogo, a velocidade do salto aumenta gradualmente para compensar a distância maior.

A pontuação aumenta sempre que o jogador passa por uma plataforma. Quando o jogador atinge certos marcos de pontuação, o jogo avança de nível e desbloqueia novas plataformas especiais que tornam o jogo mais divertido e desafiador.

4. Adicionar um jogador

Esta etapa adiciona um personagem ao jogo, neste caso, a Dash (link em inglês). O jogador controla a personagem, e toda a lógica reside na classe Player(no arquivo player.dart). A classe Player estende a classe SpriteGroupComponent do Flame, que contém métodos abstratos que você substitui para implementar a lógica personalizada. Isso inclui carregar recursos e sprites, posicionar o jogador na horizontal e na vertical, configurar a detecção de colisão e aceitar a entrada do usuário.

Carregar recursos

A Dash aparece com sprites variados, que representam diferentes versões do personagem e poderes. Por exemplo, os ícones abaixo mostram a Dash e o Sparky olhando para a esquerda, para a tela, e para a direita.

O SpriteGroupComponent (link em inglês) do Flame permite gerenciar vários estados de sprite com a propriedade sprites, como você pode conferir no método _loadCharacterSprites.

a3c16fc17be25f6c.pngNa classe Player, adicione as linhas abaixo ao método onLoad para carregar os recursos de sprite e definir o estado do sprite Player para olhar para frente:

lib/game/sprites/player.dart

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

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

Confira o código para carregar os sprites e recursos em _loadCharacterSprites. Esse código poderia ser implementado diretamente no método onLoad, mas colocá-lo em um método separado organiza o código-fonte e o torna mais legível. Esse método atribui um mapa à propriedade sprites, que associa cada estado de caractere a um recurso de sprite carregado, conforme mostrado abaixo:

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

Atualizar o componente do jogador

O Flame chama o método update de um componente uma vez a cada marcação (ou frame) do loop de eventos para renderizar de novo cada componente do jogo que foi alterado, de modo semelhante ao método build do Flutter. Em seguida, adicione lógica ao método update da classe Player para posicionar a personagem na tela.

a3c16fc17be25f6c.pngAdicione o código abaixo ao método update da classe Player para calcular a velocidade e a posição atuais do personagem:

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 o jogador, o método update verifica se o jogo não está em um estado não jogável em que o jogador não pode se mover, como quando o jogo é carregado pela primeira vez ou no estado de fim do jogo.

Se o estado for jogável, a posição da Dash será calculada usando a equação new_position = current_position + (velocity * time-elapsed-since-last-game-loop-tick), ou, como mostrado no código:

 position += _velocity * dt

Outro aspecto importante ao criar o Doodle Dash é incluir limites laterais infinitos. Assim, a Dash poderá saltar para fora da borda esquerda da tela e voltar pela direita e vice-versa.

7068325e8b2f35fc.gif

Para implementar isso, verifique se a posição da Dash saiu do limite da borda esquerda ou direita da tela e, em caso afirmativo, faça ela aparecer na borda oposta.

Eventos principais

Inicialmente, o Doodle Dash é executado na Web e no computador, por isso precisa oferecer suporte à entrada do teclado para que os jogadores possam controlar o movimento da personagem. O método onKeyEvent permite que o componente Player reconheça pressionamentos de tecla de seta para determinar se a Dash vai olhar e se mover para a esquerda ou direita.

Dash olha para a esquerda ao se mover nessa direção

Dash olha para a direita ao se mover nessa direção

Em seguida, implemente os movimentos horizontais da Dash, conforme definido na variável _hAxisInput. Você também vai fazer com que a Dash vire para a direção em que ela está se movendo.

a3c16fc17be25f6c.pngModifique os métodos moveLeft e moveRight da classe Player para definir a direção atual da 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.pngModifique o método onKeyEvent da classe Player para chamar os métodos moveLeft ou moveRight, respectivamente, quando as teclas de seta para a esquerda ou para a direita forem pressionadas:

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

Agora que a classe Player está funcional, pode ser usada no Doodle Dash.

a3c16fc17be25f6c.pngNo arquivo DoodleDash, importe sprites.dart, que disponibiliza a classe Player:

lib/game/doodle_dash.dart

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

a3c16fc17be25f6c.pngCrie uma instância Player na classe 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 Em seguida, inicialize e configure a velocidade de salto do Player com base no nível de dificuldade selecionado pelo jogador e adicione o componente Player ao FlameGame. Preencha o método setCharacter com este 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.pngChame o método setCharacter no início de initializeGameStart.

lib/game/doodle_dash.dart

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

    ...
}

a3c16fc17be25f6c.pngAlém disso, em initializeGameStart, chame resetPosition para que o jogador volte à posição inicial sempre que um jogo novo começar.

lib/game/doodle_dash.dart

void initializeGameStart() {
    ...

    levelManager.reset();

    player.resetPosition();                                           // Add this line

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

    ...
  }

a3c16fc17be25f6c.png Execute o aplicativo. Inicie um jogo, e a Dash aparecerá na tela.

ed15a9c6762595c9.png

Problemas?

Caso o app não esteja executando corretamente, verifique se há erros de digitação. Se necessário, use o código nos links abaixo para colocar tudo de volta nos eixos (links em inglês).

5. Adicionar plataformas

Esta etapa adiciona plataformas (para a Dash pousar e saltar) e a lógica de detecção de colisão para determinar quando a Dash precisa saltar.

Primeiro, confira a classe abstrata 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);
  }
}

O que é uma hitbox?

Cada componente da plataforma introduzido no Doodle Dash estende a classe abstrata Platform<T>, que é um SpriteComponent com uma hitbox. Com a hitbox, um componente de sprite pode detectar quando colide contra outros objetos com hitboxes. O Flame oferece suporte a várias formas de hitbox (link em inglês), como retângulos, círculos e polígonos. Por exemplo, o Doodle Dash usa uma hitbox retangular para plataformas e uma hitbox circular para a Dash. O Flame lida com o cálculo da colisão.

A classe Platform (plataforma) adiciona uma hitbox e callbacks de colisão a todos os subtipos.

Adicionar uma plataforma padrão

A classe Platform adiciona plataformas ao jogo. Uma plataforma normal é representada por um dos quatro elementos visuais escolhidos aleatoriamente: monitor, smartphone, terminal ou laptop. A escolha do elemento visual não afeta o comportamento da plataforma.

NormalPlatform

a3c16fc17be25f6c.pngPara ter uma plataforma normal e estática, adicione um tipo enumerado NormalPlatformState e uma classe NormalPlatform (plataforma normal):

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.

Em seguida, crie plataformas com que a personagem possa interagir.

A classe ObjectManager estende a classe Component do Flame e gera objetos Platform ao longo do jogo. Implemente a capacidade de gerar plataformas nos métodos update e onMount do ObjectManager.

a3c16fc17be25f6c.pngGere plataformas na classe ObjectManager criando um novo método com o nome _semiRandomPlatform. Você vai atualizar esse método mais tarde para retornar diferentes tipos de plataformas, mas, por enquanto, apenas retorne uma NormalPlatform:

lib/game/managers/object_manager.dart

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

a3c16fc17be25f6c.pngSubstitua o método update do ObjectManager e use o método _semiRandomPlatform para gerar uma plataforma e adicioná-la ao jogo:

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.

Faça o mesmo no método onMount do ObjectManager para que, quando o jogo for executado pela primeira vez, o método _semiRandomPlatform gere uma plataforma inicial e a adicione ao jogo.

a3c16fc17be25f6c.pngAdicione o método onMount com o código abaixo:

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 exemplo, conforme mostrado no código abaixo, o método configure permite que o jogo Doodle Dash reconfigure a distância mínima e máxima entre as plataformas e possibilita plataformas especiais quando o nível de dificuldade 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);
    }
  }

A instância DoodleDash (no método initializeGameStart) cria um ObjectManager que é inicializado, configurado com base no nível de dificuldade e adicionado ao jogo do 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);
  }

O ObjectManager aparece de novo no método checkLevelUp. Quando o jogador avança de nível, o ObjectManager reconfigura os parâmetros de geração de plataforma com base no nível de dificuldade.

lib/game/doodle_dash.dart

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

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

a3c16fc17be25f6c.png Ative as mudanças fazendo a recarga automática 7f9a9e103c7b5e5.png (ou reinicialização se estiver testando na Web). Para isso, salve o arquivo e use o botão no ambiente de desenvolvimento integrado ou, na linha de comando, digite r. Inicie um jogo. A Dash e algumas plataformas vão aparecer na tela:

7c6a6c6e630c42ce.png

Problemas?

Caso o app não esteja executando corretamente, verifique se há erros de digitação. Se necessário, use o código nos links abaixo para colocar tudo de volta nos eixos (links em inglês).

6. Jogabilidade principal

Agora que você implementou os widgets individuais Player e Platform, pode começar a juntar tudo. Esta etapa implementa a funcionalidade principal, a detecção de colisão e o movimento da câmera.

Gravidade

Para tornar o jogo mais realista, Dash precisa ser puxada para baixo pela gravidade enquanto salta. Em nossa versão do Doodle Dash, a gravidade permanece constante e positiva, sempre puxando a Dash para baixo. No futuro, porém, você pode querer alterar a gravidade para criar outros efeitos.

a3c16fc17be25f6c.png Na classe Player, adicione uma propriedade _gravity com o valor 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.pngModifique o método update da classe Player para adicionar a variável _gravity para alterar a velocidade vertical da 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);
 }

Detecção de colisão

O Flame oferece suporte à detecção de colisão (link em inglês) por padrão. Para ativá-la no jogo do Flame, adicione o mixin HasCollisionDetection. Na classe DoodleDash, é possível observar que esse mixin já foi adicionado:

lib/game/doodle_dash.dart

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

Em seguida, adicione a detecção de colisão a componentes individuais do jogo usando o mixin CollisionCallbacks, que dá a um componente acesso ao callback onCollision. Uma colisão de dois objetos com hitboxes aciona o callback onCollision e transmite uma referência ao objeto com que está colidindo, para que você implemente a lógica de como o objeto vai reagir.

Lembre-se da etapa anterior, em que a classe abstrata Platform já tem o mixin CollisionCallbacks e uma hitbox. A classe Player também tem o mixin CollisionCallbacks, então você só precisa adicionar uma CircleHitbox à classe Player. A hitbox da Dash é, na verdade, um círculo, já que a Dash é mais redonda do que retangular.

a3c16fc17be25f6c.png Na classe Player, importe sprites.dart para que ela tenha acesso às várias classes Platform:

lib/game/sprites/player.dart

import 'sprites.dart';

a3c16fc17be25f6c.png Adicione uma CircleHitbox ao método onLoad da classe 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;
}

A Dash precisa de um método de salto para poder pular quando pisar em uma plataforma.

a3c16fc17be25f6c.png Adicione um método jump que usa uma specialJumpSpeed opcional:

lib/game/sprites/player.dart

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

a3c16fc17be25f6c.pngSubstitua o método onCollision do Player adicionando este 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;
     }
   }
 }

Esse callback chama o método jump da Dash sempre que ela cai em cima de uma NormalPlatform (plataforma normal). A instrução isMovingDown && isCollidingVertically faz com que a Dash se mova para cima pelas plataformas sem acionar um salto.

Movimento da câmera

A câmera precisa seguir a Dash enquanto ela se move para cima no jogo, mas deve permanecer estática quando a Dash cai.

No Flame, se o "mundo" for maior que a tela, use os worldBounds (link em inglês) da câmera para adicionar limites que informam ao Flame qual parte do mundo será mostrada. Para dar a impressão de que a câmera está se movendo para cima enquanto permanece fixa na horizontal, ajuste os limites de cima e de baixo do mundo em cada atualização com base na posição do jogador, mas mantenha os limites esquerdo e direito iguais.

a3c16fc17be25f6c.pngNa classe DoodleDash, adicione o código abaixo ao método update para que a câmera siga a Dash durante o jogo:

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

Em seguida, a posição do Player e os limites da câmera precisam ser redefinidos para a origem sempre que houver um reinício do jogo.

a3c16fc17be25f6c.pngAdicione o código abaixo ao 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();
    ...
  }

Aumentar a velocidade do salto ao avançar de nível

Como último elemento da jogabilidade principal, é necessário que a velocidade de salto da Dash aumente sempre que o nível de dificuldade subir. Além disso, as plataformas precisam ser geradas a distâncias maiores umas das outras.

a3c16fc17be25f6c.pngAdicione uma chamada ao método setJumpSpeed e forneça a velocidade de salto associada ao nível atual:

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 Ative as mudanças fazendo a recarga automática 7f9a9e103c7b5e5.png (ou a reinicialização na Web). Para isso, salve o arquivo e use o botão no ambiente de desenvolvimento integrado ou, digite r na linha de código.

2bc7c856064d74ca.gif

Problemas?

Caso o app não esteja executando corretamente, verifique se há erros de digitação. Se necessário, use o código nos links abaixo para colocar tudo de volta nos eixos (links em inglês).

7. Mais sobre plataformas

Agora que o ObjectManager gera plataformas para a Dash saltar, você pode oferecer a ela algumas plataformas especiais interessantes.

Em seguida, adicione as classes BrokenPlatform (plataforma quebrada) e SpringBoard (plataforma trampolim). Como os nomes sugerem, uma BrokenPlatform quebra após um salto e uma SpringBoard oferece um trampolim para a Dash saltar mais alto e mais rápido.

BrokenPlatform

SpringBoard

Assim como a classe Player, cada uma dessas classes de plataforma depende de enums para representar o estado atual.

lib/game/sprites/platform.dart

enum BrokenPlatformState { cracked, broken }

Uma mudança no estado current de uma plataforma também muda o sprite mostrado no jogo. Defina o mapeamento entre o tipo enumerado State e os recursos de imagem na propriedade sprites para correlacionar qual sprite é atribuído a cada estado.

a3c16fc17be25f6c.pngAdicione um tipo enumerado BrokenPlatformState e a classe 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.pngAdicione um tipo enumerado SpringState e a classe 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.

Em seguida, ative essas plataformas especiais no ObjectManager. Por serem especiais, elas não devem aparecer no jogo o tempo todo, então precisam ser geradas condicionalmente com base em probabilidades: 15% para SpringBoard e 10% para BrokenPlatform.

a3c16fc17be25f6c.pngNo ObjectManager, dentro do método _semiRandomPlatform, antes da instrução que retorna uma NormalPlatform, adicione o código abaixo para retornar condicionalmente uma 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 da diversão de jogar é desbloquear novos desafios e recursos conforme avança de nível.

A plataforma trampolim vai estar presente desde o início no nível 1, mas assim que a Dash atingir o nível 2, a BrokenPlatform vai começar a aparecer, tornando o jogo um pouco mais difícil.

a3c16fc17be25f6c.pngNa classe ObjectManager, modifique o método enableLevelSpecialty, que é um stub no momento, adicionando uma instrução switch que ativa as plataformas SpringBoard para o nível 1 e BrokenPlatform para o nível 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.pngEm seguida, faça com que as plataformas possam se mover para frente e para trás horizontalmente. Na classe abstrata Platform**,** adicione o método _move abaixo:

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

Se a plataforma estiver se movendo, quando ela chegar à borda da tela do jogo, mudará de direção. Assim como a Dash, a posição da plataforma é determinada ao multiplicar a _direction pela speed da plataforma para descobrir a velocidade. Depois, multiplique a velocidade pelo time-elapsed e adicione a distância resultante à position atual da plataforma.

a3c16fc17be25f6c.pngSubstitua o método update da classe Platform para chamar o método _move:

lib/game/sprites/platform.dart

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

a3c16fc17be25f6c.pngPara acionar o movimento da Platform, no método onLoad, defina o booleano isMoving para ser aleatoriamente true (em movimento) com 20% de probabilidade.

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.pngPor fim, no Player, modifique o método onCollision da classe Player para reconhecer uma colisão com um Springboard ou uma BrokenPlatform. Uma SpringBoard chama jump com um multiplicador de velocidade de 2x e a BrokenPlatform só chama jump caso o estado seja .cracked (trincada), em vez de .broken (quebrada após um salto):

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 Reinicie o app. Inicie um jogo para conferir as plataformas em movimento, SpringBoard e BrokenPlatform.

d4949925e897f665.gif

Problemas?

Caso o app não esteja executando corretamente, verifique se há erros de digitação. Se necessário, use o código nos links abaixo para colocar tudo de volta nos eixos (links em inglês).

8. Perder o jogo

Esta etapa adiciona condições de derrota ao jogo Doodle Dash. O jogador pode perder de duas formas:

  1. Dash erra uma plataforma e cai pela parte de baixo da tela.
  2. Dash encosta em uma plataforma de Enemy (inimigo).

Antes de implementar qualquer condição de "fim do jogo", adicione uma lógica que defina o estado do jogo DoodleDash como gameOver.

a3c16fc17be25f6c.pngNa classe DoodleDash**,** adicione um método onLose que é chamado sempre que o jogo termina. Ele define o estado do jogo, remove o jogador da tela e ativa o menu/sobreposição **Game Over**.

lib/game/sprites/doodle_dash.dart

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

Menu Game Over:

6a79b43f4a1f780d.png

a3c16fc17be25f6c.pngNa parte de cima do método update do DoodleDash, adicione o código abaixo para impedir que o jogo seja atualizado quando o estado for 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.pngAlém disso, no método update, chame onLose quando o jogador cair pela parte de baixo da tela.

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

Os inimigos podem ter todos os tipos de formas e tamanhos. No Doodle Dash, eles aparecem como uma lixeira ou um ícone de pasta de erro. Os jogadores precisam evitar encostar neles porque isso causa o fim imediato do jogo.

Enemy

a3c16fc17be25f6c.pngCrie um tipo de plataforma de inimigo adicionando um tipo enumerado EnemyPlatformState e a classe 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.

A classe EnemyPlatform estende o supertipo Platform. O ObjectManager cria e administra plataformas de inimigo como faz em todas as outras plataformas.

a3c16fc17be25f6c.pngNo ObjectManager, adicione o código abaixo para gerar e administrar as plataformas inimigas:

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.

O ObjectManager armazena uma lista de objetos inimigos, _enemies. O _maybeAddEnemy gera inimigos com 20 porcento de probabilidade e adiciona o objeto à lista de inimigos. O método _cleanupEnemies() remove objetos EnemyPlatform antigos que não estão mais visíveis.

a3c16fc17be25f6c.pngNo ObjectManager, gere plataformas de inimigo chamando _maybeAddEnemy() no 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.pngAdicione ao método onCollision do Player para verificar se há colisão com uma EnemyPlatform. Caso haja, chame o 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.pngPor fim, modifique o método enableLevelSpecialty do ObjectManager para adicionar o nível 5 à instrução 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 Agora que você tornou o jogo mais desafiador, faça uma recarga automática 7f9a9e103c7b5e5.png e ative as mudanças. Para isso, salve os arquivos e use o botão no ambiente de desenvolvimento integrado ou, na linha de comando, digite r:

Cuidado com os inimigos de pastas quebradas. Eles são sorrateiros e se misturam com o plano de fundo!

Problemas?

Caso o app não esteja executando corretamente, verifique se há erros de digitação. Se necessário, use o código nos links abaixo para colocar tudo de volta nos eixos (links em inglês).

9. Poderes

Esta etapa adiciona recursos avançados para dar poderes à Dash durante o jogo. O Doodle Dash tem duas opções de poder: um Chapéu de Noogler ou um Foguete. Considere esses poderes como outro tipo de plataforma especial. À medida que a Dash avança no jogo, a velocidade dela aumenta quando ela pega um Chapéu de Noogler ou um Foguete e usa esses poderes.

NooglerHat

Rocket

O Chapéu de Noogler aparece no nível 3 quando o jogador atinge uma pontuação >= 40. Se a Dash encostar no chapéu, vai colocá-lo e receber um aumento de 2,5x da velocidade normal. Isso dura cinco segundos.

O Foguete aparece no nível 4 quando o jogador atinge uma pontuação >= 80. Quando a Dash encostar no Foguete, o sprite dela será substituído por um foguete e ela receberá um bônus de 3,5x a velocidade normal até pousar em uma plataforma. Além disso, ela vai ficar invencível contra os inimigos quando estiver com o poder do Foguete.

Os sprites de Chapéu de Noogler e Foguete estendem a classe abstrata PowerUp. Assim como a classe abstrata Platform, a classe abstrata PowerUp, mostrada abaixo, também inclui dimensionamento e uma hitbox.

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 Crie uma classe Rocket que estende a classe abstrata PowerUp. Quando a Dash encosta no foguete, ela recebe um bônus de 3,5 vezes a velocidade 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 Crie uma classe NooglerHat que estende a classe abstrata PowerUp. Quando a Dash encosta no NooglerHat, ela recebe um aumento de aceleração de 2,5 vezes a velocidade normal. Isso dura cinco 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.

Agora que você implementou os poderes NooglerHat e Rocket, atualize o ObjectManager para que eles apareçam no jogo.

a3c16fc17be25f6c.png Modifique a classe ObjectManger para adicionar uma lista que monitora os poderes gerados, com dois novos métodos: _maybePowerup e _cleanupPowerups, para gerar e remover as novas plataformas de poderes.

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.

O método _maybeAddPowerup gera um chapéu de noogler 20% das vezes ou um foguete 15% das vezes. O método _cleanupPowerups é chamado para remover poderes que estão abaixo dos limites de baixo da tela.

a3c16fc17be25f6c.png Modifique o método update do ObjectManager para chamar _maybePowerup em cada marcação do loop de jogo.

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.pngModifique o método enableLevelSpecialty para adicionar dois novos casos na instrução switch: um para ativar NooglerHat no nível 3 e outro para ativar Rocket no nível 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 Adicione os getters booleanos abaixo à classe Player. Se a Dash tiver um poder ativo, ela poderá ser representada por vários estados diferentes. Esses getters facilitam verificar qual poder está ativo.

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.pngModifique o método onCollision do Player para reagir a uma colisão com um NooglerHat ou um Rocket. Este código também faz com que a Dash só ganhe um novo poder caso ainda não tenha um.

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

Se a Dash encostar em um foguete, o PlayerState mudará para Rocket e permitirá que a Dash salte com um jumpSpeedMultiplier de 3,5x.

Se a Dash encostar em um chapéu de Noogler, dependendo da direção atual do PlayerState (.center, .left ou .right), o PlayerState vai mudar para o PlayerState de Noogler correspondente, em que ela usa o chapéu e recebe um jumpSpeedMultiplier que aumenta a velocidade em 2,5x. O método _removePowerupAfterTime remove o poder após 5 segundos e muda o PlayerState dos estados de poder de volta para o center.

A chamada para other.removeFromParent remove as plataformas com sprites de Chapéu de Noogler ou Foguete da tela para indicar que a Dash adquiriu o poder.

ede04fdfe074f471.gif

a3c16fc17be25f6c.pngModifique os métodos moveLeft e moveRight da classe Player para considerar o sprite NooglerHat. Você não precisa considerar o poder Rocket porque esse sprite aponta para a mesma direção, independente da direção do deslocamento.

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

A Dash fica invencível contra os inimigos quando ela tem o poder Rocket, então não encerre o jogo durante esse período.

a3c16fc17be25f6c.pngModifique o callback onCollision para conferir se a Dash isInvincible (está invencível) antes de acionar um fim de jogo ao colidir com uma EnemyPlatform (plataforma de inimigo):

lib/game/sprites/player.dart

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

a3c16fc17be25f6c.png Reinicie o app e jogue para conferir os poderes em ação.

e1fece51429dae55.gif

Problemas?

Caso o app não esteja executando corretamente, verifique se há erros de digitação. Se necessário, use o código nos links abaixo para colocar tudo de volta nos eixos (links em inglês).

10. Sobreposições

Um jogo do Flame pode ser unido a um widget, o que facilita a integração com outros widgets em um app do Flutter. Também é possível mostrar widgets do Flutter como sobreposições na parte de cima do jogo do Flame. Isso é conveniente para componentes não relacionados ao jogo que não dependem do loop dele, como menus, tela de pausa, botões e controles deslizantes.

A pontuação do jogo com todos os menus do Doodle Dash são widgets comuns do Flutter, não componentes do Flame. Todo o código dos widgets está localizado em lib/game/widgets, por exemplo, o menu Game Over é apenas uma coluna que contém outros widgets, como Text e ElevatedButton, conforme mostrado neste 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 um widget como sobreposição em um jogo do Flame, defina uma propriedade overlayBuilderMap no GameWidget com uma key que representa a sobreposição (como uma String) e o value de uma função de widget que retorna um widget, conforme mostrado neste 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),
  },
)

Depois de adicionada, uma sobreposição pode ser usada em qualquer lugar do jogo. Mostre uma sobreposição usando overlays.add e oculte-a com overlays.remove, conforme mostrado no código abaixo:

lib/game/doodle_dash.dart

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

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

11. Suporte para dispositivos móveis

O Doodle Dash é baseado no Flutter e no Flame, ou seja, já pode ser executado nas plataformas com suporte ao Flutter. Porém, o Doodle Dash aceita apenas entrada de teclado até o momento. Para dispositivos que não possuem teclado, como smartphones, é fácil adicionar botões de controle por toque na sobreposição da tela.

a3c16fc17be25f6c.png Adicione uma variável de estado booleana à GameOverlay que determina quando o jogo é executado em uma plataforma móvel:

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

Agora, mostre os botões direcionais esquerdo e direito na sobreposição quando o jogo for executado em dispositivos móveis. Semelhante à lógica de "eventos principais" da etapa 4, tocar no botão esquerdo move a Dash para a esquerda e o botão direito move para a direita.

a3c16fc17be25f6c.png No método build da GameOverlay, adicione uma seção isMobile que segue o mesmo comportamento descrito na etapa 4: tocar no botão esquerdo invoca moveLeft e no botão direito invoca moveRight. Soltar qualquer um dos botões chama resetDirection e faz com que a Dash não se mova na horizontal.

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

Pronto! Agora, o app Doodle Dash detecta automaticamente em que tipo de plataforma está sendo executado e alterna a entrada conforme necessário.

a3c16fc17be25f6c.png Execute o app no iOS ou Android para conferir os botões direcionais em ação.

7b0cac5fb69bc89.gif

Problemas?

Caso o app não esteja executando corretamente, verifique se há erros de digitação. Se necessário, use o código no link abaixo para colocar tudo de volta nos eixos (link em inglês).

12. Próximas etapas

Parabéns!

Você concluiu este codelab e aprendeu a criar um jogo no Flutter usando o mecanismo de jogo do Flame.

O que aprendemos:

  • Como usar o pacote do Flame para criar um jogo de plataforma, incluindo:
  • Adicionar um personagem.
  • Adicionar vários tipos de plataforma.
  • Implementar a detecção de colisão.
  • Adicionar um componente de gravidade.
  • Definir a movimentação da câmera.
  • Criar inimigos.
  • Criar poderes.
  • Como detectar a plataforma em que o jogo está sendo executado.
  • Como usar essas informações para alternar entre teclado e controles de entrada por toque.

Recursos

Esperamos que você tenha aprendido mais sobre como criar jogos no Flutter.

Os recursos abaixo também podem ser úteis e inspiradores (links em inglês):