Introdução ao Flame com o Flutter

1. Introdução

O Flame é um mecanismo de jogo 2D baseado no Flutter. Neste codelab, você criará um jogo inspirado em um dos clássicos dos videogames dos anos 70, o Breakout, de Steve Wozniak. Você vai usar os componentes do Flame para desenhar o taco, a bola e as peças. Você vai usar os efeitos do Flame para animar o movimento do taco e aprender a integrar o Flame ao sistema de gerenciamento de estado do Flutter.

Quando concluído, seu jogo deve ficar parecido com este gif animado, embora um pouco mais lento.

Uma gravação de tela de um jogo que está sendo jogado. O jogo foi acelerado significativamente.

O que você vai aprender

  • Como funcionam os conceitos básicos do Flame, começando com GameWidget.
  • Como usar um loop de jogo.
  • Como os Components do Flame funcionam. Eles são semelhantes aos Widgets do Flutter.
  • Como lidar com colisões.
  • Como usar Effects para animar Components.
  • Como sobrepor Widgets do Flutter em um jogo do Flame.
  • Como integrar o Flame ao gerenciamento de estado do Flutter.

O que você vai criar

Neste codelab, você criará um jogo 2D usando o Flutter e o Flame. Quando concluído, o jogo precisa atender aos seguintes requisitos

  • Função nas seis plataformas compatíveis com o Flutter: Android, iOS, Linux, macOS, Windows e Web
  • Mantenha pelo menos 60 QPS usando o loop de jogo do Flame.
  • Use os recursos do Flutter, como o pacote google_fonts e flutter_animate, para recriar a experiência dos jogos de arcade dos anos 80.

2. Configurar seu ambiente do Flutter

Editor

Para simplificar este codelab, ele pressupõe que o Visual Studio Code (VS Code) seja seu ambiente de desenvolvimento. O VS Code é sem custo financeiro e funciona em todas as principais plataformas. Neste codelab, usamos o VS Code porque as instruções são padronizadas para atalhos específicos dele. As tarefas se tornam mais diretas: "clique neste botão" ou "pressione esta tecla para fazer X" em vez de "realizar a ação adequada no seu editor para fazer X".

Você pode usar o editor que preferir: Android Studio, outros ambientes de desenvolvimento integrado do IntelliJ, Emacs, Vim ou Notepad++. Todos eles funcionam com o Flutter.

Uma captura de tela do VS Code com um código do Flutter

Escolher uma plataforma para desenvolvimento

O Flutter produz apps para várias plataformas. Seu app pode ser executado em qualquer um destes sistemas operacionais:

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

É uma prática comum escolher um sistema operacional como plataforma de desenvolvimento. Esse é o sistema operacional em que seu app é executado durante o desenvolvimento.

Desenho mostrando um laptop e um smartphone conectados a ele por um cabo. O laptop é rotulado

Por exemplo: digamos que você esteja usando um laptop Windows para desenvolver seu app do Flutter. Em seguida, escolha o Android como destino de desenvolvimento. Para visualizar o app, conecte um dispositivo Android ao laptop Windows usando um cabo USB. O app em desenvolvimento será executado nesse dispositivo ou em um Android Emulator. Você pode ter escolhido o Windows como a plataforma de desenvolvimento, que executa seu aplicativo em desenvolvimento como um aplicativo do Windows junto com seu editor.

Talvez você fique tentado a escolher a Web como plataforma de desenvolvimento. Isso tem uma desvantagem durante o desenvolvimento: você perde o recurso recarga automática com estado do Flutter. No momento, o Flutter não faz a recarga automática de aplicativos da Web.

Faça sua escolha antes de continuar. Você pode executar o app em outros sistemas operacionais depois. Escolher um destino de desenvolvimento facilita a próxima etapa.

Instalar o Flutter

As instruções mais atualizadas sobre a instalação do SDK do Flutter podem ser encontradas em docs.flutter.dev (link em inglês).

