Introdução ao Flame com o Flutter

Introdução ao Flame com o Flutter

Sobre este codelab

subjectÚltimo mai. 20, 2025 atualizado
account_circleEscrito por Brett Morgan

1. Introdução

O Flame é um mecanismo de jogo 2D baseado no Flutter. Neste codelab, você vai 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 a raquete, a bola e os tijolos. Você vai usar os efeitos do Flame para animar o movimento do morcego e saber como integrar o Flame ao sistema de gerenciamento de estado do Flutter.

Quando terminar, o jogo vai ficar parecido com este GIF animado, embora um pouco mais lento.

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

O que você vai aprender

  • Como o básico do Flame funciona, 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ê vai criar um jogo 2D usando o Flutter e o Flame. Quando estiver pronto, o jogo precisa atender aos seguintes requisitos:

  • Funciona em todas as seis plataformas com suporte do Flutter: Android, iOS, Linux, macOS, Windows e Web
  • Mantenha pelo menos 60 QPS usando o loop de jogo do Flame.
  • Use recursos do Flutter, como o pacote google_fonts e o flutter_animate, para recriar a sensação dos jogos de fliperama dos anos 80.

2. Configurar seu ambiente do Flutter

Editor

Para simplificar este codelab, presumimos que o Visual Studio Code (VS Code) é seu ambiente de desenvolvimento. O VS Code é sem custo financeiro e funciona em todas as principais plataformas. Usamos o VS Code para este codelab porque as instruções são padronizadas para atalhos específicos do VS Code. As tarefas ficam mais simples: "clique neste botão" ou "pressione esta tecla para fazer X" em vez de "realize a ação adequada no seu editor para fazer X".

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

Uma captura de tela do VS Code com alguns códigos 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 destino de desenvolvimento. É o sistema operacional em que seu app é executado durante o desenvolvimento.

Um desenho que mostra um laptop e um smartphone conectados por um cabo. O laptop é identificado como

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

Pode ser tentador escolher a Web como plataforma de desenvolvimento. Isso tem uma desvantagem durante o desenvolvimento: você perde o recurso de recarga automática com estado do Flutter. No momento, o Flutter não pode fazer a recarga automática de aplicativos da Web.

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

Instalar o Flutter

As instruções mais atualizadas sobre como instalar o SDK do Flutter podem ser encontradas em docs.flutter.dev.

As instruções no site do Flutter abrangem a instalação do SDK e das ferramentas relacionadas à plataforma de desenvolvimento e dos plug-ins do editor. Para este codelab, instale o seguinte software:

  1. SDK do Flutter.
  2. Visual Studio Code com o plug-in do Flutter.
  3. Software de compilador para a plataforma de desenvolvimento escolhida. Você precisa do Visual Studio para Windows ou do Xcode para macOS ou iOS.

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

Se você precisar resolver algum problema, algumas destas 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 Flutter em um diretório escolhido.

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

Uma captura de tela do VS Code com

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

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

  1. Nomeie o projeto como brick_breaker. O restante deste codelab pressupõe que você tenha nomeado o 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 adiciona o exemplo de código fornecido neste codelab ao seu app.

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

Uma captura de tela parcial do VS Code com setas destacando a localização 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.8.0

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

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true

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

Uma captura de tela parcial do VS Code com uma seta mostrando a localização 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 com um plano de fundo preto em branco vai aparecer. O pior videogame do mundo agora tem renderização de 60 QPS!

Uma captura de tela mostrando uma janela do aplicativo brick_breaker completamente preta.

4. Criar o jogo

Avaliar o jogo

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

Há várias maneiras de posicionar as coordenadas na área de jogo. Por uma convenção, é possível 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.

A convenção quando o jogo Breakout original foi criado foi definir a origem no canto superior esquerdo. A direção x positiva permaneceu a mesma, mas a direção y foi invertida. A direção x positiva estava à direita e y estava para baixo. Para manter a fidelidade à época, o jogo define a origem no canto superior esquerdo.

Crie um arquivo chamado config.dart em um novo diretório chamado lib/src. Esse arquivo vai receber mais constantes nas próximas etapas.

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;

O jogo terá 820 pixels de largura e 1600 pixels de altura. A área do jogo é dimensionada para caber na janela em que é exibida, mas todos os componentes adicionados à tela se conformam a essa altura e largura.

Criar uma área de jogo

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

  1. Crie um arquivo chamado play_area.dart em um novo diretório chamado lib/src/components.
  2. Adicione o seguinte ao 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 consistem em criar árvores de widgets, os jogos do Flame consistem em manter árvores de componentes.

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 use como parte de um sistema de simulação.

