Tampilan
Bab 50 — Studi Kasus: Asteroids
Bab ini menggabungkan seluruh Bagian VI menjadi satu game yang utuh. Tetapi fokusnya bukan sekadar "membuat Asteroids" — melainkan bagaimana menyusun arsitektur game agar tetap bisa dikelola ketika ia bertambah besar.
Dua pola desain menjadi tulang punggungnya: Factory Method untuk membuat objek, dan Command Pattern untuk menangani masukan. Ditambah serialisasi JSON untuk mendefinisikan level tanpa menyentuh kode.
Arsitektur tingkat atas
┌──────────────────────────────────────────────────────────┐
│ AsteroidsGame │
│ (FlameGame — mengoordinasi) │
└───────┬──────────────────┬──────────────────┬────────────┘
│ │ │
┌───────▼──────┐ ┌────────▼────────┐ ┌──────▼──────────┐
│ CONTROLLER │ │ FACTORY │ │ LEVEL DATA │
│ Mengelola │ │ Membuat objek │ │ JSON → objek │
│ alur & level│ │ game │ │ │
└───────┬──────┘ └────────┬────────┘ └──────┬──────────┘
│ │ │
└──────────────────┼──────────────────┘
▼
┌──────────────────────────────────────────────────────────┐
│ KOMPONEN GAME │
│ Kapal · Asteroid · Peluru · UFO · Partikel │
└──────────────────────────────────────────────────────────┘
▲
│ command
┌──────────────────────────┴───────────────────────────────┐
│ INPUT LAYER │
│ Joystick · Tombol · Papan ketik → Command │
└──────────────────────────────────────────────────────────┘Pemisahan ini yang membuat game bisa tumbuh. Menambahkan jenis asteroid baru menyentuh factory; menambahkan kendali baru menyentuh lapisan input; menambahkan level menyentuh berkas JSON. Ketiganya tidak saling mengganggu.
Factory Method Pattern
Masalah yang dipecahkannya: membuat objek tanpa kode pemanggil perlu tahu kelas konkretnya.
Tanpa factory, kode untuk memunculkan asteroid tersebar dan berulang:
dart
// ❌ Tersebar di mana-mana, sulit diubah
if (ukuran == 'besar') {
add(AsteroidBesar(posisi: p, kecepatan: v));
} else if (ukuran == 'sedang') {
add(AsteroidSedang(posisi: p, kecepatan: v));
} else {
add(AsteroidKecil(posisi: p, kecepatan: v));
}Dengan factory, seluruh pengetahuan itu terpusat.
dart
// lib/game/asteroid.dart
import 'dart:math';
import 'dart:ui';
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
enum UkuranAsteroid { besar, sedang, kecil }
/// Kelas dasar. Konstruktornya PRIVAT — objek hanya boleh
/// dibuat lewat factory.
abstract class Asteroid extends PositionComponent
with CollisionCallbacks, HasGameReference<AsteroidsGame> {
Asteroid._({
required Vector2 posisi,
required this.kecepatan,
required double diameter,
}) : super(
position: posisi,
size: Vector2.all(diameter),
anchor: Anchor.center,
);
/// FACTORY METHOD — satu-satunya cara membuat asteroid.
factory Asteroid.buat({
required UkuranAsteroid ukuran,
required Vector2 posisi,
required Vector2 kecepatan,
}) {
return switch (ukuran) {
UkuranAsteroid.besar =>
AsteroidBesar(posisi: posisi, kecepatan: kecepatan),
UkuranAsteroid.sedang =>
AsteroidSedang(posisi: posisi, kecepatan: kecepatan),
UkuranAsteroid.kecil =>
AsteroidKecil(posisi: posisi, kecepatan: kecepatan),
};
}
final Vector2 kecepatan;
/// Sifat yang berbeda per jenis — didefinisikan subkelas.
UkuranAsteroid get ukuran;
int get nilaiSkor;
UkuranAsteroid? get pecahMenjadi;
int get jumlahPecahan;
double _sudutPutar = 0;
late final double _lajuPutar =
(Random().nextDouble() - 0.5) * 1.6;
late final List<Offset> _bentuk = _buatBentukAcak();
static final _acak = Random();
final _cat = Paint()
..color = Colors.grey.shade400
..style = PaintingStyle.stroke
..strokeWidth = 2;
/// Bentuk asteroid dibuat acak agar tiap asteroid terlihat berbeda.
List<Offset> _buatBentukAcak() {
const titik = 10;
final jari = size.x / 2;
return List.generate(titik, (i) {
final sudut = (i / titik) * pi * 2;
// Variasi jari-jari 75%–100% membuat bentuk tidak beraturan
final r = jari * (0.75 + _acak.nextDouble() * 0.25);
return Offset(cos(sudut) * r, sin(sudut) * r);
});
}
@override
Future<void> onLoad() async {
await super.onLoad();
add(
CircleHitbox(radius: size.x / 2, anchor: Anchor.center)
..position = size / 2,
);
}
@override
void update(double dt) {
super.update(dt);
position += kecepatan * dt;
_sudutPutar += _lajuPutar * dt;
_bungkusDiTepi();
}
/// Ciri khas Asteroids: keluar dari satu sisi, masuk dari sisi lain.
void _bungkusDiTepi() {
final batas = game.size;
final margin = size.x;
if (position.x < -margin) position.x = batas.x + margin;
if (position.x > batas.x + margin) position.x = -margin;
if (position.y < -margin) position.y = batas.y + margin;
if (position.y > batas.y + margin) position.y = -margin;
}
@override
void render(Canvas canvas) {
super.render(canvas);
canvas.save();
canvas.translate(size.x / 2, size.y / 2);
canvas.rotate(_sudutPutar);
final jalur = Path()..addPolygon(_bentuk, true);
canvas.drawPath(jalur, _cat);
canvas.restore();
}
/// Dipanggil saat terkena peluru.
void hancur() {
game.controller.tambahSkor(nilaiSkor);
game.add(buatLedakan(posisi: position, skala: size.x / 60));
// Pecah menjadi asteroid yang lebih kecil
final pecahan = pecahMenjadi;
if (pecahan != null) {
for (var i = 0; i < jumlahPecahan; i++) {
final sudut = _acak.nextDouble() * pi * 2;
final laju = 40 + _acak.nextDouble() * 70;
game.add(
Asteroid.buat(
ukuran: pecahan,
posisi: position.clone(),
kecepatan: Vector2(cos(sudut), sin(sudut)) * laju,
),
);
}
}
PengaturanAudio.instance.putarSfx('pecah.wav');
removeFromParent();
game.controller.periksaLevelSelesai();
}
}
// --- Implementasi konkret ---
class AsteroidBesar extends Asteroid {
AsteroidBesar({required Vector2 posisi, required Vector2 kecepatan})
: super._(posisi: posisi, kecepatan: kecepatan, diameter: 84);
@override
UkuranAsteroid get ukuran => UkuranAsteroid.besar;
@override
int get nilaiSkor => 20;
@override
UkuranAsteroid? get pecahMenjadi => UkuranAsteroid.sedang;
@override
int get jumlahPecahan => 2;
}
class AsteroidSedang extends Asteroid {
AsteroidSedang({required Vector2 posisi, required Vector2 kecepatan})
: super._(posisi: posisi, kecepatan: kecepatan, diameter: 48);
@override
UkuranAsteroid get ukuran => UkuranAsteroid.sedang;
@override
int get nilaiSkor => 50;
@override
UkuranAsteroid? get pecahMenjadi => UkuranAsteroid.kecil;
@override
int get jumlahPecahan => 2;
}
class AsteroidKecil extends Asteroid {
AsteroidKecil({required Vector2 posisi, required Vector2 kecepatan})
: super._(posisi: posisi, kecepatan: kecepatan, diameter: 26);
@override
UkuranAsteroid get ukuran => UkuranAsteroid.kecil;
@override
int get nilaiSkor => 100;
@override
UkuranAsteroid? get pecahMenjadi => null; // tidak pecah lagi
@override
int get jumlahPecahan => 0;
}Keuntungan pola ini terlihat ketika kamu ingin menambah jenis baru:
dart
// Menambahkan asteroid es yang membekukan kapal
class AsteroidEs extends Asteroid {
AsteroidEs({required Vector2 posisi, required Vector2 kecepatan})
: super._(posisi: posisi, kecepatan: kecepatan, diameter: 56);
@override
UkuranAsteroid get ukuran => UkuranAsteroid.sedang;
@override
int get nilaiSkor => 150;
@override
UkuranAsteroid? get pecahMenjadi => UkuranAsteroid.kecil;
@override
int get jumlahPecahan => 3;
}Lalu tambahkan satu baris ke switch di factory. Seluruh kode lain — spawner, level loader, penanganan tabrakan — tidak berubah sama sekali.
Kenapa konstruktor privat
Asteroid._() privat memaksa semua kode memakai Asteroid.buat(). Ini bukan sekadar gaya — ia menjamin bahwa tidak ada jalan pintas yang melewati factory, sehingga logika pembuatan benar-benar terpusat di satu tempat.
Command Pattern
Masalah yang dipecahkannya: memisahkan "apa yang diminta" dari "siapa yang meminta".
Tanpa command, kode input terikat langsung ke aksi:
dart
// ❌ Terikat erat — sulit ditambah, sulit diuji
onKeyEvent(event) {
if (event.logicalKey == LogicalKeyboardKey.space) {
kapal.tembak();
}
}
onTapDown(event) {
kapal.tembak(); // duplikasi
}
// Dan bagaimana kalau tombol layar juga harus menembak?
// Duplikasi ketiga.Dengan command, setiap aksi menjadi objek yang bisa dibuat dari mana saja.
dart
// lib/game/command.dart
import 'package:flame/components.dart';
/// Sebuah perintah yang akan dijalankan pada frame berikutnya.
abstract class Command {
/// Nama untuk pencatatan & debugging.
String get nama;
void jalankan(AsteroidsGame game);
}
// --- Perintah konkret ---
class PerintahTembak extends Command {
@override
String get nama => 'tembak';
@override
void jalankan(AsteroidsGame game) {
game.kapal?.tembak();
}
}
class PerintahDorong extends Command {
PerintahDorong(this.arah);
final Vector2 arah;
@override
String get nama => 'dorong';
@override
void jalankan(AsteroidsGame game) {
game.kapal?.dorong(arah);
}
}
class PerintahHiperspasi extends Command {
@override
String get nama => 'hiperspasi';
@override
void jalankan(AsteroidsGame game) {
game.kapal?.hiperspasi();
}
}
class PerintahJeda extends Command {
@override
String get nama => 'jeda';
@override
void jalankan(AsteroidsGame game) {
game.controller.togglePause();
}
}
class PerintahLevelSelesai extends Command {
@override
String get nama => 'level-selesai';
@override
void jalankan(AsteroidsGame game) {
game.controller.muatLevelBerikutnya();
}
}Antreannya dikelola terpusat:
dart
// lib/game/pengelola_command.dart
class PengelolaCommand {
final List<Command> _antrean = [];
/// Riwayat untuk debugging — lihat urutan yang terjadi.
final List<String> riwayat = [];
static const _maksRiwayat = 50;
void tambah(Command perintah) => _antrean.add(perintah);
/// Jalankan semua perintah yang tertunda, lalu kosongkan.
void jalankanSemua(AsteroidsGame game) {
if (_antrean.isEmpty) return;
for (final perintah in _antrean) {
perintah.jalankan(game);
riwayat.add(perintah.nama);
if (riwayat.length > _maksRiwayat) riwayat.removeAt(0);
}
_antrean.clear();
}
void bersihkan() => _antrean.clear();
}Dan game menjalankannya di satu titik:
dart
class AsteroidsGame extends FlameGame with HasCollisionDetection {
final pengelolaCommand = PengelolaCommand();
@override
void update(double dt) {
super.update(dt);
// Semua perintah dijalankan di SATU tempat, di akhir frame
pengelolaCommand.jalankanSemua(this);
}
}Sekarang sumber mana pun bisa menambahkan perintah:
dart
// Dari papan ketik
if (event.logicalKey == LogicalKeyboardKey.space) {
pengelolaCommand.tambah(PerintahTembak());
}
// Dari tombol layar
HudButtonComponent(
onPressed: () => pengelolaCommand.tambah(PerintahTembak()),
);
// Dari logika game — asteroid terakhir hancur
if (asteroidTersisa == 0) {
pengelolaCommand.tambah(PerintahLevelSelesai());
}
// Dari pengujian otomatis
pengelolaCommand.tambah(PerintahTembak());Keuntungan lainnya:
Urutan yang dapat diprediksi. Semua perubahan terjadi di satu titik dalam frame, bukan tersebar di berbagai callback.
Riwayat untuk debugging. Ketika terjadi bug, kamu bisa melihat urutan perintah yang mendahuluinya.
Mudah diuji. Kamu bisa menjalankan skenario tanpa simulasi input sama sekali.
Command juga membuka fitur lanjutan
Karena setiap aksi adalah objek, kamu bisa:
- Merekam dan memutar ulang permainan — simpan urutan command beserta waktunya.
- Undo untuk game berbasis giliran — tambahkan metode
batalkan(). - Replay demo di layar menu.
- Kendali AI — musuh yang mengeluarkan command yang sama dengan pemain.
Kapal pemain
dart
// lib/game/kapal.dart
import 'dart:math';
import 'dart:ui';
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';
class Kapal extends PositionComponent
with CollisionCallbacks, HasGameReference<AsteroidsGame> {
Kapal({required Vector2 posisi})
: super(
position: posisi,
size: Vector2(28, 34),
anchor: Anchor.center,
);
static const double _gayaDorong = 260;
static const double _gesekan = 0.6;
static const double _kecepatanMaks = 340;
static const double _jedaTembak = 0.22;
final Vector2 kecepatan = Vector2.zero();
double _sisaJedaTembak = 0;
bool _kebal = false;
bool _mesinMenyala = false;
final _cat = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 2;
final _catApi = Paint()..color = Colors.orangeAccent;
final _acak = Random();
@override
Future<void> onLoad() async {
await super.onLoad();
add(
CircleHitbox(radius: 12, anchor: Anchor.center)
..position = size / 2,
);
_mulaiKebal(durasi: 2.5);
}
@override
void update(double dt) {
super.update(dt);
if (_sisaJedaTembak > 0) _sisaJedaTembak -= dt;
// Gesekan — kapal melambat perlahan (ruang angkasa tidak begitu,
// tetapi tanpa ini kapal sangat sulit dikendalikan)
kecepatan.scale(1 - _gesekan * dt);
// Batasi kecepatan maksimum
if (kecepatan.length > _kecepatanMaks) {
kecepatan.scaleTo(_kecepatanMaks);
}
position += kecepatan * dt;
_bungkusDiTepi();
_mesinMenyala = false; // direset tiap frame, diset oleh dorong()
}
void _bungkusDiTepi() {
final batas = game.size;
if (position.x < 0) position.x = batas.x;
if (position.x > batas.x) position.x = 0;
if (position.y < 0) position.y = batas.y;
if (position.y > batas.y) position.y = 0;
}
// --- Aksi (dipanggil oleh Command) ---
void dorong(Vector2 arah) {
if (arah.isZero()) return;
angle = arah.screenAngle();
final vektorDorong = Vector2(sin(angle), -cos(angle));
kecepatan += vektorDorong * _gayaDorong * game.currentDt;
_mesinMenyala = true;
_keluarkanApiMesin(vektorDorong);
}
void tembak() {
if (_sisaJedaTembak > 0) return;
_sisaJedaTembak = _jedaTembak;
final arah = Vector2(sin(angle), -cos(angle));
game.add(
Peluru(
posisi: position + arah * 20,
// Kecepatan peluru ditambah kecepatan kapal —
// detail yang membuat kontrolnya terasa benar
kecepatan: arah * 520 + kecepatan * 0.4,
),
);
// Gaya dorong balik
kecepatan -= arah * 24;
PengaturanAudio.instance.putarSfx('tembak.wav', pengali: 0.5);
}
/// Teleportasi ke lokasi acak — jalan keluar darurat.
void hiperspasi() {
game.add(buatLedakan(posisi: position, skala: 0.5));
position = Vector2(
40 + _acak.nextDouble() * (game.size.x - 80),
40 + _acak.nextDouble() * (game.size.y - 80),
);
kecepatan.setZero();
_mulaiKebal(durasi: 1.2);
PengaturanAudio.instance.putarSfx('hiperspasi.wav');
}
// --- Tabrakan ---
@override
void onCollisionStart(Set<Vector2> titik, PositionComponent lawan) {
super.onCollisionStart(titik, lawan);
if (_kebal) return;
if (lawan is Asteroid || lawan is Ufo || lawan is PeluruUfo) {
_hancur();
}
}
void _hancur() {
game.add(buatLedakan(posisi: position, skala: 1.4));
PengaturanAudio.instance.putarSfx('kapal_hancur.wav');
game.camera.viewfinder.add(
MoveEffect.by(
Vector2(10, 0),
NoiseEffectController(duration: 0.4, frequency: 22),
),
);
game.controller.kapalHancur();
removeFromParent();
}
void _mulaiKebal({required double durasi}) {
_kebal = true;
add(
OpacityEffect.fadeOut(
EffectController(
duration: 0.15,
reverseDuration: 0.15,
repeatCount: (durasi / 0.3).round(),
),
onComplete: () => _kebal = false,
),
);
}
void _keluarkanApiMesin(Vector2 arahDorong) {
// Api keluar berlawanan arah dorong
final belakang = -arahDorong;
game.add(
ParticleSystemComponent(
position: position + belakang * 16,
priority: -1,
particle: Particle.generate(
count: 2,
lifespan: 0.28,
generator: (i) => AcceleratedParticle(
speed: belakang * 90 +
Vector2(
(_acak.nextDouble() - 0.5) * 40,
(_acak.nextDouble() - 0.5) * 40,
),
child: ComputedParticle(
renderer: (canvas, p) {
canvas.drawCircle(
Offset.zero,
3 * (1 - p.progress),
Paint()
..color = Color.lerp(
Colors.white,
Colors.deepOrange,
p.progress,
)!
.withValues(alpha: 1 - p.progress),
);
},
),
),
),
),
);
}
@override
void render(Canvas canvas) {
super.render(canvas);
// Segitiga menghadap ke ATAS — sesuai konvensi screenAngle()
final jalur = Path()
..moveTo(size.x / 2, 0)
..lineTo(size.x, size.y)
..lineTo(size.x / 2, size.y * 0.75)
..lineTo(0, size.y)
..close();
canvas.drawPath(jalur, _cat);
// Nyala mesin
if (_mesinMenyala) {
final api = Path()
..moveTo(size.x * 0.35, size.y * 0.8)
..lineTo(size.x / 2, size.y * (1.0 + _acak.nextDouble() * 0.35))
..lineTo(size.x * 0.65, size.y * 0.8)
..close();
canvas.drawPath(api, _catApi);
}
}
}Beberapa keputusan desain yang layak diperhatikan:
Gesekan. Ruang angkasa sesungguhnya tidak punya gesekan, tetapi tanpa itu kapal menjadi sangat sulit dikendalikan. Ini contoh di mana kesenangan mengalahkan realisme.
Kecepatan kapal ditambahkan ke peluru. Detail kecil yang membuat kontrolnya terasa benar secara fisik.
Gaya dorong balik saat menembak. Menambah bobot pada tembakan.
Hiperspasi. Mekanik asli Asteroids — jalan keluar darurat yang berisiko.
Serialisasi JSON untuk level
Mendefinisikan level di dalam kode berarti setiap perubahan keseimbangan permainan butuh kompilasi ulang. JSON memisahkannya.
json
// assets/levels/levels.json
{
"versi": 1,
"levels": [
{
"nomor": 1,
"nama": "Sabuk Luar",
"asteroid": [
{ "ukuran": "besar", "jumlah": 4 }
],
"kecepatanDasar": 55,
"ufo": null,
"bonusWaktu": 0
},
{
"nomor": 2,
"nama": "Zona Padat",
"asteroid": [
{ "ukuran": "besar", "jumlah": 5 },
{ "ukuran": "sedang", "jumlah": 2 }
],
"kecepatanDasar": 70,
"ufo": { "jedaMunculDetik": 22, "jenis": "besar" },
"bonusWaktu": 30
},
{
"nomor": 3,
"nama": "Sarang UFO",
"asteroid": [
{ "ukuran": "besar", "jumlah": 6 },
{ "ukuran": "sedang", "jumlah": 4 },
{ "ukuran": "kecil", "jumlah": 2 }
],
"kecepatanDasar": 85,
"ufo": { "jedaMunculDetik": 14, "jenis": "kecil" },
"bonusWaktu": 45
}
]
}Modelnya:
dart
// lib/game/data_level.dart
class SpesifikasiAsteroid {
const SpesifikasiAsteroid({required this.ukuran, required this.jumlah});
final UkuranAsteroid ukuran;
final int jumlah;
factory SpesifikasiAsteroid.dariJson(Map<String, dynamic> json) {
return SpesifikasiAsteroid(
ukuran: UkuranAsteroid.values.firstWhere(
(u) => u.name == json['ukuran'],
orElse: () => UkuranAsteroid.besar,
),
jumlah: (json['jumlah'] as num?)?.toInt() ?? 1,
);
}
}
class SpesifikasiUfo {
const SpesifikasiUfo({required this.jedaMunculDetik, required this.jenis});
final double jedaMunculDetik;
final String jenis;
factory SpesifikasiUfo.dariJson(Map<String, dynamic> json) {
return SpesifikasiUfo(
jedaMunculDetik:
(json['jedaMunculDetik'] as num?)?.toDouble() ?? 20,
jenis: json['jenis'] as String? ?? 'besar',
);
}
}
class DataLevel {
const DataLevel({
required this.nomor,
required this.nama,
required this.asteroid,
required this.kecepatanDasar,
this.ufo,
this.bonusWaktu = 0,
});
final int nomor;
final String nama;
final List<SpesifikasiAsteroid> asteroid;
final double kecepatanDasar;
final SpesifikasiUfo? ufo;
final int bonusWaktu;
factory DataLevel.dariJson(Map<String, dynamic> json) {
return DataLevel(
nomor: (json['nomor'] as num).toInt(),
nama: json['nama'] as String? ?? 'Level',
asteroid: (json['asteroid'] as List<dynamic>)
.map((e) =>
SpesifikasiAsteroid.dariJson(e as Map<String, dynamic>))
.toList(),
kecepatanDasar:
(json['kecepatanDasar'] as num?)?.toDouble() ?? 60,
ufo: json['ufo'] == null
? null
: SpesifikasiUfo.dariJson(json['ufo'] as Map<String, dynamic>),
bonusWaktu: (json['bonusWaktu'] as num?)?.toInt() ?? 0,
);
}
/// Level di luar daftar dihasilkan secara prosedural,
/// sehingga permainan tidak pernah kehabisan level.
factory DataLevel.prosedural(int nomor) {
final tingkat = nomor - 3;
return DataLevel(
nomor: nomor,
nama: 'Sektor $nomor',
asteroid: [
SpesifikasiAsteroid(
ukuran: UkuranAsteroid.besar,
jumlah: (4 + tingkat * 0.7).round().clamp(4, 12),
),
SpesifikasiAsteroid(
ukuran: UkuranAsteroid.sedang,
jumlah: (2 + tingkat * 0.5).round().clamp(2, 8),
),
],
kecepatanDasar: (55 + tingkat * 8).clamp(55, 180).toDouble(),
ufo: SpesifikasiUfo(
jedaMunculDetik: (22 - tingkat * 1.5).clamp(8, 22).toDouble(),
jenis: nomor.isEven ? 'kecil' : 'besar',
),
);
}
}Memuatnya:
dart
// lib/game/pemuat_level.dart
import 'dart:convert';
import 'package:flutter/services.dart';
class PemuatLevel {
List<DataLevel> _terdefinisi = [];
Future<void> muat() async {
try {
final teks =
await rootBundle.loadString('assets/levels/levels.json');
final json = jsonDecode(teks) as Map<String, dynamic>;
_terdefinisi = (json['levels'] as List<dynamic>)
.map((e) => DataLevel.dariJson(e as Map<String, dynamic>))
.toList();
} catch (e) {
// Berkas rusak atau hilang — jangan gagalkan permainan,
// pakai level prosedural saja
_terdefinisi = [];
}
}
DataLevel ambil(int nomor) {
final cocok = _terdefinisi.where((l) => l.nomor == nomor);
if (cocok.isNotEmpty) return cocok.first;
return DataLevel.prosedural(nomor);
}
}Level prosedural sebagai cadangan
Pola "level yang didefinisikan untuk beberapa level pertama, lalu prosedural setelahnya" sangat efektif:
- Level awal bisa dirancang tangan untuk mengajarkan mekanik secara bertahap.
- Level lanjutan tidak pernah habis.
- Berkas JSON yang rusak tidak membuat game tidak bisa dimainkan.
Penanganan catch yang mengembalikan daftar kosong itu penting — ia mengubah kegagalan fatal menjadi degradasi yang anggun.
Controller
Kelas yang mengelola alur permainan: nyawa, skor, level, dan transisi.
dart
// lib/game/controller.dart
import 'dart:math';
import 'package:flame/components.dart';
enum StatusGame { menu, bermain, jeda, transisiLevel, selesai }
class Controller extends Component
with HasGameReference<AsteroidsGame> {
Controller({required this.pemuatLevel});
final PemuatLevel pemuatLevel;
final _acak = Random();
StatusGame status = StatusGame.menu;
int skor = 0;
int nyawa = 3;
int nomorLevel = 1;
int skorTertinggi = 0;
int _skorNyawaBerikutnya = 10000;
DataLevel? _levelSaatIni;
TimerComponent? _spawnerUfo;
// --- Alur permainan ---
void mulaiPermainan() {
skor = 0;
nyawa = 3;
nomorLevel = 1;
_skorNyawaBerikutnya = 10000;
status = StatusGame.bermain;
game.overlays.remove('menu');
game.overlays.add('hud');
_muatLevel(nomorLevel);
}
void _muatLevel(int nomor) {
final level = pemuatLevel.ambil(nomor);
_levelSaatIni = level;
// Bersihkan sisa level sebelumnya
game.removeAll(game.children.whereType<Asteroid>().toList());
game.removeAll(game.children.whereType<Ufo>().toList());
game.removeAll(game.children.whereType<Peluru>().toList());
_spawnerUfo?.removeFromParent();
_spawnerUfo = null;
// Munculkan asteroid sesuai spesifikasi
for (final spek in level.asteroid) {
for (var i = 0; i < spek.jumlah; i++) {
game.add(
Asteroid.buat(
ukuran: spek.ukuran,
posisi: _posisiAmanDariKapal(),
kecepatan: _kecepatanAcak(level.kecepatanDasar),
),
);
}
}
// Siapkan UFO kalau level ini punya
final ufo = level.ufo;
if (ufo != null) {
_spawnerUfo = TimerComponent(
period: ufo.jedaMunculDetik,
repeat: true,
onTick: () => _munculkanUfo(ufo.jenis),
);
game.add(_spawnerUfo!);
}
_munculkanKapal();
}
/// Asteroid tidak boleh muncul terlalu dekat dengan kapal —
/// pemain tidak punya kesempatan bereaksi.
Vector2 _posisiAmanDariKapal() {
final tengah = game.size / 2;
const jarakAman = 140.0;
for (var percobaan = 0; percobaan < 40; percobaan++) {
final posisi = Vector2(
_acak.nextDouble() * game.size.x,
_acak.nextDouble() * game.size.y,
);
if (posisi.distanceTo(tengah) > jarakAman) return posisi;
}
// Kalau tidak ketemu, taruh di pojok
return Vector2(20, 20);
}
Vector2 _kecepatanAcak(double laju) {
final sudut = _acak.nextDouble() * pi * 2;
final variasi = 0.7 + _acak.nextDouble() * 0.6;
return Vector2(cos(sudut), sin(sudut)) * laju * variasi;
}
void _munculkanKapal() {
game.kapal = Kapal(posisi: game.size / 2);
game.add(game.kapal!);
}
void _munculkanUfo(String jenis) {
if (status != StatusGame.bermain) return;
final dariKiri = _acak.nextBool();
game.add(
Ufo(
posisi: Vector2(
dariKiri ? -40 : game.size.x + 40,
60 + _acak.nextDouble() * (game.size.y - 120),
),
arahX: dariKiri ? 1 : -1,
kecil: jenis == 'kecil',
),
);
}
// --- Kejadian ---
void tambahSkor(int nilai) {
skor += nilai;
// Nyawa tambahan setiap 10.000 poin
if (skor >= _skorNyawaBerikutnya) {
nyawa++;
_skorNyawaBerikutnya += 10000;
PengaturanAudio.instance.putarSfx('nyawa_tambahan.wav');
}
if (skor > skorTertinggi) skorTertinggi = skor;
}
void kapalHancur() {
game.kapal = null;
nyawa--;
if (nyawa <= 0) {
_akhiriPermainan();
return;
}
// Jeda sebelum kapal baru muncul
game.add(
TimerComponent(
period: 1.6,
removeOnFinish: true,
onTick: () {
if (status == StatusGame.bermain) _munculkanKapal();
},
),
);
}
void periksaLevelSelesai() {
if (status != StatusGame.bermain) return;
// Cek pada frame BERIKUTNYA, karena asteroid pecahan
// belum tentu sudah ditambahkan saat ini
game.add(
TimerComponent(
period: 0.1,
removeOnFinish: true,
onTick: () {
final tersisa = game.children.whereType<Asteroid>().length;
if (tersisa == 0 && status == StatusGame.bermain) {
game.pengelolaCommand.tambah(PerintahLevelSelesai());
}
},
),
);
}
void muatLevelBerikutnya() {
if (status != StatusGame.bermain) return;
status = StatusGame.transisiLevel;
// Bonus skor untuk nyawa tersisa
tambahSkor(nyawa * 500);
game.overlays.add('levelSelesai');
game.add(
TimerComponent(
period: 2.5,
removeOnFinish: true,
onTick: () {
game.overlays.remove('levelSelesai');
nomorLevel++;
status = StatusGame.bermain;
_muatLevel(nomorLevel);
},
),
);
}
void togglePause() {
if (status == StatusGame.bermain) {
status = StatusGame.jeda;
game.pauseEngine();
game.overlays.add('jeda');
} else if (status == StatusGame.jeda) {
status = StatusGame.bermain;
game.overlays.remove('jeda');
game.resumeEngine();
}
}
void _akhiriPermainan() {
status = StatusGame.selesai;
game.pengelolaCommand.bersihkan();
game.overlays.remove('hud');
game.overlays.add('selesai');
game.pauseEngine();
}
void kembaliKeMenu() {
status = StatusGame.menu;
game.removeAll(game.children.whereType<PositionComponent>().toList());
game.overlays
..remove('selesai')
..remove('hud')
..add('menu');
game.resumeEngine();
}
String get namaLevel => _levelSaatIni?.nama ?? '';
}Beberapa detail yang penting:
_posisiAmanDariKapal. Asteroid yang muncul persis di atas kapal terasa tidak adil. Ini contoh di mana sedikit kode menyelamatkan pengalaman.
periksaLevelSelesai menunda 0,1 detik. Ketika asteroid besar hancur, ia menambahkan pecahan — tetapi penambahan komponen di Flame baru berlaku pada frame berikutnya. Memeriksa langsung akan menghitung nol asteroid dan langsung menyelesaikan level.
Nyawa tambahan setiap 10.000 poin. Mekanik klasik yang memberi pemain alasan untuk terus bermain.
Menyatukan semuanya
dart
// lib/game/asteroids_game.dart
import 'package:flame/collisions.dart';
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';
class AsteroidsGame extends FlameGame
with HasCollisionDetection, KeyboardEvents, TapCallbacks {
late final Controller controller;
final pengelolaCommand = PengelolaCommand();
final pemuatLevel = PemuatLevel();
Kapal? kapal;
/// `dt` frame saat ini, dibutuhkan Kapal.dorong()
double currentDt = 0;
final Set<LogicalKeyboardKey> _tombolDitekan = {};
@override
Color backgroundColor() => const Color(0xFF05050F);
@override
Future<void> onLoad() async {
await super.onLoad();
await pemuatLevel.muat();
await FlameAudio.audioCache.loadAll([
'tembak.wav',
'pecah.wav',
'kapal_hancur.wav',
'hiperspasi.wav',
'nyawa_tambahan.wav',
]);
add(BintangParallax(jumlahLapisan: 3)..priority = -100);
controller = Controller(pemuatLevel: pemuatLevel);
add(controller);
overlays.add('menu');
pauseEngine();
}
@override
void update(double dt) {
currentDt = dt;
// Terjemahkan tombol yang ditahan menjadi command
_prosesInputBerkelanjutan();
super.update(dt);
// Jalankan semua command di satu titik
pengelolaCommand.jalankanSemua(this);
}
void _prosesInputBerkelanjutan() {
if (controller.status != StatusGame.bermain) return;
final arah = Vector2.zero();
if (_tombolDitekan.contains(LogicalKeyboardKey.keyW) ||
_tombolDitekan.contains(LogicalKeyboardKey.arrowUp)) {
arah.y -= 1;
}
if (_tombolDitekan.contains(LogicalKeyboardKey.keyS) ||
_tombolDitekan.contains(LogicalKeyboardKey.arrowDown)) {
arah.y += 1;
}
if (_tombolDitekan.contains(LogicalKeyboardKey.keyA) ||
_tombolDitekan.contains(LogicalKeyboardKey.arrowLeft)) {
arah.x -= 1;
}
if (_tombolDitekan.contains(LogicalKeyboardKey.keyD) ||
_tombolDitekan.contains(LogicalKeyboardKey.arrowRight)) {
arah.x += 1;
}
if (!arah.isZero()) {
arah.normalize();
pengelolaCommand.tambah(PerintahDorong(arah));
}
}
@override
KeyEventResult onKeyEvent(
KeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
_tombolDitekan
..clear()
..addAll(keysPressed);
if (event is KeyDownEvent) {
switch (event.logicalKey) {
case LogicalKeyboardKey.space:
pengelolaCommand.tambah(PerintahTembak());
case LogicalKeyboardKey.shiftLeft:
pengelolaCommand.tambah(PerintahHiperspasi());
case LogicalKeyboardKey.escape:
pengelolaCommand.tambah(PerintahJeda());
}
}
return KeyEventResult.handled;
}
}Perhatikan currentDt yang disimpan sebagai properti. Ini diperlukan karena Kapal.dorong() dipanggil dari Command, di luar konteks update(dt) biasa.
Struktur proyek
lib/
├── main.dart
├── game/
│ ├── asteroids_game.dart ← FlameGame utama
│ ├── controller.dart ← alur permainan
│ ├── command.dart ← definisi Command
│ ├── pengelola_command.dart
│ ├── data_level.dart ← model + prosedural
│ ├── pemuat_level.dart ← JSON → DataLevel
│ ├── kapal.dart
│ ├── asteroid.dart ← factory + implementasi
│ ├── peluru.dart
│ └── ufo.dart
├── efek/
│ ├── ledakan.dart
│ └── bintang_parallax.dart
├── audio/
│ └── pengaturan_audio.dart
└── overlay/
├── menu.dart
├── hud.dart
├── jeda.dart
├── level_selesai.dart
└── selesai.dart
assets/
├── levels/
│ └── levels.json
└── audio/
├── tembak.wav
├── pecah.wav
└── ...Alur lengkap
Apa yang bisa kamu tambahkan
Arsitektur ini dirancang agar mudah diperluas. Beberapa ide, diurutkan dari yang paling mudah:
Jenis asteroid baru — tambah subkelas, tambah satu baris di factory.
Kekuatan khusus — perisai, tembakan ganda, laser. Tambahkan Command baru dan komponen yang memungutnya.
Papan skor lokal — simpan dengan SharedPreferences dari Bab 15.
Papan skor global — Firestore dari Bab 29.
Replay — rekam command beserta stempel waktunya, putar ulang dengan memasukkannya kembali ke antrean. Ini menjadi mungkin karena Command Pattern.
Editor level — antarmuka Flutter yang menghasilkan JSON, dibagikan lewat mekanisme di Bab 40.
Monetisasi — iklan berhadiah untuk nyawa tambahan (Bab 35), atau versi tanpa iklan (Bab 36).
Ikhtisar
- Factory Method memusatkan pembuatan objek. Konstruktor privat memaksa semua kode memakainya, sehingga tidak ada jalan pintas.
- Menambah jenis baru hanya butuh satu subkelas + satu baris di factory — seluruh kode lain tidak berubah.
- Command Pattern memisahkan "apa yang diminta" dari "siapa yang meminta". Papan ketik, tombol layar, logika game, dan pengujian bisa mengeluarkan perintah yang sama.
- Menjalankan semua command di satu titik per frame membuat urutannya dapat diprediksi dan mudah di-debug.
- Command Pattern membuka fitur lanjutan: replay, undo, dan kendali AI.
- JSON untuk data level memisahkan keseimbangan permainan dari kode — perubahan tidak butuh kompilasi ulang.
- Level prosedural sebagai cadangan membuat permainan tidak pernah habis, dan melindungi dari berkas JSON yang rusak.
- Gesekan pada kapal melanggar fisika ruang angkasa tetapi membuat kontrol jauh lebih baik — kesenangan mengalahkan realisme.
- Asteroid tidak boleh muncul terlalu dekat kapal — pemain butuh kesempatan bereaksi.
- Penambahan komponen di Flame baru berlaku frame berikutnya — periksa kondisi "level selesai" dengan penundaan singkat.
- Nyawa tambahan pada ambang skor adalah mekanik klasik yang memberi alasan untuk terus bermain.
- Pisahkan
Controller(alur permainan) dariFlameGame(koordinasi) dan dari komponen (perilaku).
Bagian VI selesai. Berikutnya: Bab 51 — Langkah Selanjutnya, penutup seluruh situs.
Transkrip asli
Disintesis dari 5_flutter_flame/12_architecting-and-coding-the-asteroids-game.md (11 video: Introduction To Our Approach, Top Level Architecture, Design Patterns We Use — Factory Method Pattern, Command Pattern, Putting It All Together Architecturally, Using JSON and Serialization, Generating Levels Part 1 & 2, Architecture to Code Part 1 & 2, Deploying Your Game to a Mobile Device). Lihat PDF Flame Engine.