As instruções no site do Flutter abrangem a instalação do SDK e das ferramentas relacionadas à área de desenvolvimento, além dos plug-ins do editor. Para este codelab, instale os seguintes softwares:

  1. SDK do Flutter.
  2. Visual Studio Code com o plug-in do Flutter.
  3. Compilador de software para o destino de desenvolvimento escolhido. Você precisa do Visual Studio segmentar o Windows ou o Xcode para segmentar o macOS ou iOS.

Na próxima seção, você vai criar seu primeiro projeto do Flutter.

Se você precisar resolver problemas, estas perguntas e respostas do StackOverflow podem ser úteis.

Perguntas frequentes

3. Criar um projeto

Criar seu primeiro projeto do Flutter

Isso envolve abrir o VS Code e criar o modelo de app do Flutter em um diretório de sua escolha.

  1. Inicie o Visual Studio Code.
  2. Abra a paleta de comandos (F1, Ctrl+Shift+P ou Shift+Cmd+P) e digite "flutter new". Quando ele aparecer, selecione o comando Flutter: novo projeto.

Uma captura de tela do VS Code com

  1. Selecione Empty Application. Escolha um diretório para criar o projeto. Esse diretório precisa ser qualquer diretório que não exija privilégios elevados ou que tenha um espaço no caminho. Por exemplo, seu diretório principal ou C:\src\.

Uma captura de tela do VS Code com o aplicativo vazio mostrado como parte do novo fluxo de aplicativo

  1. Nomeie seu projeto como brick_breaker. O restante deste codelab presume que você nomeou seu app como brick_breaker.

Uma captura de tela do VS Code com

Agora, a pasta do projeto será criada pelo Flutter e aberta pelo VS Code. Agora você vai substituir o conteúdo de dois arquivos por um scaffolding básico do app.

Copiar e colar o aplicativo inicial

Isso vai adicionar o código de exemplo fornecido neste codelab ao app.

  1. No painel esquerdo do VS Code, clique em Explorer e abra o arquivo pubspec.yaml.

Captura de tela parcial do VS Code com setas destacando o local do arquivo pubspec.yaml.

  1. Substitua o conteúdo do arquivo pelo indicado abaixo.

pubspec.yaml (link em inglês)

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

environment:
  sdk: '>=3.3.0 <4.0.0'

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

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^3.0.1

flutter:
  uses-material-design: true

O arquivo pubspec.yaml especifica informações básicas sobre o app, como a versão atual, as dependências e os recursos que ele terá.

  1. Abra o arquivo main.dart no diretório lib/.

Captura de tela parcial do VS Code com uma seta mostrando o local do arquivo main.dart

  1. Substitua o conteúdo do arquivo pelo indicado abaixo.

lib/main.dart

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

void main() {
  final game = FlameGame();
  runApp(GameWidget(game: game));
}
  1. Execute este código para verificar se tudo está funcionando. Uma nova janela será mostrada com apenas um plano de fundo preto em branco. O pior videogame do mundo está renderizando a 60 fps!

Captura de tela mostrando uma janela de aplicativo brick_breaker completamente preta.

4. Criar o jogo

Dimensione o jogo

Um jogo em duas dimensões (2D) precisa de uma área de jogo. Você vai construir uma área com dimensões específicas e depois usar essas dimensões para dimensionar outros aspectos do jogo.

Existem várias maneiras de colocar as coordenadas na área do jogo. Por uma convenção, você pode medir a direção a partir do centro da tela com a origem (0,0) no centro da tela. Os valores positivos movem os itens para a direita ao longo do eixo x e para cima ao longo do eixo y. Esse padrão se aplica à maioria dos jogos atuais, especialmente aqueles que envolvem três dimensões.

Quando o jogo Breakout original foi criado, a convenção era definir a origem no canto superior esquerdo. A direção X positiva permaneceu a mesma, mas y foi invertido. A direção x positiva x estava correta e y estava para baixo. Para se manter fiel à época, este jogo coloca a origem no canto superior esquerdo.

Crie um arquivo com o nome config.dart em um novo diretório chamado lib/src. Esse arquivo ganhará mais constantes nas próximas etapas.

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;

