paint-brush
Desenvolvimento de jogos MVP com Flutter and Flameby@leobit
408
408

Desenvolvimento de jogos MVP com Flutter and Flame

Leobit28m2024/06/06
Read on Terminal Reader

Os MVPs (produtos mínimos viáveis) estão se tornando cada vez mais populares na indústria de jogos. Com o MVP, você pode criar um aplicativo com funcionalidades básicas em curto prazo e com um orçamento limitado. Com a ajuda do Flame, um mecanismo de jogo robusto e de código aberto construído sobre o Flutter, você pode criar jogos 2D impressionantes.
featured image - Desenvolvimento de jogos MVP com Flutter and Flame
Leobit HackerNoon profile picture
0-item

Apenas 2 em cada 5 startups são lucrativas, de acordo com uma pesquisa recente . Um MVP (produto mínimo viável) aumenta significativamente as chances de lucratividade de uma startup, pois permite que essas empresas coletem feedback antecipado dos usuários sem gastar todo o orçamento em um aplicativo com funcionalidade completa.


Com o MVP, você pode construir um aplicativo com funcionalidades básicas em curto prazo e com um orçamento limitado, coletar feedback do usuário e continuar expandindo a solução com sua equipe de desenvolvimento de acordo com esse feedback.


Os MVPs estão se tornando cada vez mais populares na indústria de jogos. Hoje, exploraremos os meandros do desenvolvimento rápido de MVP de jogos com Flutter e Flame, uma combinação estelar para a construção de produtos mínimos viáveis de plataforma cruzada.

Por que escolher Flutter e Flame?

Flutter, uma plataforma segura e repleta de recursos para desenvolvimento multiplataforma , conquistou o mundo dos aplicativos móveis e seu alcance vai muito além da interface do usuário. Com a ajuda do Flame, um mecanismo de jogo robusto e de código aberto desenvolvido com base no Flutter , você pode criar jogos 2D impressionantes que rodam perfeitamente em dispositivos Android, iOS, Web e Desktop.


O Flutter também se tornou uma solução popular para a construção de MVPs de jogos devido aos seus recursos integrais que facilitam o rápido desenvolvimento de soluções que apresentam a funcionalidade básica em diferentes dispositivos. Em particular, vários benefícios e funções integrais do Flutter permitem:


  • Crie um produto com uma base de código compartilhada para diferentes plataformas, incluindo Android e iOS, o que é muito mais rápido e econômico do que criar aplicativos nativos separados para plataformas diferentes. Existem também certas práticas para construir aplicativos web Flutter com a mesma base de código.


  • Crie interfaces de usuário flexíveis com widgets pré-construídos e animações padrão, o que aumenta a velocidade de desenvolvimento, um dos fatores mais críticos em termos de desenvolvimento de MVP.


  • Flutter oferece funcionalidade de hot reload que permite aos desenvolvedores visualizar as alterações feitas no código do aplicativo que aparecem na tela simultaneamente, garantindo maior flexibilidade no desenvolvimento de MVP. Esse recurso torna a iteração e a experimentação muito mais simples, permitindo que os desenvolvedores experimentem rapidamente diferentes mecânicas e visuais.


  • O desenvolvimento de um produto mínimo viável geralmente envolve um número mínimo de recursos, e o Flutter satisfaz totalmente esse requisito, pois a integração padrão do Flutter com o Firebase reduz significativamente a complexidade da programação do lado do servidor.


O Flutter não consome muitos recursos computacionais e facilita a configuração simples de aplicativos multiplataforma.


O aplicativo MVP baseado na combinação Flutter e Flame é uma solução confiável, mas relativamente simples de desenvolver. Ele compila diretamente no código nativo, garantindo jogabilidade e capacidade de resposta suaves. Você pode desenvolver seu MVP de jogo uma vez e implantá-lo em diferentes plataformas, economizando tempo e recursos. Flutter e Flame lidam com as diferenças de plataforma sob o capô.


Além disso, ambas as tecnologias possuem comunidades vibrantes com extensa documentação, tutoriais e exemplos de código. Isso significa que você nunca ficará sem resposta ou inspiração.

