Tampilan
Bab 47 — Timer
Hampir setiap game butuh sesuatu yang terjadi pada waktu tertentu: musuh muncul setiap dua detik, kekuatan khusus bertahan sepuluh detik, level berakhir setelah satu menit, jeda antar tembakan.
Bab ini membahas tiga cara mengelola waktu di Flame, dan kapan memilih masing-masing.
Tiga pendekatan
┌────────────────────────────────────────────────────────────┐
│ 1. Penghitung manual di update() │
│ double _sisa = 2.0; _sisa -= dt; │
│ ✅ Paling sederhana, tanpa objek tambahan │
│ ❌ Berantakan kalau ada banyak │
└────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────┐
│ 2. Timer (kelas utilitas) │
│ Timer(2.0, onTick: ..., repeat: true) │
│ ✅ Rapi, bisa dijeda & direset │
│ ❌ Harus dipanggil update() manual │
└────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────┐
│ 3. TimerComponent │
│ add(TimerComponent(period: 2, onTick: ...)) │
│ ✅ Mengurus dirinya sendiri, ikut siklus komponen │
│ ❌ Sedikit lebih berat │
└────────────────────────────────────────────────────────────┘1. Penghitung manual
Cara paling langsung, dan sering yang paling tepat untuk satu atau dua penghitung.
dart
class Penembak extends PositionComponent {
static const double _jedaTembak = 0.25;
double _sisaJeda = 0;
@override
void update(double dt) {
super.update(dt);
if (_sisaJeda > 0) _sisaJeda -= dt;
}
void tembak() {
if (_sisaJeda > 0) return;
_sisaJeda = _jedaTembak;
// ... tembakkan peluru
}
}Perhatikan bahwa ini persis pola yang sudah kita pakai di Bab 44. Untuk kasus sesederhana ini, tidak ada gunanya memakai objek Timer.
Penghitung mundur permainan:
dart
class GameSaya extends FlameGame {
double waktuTersisa = 60;
bool _selesai = false;
@override
void update(double dt) {
super.update(dt);
if (_selesai) return;
waktuTersisa -= dt;
if (waktuTersisa <= 0) {
waktuTersisa = 0;
_selesai = true;
akhiriPermainan();
}
}
}Penanda _selesai penting: tanpa itu, akhiriPermainan() dipanggil 60 kali per detik setelah waktu habis.
Jangan lupa penanda "sudah terjadi"
Ini bug yang sangat umum. Kondisi waktuTersisa <= 0 tetap benar di setiap frame berikutnya, sehingga apa pun yang kamu lakukan di sana terjadi berulang-ulang — dialog yang muncul ratusan kali, suara yang menumpuk, skor yang disimpan berkali-kali.
2. Kelas Timer
Ketika penghitungmu lebih dari dua atau tiga, Timer membuat kodenya lebih rapi.
dart
import 'package:flame/timer.dart';
class GameSaya extends FlameGame {
late final Timer _timerMusuh;
late final Timer _timerBonus;
@override
Future<void> onLoad() async {
await super.onLoad();
_timerMusuh = Timer(
1.5,
onTick: _munculkanMusuh,
repeat: true,
);
_timerBonus = Timer(
10,
onTick: _munculkanBonus,
repeat: true,
);
}
@override
void update(double dt) {
super.update(dt);
// ⚠️ Timer TIDAK berjalan sendiri — harus di-update manual
_timerMusuh.update(dt);
_timerBonus.update(dt);
}
}Melewatkan pemanggilan update(dt) adalah kesalahan paling umum dengan Timer — timernya hanya diam tanpa error.
Metode yang tersedia:
dart
_timer.start(); // mulai / lanjutkan
_timer.stop(); // hentikan dan reset ke awal
_timer.pause(); // jeda, pertahankan posisi
_timer.resume(); // lanjutkan dari posisi
_timer.progress; // 0.0 sampai 1.0
_timer.current; // detik yang sudah berjalan
_timer.limit; // batas total
_timer.isRunning(); // sedang berjalan?
_timer.finished; // sudah selesai? (untuk timer sekali jalan)progress sangat berguna untuk bilah pengisian:
dart
@override
void render(Canvas canvas) {
super.render(canvas);
// Bilah yang terisi seiring waktu pendinginan
canvas.drawRect(
Rect.fromLTWH(0, size.y + 4, size.x * _timerPendingin.progress, 4),
_catBilah,
);
}3. TimerComponent
Ini pendekatan yang paling "Flame" — timer sebagai komponen yang mengurus dirinya sendiri.
dart
import 'package:flame/components.dart';
@override
Future<void> onLoad() async {
await super.onLoad();
// Berulang
add(
TimerComponent(
period: 2,
repeat: true,
onTick: _munculkanMusuh,
),
);
// Sekali jalan, lalu hapus dirinya sendiri
add(
TimerComponent(
period: 3,
removeOnFinish: true,
onTick: () => _hilangkanKekuatan(),
),
);
}Keunggulannya: TimerComponent otomatis di-update karena ia bagian dari pohon komponen, dan otomatis terhapus ketika induknya dihapus.
Poin kedua itu penting. Bandingkan:
dart
// ❌ Timer terus berjalan meskipun musuhnya sudah mati
class Musuh extends PositionComponent {
late final Timer _timerTembak;
@override
void update(double dt) {
super.update(dt);
_timerTembak.update(dt); // masih berjalan setelah removeFromParent
}
}
// ✅ Timer ikut terhapus bersama musuhnya
class Musuh extends PositionComponent {
@override
Future<void> onLoad() async {
await super.onLoad();
add(TimerComponent(period: 1.2, repeat: true, onTick: _tembak));
}
}Kapan memilih yang mana
- Penghitung manual — satu atau dua penghitung sederhana, terutama jeda aksi.
Timer— kamu butuh mengendalikannya (jeda, reset, bacaprogress).TimerComponent— timer terikat pada masa hidup sebuah komponen, atau kamu ingin "pasang dan lupakan".
Efek sementara
Pola yang sangat umum: kekuatan khusus yang bertahan beberapa detik.
dart
// lib/komponen/pemain.dart
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
class Pemain extends PositionComponent {
static const double _kecepatanNormal = 200;
double kecepatan = _kecepatanNormal;
bool _sedangDipercepat = false;
void ambilBonusKecepatan({double durasi = 5}) {
// Kalau sudah aktif, perpanjang saja — jangan bertumpuk
if (_sedangDipercepat) {
children
.whereType<TimerComponent>()
.where((t) => t.timer.limit == durasi)
.forEach((t) => t.timer.reset());
return;
}
_sedangDipercepat = true;
kecepatan = _kecepatanNormal * 1.8;
// Umpan balik visual
add(
ColorEffect(
Colors.cyanAccent,
EffectController(duration: 0.2),
opacityTo: 0.6,
),
);
add(
TimerComponent(
period: durasi,
removeOnFinish: true,
onTick: () {
kecepatan = _kecepatanNormal;
_sedangDipercepat = false;
},
),
);
}
}Penanganan "sudah aktif" itu penting. Tanpa itu, memungut dua bonus berturut-turut menghasilkan dua timer — dan yang pertama akan mengakhiri efeknya lebih awal dari yang diharapkan.
Kesulitan yang meningkat
Game yang menarik menjadi lebih sulit seiring waktu. Timer adalah cara alami mengelolanya.
dart
// lib/game_bertahan.dart
class GameBertahan extends FlameGame with HasCollisionDetection {
double _waktuBermain = 0;
double _jedaSpawn = 2.0;
late final TimerComponent _spawner;
@override
Future<void> onLoad() async {
await super.onLoad();
_spawner = TimerComponent(
period: _jedaSpawn,
repeat: true,
onTick: _munculkanMusuh,
);
add(_spawner);
// Tingkatkan kesulitan setiap 15 detik
add(
TimerComponent(
period: 15,
repeat: true,
onTick: _naikkanKesulitan,
),
);
}
@override
void update(double dt) {
super.update(dt);
_waktuBermain += dt;
}
void _naikkanKesulitan() {
// Percepat kemunculan, tetapi ada batasnya
_jedaSpawn = (_jedaSpawn * 0.85).clamp(0.35, 2.0);
_spawner.timer.limit = _jedaSpawn;
// Sesekali munculkan gelombang
if (_waktuBermain > 60) {
for (var i = 0; i < 3; i++) {
_munculkanMusuh();
}
}
}
int get level => (_waktuBermain / 15).floor() + 1;
}Perhatikan clamp(0.35, 2.0) — tanpa batas bawah, jeda spawn akhirnya menjadi nol dan game menjadi mustahil sekaligus membebani perangkat.
Menampilkan waktu
dart
// lib/komponen/tampilan_waktu.dart
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../game_bertahan.dart';
class TampilanWaktu extends TextComponent
with HasGameReference<GameBertahan> {
TampilanWaktu()
: super(
text: '00:00',
position: Vector2(16, 16),
textRenderer: TextPaint(
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
fontFeatures: [FontFeature.tabularFigures()],
),
),
);
String _terakhir = '';
@override
void update(double dt) {
super.update(dt);
final baru = _format(game.waktuTersisa);
// Hanya perbarui teks kalau BERUBAH —
// menyusun ulang teks setiap frame itu boros
if (baru != _terakhir) {
_terakhir = baru;
text = baru;
}
}
String _format(double detik) {
final total = detik.ceil();
final menit = total ~/ 60;
final sisa = total % 60;
return '${menit.toString().padLeft(2, '0')}:'
'${sisa.toString().padLeft(2, '0')}';
}
}Dua detail yang layak diperhatikan:
FontFeature.tabularFigures() membuat setiap angka punya lebar yang sama, sehingga penghitung waktu tidak bergeser-geser saat angkanya berubah. Detail kecil yang membuat tampilan terasa jauh lebih rapi.
Memeriksa baru != _terakhir menghindari penyusunan ulang teks 60 kali per detik padahal tampilannya hanya berubah sekali per detik. TextComponent menyusun ulang tata letak teks setiap kali text diubah, dan itu tidak murah.
Urutan kejadian
Untuk hal seperti pembuka level atau serangan bos, kamu butuh serangkaian kejadian yang berurutan.
dart
class UrutanPembuka extends Component {
UrutanPembuka({required this.saatSelesai});
final VoidCallback saatSelesai;
int _langkah = 0;
double _sisa = 0;
static const _langkahDurasi = [1.0, 1.0, 1.0, 0.5];
@override
Future<void> onLoad() async {
await super.onLoad();
_jalankanLangkah();
}
void _jalankanLangkah() {
if (_langkah >= _langkahDurasi.length) {
saatSelesai();
removeFromParent();
return;
}
switch (_langkah) {
case 0:
_tampilkanTeks('SIAP');
case 1:
_tampilkanTeks('3');
case 2:
_tampilkanTeks('2');
case 3:
_tampilkanTeks('MULAI!');
}
_sisa = _langkahDurasi[_langkah];
}
@override
void update(double dt) {
super.update(dt);
_sisa -= dt;
if (_sisa <= 0) {
_langkah++;
_jalankanLangkah();
}
}
void _tampilkanTeks(String teks) {
// ... tampilkan teks besar di tengah layar
}
}Alternatif yang lebih ringkas memakai SequenceEffect dari Bab 43:
dart
add(
SequenceEffect([
ScaleEffect.to(Vector2.all(1.2), EffectController(duration: 0.3)),
ScaleEffect.to(Vector2.all(1.0), EffectController(duration: 0.3)),
RemoveEffect(delay: 0.4),
]),
);Timer dan pause
Ini bagian yang sering salah.
Ketika kamu memanggil pauseEngine(), update() berhenti dipanggil — sehingga semua timer berbasis dt otomatis ikut berhenti. Ini perilaku yang benar dan tidak butuh penanganan tambahan.
dart
// ✅ Otomatis berhenti saat pause
_sisaWaktu -= dt;Yang tidak ikut berhenti adalah timer Dart biasa:
dart
import 'dart:async' as async;
// ❌ Terus berjalan meskipun game dijeda
async.Timer(const Duration(seconds: 3), () {
munculkanBos();
});Jangan pakai dart:async Timer untuk logika game
Timer dari dart:async memakai waktu nyata, bukan waktu game. Ia terus berjalan saat game dijeda, saat aplikasi di latar belakang, dan tidak terpengaruh perlambatan yang mungkin kamu terapkan.
Untuk apa pun yang berkaitan dengan logika permainan, selalu pakai dt atau TimerComponent.
dart:async Timer tetap tepat untuk hal di luar game — misalnya menunda pemanggilan API atau menjadwalkan penyimpanan otomatis.
Perlambatan waktu
Efek yang sering dipakai untuk momen dramatis: waktu melambat sesaat.
dart
class GameSaya extends FlameGame {
double _skalaWaktu = 1.0;
double _sisaPerlambatan = 0;
@override
void update(double dt) {
// Kelola durasi perlambatan dengan waktu ASLI
if (_sisaPerlambatan > 0) {
_sisaPerlambatan -= dt;
if (_sisaPerlambatan <= 0) _skalaWaktu = 1.0;
}
// Berikan dt yang sudah diskalakan ke seluruh komponen
super.update(dt * _skalaWaktu);
}
void lambatkan({double skala = 0.3, double durasi = 1.5}) {
_skalaWaktu = skala;
_sisaPerlambatan = durasi;
}
}Perhatikan urutannya: penghitung perlambatan memakai dt asli, sedangkan super.update() menerima dt yang sudah dikalikan. Kalau terbalik, perlambatan akan memperlambat dirinya sendiri dan tidak pernah berakhir.
Memakainya:
dart
void bosMuncul() {
game.lambatkan(skala: 0.25, durasi: 2);
PengaturanAudio.instance.putarSfx('bos_muncul.wav');
}Contoh lengkap: game bertahan
Menggabungkan semuanya.
dart
// lib/game_bertahan.dart
import 'dart:math';
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
class GameBertahan extends FlameGame with HasCollisionDetection {
final _acak = Random();
// Keadaan permainan
double waktuBermain = 0;
int skor = 0;
int nyawa = 3;
bool _selesai = false;
double _jedaSpawn = 2.0;
late final TimerComponent _spawner;
int get level => (waktuBermain / 20).floor() + 1;
@override
Color backgroundColor() => const Color(0xFF0A0E1A);
@override
Future<void> onLoad() async {
await super.onLoad();
add(ScreenHitbox());
add(TampilanWaktu());
add(TampilanSkor());
// Kemunculan musuh
_spawner = TimerComponent(
period: _jedaSpawn,
repeat: true,
onTick: _munculkanMusuh,
);
add(_spawner);
// Kenaikan kesulitan
add(
TimerComponent(
period: 20,
repeat: true,
onTick: _naikkanKesulitan,
),
);
// Bonus sesekali
add(
TimerComponent(
period: 12,
repeat: true,
onTick: _munculkanBonus,
),
);
}
@override
void update(double dt) {
super.update(dt);
if (!_selesai) waktuBermain += dt;
}
void _munculkanMusuh() {
if (_selesai) return;
final sisi = _acak.nextInt(4);
final posisi = switch (sisi) {
0 => Vector2(_acak.nextDouble() * size.x, -40),
1 => Vector2(size.x + 40, _acak.nextDouble() * size.y),
2 => Vector2(_acak.nextDouble() * size.x, size.y + 40),
_ => Vector2(-40, _acak.nextDouble() * size.y),
};
add(Musuh(posisi: posisi, kecepatan: 70 + level * 12));
}
void _munculkanBonus() {
if (_selesai) return;
add(
Bonus(
posisi: Vector2(
60 + _acak.nextDouble() * (size.x - 120),
60 + _acak.nextDouble() * (size.y - 120),
),
),
);
}
void _naikkanKesulitan() {
if (_selesai) return;
_jedaSpawn = (_jedaSpawn * 0.85).clamp(0.4, 2.0);
_spawner.timer.limit = _jedaSpawn;
}
void tambahSkor(int nilai) {
if (_selesai) return;
skor += nilai;
}
void kurangiNyawa() {
if (_selesai) return;
nyawa--;
if (nyawa <= 0) _akhiri();
}
void _akhiri() {
_selesai = true;
pauseEngine();
overlays.add('kalah');
}
void ulangi() {
// Bersihkan seluruh komponen
removeAll(children.toList());
waktuBermain = 0;
skor = 0;
nyawa = 3;
_jedaSpawn = 2.0;
_selesai = false;
onLoad();
overlays.remove('kalah');
resumeEngine();
}
}Perhatikan _selesai yang diperiksa di setiap metode. Tanpa itu, timer yang masih berjalan bisa memunculkan musuh setelah permainan berakhir.
Perhatikan juga children.toList() pada removeAll — membuat salinan sebelum menghapus, sesuai peringatan di Bab 43.
Latihan Mandiri
Kerjakan salah satu, beberapa, atau semuanya secara berurutan untuk melatih pemahamanmu sampai benar-benar lekat.
Variasi 1: Mode Bertahan — ⭐⭐ · 40–60 menit
Tantangan: Buat mode bertahan sederhana: musuh muncul dengan jeda yang memendek seiring waktu, ada penghitung waktu bertahan di layar, dan setiap tiga puluh detik muncul "gelombang" dengan pengumuman singkat. Tambahkan satu item bonus berdurasi yang bisa didapat berulang kali.
Bayangan tampilan:
text
┌─────────────────────────────────┐
│ ⏱ 01:23 GELOMBANG 3! │
│ 👾 👾 │
│ 👾 👾 │
│ 🙂 │
│ ⚡ (bonus aktif, │
│ 4 detik tersisa) │
└─────────────────────────────────┘"GELOMBANG 3!" muncul sekali tiap 30 detik, tidak berkedip terus-menerus. Ambil bonus ⚡ lagi saat masih aktif → durasinya diperpanjang, bukan menumpuk jadi dua timer terpisah.
Kriteria selesai:
- Jeda spawn memendek seiring waktu tetapi diberi
clamppada batas bawah, sehingga game tetap mungkin dimainkan. - Pengumuman gelombang punya penanda "sudah terjadi", sehingga tidak terpicu enam puluh kali per detik saat kondisinya terpenuhi.
- Mengambil bonus saat bonus yang sama masih aktif memperpanjang durasinya, bukan menumpuk dua timer yang saling menimpa.
TextComponentpenghitung waktu hanya diperbarui ketika teksnya benar-benar berubah.- Menekan pause menghentikan seluruh hitungan — termasuk durasi bonus yang sedang berjalan.
Petunjuk: Kriteria terakhir adalah alasan bab ini menekankan dt berkali-kali. Timer dari dart:async berjalan menurut jam dinding dan tidak peduli bahwa pauseEngine() sudah dipanggil — pemain yang menjeda game selama semenit akan kembali dan mendapati bonusnya sudah habis. Timer berbasis dt berhenti sendiri karena update() berhenti dipanggil. Untuk penanda "sudah terjadi": kondisi seperti waktu % 30 < 0.1 terlihat benar tetapi akan benar selama beberapa frame berturut-turut. Simpan nomor gelombang terakhir yang sudah diumumkan dan bandingkan, alih-alih memeriksa waktunya langsung.
Variasi 2: Adegan Terjadwal — ⭐⭐⭐ · 45–60 menit
Tantangan: Bangun adegan pembuka yang berjalan sendiri: judul muncul, tiga karakter masuk berurutan dari sisi berbeda, teks dialog muncul satu per satu, lalu seluruhnya menghilang dan permainan dimulai. Pemain bisa melewatinya kapan saja dengan satu ketukan.
Bayangan tampilan:
text
┌─────────────────────────────────┐
│ NAMA GAME │
│ │
│ 🧙← 🗡️→ 🏹 │
│ │
│ "Kita harus pergi sekarang!" │
│ │
│ (ketuk untuk lewati) │
└─────────────────────────────────┘Ketiga karakter masuk berurutan dari sisi berbeda, dialog muncul satu kalimat per kalimat. Ketuk kapan saja → langsung ke keadaan akhir adegan, bukan animasi yang berhenti di tengah.
Kriteria selesai:
- Urutan kejadian dinyatakan sebagai data — daftar waktu dan aksi — bukan rantai
ifpada penghitung waktu. - Melewati adegan langsung membawa ke keadaan akhir yang benar, bukan sekadar menghentikan animasi di tengah.
- Menambahkan satu langkah baru di tengah urutan tidak mengharuskan kamu menghitung ulang waktu langkah-langkah sesudahnya.
- Adegan bisa dijeda bersama seluruh permainan.
Petunjuk: Kriteria ketiga adalah alasan urutan sebaiknya dinyatakan sebagai durasi relatif ("tunggu 0,5 detik setelah langkah sebelumnya") alih-alih waktu absolut ("pada detik ke-3,2"). Dengan durasi relatif, menyisipkan langkah baru tidak menyentuh apa pun sesudahnya. Untuk melewati, jalankan seluruh aksi yang tersisa secara langsung tanpa penundaan — itu menjamin keadaan akhirnya identik dengan menontonnya sampai habis.
Variasi 3: Waktu yang Bisa Ditekuk — ⭐⭐⭐⭐ · 60–90 menit
Tantangan: Terapkan tiga manipulasi waktu dalam satu game: jeda penuh, perlambatan gerak saat pemain hampir tertabrak, dan percepatan saat pemain mengaktifkan bonus. Semua efek berdurasi harus tetap benar di ketiga keadaan.
Bayangan tampilan (tiga keadaan waktu):
text
Normal Diperlambat Dipercepat
┌─────────┐ (hampir tertabrak) (bonus aktif)
│ 🙂 👾 │ ┌─────────┐ ┌─────────┐
│Menu 60fps│ │ 🙂 👾 │ │ 🙂 👾 │
└─────────┘ │Menu 60fps│ │Menu 60fps│
└─────────┘ └─────────┘Gerak 🙂/👾 melambat atau dipercepat sesuai keadaan, tapi teks "Menu 60fps" (antarmuka) tetap berjalan normal di ketiganya.
Kriteria selesai:
- Faktor waktu diterapkan di satu tempat, bukan dikalikan di setiap komponen.
- Efek berdurasi tetap berjalan sesuai waktu permainan, bukan waktu dinding — bonus lima detik tetap terasa lima detik meski waktu diperlambat.
- Animasi antarmuka seperti menu tetap berjalan pada kecepatan normal saat permainan diperlambat.
- Peralihan masuk dan keluar perlambatan halus, bukan mendadak.
Petunjuk: Kriteria kedua menyembunyikan keputusan desain yang menarik dan tidak punya jawaban tunggal: apakah "bonus lima detik" berarti lima detik waktu permainan atau lima detik waktu nyata? Keduanya bisa dibenarkan, dan yang penting kamu memilih sadar lalu konsisten. Untuk kriteria ketiga, itulah alasan komponen antarmuka biasanya dipisahkan dari komponen dunia — mereka membaca sumber waktu yang berbeda.
Ikhtisar
- Tiga cara mengelola waktu: penghitung manual (sederhana),
Timer(bisa dikendalikan),TimerComponent(mengurus diri sendiri). Timerharus di-update(dt)manual dariupdate()induknya — melewatkan ini membuatnya diam tanpa error.TimerComponentotomatis di-update dan otomatis terhapus bersama induknya — pilihan terbaik untuk timer yang terikat pada masa hidup komponen.- Selalu beri penanda "sudah terjadi" untuk kejadian sekali pakai, atau kondisi seperti
waktu <= 0akan terpicu 60 kali per detik. - Untuk efek sementara, tangani kasus "sudah aktif" agar bonus beruntun memperpanjang alih-alih menumpuk timer.
- Beri
clamppada kesulitan yang meningkat, atau game menjadi mustahil dan membebani perangkat. - Perbarui
TextComponenthanya saat teksnya berubah — menyusun ulang tata letak setiap frame itu boros. FontFeature.tabularFigures()mencegah penghitung waktu bergeser-geser.- Timer berbasis
dtotomatis berhenti saatpauseEngine(). Timer daridart:asynctidak — jangan pakai itu untuk logika game. - Perlambatan waktu: kalikan
dtsebelumsuper.update(), tetapi kelola durasi perlambatan dengandtasli. - Periksa penanda "selesai" di setiap callback timer, atau musuh tetap muncul setelah permainan berakhir.
Berikutnya: Bab 48 — Parallax.
Transkrip asli
Disintesis dari 5_flutter_flame/9_case-study-5-using-timers-in-games.md (6 video: What are Timers and how do we utilize them, Coding with Timer Utility Classes, Coding with Timer Components, dan latihan-latihannya). Lihat PDF Flame Engine.