Este jogo terá 820 pixels de largura e 1.600 pixels de altura. A área do jogo é dimensionada para se ajustar à janela em que é exibida, mas todos os componentes adicionados à tela estão em conformidade com essa altura e largura.

Criar uma área de recreação

No jogo Breakout, a bola quica nas paredes da área de recreação. Para acomodar colisões, você precisa primeiro de um componente PlayArea.

  1. Crie um arquivo com o nome play_area.dart em um novo diretório chamado lib/src/components.
  2. Adicione o seguinte a esse arquivo:

lib/src/components/play_area.dart

import 'dart:async';

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

import '../brick_breaker.dart';

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

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

Enquanto o Flutter tem Widgets, o Flame tem Components. Enquanto os apps do Flutter criam árvores de widgets, os jogos do Flame consistem na manutenção de árvores de componentes.

É por isso que há uma diferença interessante entre o Flutter e o Flame. A árvore de widgets do Flutter é uma descrição temporária criada para ser usada na atualização da camada RenderObject persistente e mutável. Os componentes do Flame são persistentes e mutáveis, com a expectativa de que o desenvolvedor os usará como parte de um sistema de simulação.

Os componentes do Flame são otimizados para expressar a mecânica do jogo. Este codelab começará com o loop de jogo, que será apresentado na próxima etapa.

  1. Para controlar a sobrecarga, adicione um arquivo com todos os componentes do projeto. Crie um arquivo components.dart em lib/src/components e adicione o seguinte conteúdo.

lib/src/components/components.dart

export 'play_area.dart';

A diretiva export desempenha o papel inverso de import. Ele declara qual funcionalidade esse arquivo expõe quando importado para outro arquivo. Esse arquivo vai aumentar as entradas à medida que você adicionar novos componentes nas etapas a seguir.

Criar um jogo do Flame

Para apagar os rabiscos vermelhos da etapa anterior, crie uma nova subclasse para a FlameGame do Flame.

  1. Crie um arquivo chamado brick_breaker.dart em lib/src e adicione o seguinte código.

lib/src/brick_breaker.dart

import 'dart:async';

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

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

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

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

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());
  }
}

Esse arquivo coordena as ações do jogo. Durante a construção da instância do jogo, esse código configura o jogo para usar a renderização de resolução fixa. O jogo é redimensionado para preencher a tela que o contém e adiciona letterboxing conforme necessário.

Exponha a largura e a altura do jogo para que os componentes filhos, como PlayArea, possam ser definidos com o tamanho adequado.

No método substituído onLoad, seu código executa duas ações.

  1. Configura o canto superior esquerdo como a âncora do visor. Por padrão, o visor usa o meio da área como âncora para (0,0).
  2. Adiciona o PlayArea ao world. O mundo representa o mundo dos jogos. Ela projeta todos os filhos por meio da transformação de visualização de CameraComponents.

Mostrar o jogo na tela

Para acessar todas as mudanças feitas nesta etapa, atualize o arquivo lib/main.dart com as mudanças abaixo.

lib/main.dart

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

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

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

Depois de fazer essas mudanças, reinicie o jogo. O jogo será semelhante à figura a seguir.

Captura de tela mostrando uma janela do aplicativo brick_breaker com um retângulo colorido de areia no meio da janela do app

Na próxima etapa, você vai adicionar uma bola ao mundo e começar a mexer!

5. Mostrar a bola

Criar o componente bola

Colocar uma bola que se move na tela envolve criar outro componente e adicioná-lo ao mundo do jogo.

  1. Edite o conteúdo do arquivo lib/src/config.dart da seguinte maneira.

lib/src/config.dart

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

O padrão de design para definir constantes nomeadas como valores derivados vai ser retornado muitas vezes neste codelab. Isso permite que você modifique a gameWidth e a gameHeight de nível superior para conferir as mudanças na aparência do jogo.

  1. Crie o componente Ball em um arquivo chamado ball.dart em lib/src/components.