Os componentes do Flame são otimizados para expressar as mecânicas do jogo. Este codelab vai começar com o loop do jogo, mostrado na próxima etapa.

  1. Para controlar a desordem, 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 tem o papel inverso de import. Ele declara qual funcionalidade esse arquivo expõe quando importado para outro arquivo. Esse arquivo vai aumentar o número de entradas à medida que você adicionar novos componentes nas etapas a seguir.

Criar um jogo do Flame

Para extinguir os rabiscos vermelhos da etapa anterior, derive uma nova subclasse para o 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.

Você expõe a largura e a altura do jogo para que os componentes filhos, como PlayArea, possam ser definidos no tamanho adequado.

No método modificado onLoad, o código executa duas ações.

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

Mostrar o jogo na tela

Para conferir todas as mudanças feitas nesta etapa, atualize o arquivo lib/main.dart com as seguintes alterações.

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 deve se parecer com a figura a seguir.

Uma captura de tela mostrando uma janela do aplicativo brick_breaker com um retângulo cor de areia no meio da janela do app

Na próxima etapa, você vai adicionar uma bola ao mundo e fazer com que ela se mova.

5. Mostrar a bola

Criar o componente da bola

Colocar uma bola em movimento na tela envolve a criação de outro componente e a adição dele ao mundo do jogo.

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

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 de definição de constantes nomeadas como valores derivados será retornado muitas vezes neste codelab. Isso permite modificar o nível superior gameWidth e gameHeight para conferir como a aparência e a sensação do jogo mudam como resultado.

  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 é razoável que existam mais formas. CircleComponent, como RectangleComponent, deriva de PositionedComponent, para que você possa posicionar a bola na tela. Mais importante, a 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, porque ela é a velocidade e 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 este frame. Isso permite que você se adapte a fatores como diferentes taxas de frames (60 Hz ou 120 Hz) ou frames longos devido a cálculos excessivos.

Preste atenção na atualização 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';

Adicionando a bola ao mundo

Você tem uma bola. Coloque-o no mundo e configure-o para se mover pela área de jogo.

Edite o arquivo lib/src/brick_breaker.dart da seguinte forma.

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 a position da bola no 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 uma Vector2 por um valor escalar.

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

Para acertar esses valores, é preciso fazer algumas iterações, também conhecidas como testes de jogo no setor.

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

Quando você executar o jogo, ele vai ficar parecido com a tela a seguir.

Uma captura de tela mostrando uma janela do aplicativo brick_breaker com um círculo azul sobre o retângulo cor de areia. O círculo azul é anotado com números que indicam o tamanho e a localização na tela

O componente PlayArea e o Ball têm informações de depuração, mas o plano de fundo corta os números do PlayArea. O motivo de tudo mostrar informações de depuração é porque você ativou debugMode para toda a árvore de componentes. Também é possível ativar a depuração apenas para componentes selecionados, se isso for mais útil.

Se você reiniciar o jogo algumas vezes, vai notar que a bola não interage com as paredes como esperado. Para conseguir esse efeito, você precisa adicionar a detecção de colisão, o que será feito na próxima etapa.

6. Bounce around

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.

Para adicionar a detecção de colisão ao jogo, 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 tick 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 vai criar uma caixa de colisão para detecção de colisão que corresponde ao tamanho do componente pai. Há um construtor de fábrica para RectangleHitbox chamado relative para momentos em que você quer uma hitbox menor ou maior que o componente pai.

Jogar a bola

Até agora, adicionar a detecção de colisão não fez diferença na jogabilidade. Ele muda quando você modifica o componente Ball. É o comportamento da bola que precisa mudar quando ela colide com o 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.
}

Esse exemplo faz uma mudança importante 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 do jogo. Isso vai mudar na próxima etapa, quando você adicionar um morcego ao mundo. Em seguida, ele também adiciona uma condição else para processar quando a bola colidir com objetos que não são o taco. Um lembrete para implementar a lógica restante, se quiser.

Quando a bola colide com a parede de baixo, ela simplesmente desaparece da superfície de jogo, mas continua visível. Você vai lidar com esse artefato em uma etapa futura, usando o poder dos efeitos da Chama.

Agora que a bola está colidindo com as paredes do jogo, seria útil dar ao jogador uma raquete para bater na bola...

7. Bater na bola

Criar o morcego

Para adicionar uma raquete para manter a bola em 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 uma explicação. Para interagir com a bola neste jogo, o jogador pode arrastar o taco com o mouse ou o dedo, dependendo da plataforma, ou usar o teclado. A constante batStep configura a distância percorrida pelo morcego para cada pressionamento da tecla de seta para a esquerda ou direita.

  1. Defina a classe do componente Bat da seguinte 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 isso, ele substitui o callback render.