Então, o que a chama pode fazer?

Flame fornece um conjunto completo de ferramentas para criar recursos de jogos MVP em curto prazo e sem gastar recursos excessivos. Esta estrutura de modelagem multiplataforma oferece ferramentas para uma ampla variedade de casos de uso diferentes:


  • Sprites e animações: você pode criar seus sprites rapidamente ou usá-los em várias bibliotecas online. O Flame também suporta animação esquelética, o que permite fazer animações mais complexas e realistas.


  • Detecção de colisão: Possui um sistema integrado de detecção de colisão que facilita a criação de jogos com sua própria física. Você pode usar a detecção de colisão para construir plataformas, paredes, itens colecionáveis e outras coisas com as quais os personagens do jogo possam interagir.


  • Simulações de Física: Flame também suporta simulações de física, que permitem criar mecânicas de jogo mais dinâmicas e envolventes. Você pode usar simulações de física para criar coisas como gravidade, saltos e saltos.


  • Áudio e efeitos sonoros: você pode usar áudio para criar música de fundo, efeitos sonoros (como bater, pular, etc.) e até dublagem.


  • Gerenciamento de estado: o Flame oferece vários recursos para gerenciar o estado do seu jogo. Isso inclui coisas como pontuação, gerenciamento de nível e dados do jogador.


  • Dispositivos de entrada: O Flame oferece suporte a vários dispositivos de entrada, como telas sensíveis ao toque, teclados e controladores de jogos. Isso o torna uma ótima opção para desenvolver jogos para diversas plataformas.


  • Rolagem paralaxe: suporta rolagem paralaxe, que pode adicionar profundidade e imersão ao mundo do jogo. A rolagem paralaxe cria a ilusão de profundidade movendo diferentes camadas do fundo em velocidades diferentes.


  • Sistemas de Partículas: Flame também suporta sistemas de partículas, que podem ser usados para criar uma variedade de efeitos visuais, como explosões, fumaça e chuva.


  • Jogabilidade multijogador: permite que os jogadores compitam ou colaborem entre si em tempo real.


A maioria dos recursos mencionados acima são essenciais para muitos jogos e não devem ser negligenciados mesmo no estágio de desenvolvimento do MVP. O que é realmente importante é que o Flame aumenta significativamente a velocidade de desenvolvimento das funcionalidades mencionadas acima, permitindo o lançamento de tais recursos mesmo nas primeiras versões do produto.

Vamos tentar

Agora, ao invés de falar sobre Flame, vamos criar um MVP contendo características básicas do nosso próprio jogo com este framework. Antes de começarmos, você deve ter instalado o Flutter 3.13 ou superior, seu IDE e dispositivo favorito para teste.

Uma ideia

Este jogo é inspirado no Chrome Dino. Ah, o famoso Dino Run! É mais do que apenas um jogo do Chrome. É um querido ovo de Páscoa escondido no modo offline do navegador.


Nosso projeto terá a seguinte jogabilidade:

  • Você joga como Jack, um cara aventureiro que corre sem parar por uma floresta escura.
  • Os controles são mínimos: toque na barra de espaço ou clique na tela para pular.
  • O jogo começa lento, mas gradualmente aumenta a velocidade, mantendo você alerta.
  • Seu objetivo é simples: desviar dos obstáculos e correr o mais longe possível, acumulando pontos pelo caminho.


E será chamado de “Forest Run!”

Se prepare

Crie um projeto Flutter vazio como você faz sempre que inicia um novo aplicativo. Para começar, precisamos definir dependências em pubspec.yaml para nosso projeto. Ao escrever esta postagem, a versão mais recente do Flame é 1.14.0. Além disso, vamos definir todos os caminhos dos ativos agora, para que não haja necessidade de retornar a este arquivo posteriormente. E coloque as imagens no diretório assets/images/. Precisamos colocá-lo aqui porque o Flame irá escanear exatamente este caminho:


 environment: sdk: '>=3.2.3 <4.0.0' flutter: '>=3.13.0' dependencies: flutter: sdk: flutter flame: ^1.14.0 flutter: uses-material-design: true assets: - assets/images/ - assets/images/character/ - assets/images/background/ - assets/images/forest/ - assets/images/font/