lib/src/components/ball.dart

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

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

  final Vector2 velocity;

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

Anteriormente, você definiu o PlayArea usando o RectangleComponent, então é normal que existam mais formas. A CircleComponent, assim como a RectangleComponent, deriva de PositionedComponent, então você pode posicionar a bola na tela. Mais importante ainda, sua posição pode ser atualizada.

Esse componente introduz o conceito de velocity, ou mudança de posição ao longo do tempo. A velocidade é um objeto Vector2 e a velocidade é tanto a velocidade quanto a direção. Para atualizar a posição, substitua o método update, que o mecanismo de jogo chama para cada frame. O dt é a duração entre o frame anterior e o atual. Isso permite que você se adapte a fatores como diferentes frame rates (60 hz ou 120 hz) ou quadros longos devido ao excesso de computação.

Preste muita atenção à atualização de position += velocity * dt. É assim que você implementa a atualização de uma simulação discreta de movimento ao longo do tempo.

  1. Para incluir o componente Ball na lista de componentes, edite o arquivo lib/src/components/components.dart da seguinte maneira.

lib/src/components/components.dart

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

Ponha a bola em prática

Você tem uma bola. Vamos colocá-lo no mundo e configurá-lo para se mover pela área de recreação.

Edite o arquivo lib/src/brick_breaker.dart da seguinte maneira:

lib/src/brick_breaker.dart

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

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

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

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

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

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

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

    debugMode = true;                                           // To here.
  }
}

Essa mudança adiciona o componente Ball ao world. Para definir o position da bola como o centro da área de exibição, o código primeiro reduz pela metade o tamanho do jogo, já que Vector2 tem sobrecargas de operador (* e /) para dimensionar um Vector2 por um valor escalar.

Definir o velocity da bola envolve mais complexidade. A intenção é mover a bola pela tela em uma direção aleatória e a uma velocidade razoável. A chamada para o método normalized cria um objeto Vector2 definido para a mesma direção que a Vector2 original, mas reduzido para uma distância de 1. Isso mantém a velocidade da bola consistente, independentemente da direção. A velocidade da bola é então dimensionada para ser 1/4 da altura do jogo.

Acertar esses diversos valores envolve algumas iterações, também conhecidas como playtests no setor.

A última linha ativa a tela de depuração, que adiciona outras informações para ajudar na depuração.

Quando o jogo for executado, ele vai ficar parecido com a tela abaixo.

Captura de tela mostrando uma janela de aplicativo brick_breaker com um círculo azul sobre o retângulo colorido de areia. O círculo azul é anotado com números indicando o tamanho e a localização na tela

Tanto o componente PlayArea quanto o Ball têm informações de depuração, mas o plano de fundo recorta os números de PlayArea. Tudo tem informações de depuração exibidas porque você ativou a debugMode para toda a árvore de componentes. Você também pode ativar a depuração apenas para componentes selecionados, se isso for mais útil.

Se você reiniciar o jogo algumas vezes, vai perceber que a bola não interage com as paredes como esperado. Para isso, adicione a detecção de colisão, o que será feito na próxima etapa.

6. Pular

Adicionar detecção de colisão

A detecção de colisão adiciona um comportamento em que o jogo reconhece quando dois objetos entram em contato um com o outro.

Para adicionar a detecção de colisão, adicione o mixin HasCollisionDetection ao jogo BrickBreaker, conforme mostrado no código abaixo.

lib/src/brick_breaker.dart

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

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

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

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

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

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

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

    debugMode = true;
  }
}

Isso rastreia as hitboxes dos componentes e aciona callbacks de colisão em cada marcação do jogo.

Para começar a preencher as hitboxes do jogo, modifique o componente PlayArea, conforme mostrado abaixo.

lib/src/components/play_area.dart

import 'dart:async';

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

import '../brick_breaker.dart';

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

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

Adicionar um componente RectangleHitbox como filho do RectangleComponent criará uma caixa de hit para detecção de colisão que corresponde ao tamanho do componente pai. Existe um construtor de fábrica para RectangleHitbox chamado relative para momentos em que você quer uma hitbox menor ou maior que o componente pai.