Observando atentamente a chamada canvas.drawRRect (desenhar 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 você vai encontrar esse tipo de abreviação com frequência no código de nível inferior do Flutter e do Flame.

Em segundo lugar, esse 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 a esse morcego para se mover para a esquerda ou direita por um determinado número de pixels virtuais. Essa função apresenta um novo recurso do mecanismo de jogo Flame: Effects. Ao adicionar o objeto MoveToEffect como filho desse componente, o morcego aparece animado em uma nova posição. Há uma coleção de Effects disponíveis no Flame para realizar vários efeitos.

Os argumentos do construtor do Effect incluem uma referência ao getter game. É por isso que você inclui o mixin HasGameReference nessa classe. Esse mixin adiciona um accessor game seguro de tipo a esse componente para acessar a instância BrickBreaker na parte de cima 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';

Adicionar o morcego ao mundo

Para adicionar o componente Bat ao mundo do jogo, atualize BrickBreaker da seguinte 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(                                                  // Add from here...
     
Bat(
       
size: Vector2(batWidth, batHeight),
       
cornerRadius: const Radius.circular(ballRadius / 2),
       
position: Vector2(width / 2, height * 0.95),
     
),
   
);                                                          // To here.

   
debugMode = true;
 
}

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

A adição do mixin KeyboardEvents e do método onKeyEvent substituído processam a entrada do teclado. Lembre-se do código que você adicionou anteriormente para mover o morcego pelo valor de etapa adequado.

O restante do código adicionado adiciona o morcego ao mundo do jogo na posição e nas proporções corretas. Ter todas essas configurações expostas neste arquivo simplifica sua capacidade de ajustar o tamanho relativo da raquete e da bola para ter a sensação certa do jogo.

Se você jogar o jogo neste ponto, vai perceber que pode mover a raquete para interceptar a bola, mas não vai receber nenhuma resposta visível, além do registro de depuração que você deixou no código de detecção de colisão de Ball.

É hora de corrigir isso. 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(delay: 0.35));                         // Modify from here...
     
}
   
} else if (other is Bat) {
     
velocity.y = -velocity.y;
     
velocity.x =
         
velocity.x +
         
(position.x - other.position.x) / other.size.x * game.width * 0.3;
   
} else {                                                    // To here.
     
debugPrint('collision with $other');
   
}
 
}
}

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

Primeiro, ele corrige a bola que desaparece no momento em que toca a parte de baixo da tela. Para corrigir esse problema, substitua a chamada removeFromParent por RemoveEffect. O RemoveEffect remove a bola do mundo do jogo depois de deixá-la sair da área de jogo visível.

Em segundo lugar, essas mudanças corrigem o processamento da colisão entre a raquete e a bola. Esse código de tratamento funciona muito a favor do jogador. Enquanto o jogador toca na bola com o taco, ela volta para a parte de cima da tela. Se isso parecer muito tolerante e você quiser algo mais realista, mude o tratamento para se adequar melhor à sensação que você quer que o jogo tenha.

Vale a pena apontar a complexidade da atualização velocity. Ele não inverte apenas 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 da raquete e da bola no momento do contato. Isso dá ao jogador mais controle sobre o que a bola faz, mas não é comunicado ao jogador exatamente como isso acontece, exceto durante o jogo.

Agora que você tem uma raquete para bater na bola, seria legal ter alguns tijolos para quebrar com a bola.

8. Quebre a barreira

Como criar os tijolos

Para adicionar tijolos 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>());
   
}
 
}
}

Até agora, a maior parte desse código deve ser familiar. Esse código usa um RectangleComponent, com detecção de colisão e uma referência segura de tipo para o jogo BrickBreaker na parte de cima da árvore de componentes.

O conceito mais importante que este código apresenta é como o jogador alcança a condição de vitória. A verificação da condição de vitória consulta o mundo em busca de tijolos e confirma que apenas um deles permanece. Isso pode ser um pouco confuso, porque a linha anterior remove esse bloco do elemento pai.

O ponto principal a ser entendido é que a remoção de componentes é um comando em fila. Ele remove o bloco depois que esse código é executado, mas antes do próximo tick do mundo do jogo.

Para tornar o componente Brick acessível a 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';

Adicionar blocos ao mundo

Atualize 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';                                            // 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.
   
}
 
}
}

Isso introduz o único aspecto novo, um modificador de dificuldade que aumenta a velocidade da bola após cada colisão com o tijolo. Esse parâmetro ajustável precisa ser testado para encontrar a curva de dificuldade adequada para o jogo.