Lembre-se de colocar todas as imagens em assets/images/ porque o Flame não analisará outros diretórios.


Você precisará de muitas imagens para qualquer jogo. Mas e se você não for bom em design? Felizmente, existem muitos recursos de código aberto que você pode usar em seus projetos. Os recursos deste jogo foram retirados de itch.io. Usaremos estes recursos para nosso projeto:



Você pode visitar esses links ou apenas baixar os ativos preparados (LINK PARA ARQUIVO DE ATIVOS) para este projeto e copiar todo o conteúdo para o seu projeto.


Flame tem uma filosofia semelhante à do Flutter. No Flutter, tudo é um widget; no Flame, tudo é um componente, até mesmo o jogo inteiro. Cada componente pode substituir 2 métodos: onLoad() e update(). onLoad() é chamado apenas uma vez quando Component é montado no ComponentTree e update() é disparado em cada quadro. Muito semelhante a initState() e build() do StatefulWidget no Flutter.


Agora, vamos escrever algum código. Crie uma classe que estenda FlameGame e carregue todos os nossos ativos no cache.


 class ForestRunGame extends FlameGame { @override Future<void> onLoad() async { await super.onLoad(); await images.loadAllImages(); } }


Em seguida, use ForestRunGame em main.dart. Além disso, você pode usar métodos de Flame.device para configurar a orientação do dispositivo. E existe o GameWidget, que serve como ponte entre widgets e componentes.


 Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); await Flame.device.fullScreen(); await Flame.device.setLandscape(); runApp(GameWidget(game: ForestRunGame())); }


Neste ponto já podemos iniciar o jogo, mas haverá apenas uma tela preta. Então, precisamos adicionar nossos componentes.

Floresta Negra

Dividiremos a floresta em dois componentes: fundo e primeiro plano. Primeiramente, cuidaremos do plano de fundo. Você já percorreu uma página que parecia dinâmica? Como se você estivesse percorrendo mais de uma visualização ao mesmo tempo? Esse é um efeito de paralaxe e acontece quando os diferentes elementos de uma página se movem em velocidades diferentes, criando um efeito de profundidade 3D.


Como você pode imaginar, usaremos paralaxe como plano de fundo. Estenda o ParallaxComponent e configure uma pilha de imagens usando ParallaxImageData. Além disso, existe baseVelocity para a velocidade das camadas iniciais e speedMultiplierDelta, que representa a diferença relativa de velocidade entre as camadas. E por último, configure o campo de prioridade (índice z) para movê-lo para trás de outros componentes.


 class ForestBackground extends ParallaxComponent<ForestRunGame> { @override Future<void> onLoad() async { priority = -10; parallax = await game.loadParallax( [ ParallaxImageData('background/plx-1.png'), ParallaxImageData('background/plx-2.png'), ParallaxImageData('background/plx-3.png'), ParallaxImageData('background/plx-4.png'), ParallaxImageData('background/plx-5.png'), ], baseVelocity: Vector2.zero(), velocityMultiplierDelta: Vector2(1.4, 1.0), ); } }


O plano de fundo está pronto; agora é hora de adicionar o primeiro plano. Estenda o PositionComponent para que possamos alinhar o solo com a parte inferior da tela. Também precisamos do mixin HasGameReference para acessar o cache do jogo.


Para criar o terreno, você só precisa alinhar a imagem do bloco de solo várias vezes. No Flame, os componentes da imagem são chamados de sprites. Um Sprite é uma região de uma imagem que pode ser renderizada no Canvas. Pode representar a imagem inteira ou ser uma das peças que compõem uma sprite sheet.


