1. Antes de começar
O Flame é um mecanismo de jogo 2D baseado no Flutter. Neste codelab, você vai criar um jogo que usa uma simulação de física 2D nas linhas do Box2D, chamada Forge2D. Você vai usar os componentes do Flame para pintar a realidade física simulada na tela para os usuários brincarem. Quando concluído, seu jogo deve ficar parecido com este GIF animado:
Pré-requisitos
- Já ter feito o codelab Introdução ao Flame com o Flutter.
Conteúdo do laboratório
- Como funcionam os fundamentos do Forge2D, começando com os diferentes tipos de corpos físicos.
- Como configurar uma simulação física em 2D.
O que é necessário
- O SDK do Flutter
- Visual Studio Code (VS Code) com os plug-ins do Flutter e Dart
Compilador de software para o destino de desenvolvimento escolhido. Este codelab funciona para todas as seis plataformas compatíveis com o Flutter. Você precisa do Visual Studio para segmentar o Windows, o Xcode para segmentar macOS ou iOS e o Android Studio para segmentar o Android.
2. Criar um projeto
Criar seu projeto do Flutter
Há muitas maneiras de criar um projeto do Flutter. Nesta seção, você vai usar a linha de comando para simplificar.
Para começar, siga estas etapas:
- Em uma linha de comando, crie um projeto do Flutter:
$ flutter create --empty forge2d_game Creating project forge2d_game... Resolving dependencies in forge2d_game... (4.7s) Got dependencies in forge2d_game. Wrote 128 files. All done! You can find general documentation for Flutter at: https://docs.flutter.dev/ Detailed API documentation is available at: https://api.flutter.dev/ If you prefer video documentation, consider: https://www.youtube.com/c/flutterdev In order to run your empty application, type: $ cd forge2d_game $ flutter run Your empty application code is in forge2d_game/lib/main.dart.
- Modifique as dependências do projeto para adicionar Flame e Forge2D:
$ cd forge2d_game $ flutter pub add characters flame flame_forge2d flame_kenney_xml xml Resolving dependencies... Downloading packages... characters 1.3.0 (from transitive dependency to direct dependency) collection 1.18.0 (1.19.0 available) + flame 1.18.0 + flame_forge2d 0.18.1 + flame_kenney_xml 0.1.0 flutter_lints 3.0.2 (4.0.0 available) + forge2d 0.13.0 leak_tracker 10.0.4 (10.0.5 available) leak_tracker_flutter_testing 3.0.3 (3.0.5 available) lints 3.0.0 (4.0.0 available) material_color_utilities 0.8.0 (0.12.0 available) meta 1.12.0 (1.15.0 available) + ordered_set 5.0.3 (6.0.1 available) + petitparser 6.0.2 test_api 0.7.0 (0.7.3 available) vm_service 14.2.1 (14.2.4 available) + xml 6.5.0 Changed 8 dependencies! 10 packages have newer versions incompatible with dependency constraints. Try `flutter pub outdated` for more information.
Você já conhece o pacote flame
, mas os outros três podem precisar de algumas explicações. O pacote characters
é usado para manipulação de caminho de arquivo em conformidade com UTF8. O pacote flame_forge2d
expõe a funcionalidade Forge2D de uma forma que funcione bem com o Flame. Por fim, o pacote xml
é usado em vários lugares para consumir e modificar conteúdo XML.
Abra o projeto e substitua o conteúdo do arquivo lib/main.dart
pelo seguinte:
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
void main() {
runApp(
GameWidget.controlled(
gameFactory: FlameGame.new,
),
);
}
Isso inicia o app com uma GameWidget
que instancia a instância do FlameGame
. Não há código do Flutter neste codelab que use o estado da instância do jogo para mostrar informações sobre o jogo em execução. Por isso, esse bootstrap simplificado funciona bem.
Opcional: fazer uma missão secundária apenas para macOS
As capturas de tela deste projeto são do jogo como um app para computador macOS. Para evitar que a barra de título do app prejudique a experiência geral, você pode modificar a configuração do projeto do executor do macOS para ocultar a barra de título.
Para isso, siga estas etapas:
- Crie um arquivo
bin/modify_macos_config.dart
e adicione o seguinte conteúdo:
bin/modify_macos_config.dart (link em inglês)
import 'dart:io';
import 'package:xml/xml.dart';
import 'package:xml/xpath.dart';
void main() {
final file = File('macos/Runner/Base.lproj/MainMenu.xib');
var document = XmlDocument.parse(file.readAsStringSync());
document.xpath('//document/objects/window').first
..setAttribute('titlebarAppearsTransparent', 'YES')
..setAttribute('titleVisibility', 'hidden');
document
.xpath('//document/objects/window/windowStyleMask')
.first
.setAttribute('fullSizeContentView', 'YES');
file.writeAsStringSync(document.toString());
}
Esse arquivo não está no diretório lib
porque não faz parte da base de código do ambiente de execução do jogo. É uma ferramenta de linha de comando usada para modificar o projeto.
- No diretório base do projeto, execute a ferramenta da seguinte maneira:
$ dart bin/modify_macos_config.dart
Se tudo correr conforme o planejado, o programa não vai gerar saída na linha de comando. No entanto, ele vai modificar o arquivo de configuração macos/Runner/Base.lproj/MainMenu.xib
para executar o jogo sem uma barra de título visível e com o jogo do Flame ocupando a janela inteira.
Execute o jogo para verificar se tudo está funcionando. Uma nova janela será mostrada com apenas um plano de fundo preto em branco.
3. Adicionar recursos de imagem
Adicionar as imagens
Qualquer jogo precisa de recursos de arte para pintar uma tela de uma forma que use a diversão. Este codelab vai usar o pacote Physics Assets (link em inglês) do Kenney.nl. Esses recursos são licenciados Creative Commons CC0, mas ainda sugiro que você faça uma doação à equipe de Carlos para que eles possam continuar o ótimo trabalho que estão fazendo. Eu fiz.
Você precisará modificar o arquivo de configuração pubspec.yaml
para permitir o uso dos recursos do Kenney. Modifique-o da seguinte maneira:
pubspec.yaml
name: forge2d_game
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0
environment:
sdk: '>=3.3.3 <4.0.0'
dependencies:
characters: ^1.3.0
flame: ^1.17.0
flame_forge2d: ^0.18.0
flutter:
sdk: flutter
xml: ^6.5.0
dev_dependencies:
flutter_lints: ^3.0.0
flutter_test:
sdk: flutter
flutter:
uses-material-design: true
assets: # Add from here
- assets/
- assets/images/ # To here.
O Flame espera que os recursos de imagem estejam localizados em assets/images
, embora isso possa ser configurado de maneira diferente. Consulte a documentação de imagens do Flame para mais detalhes. Agora que os caminhos estão configurados, você precisa adicioná-los ao próprio projeto. Uma maneira de fazer isso é usar a linha de comando da seguinte maneira:
$ mkdir -p assets/images
O comando mkdir
não terá saída, mas o novo diretório vai estar visível no editor ou em um explorador de arquivos.
Expanda o arquivo kenney_physics-assets.zip
que você transferiu por download. O resultado será semelhante a este:
No diretório PNG/Backgrounds
, copie os arquivos colored_desert.png
, colored_grass.png
, colored_land.png
e colored_shroom.png
para o diretório assets/images
do projeto.
Há também folhas de sprite. Eles são uma combinação de uma imagem PNG e um arquivo XML que descreve em que parte da imagem da Folha de sprite as imagens menores podem ser encontradas. Folhas de sprite são uma técnica para reduzir o tempo de carregamento ao carregar apenas um único arquivo, em vez de dezenas, se não centenas, de arquivos de imagem individuais.
Copie spritesheet_aliens.png
, spritesheet_elements.png
e spritesheet_tiles.png
para o diretório assets/images
do projeto. Enquanto você estiver aqui, copie também os arquivos spritesheet_aliens.xml
, spritesheet_elements.xml
e spritesheet_tiles.xml
para o diretório assets
do projeto. O projeto vai ficar assim:
Pintar o plano de fundo
Agora que seu projeto tem recursos de imagem adicionados, é hora de colocá-los na tela. Uma imagem na tela. Mais informações virão nas próximas etapas.
Crie um arquivo chamado background.dart
em um novo diretório chamado lib/components
e adicione o conteúdo a seguir.
lib/components/background.dart
import 'dart:math';
import 'package:flame/components.dart';
import 'game.dart';
class Background extends SpriteComponent with HasGameReference<MyPhysicsGame> {
Background({required super.sprite})
: super(
anchor: Anchor.center,
position: Vector2(0, 0),
);
@override
void onMount() {
super.onMount();
size = Vector2.all(max(
game.camera.visibleWorldRect.width,
game.camera.visibleWorldRect.height,
));
}
}
Esse componente é um SpriteComponent
especializado. Ele é responsável por exibir uma das quatro imagens de plano de fundo do Kenney.nl. Há algumas suposições simplificadas nesse código. O primeiro é que as imagens são quadradas, como as quatro imagens de plano de fundo do Kenney. O segundo é que o tamanho do mundo visível nunca muda. Caso contrário, esse componente precisaria processar eventos de redimensionamento do jogo. A terceira suposição é que a posição (0,0) estará no centro da tela. Essas suposições exigem uma configuração específica do CameraComponent
do jogo.
Crie outro arquivo, este com o nome game.dart
, novamente no diretório lib/components
.
lib/components/game.dart
import 'dart:async';
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';
import 'background.dart';
class MyPhysicsGame extends Forge2DGame {
MyPhysicsGame()
: super(
gravity: Vector2(0, 10),
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
);
late final XmlSpriteSheet aliens;
late final XmlSpriteSheet elements;
late final XmlSpriteSheet tiles;
@override
FutureOr<void> onLoad() async {
final backgroundImage = await images.load('colored_grass.png');
final spriteSheets = await Future.wait([
XmlSpriteSheet.load(
imagePath: 'spritesheet_aliens.png',
xmlPath: 'spritesheet_aliens.xml',
),
XmlSpriteSheet.load(
imagePath: 'spritesheet_elements.png',
xmlPath: 'spritesheet_elements.xml',
),
XmlSpriteSheet.load(
imagePath: 'spritesheet_tiles.png',
xmlPath: 'spritesheet_tiles.xml',
),
]);
aliens = spriteSheets[0];
elements = spriteSheets[1];
tiles = spriteSheets[2];
await world.add(Background(sprite: Sprite(backgroundImage)));
return super.onLoad();
}
}
Muita coisa está acontecendo aqui. Vamos começar com a classe MyPhysicsGame
. Ao contrário do codelab anterior, isso estende Forge2DGame
, não FlameGame
. O próprio Forge2DGame
estende FlameGame
com alguns ajustes interessantes. O primeiro é que, por padrão, zoom
é definido como 10. Essa configuração zoom
está relacionada ao intervalo de valores úteis com que os mecanismos de simulação de física de estilo Box2D
funcionam bem. O mecanismo é escrito usando o sistema MKS, em que são consideradas unidades em metros, quilogramas e segundos. O intervalo em que não são exibidos erros matemáticos perceptíveis para objetos é de 0,1 metro a 10 segundos de metros. Inserir diretamente as dimensões em pixels sem reduzir a redução de escala faria com que o Forge2D ficasse fora do potencial de uso. O resumo útil é pensar em simular objetos no alcance de uma lata de refrigerante até um ônibus.
As suposições feitas no componente "Plano de fundo" são atendidas aqui ao corrigir a resolução de CameraComponent
para 800 por 600 pixels virtuais. Isso significa que a área de jogo terá 80 unidades de largura e 60 unidades de altura, centralizada em (0,0). Isso não afeta a resolução exibida, mas afeta o local onde colocamos objetos na cena do jogo.
Junto com o argumento do construtor camera
há outro argumento mais alinhado à física, chamado gravity
. A gravidade é definida como Vector2
, com x
de 0 e y
de 10. O 10 é uma aproximação do valor de gravidade geralmente aceito de 9,81 metros por segundo por segundo. O fato de que a gravidade está definida como 10 positiva mostra que, nesse sistema, a direção do eixo Y é para baixo. Ele é diferente do Box2D em geral, mas está de acordo com a configuração do Flame.
A seguir, temos o método onLoad
. Esse método é assíncrono, o que é apropriado, porque é responsável por carregar recursos de imagem do disco. As chamadas para images.load
retornam um Future<Image>
e, como efeito colateral, armazenam a imagem carregada no objeto Game. Esses futuros são reunidos e esperados como uma única unidade usando o método estático Futures.wait
. Em seguida, a lista de imagens retornadas corresponde a padrões nos nomes individuais.
As imagens da Folha de sprite são alimentadas em uma série de objetos XmlSpriteSheet
, que são responsáveis por recuperar os Sprites nomeados individualmente contidos na Folha de sprite. A classe XmlSpriteSheet
é definida no pacote flame_kenney_xml
.
Com tudo isso resolvido, você só precisa de algumas pequenas edições no lib/main.dart
para mostrar uma imagem na tela.
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'components/game.dart'; // Add this import
void main() {
runApp(
GameWidget.controlled(
gameFactory: MyPhysicsGame.new, // Modify this line
),
);
}
Com essa mudança simples, você pode executar o jogo novamente para conferir o plano de fundo na tela. Observe que a instância da câmera CameraComponent.withFixedResolution()
vai adicionar o efeito letterbox conforme necessário para que a proporção de 800 por 600 do jogo funcione.
4. Adicionar o solo
Algo para desenvolver
Se tivermos gravidade, precisamos de algo para capturar objetos no jogo antes que eles caiam da parte de baixo da tela. A menos que cair da tela faça parte do design do seu jogo, é claro. Crie um novo arquivo ground.dart
no diretório lib/components
e adicione o seguinte a ele:
lib/components/ground.dart
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
const groundSize = 7.0;
class Ground extends BodyComponent {
Ground(Vector2 position, Sprite sprite)
: super(
renderBody: false,
bodyDef: BodyDef()
..position = position
..type = BodyType.static,
fixtureDefs: [
FixtureDef(
PolygonShape()..setAsBoxXY(groundSize / 2, groundSize / 2),
friction: 0.3,
)
],
children: [
SpriteComponent(
anchor: Anchor.center,
sprite: sprite,
size: Vector2.all(groundSize),
position: Vector2(0, 0),
),
],
);
}
Esse componente Ground
deriva de BodyComponent
. Em Forge2D, os corpos são importantes, pois são os objetos que fazem parte da simulação física bidimensional. O BodyDef
desse componente é especificado para ter um BodyType.static
.
Em Forge2D, os corpos têm três tipos diferentes. Corpos estáticos não se mexem. Eles têm massa zero (não reagem à gravidade) e massa infinita (mas não se movem quando atingidos por outros objetos, não importa o peso). Isso torna os corpos estáticos perfeitos para uma superfície terrestre, uma vez que eles não se movem.
Os outros dois tipos de corpos são cinemáticos e dinâmicos. Corpos dinâmicos são corpos que são completamente simulados e reagem à gravidade e aos objetos em que se chocam. Você vai encontrar muitos corpos dinâmicos no restante deste codelab. Os corpos cinemáticos são uma forma intermediária entre o estático e o dinâmico. Eles se movem, mas não reagem à gravidade ou a outros objetos que os atingiram. Útil, mas além do escopo deste codelab.
O corpo em si não faz muita coisa. Um corpo precisa de formas associadas para ter substância. Nesse caso, o corpo tem uma forma associada, uma PolygonShape
definida como BoxXY
. Esse tipo de caixa é alinhado ao eixo com o mundo, ao contrário de uma PolygonShape
definida como BoxXY
, que pode ser girada em torno de um ponto de rotação. Isso é útil, mas também está fora do escopo deste codelab. A forma e o corpo são anexados a um acessório, o que é útil para adicionar elementos como friction
ao sistema.
Por padrão, um corpo renderiza as formas anexadas de maneira útil para depuração, mas não é uma boa jogabilidade. Definir o renderBody
do argumento super
como false
desativa essa renderização de depuração. Conceder a esse corpo uma renderização no jogo é responsabilidade do SpriteComponent
filho.
Para adicionar o componente Ground
ao jogo, edite o arquivo game.dart
da seguinte maneira.
lib/components/game.dart
import 'dart:async';
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';
import 'background.dart';
import 'ground.dart'; // Add this import
class MyPhysicsGame extends Forge2DGame {
MyPhysicsGame()
: super(
gravity: Vector2(0, 10),
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
);
late final XmlSpriteSheet aliens;
late final XmlSpriteSheet elements;
late final XmlSpriteSheet tiles;
@override
FutureOr<void> onLoad() async {
final backgroundImage = await images.load('colored_grass.png');
final spriteSheets = await Future.wait([
XmlSpriteSheet.load(
imagePath: 'spritesheet_aliens.png',
xmlPath: 'spritesheet_aliens.xml',
),
XmlSpriteSheet.load(
imagePath: 'spritesheet_elements.png',
xmlPath: 'spritesheet_elements.xml',
),
XmlSpriteSheet.load(
imagePath: 'spritesheet_tiles.png',
xmlPath: 'spritesheet_tiles.xml',
),
]);
aliens = spriteSheets[0];
elements = spriteSheets[1];
tiles = spriteSheets[2];
await world.add(Background(sprite: Sprite(backgroundImage)));
await addGround(); // Add this line
return super.onLoad();
}
Future<void> addGround() { // Add from here...
return world.addAll([
for (var x = camera.visibleWorldRect.left;
x < camera.visibleWorldRect.right + groundSize;
x += groundSize)
Ground(
Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
tiles.getSprite('grass.png'),
),
]);
} // To here.
}
Essa edição adiciona uma série de componentes Ground
ao mundo usando uma repetição for
dentro de um contexto List
e transmitindo a lista resultante de componentes Ground
para o método addAll
do world
.
A execução do jogo agora mostra o plano de fundo e o solo.
5. Adicionar as peças
Construir uma parede
O chão nos deu um exemplo de corpo estático. Agora é hora de seu primeiro componente dinâmico. Componentes dinâmicos no Forge2D são a base da experiência do jogador, são coisas que se movem e interagem com o mundo ao redor. Nesta etapa, você vai introduzir as peças, que serão escolhidas aleatoriamente para aparecer na tela em um grupo de peças. Você vai vê-los cair e se esbarrar uns nos outros enquanto fazem isso.
Os blocos serão feitos com a folha de sprite de elementos. Se você conferir a descrição da folha de sprite em assets/spritesheet_elements.xml
, vai ver que temos um problema interessante. Os nomes não parecem ser muito úteis. Seria útil selecionar um tijolo por tipo de material, tamanho e quantidade de danos. Felizmente, uma elfa útil passou algum tempo descobrindo o padrão dos nomes dos arquivos e criou uma ferramenta para facilitar o trabalho de todos. Crie um novo arquivo generate_brick_file_names.dart
no diretório bin
e adicione o seguinte conteúdo:
bin/generate_brick_file_names.dart
import 'dart:io';
import 'package:equatable/equatable.dart';
import 'package:xml/xml.dart';
import 'package:xml/xpath.dart';
void main() {
final file = File('assets/spritesheet_elements.xml');
final rects = <String, Rect>{};
final document = XmlDocument.parse(file.readAsStringSync());
for (final node in document.xpath('//TextureAtlas/SubTexture')) {
final name = node.getAttribute('name')!;
rects[name] = Rect(
x: int.parse(node.getAttribute('x')!),
y: int.parse(node.getAttribute('y')!),
width: int.parse(node.getAttribute('width')!),
height: int.parse(node.getAttribute('height')!),
);
}
print(generateBrickFileNames(rects));
}
class Rect extends Equatable {
final int x;
final int y;
final int width;
final int height;
const Rect(
{required this.x,
required this.y,
required this.width,
required this.height});
Size get size => Size(width, height);
@override
List<Object?> get props => [x, y, width, height];
@override
bool get stringify => true;
}
class Size extends Equatable {
final int width;
final int height;
const Size(this.width, this.height);
@override
List<Object?> get props => [width, height];
@override
bool get stringify => true;
}
String generateBrickFileNames(Map<String, Rect> rects) {
final groups = <Size, List<String>>{};
for (final entry in rects.entries) {
groups.putIfAbsent(entry.value.size, () => []).add(entry.key);
}
final buff = StringBuffer();
buff.writeln('''
Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
return switch ((type, size)) {''');
for (final entry in groups.entries) {
final size = entry.key;
final entries = entry.value;
entries.sort();
for (final type in ['Explosive', 'Glass', 'Metal', 'Stone', 'Wood']) {
var filtered = entries.where((element) => element.contains(type));
if (filtered.length == 5) {
buff.writeln('''
(BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
BrickDamage.none: '${filtered.elementAt(0)}',
BrickDamage.some: '${filtered.elementAt(1)}',
BrickDamage.lots: '${filtered.elementAt(4)}',
},''');
} else if (filtered.length == 10) {
buff.writeln('''
(BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
BrickDamage.none: '${filtered.elementAt(3)}',
BrickDamage.some: '${filtered.elementAt(4)}',
BrickDamage.lots: '${filtered.elementAt(9)}',
},''');
} else if (filtered.length == 15) {
buff.writeln('''
(BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
BrickDamage.none: '${filtered.elementAt(7)}',
BrickDamage.some: '${filtered.elementAt(8)}',
BrickDamage.lots: '${filtered.elementAt(13)}',
},''');
}
}
}
buff.writeln('''
};
}''');
return buff.toString();
}
Seu editor deve fornecer um aviso ou um erro sobre uma dependência ausente. Adicione-o da seguinte maneira:
$ flutter pub add equatable
Você já pode executar este programa da seguinte maneira:
$ dart run bin/generate_brick_file_names.dart Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) { return switch ((type, size)) { (BrickType.explosive, BrickSize.size140x70) => { BrickDamage.none: 'elementExplosive009.png', BrickDamage.some: 'elementExplosive012.png', BrickDamage.lots: 'elementExplosive050.png', }, (BrickType.glass, BrickSize.size140x70) => { BrickDamage.none: 'elementGlass010.png', BrickDamage.some: 'elementGlass013.png', BrickDamage.lots: 'elementGlass048.png', }, [Content elided...] (BrickType.wood, BrickSize.size140x220) => { BrickDamage.none: 'elementWood020.png', BrickDamage.some: 'elementWood025.png', BrickDamage.lots: 'elementWood052.png', }, }; }
Essa ferramenta analisou o arquivo de descrição da folha de sprite e o converteu em código Dart que podemos usar para selecionar o arquivo de imagem correto para cada peça que você quer colocar na tela. Útil!
Crie o arquivo brick.dart
com o seguinte conteúdo:
lib/components/brick.dart
import 'dart:math';
import 'dart:ui' as ui;
import 'package:flame/components.dart';
import 'package:flame/extensions.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
const brickScale = 0.5;
enum BrickType {
explosive(density: 1, friction: 0.5),
glass(density: 0.5, friction: 0.2),
metal(density: 1, friction: 0.4),
stone(density: 2, friction: 1),
wood(density: 0.25, friction: 0.6);
final double density;
final double friction;
const BrickType({required this.density, required this.friction});
static BrickType get randomType => values[Random().nextInt(values.length)];
}
enum BrickSize {
size70x70(ui.Size(70, 70)),
size140x70(ui.Size(140, 70)),
size220x70(ui.Size(220, 70)),
size70x140(ui.Size(70, 140)),
size140x140(ui.Size(140, 140)),
size220x140(ui.Size(220, 140)),
size140x220(ui.Size(140, 220)),
size70x220(ui.Size(70, 220));
final ui.Size size;
const BrickSize(this.size);
static BrickSize get randomSize => values[Random().nextInt(values.length)];
}
enum BrickDamage { none, some, lots }
Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
return switch ((type, size)) {
(BrickType.explosive, BrickSize.size140x70) => {
BrickDamage.none: 'elementExplosive009.png',
BrickDamage.some: 'elementExplosive012.png',
BrickDamage.lots: 'elementExplosive050.png',
},
(BrickType.glass, BrickSize.size140x70) => {
BrickDamage.none: 'elementGlass010.png',
BrickDamage.some: 'elementGlass013.png',
BrickDamage.lots: 'elementGlass048.png',
},
(BrickType.metal, BrickSize.size140x70) => {
BrickDamage.none: 'elementMetal009.png',
BrickDamage.some: 'elementMetal012.png',
BrickDamage.lots: 'elementMetal050.png',
},
(BrickType.stone, BrickSize.size140x70) => {
BrickDamage.none: 'elementStone009.png',
BrickDamage.some: 'elementStone012.png',
BrickDamage.lots: 'elementStone047.png',
},
(BrickType.wood, BrickSize.size140x70) => {
BrickDamage.none: 'elementWood011.png',
BrickDamage.some: 'elementWood014.png',
BrickDamage.lots: 'elementWood054.png',
},
(BrickType.explosive, BrickSize.size70x70) => {
BrickDamage.none: 'elementExplosive011.png',
BrickDamage.some: 'elementExplosive014.png',
BrickDamage.lots: 'elementExplosive049.png',
},
(BrickType.glass, BrickSize.size70x70) => {
BrickDamage.none: 'elementGlass011.png',
BrickDamage.some: 'elementGlass012.png',
BrickDamage.lots: 'elementGlass046.png',
},
(BrickType.metal, BrickSize.size70x70) => {
BrickDamage.none: 'elementMetal011.png',
BrickDamage.some: 'elementMetal014.png',
BrickDamage.lots: 'elementMetal049.png',
},
(BrickType.stone, BrickSize.size70x70) => {
BrickDamage.none: 'elementStone011.png',
BrickDamage.some: 'elementStone014.png',
BrickDamage.lots: 'elementStone046.png',
},
(BrickType.wood, BrickSize.size70x70) => {
BrickDamage.none: 'elementWood010.png',
BrickDamage.some: 'elementWood013.png',
BrickDamage.lots: 'elementWood045.png',
},
(BrickType.explosive, BrickSize.size220x70) => {
BrickDamage.none: 'elementExplosive013.png',
BrickDamage.some: 'elementExplosive016.png',
BrickDamage.lots: 'elementExplosive051.png',
},
(BrickType.glass, BrickSize.size220x70) => {
BrickDamage.none: 'elementGlass014.png',
BrickDamage.some: 'elementGlass017.png',
BrickDamage.lots: 'elementGlass049.png',
},
(BrickType.metal, BrickSize.size220x70) => {
BrickDamage.none: 'elementMetal013.png',
BrickDamage.some: 'elementMetal016.png',
BrickDamage.lots: 'elementMetal051.png',
},
(BrickType.stone, BrickSize.size220x70) => {
BrickDamage.none: 'elementStone013.png',
BrickDamage.some: 'elementStone016.png',
BrickDamage.lots: 'elementStone048.png',
},
(BrickType.wood, BrickSize.size220x70) => {
BrickDamage.none: 'elementWood012.png',
BrickDamage.some: 'elementWood015.png',
BrickDamage.lots: 'elementWood047.png',
},
(BrickType.explosive, BrickSize.size70x140) => {
BrickDamage.none: 'elementExplosive017.png',
BrickDamage.some: 'elementExplosive022.png',
BrickDamage.lots: 'elementExplosive052.png',
},
(BrickType.glass, BrickSize.size70x140) => {
BrickDamage.none: 'elementGlass018.png',
BrickDamage.some: 'elementGlass023.png',
BrickDamage.lots: 'elementGlass050.png',
},
(BrickType.metal, BrickSize.size70x140) => {
BrickDamage.none: 'elementMetal017.png',
BrickDamage.some: 'elementMetal022.png',
BrickDamage.lots: 'elementMetal052.png',
},
(BrickType.stone, BrickSize.size70x140) => {
BrickDamage.none: 'elementStone017.png',
BrickDamage.some: 'elementStone022.png',
BrickDamage.lots: 'elementStone049.png',
},
(BrickType.wood, BrickSize.size70x140) => {
BrickDamage.none: 'elementWood016.png',
BrickDamage.some: 'elementWood021.png',
BrickDamage.lots: 'elementWood048.png',
},
(BrickType.explosive, BrickSize.size140x140) => {
BrickDamage.none: 'elementExplosive018.png',
BrickDamage.some: 'elementExplosive023.png',
BrickDamage.lots: 'elementExplosive053.png',
},
(BrickType.glass, BrickSize.size140x140) => {
BrickDamage.none: 'elementGlass019.png',
BrickDamage.some: 'elementGlass024.png',
BrickDamage.lots: 'elementGlass051.png',
},
(BrickType.metal, BrickSize.size140x140) => {
BrickDamage.none: 'elementMetal018.png',
BrickDamage.some: 'elementMetal023.png',
BrickDamage.lots: 'elementMetal053.png',
},
(BrickType.stone, BrickSize.size140x140) => {
BrickDamage.none: 'elementStone018.png',
BrickDamage.some: 'elementStone023.png',
BrickDamage.lots: 'elementStone050.png',
},
(BrickType.wood, BrickSize.size140x140) => {
BrickDamage.none: 'elementWood017.png',
BrickDamage.some: 'elementWood022.png',
BrickDamage.lots: 'elementWood049.png',
},
(BrickType.explosive, BrickSize.size220x140) => {
BrickDamage.none: 'elementExplosive019.png',
BrickDamage.some: 'elementExplosive024.png',
BrickDamage.lots: 'elementExplosive054.png',
},
(BrickType.glass, BrickSize.size220x140) => {
BrickDamage.none: 'elementGlass020.png',
BrickDamage.some: 'elementGlass025.png',
BrickDamage.lots: 'elementGlass052.png',
},
(BrickType.metal, BrickSize.size220x140) => {
BrickDamage.none: 'elementMetal019.png',
BrickDamage.some: 'elementMetal024.png',
BrickDamage.lots: 'elementMetal054.png',
},
(BrickType.stone, BrickSize.size220x140) => {
BrickDamage.none: 'elementStone019.png',
BrickDamage.some: 'elementStone024.png',
BrickDamage.lots: 'elementStone051.png',
},
(BrickType.wood, BrickSize.size220x140) => {
BrickDamage.none: 'elementWood018.png',
BrickDamage.some: 'elementWood023.png',
BrickDamage.lots: 'elementWood050.png',
},
(BrickType.explosive, BrickSize.size70x220) => {
BrickDamage.none: 'elementExplosive020.png',
BrickDamage.some: 'elementExplosive025.png',
BrickDamage.lots: 'elementExplosive055.png',
},
(BrickType.glass, BrickSize.size70x220) => {
BrickDamage.none: 'elementGlass021.png',
BrickDamage.some: 'elementGlass026.png',
BrickDamage.lots: 'elementGlass053.png',
},
(BrickType.metal, BrickSize.size70x220) => {
BrickDamage.none: 'elementMetal020.png',
BrickDamage.some: 'elementMetal025.png',
BrickDamage.lots: 'elementMetal055.png',
},
(BrickType.stone, BrickSize.size70x220) => {
BrickDamage.none: 'elementStone020.png',
BrickDamage.some: 'elementStone025.png',
BrickDamage.lots: 'elementStone052.png',
},
(BrickType.wood, BrickSize.size70x220) => {
BrickDamage.none: 'elementWood019.png',
BrickDamage.some: 'elementWood024.png',
BrickDamage.lots: 'elementWood051.png',
},
(BrickType.explosive, BrickSize.size140x220) => {
BrickDamage.none: 'elementExplosive021.png',
BrickDamage.some: 'elementExplosive026.png',
BrickDamage.lots: 'elementExplosive056.png',
},
(BrickType.glass, BrickSize.size140x220) => {
BrickDamage.none: 'elementGlass022.png',
BrickDamage.some: 'elementGlass027.png',
BrickDamage.lots: 'elementGlass054.png',
},
(BrickType.metal, BrickSize.size140x220) => {
BrickDamage.none: 'elementMetal021.png',
BrickDamage.some: 'elementMetal026.png',
BrickDamage.lots: 'elementMetal056.png',
},
(BrickType.stone, BrickSize.size140x220) => {
BrickDamage.none: 'elementStone021.png',
BrickDamage.some: 'elementStone026.png',
BrickDamage.lots: 'elementStone053.png',
},
(BrickType.wood, BrickSize.size140x220) => {
BrickDamage.none: 'elementWood020.png',
BrickDamage.some: 'elementWood025.png',
BrickDamage.lots: 'elementWood052.png',
},
};
}
class Brick extends BodyComponent {
Brick({
required this.type,
required this.size,
required BrickDamage damage,
required Vector2 position,
required Map<BrickDamage, Sprite> sprites,
}) : _damage = damage,
_sprites = sprites,
super(
renderBody: false,
bodyDef: BodyDef()
..position = position
..type = BodyType.dynamic,
fixtureDefs: [
FixtureDef(
PolygonShape()
..setAsBoxXY(
size.size.width / 20 * brickScale,
size.size.height / 20 * brickScale,
),
)
..restitution = 0.4
..density = type.density
..friction = type.friction
]);
late final SpriteComponent _spriteComponent;
final BrickType type;
final BrickSize size;
final Map<BrickDamage, Sprite> _sprites;
BrickDamage _damage;
BrickDamage get damage => _damage;
set damage(BrickDamage value) {
_damage = value;
_spriteComponent.sprite = _sprites[value];
}
@override
Future<void> onLoad() {
_spriteComponent = SpriteComponent(
anchor: Anchor.center,
scale: Vector2.all(1),
sprite: _sprites[_damage],
size: size.size.toVector2() / 10 * brickScale,
position: Vector2(0, 0),
);
add(_spriteComponent);
return super.onLoad();
}
}
Agora você pode ver como o código Dart gerado acima é integrado a essa base de código para facilitar e agilizar a seleção de imagens de tijolos com base no material, tamanho e condição. Analisando as enum
s e o próprio componente Brick
, você vai perceber que a maior parte desse código parece bastante familiar para o componente Ground
na etapa anterior. Aqui há um estado mutável para permitir que o tijolo seja danificado, embora o uso dele seja deixado como um exercício para o leitor.
É hora de colocar as peças na tela. Edite o arquivo game.dart
da seguinte forma:
lib/components/game.dart
import 'dart:async';
import 'dart:math'; // Add this import
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';
import 'background.dart';
import 'brick.dart'; // Add this import
import 'ground.dart';
class MyPhysicsGame extends Forge2DGame {
MyPhysicsGame()
: super(
gravity: Vector2(0, 10),
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
);
late final XmlSpriteSheet aliens;
late final XmlSpriteSheet elements;
late final XmlSpriteSheet tiles;
@override
FutureOr<void> onLoad() async {
final backgroundImage = await images.load('colored_grass.png');
final spriteSheets = await Future.wait([
XmlSpriteSheet.load(
imagePath: 'spritesheet_aliens.png',
xmlPath: 'spritesheet_aliens.xml',
),
XmlSpriteSheet.load(
imagePath: 'spritesheet_elements.png',
xmlPath: 'spritesheet_elements.xml',
),
XmlSpriteSheet.load(
imagePath: 'spritesheet_tiles.png',
xmlPath: 'spritesheet_tiles.xml',
),
]);
aliens = spriteSheets[0];
elements = spriteSheets[1];
tiles = spriteSheets[2];
await world.add(Background(sprite: Sprite(backgroundImage)));
await addGround();
unawaited(addBricks()); // Add this line
return super.onLoad();
}
Future<void> addGround() {
return world.addAll([
for (var x = camera.visibleWorldRect.left;
x < camera.visibleWorldRect.right + groundSize;
x += groundSize)
Ground(
Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
tiles.getSprite('grass.png'),
),
]);
}
final _random = Random(); // Add from here...
Future<void> addBricks() async {
for (var i = 0; i < 5; i++) {
final type = BrickType.randomType;
final size = BrickSize.randomSize;
await world.add(
Brick(
type: type,
size: size,
damage: BrickDamage.some,
position: Vector2(
camera.visibleWorldRect.right / 3 +
(_random.nextDouble() * 5 - 2.5),
0),
sprites: brickFileNames(type, size).map(
(key, filename) => MapEntry(
key,
elements.getSprite(filename),
),
),
),
);
await Future<void>.delayed(const Duration(milliseconds: 500));
}
} // To here.
}
Essa adição de código é um pouco diferente do código usado para adicionar os componentes Ground
. Desta vez, as Brick
s estão sendo adicionadas em um cluster aleatório ao longo do tempo. Há duas partes nesse processo. A primeira é que o método que adiciona as await
s das Brick
s a um Future.delayed
, que é o equivalente assíncrono a uma chamada de sleep()
. No entanto, há uma segunda parte para fazer isso funcionar: a chamada para addBricks
no método onLoad
não usa await
. Se fosse, o método onLoad
não seria concluído até que todas as peças estivessem na tela. Colocar a chamada para addBricks
em uma chamada unawaited
deixa os linters felizes e deixa nossa intenção óbvia para futuros programadores. Não esperar o retorno desse método é intencional.
Execute o jogo e você verá as peças aparecerem, chocando umas nas outras e derramando no chão.
6. Adicionar o jogador
Arremessando alienígenas em tijolos
Assistir tijolos caírem é divertido nas primeiras vezes, mas acho que o jogo vai ficar mais divertido se dermos ao jogador um avatar que ele possa usar para interagir com o mundo. Que tal um alienígena que eles possam jogar nos tijolos?
Crie um novo arquivo player.dart
no diretório lib/components
e adicione o seguinte a ele:
lib/components/player.dart
import 'dart:math';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';
const playerSize = 5.0;
enum PlayerColor {
pink,
blue,
green,
yellow;
static PlayerColor get randomColor =>
PlayerColor.values[Random().nextInt(PlayerColor.values.length)];
String get fileName =>
'alien${toString().split('.').last.capitalize}_round.png';
}
class Player extends BodyComponent with DragCallbacks {
Player(Vector2 position, Sprite sprite)
: _sprite = sprite,
super(
renderBody: false,
bodyDef: BodyDef()
..position = position
..type = BodyType.static
..angularDamping = 0.1
..linearDamping = 0.1,
fixtureDefs: [
FixtureDef(CircleShape()..radius = playerSize / 2)
..restitution = 0.4
..density = 0.75
..friction = 0.5
],
);
final Sprite _sprite;
@override
Future<void> onLoad() {
addAll([
CustomPainterComponent(
painter: _DragPainter(this),
anchor: Anchor.center,
size: Vector2(playerSize, playerSize),
position: Vector2(0, 0),
),
SpriteComponent(
anchor: Anchor.center,
sprite: _sprite,
size: Vector2(playerSize, playerSize),
position: Vector2(0, 0),
)
]);
return super.onLoad();
}
@override
void update(double dt) {
super.update(dt);
if (!body.isAwake) {
removeFromParent();
}
if (position.x > camera.visibleWorldRect.right + 10 ||
position.x < camera.visibleWorldRect.left - 10) {
removeFromParent();
}
}
Vector2 _dragStart = Vector2.zero();
Vector2 _dragDelta = Vector2.zero();
Vector2 get dragDelta => _dragDelta;
@override
void onDragStart(DragStartEvent event) {
super.onDragStart(event);
if (body.bodyType == BodyType.static) {
_dragStart = event.localPosition;
}
}
@override
void onDragUpdate(DragUpdateEvent event) {
if (body.bodyType == BodyType.static) {
_dragDelta = event.localEndPosition - _dragStart;
}
}
@override
void onDragEnd(DragEndEvent event) {
super.onDragEnd(event);
if (body.bodyType == BodyType.static) {
children
.whereType<CustomPainterComponent>()
.firstOrNull
?.removeFromParent();
body.setType(BodyType.dynamic);
body.applyLinearImpulse(_dragDelta * -50);
add(RemoveEffect(
delay: 5.0,
));
}
}
}
extension on String {
String get capitalize =>
characters.first.toUpperCase() + characters.skip(1).toLowerCase().join();
}
class _DragPainter extends CustomPainter {
_DragPainter(this.player);
final Player player;
@override
void paint(Canvas canvas, Size size) {
if (player.dragDelta != Vector2.zero()) {
var center = size.center(Offset.zero);
canvas.drawLine(
center,
center + (player.dragDelta * -1).toOffset(),
Paint()
..color = Colors.orange.withOpacity(0.7)
..strokeWidth = 0.4
..strokeCap = StrokeCap.round);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
Este é um passo dos componentes Brick
na etapa anterior. Esse componente Player
tem dois componentes filhos, um SpriteComponent
que você precisa reconhecer e um CustomPainterComponent
que é novo. O conceito CustomPainter
é do Flutter e permite pintar em uma tela. Ela é usada aqui para dar feedback ao jogador sobre para onde o alienígena circular vai voar quando for lançado.
Como o jogador inicia o lançamento do alienígena? Usando um gesto de arrastar, que o componente Player detecta com os callbacks DragCallbacks
. A águia que está olhando entre vocês notaram outra coisa aqui.
Enquanto os componentes Ground
eram corpos estáticos, os componentes de tijolos eram corpos dinâmicos. O player aqui é uma combinação de ambos. O jogador começa como estático, esperando que o jogador arraste e, ao soltar, ele se converte de estático para dinâmico, adiciona impulso linear em proporção à ação de arrastar e deixa o avatar alienígena voar!
Há também um código no componente Player
para removê-lo da tela se ele sair dos limites, adormecer ou expirar. A intenção é permitir que o jogador fuja do alien, veja o que acontece e tente novamente.
Integre o componente Player
ao jogo editando game.dart
da seguinte maneira:
lib/components/game.dart
import 'dart:async';
import 'dart:math';
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';
import 'background.dart';
import 'brick.dart';
import 'ground.dart';
import 'player.dart'; // Add this import
class MyPhysicsGame extends Forge2DGame {
MyPhysicsGame()
: super(
gravity: Vector2(0, 10),
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
);
late final XmlSpriteSheet aliens;
late final XmlSpriteSheet elements;
late final XmlSpriteSheet tiles;
@override
FutureOr<void> onLoad() async {
final backgroundImage = await images.load('colored_grass.png');
final spriteSheets = await Future.wait([
XmlSpriteSheet.load(
imagePath: 'spritesheet_aliens.png',
xmlPath: 'spritesheet_aliens.xml',
),
XmlSpriteSheet.load(
imagePath: 'spritesheet_elements.png',
xmlPath: 'spritesheet_elements.xml',
),
XmlSpriteSheet.load(
imagePath: 'spritesheet_tiles.png',
xmlPath: 'spritesheet_tiles.xml',
),
]);
aliens = spriteSheets[0];
elements = spriteSheets[1];
tiles = spriteSheets[2];
await world.add(Background(sprite: Sprite(backgroundImage)));
await addGround();
unawaited(addBricks());
await addPlayer(); // Add this line
return super.onLoad();
}
Future<void> addGround() {
return world.addAll([
for (var x = camera.visibleWorldRect.left;
x < camera.visibleWorldRect.right + groundSize;
x += groundSize)
Ground(
Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
tiles.getSprite('grass.png'),
),
]);
}
final _random = Random();
Future<void> addBricks() async {
for (var i = 0; i < 5; i++) {
final type = BrickType.randomType;
final size = BrickSize.randomSize;
await world.add(
Brick(
type: type,
size: size,
damage: BrickDamage.some,
position: Vector2(
camera.visibleWorldRect.right / 3 +
(_random.nextDouble() * 5 - 2.5),
0),
sprites: brickFileNames(type, size).map(
(key, filename) => MapEntry(
key,
elements.getSprite(filename),
),
),
),
);
await Future<void>.delayed(const Duration(milliseconds: 500));
}
}
Future<void> addPlayer() async => world.add( // Add from here...
Player(
Vector2(camera.visibleWorldRect.left * 2 / 3, 0),
aliens.getSprite(PlayerColor.randomColor.fileName),
),
);
@override
void update(double dt) {
super.update(dt);
if (isMounted && world.children.whereType<Player>().isEmpty) {
addPlayer();
}
} // To here.
}
A adição do jogador ao jogo é semelhante aos componentes anteriores, mas com uma diferença adicional. O alienígena do jogador é projetado para se remover do jogo em determinadas condições. Portanto, há um gerenciador de atualizações aqui que verifica se não há nenhum componente Player
no jogo e, em caso afirmativo, adiciona um novamente. A execução do jogo é assim.
7. Reaja ao impacto
Adicionando os inimigos
Você já viu objetos estáticos e dinâmicos interagindo entre si. No entanto, para realmente chegar a algum lugar, você precisa receber callbacks no código quando houver problemas. Vamos ver como isso é feito. Você vai apresentar alguns inimigos para o jogador enfrentar. Assim, você terá uma condição vencedora: remova todos os inimigos do jogo.
Crie um arquivo enemy.dart
no diretório lib/components
e adicione o seguinte:
lib/components/enemy.dart
import 'dart:math';
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';
import 'body_component_with_user_data.dart';
const enemySize = 5.0;
enum EnemyColor {
pink(color: 'pink', boss: false),
blue(color: 'blue', boss: false),
green(color: 'green', boss: false),
yellow(color: 'yellow', boss: false),
pinkBoss(color: 'pink', boss: true),
blueBoss(color: 'blue', boss: true),
greenBoss(color: 'green', boss: true),
yellowBoss(color: 'yellow', boss: true);
final bool boss;
final String color;
const EnemyColor({required this.color, required this.boss});
static EnemyColor get randomColor =>
EnemyColor.values[Random().nextInt(EnemyColor.values.length)];
String get fileName =>
'alien${color.capitalize}_${boss ? 'suit' : 'square'}.png';
}
class Enemy extends BodyComponentWithUserData with ContactCallbacks {
Enemy(Vector2 position, Sprite sprite)
: super(
renderBody: false,
bodyDef: BodyDef()
..position = position
..type = BodyType.dynamic,
fixtureDefs: [
FixtureDef(
PolygonShape()..setAsBoxXY(enemySize / 2, enemySize / 2),
friction: 0.3,
)
],
children: [
SpriteComponent(
anchor: Anchor.center,
sprite: sprite,
size: Vector2.all(enemySize),
position: Vector2(0, 0),
),
],
);
@override
void beginContact(Object other, Contact contact) {
var interceptVelocity =
(contact.bodyA.linearVelocity - contact.bodyB.linearVelocity)
.length
.abs();
if (interceptVelocity > 35) {
removeFromParent();
}
super.beginContact(other, contact);
}
@override
void update(double dt) {
super.update(dt);
if (position.x > camera.visibleWorldRect.right + 10 ||
position.x < camera.visibleWorldRect.left - 10) {
removeFromParent();
}
}
}
extension on String {
String get capitalize =>
characters.first.toUpperCase() + characters.skip(1).toLowerCase().join();
}
A partir de suas interações anteriores com os componentes de player e peça, a maior parte desse arquivo deve ser familiar. No entanto, haverá alguns sublinhados vermelhos no seu editor devido a uma nova classe de base desconhecida. Adicione essa classe agora adicionando um arquivo chamado body_component_with_user_data.dart
a lib/components
com o seguinte conteúdo:
lib/components/body_component_with_user_data.dart
import 'package:flame_forge2d/flame_forge2d.dart';
class BodyComponentWithUserData extends BodyComponent {
BodyComponentWithUserData({
super.key,
super.bodyDef,
super.children,
super.fixtureDefs,
super.paint,
super.priority,
super.renderBody,
});
@override
Body createBody() {
final body = world.createBody(super.bodyDef!)..userData = this;
fixtureDefs?.forEach(body.createFixture);
return body;
}
}
Essa classe de base, combinada com o novo callback beginContact
no componente Enemy
, forma a base para receber notificações de forma programática sobre os impactos entre os corpos. Na verdade, você precisará editar todos os componentes entre os quais deseja receber notificações de impacto. Edite os componentes Brick
, Ground
e Player
para usar esse BodyComponentWithUserData
no lugar da classe base BodyComponent
que esses componentes usam no momento. Por exemplo, veja como editar o componente Ground
:
lib/components/ground.dart
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'body_component_with_user_data.dart'; // Add this import
const groundSize = 7.0;
class Ground extends BodyComponentWithUserData { // Edit this line
Ground(Vector2 position, Sprite sprite)
: super(
renderBody: false,
bodyDef: BodyDef()
..position = position
..type = BodyType.static,
fixtureDefs: [
FixtureDef(
PolygonShape()..setAsBoxXY(groundSize / 2, groundSize / 2),
friction: 0.3,
)
],
children: [
SpriteComponent(
anchor: Anchor.center,
sprite: sprite,
size: Vector2.all(groundSize),
position: Vector2(0, 0),
),
],
);
}
Para mais informações sobre como o Forge2d processa os contatos, consulte a documentação do Forge2D sobre callbacks de contatos.
Como vencer o jogo
Agora que você tem inimigos e uma forma de removê-los do mundo, é bem simples transformar essa simulação em um jogo. Faça o objetivo de remover todos os inimigos! É hora de editar o arquivo game.dart
da seguinte forma:
lib/components/game.dart
import 'dart:async';
import 'dart:math';
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';
import 'package:flutter/material.dart'; // Add this import
import 'background.dart';
import 'brick.dart';
import 'enemy.dart'; // Add this import
import 'ground.dart';
import 'player.dart';
class MyPhysicsGame extends Forge2DGame {
MyPhysicsGame()
: super(
gravity: Vector2(0, 10),
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
);
late final XmlSpriteSheet aliens;
late final XmlSpriteSheet elements;
late final XmlSpriteSheet tiles;
@override
FutureOr<void> onLoad() async {
final backgroundImage = await images.load('colored_grass.png');
final spriteSheets = await Future.wait([
XmlSpriteSheet.load(
imagePath: 'spritesheet_aliens.png',
xmlPath: 'spritesheet_aliens.xml',
),
XmlSpriteSheet.load(
imagePath: 'spritesheet_elements.png',
xmlPath: 'spritesheet_elements.xml',
),
XmlSpriteSheet.load(
imagePath: 'spritesheet_tiles.png',
xmlPath: 'spritesheet_tiles.xml',
),
]);
aliens = spriteSheets[0];
elements = spriteSheets[1];
tiles = spriteSheets[2];
await world.add(Background(sprite: Sprite(backgroundImage)));
await addGround();
unawaited(addBricks().then((_) => addEnemies())); // Modify this line
await addPlayer();
return super.onLoad();
}
Future<void> addGround() {
return world.addAll([
for (var x = camera.visibleWorldRect.left;
x < camera.visibleWorldRect.right + groundSize;
x += groundSize)
Ground(
Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
tiles.getSprite('grass.png'),
),
]);
}
final _random = Random();
Future<void> addBricks() async {
for (var i = 0; i < 5; i++) {
final type = BrickType.randomType;
final size = BrickSize.randomSize;
await world.add(
Brick(
type: type,
size: size,
damage: BrickDamage.some,
position: Vector2(
camera.visibleWorldRect.right / 3 +
(_random.nextDouble() * 5 - 2.5),
0),
sprites: brickFileNames(type, size).map(
(key, filename) => MapEntry(
key,
elements.getSprite(filename),
),
),
),
);
await Future<void>.delayed(const Duration(milliseconds: 500));
}
}
Future<void> addPlayer() async => world.add(
Player(
Vector2(camera.visibleWorldRect.left * 2 / 3, 0),
aliens.getSprite(PlayerColor.randomColor.fileName),
),
);
@override
void update(double dt) {
super.update(dt);
if (isMounted && // Modify from here...
world.children.whereType<Player>().isEmpty &&
world.children.whereType<Enemy>().isNotEmpty) {
addPlayer();
}
if (isMounted &&
enemiesFullyAdded &&
world.children.whereType<Enemy>().isEmpty &&
world.children.whereType<TextComponent>().isEmpty) {
world.addAll(
[
(position: Vector2(0.5, 0.5), color: Colors.white),
(position: Vector2.zero(), color: Colors.orangeAccent),
].map(
(e) => TextComponent(
text: 'You win!',
anchor: Anchor.center,
position: e.position,
textRenderer: TextPaint(
style: TextStyle(color: e.color, fontSize: 16),
),
),
),
);
}
}
var enemiesFullyAdded = false;
Future<void> addEnemies() async {
await Future<void>.delayed(const Duration(seconds: 2));
for (var i = 0; i < 3; i++) {
await world.add(
Enemy(
Vector2(
camera.visibleWorldRect.right / 3 +
(_random.nextDouble() * 7 - 3.5),
(_random.nextDouble() * 3)),
aliens.getSprite(EnemyColor.randomColor.fileName),
),
);
await Future<void>.delayed(const Duration(seconds: 1));
}
enemiesFullyAdded = true; // To here.
}
}
Se você aceitar, seu desafio é executar o jogo e chegar até esta tela.
8. Parabéns
Parabéns, você conseguiu criar um jogo com o Flutter e o Flame.
Você criou um jogo usando o mecanismo 2D do Flame e o incorporou a um wrapper do Flutter. Você usou os efeitos do Flame para animar e remover componentes. Você usou o Google Fonts e os pacotes do Flutter Animate para fazer o jogo ficar com um visual incrível.
A seguir
Confira alguns destes codelabs:
- Como criar interfaces de última geração no Flutter
- Deixe seu app do Flutter lindo, não chato
- Como adicionar compras ao seu app do Flutter