1. Pengantar
Flame adalah game engine 2D berbasis Flutter. Dalam codelab ini, Anda akan membangun game yang terinspirasi dari salah satu video game klasik tahun 70-an, Breakout oleh Steve Wozniak. Kamu akan menggunakan Komponen Flame, untuk menggambar pemukul, bola, dan batu bata. Anda akan memanfaatkan Flame's Effects untuk menganimasikan gerakan kelelawar dan melihat cara mengintegrasikan Flame dengan sistem manajemen status Flutter.
Setelah selesai, game Anda akan terlihat seperti gif animasi ini, meskipun sedikit lebih lambat.
Yang akan Anda pelajari
- Cara kerja dasar-dasar Flame, dimulai dengan
GameWidget
. - Cara menggunakan game loop.
- Cara kerja
Component
Flame. Keduanya mirip denganWidget
Flutter. - Cara menangani tabrakan.
- Cara menggunakan
Effect
untuk menganimasikanComponent
. - Cara menempatkan
Widget
Flutter di atas game Flame. - Cara mengintegrasikan Flame dengan pengelolaan status Flutter.
Yang akan Anda build
Dalam codelab ini, Anda akan membangun game 2D menggunakan Flutter dan Flame. Setelah selesai, game Anda harus memenuhi persyaratan berikut
- Berfungsi di keenam platform yang didukung Flutter: Android, iOS, Linux, macOS, Windows, dan web
- Pertahankan minimal 60 fps menggunakan game loop Flame.
- Gunakan kemampuan Flutter seperti paket
google_fonts
danflutter_animate
untuk menciptakan kembali nuansa game arcade tahun 80-an.
2. Menyiapkan lingkungan Flutter Anda
Editor
Untuk menyederhanakan codelab ini, Visual Studio Code (VS Code) dianggap sebagai lingkungan pengembangan Anda. VS Code gratis dan berfungsi di semua platform utama. Kita menggunakan VS Code untuk codelab ini karena instruksi ini menggunakan default untuk pintasan khusus VS Code. Tugas menjadi lebih mudah: "klik tombol ini" atau "tekan tombol ini untuk melakukan X" daripada "melakukan tindakan yang sesuai di editor Anda untuk melakukan X".
Anda dapat menggunakan editor apa pun yang Anda suka: Android Studio, IntelliJ IDEs lainnya, Emacs, Vim, atau Notepad++. Semuanya berfungsi dengan Flutter.
Memilih target pengembangan
Flutter memproduksi aplikasi untuk berbagai platform. Aplikasi Anda dapat berjalan pada setiap sistem operasi berikut:
- iOS
- Android
- Windows
- macOS
- Linux
- web
Memilih satu sistem operasi sebagai target pengembangan Anda adalah hal yang umum dilakukan. Ini adalah sistem operasi yang digunakan aplikasi Anda selama pengembangan.
Misalnya: anggaplah Anda menggunakan laptop Windows untuk mengembangkan aplikasi Flutter. Kemudian, pilih Android sebagai target pengembangan Anda. Untuk melihat pratinjau aplikasi, pasang perangkat Android ke laptop Windows dengan kabel USB dan aplikasi yang Anda kembangkan berjalan di perangkat Android yang terpasang, atau di emulator Android. Anda bisa memilih Windows sebagai target pengembangan, yang menjalankan aplikasi yang Anda kembangkan sebagai aplikasi Windows bersama editor Anda.
Anda mungkin tergoda untuk memilih web sebagai target pengembangan. Ini memiliki kelemahan selama pengembangan: Anda kehilangan kemampuan Stateful Hot Reload Flutter. Flutter saat ini tidak dapat melakukan hot-reload pada aplikasi web.
Tentukan pilihan Anda sebelum melanjutkan. Anda dapat menjalankan aplikasi Anda di sistem operasi lainnya kapan saja setelahnya. Memilih target pengembangan akan memperlancar langkah berikutnya.
Menginstal Flutter
Petunjuk terbaru tentang cara menginstal Flutter SDK dapat ditemukan di docs.flutter.dev.
Instruksi di situs Flutter membahas penginstalan SDK dan alat terkait target pengembangan serta plugin editor. Untuk codelab ini, instal software berikut:
- Flutter SDK
- Visual Studio Code dengan plugin Flutter
- Software compiler untuk target pengembangan pilihan Anda. (Anda memerlukan Visual Studio untuk menargetkan Windows atau Xcode guna menargetkan macOS atau iOS)
Di bagian berikutnya, Anda akan membuat proyek Flutter pertama Anda.
Jika Anda perlu memecahkan masalah apa pun, Anda mungkin merasa beberapa pertanyaan dan jawaban ini (dari StackOverflow) berguna untuk pemecahan masalah.
Pertanyaan Umum (FAQ)
- Bagaimana cara menemukan jalur Flutter SDK?
- Apa yang harus saya lakukan saat perintah Flutter tidak ditemukan?
- Bagaimana cara memperbaiki masalah "Menunggu perintah flutter lainnya untuk melepaskan kunci startup"?
- Bagaimana cara memberi tahu Flutter letak penginstalan Android SDK berada?
- Bagaimana cara menangani kesalahan Java saat menjalankan
flutter doctor --android-licenses
? - Bagaimana cara menangani masalah alat
sdkmanager
Android tidak ditemukan? - Bagaimana cara menangani kesalahan "komponen
cmdline-tools
tidak ditemukan"? - Bagaimana cara menjalankan CocoaPods pada Apple Silicon (M1)?
- Bagaimana cara menonaktifkan pemformatan otomatis pada file yang disimpan di VS Code?
3. Membuat proyek
Membuat proyek Flutter pertama Anda
Langkah ini melibatkan pembukaan VS Code dan membuat template aplikasi Flutter di direktori yang Anda pilih.
- Luncurkan Visual Studio Code.
- Buka palet perintah (
F1
atauCtrl+Shift+P
atauShift+Cmd+P
), lalu ketik "flutter new". Saat muncul, pilih perintah Flutter: New Project.
- Pilih Empty Application. Pilih direktori tempat Anda akan membuat project. Direktori ini harus berupa direktori apa pun yang tidak memerlukan hak istimewa yang ditingkatkan atau memiliki spasi di jalurnya. Contohnya mencakup direktori utama atau
C:\src\
Anda.
- Beri nama project Anda
brick_breaker
. Bagian selanjutnya dari codelab ini menganggap Anda memberi nama aplikasibrick_breaker
.
Flutter kini membuat folder proyek Anda dan VS Code membuka folder tersebut. Anda sekarang akan menimpa konten dua file dengan scaffold dasar aplikasi.
Menyalin & Menempelkan aplikasi awal
Tindakan ini akan menambahkan kode contoh yang diberikan dalam codelab ini ke aplikasi Anda.
- Pada panel sebelah kiri VS Code, klik Explorer dan buka file
pubspec.yaml
.
- Ganti konten file ini dengan kode berikut:
pubspec.yaml
name: brick_breaker
description: "Re-implementing Woz's Breakout"
publish_to: 'none'
version: 0.1.0
environment:
sdk: '>=3.3.0 <4.0.0'
dependencies:
flame: ^1.16.0
flutter:
sdk: flutter
flutter_animate: ^4.5.0
google_fonts: ^6.1.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.1
flutter:
uses-material-design: true
File pubspec.yaml
menentukan informasi dasar tentang aplikasi Anda, seperti versi aplikasi saat ini, dependensi aplikasi, dan aset yang digunakan oleh aplikasi untuk pengiriman.
- Buka file
main.dart
di direktorilib/
.
- Ganti konten file ini dengan kode berikut:
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
void main() {
final game = FlameGame();
runApp(GameWidget(game: game));
}
- Jalankan kode ini untuk memastikan semuanya berfungsi. Seharusnya jendela baru yang ditampilkan hanya dengan latar belakang hitam kosong. Video game terburuk di dunia kini dirender pada 60 fps!
4. Membuat game
Tingkatkan skala game
Game yang dimainkan dalam dua dimensi (2D) membutuhkan area bermain. Anda akan membuat area dengan dimensi tertentu, lalu menggunakan dimensi tersebut untuk mengukur aspek lain dari game tersebut.
Ada berbagai cara untuk menata koordinat di area bermain. Dengan satu konvensi, Anda dapat mengukur arah dari tengah layar dengan titik asal (0,0)
di bagian tengah layar, nilai positif memindahkan item ke kanan di sepanjang sumbu x dan ke atas di sepanjang sumbu y. Standar ini berlaku untuk sebagian besar game terbaru saat ini, terutama saat game yang melibatkan tiga dimensi.
Konvensi saat game Breakout asli dibuat adalah menentukan origin di sudut kiri atas. Arah x positif tetap sama, tetapi y dibalik. Arah x positif x tepat dan y turun. Agar tetap sesuai dengan era ini, game ini menetapkan titik asal ke sudut kiri atas.
Buat file bernama config.dart
di direktori baru bernama lib/src
. File ini akan mendapatkan lebih banyak konstanta dalam langkah-langkah berikut.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
Game ini akan memiliki lebar 820 piksel dan tinggi 1600 piksel. Area game diskalakan agar sesuai dengan jendela tempat game ditampilkan, tetapi semua komponen yang ditambahkan ke layar sesuai dengan tinggi dan lebar ini.
Membuat PlayArea
Dalam game Breakout, bola memantul dari dinding area bermain. Untuk mengakomodasi tabrakan, Anda memerlukan komponen PlayArea
terlebih dahulu.
- Buat file bernama
play_area.dart
di direktori baru bernamalib/src/components
. - Tambahkan kode berikut ke file ini.
lib/src/components/play_area.dart
import 'dart:async';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
PlayArea()
: super(
paint: Paint()..color = const Color(0xfff2e8cf),
);
@override
FutureOr<void> onLoad() async {
super.onLoad();
size = Vector2(game.width, game.height);
}
}
Jika Flutter memiliki Widget
, Flame memiliki Component
. Jika aplikasi Flutter terdiri dari pembuatan pohon widget, game Flame terdiri dari pemeliharaan pohon komponen.
Di situlah perbedaan menarik antara Flutter dan Flame. Hierarki widget Flutter adalah deskripsi singkat yang dibuat untuk digunakan guna mengupdate lapisan RenderObject
persisten dan yang dapat berubah. Komponen Flame bersifat persisten dan dapat berubah, dengan ekspektasi bahwa developer akan menggunakan komponen ini sebagai bagian dari sistem simulasi.
Komponen Flame dioptimalkan untuk mengekspresikan mekanika game. Codelab ini akan dimulai dengan game loop, yang ditampilkan di langkah berikutnya.
- Untuk mengontrol kekacauan, tambahkan file yang berisi semua komponen dalam project ini. Buat file
components.dart
dilib/src/components
dan tambahkan konten berikut.
lib/src/components/components.dart
export 'play_area.dart';
Perintah export
memainkan peran terbalik dari import
. File ini mendeklarasikan fungsi yang diekspos oleh file ini saat diimpor ke file lain. File ini akan menambah lebih banyak entri saat Anda menambahkan komponen baru pada langkah berikut.
Membuat game Flame
Untuk memadamkan garis coretan merah dari langkah sebelumnya, dapatkan subclass baru untuk FlameGame
Flame.
- Buat file bernama
brick_breaker.dart
dilib/src
, lalu tambahkan kode berikut.
lib/src/brick_breaker.dart
import 'dart:async';
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
}
}
File ini mengoordinasikan tindakan game. Selama pembuatan instance game, kode ini mengonfigurasi game untuk menggunakan rendering resolusi tetap. Game berubah ukurannya untuk mengisi layar yang berisi game dan menambahkan tampilan lebar sesuai kebutuhan.
Anda mengekspos lebar dan tinggi game sehingga komponen turunan, seperti PlayArea
, dapat menyetel sendiri ke ukuran yang sesuai.
Dalam metode yang diganti onLoad
, kode Anda melakukan dua tindakan.
- Mengonfigurasi kiri atas sebagai anchor untuk jendela bidik. Secara default, jendela bidik menggunakan bagian tengah area sebagai anchor untuk
(0,0)
. - Menambahkan
PlayArea
keworld
. Dunia mewakili dunia game. AI generatif memproyeksikan semua turunannya melalui transformasi tampilanCameraComponent
.
Tampilkan game di layar
Untuk melihat semua perubahan yang telah Anda buat di langkah ini, perbarui file lib/main.dart
Anda dengan perubahan berikut.
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'src/brick_breaker.dart'; // Add this import
void main() {
final game = BrickBreaker(); // Modify this line
runApp(GameWidget(game: game));
}
Setelah melakukan perubahan ini, mulai ulang game. Game akan terlihat seperti gambar berikut.
Di langkah berikutnya, Anda akan menambahkan bola ke dunia, dan menggerakkannya!
5. Pamerkan bola
Membuat komponen ball
Menempatkan bola yang bergerak di layar melibatkan pembuatan komponen lain dan menambahkannya ke dunia game.
- Edit konten file
lib/src/config.dart
sebagai berikut.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02; // Add this constant
Pola desain yang menentukan konstanta bernama sebagai nilai turunan akan ditampilkan berkali-kali dalam codelab ini. Tindakan ini memungkinkan Anda mengubah gameWidth
dan gameHeight
level teratas untuk mempelajari tampilan dan nuansa game sebagai hasilnya.
- Buat komponen
Ball
dalam file bernamaball.dart
dilib/src/components
.
lib/src/components/ball.dart
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
class Ball extends CircleComponent {
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill);
final Vector2 velocity;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
}
Sebelumnya, Anda menentukan PlayArea
menggunakan RectangleComponent
, sehingga wajar jika ada lebih banyak bentuk. CircleComponent
, seperti RectangleComponent
, berasal dari PositionedComponent
, sehingga Anda dapat memosisikan bola di layar. Lebih penting lagi, posisinya dapat diperbarui.
Komponen ini memperkenalkan konsep velocity
, atau perubahan posisi dari waktu ke waktu. Kecepatan adalah objek Vector2
karena kecepatan adalah kecepatan dan arah. Untuk memperbarui posisi, ganti metode update
, yang dipanggil oleh game engine untuk setiap frame. dt
adalah durasi antara frame sebelumnya dan frame ini. Hal ini memungkinkan Anda untuk beradaptasi dengan faktor-faktor seperti kecepatan frame yang berbeda (60 hz atau 120 hz) atau frame panjang karena komputasi yang berlebihan.
Perhatikan update position += velocity * dt
. Ini adalah cara Anda mengimplementasikan pembaruan simulasi gerak diskret dari waktu ke waktu.
- Untuk menyertakan komponen
Ball
dalam daftar komponen, edit filelib/src/components/components.dart
sebagai berikut.
lib/src/components/components.dart
export 'ball.dart'; // Add this export
export 'play_area.dart';
Menambahkan bola ke dunia
Anda hebat. Mari kita letakkan di dunia digital dan siapkan untuk bergerak di sekitar area bermain.
Edit file lib/src/brick_breaker.dart
sebagai berikut.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math; // Add this import
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random(); // Add this variable
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(Ball( // Add from here...
radius: ballRadius,
position: size / 2,
velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
.normalized()
..scale(height / 4)));
debugMode = true; // To here.
}
}
Perubahan ini akan menambahkan komponen Ball
ke world
. Untuk menetapkan position
bola ke bagian tengah area tampilan, kode pertama-tama akan membagi ukuran game menjadi separuh, karena Vector2
memiliki overload operator (*
dan /
) untuk menskalakan Vector2
berdasarkan nilai skalar.
Menyetel velocity
bola melibatkan lebih banyak kompleksitas. Tujuannya adalah menggerakkan bola ke bawah layar dalam arah acak dengan kecepatan yang wajar. Panggilan ke metode normalized
membuat objek Vector2
yang disetel ke arah yang sama dengan Vector2
asli, tetapi diperkecil hingga jarak 1. Hal ini menjaga kecepatan bola tetap konsisten ke mana pun arah bola. Kecepatan bola kemudian ditingkatkan skalanya menjadi 1/4 dari tinggi permainan.
Mendapatkan berbagai nilai ini dengan tepat melibatkan beberapa iterasi, yang juga dikenal sebagai pengujian permainan dalam industri.
Baris terakhir mengaktifkan tampilan proses debug, yang menambahkan informasi tambahan ke tampilan untuk membantu proses debug.
Saat sekarang Anda menjalankan game, game tersebut akan terlihat seperti tampilan berikut.
Komponen PlayArea
dan komponen Ball
memiliki informasi proses debug, tetapi latar belakang akan memangkas angka PlayArea
. Alasan semua informasi proses debug ditampilkan adalah karena Anda mengaktifkan debugMode
untuk seluruh hierarki komponen. Anda juga dapat mengaktifkan proses debug hanya untuk komponen yang dipilih, jika hal itu lebih berguna.
Jika Anda memulai ulang game beberapa kali, Anda mungkin melihat bahwa bola tidak berinteraksi dengan dinding seperti yang diharapkan. Untuk mencapai efek tersebut, Anda perlu menambahkan deteksi tabrakan, yang akan Anda lakukan di langkah selanjutnya.
6. Memantul
Tambahkan deteksi tabrakan
Deteksi tabrakan menambahkan perilaku saat game Anda mengenali saat dua objek bersentuhan satu sama lain.
Untuk menambahkan deteksi tabrakan ke game, tambahkan mixin HasCollisionDetection
ke game BrickBreaker
seperti yang ditunjukkan pada kode berikut.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame with HasCollisionDetection { // Modify this line
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(Ball(
radius: ballRadius,
position: size / 2,
velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
.normalized()
..scale(height / 4)));
debugMode = true;
}
}
Fungsi ini melacak hitbox komponen dan memicu callback tabrakan di setiap tick game.
Untuk mulai mengisi hitbox game, ubah komponen PlayArea
seperti yang ditunjukkan di bawah ini.
lib/src/components/play_area.dart
import 'dart:async';
import 'package:flame/collisions.dart'; // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
PlayArea()
: super(
paint: Paint()..color = const Color(0xfff2e8cf),
children: [RectangleHitbox()], // Add this parameter
);
@override
FutureOr<void> onLoad() async {
super.onLoad();
size = Vector2(game.width, game.height);
}
}
Menambahkan komponen RectangleHitbox
sebagai turunan dari RectangleComponent
akan membuat kotak hit untuk deteksi tabrakan yang sesuai dengan ukuran komponen induk. Ada konstruktor factory untuk RectangleHitbox
yang disebut relative
ketika Anda menginginkan hitbox yang lebih kecil, atau lebih besar, dari komponen induk.
Pantulkan bola
Sejauh ini, menambahkan deteksi tabrakan tidak memberikan perbedaan pada gameplay-nya. Kolom ini akan berubah setelah Anda mengubah komponen Ball
. Perilaku bolalah yang harus diubah saat bertabrakan dengan PlayArea
.
Ubah komponen Ball
sebagai berikut.
lib/src/components/ball.dart
import 'package:flame/collisions.dart'; // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart'; // And this import
import 'play_area.dart'; // And this one too
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> { // Add these mixins
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()]); // Add this parameter
final Vector2 velocity;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override // Add from here...
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
removeFromParent();
}
} else {
debugPrint('collision with $other');
}
} // To here.
}
Contoh ini membuat perubahan besar dengan penambahan callback onCollisionStart
. Sistem deteksi tabrakan yang ditambahkan ke BrickBreaker
pada contoh sebelumnya akan memanggil callback ini.
Pertama, kode akan menguji apakah Ball
bertabrakan dengan PlayArea
. Hal ini tampak berlebihan untuk saat ini, karena tidak ada komponen lain di dunia game. Itu akan berubah di langkah berikutnya, saat Anda menambahkan pemukul ke dunia. Kemudian, model ini juga menambahkan kondisi else
untuk ditangani saat bola bertabrakan dengan benda yang bukan pemukul. Pengingat untuk menerapkan logika yang tersisa, jika Anda mau.
Saat bertabrakan dengan dinding bawah, bola akan menghilang dari permukaan permainan dan tetap terlihat. Anda menangani artefak ini di langkah mendatang, menggunakan kekuatan Flame's Effects.
Sekarang setelah bolamu bertabrakan dengan dinding game, akan sangat membantu jika pemain memukul bola dengan...
7. Memukul bola
Membuat tongkat pemukul
Untuk menambahkan pemukul agar bola tetap berada di dalam permainan,
- Sisipkan beberapa konstanta dalam file
lib/src/config.dart
sebagai berikut.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2; // Add from here...
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05; // To here.
Konstanta batHeight
dan batWidth
dapat dipahami dengan jelas. Di sisi lain, konstanta batStep
memerlukan sentuhan penjelasan. Untuk berinteraksi dengan bola dalam game ini, pemain dapat menarik pemukul dengan mouse atau jari, bergantung pada platformnya, atau menggunakan keyboard. Konstanta batStep
mengonfigurasi seberapa jauh bat akan ditekan untuk setiap tombol panah kiri atau kanan.
- Tentukan class komponen
Bat
seperti berikut.
lib/src/components/bat.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
class Bat extends PositionComponent
with DragCallbacks, HasGameReference<BrickBreaker> {
Bat({
required this.cornerRadius,
required super.position,
required super.size,
}) : super(
anchor: Anchor.center,
children: [RectangleHitbox()],
);
final Radius cornerRadius;
final _paint = Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill;
@override
void render(Canvas canvas) {
super.render(canvas);
canvas.drawRRect(
RRect.fromRectAndRadius(
Offset.zero & size.toSize(),
cornerRadius,
),
_paint);
}
@override
void onDragUpdate(DragUpdateEvent event) {
super.onDragUpdate(event);
position.x = (position.x + event.localDelta.x).clamp(0, game.width);
}
void moveBy(double dx) {
add(MoveToEffect(
Vector2((position.x + dx).clamp(0, game.width), position.y),
EffectController(duration: 0.1),
));
}
}
Komponen ini memperkenalkan beberapa kemampuan baru.
Pertama, komponen Bat adalah PositionComponent
, bukan RectangleComponent
atau CircleComponent
. Artinya, kode ini harus merender Bat
di layar. Untuk melakukannya, kode ini mengganti callback render
.
Perhatikan panggilan canvas.drawRRect
(gambar persegi panjang bulat), dan Anda mungkin bertanya pada diri sendiri, "di mana persegi panjang itu?" Offset.zero & size.toSize()
memanfaatkan overload operator &
di class dart:ui
Offset
yang membuat Rect
. Singkatan ini mungkin membingungkan Anda pada awalnya, tetapi Anda akan sering melihatnya di kode Flutter dan Flame tingkat rendah.
Kedua, komponen Bat
ini dapat ditarik menggunakan jari atau mouse, bergantung pada platformnya. Untuk menerapkan fungsi ini, tambahkan mixin DragCallbacks
dan ganti peristiwa onDragUpdate
.
Terakhir, komponen Bat
harus merespons kontrol keyboard. Fungsi moveBy
memungkinkan kode lain memberi tahu bat ini untuk bergerak ke kiri atau kanan sejauh sejumlah piksel virtual tertentu. Fungsi ini memperkenalkan kemampuan baru game engine Flame: Effect
. Dengan menambahkan objek MoveToEffect
sebagai turunan dari komponen ini, pemain akan melihat bat dianimasikan ke posisi baru. Ada koleksi Effect
yang tersedia di Flame untuk melakukan berbagai efek.
Argumen konstruktor Effect menyertakan referensi ke pengambil game
. Inilah alasan Anda menyertakan mixin HasGameReference
di class ini. Mixin ini menambahkan pengakses game
dengan jenis yang aman ke komponen ini untuk mengakses instance BrickBreaker
di bagian atas hierarki komponen.
- Agar
Bat
tersedia untukBrickBreaker
, update filelib/src/components/components.dart
seperti berikut.
lib/src/components/components.dart
export 'ball.dart';
export 'bat.dart'; // Add this export
export 'play_area.dart';
Tambahkan tongkat pemukul kepada dunia
Untuk menambahkan komponen Bat
ke dunia game, update BrickBreaker
seperti berikut.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart'; // Add this import
import 'package:flame/game.dart';
import 'package:flutter/material.dart'; // And this import
import 'package:flutter/services.dart'; // And this
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents { // Modify this line
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(Ball(
radius: ballRadius,
position: size / 2,
velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
.normalized()
..scale(height / 4)));
world.add(Bat( // Add from here...
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95))); // To here
debugMode = true;
}
@override // Add from here...
KeyEventResult onKeyEvent(
KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
}
return KeyEventResult.handled;
} // To here
}
Penambahan mixin KeyboardEvents
dan metode onKeyEvent
yang diganti akan menangani input keyboard. Ingat kembali kode yang Anda tambahkan sebelumnya untuk menggerakkan pemukul dengan jumlah langkah yang sesuai.
Sisa potongan kode yang ditambahkan menambahkan pemukul ke dunia game pada posisi yang sesuai dan dengan proporsi yang tepat. Dengan mengekspos semua setelan tersebut dalam file ini, Anda akan lebih mudah untuk menyesuaikan ukuran relatif pemukul dan bola untuk mendapatkan nuansa yang tepat untuk game.
Jika memainkan game pada tahap ini, Anda akan melihat bahwa Anda dapat menggerakkan pemukul untuk mencegat bola, tetapi tidak mendapatkan respons yang terlihat, selain logging debug yang Anda tinggalkan dalam kode deteksi tabrakan Ball
.
Saatnya memperbaikinya sekarang. Edit komponen Ball
sebagai berikut.
lib/src/components/ball.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart'; // Add this import
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import 'bat.dart'; // And this import
import 'play_area.dart';
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()]);
final Vector2 velocity;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
add(RemoveEffect( // Modify from here...
delay: 0.35,
));
}
} else if (other is Bat) {
velocity.y = -velocity.y;
velocity.x = velocity.x +
(position.x - other.position.x) / other.size.x * game.width * 0.3;
} else { // To here.
debugPrint('collision with $other');
}
}
}
Perubahan kode ini memperbaiki dua masalah terpisah.
Pertama, fitur ini memperbaiki bola yang muncul dari luar saat menyentuh bagian bawah layar. Untuk memperbaiki masalah ini, Anda mengganti panggilan removeFromParent
dengan RemoveEffect
. RemoveEffect
membuang bola dari dunia game setelah membiarkan bola keluar dari area bermain yang dapat dilihat.
Kedua, perubahan ini memperbaiki penanganan pukulan antara pemukul dan bola. Kode penanganan ini sangat membantu pemain. Selama pemain menyentuh bola dengan pemukul, bola akan kembali ke bagian atas layar. Jika hal ini terasa terlalu memaafkan dan Anda menginginkan sesuatu yang lebih realistis, ubah penanganan ini agar lebih sesuai dengan perasaan yang Anda inginkan untuk game.
Sebaiknya tunjukkan kompleksitas update velocity
. Hal ini tidak hanya membalikkan komponen y
kecepatan, seperti yang dilakukan untuk tabrakan dinding. Kode ini juga mengupdate komponen x
dengan cara yang bergantung pada posisi relatif pemukul dan bola pada saat kontak. Hal ini memberi pemain kontrol lebih besar atas apa yang dilakukan bola, tetapi dengan cara yang tepat, cara ini tidak dikomunikasikan kepada pemain dengan cara apa pun kecuali melalui permainan.
Sekarang setelah kamu memiliki tongkat pemukul untuk memukul bola, sebaiknya kita pecahkan beberapa batu bata untuk mematahkan bola!
8. Menghancurkan tembok
Membuat bata
Untuk menambahkan batu bata ke dalam game,
- Sisipkan beberapa konstanta dalam file
lib/src/config.dart
sebagai berikut.
lib/src/config.dart
import 'package:flutter/material.dart'; // Add this import
const brickColors = [ // Add this const
Color(0xfff94144),
Color(0xfff3722c),
Color(0xfff8961e),
Color(0xfff9844a),
Color(0xfff9c74f),
Color(0xff90be6d),
Color(0xff43aa8b),
Color(0xff4d908e),
Color(0xff277da1),
Color(0xff577590),
];
const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2;
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05;
const brickGutter = gameWidth * 0.015; // Add from here...
final brickWidth =
(gameWidth - (brickGutter * (brickColors.length + 1)))
/ brickColors.length;
const brickHeight = gameHeight * 0.03;
const difficultyModifier = 1.03; // To here.
- Sisipkan komponen
Brick
sebagai berikut.
lib/src/components/brick.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';
class Brick extends RectangleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Brick({required super.position, required Color color})
: super(
size: Vector2(brickWidth, brickHeight),
anchor: Anchor.center,
paint: Paint()
..color = color
..style = PaintingStyle.fill,
children: [RectangleHitbox()],
);
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
removeFromParent();
if (game.world.children.query<Brick>().length == 1) {
game.world.removeAll(game.world.children.query<Ball>());
game.world.removeAll(game.world.children.query<Bat>());
}
}
}
Sekarang, sebagian besar kode ini seharusnya sudah familier. Kode ini menggunakan RectangleComponent
, dengan deteksi tabrakan dan referensi jenis aman ke game BrickBreaker
di bagian atas hierarki komponen.
Konsep baru yang paling penting yang diperkenalkan oleh kode ini adalah bagaimana pemain mencapai kondisi menang. Pemeriksaan kondisi menang mengajukan kueri ke seluruh dunia untuk menemukan batu bata, dan mengonfirmasi bahwa hanya satu batu bata yang tersisa. Ini mungkin sedikit membingungkan, karena baris sebelumnya menghapus bata ini dari induknya.
Poin penting yang perlu dipahami adalah bahwa penghapusan komponen adalah perintah dalam antrean. Ini menghapus brick setelah kode ini berjalan, tetapi sebelum tick berikutnya dalam dunia game.
Agar komponen Brick
dapat diakses oleh BrickBreaker
, edit lib/src/components/components.dart
sebagai berikut.
lib/src/components/components.dart
export 'ball.dart';
export 'bat.dart';
export 'brick.dart'; // Add this export
export 'play_area.dart';
Percantik dunia bata
Update komponen Ball
seperti berikut.
lib/src/components/ball.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart'; // Add this import
import 'play_area.dart';
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Ball({
required this.velocity,
required super.position,
required double radius,
required this.difficultyModifier, // Add this parameter
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()]);
final Vector2 velocity;
final double difficultyModifier; // Add this member
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
add(RemoveEffect(
delay: 0.35,
));
}
} else if (other is Bat) {
velocity.y = -velocity.y;
velocity.x = velocity.x +
(position.x - other.position.x) / other.size.x * game.width * 0.3;
} else if (other is Brick) { // Modify from here...
if (position.y < other.position.y - other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.y > other.position.y + other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.x < other.position.x) {
velocity.x = -velocity.x;
} else if (position.x > other.position.x) {
velocity.x = -velocity.x;
}
velocity.setFrom(velocity * difficultyModifier); // To here.
}
}
}
Game ini memperkenalkan satu-satunya aspek baru, yaitu pengubah tingkat kesulitan yang meningkatkan kecepatan bola setelah setiap tabrakan batu bata. Parameter yang dapat disesuaikan ini perlu diuji coba untuk menemukan kurva kesulitan yang tepat dan sesuai untuk game Anda.
Edit game BrickBreaker
sebagai berikut.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(Ball(
difficultyModifier: difficultyModifier, // Add this argument
radius: ballRadius,
position: size / 2,
velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
.normalized()
..scale(height / 4)));
world.add(Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95)));
await world.addAll([ // Add from here...
for (var i = 0; i < brickColors.length; i++)
for (var j = 1; j <= 5; j++)
Brick(
position: Vector2(
(i + 0.5) * brickWidth + (i + 1) * brickGutter,
(j + 2.0) * brickHeight + j * brickGutter,
),
color: brickColors[i],
),
]); // To here.
debugMode = true;
}
@override
KeyEventResult onKeyEvent(
KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
}
return KeyEventResult.handled;
}
}
Jika Anda menjalankan game seperti saat ini, game akan menampilkan semua mekanisme game utama. Anda dapat menonaktifkan {i>debugging<i} dan menyebutnya selesai, tetapi ada sesuatu yang hilang.
Bagaimana dengan layar sambutan, permainan di atas layar, dan mungkin skor? Flutter dapat menambahkan fitur-fitur ini ke game, dan di situlah Anda akan mengalihkan perhatian Anda selanjutnya.
9. Menangkan game
Menambahkan status pemutaran
Di langkah ini, Anda menyematkan game Flame di dalam wrapper Flutter, lalu menambahkan overlay Flutter untuk layar sambutan, game selesai, dan menang.
Pertama, Anda akan memodifikasi file game dan komponen untuk menerapkan status pemutaran yang mencerminkan apakah akan menampilkan overlay, dan jika demikian, yang mana.
- Ubah game
BrickBreaker
sebagai berikut.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'components/components.dart';
import 'config.dart';
enum PlayState { welcome, playing, gameOver, won } // Add this enumeration
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents, TapDetector { // Modify this line
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
late PlayState _playState; // Add from here...
PlayState get playState => _playState;
set playState(PlayState playState) {
_playState = playState;
switch (playState) {
case PlayState.welcome:
case PlayState.gameOver:
case PlayState.won:
overlays.add(playState.name);
case PlayState.playing:
overlays.remove(PlayState.welcome.name);
overlays.remove(PlayState.gameOver.name);
overlays.remove(PlayState.won.name);
}
} // To here.
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
playState = PlayState.welcome; // Add from here...
}
void startGame() {
if (playState == PlayState.playing) return;
world.removeAll(world.children.query<Ball>());
world.removeAll(world.children.query<Bat>());
world.removeAll(world.children.query<Brick>());
playState = PlayState.playing; // To here.
world.add(Ball(
difficultyModifier: difficultyModifier,
radius: ballRadius,
position: size / 2,
velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
.normalized()
..scale(height / 4)));
world.add(Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95)));
world.addAll([ // Drop the await
for (var i = 0; i < brickColors.length; i++)
for (var j = 1; j <= 5; j++)
Brick(
position: Vector2(
(i + 0.5) * brickWidth + (i + 1) * brickGutter,
(j + 2.0) * brickHeight + j * brickGutter,
),
color: brickColors[i],
),
]);
} // Drop the debugMode
@override // Add from here...
void onTap() {
super.onTap();
startGame();
} // To here.
@override
KeyEventResult onKeyEvent(
KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
case LogicalKeyboardKey.space: // Add from here...
case LogicalKeyboardKey.enter:
startGame(); // To here.
}
return KeyEventResult.handled;
}
@override
Color backgroundColor() => const Color(0xfff2e8cf); // Add this override
}
Kode ini banyak mengubah game BrickBreaker
. Menambahkan enumerasi playState
membutuhkan banyak pekerjaan. Kategori ini merekam lokasi pemain saat masuk, bermain, dan kalah atau memenangkan game. Di bagian atas file, Anda menentukan enumerasi, lalu membuat instance sebagai status tersembunyi dengan pengambil dan penyetel yang cocok. Pengambil dan penyetel ini memungkinkan modifikasi overlay saat berbagai bagian game memicu transisi status pemutaran.
Selanjutnya, Anda membagi kode di onLoad
menjadi onLoad dan metode startGame
baru. Sebelum perubahan ini, Anda hanya dapat memulai game baru dengan memulai ulang game. Dengan tambahan baru ini, pemain kini dapat memulai game baru tanpa tindakan drastis seperti itu.
Untuk mengizinkan pemain memulai game baru, Anda mengonfigurasi dua pengendali baru untuk game. Anda telah menambahkan pengendali ketuk dan memperluas pengendali keyboard sehingga pengguna dapat memulai game baru dalam berbagai modalitas. Dengan model status pemutaran, sebaiknya update komponen untuk memicu transisi status pemutaran saat pemain menang atau kalah.
- Ubah komponen
Ball
sebagai berikut.
lib/src/components/ball.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart';
import 'play_area.dart';
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Ball({
required this.velocity,
required super.position,
required double radius,
required this.difficultyModifier,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()]);
final Vector2 velocity;
final double difficultyModifier;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
add(RemoveEffect(
delay: 0.35,
onComplete: () { // Modify from here
game.playState = PlayState.gameOver;
})); // To here.
}
} else if (other is Bat) {
velocity.y = -velocity.y;
velocity.x = velocity.x +
(position.x - other.position.x) / other.size.x * game.width * 0.3;
} else if (other is Brick) {
if (position.y < other.position.y - other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.y > other.position.y + other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.x < other.position.x) {
velocity.x = -velocity.x;
} else if (position.x > other.position.x) {
velocity.x = -velocity.x;
}
velocity.setFrom(velocity * difficultyModifier);
}
}
}
Perubahan kecil ini menambahkan callback onComplete
ke RemoveEffect
yang memicu status pemutaran gameOver
. Game ini harus terasa tepat jika pemain membiarkan bolanya terlepas dari bagian bawah layar.
- Edit komponen
Brick
sebagai berikut.
lib/src/components/brick.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';
class Brick extends RectangleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Brick({required super.position, required Color color})
: super(
size: Vector2(brickWidth, brickHeight),
anchor: Anchor.center,
paint: Paint()
..color = color
..style = PaintingStyle.fill,
children: [RectangleHitbox()],
);
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
removeFromParent();
if (game.world.children.query<Brick>().length == 1) {
game.playState = PlayState.won; // Add this line
game.world.removeAll(game.world.children.query<Ball>());
game.world.removeAll(game.world.children.query<Bat>());
}
}
}
Di sisi lain, jika pemain dapat menghancurkan semua batu bata, mereka telah memperoleh "game yang dimenangkan" layar. Bagus, pemain bagus!
Menambahkan wrapper Flutter
Untuk menyediakan tempat guna menyematkan game dan menambahkan overlay status pemutaran, tambahkan shell Flutter.
- Buat direktori
widgets
di bagianlib/src
. - Tambahkan file
game_app.dart
dan sisipkan konten berikut ke file tersebut.
lib/src/widgets/game_app.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../brick_breaker.dart';
import '../config.dart';
class GameApp extends StatelessWidget {
const GameApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
textTheme: GoogleFonts.pressStart2pTextTheme().apply(
bodyColor: const Color(0xff184e77),
displayColor: const Color(0xff184e77),
),
),
home: Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xffa9d6e5),
Color(0xfff2e8cf),
],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: FittedBox(
child: SizedBox(
width: gameWidth,
height: gameHeight,
child: GameWidget.controlled(
gameFactory: BrickBreaker.new,
overlayBuilderMap: {
PlayState.welcome.name: (context, game) => Center(
child: Text(
'TAP TO PLAY',
style:
Theme.of(context).textTheme.headlineLarge,
),
),
PlayState.gameOver.name: (context, game) => Center(
child: Text(
'G A M E O V E R',
style:
Theme.of(context).textTheme.headlineLarge,
),
),
PlayState.won.name: (context, game) => Center(
child: Text(
'Y O U W O N ! ! !',
style:
Theme.of(context).textTheme.headlineLarge,
),
),
},
),
),
),
),
),
),
),
),
);
}
}
Sebagian besar konten dalam file ini mengikuti build hierarki widget Flutter standar. Bagian khusus untuk Flame meliputi penggunaan GameWidget.controlled
untuk membuat dan mengelola instance game BrickBreaker
dan argumen overlayBuilderMap
baru ke GameWidget
.
Kunci overlayBuilderMap
ini harus selaras dengan overlay yang ditambahkan atau dihapus oleh penyetel playState
di BrickBreaker
. Mencoba menetapkan overlay yang tidak ada dalam peta ini akan memunculkan wajah-wajah tidak senang di sekeliling.
- Untuk mendapatkan fungsi baru ini di layar, ganti file
lib/main.dart
dengan konten berikut.
lib/main.dart
import 'package:flutter/material.dart';
import 'src/widgets/game_app.dart';
void main() {
runApp(const GameApp());
}
Jika Anda menjalankan kode ini di iOS, Linux, Windows, atau web, output yang diinginkan akan ditampilkan dalam game. Jika menargetkan macOS atau Android, Anda memerlukan satu penyesuaian terakhir untuk memungkinkan google_fonts
ditampilkan.
Mengaktifkan akses font
Menambahkan izin internet untuk Android
Untuk Android, Anda harus menambahkan izin Internet. Edit AndroidManifest.xml
Anda sebagai berikut.
android/app/src/main/AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Add the following line -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="brick_breaker"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
Mengedit file hak untuk macOS
Untuk macOS, Anda memiliki dua file untuk diedit.
- Edit file
DebugProfile.entitlements
agar cocok dengan kode berikut.
macos/Runner/DebugProfile.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<!-- Add from here... -->
<key>com.apple.security.network.client</key>
<true/>
<!-- to here. -->
</dict>
</plist>
- Edit file
Release.entitlements
agar cocok dengan kode berikut
macos/Runner/Release.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- Add from here... -->
<key>com.apple.security.network.client</key>
<true/>
<!-- to here. -->
</dict>
</plist>
Menjalankan game ini apa adanya akan menampilkan layar sambutan dan layar game berakhir atau menang di semua platform. Layar tersebut mungkin sedikit sederhana dan akan lebih baik jika memiliki skor. Jadi, tebak apa yang akan Anda lakukan pada langkah selanjutnya!
10. Simpan skor
Tambahkan skor ke pertandingan
Pada langkah ini, Anda akan mengekspos skor game ke konteks Flutter di sekitarnya. Pada langkah ini, Anda mengekspos status dari game Flame ke pengelolaan status Flutter di sekitarnya. Dengan begitu, kode game dapat memperbarui skor setiap kali pemain memecahkan batu bata.
- Ubah game
BrickBreaker
sebagai berikut.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'components/components.dart';
import 'config.dart';
enum PlayState { welcome, playing, gameOver, won }
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents, TapDetector {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final ValueNotifier<int> score = ValueNotifier(0); // Add this line
final rand = math.Random();
double get width => size.x;
double get height => size.y;
late PlayState _playState;
PlayState get playState => _playState;
set playState(PlayState playState) {
_playState = playState;
switch (playState) {
case PlayState.welcome:
case PlayState.gameOver:
case PlayState.won:
overlays.add(playState.name);
case PlayState.playing:
overlays.remove(PlayState.welcome.name);
overlays.remove(PlayState.gameOver.name);
overlays.remove(PlayState.won.name);
}
}
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
playState = PlayState.welcome;
}
void startGame() {
if (playState == PlayState.playing) return;
world.removeAll(world.children.query<Ball>());
world.removeAll(world.children.query<Bat>());
world.removeAll(world.children.query<Brick>());
playState = PlayState.playing;
score.value = 0; // Add this line
world.add(Ball(
difficultyModifier: difficultyModifier,
radius: ballRadius,
position: size / 2,
velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
.normalized()
..scale(height / 4)));
world.add(Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95)));
world.addAll([
for (var i = 0; i < brickColors.length; i++)
for (var j = 1; j <= 5; j++)
Brick(
position: Vector2(
(i + 0.5) * brickWidth + (i + 1) * brickGutter,
(j + 2.0) * brickHeight + j * brickGutter,
),
color: brickColors[i],
),
]);
}
@override
void onTap() {
super.onTap();
startGame();
}
@override
KeyEventResult onKeyEvent(
KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
case LogicalKeyboardKey.space:
case LogicalKeyboardKey.enter:
startGame();
}
return KeyEventResult.handled;
}
@override
Color backgroundColor() => const Color(0xfff2e8cf);
}
Dengan menambahkan score
ke game, Anda mengaitkan status game ke pengelolaan status Flutter.
- Ubah class
Brick
untuk menambahkan poin ke skor saat pemain memecahkan batu bata.
lib/src/components/brick.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';
class Brick extends RectangleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Brick({required super.position, required Color color})
: super(
size: Vector2(brickWidth, brickHeight),
anchor: Anchor.center,
paint: Paint()
..color = color
..style = PaintingStyle.fill,
children: [RectangleHitbox()],
);
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
removeFromParent();
game.score.value++; // Add this line
if (game.world.children.query<Brick>().length == 1) {
game.playState = PlayState.won;
game.world.removeAll(game.world.children.query<Ball>());
game.world.removeAll(game.world.children.query<Bat>());
}
}
}
Buat game yang menarik
Setelah Anda dapat mempertahankan skor di Flutter, inilah saatnya untuk mengumpulkan widget agar terlihat bagus.
- Buat
score_card.dart
dilib/src/widgets
dan tambahkan hal berikut.
lib/src/widgets/score_card.dart
import 'package:flutter/material.dart';
class ScoreCard extends StatelessWidget {
const ScoreCard({
super.key,
required this.score,
});
final ValueNotifier<int> score;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<int>(
valueListenable: score,
builder: (context, score, child) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 12, 18),
child: Text(
'Score: $score'.toUpperCase(),
style: Theme.of(context).textTheme.titleLarge!,
),
);
},
);
}
}
- Buat
overlay_screen.dart
dilib/src/widgets
dan tambahkan kode berikut.
Ini akan menambahkan lebih banyak polesan pada overlay menggunakan kecanggihan paket flutter_animate
untuk menambahkan beberapa gerakan dan gaya pada layar overlay.
lib/src/widgets/overlay_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
class OverlayScreen extends StatelessWidget {
const OverlayScreen({
super.key,
required this.title,
required this.subtitle,
});
final String title;
final String subtitle;
@override
Widget build(BuildContext context) {
return Container(
alignment: const Alignment(0, -0.15),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: Theme.of(context).textTheme.headlineLarge,
).animate().slideY(duration: 750.ms, begin: -3, end: 0),
const SizedBox(height: 16),
Text(
subtitle,
style: Theme.of(context).textTheme.headlineSmall,
)
.animate(onPlay: (controller) => controller.repeat())
.fadeIn(duration: 1.seconds)
.then()
.fadeOut(duration: 1.seconds),
],
),
);
}
}
Untuk memahami kecanggihan flutter_animate
secara lebih mendalam, lihat codelab Membuat UI generasi berikutnya di Flutter.
Kode ini banyak berubah dalam komponen GameApp
. Pertama, untuk mengaktifkan ScoreCard
agar dapat mengakses score
, konversikan dari StatelessWidget
menjadi StatefulWidget
. Penambahan kartu skor memerlukan penambahan Column
untuk menumpuk skor di atas game.
Kedua, untuk meningkatkan pengalaman sambutan, game selesai, dan kemenangan, tambahkan widget OverlayScreen
baru.
lib/src/widgets/game_app.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'overlay_screen.dart'; // Add this import
import 'score_card.dart'; // And this one too
class GameApp extends StatefulWidget { // Modify this line
const GameApp({super.key});
@override // Add from here...
State<GameApp> createState() => _GameAppState();
}
class _GameAppState extends State<GameApp> {
late final BrickBreaker game;
@override
void initState() {
super.initState();
game = BrickBreaker();
} // To here.
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
textTheme: GoogleFonts.pressStart2pTextTheme().apply(
bodyColor: const Color(0xff184e77),
displayColor: const Color(0xff184e77),
),
),
home: Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xffa9d6e5),
Color(0xfff2e8cf),
],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Column( // Modify from here...
children: [
ScoreCard(score: game.score),
Expanded(
child: FittedBox(
child: SizedBox(
width: gameWidth,
height: gameHeight,
child: GameWidget(
game: game,
overlayBuilderMap: {
PlayState.welcome.name: (context, game) =>
const OverlayScreen(
title: 'TAP TO PLAY',
subtitle: 'Use arrow keys or swipe',
),
PlayState.gameOver.name: (context, game) =>
const OverlayScreen(
title: 'G A M E O V E R',
subtitle: 'Tap to Play Again',
),
PlayState.won.name: (context, game) =>
const OverlayScreen(
title: 'Y O U W O N ! ! !',
subtitle: 'Tap to Play Again',
),
},
),
),
),
),
],
), // To here.
),
),
),
),
),
);
}
}
Dengan semua itu, sekarang Anda seharusnya dapat menjalankan game ini di salah satu dari keenam platform target Flutter. Game akan terlihat seperti berikut.
11. Selamat
Selamat, Anda berhasil membangun game dengan Flutter dan Flame!
Anda telah membangun game menggunakan game engine Flame 2D dan menyematkannya di 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 dengan baik.
Apa langkah selanjutnya?
Lihat beberapa codelab ini...
- Membangun UI generasi berikutnya di Flutter
- Membuat tampilan aplikasi Flutter menjadi lebih menarik
- Menambahkan pembelian dalam aplikasi ke aplikasi Flutter Anda