1. Introdução
Aprenda a criar um jogo de plataforma com o Flutter e o Flame. No jogo Doodle Dash, inspirado em Doodle Jump, você joga como Dash (mascote do Flutter) ou o melhor amigo dela, Sparky (mascote do Firebase), e tenta chegar o mais alto possível pulando em plataformas.
O que você vai aprender
- Como construir um jogo multiplataforma no Flutter.
- Como criar componentes de jogo reutilizáveis que podem ser renderizados e atualizados como parte do loop de jogo do Flame.
- Como controlar e animar os movimentos do seu personagem (chamados de sprite) com a física do jogo.
- Como adicionar e gerenciar a detecção de colisão.
- Como adicionar entrada de teclado e por toque como controles para o jogo.
Pré-requisitos
Este codelab presume que você tem alguma experiência com o Flutter. Caso contrário, você pode aprender as noções básicas no codelab Seu primeiro app do Flutter.
O que você vai criar
Este codelab orienta você sobre a criação de um jogo chamado Doodle Dash: um jogo de plataforma com a Dash, mascote do Flutter, ou o Sparky, mascote do Firebase. Este codelab foca na Dash, mas as etapas também se aplicam ao Sparky. O jogo terá os recursos abaixo:
- Um sprite que pode se mover na horizontal e vertical.
- Plataformas geradas aleatoriamente.
- Um efeito de gravidade que puxa o sprite para baixo.
- Menus do jogo.
- Controles no jogo, como para pausar e jogar de novo.
- Funcionalidade para salvar a pontuação.
Jogabilidade
Para jogar Doodle Dash, é preciso mover a Dash para a esquerda e para a direita, pulando em plataformas e usando poderes para aumentar as habilidades dela ao longo do jogo. Inicie o jogo escolhendo o nível de dificuldade (1 a 5) e clicando em Start (iniciar).
Níveis
Há cinco níveis no jogo. Cada nível (após o nível 1) desbloqueia novos recursos.
- Nível 1 (padrão): este nível usa as plataformas
NormalPlatform
eSpringBoard
. Quando criada, toda plataforma tem 20% de chance de ser móvel. - Nível 2 (pontuação >= 20): adiciona a
BrokenPlatform
, que só pode ser tocada uma vez. - Nível 3 (pontuação >= 40): desbloqueia o poder
NooglerHat
. Essa plataforma especial dura 5 segundos e aumenta a capacidade de salto de Dash em 2,5x da velocidade normal. Ela também usa um chapéu de Noogler legal durante esses 5 segundos. - Nível 4 (pontuação >=80): desbloqueia o poder
Rocket
. Essa plataforma especial, representada por um foguete, torna Dash invencível. Também aumenta a capacidade de salto de Dash em 3,5x da velocidade normal. - Nível 5 (pontuação >= 100): desbloqueia plataformas
Enemy
. Se a Dash encostar em um inimigo, o jogo vai terminar automaticamente.
Tipos de plataforma por nível
Nível 1 (padrão)
|
|
Nível 2 (pontuação >= 20) | Nível 3 (pontuação >= 40) | Nível 4 (pontuação >= 80) | Nível 5 (pontuação >= 100) |
|
|
|
|
Perder o jogo
Há duas maneiras de perder o jogo:
- Dash cai pela parte de baixo da tela.
- Dash encosta em um inimigo (inimigos aparecem no nível 5).
Poderes
Os poderes melhoram as habilidades de jogo da personagem, como aumentar a velocidade de salto, permitir que ela se torne "invencível" contra inimigos ou ambos. O Doodle Dash tem duas opções de poder. Somente um poder fica ativo por vez.
- O poder do chapéu de Noogler aumenta a capacidade de salto de Dash em 2,5x da altura de salto normal. Além disso, ela usa um chapéu de Noogler quando está com esse poder.
- O poder de foguete torna Dash invencível contra inimigos, ou seja, a colisão com um inimigo não tem efeito. Também aumenta em 3,5x a altura do salto. Ela voa em um foguete até que o combustível acaba e ela cai em uma plataforma.
2. Fazer o download do código inicial do codelab
Faça o download da versão inicial do seu projeto no GitHub:
- Na linha de comando, clone o repositório do GitHub (link em inglês) em um diretório
flutter-codelabs
:
git clone https://github.com/flutter/codelabs.git flutter-codelabs
O código para este codelab está no diretório flutter-codelabs/flame-building-doodle-dash
. O diretório contém o código do projeto concluído para cada etapa do codelab.
Importar o app inicial
- Importe o diretório
flutter-codelabs/flame-building-doodle-dash/step_02
no ambiente de desenvolvimento integrado de sua preferência.
Instalar pacotes:
- Todos os pacotes necessários, como o Flame, já foram adicionados ao arquivo
pubspec.yaml
do projeto. Se o ambiente de desenvolvimento integrado não instalar dependências automaticamente, abra um terminal de linha de comando e, na raiz do projeto Flutter, execute o comando abaixo para extrair as dependências do projeto:
flutter pub get
Configurar o ambiente de desenvolvimento do Flutter
Para concluir este codelab, você precisa do seguinte:
- SDK do Flutter (link em inglês).
- Um editor de código (link em inglês).
3. Tour pelo código
Em seguida, faça um tour pelo código.
Consulte o arquivo lib/game/doodle_dash.dart
, que contém o jogo DoodleDash que estende o FlameGame
. Você registra seus componentes com a instância FlameGame
, o componente mais básico do Flame (semelhante a um Scaffold
do Flutter), e ele renderiza e atualiza todos os componentes registrados durante o jogo. Pense nisso como o sistema nervoso central do jogo.
O que são componentes? Da mesma forma que um app do Flutter é composto de Widgets
, um FlameGame
é constituído por Components
: todos os elementos básicos que compõem o jogo. Assim como os widgets do Flutter, os componentes também podem ter filhos. O sprite de um personagem, o segundo plano do jogo e o objeto responsável por gerar novos componentes (como os inimigos) são exemplos de componentes. Na verdade, o FlameGame
em si é um Component
. O Flame chama isso de Flame Component System.
Os componentes herdam de uma classe Component
(link em inglês) abstrata. Implemente os métodos abstratos do Component
para criar a mecânica da classe FlameGame
. Por exemplo, você vai encontrar os métodos abaixo implementados com frequência no DoodleDash:
onLoad
: inicializa um componente de forma assíncrona, de modo semelhante ao métodoinitState
do Flutter.update
: atualiza um componente a cada marcação do loop do jogo, de modo semelhante ao métodobuild
do Flutter.
Além disso, o método add
registra componentes com o mecanismo do Flame.
Por exemplo, o arquivo lib/game/world.dart
contém a classe World
, que estende ParallaxComponent
(link em inglês) para renderizar o segundo plano do jogo. Essa classe recebe uma lista de recursos de imagem e os renderiza em camadas, fazendo com que cada camada se mova em uma velocidade diferente para torná-la mais realista. A classe DoodleDash
contém uma instância ParallaxComponent
e a adiciona ao jogo no método onLoad
do DoodleDash:
lib/game/world.dart
class World extends ParallaxComponent<DoodleDash> {
@override
Future<void> onLoad() async {
parallax = await gameRef.loadParallax(
[
ParallaxImageData('game/background/06_Background_Solid.png'),
ParallaxImageData('game/background/05_Background_Small_Stars.png'),
ParallaxImageData('game/background/04_Background_Big_Stars.png'),
ParallaxImageData('game/background/02_Background_Orbs.png'),
ParallaxImageData('game/background/03_Background_Block_Shapes.png'),
ParallaxImageData('game/background/01_Background_Squiggles.png'),
],
fill: LayerFill.width,
repeat: ImageRepeat.repeat,
baseVelocity: Vector2(0, -5),
velocityMultiplierDelta: Vector2(0, 1.2),
);
}
}
lib/game/doodle_dash.dart
class DoodleDash extends FlameGame
with HasKeyboardHandlerComponents, HasCollisionDetection {
...
final World _world = World();
...
@override
Future<void> onLoad() async {
await add(_world);
...
}
}
Gerenciamento do estado
O diretório lib/game/managers
contém arquivos que lidam com o gerenciamento do estado do Doodle Dash: game_manager.dart
, object_manager.dart
e level_manager.dart
.
A classe GameManager
(em game_manager.dart
) monitora o estado geral do jogo e a pontuação.
A classe ObjectManager
(em object_manager.dart
) gerencia onde e quando as plataformas são criadas e removidas. Você vai adicionar elementos a essa classe mais tarde.
E, por fim, a classe LevelManager
(em level_manager.dart
) gerencia o nível de dificuldade do jogo e qualquer configuração relevante para quando os jogadores avançam de nível. O jogo oferece cinco níveis de dificuldade: o jogador passa para o próximo nível ao atingir um dos marcos de pontuação. Cada avanço de nível aumenta a dificuldade e o quanto a Dash precisa saltar. Como a gravidade é constante ao longo do jogo, a velocidade do salto aumenta gradualmente para compensar a distância maior.
A pontuação aumenta sempre que o jogador passa por uma plataforma. Quando o jogador atinge certos marcos de pontuação, o jogo avança de nível e desbloqueia novas plataformas especiais que tornam o jogo mais divertido e desafiador.
4. Adicionar um jogador
Esta etapa adiciona um personagem ao jogo, neste caso, a Dash (link em inglês). O jogador controla a personagem, e toda a lógica reside na classe Player
(no arquivo player.dart
). A classe Player
estende a classe SpriteGroupComponent
do Flame, que contém métodos abstratos que você substitui para implementar a lógica personalizada. Isso inclui carregar recursos e sprites, posicionar o jogador na horizontal e na vertical, configurar a detecção de colisão e aceitar a entrada do usuário.
Carregar recursos
A Dash aparece com sprites variados, que representam diferentes versões do personagem e poderes. Por exemplo, os ícones abaixo mostram a Dash e o Sparky olhando para a esquerda, para a tela, e para a direita.
O SpriteGroupComponent
(link em inglês) do Flame permite gerenciar vários estados de sprite com a propriedade sprites
, como você pode conferir no método _loadCharacterSprites
.
Na classe Player
, adicione as linhas abaixo ao método onLoad
para carregar os recursos de sprite e definir o estado do sprite Player
para olhar para frente:
lib/game/sprites/player.dart
@override
Future<void> onLoad() async {
await super.onLoad();
await _loadCharacterSprites(); // Add this line
current = PlayerState.center; // Add this line
}
Confira o código para carregar os sprites e recursos em _loadCharacterSprites
. Esse código poderia ser implementado diretamente no método onLoad
, mas colocá-lo em um método separado organiza o código-fonte e o torna mais legível. Esse método atribui um mapa à propriedade sprites
, que associa cada estado de caractere a um recurso de sprite carregado, conforme mostrado abaixo:
lib/game/sprites/player.dart
Future<void> _loadCharacterSprites() async {
final left = await gameRef.loadSprite('game/${character.name}_left.png');
final right = await gameRef.loadSprite('game/${character.name}_right.png');
final center =
await gameRef.loadSprite('game/${character.name}_center.png');
final rocket = await gameRef.loadSprite('game/rocket_4.png');
final nooglerCenter =
await gameRef.loadSprite('game/${character.name}_hat_center.png');
final nooglerLeft =
await gameRef.loadSprite('game/${character.name}_hat_left.png');
final nooglerRight =
await gameRef.loadSprite('game/${character.name}_hat_right.png');
sprites = <PlayerState, Sprite>{
PlayerState.left: left,
PlayerState.right: right,
PlayerState.center: center,
PlayerState.rocket: rocket,
PlayerState.nooglerCenter: nooglerCenter,
PlayerState.nooglerLeft: nooglerLeft,
PlayerState.nooglerRight: nooglerRight,
};
}
Atualizar o componente do jogador
O Flame chama o método update
de um componente uma vez a cada marcação (ou frame) do loop de eventos para renderizar de novo cada componente do jogo que foi alterado, de modo semelhante ao método build
do Flutter. Em seguida, adicione lógica ao método update
da classe Player
para posicionar a personagem na tela.
Adicione o código abaixo ao método update
da classe Player
para calcular a velocidade e a posição atuais do personagem:
lib/game/sprites/player.dart
void update(double dt) {
// Add lines from here...
if (gameRef.gameManager.isIntro || gameRef.gameManager.isGameOver) return;
_velocity.x = _hAxisInput * jumpSpeed; // ... to here.
final double dashHorizontalCenter = size.x / 2;
if (position.x < dashHorizontalCenter) { // Add lines from here...
position.x = gameRef.size.x - (dashHorizontalCenter);
}
if (position.x > gameRef.size.x - (dashHorizontalCenter)) {
position.x = dashHorizontalCenter;
} // ... to here.
// Core gameplay: Add gravity
position += _velocity * dt; // Add this line
super.update(dt);
}
Antes de mover o jogador, o método update
verifica se o jogo não está em um estado não jogável em que o jogador não pode se mover, como quando o jogo é carregado pela primeira vez ou no estado de fim do jogo.
Se o estado for jogável, a posição da Dash será calculada usando a equação new_position = current_position + (velocity * time-elapsed-since-last-game-loop-tick)
, ou, como mostrado no código:
position += _velocity * dt
Outro aspecto importante ao criar o Doodle Dash é incluir limites laterais infinitos. Assim, a Dash poderá saltar para fora da borda esquerda da tela e voltar pela direita e vice-versa.
Para implementar isso, verifique se a posição da Dash saiu do limite da borda esquerda ou direita da tela e, em caso afirmativo, faça ela aparecer na borda oposta.
Eventos principais
Inicialmente, o Doodle Dash é executado na Web e no computador, por isso precisa oferecer suporte à entrada do teclado para que os jogadores possam controlar o movimento da personagem. O método onKeyEvent
permite que o componente Player
reconheça pressionamentos de tecla de seta para determinar se a Dash vai olhar e se mover para a esquerda ou direita.
Dash olha para a esquerda ao se mover nessa direção | Dash olha para a direita ao se mover nessa direção |
Em seguida, implemente os movimentos horizontais da Dash, conforme definido na variável _hAxisInput
. Você também vai fazer com que a Dash vire para a direção em que ela está se movendo.
Modifique os métodos moveLeft
e moveRight
da classe Player
para definir a direção atual da Dash:
lib/game/sprites/player.dart
void moveLeft() {
_hAxisInput = 0;
current = PlayerState.left; // Add this line
_hAxisInput += movingLeftInput; // Add this line
}
void moveRight() {
_hAxisInput = 0;
current = PlayerState.right; // Add this line
_hAxisInput += movingRightInput; // Add this line
}
Modifique o método onKeyEvent
da classe Player
para chamar os métodos moveLeft
ou moveRight
, respectivamente, quando as teclas de seta para a esquerda ou para a direita forem pressionadas:
lib/game/sprites/player.dart
@override
bool onKeyEvent(RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
_hAxisInput = 0;
// Add lines from here...
if (keysPressed.contains(LogicalKeyboardKey.arrowLeft)) {
moveLeft();
}
if (keysPressed.contains(LogicalKeyboardKey.arrowRight)) {
moveRight();
} // ... to here.
// During development, it's useful to "cheat"
if (keysPressed.contains(LogicalKeyboardKey.arrowUp)) {
// jump();
}
return true;
}
Agora que a classe Player
está funcional, pode ser usada no Doodle Dash.
No arquivo DoodleDash, importe sprites.dart
, que disponibiliza a classe Player
:
lib/game/doodle_dash.dart
import 'sprites/sprites.dart'; // Add this line
Crie uma instância Player
na classe DoodleDash
:
lib/game/doodle_dash.dart
class DoodleDash extends FlameGame
with HasKeyboardHandlerComponents, HasCollisionDetection {
DoodleDash({super.children});
final World _world = World();
LevelManager levelManager = LevelManager();
GameManager gameManager = GameManager();
int screenBufferSpace = 300;
ObjectManager objectManager = ObjectManager();
late Player player; // Add this line
...
}
Em seguida, inicialize e configure a velocidade de salto do Player
com base no nível de dificuldade selecionado pelo jogador e adicione o componente Player
ao FlameGame
. Preencha o método setCharacter
com este código:
lib/game/doodle_dash.dart
void setCharacter() {
player = Player( // Add lines from here...
character: gameManager.character,
jumpSpeed: levelManager.startingJumpSpeed,
);
add(player); // ... to here.
}
Chame o método setCharacter
no início de initializeGameStart
.
lib/game/doodle_dash.dart
void initializeGameStart() {
setCharacter(); // Add this line
...
}
Além disso, em initializeGameStart
, chame resetPosition
para que o jogador volte à posição inicial sempre que um jogo novo começar.
lib/game/doodle_dash.dart
void initializeGameStart() {
...
levelManager.reset();
player.resetPosition(); // Add this line
objectManager = ObjectManager(
minVerticalDistanceToNextPlatform: levelManager.minDistance,
maxVerticalDistanceToNextPlatform: levelManager.maxDistance);
...
}
Execute o aplicativo. Inicie um jogo, e a Dash aparecerá na tela.
Problemas?
Caso o app não esteja executando corretamente, verifique se há erros de digitação. Se necessário, use o código nos links abaixo para colocar tudo de volta nos eixos (links em inglês).
5. Adicionar plataformas
Esta etapa adiciona plataformas (para a Dash pousar e saltar) e a lógica de detecção de colisão para determinar quando a Dash precisa saltar.
Primeiro, confira a classe abstrata Platform
:
lib/game/sprites/platform.dart
abstract class Platform<T> extends SpriteGroupComponent<T>
with HasGameRef<DoodleDash>, CollisionCallbacks {
final hitbox = RectangleHitbox();
bool isMoving = false;
Platform({
super.position,
}) : super(
size: Vector2.all(100),
priority: 2,
);
@override
Future<void>? onLoad() async {
await super.onLoad();
await add(hitbox);
}
}
O que é uma hitbox?
Cada componente da plataforma introduzido no Doodle Dash estende a classe abstrata Platform<T>
, que é um SpriteComponent
com uma hitbox. Com a hitbox, um componente de sprite pode detectar quando colide contra outros objetos com hitboxes. O Flame oferece suporte a várias formas de hitbox (link em inglês), como retângulos, círculos e polígonos. Por exemplo, o Doodle Dash usa uma hitbox retangular para plataformas e uma hitbox circular para a Dash. O Flame lida com o cálculo da colisão.
A classe Platform
(plataforma) adiciona uma hitbox e callbacks de colisão a todos os subtipos.
Adicionar uma plataforma padrão
A classe Platform
adiciona plataformas ao jogo. Uma plataforma normal é representada por um dos quatro elementos visuais escolhidos aleatoriamente: monitor, smartphone, terminal ou laptop. A escolha do elemento visual não afeta o comportamento da plataforma.
|
Para ter uma plataforma normal e estática, adicione um tipo enumerado NormalPlatformState
e uma classe NormalPlatform
(plataforma normal):
lib/game/sprites/platform.dart
enum NormalPlatformState { only } // Add lines from here...
class NormalPlatform extends Platform<NormalPlatformState> {
NormalPlatform({super.position});
final Map<String, Vector2> spriteOptions = {
'platform_monitor': Vector2(115, 84),
'platform_phone_center': Vector2(100, 55),
'platform_terminal': Vector2(110, 83),
'platform_laptop': Vector2(100, 63),
};
@override
Future<void>? onLoad() async {
var randSpriteIndex = Random().nextInt(spriteOptions.length);
String randSprite = spriteOptions.keys.elementAt(randSpriteIndex);
sprites = {
NormalPlatformState.only: await gameRef.loadSprite('game/$randSprite.png')
};
current = NormalPlatformState.only;
size = spriteOptions[randSprite]!;
await super.onLoad();
}
} // ... to here.
Em seguida, crie plataformas com que a personagem possa interagir.
A classe ObjectManager
estende a classe Component
do Flame e gera objetos Platform
ao longo do jogo. Implemente a capacidade de gerar plataformas nos métodos update
e onMount
do ObjectManager
.
Gere plataformas na classe ObjectManager
criando um novo método com o nome _semiRandomPlatform
. Você vai atualizar esse método mais tarde para retornar diferentes tipos de plataformas, mas, por enquanto, apenas retorne uma NormalPlatform
:
lib/game/managers/object_manager.dart
Platform _semiRandomPlatform(Vector2 position) { // Add lines from here...
return NormalPlatform(position: position);
} // ... to here.
Substitua o método update
do ObjectManager
e use o método _semiRandomPlatform
para gerar uma plataforma e adicioná-la ao jogo:
lib/game/managers/object_manager.dart
@override // Add lines from here...
void update(double dt) {
final topOfLowestPlatform =
_platforms.first.position.y + _tallestPlatformHeight;
final screenBottom = gameRef.player.position.y +
(gameRef.size.x / 2) +
gameRef.screenBufferSpace;
if (topOfLowestPlatform > screenBottom) {
var newPlatY = _generateNextY();
var newPlatX = _generateNextX(100);
final nextPlat = _semiRandomPlatform(Vector2(newPlatX, newPlatY));
add(nextPlat);
_platforms.add(nextPlat);
gameRef.gameManager.increaseScore();
_cleanupPlatforms();
// Losing the game: Add call to _maybeAddEnemy()
// Powerups: Add call to _maybeAddPowerup();
}
super.update(dt);
} // ... to here.
Faça o mesmo no método onMount
do ObjectManager
para que, quando o jogo for executado pela primeira vez, o método _semiRandomPlatform
gere uma plataforma inicial e a adicione ao jogo.
Adicione o método onMount
com o código abaixo:
lib/game/managers/object_manager.dart
@override // Add lines from here...
void onMount() {
super.onMount();
var currentX = (gameRef.size.x.floor() / 2).toDouble() - 50;
var currentY =
gameRef.size.y - (_rand.nextInt(gameRef.size.y.floor()) / 3) - 50;
for (var i = 0; i < 9; i++) {
if (i != 0) {
currentX = _generateNextX(100);
currentY = _generateNextY();
}
_platforms.add(
_semiRandomPlatform(
Vector2(
currentX,
currentY,
),
),
);
add(_platforms[i]);
}
} // ... to here.
Por exemplo, conforme mostrado no código abaixo, o método configure
permite que o jogo Doodle Dash reconfigure a distância mínima e máxima entre as plataformas e possibilita plataformas especiais quando o nível de dificuldade aumenta:
lib/game/managers/object_manager.dart
void configure(int nextLevel, Difficulty config) {
minVerticalDistanceToNextPlatform = gameRef.levelManager.minDistance;
maxVerticalDistanceToNextPlatform = gameRef.levelManager.maxDistance;
for (int i = 1; i <= nextLevel; i++) {
enableLevelSpecialty(i);
}
}
A instância DoodleDash
(no método initializeGameStart
) cria um ObjectManager
que é inicializado, configurado com base no nível de dificuldade e adicionado ao jogo do Flame:
lib/game/doodle_dash.dart
void initializeGameStart() {
gameManager.reset();
if (children.contains(objectManager)) objectManager.removeFromParent();
levelManager.reset();
player.resetPosition();
objectManager = ObjectManager(
minVerticalDistanceToNextPlatform: levelManager.minDistance,
maxVerticalDistanceToNextPlatform: levelManager.maxDistance);
add(objectManager);
objectManager.configure(levelManager.level, levelManager.difficulty);
}
O ObjectManager
aparece de novo no método checkLevelUp
. Quando o jogador avança de nível, o ObjectManager
reconfigura os parâmetros de geração de plataforma com base no nível de dificuldade.
lib/game/doodle_dash.dart
void checkLevelUp() {
if (levelManager.shouldLevelUp(gameManager.score.value)) {
levelManager.increaseLevel();
objectManager.configure(levelManager.level, levelManager.difficulty);
}
}
Ative as mudanças fazendo a recarga automática (ou reinicialização se estiver testando na Web). Para isso, salve o arquivo e use o botão no ambiente de desenvolvimento integrado ou, na linha de comando, digite r
. Inicie um jogo. A Dash e algumas plataformas vão aparecer na tela:
Problemas?
Caso o app não esteja executando corretamente, verifique se há erros de digitação. Se necessário, use o código nos links abaixo para colocar tudo de volta nos eixos (links em inglês).
6. Jogabilidade principal
Agora que você implementou os widgets individuais Player
e Platform
, pode começar a juntar tudo. Esta etapa implementa a funcionalidade principal, a detecção de colisão e o movimento da câmera.
Gravidade
Para tornar o jogo mais realista, Dash precisa ser puxada para baixo pela gravidade enquanto salta. Em nossa versão do Doodle Dash, a gravidade permanece constante e positiva, sempre puxando a Dash para baixo. No futuro, porém, você pode querer alterar a gravidade para criar outros efeitos.
Na classe Player
, adicione uma propriedade _gravity
com o valor 9:
lib/game/sprites/player.dart
class Player extends SpriteGroupComponent<PlayerState>
with HasGameRef<DoodleDash>, KeyboardHandler, CollisionCallbacks {
...
Character character;
double jumpSpeed;
final double _gravity = 9; // Add this line
@override
Future<void> onLoad() async {
...
}
...
}
Modifique o método update
da classe Player
para adicionar a variável _gravity
para alterar a velocidade vertical da Dash:
lib/game/sprites/player.dart
void update(double dt) {
if (gameRef.gameManager.isIntro || gameRef.gameManager.isGameOver) return;
_velocity.x = _hAxisInput * jumpSpeed;
final double dashHorizontalCenter = size.x / 2;
if (position.x < dashHorizontalCenter) {
position.x = gameRef.size.x - (dashHorizontalCenter);
}
if (position.x > gameRef.size.x - (dashHorizontalCenter)) {
position.x = dashHorizontalCenter;
}
_velocity.y += _gravity; // Add this line
position += _velocity * dt;
super.update(dt);
}
Detecção de colisão
O Flame oferece suporte à detecção de colisão (link em inglês) por padrão. Para ativá-la no jogo do Flame, adicione o mixin HasCollisionDetection
. Na classe DoodleDash
, é possível observar que esse mixin já foi adicionado:
lib/game/doodle_dash.dart
class DoodleDash extends FlameGame
with HasKeyboardHandlerComponents, HasCollisionDetection {
...
}
Em seguida, adicione a detecção de colisão a componentes individuais do jogo usando o mixin CollisionCallbacks
, que dá a um componente acesso ao callback onCollision
. Uma colisão de dois objetos com hitboxes aciona o callback onCollision
e transmite uma referência ao objeto com que está colidindo, para que você implemente a lógica de como o objeto vai reagir.
Lembre-se da etapa anterior, em que a classe abstrata Platform
já tem o mixin CollisionCallbacks
e uma hitbox. A classe Player
também tem o mixin CollisionCallbacks
, então você só precisa adicionar uma CircleHitbox
à classe Player
. A hitbox da Dash é, na verdade, um círculo, já que a Dash é mais redonda do que retangular.
Na classe Player
, importe sprites.dart
para que ela tenha acesso às várias classes Platform
:
lib/game/sprites/player.dart
import 'sprites.dart';
Adicione uma CircleHitbox
ao método onLoad
da classe Player
:
lib/game/sprites/player.dart
@override
Future<void> onLoad() async {
await super.onLoad();
await add(CircleHitbox()); // Add this line
await _loadCharacterSprites();
current = PlayerState.center;
}
A Dash precisa de um método de salto para poder pular quando pisar em uma plataforma.
Adicione um método jump
que usa uma specialJumpSpeed
opcional:
lib/game/sprites/player.dart
void jump({double? specialJumpSpeed}) {
_velocity.y = specialJumpSpeed != null ? -specialJumpSpeed : -jumpSpeed;
}
Substitua o método onCollision
do Player
adicionando este código:
lib/game/sprites/player.dart
@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollision(intersectionPoints, other);
bool isCollidingVertically =
(intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;
if (isMovingDown && isCollidingVertically) {
current = PlayerState.center;
if (other is NormalPlatform) {
jump();
return;
}
}
}
Esse callback chama o método jump
da Dash sempre que ela cai em cima de uma NormalPlatform
(plataforma normal). A instrução isMovingDown && isCollidingVertically
faz com que a Dash se mova para cima pelas plataformas sem acionar um salto.
Movimento da câmera
A câmera precisa seguir a Dash enquanto ela se move para cima no jogo, mas deve permanecer estática quando a Dash cai.
No Flame, se o "mundo" for maior que a tela, use os worldBounds
(link em inglês) da câmera para adicionar limites que informam ao Flame qual parte do mundo será mostrada. Para dar a impressão de que a câmera está se movendo para cima enquanto permanece fixa na horizontal, ajuste os limites de cima e de baixo do mundo em cada atualização com base na posição do jogador, mas mantenha os limites esquerdo e direito iguais.
Na classe DoodleDash
, adicione o código abaixo ao método update
para que a câmera siga a Dash durante o jogo:
lib/game/doodle_dash.dart
@override
void update(double dt) {
super.update(dt);
if (gameManager.isIntro) {
overlays.add('mainMenuOverlay');
return;
}
if (gameManager.isPlaying) {
checkLevelUp();
// Add lines from here...
final Rect worldBounds = Rect.fromLTRB(
0,
camera.position.y - screenBufferSpace,
camera.gameSize.x,
camera.position.y + _world.size.y,
);
camera.worldBounds = worldBounds;
if (player.isMovingDown) {
camera.worldBounds = worldBounds;
}
var isInTopHalfOfScreen = player.position.y <= (_world.size.y / 2);
if (!player.isMovingDown && isInTopHalfOfScreen) {
camera.followComponent(player);
} // ... to here.
}
}
Em seguida, a posição do Player
e os limites da câmera precisam ser redefinidos para a origem sempre que houver um reinício do jogo.
Adicione o código abaixo ao método initializeGameStart
:
lib/game/doodle_dash.dart
void initializeGameStart() {
...
levelManager.reset();
// Add the lines from here...
player.reset();
camera.worldBounds = Rect.fromLTRB(
0,
-_world.size.y,
camera.gameSize.x,
_world.size.y +
screenBufferSpace,
);
camera.followComponent(player);
// ... to here.
player.resetPosition();
...
}
Aumentar a velocidade do salto ao avançar de nível
Como último elemento da jogabilidade principal, é necessário que a velocidade de salto da Dash aumente sempre que o nível de dificuldade subir. Além disso, as plataformas precisam ser geradas a distâncias maiores umas das outras.
Adicione uma chamada ao método setJumpSpeed
e forneça a velocidade de salto associada ao nível atual:
lib/game/doodle_dash.dart
void checkLevelUp() {
if (levelManager.shouldLevelUp(gameManager.score.value)) {
levelManager.increaseLevel();
objectManager.configure(levelManager.level, levelManager.difficulty);
player.setJumpSpeed(levelManager.jumpSpeed); // Add this line
}
}
Ative as mudanças fazendo a recarga automática (ou a reinicialização na Web). Para isso, salve o arquivo e use o botão no ambiente de desenvolvimento integrado ou, digite r
na linha de código.
Problemas?
Caso o app não esteja executando corretamente, verifique se há erros de digitação. Se necessário, use o código nos links abaixo para colocar tudo de volta nos eixos (links em inglês).
7. Mais sobre plataformas
Agora que o ObjectManager
gera plataformas para a Dash saltar, você pode oferecer a ela algumas plataformas especiais interessantes.
Em seguida, adicione as classes BrokenPlatform
(plataforma quebrada) e SpringBoard
(plataforma trampolim). Como os nomes sugerem, uma BrokenPlatform
quebra após um salto e uma SpringBoard
oferece um trampolim para a Dash saltar mais alto e mais rápido.
|
|
Assim como a classe Player
, cada uma dessas classes de plataforma depende de enums
para representar o estado atual.
lib/game/sprites/platform.dart
enum BrokenPlatformState { cracked, broken }
Uma mudança no estado current
de uma plataforma também muda o sprite mostrado no jogo. Defina o mapeamento entre o tipo enumerado State
e os recursos de imagem na propriedade sprites
para correlacionar qual sprite é atribuído a cada estado.
Adicione um tipo enumerado BrokenPlatformState
e a classe BrokenPlatform
:
lib/game/sprites/platform.dart
enum BrokenPlatformState { cracked, broken } // Add lines from here...
class BrokenPlatform extends Platform<BrokenPlatformState> {
BrokenPlatform({super.position});
@override
Future<void>? onLoad() async {
await super.onLoad();
sprites = <BrokenPlatformState, Sprite>{
BrokenPlatformState.cracked:
await gameRef.loadSprite('game/platform_cracked_monitor.png'),
BrokenPlatformState.broken:
await gameRef.loadSprite('game/platform_monitor_broken.png'),
};
current = BrokenPlatformState.cracked;
size = Vector2(115, 84);
}
void breakPlatform() {
current = BrokenPlatformState.broken;
}
} // ... to here.
Adicione um tipo enumerado SpringState
e a classe SpringBoard
:
lib/game/sprites/platform.dart
enum SpringState { down, up } // Add lines from here...
class SpringBoard extends Platform<SpringState> {
SpringBoard({
super.position,
});
@override
Future<void>? onLoad() async {
await super.onLoad();
sprites = <SpringState, Sprite>{
SpringState.down:
await gameRef.loadSprite('game/platform_trampoline_down.png'),
SpringState.up:
await gameRef.loadSprite('game/platform_trampoline_up.png'),
};
current = SpringState.up;
size = Vector2(100, 45);
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
bool isCollidingVertically =
(intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;
if (isCollidingVertically) {
current = SpringState.down;
}
}
@override
void onCollisionEnd(PositionComponent other) {
super.onCollisionEnd(other);
current = SpringState.up;
}
} // ... to here.
Em seguida, ative essas plataformas especiais no ObjectManager
. Por serem especiais, elas não devem aparecer no jogo o tempo todo, então precisam ser geradas condicionalmente com base em probabilidades: 15% para SpringBoard
e 10% para BrokenPlatform
.
No ObjectManager
, dentro do método _semiRandomPlatform
, antes da instrução que retorna uma NormalPlatform
, adicione o código abaixo para retornar condicionalmente uma plataforma especial:
lib/game/managers/object_manager.dart
Platform _semiRandomPlatform(Vector2 position) {
if (specialPlatforms['spring'] == true && // Add lines from here...
probGen.generateWithProbability(15)) {
return SpringBoard(position: position);
}
if (specialPlatforms['broken'] == true &&
probGen.generateWithProbability(10)) {
return BrokenPlatform(position: position);
} // ... to here.
return NormalPlatform(position: position);
}
Parte da diversão de jogar é desbloquear novos desafios e recursos conforme avança de nível.
A plataforma trampolim vai estar presente desde o início no nível 1, mas assim que a Dash atingir o nível 2, a BrokenPlatform
vai começar a aparecer, tornando o jogo um pouco mais difícil.
Na classe ObjectManager
, modifique o método enableLevelSpecialty
, que é um stub no momento, adicionando uma instrução switch
que ativa as plataformas SpringBoard
para o nível 1 e BrokenPlatform
para o nível 2:
lib/game/managers/object_manager.dart
void enableLevelSpecialty(int level) {
switch (level) { // Add lines from here...
case 1:
enableSpecialty('spring');
break;
case 2:
enableSpecialty('broken');
break;
} // ... to here.
}
Em seguida, faça com que as plataformas possam se mover para frente e para trás horizontalmente. Na classe abstrata Platform
**,** adicione o método _move
abaixo:
lib/game/sprites/platform.dart
void _move(double dt) {
if (!isMoving) return;
final double gameWidth = gameRef.size.x;
if (position.x <= 0) {
direction = 1;
} else if (position.x >= gameWidth - size.x) {
direction = -1;
}
_velocity.x = direction * speed;
position += _velocity * dt;
}
Se a plataforma estiver se movendo, quando ela chegar à borda da tela do jogo, mudará de direção. Assim como a Dash, a posição da plataforma é determinada ao multiplicar a _direction
pela speed
da plataforma para descobrir a velocidade. Depois, multiplique a velocidade pelo time-elapsed
e adicione a distância resultante à position
atual da plataforma.
Substitua o método update
da classe Platform
para chamar o método _move
:
lib/game/sprites/platform.dart
@override
void update(double dt) {
_move(dt);
super.update(dt);
}
Para acionar o movimento da Platform
, no método onLoad
, defina o booleano isMoving
para ser aleatoriamente true
(em movimento) com 20% de probabilidade.
lib/game/sprites/platform.dart
@override
Future<void>? onLoad() async {
await super.onLoad();
await add(hitbox);
final int rand = Random().nextInt(100); // Add this line
if (rand > 80) isMoving = true; // Add this line
}
Por fim, no Player
, modifique o método onCollision
da classe Player
para reconhecer uma colisão com um Springboard
ou uma BrokenPlatform
. Uma SpringBoard
chama jump
com um multiplicador de velocidade de 2x e a BrokenPlatform
só chama jump
caso o estado seja .cracked
(trincada), em vez de .broken
(quebrada após um salto):
lib/game/sprites/player.dart
@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollision(intersectionPoints, other);
bool isCollidingVertically =
(intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;
if (isMovingDown && isCollidingVertically) {
current = PlayerState.center;
if (other is NormalPlatform) {
jump();
return;
} else if (other is SpringBoard) { // Add lines from here...
jump(specialJumpSpeed: jumpSpeed * 2);
return;
} else if (other is BrokenPlatform &&
other.current == BrokenPlatformState.cracked) {
jump();
other.breakPlatform();
return;
} // ... to here.
}
}
Reinicie o app. Inicie um jogo para conferir as plataformas em movimento, SpringBoard
e BrokenPlatform
.
Problemas?
Caso o app não esteja executando corretamente, verifique se há erros de digitação. Se necessário, use o código nos links abaixo para colocar tudo de volta nos eixos (links em inglês).
8. Perder o jogo
Esta etapa adiciona condições de derrota ao jogo Doodle Dash. O jogador pode perder de duas formas:
- Dash erra uma plataforma e cai pela parte de baixo da tela.
- Dash encosta em uma plataforma de
Enemy
(inimigo).
Antes de implementar qualquer condição de "fim do jogo", adicione uma lógica que defina o estado do jogo DoodleDash como gameOver
.
Na classe DoodleDash
**,** adicione um método onLose
que é chamado sempre que o jogo termina. Ele define o estado do jogo, remove o jogador da tela e ativa o menu/sobreposição **Game Over**.
lib/game/sprites/doodle_dash.dart
void onLose() { // Add lines from here...
gameManager.state = GameState.gameOver;
player.removeFromParent();
overlays.add('gameOverOverlay');
} // ... to here.
Menu Game Over:
Na parte de cima do método update
do DoodleDash
, adicione o código abaixo para impedir que o jogo seja atualizado quando o estado for GameOver
:
lib/game/sprites/doodle_dash.dart
@override
void update(double dt) {
super.update(dt);
if (gameManager.isGameOver) { // Add lines from here...
return;
} // ... to here.
...
}
Além disso, no método update
, chame onLose
quando o jogador cair pela parte de baixo da tela.
lib/game/sprites/doodle_dash.dart
@override
void update(double dt) {
...
if (gameManager.isPlaying) {
checkLevelUp();
final Rect worldBounds = Rect.fromLTRB(
0,
camera.position.y - screenBufferSpace,
camera.gameSize.x,
camera.position.y + _world.size.y,
);
camera.worldBounds = worldBounds;
if (player.isMovingDown) {
camera.worldBounds = worldBounds;
}
var isInTopHalfOfScreen = player.position.y <= (_world.size.y / 2);
if (!player.isMovingDown && isInTopHalfOfScreen) {
camera.followComponent(player);
}
// Add lines from here...
if (player.position.y >
camera.position.y +
_world.size.y +
player.size.y +
screenBufferSpace) {
onLose();
} // ... to here.
}
}
Os inimigos podem ter todos os tipos de formas e tamanhos. No Doodle Dash, eles aparecem como uma lixeira ou um ícone de pasta de erro. Os jogadores precisam evitar encostar neles porque isso causa o fim imediato do jogo.
|
Crie um tipo de plataforma de inimigo adicionando um tipo enumerado EnemyPlatformState
e a classe EnemyPlatform
:
lib/game/sprites/platform.dart
enum EnemyPlatformState { only } // Add lines from here...
class EnemyPlatform extends Platform<EnemyPlatformState> {
EnemyPlatform({super.position});
@override
Future<void>? onLoad() async {
var randBool = Random().nextBool();
var enemySprite = randBool ? 'enemy_trash_can' : 'enemy_error';
sprites = <EnemyPlatformState, Sprite>{
EnemyPlatformState.only:
await gameRef.loadSprite('game/$enemySprite.png'),
};
current = EnemyPlatformState.only;
return super.onLoad();
}
} // ... to here.
A classe EnemyPlatform
estende o supertipo Platform
. O ObjectManager
cria e administra plataformas de inimigo como faz em todas as outras plataformas.
No ObjectManager
, adicione o código abaixo para gerar e administrar as plataformas inimigas:
lib/game/managers/object_manager.dart
final List<EnemyPlatform> _enemies = []; // Add lines from here...
void _maybeAddEnemy() {
if (specialPlatforms['enemy'] != true) {
return;
}
if (probGen.generateWithProbability(20)) {
var enemy = EnemyPlatform(
position: Vector2(_generateNextX(100), _generateNextY()),
);
add(enemy);
_enemies.add(enemy);
_cleanupEnemies();
}
}
void _cleanupEnemies() {
final screenBottom = gameRef.player.position.y +
(gameRef.size.x / 2) +
gameRef.screenBufferSpace;
while (_enemies.isNotEmpty && _enemies.first.position.y > screenBottom) {
remove(_enemies.first);
_enemies.removeAt(0);
}
} // ... to here.
O ObjectManager
armazena uma lista de objetos inimigos, _enemies
. O _maybeAddEnemy
gera inimigos com 20 porcento de probabilidade e adiciona o objeto à lista de inimigos. O método _cleanupEnemies()
remove objetos EnemyPlatform
antigos que não estão mais visíveis.
No ObjectManager
, gere plataformas de inimigo chamando _maybeAddEnemy()
no método update
:
lib/game/managers/object_manager.dart
@override
void update(double dt) {
final topOfLowestPlatform =
_platforms.first.position.y + _tallestPlatformHeight;
final screenBottom = gameRef.player.position.y +
(gameRef.size.x / 2) +
gameRef.screenBufferSpace;
if (topOfLowestPlatform > screenBottom) {
var newPlatY = _generateNextY();
var newPlatX = _generateNextX(100);
final nextPlat = _semiRandomPlatform(Vector2(newPlatX, newPlatY));
add(nextPlat);
_platforms.add(nextPlat);
gameRef.gameManager.increaseScore();
_cleanupPlatforms();
_maybeAddEnemy(); // Add this line
}
super.update(dt);
}
Adicione ao método onCollision
do Player
para verificar se há colisão com uma EnemyPlatform
. Caso haja, chame o método onLose()
.
lib/game/sprites/player.dart
@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollision(intersectionPoints, other);
if (other is EnemyPlatform) { // Add lines from here...
gameRef.onLose();
return;
} // ... to here.
bool isCollidingVertically =
(intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;
if (isMovingDown && isCollidingVertically) {
current = PlayerState.center;
if (other is NormalPlatform) {
jump();
return;
} else if (other is SpringBoard) {
jump(specialJumpSpeed: jumpSpeed * 2);
return;
} else if (other is BrokenPlatform &&
other.current == BrokenPlatformState.cracked) {
jump();
other.breakPlatform();
return;
}
}
}
Por fim, modifique o método enableLevelSpecialty
do ObjectManager
para adicionar o nível 5 à instrução switch
:
lib/game/managers/object_manager.dart
void enableLevelSpecialty(int level) {
switch (level) {
case 1:
enableSpecialty('spring');
break;
case 2:
enableSpecialty('broken');
break;
case 5: // Add lines from here...
enableSpecialty('enemy');
break; // ... to here.
}
}
Agora que você tornou o jogo mais desafiador, faça uma recarga automática e ative as mudanças. Para isso, salve os arquivos e use o botão no ambiente de desenvolvimento integrado ou, na linha de comando, digite r
:
Cuidado com os inimigos de pastas quebradas. Eles são sorrateiros e se misturam com o plano de fundo!
Problemas?
Caso o app não esteja executando corretamente, verifique se há erros de digitação. Se necessário, use o código nos links abaixo para colocar tudo de volta nos eixos (links em inglês).
9. Poderes
Esta etapa adiciona recursos avançados para dar poderes à Dash durante o jogo. O Doodle Dash tem duas opções de poder: um Chapéu de Noogler ou um Foguete. Considere esses poderes como outro tipo de plataforma especial. À medida que a Dash avança no jogo, a velocidade dela aumenta quando ela pega um Chapéu de Noogler ou um Foguete e usa esses poderes.
|
|
O Chapéu de Noogler aparece no nível 3 quando o jogador atinge uma pontuação >= 40. Se a Dash encostar no chapéu, vai colocá-lo e receber um aumento de 2,5x da velocidade normal. Isso dura cinco segundos.
O Foguete aparece no nível 4 quando o jogador atinge uma pontuação >= 80. Quando a Dash encostar no Foguete, o sprite dela será substituído por um foguete e ela receberá um bônus de 3,5x a velocidade normal até pousar em uma plataforma. Além disso, ela vai ficar invencível contra os inimigos quando estiver com o poder do Foguete.
Os sprites de Chapéu de Noogler e Foguete estendem a classe abstrata PowerUp
. Assim como a classe abstrata Platform
, a classe abstrata PowerUp
, mostrada abaixo, também inclui dimensionamento e uma hitbox.
lib/game/sprites/powerup.dart
abstract class PowerUp extends SpriteComponent
with HasGameRef<DoodleDash>, CollisionCallbacks {
final hitbox = RectangleHitbox();
double get jumpSpeedMultiplier;
PowerUp({
super.position,
}) : super(
size: Vector2.all(50),
priority: 2,
);
@override
Future<void>? onLoad() async {
await super.onLoad();
await add(hitbox);
}
}
Crie uma classe Rocket
que estende a classe abstrata PowerUp
. Quando a Dash encosta no foguete, ela recebe um bônus de 3,5 vezes a velocidade normal.
lib/game/sprites/powerup.dart
class Rocket extends PowerUp { // Add lines from here...
@override
double get jumpSpeedMultiplier => 3.5;
Rocket({
super.position,
});
@override
Future<void>? onLoad() async {
await super.onLoad();
sprite = await gameRef.loadSprite('game/rocket_1.png');
size = Vector2(50, 70);
}
} // ... to here.
Crie uma classe NooglerHat
que estende a classe abstrata PowerUp
. Quando a Dash encosta no NooglerHat
, ela recebe um aumento de aceleração de 2,5 vezes a velocidade normal. Isso dura cinco segundos.
lib/game/sprites/powerup.dart
class NooglerHat extends PowerUp { // Add lines from here...
@override
double get jumpSpeedMultiplier => 2.5;
NooglerHat({
super.position,
});
final int activeLengthInMS = 5000;
@override
Future<void>? onLoad() async {
await super.onLoad();
sprite = await gameRef.loadSprite('game/noogler_hat.png');
size = Vector2(75, 50);
}
} // ... to here.
Agora que você implementou os poderes NooglerHat
e Rocket
, atualize o ObjectManager
para que eles apareçam no jogo.
Modifique a classe ObjectManger
para adicionar uma lista que monitora os poderes gerados, com dois novos métodos: _maybePowerup
e _cleanupPowerups
, para gerar e remover as novas plataformas de poderes.
lib/game/managers/object_manager.dart
final List<PowerUp> _powerups = []; // Add lines from here...
void _maybeAddPowerup() {
if (specialPlatforms['noogler'] == true &&
probGen.generateWithProbability(20)) {
var nooglerHat = NooglerHat(
position: Vector2(_generateNextX(75), _generateNextY()),
);
add(nooglerHat);
_powerups.add(nooglerHat);
} else if (specialPlatforms['rocket'] == true &&
probGen.generateWithProbability(15)) {
var rocket = Rocket(
position: Vector2(_generateNextX(50), _generateNextY()),
);
add(rocket);
_powerups.add(rocket);
}
_cleanupPowerups();
}
void _cleanupPowerups() {
final screenBottom = gameRef.player.position.y +
(gameRef.size.x / 2) +
gameRef.screenBufferSpace;
while (_powerups.isNotEmpty && _powerups.first.position.y > screenBottom) {
if (_powerups.first.parent != null) {
remove(_powerups.first);
}
_powerups.removeAt(0);
}
} // ... to here.
O método _maybeAddPowerup
gera um chapéu de noogler 20% das vezes ou um foguete 15% das vezes. O método _cleanupPowerups
é chamado para remover poderes que estão abaixo dos limites de baixo da tela.
Modifique o método update
do ObjectManager
para chamar _maybePowerup
em cada marcação do loop de jogo.
lib/game/managers/object_manager.dart
@override
void update(double dt) {
final topOfLowestPlatform =
_platforms.first.position.y + _tallestPlatformHeight;
final screenBottom = gameRef.player.position.y +
(gameRef.size.x / 2) +
gameRef.screenBufferSpace;
if (topOfLowestPlatform > screenBottom) {
var newPlatY = _generateNextY();
var newPlatX = _generateNextX(100);
final nextPlat = _semiRandomPlatform(Vector2(newPlatX, newPlatY));
add(nextPlat);
_platforms.add(nextPlat);
gameRef.gameManager.increaseScore();
_cleanupPlatforms();
_maybeAddEnemy();
_maybeAddPowerup(); // Add this line
}
super.update(dt);
}
Modifique o método enableLevelSpecialty
para adicionar dois novos casos na instrução switch: um para ativar NooglerHat
no nível 3 e outro para ativar Rocket
no nível 4:
lib/game/managers/object_manager.dart
void enableLevelSpecialty(int level) {
switch (level) {
case 1:
enableSpecialty('spring');
break;
case 2:
enableSpecialty('broken');
break;
case 3: // Add lines from here...
enableSpecialty('noogler');
break;
case 4:
enableSpecialty('rocket');
break; // ... to here.
case 5:
enableSpecialty('enemy');
break;
}
}
Adicione os getters booleanos abaixo à classe Player
. Se a Dash tiver um poder ativo, ela poderá ser representada por vários estados diferentes. Esses getters facilitam verificar qual poder está ativo.
lib/game/sprites/player.dart
bool get hasPowerup => // Add lines from here...
current == PlayerState.rocket ||
current == PlayerState.nooglerLeft ||
current == PlayerState.nooglerRight ||
current == PlayerState.nooglerCenter;
bool get isInvincible => current == PlayerState.rocket;
bool get isWearingHat =>
current == PlayerState.nooglerLeft ||
current == PlayerState.nooglerRight ||
current == PlayerState.nooglerCenter; // ... to here.
Modifique o método onCollision
do Player
para reagir a uma colisão com um NooglerHat
ou um Rocket
. Este código também faz com que a Dash só ganhe um novo poder caso ainda não tenha um.
lib/game/sprites/player.dart
@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollision(intersectionPoints, other);
if (other is EnemyPlatform && !isInvincible) {
gameRef.onLose();
return;
}
bool isCollidingVertically =
(intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;
if (isMovingDown && isCollidingVertically) {
current = PlayerState.center;
if (other is NormalPlatform) {
jump();
return;
} else if (other is SpringBoard) {
jump(specialJumpSpeed: jumpSpeed * 2);
return;
} else if (other is BrokenPlatform &&
other.current == BrokenPlatformState.cracked) {
jump();
other.breakPlatform();
return;
}
}
if (!hasPowerup && other is Rocket) { // Add lines from here...
current = PlayerState.rocket;
other.removeFromParent();
jump(specialJumpSpeed: jumpSpeed * other.jumpSpeedMultiplier);
return;
} else if (!hasPowerup && other is NooglerHat) {
if (current == PlayerState.center) current = PlayerState.nooglerCenter;
if (current == PlayerState.left) current = PlayerState.nooglerLeft;
if (current == PlayerState.right) current = PlayerState.nooglerRight;
other.removeFromParent();
_removePowerupAfterTime(other.activeLengthInMS);
jump(specialJumpSpeed: jumpSpeed * other.jumpSpeedMultiplier);
return;
} // ... to here.
}
Se a Dash encostar em um foguete, o PlayerState
mudará para Rocket
e permitirá que a Dash salte com um jumpSpeedMultiplier
de 3,5x.
Se a Dash encostar em um chapéu de Noogler, dependendo da direção atual do PlayerState
(.center
, .left
ou .right
), o PlayerState
vai mudar para o PlayerState
de Noogler correspondente, em que ela usa o chapéu e recebe um jumpSpeedMultiplier
que aumenta a velocidade em 2,5x. O método _removePowerupAfterTime
remove o poder após 5 segundos e muda o PlayerState
dos estados de poder de volta para o center
.
A chamada para other.removeFromParent
remove as plataformas com sprites de Chapéu de Noogler ou Foguete da tela para indicar que a Dash adquiriu o poder.
Modifique os métodos moveLeft
e moveRight
da classe Player para considerar o sprite NooglerHat
. Você não precisa considerar o poder Rocket
porque esse sprite aponta para a mesma direção, independente da direção do deslocamento.
lib/game/sprites/player.dart
void moveLeft() {
_hAxisInput = 0;
if (isWearingHat) { // Add lines from here...
current = PlayerState.nooglerLeft;
} else if (!hasPowerup) { // ... to here.
current = PlayerState.left;
} // Add this line
_hAxisInput += movingLeftInput;
}
void moveRight() {
_hAxisInput = 0;
if (isWearingHat) { // Add lines from here...
current = PlayerState.nooglerRight;
} else if (!hasPowerup) { //... to here.
current = PlayerState.right;
} // Add this line
_hAxisInput += movingRightInput;
}
A Dash fica invencível contra os inimigos quando ela tem o poder Rocket
, então não encerre o jogo durante esse período.
Modifique o callback onCollision
para conferir se a Dash isInvincible
(está invencível) antes de acionar um fim de jogo ao colidir com uma EnemyPlatform
(plataforma de inimigo):
lib/game/sprites/player.dart
if (other is EnemyPlatform && !isInvincible) { // Modify this line
gameRef.onLose();
return;
}
Reinicie o app e jogue para conferir os poderes em ação.
Problemas?
Caso o app não esteja executando corretamente, verifique se há erros de digitação. Se necessário, use o código nos links abaixo para colocar tudo de volta nos eixos (links em inglês).
10. Sobreposições
Um jogo do Flame pode ser unido a um widget, o que facilita a integração com outros widgets em um app do Flutter. Também é possível mostrar widgets do Flutter como sobreposições na parte de cima do jogo do Flame. Isso é conveniente para componentes não relacionados ao jogo que não dependem do loop dele, como menus, tela de pausa, botões e controles deslizantes.
A pontuação do jogo com todos os menus do Doodle Dash são widgets comuns do Flutter, não componentes do Flame. Todo o código dos widgets está localizado em lib/game/widgets
, por exemplo, o menu Game Over é apenas uma coluna que contém outros widgets, como Text
e ElevatedButton
, conforme mostrado neste código:
lib/game/widgets/game_over_overlay.dart
class GameOverOverlay extends StatelessWidget {
const GameOverOverlay(this.game, {super.key});
final Game game;
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).colorScheme.background,
child: Center(
child: Padding(
padding: const EdgeInsets.all(48.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'Game Over',
style: Theme.of(context).textTheme.displayMedium!.copyWith(),
),
const WhiteSpace(height: 50),
ScoreDisplay(
game: game,
isLight: true,
),
const WhiteSpace(
height: 50,
),
ElevatedButton(
onPressed: () {
(game as DoodleDash).resetGame();
},
style: ButtonStyle(
minimumSize: MaterialStateProperty.all(
const Size(200, 75),
),
textStyle: MaterialStateProperty.all(
Theme.of(context).textTheme.titleLarge),
),
child: const Text('Play Again'),
),
],
),
),
),
);
}
}
Para usar um widget como sobreposição em um jogo do Flame, defina uma propriedade overlayBuilderMap
no GameWidget
com uma key
que representa a sobreposição (como uma String
) e o value
de uma função de widget que retorna um widget, conforme mostrado neste código:
lib/main.dart
GameWidget(
game: game,
overlayBuilderMap: <String, Widget Function(BuildContext, Game)>{
'gameOverlay': (context, game) => GameOverlay(game),
'mainMenuOverlay': (context, game) => MainMenuOverlay(game),
'gameOverOverlay': (context, game) => GameOverOverlay(game),
},
)
Depois de adicionada, uma sobreposição pode ser usada em qualquer lugar do jogo. Mostre uma sobreposição usando overlays.add
e oculte-a com overlays.remove
, conforme mostrado no código abaixo:
lib/game/doodle_dash.dart
void resetGame() {
startGame();
overlays.remove('gameOverOverlay');
}
void onLose() {
gameManager.state = GameState.gameOver;
player.removeFromParent();
overlays.add('gameOverOverlay');
}
11. Suporte para dispositivos móveis
O Doodle Dash é baseado no Flutter e no Flame, ou seja, já pode ser executado nas plataformas com suporte ao Flutter. Porém, o Doodle Dash aceita apenas entrada de teclado até o momento. Para dispositivos que não possuem teclado, como smartphones, é fácil adicionar botões de controle por toque na sobreposição da tela.
Adicione uma variável de estado booleana à GameOverlay
que determina quando o jogo é executado em uma plataforma móvel:
lib/game/widgets/game_overlay.dart
class GameOverlayState extends State<GameOverlay> {
bool isPaused = false;
// Add this line
final bool isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS);
@override
Widget build(BuildContext context) {
...
}
}
Agora, mostre os botões direcionais esquerdo e direito na sobreposição quando o jogo for executado em dispositivos móveis. Semelhante à lógica de "eventos principais" da etapa 4, tocar no botão esquerdo move a Dash para a esquerda e o botão direito move para a direita.
No método build
da GameOverlay
, adicione uma seção isMobile
que segue o mesmo comportamento descrito na etapa 4: tocar no botão esquerdo invoca moveLeft
e no botão direito invoca moveRight
. Soltar qualquer um dos botões chama resetDirection
e faz com que a Dash não se mova na horizontal.
lib/game/widgets/game_overlay.dart
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: Stack(
children: [
Positioned(... child: ScoreDisplay(...)),
Positioned(... child: ElevatedButton(...)),
if (isMobile) // Add lines from here...
Positioned(
bottom: MediaQuery.of(context).size.height / 4,
child: SizedBox(
width: MediaQuery.of(context).size.width,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.only(left: 24),
child: GestureDetector(
onTapDown: (details) {
(widget.game as DoodleDash).player.moveLeft();
},
onTapUp: (details) {
(widget.game as DoodleDash).player.resetDirection();
},
child: Material(
color: Colors.transparent,
elevation: 3.0,
shadowColor: Theme.of(context).colorScheme.background,
child: const Icon(Icons.arrow_left, size: 64),
),
),
),
Padding(
padding: const EdgeInsets.only(right: 24),
child: GestureDetector(
onTapDown: (details) {
(widget.game as DoodleDash).player.moveRight();
},
onTapUp: (details) {
(widget.game as DoodleDash).player.resetDirection();
},
child: Material(
color: Colors.transparent,
elevation: 3.0,
shadowColor: Theme.of(context).colorScheme.background,
child: const Icon(Icons.arrow_right, size: 64),
),
),
),
],
),
),
), // ... to here.
if (isPaused)
...
],
),
);
}
Pronto! Agora, o app Doodle Dash detecta automaticamente em que tipo de plataforma está sendo executado e alterna a entrada conforme necessário.
Execute o app no iOS ou Android para conferir os botões direcionais em ação.
Problemas?
Caso o app não esteja executando corretamente, verifique se há erros de digitação. Se necessário, use o código no link abaixo para colocar tudo de volta nos eixos (link em inglês).
12. Próximas etapas
Parabéns!
Você concluiu este codelab e aprendeu a criar um jogo no Flutter usando o mecanismo de jogo do Flame.
O que aprendemos:
- Como usar o pacote do Flame para criar um jogo de plataforma, incluindo:
- Adicionar um personagem.
- Adicionar vários tipos de plataforma.
- Implementar a detecção de colisão.
- Adicionar um componente de gravidade.
- Definir a movimentação da câmera.
- Criar inimigos.
- Criar poderes.
- Como detectar a plataforma em que o jogo está sendo executado.
- Como usar essas informações para alternar entre teclado e controles de entrada por toque.
Recursos
Esperamos que você tenha aprendido mais sobre como criar jogos no Flutter.
Os recursos abaixo também podem ser úteis e inspiradores (links em inglês):
- Documentação do Flame e o pacote do Flame em pub.dev.
- Noções básicas do mecanismo de jogo do Flame, vídeo de Lukas Klingsbo no YouTube.
- Jogo de plataforma simples, série de jogos Flame + Flutter, de DevKage.
- Dino Run; série Flutter Game Development, de DevKage.
- Spacescape, série Flutter Game Development, de DevKage.
- Jogos do Flutter.
- A página do kit de ferramentas de jogos casuais do Flutter e o modelo de introdução correspondente desse kit. O kit não usa o mecanismo do Flame, mas é projetado para dar suporte a anúncios para dispositivos móveis e compras no app de jogo.
- Crie seu jogo no Flutter, um vídeo sobre o kit de ferramentas de jogos casuais.
- A página Flutter Puzzle Hack (uma competição realizada em janeiro de 2022) e o vídeo dos vencedores.