Além disso, lembre-se de que o eixo X está orientado para a direita e o eixo Y está orientado para baixo. O centro dos eixos está posicionado no canto superior esquerdo da tela.



 class ForestForeground extends PositionComponent with HasGameReference<ForestRunGame> { static final blockSize = Vector2(480, 96); late final Sprite groundBlock; late final Queue<SpriteComponent> ground; @override void onLoad() { super.onLoad(); groundBlock = Sprite(game.images.fromCache('forest/ground.png')); ground = Queue(); } @override void onGameResize(Vector2 size) { super.onGameResize(size); final newBlocks = _generateBlocks(); ground.addAll(newBlocks); addAll(newBlocks); y = size.y - blockSize.y; } List<SpriteComponent> _generateBlocks() { final number = 1 + (game.size.x / blockSize.x).ceil() - ground.length; final lastBlock = ground.lastOrNull; final lastX = lastBlock == null ? 0 : lastBlock.x + lastBlock.width; return List.generate( max(number, 0), (i) => SpriteComponent( sprite: groundBlock, size: blockSize, position: Vector2(lastX + blockSize.x * i, y), priority: -5, ), growable: false, ); } }


E por último, adicione esses componentes ao nosso ForestRunGame.


 class ForestRunGame extends FlameGame { late final foreground = ForestForeground(); late final background = ForestBackground(); @override Future<void> onLoad() async { await super.onLoad(); await images.loadAllImages(); add(foreground); add(background); } }


Agora, tente iniciar o jogo. Neste ponto já temos a nossa floresta.


Um estranho na floresta

A floresta parece boa, mas é apenas uma imagem neste momento. Então, vamos criar o Jack, que correrá por essa floresta sob a orientação do jogador. Ao contrário das árvores e do solo, o jogador precisa de animações para se sentir vivo. Usamos Sprite para blocos terrestres, mas usaremos SpriteAnimation para Jack. Como é que isso funciona? Bem, tudo é fácil, você só precisa fazer um loop em uma sequência de sprites. Por exemplo, nossa animação de execução possui 8 sprites, que se substituem com um pequeno intervalo de tempo.



Jack pode correr, pular e ficar ocioso. Para representar seus estados, podemos adicionar uma enumeração PlayerState. Em seguida, crie um Player que estenda SpriteAnimationGroupComponent e passe PlayerState como um argumento genérico. Este componente possui um campo de animações onde são armazenadas as animações de cada PlayerState e um campo atual, que representa o estado atual do player, que precisa ser animado.


 enum PlayerState { jumping, running, idle } class Player extends SpriteAnimationGroupComponent<PlayerState> { @override void onLoad() { super.onLoad(); animations = { PlayerState.running: SpriteAnimation.fromFrameData( game.images.fromCache('character/run.png'), SpriteAnimationData.sequenced( amount: 8, amountPerRow: 5, stepTime: 0.1, textureSize: Vector2(23, 34), ), ), PlayerState.idle: SpriteAnimation.fromFrameData( game.images.fromCache('character/idle.png'), SpriteAnimationData.sequenced( amount: 12, amountPerRow: 5, stepTime: 0.1, textureSize: Vector2(21, 35), ), ), PlayerState.jumping: SpriteAnimation.spriteList( [ Sprite(game.images.fromCache('character/jump.png')), Sprite(game.images.fromCache('character/land.png')), ], stepTime: 0.4, loop: false, ), }; current = PlayerState.idle; } }