Edite o jogo BrickBreaker da seguinte 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';

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 como está, ele vai mostrar todas as mecânicas principais. Você pode desativar a depuração e considerar o trabalho concluído, mas algo parece estar faltando.

Uma captura de tela mostrando brick_breaker com bola, taco e a maioria dos tijolos na área de jogo. Cada um dos componentes 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 é isso que você vai fazer em seguida.

9. Vencer o jogo

Adicionar estados de reprodução

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

Primeiro, você modifica os arquivos do jogo e do componente para implementar um estado de jogo que reflita se uma sobreposição será mostrada e, em caso afirmativo, qual.

  1. Modifique o jogo BrickBreaker da seguinte 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 muito do jogo BrickBreaker. Adicionar a enumeração playState exige muito trabalho. Isso captura onde o jogador está ao entrar, jogar e perder ou ganhar 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 um novo método startGame. Antes dessa mudança, só era possível iniciar um novo jogo reiniciando o jogo. Com essas novas adições, o jogador agora pode iniciar um novo jogo sem medidas tão drásticas.

Para permitir que o jogador inicie uma nova partida, você configurou dois novos manipuladores para o jogo. Você adicionou um manipulador de toque e estendeu o manipulador de teclado para permitir que o usuário inicie um novo jogo em várias modalidades. Com o estado de jogo modelado, faz sentido atualizar os componentes para acionar as transições de estado de jogo quando o jogador vence 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 vai parecer certo se o jogador permitir que a bola escape da parte de baixo da tela.

  1. Edite o componente Brick da seguinte maneira.

lib/src/components/brick.dart

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

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

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

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

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

Por outro lado, se o jogador conseguir quebrar todos os tijolos, ele vai ganhar uma tela de "jogo ganho". Parabéns, jogador!

Adicionar o wrapper do Flutter

Para fornecer um local 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(
       
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 desse arquivo segue um build de árvore de widgets padrão 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 com as sobreposições que o setter playState em BrickBreaker adicionou ou removeu. Tentar definir uma sobreposição que não está neste mapa gera rostos infelizes.

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

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, a saída esperada será exibida no jogo. Se você segmentar o macOS ou o Android, vai precisar de um último ajuste para permitir a exibição de google_fonts.

Como ativar o acesso à fonte

Adicionar permissão de internet para Android

Para Android, adicione a permissão de acesso à Internet. Edite o AndroidManifest.xml da seguinte 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 direito de acesso para macOS

No macOS, você precisa editar dois arquivos.

  1. Edite o arquivo DebugProfile.entitlements para que ele corresponda ao código abaixo.

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 que ele corresponda ao código abaixo

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>

A execução desse código vai mostrar uma tela de boas-vindas e uma tela de vitória ou de fim de jogo em todas as plataformas. Essas telas podem ser um pouco simplistas, e seria bom ter uma pontuação. Então, adivinhe o que você vai fazer na próxima etapa!

10. Manter a pontuação

Adicionar pontuação ao jogo

Nesta etapa, você vai expor a pontuação do jogo ao contexto do Flutter. Nesta etapa, você expõe 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 quebra um tijolo.

  1. Modifique o jogo BrickBreaker da seguinte 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 quebra 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>());
   
}
 
}
}

Criar um jogo bonito

Agora que você pode manter a pontuação no Flutter, é hora de montar os widgets para que eles fiquem bonitos.

  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 em lib/src/widgets e adicione o seguinte código.

Isso dá mais destaque às sobreposições usando o poder 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 saber mais sobre o poder do flutter_animate, confira o codelab Criar interfaces de última geração no Flutter.

Esse código mudou muito no componente GameApp. Primeiro, para permitir que ScoreCard acesse o score , converta-o de StatelessWidget para StatefulWidget. A adição da visão geral de pontuação requer a adição de um Column para empilhar a pontuação acima do jogo.

Em segundo lugar, para melhorar as experiências de boas-vindas, de fim de jogo e de vitória, 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(
       
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 em ordem, agora você pode executar esse jogo em qualquer uma das seis plataformas de destino do Flutter. O jogo deve ser parecido com este.

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

Captura de tela do brick_breaker mostrando a tela de fim de jogo sobreposta a um taco e alguns tijolos

11. Parabéns

Parabéns! Você conseguiu criar um jogo com o Flutter e o Flame.

Você criou um jogo usando o mecanismo de jogo Flame 2D e o incorporou a um wrapper do Flutter. Você usou os efeitos do Flame para animar e remover componentes. Você usou os pacotes Google Fonts e Flutter Animate para deixar o jogo com uma aparência bem projetada.

A seguir

Confira alguns destes codelabs:

Leitura adicional