1. Introdução
O Flame é um mecanismo de jogos 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 o taco, a bola e os blocos. Você vai usar os efeitos do Flame para animar o movimento do morcego e aprender a integrar o Flame ao sistema de gerenciamento de estado do Flutter.
Quando concluído, o jogo vai ficar parecido com este GIF animado, mas um pouco mais lento.
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
Component
s do Flame funcionam. Eles são semelhantes aosWidget
s do Flutter. - Como lidar com colisões.
- Como usar
Effect
s para animarComponent
s. - Como sobrepor
Widget
s 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 concluído, o jogo precisa atender aos seguintes requisitos:
- Funcionar em todas as seis plataformas compatíveis com o Flutter: Android, iOS, Linux, macOS, Windows e Web
- Mantenha pelo menos 60 fps usando o loop de jogo do Flame.
- Use recursos do Flutter, como o pacote
google_fonts
e oflutter_animate
, para recriar a sensação dos jogos de arcade 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 neste 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.
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. É o sistema operacional em que o app é executado durante o desenvolvimento.
Por exemplo, digamos que você esteja usando um laptop Windows para desenvolver seu app do Flutter. Em seguida, escolha 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 Android. Você pode ter escolhido o Windows como a plataforma de desenvolvimento, que executa o app em desenvolvimento como um aplicativo do Windows no seu editor.
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 estão 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 à plataforma de desenvolvimento e dos plug-ins do editor. Para este codelab, instale o seguinte software:
- SDK do Flutter.
- Visual Studio Code com o plug-in do Flutter.
- Software de compilador para a plataforma de desenvolvimento escolhida. Você precisa do Visual Studio para segmentar o Windows ou do Xcode para segmentar o macOS ou o iOS.
Na próxima seção, você vai criar seu primeiro projeto do Flutter.
Se você precisar resolver problemas, pode achar algumas destas perguntas e respostas do StackOverflow úteis.
Perguntas frequentes
- Como encontrar o caminho do SDK do Flutter?
- O que fazer quando o comando do Flutter não é encontrado?
- Como corrigir o problema "Aguardando outro comando do Flutter para liberar o bloqueio de inicialização"?
- Como dizer ao Flutter onde está minha instalação do SDK do Android?
- Como lidar com o erro de Java ao executar
flutter doctor --android-licenses
? - O que fazer quando a ferramenta
sdkmanager
do Android não for encontrada? - Como lidar com o erro "o componente
cmdline-tools
está ausente"? - Como executar o CocoaPods no Apple Silicon (M1)?
- Como desativar a autoformatação ao salvar no VS Code?
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 de sua escolha.
- Inicie o Visual Studio Code.
- Abra a paleta de comandos (
F1
,Ctrl+Shift+P
ouShift+Cmd+P
) e digite "flutter new". Quando ele aparecer, selecione o comando Flutter: New Project.
- Selecione Aplicativo vazio. Escolha um diretório para criar o projeto. Esse diretório não pode exigir privilégios elevados nem ter um espaço no caminho. Por exemplo, seu diretório inicial ou
C:\src\
.
- Nomeie o projeto como
brick_breaker
. O restante deste codelab pressupõe que você nomeou seu app comobrick_breaker
.
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 código de exemplo fornecido neste codelab ao seu app.
- No painel esquerdo do VS Code, clique em Explorer e abra o arquivo
pubspec.yaml
.
- 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á.
- Abra o arquivo
main.dart
no diretóriolib/
.
- 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));
}
- Execute este código para verificar se tudo está funcionando. Uma nova janela vai aparecer com apenas um plano de fundo preto em branco. O pior videogame do mundo agora está sendo renderizado a 60 QPS!
4. Criar o jogo
Avalie o jogo
Um jogo em duas dimensões (2D) precisa de uma área de jogo. Você vai construir uma área de dimensões específicas e usar essas dimensões para dimensionar outros aspectos do jogo.
Há várias maneiras de organizar as coordenadas na área de jogo. Por uma convenção, é possível medir a direção 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 os 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 era para a direita e y era para baixo. Para manter a fidelidade à época, esse 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 etapas a seguir.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
O jogo terá 820 pixels de largura e 1.600 pixels de altura. A área de jogo é dimensionada para se ajustar à janela em que é exibida, mas todos os componentes adicionados à tela obedecem a essa altura e largura.
Criar uma PlayArea
No jogo Breakout, a bola quica nas paredes da área de jogo. Para acomodar colisões, primeiro você precisa de um componente PlayArea
.
- Crie um arquivo chamado
play_area.dart
em um novo diretório chamadolib/src/components
. - 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 Widget
s, o Flame tem Component
s. Enquanto os apps do Flutter consistem em criar árvores de widgets, os jogos do Flame consistem em manter árvores de componentes.
Essa é uma diferença interessante entre o Flutter e o Flame. A árvore de widgets do Flutter é uma descrição efêmera criada para ser usada na atualização da camada RenderObject
persistente e mutável. Os componentes do Flame são persistentes e mutáveis, e espera-se que o desenvolvedor os use como parte de um sistema de simulação.
Os componentes do Flame são otimizados para expressar mecânicas de jogos. Este codelab vai começar com o loop do jogo, que será apresentado na próxima etapa.
- Para controlar a desordem, adicione um arquivo com todos os componentes deste projeto. Crie um arquivo
components.dart
emlib/src/components
e adicione o seguinte conteúdo.
lib/src/components/components.dart
export 'play_area.dart';
A diretiva export
tem a função inversa de import
. Ele declara qual funcionalidade esse arquivo expõe quando importado para outro arquivo. Esse arquivo vai receber mais entradas à medida que você adicionar novos componentes nas etapas a seguir.
Criar um jogo com o Flame
Para eliminar os rabiscos vermelhos da etapa anterior, derive uma nova subclasse para o FlameGame
do Flame.
- Crie um arquivo chamado
brick_breaker.dart
emlib/src
e adicione o código a seguir.
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 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 se definir no tamanho adequado.
No método substituído onLoad
, seu código realiza duas ações.
- 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)
. - Adiciona o
PlayArea
aoworld
. O mundo representa o mundo do jogo. Ele projeta todos os filhos pela transformação de visualização doCameraComponent
.
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 ser parecido com a figura a seguir.
Na próxima etapa, você vai adicionar uma bola ao mundo e fazê-la se mover.
5. Mostrar a bola
Criar o componente da bola
Para colocar uma bola em movimento na tela, é necessário criar outro componente e adicioná-lo ao mundo do jogo.
- 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 projeto de definir constantes nomeadas como valores derivados vai aparecer muitas vezes neste codelab. Isso permite modificar os gameWidth
e gameHeight
de nível superior para explorar como a aparência do jogo muda como resultado.
- Crie o componente
Ball
em um arquivo chamadoball.dart
emlib/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;
}
}
Antes, você definiu o PlayArea
usando o RectangleComponent
. Portanto, é razoável que existam mais formas. CircleComponent
, assim como RectangleComponent
, deriva de PositionedComponent
, para que você possa posicionar a bola na tela. Mais importante ainda, a posição pode ser atualizada.
Esse componente apresenta o conceito de velocity
, ou mudança de posição ao longo do tempo. A velocidade é um objeto Vector2
, já que velocidade é tanto velocidade quanto 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 taxas de frames (60 Hz ou 120 Hz) ou frames longos devido ao excesso de computação.
Preste atenção à atualização do position += velocity * dt
. É assim que você implementa a atualização de uma simulação discreta de movimento ao longo do tempo.
- Para incluir o componente
Ball
na lista de componentes, edite o arquivolib/src/components/components.dart
da seguinte maneira.
lib/src/components/components.dart
export 'ball.dart'; // Add this export
export 'play_area.dart';
Adicionar a bola ao mundo
Você tem uma bola. Coloque-o no mundo e configure para que ele se mova 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 o position
da bola como o centro da área de exibição, o código primeiro divide o tamanho do jogo pela metade, 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 para baixo na tela em uma direção aleatória a uma velocidade razoável. A chamada ao método normalized
cria um objeto Vector2
definido na 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 em qual direção ela vá. A velocidade da bola é aumentada para ser um quarto da altura do jogo.
Para acertar esses valores, é necessário fazer algumas iterações, também conhecidas como testes de jogo no setor.
A última linha ativa a exibição de depuração, que adiciona mais informações para ajudar na depuração.
Ao executar o jogo, ele vai ficar parecido com a tela abaixo.
Os componentes PlayArea
e Ball
têm informações de depuração, mas os planos de fundo cortam os números de PlayArea
. O motivo de tudo ter informações de depuração exibidas é que você ativou 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 notar que a bola não interage com as paredes como esperado. Para conseguir esse efeito, adicione a detecção de colisões, 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 a seguir.
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 tique do jogo.
Para começar a preencher as hitboxes do jogo, modifique o componente PlayArea
conforme mostrado:
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 impacto para detecção de colisões que corresponda ao tamanho do componente pai. Há um construtor de fábrica para RectangleHitbox
chamado relative
para quando você quer uma caixa de seleção menor ou maior que o componente pai.
Quicar a bola
Até agora, adicionar a detecção de colisões 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.
}
Este exemplo faz uma grande mudança com a adição do callback onCollisionStart
. O sistema de detecção de colisões adicionado a BrickBreaker
no exemplo anterior chama esse callback.
Primeiro, o código testa se o Ball
colidiu com o 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 lidar com o momento em que a bola colide com coisas 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 desaparece da superfície de jogo, mas ainda fica bem visível. Você vai processar esse artefato em uma etapa futura usando o poder dos efeitos do Flame.
Agora que a bola está colidindo com as paredes do jogo, seria útil dar ao jogador um taco para acertar a bola.
7. Acertar a bola com o taco
Criar o morcego
Para adicionar um taco e manter a bola em jogo,
- 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 nesse 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 que o morcego se move para cada pressionamento de tecla de seta para a esquerda ou direita.
- Defina a classe de 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
.
Ao analisar de perto 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 dart:ui
Offset
que cria Rect
s. Essa abreviação pode confundir você no início, mas ela aparece com frequência em códigos do Flutter e do Flame de nível mais baixo.
Em segundo lugar, esse componente Bat
pode ser arrastado com 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 morcego para se mover para a esquerda ou para a direita por um determinado número de pixels virtuais. Essa função apresenta um novo recurso do mecanismo de jogo Flame: Effect
s. Ao adicionar o objeto MoveToEffect
como filho desse componente, o jogador vê o morcego animado em uma nova posição. Há uma coleção de Effect
s disponíveis no Flame para realizar vários efeitos.
Os argumentos do construtor do Effect incluem uma referência ao getter game
. Por isso, você inclui o mixin HasGameReference
nessa classe. Essa 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.
- Para disponibilizar o
Bat
paraBrickBreaker
, atualize o arquivolib/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';
Adicione 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 de teclado. Lembre-se do código que você adicionou antes para mover o morcego pela quantidade de etapas adequada.
O restante do código adicionado coloca o morcego no mundo do jogo na posição adequada e com as proporções corretas. Ter todas essas configurações expostas nesse arquivo simplifica sua capacidade de ajustar o tamanho relativo do taco e da bola para ter a sensação certa do jogo.
Se você jogar agora, vai perceber que é possível mover o taco para interceptar a bola, mas não há resposta visível, além do registro de depuração que você deixou no código de detecção de colisões 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 separados.
Primeiro, ele corrige o problema da 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 que ela sai da área de jogo visível.
Em segundo lugar, essas mudanças corrigem o processamento da colisão entre o taco e a bola. Esse código de manipulação funciona muito a favor do jogador. Enquanto o jogador tocar na bola com o taco, ela vai voltar para a parte de cima da tela. Se isso parecer muito fácil e você quiser algo mais realista, mude esse processamento para se adequar melhor à sensação que você quer que o jogo tenha.
Vale a pena destacar a complexidade da atualização do velocity
. Ele não apenas inverte o componente y
da velocidade, como foi feito para as colisões com a parede. Ele também atualiza o componente x
de uma maneira 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 não é comunicado 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 ela.
8. Derrube a parede
Criar os blocos
Para adicionar blocos ao jogo,
- 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.
- 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>());
}
}
}
A maior parte desse código já é conhecida. Esse código usa um RectangleComponent
, com detecção de colisões e uma referência com segurança de tipo ao jogo BrickBreaker
na parte superior da árvore de componentes.
O novo conceito mais importante introduzido por esse código é 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 blocos e confirma que apenas um permanece. Isso pode ser um pouco confuso, porque a linha anterior remove esse bloco do elemento pai.
O ponto principal é entender que a remoção de componentes é um comando enfileirado. 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
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.
}
}
}
Isso apresenta o único aspecto novo, um modificador de dificuldade que aumenta a velocidade da bola após cada colisão com um bloco. Esse parâmetro ajustável precisa ser testado para encontrar a curva de dificuldade adequada ao seu jogo.
Edite o jogo BrickBreaker
da seguinte 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, todas as principais mecânicas serão exibidas. Você pode desativar a depuração e considerar o trabalho concluído, mas algo parece estar faltando.
Que tal uma tela de boas-vindas, uma de fim de jogo e talvez uma pontuação? O Flutter pode adicionar esses recursos ao jogo, e é nisso que você vai se concentrar agora.
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, fim de jogo e vitória.
Primeiro, modifique os arquivos de jogo e componente para implementar um estado de jogo que reflita se uma sobreposição deve ser mostrada e, em caso afirmativo, qual.
- 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 bastante o 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 reprodução.
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 pode iniciar um novo jogo sem medidas tão drásticas.
Para permitir que o jogador inicie um novo jogo, 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 transições de estado quando o jogador vence ou perde.
- 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 certo se o player permitir que a bola escape da parte de baixo da tela.
- 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". Muito bem, jogador!
Adicionar o wrapper do Flutter
Para fornecer um lugar para incorporar o jogo e adicionar sobreposições de estado de jogo, adicione o shell do Flutter.
- Crie um diretório
widgets
emlib/src
. - 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 padrão da árvore de widgets do Flutter. As partes específicas do Flame incluem o uso de GameWidget.controlled
para construir e gerenciar a instância do jogo BrickBreaker
e o novo argumento overlayBuilderMap
para o GameWidget
.
As chaves de 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á nesse mapa resulta em rostos tristes por toda parte.
- Para mostrar 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 pretendida vai aparecer no jogo. Se você segmentar macOS ou Android, será necessário fazer um último ajuste para ativar a exibição de google_fonts
.
Ativar o acesso a fontes
Adicionar permissão de internet para Android
No Android, adicione a permissão de Internet. Edite o AndroidManifest.xml
da seguinte forma.
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.
- Edite o arquivo
DebugProfile.entitlements
para corresponder ao código a seguir.
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>
- 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 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 ao redor. 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 quebrar um bloco.
- 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.
- Modifique a classe
Brick
para adicionar um ponto à pontuação quando o jogador quebrar blocos.
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 boa aparência
Agora que você pode manter a pontuação no Flutter, é hora de juntar os widgets para que ele fique bonito.
- Crie
score_card.dart
emlib/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!,
),
);
},
);
}
}
- Crie
overlay_screen.dart
emlib/src/widgets
e adicione o seguinte código.
Isso adiciona mais refinamento à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 um StatelessWidget
para StatefulWidget
. Para adicionar o quadro de pontuação, é necessário incluir um Column
para empilhar a pontuação acima do jogo.
Em segundo lugar, para melhorar as experiências de boas-vindas, fim de jogo e 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 configurado, agora você pode executar o jogo em qualquer uma das seis plataformas de destino do Flutter. O jogo será parecido com o seguinte.
11. Parabéns
Parabéns! Você criou um jogo com o Flutter e o Flame.
Você criou um jogo usando o mecanismo de jogo 2D do Flame e o incorporou em 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 um design bem feito.
A seguir
Confira alguns destes codelabs:
- Como criar interfaces de última geração no Flutter
- Deixe seu app do Flutter lindo, não chato
- Adicionar compras no app ao seu app Flutter