Sempre quis fazer videogames. Meu primeiro aplicativo Android que me ajudou a conseguir meu primeiro emprego foi um jogo simples, feito com visualizações Android. Depois disso, houve muitas tentativas de criar um jogo mais elaborado usando um motor de jogo, mas todas falharam por falta de tempo ou pela complexidade de um framework. Mas quando ouvi pela primeira vez sobre o Flame engine, baseado no Flutter, fui imediatamente atraído por sua simplicidade e suporte multiplataforma, então decidi tentar criar um jogo com ele.
Eu queria começar com algo simples, mas desafiador, para ter uma ideia do motor. Esta série de artigos é minha jornada para aprender Flame (e Flutter) e construir um jogo de plataforma básico. Vou tentar torná-lo bem detalhado, então deve ser útil para quem está apenas mergulhando no Flame ou no desenvolvedor de jogos em geral.
Ao longo de 4 artigos, vou construir um jogo de rolagem lateral 2D, que inclui:
Um personagem que pode correr e pular
Uma câmera que segue o jogador
Mapa de nível de rolagem, com solo e plataformas
Fundo de paralaxe
Moedas que o jogador pode coletar e HUD que exibe o número de moedas
tela de vitória
Na primeira parte, vamos criar um novo projeto Flame, carregar todos os recursos, adicionar um personagem do jogador e ensiná-lo a correr.
Primeiro, vamos criar um novo projeto. O tutorial oficial do jogo Bare Flame faz um ótimo trabalho ao descrever todas as etapas para fazer isso, então apenas siga-o.
Uma coisa a acrescentar: ao configurar o arquivo pubspec.yaml
, você pode atualizar as versões das bibliotecas para as últimas disponíveis ou deixá-las como estão, porque o sinal de circunflexo (^) antes de uma versão garantirá que seu aplicativo use as versões não -versão de quebra. ( sintaxe de acento circunflexo )
Se você seguiu todas as etapas, seu arquivo main.dart
deve ficar assim:
import 'package:flame/game.dart'; import 'package:flutter/widgets.dart'; void main() { final game = FlameGame(); runApp(GameWidget(game: game)); }
Antes de continuarmos, precisamos preparar os recursos que serão usados no jogo. Ativos são imagens, animações, sons, etc. Para os propósitos desta série, usaremos apenas imagens que também são chamadas de sprites no desenvolvimento de jogos.
A maneira mais simples de construir um nível de plataforma é usar mapas de blocos e sprites de blocos. Significa que o nível é basicamente uma grade, onde cada célula indica qual objeto/chão/plataforma ela representa. Mais tarde, quando o jogo está rodando, as informações de cada célula são mapeadas para o sprite do tile correspondente.
Os gráficos dos jogos construídos com esta técnica podem ser muito elaborados ou muito simples. Por exemplo, em Super Mario bros, você vê que muitos elementos se repetem. Isso porque, para cada ladrilho de chão na grade do jogo, existe apenas uma imagem de chão que o representa. Seguiremos a mesma abordagem e prepararemos uma única imagem para cada objeto estático que tivermos.
Também queremos que alguns dos objetos, como o personagem do jogador e as moedas, sejam animados. A animação geralmente é armazenada como uma série de imagens estáticas, cada uma representando um único quadro. Quando a animação está sendo reproduzida, os quadros vão um após o outro, criando a ilusão do objeto em movimento.
Agora, a questão mais importante é onde obter os ativos. Claro, você mesmo pode desenhá-los ou encomendá-los a um artista. Além disso, existem muitos artistas incríveis que contribuíram com ativos de jogos para código aberto. Estarei usando o pacote Arcade Platformer Assets da GrafxKid .
Normalmente, os recursos de imagem vêm em duas formas: folhas de sprite e sprites individuais. O primeiro é uma imagem grande, contendo todos os recursos do jogo em um. Em seguida, os desenvolvedores de jogos especificam a posição exata do sprite necessário e o mecanismo de jogo o corta da folha. Para este jogo, usarei sprites únicos (exceto animações, é mais fácil mantê-los como uma imagem) porque não preciso de todos os ativos fornecidos na folha de sprite.
Esteja você mesmo criando sprites ou obtendo-os de um artista, pode ser necessário cortá-los para torná-los mais adequados para o mecanismo de jogo. Você pode usar ferramentas criadas especificamente para esse fim (como o empacotador de texturas) ou qualquer editor gráfico. Eu usei o Adobe Photoshop, porque nessa folha de sprites os sprites tem espaço desigual entre eles, o que dificultou a extração de imagens por ferramentas automáticas, então tive que fazer manualmente.
Você também pode querer aumentar o tamanho dos ativos, mas se não for uma imagem vetorial, o sprite resultante pode ficar embaçado. Uma solução alternativa que descobri que funciona muito bem para pixel art é usar o método de redimensionamento Nearest Neighbour (hard edges)
no Photoshop (ou Interpolação definida como Nenhum no Gimp). Mas se seu ativo for mais detalhado, provavelmente não funcionará.
Com as explicações prontas, baixe os recursos que preparei ou prepare o seu próprio e adicione-os à pasta assets/images
do seu projeto.
Sempre que adicionar novos recursos, você precisa registrá-los no arquivo pubspec.yaml
como este:
flutter: assets: - assets/images/
E a dica para o futuro: se você estiver atualizando ativos já cadastrados, você precisa reiniciar o jogo para ver as alterações.
Agora vamos carregar os ativos no jogo. Gosto de ter todos os nomes de recursos em um só lugar, o que funciona muito bem para um jogo pequeno, pois é mais fácil acompanhar tudo e modificar, se necessário. Então, vamos criar um novo arquivo no diretório lib
: assets.dart
const String THE_BOY = "theboy.png"; const String GROUND = "ground.png"; const String PLATFORM = "platform.png"; const String MIST = "mist.png"; const String CLOUDS = "clouds.png"; const String HILLS = "hills.png"; const String COIN = "coin.png"; const String HUD = "hud.png"; const List<String> SPRITES = [THE_BOY, GROUND, PLATFORM, MIST, CLOUDS, HILLS, COIN, HUD];
E então crie outro arquivo, que conterá toda a lógica do jogo no futuro: game.dart
import 'package:flame/game.dart'; import 'assets.dart' as Assets; class PlatformerGame extends FlameGame { @override Future<void> onLoad() async { await images.loadAll(Assets.SPRITES); } }
PlatformerGame
é a classe principal que representa nosso jogo, ela estende FlameGame
, a classe de jogo base usada no motor Flame. O que, por sua vez, estende Component
- o bloco de construção básico do Flame. Tudo em seu jogo, incluindo imagens, interface ou efeitos são Componentes. Cada Component
possui um método assíncrono onLoad
, que é chamado na inicialização do componente. Normalmente, toda a lógica de configuração do componente vai para lá.
Por fim, importamos nosso arquivo assets.dart
que criamos anteriormente e adicionamos as Assets
para declarar explicitamente de onde vêm nossas constantes de assets. E usei o método images.loadAll
para carregar todos os recursos listados na lista SPRITES
para o cache de imagens do jogo.
Então, precisamos criar nosso novo PlatformerGame
a partir de main.dart
. Modifique o arquivo da seguinte maneira:
import 'package:flame/game.dart'; import 'package:flutter/widgets.dart'; import 'game.dart'; void main() { runApp( const GameWidget<PlatformerGame>.controlled( gameFactory: PlatformerGame.new, ), ); }
Toda a preparação é feita e a parte divertida começa.
Crie uma nova pasta lib/actors/
e um novo arquivo theboy.dart
dentro dela. Este será o componente que representa o personagem do jogador: The Boy.
import '../game.dart'; import '../assets.dart' as Assets; import 'package:flame/components.dart'; class TheBoy extends SpriteAnimationComponent with HasGameRef<PlatformerGame> { TheBoy({ required super.position, // Position on the screen }) : super( size: Vector2.all(48), // Size of the component anchor: Anchor.bottomCenter // ); @override Future<void> onLoad() async { animation = SpriteAnimation.fromFrameData( game.images.fromCache(Assets.THE_BOY), SpriteAnimationData.sequenced( amount: 1, // For now we only need idle animation, so we load only 1 frame textureSize: Vector2.all(20), // Size of a single sprite in the sprite sheet stepTime: 0.12, // Time between frames, since it's a single frame not that important ), ); } }
A classe estende SpriteAnimationComponent
que é um componente usado para sprites animados e possui um mixin HasGameRef
que nos permite referenciar o objeto do jogo para carregar imagens do cache do jogo ou obter variáveis globais posteriormente.
Em nosso método onLoad
, criamos uma nova SpriteAnimation
a partir da folha de sprite THE_BOY
que declaramos no arquivo assets.dart
.
Agora vamos adicionar nosso jogador ao jogo! Retorne ao arquivo game.dart
e adicione o seguinte ao final do método onLoad
:
final theBoy = TheBoy(position: Vector2(size.x / 2, size.y / 2)); add(theBoy);
Se você executar o jogo agora, poderemos conhecer o Garoto!
Primeiro, precisamos adicionar a capacidade de controlar The Boy a partir do teclado. Vamos adicionar o mixin HasKeyboardHandlerComponents
ao arquivo game.dart
.
class PlatformerGame extends FlameGame with HasKeyboardHandlerComponents
Em seguida, vamos retornar ao mixin theboy.dart
e KeyboardHandler
:
class TheBoy extends SpriteAnimationComponent with KeyboardHandler, HasGameRef<PlatformerGame>
Em seguida, adicione algumas novas variáveis de classe ao componente TheBoy
:
final double _moveSpeed = 300; // Max player's move speed int _horizontalDirection = 0; // Current direction the player is facing final Vector2 _velocity = Vector2.zero(); // Current player's speed
Por fim, vamos sobrescrever o método onKeyEvent
que permite ouvir as entradas do teclado:
@override bool onKeyEvent(RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed) { _horizontalDirection = 0; _horizontalDirection += (keysPressed.contains(LogicalKeyboardKey.keyA) || keysPressed.contains(LogicalKeyboardKey.arrowLeft)) ? -1 : 0; _horizontalDirection += (keysPressed.contains(LogicalKeyboardKey.keyD) || keysPressed.contains(LogicalKeyboardKey.arrowRight)) ? 1 : 0; return true; }
Agora _horizontalDirection
é igual a 1 se o jogador se mover para a direita, -1 se o jogador se mover para a esquerda e 0 se o jogador não se mover. No entanto, ainda não podemos vê-lo na tela, porque a posição do jogador ainda não foi alterada. Vamos corrigir isso adicionando o método update
.
Agora preciso explicar o que é o loop do jogo. Basicamente, significa que o jogo está sendo executado em um loop infinito. A cada iteração, o estado atual é renderizado no método render
Component's
e então um novo estado é calculado no método update
. O parâmetro dt
na assinatura do método é o tempo em milissegundos desde a última atualização de estado. Com isso em mente, adicione o seguinte ao theboy.dart
:
@override void update(double dt) { super.update(dt); _velocity.x = _horizontalDirection * _moveSpeed; position += _velocity * dt; }
Para cada ciclo de loop do jogo, atualizamos a velocidade horizontal, usando a direção atual e a velocidade máxima. Em seguida, alteramos a posição do sprite com o valor atualizado multiplicado por dt
.
Por que precisamos da última parte? Bem, se você atualizar a posição apenas com velocidade, o sprite voará para o espaço. Mas podemos apenas usar o menor valor de velocidade, você pode perguntar? Podemos, mas a maneira como o jogador se move será diferente com diferentes taxas de quadros por segundo (FPS). O número de quadros (ou loops de jogo) por segundo depende do desempenho do jogo e do hardware em que ele é executado. Quanto melhor o desempenho do dispositivo, maior o FPS e mais rápido o jogador se move. Para evitar isso, fazemos a velocidade depender do tempo passado desde o último quadro. Dessa forma, o sprite se moverá de maneira semelhante em qualquer FPS.
Ok, se executarmos o jogo agora, veremos isso:
Incrível, agora vamos fazer o menino virar quando for para a esquerda. Adicione isso ao final do método update
:
if ((_horizontalDirection < 0 && scale.x > 0) || (_horizontalDirection > 0 && scale.x < 0)) { flipHorizontally(); }
Lógica bastante fácil: verificamos se a direção atual (a seta que o usuário está pressionando) é diferente da direção do sprite e, em seguida, invertemos o sprite ao longo do eixo horizontal.
Agora vamos também adicionar animação em execução. Primeiro defina duas novas variáveis de classe:
late final SpriteAnimation _runAnimation; late final SpriteAnimation _idleAnimation;
Em seguida, atualize onLoad
assim:
@override Future<void> onLoad() async { _idleAnimation = SpriteAnimation.fromFrameData( game.images.fromCache(Assets.THE_BOY), SpriteAnimationData.sequenced( amount: 1, textureSize: Vector2.all(20), stepTime: 0.12, ), ); _runAnimation = SpriteAnimation.fromFrameData( game.images.fromCache(Assets.THE_BOY), SpriteAnimationData.sequenced( amount: 4, textureSize: Vector2.all(20), stepTime: 0.12, ), ); animation = _idleAnimation; }
Aqui extraímos a animação inativa adicionada anteriormente à variável de classe e definimos uma nova variável de animação de execução.
Em seguida, vamos adicionar um novo método updateAnimation
:
void updateAnimation() { if (_horizontalDirection == 0) { animation = _idleAnimation; } else { animation = _runAnimation; } }
E, finalmente, invoque esse método na parte inferior do método update
e execute o jogo.
É isso para a primeira parte. Aprendemos como configurar um jogo Flame, onde encontrar recursos, como carregá-los em seu jogo e como criar um personagem animado incrível e movê-lo com base nas entradas do teclado. O código desta parte pode ser encontrado no meu github .
No próximo artigo, abordarei como criar um nível de jogo usando Tiled, como controlar a câmera Flame e adicionar um plano de fundo paralaxe. Fique atento!
No final de cada parte, adicionarei uma lista de criadores e recursos incríveis com os quais aprendi.