Pular a bola

Até agora, o uso da detecção de colisão não fez diferença na jogabilidade. Ele muda depois que você modifica o componente Ball. É o comportamento da bola que precisa mudar quando ela colide com a PlayArea.

Modifique o componente Ball da seguinte maneira:

lib/src/components/ball.dart

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

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

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

  final Vector2 velocity;

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

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

Este exemplo faz uma grande mudança com a adição do callback onCollisionStart. O sistema de detecção de colisão adicionado a BrickBreaker no exemplo anterior chama esse callback.

Primeiro, o código testa se o Ball colidiu com PlayArea. Isso parece redundante por enquanto, já que não há outros componentes no mundo dos jogos. Isso mudará na próxima etapa, quando você adicionar um morcego ao mundo. Em seguida, ele também adiciona uma condição else para lidar quando a bola colidir com coisas que não são o taco. Um lembrete gentil para implementar a lógica restante, se você quiser.

Quando a bola encosta na parede de baixo, ela simplesmente desaparece da superfície de jogo e ainda está muito visível. Você vai lidar com esse artefato em uma etapa futura usando o poder dos efeitos do Flame.

Agora que a bola está colidindo com as paredes do jogo, com certeza seria útil dar ao jogador um taco para acertar a bola com...

7. Acerte o taco na bola

Criar o taco

Para adicionar um taco para manter a bola em ação durante o jogo,

  1. Insira algumas constantes no arquivo lib/src/config.dart da seguinte maneira.

lib/src/config.dart

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

As constantes batHeight e batWidth são autoexplicativas. A constante batStep, por outro lado, precisa de um toque de explicação. Para interagir com a bola neste jogo, o jogador pode arrastar o taco com o mouse ou dedo, dependendo da plataforma, ou usar o teclado. A constante batStep configura a distância que o bastão percorre a cada pressionamento da tecla de seta para a esquerda ou para a direita.

  1. Defina a classe do componente Bat desta maneira.

lib/src/components/bat.dart

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

import '../brick_breaker.dart';

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

  final Radius cornerRadius;

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

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

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

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

Esse componente apresenta alguns novos recursos.

Primeiro, o componente Bat é um PositionComponent, não um RectangleComponent nem um CircleComponent. Isso significa que esse código precisa renderizar o Bat na tela. Para fazer isso, ele substitui o callback render.

Olhando de perto para a chamada canvas.drawRRect (retângulo arredondado), você pode se perguntar "onde está o retângulo?". O Offset.zero & size.toSize() usa uma sobrecarga operator & na classe Offset dart:ui que cria Rects. Essa abreviação pode confundir você no início, mas é comum em código do Flutter e do Flame de nível inferior.

Em segundo lugar, o componente Bat pode ser arrastado usando o dedo ou o mouse, dependendo da plataforma. Para implementar essa funcionalidade, adicione o mixin DragCallbacks e substitua o evento onDragUpdate.

Por fim, o componente Bat precisa responder ao controle do teclado. A função moveBy permite que outro código diga ao bastão para se mover para a esquerda ou direita por um determinado número de pixels virtuais. Essa função introduz um novo recurso do mecanismo de jogo do Flame: Effects. Ao adicionar o objeto MoveToEffect como filho desse componente, o jogador vê o bastão animado para uma nova posição. Há uma coleção de Effects disponíveis no Flame para executar vários efeitos.

Os argumentos do construtor do efeito incluem uma referência ao getter game. É por isso que você inclui o mixin HasGameReference nessa classe. Esse mixin adiciona um acessador game com segurança de tipo a esse componente para acessar a instância BrickBreaker na parte superior da árvore de componentes.

  1. Para disponibilizar o Bat para BrickBreaker, atualize o arquivo lib/src/components/components.dart da seguinte maneira.

lib/src/components/components.dart

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

Coloque o taco no mundo

Para adicionar o componente Bat ao mundo do jogo, atualize BrickBreaker desta maneira.

