Tentang codelab ini
1. Sebelum memulai
Flame adalah game engine 2D berbasis Flutter. Dalam codelab ini, Anda akan mem-build game yang menggunakan simulasi fisika 2D seperti Box2D yang disebut Forge2D. Anda menggunakan komponen Flame untuk melukis simulasi realitas fisik di layar agar dapat dimainkan oleh pengguna. Setelah selesai, game Anda akan terlihat seperti gif animasi ini:
Prasyarat
- Menyelesaikan codelab Pengantar Flame dengan Flutter
Yang Anda pelajari
- Cara kerja dasar-dasar Forge2D, dimulai dengan berbagai jenis objek fisik.
- Cara menyiapkan simulasi fisik dalam 2D.
Yang Anda perlukan
- Flutter SDK
- Visual Studio Code (VS Code) dengan plugin Flutter dan Dart
Software compiler untuk target pengembangan yang Anda pilih. Codelab ini berfungsi untuk keenam platform yang didukung Flutter. Anda memerlukan Visual Studio untuk menargetkan Windows, Xcode untuk menargetkan macOS atau iOS, dan Android Studio untuk menargetkan Android.
2. Membuat project
Membuat project Flutter
Ada banyak cara untuk membuat project Flutter. Di bagian ini, Anda akan menggunakan command line untuk mempersingkat.
Untuk memulai, ikuti langkah-langkah berikut:
- Di command line, buat project 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.
- Ubah dependensi project untuk menambahkan Flame dan 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.
Paket flame
sudah Anda kenal, tetapi tiga paket lainnya mungkin memerlukan penjelasan. Paket characters
digunakan untuk manipulasi jalur dengan cara yang sesuai dengan UTF8. Paket flame_forge2d
mengekspos fungsi Forge2D dengan cara yang berfungsi baik dengan Flame. Terakhir, paket xml
digunakan di berbagai tempat untuk menggunakan dan mengubah konten XML.
Buka project, lalu ganti konten file lib/main.dart
dengan kode berikut:
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
void main() {
runApp(GameWidget.controlled(gameFactory: FlameGame.new));
}
Tindakan ini akan memulai aplikasi dengan GameWidget
yang membuat instance FlameGame
. Tidak ada kode Flutter dalam codelab ini yang menggunakan status instance game untuk menampilkan informasi tentang game yang sedang berjalan, sehingga bootstrap sederhana ini berfungsi dengan baik.
Opsional: Mengikuti misi sampingan khusus macOS
Screenshot dalam project ini berasal dari game sebagai aplikasi desktop macOS. Agar panel judul aplikasi tidak mengurangi pengalaman secara keseluruhan, Anda dapat mengubah konfigurasi project runner macOS untuk menghapus panel judul.
Untuk melakukannya, ikuti langkah-langkah berikut:
- Buat file
bin/modify_macos_config.dart
, lalu tambahkan konten berikut:
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());
}
File ini tidak ada dalam direktori lib
karena bukan bagian dari codebase runtime untuk game. Ini adalah alat command line yang digunakan untuk mengubah project.
- Dari direktori dasar project, jalankan alat sebagai berikut:
dart bin/modify_macos_config.dart
Jika semuanya berjalan sesuai rencana, program tidak akan menghasilkan output di command line. Namun, file ini akan mengubah file konfigurasi macos/Runner/Base.lproj/MainMenu.xib
untuk menjalankan game tanpa panel judul yang terlihat dan dengan game Flame yang memenuhi seluruh jendela.
Jalankan game untuk memverifikasi bahwa semuanya berfungsi. Tindakan ini akan menampilkan jendela baru dengan latar belakang hitam kosong.
3. Menambahkan aset gambar
Tambahkan gambar
Setiap game memerlukan aset seni agar dapat mewarnai layar dengan cara yang menyenangkan. Codelab ini akan menggunakan paket Physics Assets dari Kenney.nl. Aset ini dilisensikan Creative Commons CC0, tetapi sebaiknya Anda tetap memberikan donasi kepada tim di Kenney agar mereka dapat terus melakukan pekerjaan yang luar biasa. Ya.
Anda harus mengubah file konfigurasi pubspec.yaml
untuk mengaktifkan penggunaan aset Kenney. Ubah sebagai berikut:
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.
Flame mengharapkan aset gambar berada di assets/images
, meskipun hal ini dapat dikonfigurasi secara berbeda. Lihat dokumentasi Gambar Flame untuk mengetahui detail selengkapnya. Setelah mengonfigurasi jalur, Anda perlu menambahkannya ke project itu sendiri. Salah satu cara melakukannya adalah dengan menggunakan command line sebagai berikut:
mkdir -p assets/images
Tidak akan ada output dari perintah mkdir
, tetapi direktori baru akan terlihat di editor atau file explorer.
Luaskan file kenney_physics-assets.zip
yang Anda download, dan Anda akan melihat sesuatu seperti ini:
Dari direktori PNG/Backgrounds
, salin file colored_desert.png
, colored_grass.png
, colored_land.png
, dan colored_shroom.png
ke direktori assets/images
project Anda.
Ada juga sheet sprite. Ini adalah kombinasi gambar PNG dan file XML yang menjelaskan letak gambar yang lebih kecil dalam gambar spritesheet. Spritesheet adalah teknik untuk mengurangi waktu pemuatan dengan hanya memuat satu file, bukan puluhan, bahkan ratusan file gambar individual.
Salin spritesheet_aliens.png
, spritesheet_elements.png
, dan spritesheet_tiles.png
ke direktori assets/images
project Anda. Saat Anda berada di sini, salin juga file spritesheet_aliens.xml
, spritesheet_elements.xml
, dan spritesheet_tiles.xml
ke direktori assets
project Anda. Project Anda akan terlihat seperti berikut.
Menggambar latar belakang
Setelah project Anda menambahkan aset gambar, saatnya untuk menempatkannya di layar. Nah, satu gambar di layar. Langkah-langkah lainnya akan dijelaskan dalam langkah-langkah berikut.
Buat file bernama background.dart
di direktori baru bernama lib/components
dan tambahkan konten berikut.
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,
),
);
}
}
Komponen ini adalah SpriteComponent
khusus. Class ini bertanggung jawab untuk menampilkan salah satu dari empat gambar latar Kenney.nl. Ada beberapa asumsi penyederhanaan dalam kode ini. Pertama, gambarnya berbentuk persegi, seperti keempat gambar latar belakang dari Kenney. Yang kedua adalah ukuran dunia yang terlihat tidak akan pernah berubah. Jika tidak, komponen ini harus menangani peristiwa pengubahan ukuran game. Asumsi ketiga adalah posisi (0,0)
akan berada di tengah layar. Asumsi ini memerlukan konfigurasi CameraComponent
game tertentu.
Buat file baru lainnya, yang ini bernama game.dart
, lagi-lagi di direktori 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();
}
}
Ada banyak hal yang terjadi di sini. Mulai dengan class MyPhysicsGame
. Tidak seperti codelab sebelumnya, codelab ini memperluas Forge2DGame
, bukan FlameGame
. Forge2DGame
sendiri memperluas FlameGame
dengan beberapa penyesuaian yang menarik. Yang pertama adalah, secara default, zoom
ditetapkan ke 10. Setelan zoom
ini berkaitan dengan rentang nilai berguna yang berfungsi dengan baik dengan mesin simulasi fisika gaya Box2D
. Mesin ditulis menggunakan sistem MKS dengan unit yang diasumsikan dalam meter, kilogram, dan detik. Rentang yang tidak menampilkan error matematika yang terlihat untuk objek adalah dari 0,1 meter hingga puluhan meter. Memasukkan dimensi piksel secara langsung tanpa beberapa tingkat penskalaan ke bawah akan membuat Forge2D berada di luar amplop yang berguna. Ringkasan yang berguna adalah memikirkan simulasi objek dalam rentang kaleng soda hingga bus.
Asumsi yang dibuat di komponen Latar Belakang dipenuhi di sini dengan memperbaiki resolusi CameraComponent
menjadi 800x600 piksel virtual. Artinya, area game akan memiliki lebar 80 unit dan tinggi 60 unit, yang berpusat di (0,0)
. Hal ini tidak memengaruhi resolusi yang ditampilkan, tetapi akan memengaruhi tempat kita menempatkan objek di tampilan game.
Di samping argumen konstruktor camera
, terdapat argumen lain yang lebih selaras dengan fisika yang disebut gravity
. Gravitasi ditetapkan ke Vector2
dengan x
0
dan y
10
. 10
adalah perkiraan yang mendekati nilai gravitasi 9,81 meter per detik per detik yang umumnya diterima. Fakta bahwa gravitasi disetel ke 10 positif menunjukkan bahwa dalam sistem ini arah untuk sumbu Y adalah ke bawah. Yang berbeda dengan Box2D secara umum, tetapi sesuai dengan cara Flame biasanya dikonfigurasi.
Selanjutnya adalah metode onLoad
. Metode ini bersifat asinkron, yang sesuai karena bertanggung jawab untuk memuat aset gambar dari disk. Panggilan ke images.load
menampilkan Future<Image>
, dan sebagai efek samping, meng-cache gambar yang dimuat dalam objek Game. Future ini dikumpulkan dan ditunggu sebagai satu unit menggunakan metode statis Futures.wait
. Daftar gambar yang ditampilkan kemudian dicocokkan polanya ke dalam setiap nama.
Gambar spritesheet kemudian dimasukkan ke dalam serangkaian objek XmlSpriteSheet
yang bertanggung jawab untuk mengambil Sprite yang diberi nama secara terpisah yang terdapat dalam spritesheet. Class XmlSpriteSheet
ditentukan dalam paket flame_kenney_xml
.
Setelah semua itu selesai, Anda hanya memerlukan beberapa pengeditan kecil pada lib/main.dart
untuk mendapatkan gambar di layar.
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
}
Dengan perubahan ini, Anda kini dapat menjalankan game lagi untuk melihat latar belakang di layar. Perhatikan, instance kamera CameraComponent.withFixedResolution()
akan menambahkan letterbox sesuai kebutuhan agar rasio 800x600 game berfungsi.
4. Menambahkan tanah
Dasar untuk membangun
Jika memiliki gravitasi, kita memerlukan sesuatu untuk menangkap objek dalam game sebelum jatuh dari bagian bawah layar. Tentu saja, kecuali jika jatuh dari layar adalah bagian dari desain game Anda. Buat file ground.dart
baru di direktori lib/components
Anda dan tambahkan kode berikut ke dalamnya:
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),
),
],
);
}
Komponen Ground
ini berasal dari BodyComponent
. Di Forge2D, objek penting, yaitu objek yang merupakan bagian dari simulasi fisik dua dimensi. BodyDef
untuk komponen ini ditentukan untuk memiliki BodyType.static
.
Di Forge2D, ada tiga jenis tubuh yang berbeda. Objek statis tidak bergerak. Partikel ini secara efektif memiliki massa nol - tidak bereaksi terhadap gravitasi - dan massa tak terbatas - tidak bergerak saat tertabrak objek lain, tidak peduli seberapa beratnya. Hal ini membuat objek statis cocok untuk permukaan tanah, karena tidak bergerak.
Dua jenis tubuh lainnya adalah kinematik dan dinamis. Objek dinamis adalah objek yang sepenuhnya disimulasikan, yang bereaksi terhadap gravitasi dan objek yang ditabraknya. Anda akan melihat banyak isi dinamis di bagian lain codelab ini. Objek kinematik adalah perantara antara statis dan dinamis. Objek bergerak, tetapi tidak bereaksi terhadap gravitasi atau objek lain yang menabraknya. Berguna, tetapi berada di luar cakupan codelab ini.
Isi sendiri tidak melakukan banyak hal. Tubuh memerlukan bentuk terkait agar memiliki substansi. Dalam hal ini, isi ini memiliki satu bentuk terkait, PolygonShape
yang ditetapkan sebagai BoxXY
. Jenis kotak ini adalah sumbu yang sejajar dengan dunia, tidak seperti PolygonShape
yang ditetapkan sebagai BoxXY
yang dapat diputar di sekitar titik rotasi. Sekali lagi, ini berguna, tetapi juga berada di luar cakupan codelab ini. Bentuk dan isi dilampirkan bersama dengan perlengkapan, yang berguna untuk menambahkan hal-hal seperti friction
ke sistem.
Secara default, isi akan merender bentuk yang terlampir dengan cara yang berguna untuk proses debug, tetapi tidak menghasilkan gameplay yang bagus. Menyetel argumen super
renderBody
ke false
akan menonaktifkan rendering debug ini. Memberikan rendering dalam game pada isi ini adalah tanggung jawab SpriteComponent
turunan.
Untuk menambahkan komponen Ground
ke game, edit file game.dart
sebagai berikut.
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.
}
Pengeditan ini menambahkan serangkaian komponen Ground
ke dunia dengan menggunakan loop for
di dalam konteks List
, dan meneruskan daftar komponen Ground
yang dihasilkan ke metode addAll
world
.
Menjalankan game sekarang akan menampilkan latar belakang dan tanah.
5. Menambahkan batu bata
Membangun dinding
Tanah memberi kita contoh benda statis. Sekarang saatnya untuk komponen dinamis pertama Anda. Komponen dinamis di Forge2D adalah fondasi pengalaman pemain, yaitu hal-hal yang bergerak dan berinteraksi dengan dunia di sekitarnya. Pada langkah ini, Anda akan memperkenalkan batu bata, yang akan dipilih secara acak untuk muncul di layar dalam cluster batu bata. Anda akan melihatnya jatuh dan saling bertabrakan saat melakukannya.
Bata akan dibuat dari sheet sprite elemen. Jika melihat deskripsi sheet sprite di assets/spritesheet_elements.xml
, Anda akan melihat bahwa kita memiliki masalah yang menarik. Nama tersebut tampaknya tidak terlalu membantu. Yang akan berguna adalah dapat memilih bata berdasarkan jenis bahan, ukurannya, dan jumlah kerusakannya. Untungnya, seorang elf yang baik hati meluangkan waktu untuk mencari tahu pola dalam penamaan file dan membuat alat untuk memudahkan Anda. Buat file baru generate_brick_file_names.dart
di direktori bin
dan tambahkan konten berikut:
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();
}
Editor Anda akan memberikan peringatan atau error tentang dependensi yang hilang. Tambahkan menggunakan perintah berikut:
flutter pub add equatable
Sekarang Anda dapat menjalankan program ini sebagai berikut:
$ 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', }, }; }
Alat ini telah mengurai file deskripsi sprite sheet dan mengonversinya menjadi kode Dart yang dapat kita gunakan untuk memilih file gambar yang tepat untuk setiap batu bata yang ingin Anda tampilkan di layar. Berguna!
Buat file brick.dart
dengan konten berikut:
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();
}
}
Sekarang Anda dapat melihat cara kode Dart yang dihasilkan sebelumnya diintegrasikan ke dalam codebase ini untuk memudahkan pemilihan gambar bata berdasarkan bahan, ukuran, dan kondisi. Melihat di luar enum
dan ke komponen Brick
itu sendiri, Anda akan menemukan bahwa sebagian besar kode ini tampak cukup familier dari komponen Ground
di langkah sebelumnya. Ada status yang dapat diubah di sini untuk memungkinkan brick menjadi rusak, meskipun penggunaannya dibiarkan sebagai latihan bagi pembaca.
Saatnya menampilkan batu bata di layar. Edit file game.dart
sebagai berikut:
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.
}
Penambahan kode ini sedikit berbeda dengan kode yang Anda gunakan untuk menambahkan komponen Ground
. Kali ini, Brick
ditambahkan dalam cluster acak, dari waktu ke waktu. Ada dua bagian untuk ini, yang pertama adalah metode yang menambahkan await
Brick
menjadi Future.delayed
, yang merupakan padanan asinkron dari panggilan sleep()
. Namun, ada bagian kedua untuk membuat ini berfungsi, panggilan ke addBricks
dalam metode onLoad
tidak await
. Jika demikian, metode onLoad
tidak akan selesai hingga semua batu bata berada di layar. Menggabungkan panggilan ke addBricks
dalam panggilan unawaited
akan membuat linter senang, dan membuat intent kita jelas bagi programmer di masa mendatang. Tidak menunggu metode ini ditampilkan adalah hal yang disengaja.
Jalankan game, dan Anda akan melihat batu bata muncul, saling bertabrakan, dan berhamburan di tanah.
6. Menambahkan pemain
Lempar alien ke batu bata
Menonton batu bata berjatuhan memang menyenangkan pada beberapa kali pertama, tetapi saya rasa game ini akan lebih menyenangkan jika kita memberi pemain avatar yang dapat mereka gunakan untuk berinteraksi dengan dunia. Bagaimana dengan alien yang dapat mereka lemparkan ke batu bata?
Buat file player.dart
baru di direktori lib/components
dan tambahkan kode berikut ke dalamnya:
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;
}
Ini adalah langkah lanjutan dari komponen Brick
di langkah sebelumnya. Komponen Player
ini memiliki dua komponen turunan, SpriteComponent
yang harus Anda kenali, dan CustomPainterComponent
yang baru. Konsep CustomPainter
berasal dari Flutter, dan memungkinkan Anda melukis di kanvas. Ini digunakan di sini untuk memberi pemain masukan tentang tempat alien bulat akan terbang saat dilempar.
Bagaimana pemain memulai lemparan alien? Menggunakan gestur tarik, yang dideteksi komponen Player dengan callback DragCallbacks
. Pengamat yang jeli akan melihat hal lain di sini.
Jika komponen Ground
adalah isi statis, komponen Brick adalah isi dinamis. Pemain di sini adalah kombinasi dari keduanya. Pemain dimulai sebagai statis, menunggu pemain menariknya, dan saat dilepaskan, pemain akan mengubah dirinya dari statis menjadi dinamis, menambahkan impuls linear yang sebanding dengan tarikan, dan memungkinkan avatar alien terbang.
Ada juga kode di komponen Player
untuk menghapusnya dari layar jika keluar dari batas, tertidur, atau waktu habis. Tujuannya di sini adalah untuk memungkinkan pemain melemparkan alien, melihat apa yang terjadi, lalu mencoba lagi.
Integrasikan komponen Player
ke dalam game dengan mengedit game.dart
sebagai berikut:
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.
}
Menambahkan pemain ke game mirip dengan komponen sebelumnya, dengan satu detail tambahan. Alien pemain dirancang untuk menghapus dirinya sendiri dari game dalam kondisi tertentu, sehingga ada pengendali update di sini yang memeriksa apakah tidak ada komponen Player
dalam game, dan jika ya, menambahkannya kembali. Menjalankan game akan terlihat seperti ini.
7. Merespons dampak
Menambahkan musuh
Anda telah melihat objek statis dan dinamis yang saling berinteraksi. Namun, untuk benar-benar mendapatkan sesuatu, Anda perlu mendapatkan callback dalam kode saat terjadi tabrakan. Anda akan memperkenalkan beberapa musuh yang akan dihadapi pemain. Hal ini memberikan jalan menuju kondisi kemenangan - hapus semua musuh dari game.
Buat file enemy.dart
di direktori lib/components
dan tambahkan hal berikut:
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();
}
Dari interaksi Anda sebelumnya dengan komponen Player dan Brick, sebagian besar file ini seharusnya sudah tidak asing lagi. Namun, akan ada beberapa garis bawah merah di editor Anda karena class dasar baru yang tidak diketahui. Tambahkan class ini sekarang dengan menambahkan file bernama body_component_with_user_data.dart
ke lib/components
dengan konten berikut:
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;
}
}
Class dasar ini, yang digabungkan dengan callback beginContact
baru di komponen Enemy
, membentuk dasar untuk mendapatkan notifikasi secara terprogram tentang dampak antar-body. Bahkan, Anda harus mengedit komponen yang ingin Anda dapatkan notifikasi dampaknya. Jadi, lanjutkan dan edit komponen Brick
, Ground
, dan Player
untuk menggunakan BodyComponentWithUserData
ini sebagai pengganti class dasar BodyComponent
yang digunakan komponen tersebut. Misalnya, berikut adalah cara mengedit komponen 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),
),
],
);
}
Untuk informasi selengkapnya tentang cara Forge2d menangani kontak, lihat dokumentasi Forge2D tentang callback kontak.
Menang pertandingan
Setelah Anda memiliki musuh, dan cara untuk menghapus musuh dari dunia, ada cara sederhana untuk mengubah simulasi ini menjadi game. Buat sasaran untuk menghapus semua musuh. Saatnya mengedit file game.dart
sebagai berikut:
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.
}
}
Tantangan Anda, jika Anda memilih untuk menerimanya, adalah menjalankan game dan membuka layar ini.
8. Selamat
Selamat, Anda berhasil mem-build game dengan Flutter dan Flame.
Anda telah mem-build game menggunakan game engine Flame 2D dan menyematkannya dalam wrapper Flutter. Anda telah menggunakan Efek Flame untuk menganimasikan dan menghapus komponen. Anda telah menggunakan paket Google Fonts dan Flutter Animate untuk membuat seluruh game terlihat didesain dengan baik.
Apa langkah selanjutnya?
Lihat beberapa codelab ini...
- Mem-build UI generasi berikutnya di Flutter
- Membuat tampilan aplikasi Flutter menjadi lebih menarik
- Menambahkan pembelian dalam aplikasi ke aplikasi Flutter