Sobre este codelab
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 semelhante à Box2D chamada Forge2D. Você usa os componentes do Flame para pintar a realidade física simulada na tela para que os usuários possam jogar. Quando terminar, o jogo vai ficar parecido com este GIF animado:
Pré-requisitos
- Já ter concluído o codelab Introdução ao Flame com o Flutter.
Conteúdo do laboratório
- Como o Forge2D funciona, começando pelos 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 Flutter e Dart
Software de compilador para a plataforma de desenvolvimento escolhida. Este codelab funciona para as seis plataformas com suporte do Flutter. Você precisa do Visual Studio para Windows, do Xcode para macOS ou iOS e do Android Studio para 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 encurtar.
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 o Flame e o Forge2D:
$ cd forge2d_game $ flutter pub add characters flame flame_forge2d flame_kenney_xml xml Resolving dependencies... Downloading packages... characters 1.4.0 (from transitive dependency to direct dependency) + flame 1.29.0 + flame_forge2d 0.19.0+2 + flame_kenney_xml 0.1.1+12 flutter_lints 5.0.0 (6.0.0 available) + forge2d 0.14.0 leak_tracker 10.0.9 (11.0.1 available) leak_tracker_flutter_testing 3.0.9 (3.0.10 available) leak_tracker_testing 3.0.1 (3.0.2 available) lints 5.1.1 (6.0.0 available) material_color_utilities 0.11.1 (0.13.0 available) meta 1.16.0 (1.17.0 available) + ordered_set 8.0.0 + petitparser 6.1.0 (7.0.0 available) test_api 0.7.4 (0.7.6 available) vector_math 2.1.4 (2.2.0 available) vm_service 15.0.0 (15.0.2 available) + xml 6.5.0 (6.6.0 available) Changed 8 dependencies! 12 packages have newer versions incompatible with dependency constraints. Try `flutter pub outdated` for more information.
O pacote flame
é familiar para você, mas os outros três podem precisar de alguma explicação. O pacote characters
é usado para manipular caminhos de acordo com o UTF8. O pacote flame_forge2d
expõe a funcionalidade do Forge2D de uma maneira 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 um GameWidget
que instancia a instância 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. Portanto, esse bootstrap simplificado funciona bem.
Opcional: fazer uma missão secundária exclusiva 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 atrapalhe a experiência geral, modifique a configuração do projeto do executável do macOS para eliminar 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
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 ocorrer 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 Flame ocupando toda a janela.
Execute o jogo para verificar se tudo está funcionando. Uma nova janela com um plano de fundo preto em branco vai aparecer.
3. Adicionar recursos de imagem
Adicionar as imagens
Qualquer jogo precisa de recursos de arte para pintar uma tela de uma maneira que seja divertida. Este codelab vai usar o pacote Physics Assets do Kenney.nl. Esses recursos têm licença Creative Commons CC0, mas ainda recomendo que você faça uma doação para a equipe da Kenney continuar o ótimo trabalho que está fazendo. Eu fiz.
Você vai precisar modificar o arquivo de configuração pubspec.yaml
para permitir o uso dos recursos de Kenney. Modifique da seguinte maneira:
pubspec.yaml
name: forge2d_game
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0
environment:
sdk: ^3.8.1
dependencies:
flutter:
sdk: flutter
characters: ^1.4.0
flame: ^1.29.0
flame_forge2d: ^0.19.0+2
flame_kenney_xml: ^0.1.1+12
xml: ^6.5.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
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 você configurou os caminhos, é necessário adicioná-los ao projeto. Uma maneira de fazer isso é usar a linha de comando da seguinte maneira:
mkdir -p assets/images
Não deve haver saída do comando mkdir
, mas o novo diretório precisa estar visível no editor ou em um explorador de arquivos.
Abra o arquivo kenney_physics-assets.zip
que você fez o download. Ele vai ficar assim:
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 sprites sheets. Elas são uma combinação de uma imagem PNG e um arquivo XML que descreve onde as imagens menores podem ser encontradas na imagem da spritesheet. As folhas de sprite são uma técnica para reduzir o tempo de carregamento carregando apenas um arquivo, em vez de dezenas ou 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 estiver aqui, copie também os arquivos spritesheet_aliens.xml
, spritesheet_elements.xml
e spritesheet_tiles.xml
para o diretório assets
do projeto. Seu 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 serão fornecidas nas próximas etapas.
Crie um arquivo chamado background.dart
em um novo diretório chamado lib/components
e adicione o seguinte conteúdo.
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 é uma SpriteComponent
especializada. Ele é responsável por mostrar uma das quatro imagens de plano de fundo do Kenney.nl. Há algumas suposições simplificadas neste código. A primeira é que as imagens são quadradas, como as quatro imagens de plano de fundo de Kenney. A segunda é que o tamanho do mundo visível nunca vai mudar. 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, chamado 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();
}
}
Há muita coisa acontecendo aqui. Comece com a classe MyPhysicsGame
. Ao contrário do codelab anterior, este estende Forge2DGame
, não FlameGame
. O Forge2DGame
estende o FlameGame
com alguns ajustes interessantes. A primeira é que, por padrão, zoom
é definido como 10. Essa configuração zoom
tem a ver com o intervalo de valores úteis com os quais os mecanismos de simulação de física do tipo Box2D
funcionam bem. O mecanismo é escrito usando o sistema MKS, em que as unidades são consideradas em metros, quilogramas e segundos. O intervalo em que não há erros matemáticos perceptíveis para objetos é de 0,1 a 10 metros. Alimentar as dimensões de pixels diretamente sem algum nível de redução de escala tiraria o Forge2D do envelope útil. O resumo útil é pensar em simular objetos na faixa de uma lata de refrigerante até um ônibus.
As suposições feitas no componente de plano de fundo são atendidas aqui fixando a resolução da CameraComponent
em 800 x 600 pixels virtuais. Isso significa que a área do 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 onde colocamos os objetos na cena do jogo.
Além do argumento do construtor camera
, há outro argumento mais alinhado com a física chamado gravity
. A gravidade está definida como Vector2
com um x
de 0
e um y
de 10
. O 10
é uma aproximação aproximada do valor geralmente aceito de 9,81 metros por segundo para a gravidade. O fato de a gravidade estar definida como 10 positivo mostra que, nesse sistema, a direção do eixo Y é para baixo. O que é diferente do Box2D em geral, mas está de acordo com a configuração usual do Flame.
O próximo é o método onLoad
. Esse método é assíncrono, o que é apropriado porque ele é responsável por carregar recursos de imagem do disco. As chamadas para images.load
retornam um Future<Image>
e, como efeito colateral, armazenam em cache a imagem carregada no objeto do jogo. Esses futuros são reunidos e aguardados como uma única unidade usando o método estático Futures.wait
. A lista de imagens retornadas é então combinada com padrões em nomes individuais.
As imagens da spritesheet são alimentadas em uma série de objetos XmlSpriteSheet
, responsáveis por recuperar os sprites nomeados individualmente contidos na spritesheet. A classe XmlSpriteSheet
é definida no pacote flame_kenney_xml
.
Com tudo isso resolvido, você só precisa de algumas edições menores em lib/main.dart
para colocar 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, agora você pode executar o jogo novamente para conferir o plano de fundo na tela. A instância da câmera CameraComponent.withFixedResolution()
vai adicionar letterboxing conforme necessário para que a proporção de 800 x 600 do jogo funcione.
4. Adicionar o solo
Algo para construir
Se tivermos gravidade, precisamos de algo para pegar os objetos no jogo antes que eles caiam para a parte de baixo da tela. A menos que cair da tela faça parte do design do jogo, é claro. Crie um novo arquivo ground.dart
no diretório lib/components
e adicione o seguinte:
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
é derivado de BodyComponent
. No Forge2D, os corpos são importantes. Eles são os objetos que fazem parte da simulação física bidimensional. O BodyDef
desse componente é especificado para ter um BodyType.static
.
No Forge2D, os corpos têm três tipos diferentes. Os corpos estáticos não se movem. Elas têm massa zero, ou seja, não reagem à gravidade, e massa infinita, ou seja, não se movem quando atingidas por outros objetos, não importa o peso deles. Isso torna os corpos estáticos perfeitos para uma superfície de solo, já que não se movem.
Os outros dois tipos de corpos são cinemáticos e dinâmicos. Os corpos dinâmicos são completamente simulados, eles reagem à gravidade e aos objetos com que se chocam. Você vai encontrar muitos corpos dinâmicos no restante deste codelab. Os corpos cinemáticos são uma casa intermediária entre estática e dinâmica. Eles se movem, mas não reagem à gravidade ou a outros objetos que os atingem. Útil, mas fora do escopo deste codelab.
O corpo em si não faz muito. Um corpo precisa de formas associadas para ter substância. Nesse caso, o corpo tem uma forma associada, um PolygonShape
definido como BoxXY
. Esse tipo de caixa é alinhado ao eixo com o mundo, ao contrário de um PolygonShape
definido como um BoxXY
, que pode ser girado em torno de um ponto de rotação. Novamente útil, mas também fora do escopo deste codelab. A forma e o corpo são conectados com um fixador, que é útil para adicionar elementos como friction
ao sistema.
Por padrão, um corpo renderiza as formas anexadas de uma maneira útil para depuração, mas que não é ideal para a jogabilidade. Definir o argumento renderBody
super
como false
desativa essa renderização de depuração. A renderização desse corpo 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 um loop for
dentro de um contexto List
e transmitindo a lista resultante de componentes Ground
ao método addAll
do world
.
Agora, ao executar o jogo, o plano de fundo e o solo aparecem.
5. Adicionar os tijolos
Construir uma parede
O chão é um exemplo de corpo estático. Agora é hora do seu primeiro componente dinâmico. Os componentes dinâmicos no Forge2D são a base da experiência do jogador. Eles se movem e interagem com o mundo ao redor. Nesta etapa, você vai introduzir os blocos, que serão escolhidos aleatoriamente para aparecer na tela em um cluster de blocos. Você vai ver que eles caem e se chocam um com o outro.
Os blocos serão feitos a partir da folha de sprite dos elementos. Se você olhar a descrição da sprite sheet em assets/spritesheet_elements.xml
, vai notar que temos um problema interessante. Os nomes não parecem muito úteis. Seria útil selecionar um tijolo por tipo de material, tamanho e quantidade de danos. Felizmente, um elfo útil passou algum tempo para descobrir o padrão na nomenclatura de arquivos e criou uma ferramenta para facilitar as coisas. 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();
}
O editor vai mostrar um aviso ou erro sobre uma dependência ausente. Adicione usando o seguinte comando:
flutter pub add equatable
Agora você 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 sprite sheet e o converteu em código Dart, que podemos usar para selecionar o arquivo de imagem certo para cada bloco 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 conferir como o código Dart gerado anteriormente é integrado a essa base de código para facilitar a seleção de imagens de tijolos com base no material, tamanho e condição. Além dos enum
s, o componente Brick
parece bastante familiar do componente Ground
na etapa anterior. Há um estado mutável para permitir que o bloco seja danificado, embora o uso seja deixado como um exercício para o leitor.
É hora de mostrar os tijolos 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, os Brick
s estão sendo adicionados em um cluster aleatório ao longo do tempo. Há duas partes nisso. A primeira é que o método que adiciona os await
s Brick
s é um Future.delayed
, que é o equivalente assíncrono de uma chamada sleep()
. No entanto, há uma segunda parte para fazer isso funcionar. A chamada para addBricks
no método onLoad
não é await
ada. Se fosse, o método onLoad
não seria concluído até que todos os blocos estivessem na tela. O agrupamento da chamada para addBricks
em uma chamada unawaited
deixa os linters felizes e torna nossa intenção óbvia para futuros programadores. Não esperar que esse método retorne é intencional.
Execute o jogo e os tijolos vão aparecer, colidir entre si e se espalhar pelo chão.
6. Adicionar o jogador
Atire alienígenas em tijolos
Assistir os tijolos caindo é divertido nas primeiras vezes, mas acho que o jogo vai ser mais divertido se dermos ao jogador um avatar para interagir com o mundo. Que tal um alienígena que eles podem jogar nos tijolos?
Crie um novo arquivo player.dart
no diretório lib/components
e adicione o seguinte:
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.withAlpha(180)
..strokeWidth = 0.4
..strokeCap = StrokeCap.round,
);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
Essa é uma etapa superior aos componentes Brick
da etapa anterior. Esse componente Player
tem dois componentes filhos, uma SpriteComponent
que você deve reconhecer e uma CustomPainterComponent
que é nova. O conceito CustomPainter
é do Flutter e permite pintar em uma tela. Ele é usado aqui para dar feedback ao jogador sobre para onde o alienígena redondo vai voar quando for lançado.
Como o jogador inicia o lançamento do alienígena? Usando um gesto de arrasto, que o componente Player detecta com os callbacks DragCallbacks
. Os mais atentos já perceberam algo mais aqui.
Enquanto os componentes Ground
eram corpos estáticos, os componentes de bloco eram corpos dinâmicos. O Player é uma combinação dos dois. O jogador começa estático, esperando que o jogador o arraste e, ao soltar o arrasto, ele se converte de estático para dinâmico, adiciona impulso linear proporcional ao arrasto e permite que o avatar alienígena voe.
Há também um código no componente Player
para removê-lo da tela se ele sair dos limites, entrar em suspensão ou expirar. A intenção aqui é permitir que o jogador jogue o alienígena, veja o que acontece e tente de novo.
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.
}
Adicionar o jogador ao jogo é semelhante aos componentes anteriores, com uma diferença. O alienígena do jogador foi projetado para se remover do jogo em determinadas condições. Portanto, há um gerenciador de atualização que verifica se não há um componente Player
no jogo e, se houver, adiciona um de volta. A execução do jogo fica assim.
7. Reagir ao impacto
Adicionar os inimigos
Você 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 as coisas colidem. Você vai introduzir alguns inimigos para o jogador enfrentar. Isso dá um caminho para uma condição de vitória: 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();
}
Com base nas suas interações anteriores com os componentes Player e Brick, a maior parte desse arquivo deve ser familiar. No entanto, haverá algumas sublinhados vermelhos no editor devido a uma nova classe 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 base, combinada com o novo callback beginContact
no componente Enemy
, é a base para receber notificações de forma programática sobre impactos entre corpos. Na verdade, você vai precisar editar todos os componentes para receber notificações de impacto. Então, edite os componentes Brick
, Ground
e Player
para usar esse BodyComponentWithUserData
em vez da classe base BodyComponent
que esses componentes usam. 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 o contato, consulte a documentação do Forge2D sobre callbacks de contato.
Vencer o jogo
Agora que você tem inimigos e uma maneira de removê-los do mundo, há uma maneira simples de transformar essa simulação em um jogo. O objetivo é eliminar 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.
}
}
Seu desafio, se você aceitar, é executar o jogo e chegar a essa tela.
8. Parabéns
Parabéns! Você conseguiu criar um jogo com o Flutter e o Flame.
Você criou um jogo usando o mecanismo de jogo Flame 2D e o incorporou a um wrapper do Flutter. Você usou os efeitos do Flame para animar e remover componentes. Você usou os pacotes Google Fonts e Flutter Animate para deixar o jogo com uma aparência bem projetada.
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 no app ao seu app Flutter