lib/src/brick_breaker.dart

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

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

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

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

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

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

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

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

    debugMode = true;
  }

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

A adição do mixin KeyboardEvents e do método onKeyEvent substituído processam a entrada do teclado. Lembre-se do código adicionado anteriormente para mover o bastão na quantidade de passos adequada.

A parte restante do código adicionado adiciona o taco ao mundo do jogo na posição e com as proporções certas. Com todas essas configurações expostas neste arquivo, você pode ajustar o tamanho relativo do taco e da bola para ter a sensação certa para o jogo.

Se você estiver jogando neste momento, vai perceber que é possível mover o taco para interceptar a bola, mas nenhuma resposta visível, além do registro de depuração que você deixou no código de detecção de colisão do Ball.

Hora de corrigir isso agora. Edite o componente Ball da seguinte maneira:

lib/src/components/ball.dart

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

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

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

  final Vector2 velocity;

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

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

Essas mudanças de código corrigem dois problemas separados.

Primeiro, corrige a bola que saiu da existência no momento em que toca na parte inferior da tela. Para corrigir esse problema, substitua a chamada removeFromParent por RemoveEffect. O RemoveEffect remove a bola do mundo do jogo depois de permitir que ela saia da área de jogo visível.

Em segundo lugar, essas mudanças corrigem o tratamento da colisão entre o bastão e a bola. Esse código de tratamento funciona muito a favor do jogador. Se o jogador tocar na bola com o bastão, a bola voltará para o topo da tela. Se você quiser algo mais realista e quiser algo mais realista, mude essa forma de lidar com a experiência do jogo.

Vale ressaltar a complexidade da atualização do velocity. Ela não apenas inverte o componente y da velocidade, como foi feito para as colisões na parede. Ele também atualiza o componente x de uma forma que depende da posição relativa do taco e da bola no momento do contato. Isso dá ao jogador mais controle sobre o que a bola faz, mas exatamente como ela não é comunicada ao jogador de nenhuma maneira, exceto durante o jogo.

Agora que você tem um taco para acertar a bola, seria legal ter alguns tijolos para quebrar com a bola!

8. Quebre o muro

Criando as peças

Para adicionar peças ao jogo,

  1. Insira algumas constantes no arquivo lib/src/config.dart da seguinte maneira.

lib/src/config.dart

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

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

const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2;
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05;
const brickGutter = gameWidth * 0.015;                          // Add from here...
final brickWidth =
    (gameWidth - (brickGutter * (brickColors.length + 1)))
    / brickColors.length;
const brickHeight = gameHeight * 0.03;
const difficultyModifier = 1.03;                                // To here.
  1. Insira o componente Brick da seguinte maneira:

lib/src/components/brick.dart

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

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

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

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

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

Você provavelmente já conhece a maior parte desse código. Esse código usa um RectangleComponent, com detecção de colisão e uma referência segura ao jogo BrickBreaker na parte de cima da árvore de componentes.

O novo conceito mais importante que esse código apresenta é como o jogador atinge a condição de vitória. A verificação da condição de vitória consulta o mundo em busca de peças e confirma que resta apenas uma. Isso pode ser um pouco confuso, porque a linha anterior remove essa peça do pai.

O ponto-chave a entender é que a remoção de componente é um comando na fila. Ele remove o bloco depois da execução desse código, mas antes da próxima etapa do mundo do jogo.

Para tornar o componente Brick acessível para BrickBreaker, edite lib/src/components/components.dart da seguinte maneira.

lib/src/components/components.dart

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

Adicione peças ao mundo

Atualize o componente Ball desta forma:

lib/src/components/ball.dart

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

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

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

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

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

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

Essa é a única novidade: um modificador de dificuldade que aumenta a velocidade da bola após cada colisão de peças. Esse parâmetro ajustável precisa ser testado em um loop para encontrar a curva de dificuldade adequada para seu jogo.

Edite o jogo BrickBreaker desta forma.

lib/src/brick_breaker.dart

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

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

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

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

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

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

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

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

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

    debugMode = true;
  }

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