Os estados do jogador estão prontos. Agora, precisamos dar ao player um tamanho e posição na tela. Vou definir o tamanho dele para 69x102 pixels, mas fique à vontade para alterá-lo como quiser. Para a posição, devemos conhecer as coordenadas do solo. Ao adicionar o mixin HasGameReference, podemos acessar o campo de primeiro plano e obter suas coordenadas. Agora, vamos substituir o método onGameResize, que é chamado toda vez que o tamanho do aplicativo é alterado, e definir a posição de Jack ali.


 class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame> { static const startXPosition = 80.0; Player() : super(size: Vector2(69, 102)); double get groundYPosition => game.foreground.y - height + 20; // onLoad() {...} with animation setup @override void onGameResize(Vector2 size) { super.onGameResize(size); x = startXPosition; y = groundYPosition; } }


Como foi feito antes, adicione o jogador ao nosso Jogo.


 class ForestRunGame extends FlameGame { // Earlier written code here... late final player = Player(); @override Future<void> onLoad() async { // Earlier written code here... add(player); } }


Se você iniciar o jogo, verá que Jack já está na floresta!


Corra Jack, corra!

Nosso jogo tem três estados: introdução, jogo e fim do jogo. Então, adicionaremos o enum GameState que representa esses. Para fazer Jack correr, precisamos de variáveis de velocidade e aceleração. Além disso, precisamos calcular a distância percorrida (será usada posteriormente).


Como foi mencionado anteriormente, o Componente possui dois métodos principais: onLoad() e update(). Já usamos o método onLoad algumas vezes. Agora vamos falar sobre update(). Este método possui um parâmetro chamado dt. Representa o tempo decorrido desde a última vez que update() foi chamado.


Para calcular a velocidade atual e a distância percorrida, usaremos o método update() e algumas fórmulas cinemáticas básicas:

  • Distância = velocidade * tempo;
  • Velocidade = aceleração * tempo;


 enum GameState { intro, playing, gameOver } class ForestRunGame extends FlameGame { static const acceleration = 10.0; static const maxSpeed = 2000.0; static const startSpeed = 400.0; GameState state = GameState.intro; double currentSpeed = 0; double traveledDistance = 0; // Earlier written code here... @override void update(double dt) { super.update(dt); if (state == GameState.playing) { traveledDistance += currentSpeed * dt; if (currentSpeed < maxSpeed) { currentSpeed += acceleration * dt; } } } }


Na verdade, usaremos um truque para simplificar o desenvolvimento: Jack ficará estável, mas a floresta se moverá em direção a Jack. Então, precisamos que nossa floresta aplique velocidade ao jogo.


Para fundo de paralaxe, só precisamos passar a velocidade do jogo. E cuidará automaticamente do resto.


 class ForestBackground extends ParallaxComponent<ForestRunGame> { // Earlier written code here... @override void update(double dt) { super.update(dt); parallax?.baseVelocity = Vector2(game.currentSpeed / 10, 0); } }


Para o primeiro plano, precisamos deslocar cada bloco de solo. Além disso, precisamos verificar se o primeiro bloco da fila saiu da tela. Se sim, então remova-o e coloque-o no final da fila;


 class ForestForeground extends PositionComponent with HasGameReference<ForestRunGame> { // Earlier written code here... @override void update(double dt) { super.update(dt); final shift = game.currentSpeed * dt; for (final block in ground) { block.x -= shift; } final firstBlock = ground.first; if (firstBlock.x <= -firstBlock.width) { firstBlock.x = ground.last.x + ground.last.width; ground.remove(firstBlock); ground.add(firstBlock); } } }


Está tudo pronto, mas um gatilho. Queremos começar a correr com um clique. Nossos alvos são dispositivos móveis e desktops, por isso queremos lidar com toques na tela e eventos de teclado.


Felizmente, Flame tem uma maneira de fazer isso. Basta adicionar um mixin para o seu tipo de entrada. Para o teclado, são KeyboardEvents e TapCallbacks para tocar na tela. Esses mixins oferecem a possibilidade de substituir métodos relacionados e fornecer sua lógica.


O jogo deve iniciar se o usuário pressionar a barra de espaço ou tocar na tela.


 class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks { // Earlier written code here... @override KeyEventResult onKeyEvent( RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed, ) { if (keysPressed.contains(LogicalKeyboardKey.space)) { start(); } return KeyEventResult.handled; } @override void onTapDown(TapDownEvent event) { start(); } void start() { state = GameState.playing; player.current = PlayerState.running; currentSpeed = startSpeed; traveledDistance = 0; } }


Como resultado, Jack pode correr agora após clicar.


Oh não, um arbusto!

Agora, queremos ter obstáculos no caminho. No nosso caso, eles serão representados como arbustos venenosos. Bush não é animado, então podemos usar SpriteComponent. Além disso, precisamos de uma referência do jogo para acessar sua velocidade. E mais uma coisa; não queremos gerar arbustos um por um, porque essa abordagem pode causar uma situação em que Jack simplesmente não consegue passar por uma linha de arbustos com um salto. É um número aleatório do intervalo, que depende da velocidade atual do jogo.


 class Bush extends SpriteComponent with HasGameReference<ForestRunGame> { late double gap; Bush() : super(size: Vector2(200, 84)); bool get isVisible => x + width > 0; @override Future<void> onLoad() async { x = game.size.x + width; y = -height + 20; gap = _computeRandomGap(); sprite = Sprite(game.images.fromCache('forest/bush.png')); } double _computeRandomGap() { final minGap = width * game.currentSpeed * 100; final maxGap = minGap * 5; return (Random().nextDouble() * (maxGap - minGap + 1)).floor() + minGap; } @override void update(double dt) { super.update(dt); x -= game.currentSpeed * dt; if (!isVisible) { removeFromParent(); } } }


Quem está plantando arbustos? Natureza, é claro. Vamos criar a Natureza que irá gerir a nossa geração de arbustos.


 class Nature extends Component with HasGameReference<ForestRunGame> { @override void update(double dt) { super.update(dt); if (game.currentSpeed > 0) { final plant = children.query<Bush>().lastOrNull; if (plant == null || (plant.x + plant.width + plant.gap) < game.size.x) { add(Bush()); } } } }


Agora, vamos adicionar Nature ao nosso ForestForeground.


 class ForestForeground extends PositionComponent with HasGameReference<ForestRunGame> { // Earlier written code here... late final Nature nature; @override void onLoad() { // Earlier written code here... nature = Nature(); add(nature); }


Agora, nossa floresta tem arbustos. Mas espere, Jack está apenas examinando-os. Por que isso está acontecendo? É porque ainda não implementamos a rebatida.


Aqui, Hitbox nos ajudará. Hitbox é outro componente do zoológico de componentes do Flame. Ele encapsula a detecção de colisão e oferece a possibilidade de lidar com isso com lógica personalizada.


Adicione um para Jack. Lembre-se de que a posição do componente será no canto esquerdo-direito, e não no centro. E com o tamanho você cuida do resto.


 class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame> { // Earlier written code here... @override void onLoad() { // Earlier written code here... add( RectangleHitbox( position: Vector2(2, 2), size: Vector2(60, 100), ), ); } }


E um para o mato. Aqui, definiremos o tipo de colisão como passivo para alguma otimização. Por padrão, o tipo está ativo, o que significa que o Flame verificará se esta hitbox colidiu com todas as outras hitboxes. Temos apenas um jogador e arbustos. Como o jogador já possui um tipo de colisão ativo e os arbustos não podem colidir entre si, podemos definir o tipo como passivo.


 class Bush extends SpriteComponent with HasGameReference<ForestRunGame> { // Earlier written code here... @override void onLoad() { // Earlier written code here... add( RectangleHitbox( position: Vector2(30, 30), size: Vector2(150, 54), collisionType: CollisionType.passive, ), ); } }


É legal, mas não consigo ver se a posição do hitbox foi ajustada corretamente. Como posso testá-lo?


Bem, você pode definir o campo debugMode de Player e Bush como verdadeiro. Isso permitirá que você veja como seus hitboxes estão posicionados. Roxo define o tamanho do componente e amarelo indica o hitbox.


Agora, queremos detectar quando há uma colisão entre o jogador e o arbusto. Para isso, você precisa adicionar o mixin HasCollisionDetection ao Game e, em seguida, CollisionCallbacks para componentes, que precisam lidar com colisões.


 class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks, HasCollisionDetection { // Earlier written code here... }


Por enquanto, basta pausar o jogo quando a colisão for detectada.


 class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame>, CollisionCallbacks { // Earlier written code here... @override void onCollisionStart( Set<Vector2> intersectionPoints, PositionComponent other, ) { super.onCollisionStart(intersectionPoints, other); game.paused = true; } }


Salte ou morra

Se Jack quiser evitar aqueles arbustos, ele precisa pular. Vamos ensiná-lo. Para este recurso, precisamos da constante de gravidade e da velocidade vertical inicial do salto de Jack. Esses valores foram escolhidos a olho nu, então fique à vontade para ajustá-los.


Então, como funciona a gravidade? Basicamente, é a mesma aceleração, mas orientada para o solo. Portanto, podemos utilizar as mesmas fórmulas para posição vertical e velocidade. Então, nosso salto terá 3 etapas:

  1. O salto é acionado e a velocidade vertical de Jack muda de zero para o valor inicial.
  2. Ele está subindo e a gravidade muda gradualmente sua velocidade. Em um momento, Jack irá parar de subir e começar a descer.
  3. Quando Jack toca o chão, precisamos parar de aplicar gravidade a ele e redefinir seu estado de corrida.


 class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame>, CollisionCallbacks { static const gravity = 1400.0; static const initialJumpVelocity = -700.0; double jumpSpeed = 0; // Earlier written code here... void jump() { if (current != PlayerState.jumping) { current = PlayerState.jumping; jumpSpeed = initialJumpVelocity - (game.currentSpeed / 500); } } void reset() { y = groundYPos; jumpSpeed = 0; current = PlayerState.running; } @override void update(double dt) { super.update(dt); if (current == PlayerState.jumping) { y += jumpSpeed * dt; jumpSpeed += gravity * dt; if (y > groundYPos) { reset(); } } else { y = groundYPos; } } }


E agora vamos acionar o salto clicando no ForestRunGame


 class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks, HasCollisionDetection { // Earlier written code here... @override KeyEventResult onKeyEvent( RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed, ) { if (keysPressed.contains(LogicalKeyboardKey.space)) { onAction(); } return KeyEventResult.handled; } @override void onTapDown(TapDownEvent event) { onAction(); } void onAction() { switch (state) { case GameState.intro: case GameState.gameOver: start(); break; case GameState.playing: player.jump(); break; } } }


Agora, Jack pode lidar com arbustos.

Game Over

Quando o jogo terminar, queremos mostrar o texto na tela. Text in Flame funciona de maneira diferente do Flutter. Você deve criar uma fonte primeiro. Nos bastidores, é apenas um mapa, onde char é uma chave e sprite é um valor. Quase sempre, a fonte do jogo é uma imagem onde todos os símbolos necessários estão reunidos.


Para este jogo, precisamos apenas de dígitos e letras maiúsculas. Então, vamos criar nossa fonte. Para fazer isso, você deve passar a imagem de origem e os glifos. O que é um glifo? Glyph é uma união de informações sobre char, seu tamanho e posição na imagem de origem.


 class StoneText extends TextBoxComponent { static const digits = '123456789'; static const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; StoneText({ required Image source, required super.position, super.text = '', }) : super( textRenderer: SpriteFontRenderer.fromFont( SpriteFont( source: source, size: 32, ascent: 32, glyphs: [ _buildGlyph(char: '0', left: 480, top: 0), for (var i = 0; i < digits.length; i++) _buildGlyph(char: digits[i], left: 32.0 * i, top: 32), for (var i = 0; i < letters.length; i++) _buildGlyph( char: letters[i], left: 32.0 * (i % 16), top: 64.0 + 32 * (i ~/ 16), ), ], ), letterSpacing: 2, ), ); static Glyph _buildGlyph({ required String char, required double left, required double top, }) => Glyph(char, left: left, top: top, height: 32, width: 32); }


Agora podemos criar o painel de game over e usá-lo no jogo.


 class GameOverPanel extends PositionComponent with HasGameReference<ForestRunGame> { bool visible = false; @override Future<void> onLoad() async { final source = game.images.fromCache('font/keypound.png'); add(StoneText(text: 'GAME', source: source, position: Vector2(-144, -16))); add(StoneText(text: 'OVER', source: source, position: Vector2(16, -16))); } @override void renderTree(Canvas canvas) { if (visible) { super.renderTree(canvas); } } @override void onGameResize(Vector2 size) { super.onGameResize(size); x = size.x / 2; y = size.y / 2; } }


Agora podemos mostrar nosso painel, quando Jack cai no mato. Também vamos modificar o método start(), para que possamos reiniciar o jogo com um clique. Além disso, precisamos limpar todos os arbustos da floresta.


 class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks, HasCollisionDetection { // Earlier written code here... late final gameOverPanel = GameOverPanel(); @override Future<void> onLoad() async { // Earlier written code here... add(gameOverPanel); } void gameOver() { paused = true; gameOverPanel.visible = true; state = GameState.gameOver; currentSpeed = 0; } void start() { paused = false; state = GameState.playing; currentSpeed = startSpeed; traveledDistance = 0; player.reset(); foreground.nature.removeAll(foreground.nature.children); gameOverPanel.visible = false; } }


E agora, precisamos atualizar o retorno de chamada de colisão no player.


 class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame>, CollisionCallbacks { // Earlier written code here... @override void onCollisionStart( Set<Vector2> intersectionPoints, PositionComponent other, ) { super.onCollisionStart(intersectionPoints, other); game.gameOver(); } }


Agora, você pode ver o Game Over quando Jack atinge um arbusto. E reinicie o jogo apenas clicando novamente.


E quanto à minha pontuação?

E o cálculo final da pontuação de toque.


 class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks, HasCollisionDetection { late final StoneText scoreText; late final StoneText highText; late final StoneText highScoreText; int _score = 0; int _highScore = 0; // Earlier written code here... @override Future<void> onLoad() async { // Earlier written code here... final font = images.fromCache('font/keypound.png'); scoreText = StoneText(source: font, position: Vector2(20, 20)); highText = StoneText(text: 'HI', source: font, position: Vector2(256, 20)); highScoreText = StoneText( text: '00000', source: font, position: Vector2(332, 20), ); add(scoreText); add(highScoreText); add(highText); setScore(0); } void start() { // Earlier written code here... if (_score > _highScore) { _highScore = _score; highScoreText.text = _highScore.toString().padLeft(5, '0'); } _score = 0; } @override void update(double dt) { super.update(dt); if (state == GameState.playing) { traveledDistance += currentSpeed * dt; setScore(traveledDistance ~/ 50); if (currentSpeed < maxSpeed) { currentSpeed += acceleration * dt; } } } void setScore(int score) { _score = score; scoreText.text = _score.toString().padLeft(5, '0'); } }


Isso é tudo, pessoal!


Agora, experimente e tente bater minha pontuação mais alta. São 2537 pontos!

Conclusões

Foi muito, mas conseguimos. Criamos um produto mínimo viável para um jogo mobile com física, animações, cálculo de pontuação e muito mais. Sempre há espaço para melhorias e, assim como qualquer outro MVP, nosso produto pode ser esperado com novos recursos, mecânicas e modos de jogo no futuro.


Além disso, existe um pacote flame_audio, que você pode usar para adicionar música de fundo, sons de pulos ou batidas, etc.


Por enquanto, nosso principal objetivo era criar a funcionalidade básica do produto em curto prazo e com alocação limitada de recursos. A combinação de Flutter e Flame provou ser perfeita para construir um MVP de jogo que pode ser usado para coletar feedback do usuário e continuar atualizando o aplicativo no futuro.


Você pode conferir os resultados do nosso esforço aqui .


Com seus recursos poderosos, facilidade de uso e comunidade próspera, Flutter e Flame são uma escolha atraente para aspirantes a desenvolvedores de jogos. Quer você seja um profissional experiente ou esteja apenas começando, esta combinação oferece as ferramentas e o potencial para dar vida às suas ideias de jogo. Então, pegue sua criatividade, mergulhe no mundo do Flutter and Flame e comece a construir a próxima sensação dos jogos para celular!


Esperamos que você tenha achado este artigo agradável e informativo. Se você deseja mais informações sobre desenvolvimento de software ou deseja discutir seu próprio projeto MVP, não hesite em explorar o Leobit ou entrar em contato com nossa equipe técnica!