Se você executar o jogo do jeito que está atualmente, ele vai mostrar todas as principais mecânicas do jogo. Você pode desativar a depuração e chamá-la de concluída, mas parece que falta algo.

Uma captura de tela mostrando brick_breaker com uma bola, um taco e a maioria das peças na área de jogo. Cada componente tem rótulos de depuração.

Que tal uma tela de boas-vindas, uma tela de fim de jogo e talvez uma pontuação? O Flutter pode adicionar esses recursos ao jogo, e é aí que você focará sua atenção.

9. Vença o jogo

Adicionar estados de reprodução

Nesta etapa, você vai incorporar o jogo do Flame a um wrapper do Flutter e, em seguida, adicionar sobreposições do Flutter para as telas de boas-vindas, fim de jogo e vitória.

Primeiro, modifique os arquivos do jogo e dos componentes para implementar um estado de reprodução que indique se uma sobreposição será exibida e, em caso afirmativo, qual.

  1. Modifique o jogo BrickBreaker desta maneira.

lib/src/brick_breaker.dart

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

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

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

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

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

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

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

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

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

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

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

    playState = PlayState.playing;                              // To here.

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

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

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

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

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

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

Esse código muda boa parte do jogo BrickBreaker. Adicionar a enumeração playState exige muito trabalho. Isso capta onde o jogador está entrando, jogando e perdendo ou ganhando o jogo. Na parte de cima do arquivo, você define a enumeração e a instancia como um estado oculto com getters e setters correspondentes. Esses getters e setters permitem modificar sobreposições quando as várias partes do jogo acionam transições de estado de jogo.

Em seguida, divida o código em onLoad em onLoad e em um novo método startGame. Antes dessa mudança, só era possível começar um novo jogo reiniciando-o. Com essas novidades, o jogador pode começar um novo jogo sem precisar de medidas drásticas.

Para permitir que o jogador inicie um novo jogo, você configurou dois novos gerenciadores. Você adicionou um gerenciador de toque e estendeu o gerenciador do teclado para permitir que o usuário inicie um novo jogo em várias modalidades. Com o estado de reprodução modelado, faria sentido atualizar os componentes para acionar transições de estado de reprodução quando o jogador ganha ou perde.

  1. Modifique o componente Ball da seguinte maneira:

lib/src/components/ball.dart

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

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

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

  final Vector2 velocity;
  final double difficultyModifier;

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

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

Essa pequena mudança adiciona um callback onComplete ao RemoveEffect, que aciona o estado de reprodução gameOver. Isso deve parecer correto se o jogador permitir que a bola escape da parte inferior da tela.

  1. Edite o componente Brick da seguinte maneira:

lib/src/components/brick.dart

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

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

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

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

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

Por outro lado, se o jogador consegue quebrar todos os tijolos, ele ganha um "jogo vencido" tela. Muito bem, jogador, bom trabalho!

Adicionar o wrapper do Flutter

Para oferecer um lugar para incorporar o jogo e adicionar sobreposições de estado de jogo, adicione o shell do Flutter.

  1. Crie um diretório widgets em lib/src.
  2. Adicione um arquivo game_app.dart e insira o seguinte conteúdo nele.

lib/src/widgets/game_app.dart

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

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

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

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

A maior parte do conteúdo nesse arquivo segue um build padrão de árvore de widgets do Flutter. As partes específicas do Flame incluem o uso de GameWidget.controlled para criar e gerenciar a instância do jogo BrickBreaker e o novo argumento overlayBuilderMap para o GameWidget.

As chaves desse overlayBuilderMap precisam estar alinhadas às sobreposições que o setter de playState no BrickBreaker adicionou ou removeu. A tentativa de definir uma sobreposição que não esteja neste mapa leva a rostos infelizes por toda parte.

  1. Para mostrar essa nova funcionalidade na tela, substitua o arquivo lib/main.dart pelo conteúdo abaixo.

lib/main.dart

import 'package:flutter/material.dart';

import 'src/widgets/game_app.dart';

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

Se você executar esse código no iOS, Linux, Windows ou na Web, o resultado pretendido será exibido no jogo. Se você segmentar macOS ou Android, vai precisar de um último ajuste para ativar a exibição de google_fonts.

Como ativar o acesso à fonte

Adicionar permissão de Internet para Android

No Android, você precisa adicionar permissão de Internet. Edite o AndroidManifest.xml desta maneira.

android/app/src/main/AndroidManifest.xml

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

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

Editar arquivos de direitos para macOS

No macOS, você tem dois arquivos para editar.

  1. Edite o arquivo DebugProfile.entitlements para corresponder ao seguinte código.

macos/Runner/DebugProfile.entitlements (link em inglês)

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

macos/Runner/Release.entitlements (link em inglês)

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

Executar isso como está deve mostrar uma tela de boas-vindas e uma tela de fim de jogo ou de vitória em todas as plataformas. Essas telas podem ser um pouco simples, e seria bom ter uma pontuação. Então, adivinhe o que você vai fazer na próxima etapa.

10. Manter pontuação

Adicionar pontuação ao jogo

Nesta etapa, você expõe a pontuação do jogo ao contexto do Flutter. Nesta etapa, você vai expor o estado do jogo do Flame ao gerenciamento de estado do Flutter. Isso permite que o código do jogo atualize a pontuação sempre que o jogador quebrar um tijolo.

  1. Modifique o jogo BrickBreaker desta maneira.

lib/src/brick_breaker.dart

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

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

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

enum PlayState { welcome, playing, gameOver, won }

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

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

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

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    playState = PlayState.welcome;
  }

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

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

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

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

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

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

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

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

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

Ao adicionar score ao jogo, você vincula o estado dele ao gerenciamento de estado do Flutter.

  1. Modifique a classe Brick para adicionar um ponto à pontuação quando o jogador quebrar os tijolos.

lib/src/components/brick.dart

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

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

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

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

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

Crie um jogo com um visual incrível

Agora que você pode manter a pontuação no Flutter, é hora de montar os widgets e melhorar a aparência.

  1. Crie score_card.dart em lib/src/widgets e adicione o seguinte.

lib/src/widgets/score_card.dart

import 'package:flutter/material.dart';

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

  final ValueNotifier<int> score;

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

Isso deixa as sobreposições mais refinadas usando a capacidade do pacote flutter_animate para adicionar movimento e estilo às telas de sobreposição.

lib/src/widgets/overlay_screen.dart

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

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

  final String title;
  final String subtitle;

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

Para ter uma visão mais detalhada sobre o poder do flutter_animate, confira o codelab Como criar interfaces de última geração no Flutter (link em inglês).

Esse código mudou muito no componente GameApp. Primeiro, para permitir que o ScoreCard acesse o score , converta-o de um StatelessWidget em um StatefulWidget. Para adicionar um cartão de pontuação, é preciso adicionar um Column para empilhar a pontuação acima do jogo.

Segundo, para melhorar as experiências de boas-vindas, de fim de jogo e de vitórias, você adicionou o novo widget OverlayScreen.

lib/src/widgets/game_app.dart

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

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

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

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

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

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

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

Com tudo isso pronto, agora você pode executar o jogo em qualquer uma das seis plataformas de destino do Flutter. O jogo será semelhante a este:

Captura de tela de brick_breaker mostrando a tela pré-jogo convidando o usuário a tocar na tela para jogar

Uma captura de tela de brick_breaker mostrando o jogo sobre a tela, sobreposta a um taco e alguns dos tijolos

11. Parabéns

Parabéns, você conseguiu criar um jogo com o Flutter e o Flame.

Você criou um jogo usando o mecanismo 2D do Flame e o incorporou a um wrapper do Flutter. Você usou os efeitos do Flame para animar e remover componentes. Você usou o Google Fonts e os pacotes do Flutter Animate para fazer o jogo ficar com um visual incrível.

A seguir

Confira alguns destes codelabs:

Leitura adicional