Skip to content

Bab 43 — Component & Vector

Bab ini membedah dua konsep yang mendasari hampir semua yang kamu bangun di Flame: PositionComponent sebagai induk dari hampir seluruh objek game, dan vektor sebagai cara menyatakan posisi, kecepatan, serta arah.

Kita akan membangunnya lewat studi kasus yang dipakai kursus sumbernya: sebuah game sederhana bernama "Squares" — kotak-kotak yang bergerak, berputar, dan bisa diketuk.

PositionComponent

Ini kelas dasar untuk hampir setiap komponen visual di Flame. Memahami propertinya berarti memahami sebagian besar API-nya.

dart
class Kotak extends PositionComponent {
  Kotak()
      : super(
          position: Vector2(100, 100),   // di mana ia berada
          size: Vector2(80, 80),         // seberapa besar
          angle: 0,                      // rotasi dalam RADIAN
          anchor: Anchor.center,         // titik acuan
          priority: 1,                   // urutan gambar
        );
}

Lima properti itu menjelaskan hampir semua perilaku visual.

position

Posisi komponen di dalam induknya. Untuk komponen yang ditambahkan langsung ke game, ini adalah posisi di layar.

dart
position = Vector2(100, 200);
position.x = 150;
position += Vector2(10, 0);       // geser ke kanan
position.add(Vector2(0, 5));      // sama, tanpa membuat objek baru

Sistem koordinat game

Berbeda dari matematika sekolah, sumbu Y menunjuk ke bawah:

   (0,0) ─────────────────► X bertambah ke kanan


     │        (100, 50)
     │            ●


   Y bertambah ke bawah

Jadi untuk menggerakkan sesuatu ke atas, kamu mengurangi Y.

size

Ukuran komponen. Ia dipakai untuk deteksi ketukan dan tabrakan, jadi harus mencerminkan ukuran visual yang sebenarnya.

dart
size = Vector2(80, 60);
size = Vector2.all(80);           // persegi
size.scale(1.5);                  // perbesar 50%

angle

Rotasi dalam radian, bukan derajat. Ini sumber kebingungan yang sangat umum.

   Lingkaran penuh = 360 derajat = 2π radian ≈ 6,283

   0 radian      = 0°     (tidak berputar)
   π/2 ≈ 1,571   = 90°
   π   ≈ 3,142   = 180°
   3π/2 ≈ 4,712  = 270°
   2π  ≈ 6,283   = 360°  (kembali ke awal)

Konversinya:

dart
import 'dart:math' as math;

double keRadian(double derajat) => derajat * math.pi / 180;
double keDerajat(double radian) => radian * 180 / math.pi;

// Memakainya
angle = keRadian(45);      // miring 45 derajat
angle += keRadian(90);     // putar 90 derajat lagi

Untuk rotasi terus-menerus, ingat aturan dt dari Bab 42:

dart
@override
void update(double dt) {
  super.update(dt);
  angle += 1.5 * dt;   // 1,5 radian per DETIK ≈ 86°/detik
}

anchor

Titik pada komponen yang dijadikan acuan position dan pusat rotasi. Ini properti yang paling sering menyebabkan hasil tidak sesuai dugaan.

   Anchor.topLeft (BAWAAN)        Anchor.center
   ●─────────────┐                ┌─────────────┐
   │             │                │             │
   │   position  │                │      ●      │  ← position
   │   ada di    │                │             │
   │   pojok     │                │             │
   └─────────────┘                └─────────────┘

   Berputar mengelilingi          Berputar mengelilingi
   pojok kiri atas                titik tengah
dart
anchor = Anchor.center;        // paling sering dipakai
anchor = Anchor.topLeft;       // bawaan
anchor = Anchor.bottomCenter;  // berguna untuk karakter berdiri
anchor = const Anchor(0.5, 0.8);  // kustom

Anchor mengubah arti position

Dengan Anchor.topLeft (bawaan), position = Vector2(100, 100) menaruh pojok kiri atas di titik itu.

Dengan Anchor.center, titik yang sama menaruh pusat komponen di sana — artinya komponen berukuran 80×80 akan menempati area dari (60, 60) sampai (140, 140).

Untuk objek yang berputar, Anchor.center hampir selalu yang kamu inginkan. Berputar dengan anchor di pojok menghasilkan gerakan mengayun yang jarang diharapkan.

priority

Menentukan urutan menggambar. Nilai lebih tinggi digambar di atas.

dart
latar.priority = 0;
musuh.priority = 5;
pemain.priority = 10;
efekPartikel.priority = 20;
hud.priority = 100;

Ini padanan Stack di Flutter dari Bab 4, tetapi dengan angka alih-alih urutan dalam list.

Vector2

Vector2 adalah pasangan dua angka, tetapi memperlakukannya hanya sebagai "koordinat" melewatkan sebagian besar kegunaannya.

dart
final v = Vector2(3, 4);

v.x;              // 3
v.y;              // 4
v.length;         // 5 — panjangnya (teorema Pythagoras)
v.length2;        // 25 — panjang kuadrat, lebih cepat dihitung

Vektor sebagai kecepatan

Ini penggunaan yang paling penting untuk dipahami. Sebuah vektor bisa mewakili arah dan besaran sekaligus.

dart
final kecepatan = Vector2(100, -50);
// Artinya: 100 piksel/detik ke kanan, 50 piksel/detik ke ATAS
// (ingat, Y negatif = ke atas)

position += kecepatan * dt;
   Vector2(100, -50)

          ▲ -Y (atas)

       ╱  │
     ╱    │  50
   ╱      │
  ●───────┴──────► +X (kanan)
       100

   Panjang = √(100² + 50²) ≈ 112 piksel/detik
   Arah = ke kanan-atas

Normalisasi

Sering kali kamu ingin hanya arahnya, tanpa besarannya. Untuk itu ada normalisasi — mengubah vektor menjadi panjang tepat 1.

dart
final arah = Vector2(3, 4);
arah.normalize();          // sekarang panjangnya 1
print(arah);               // (0.6, 0.8)

// Sekarang kamu bisa mengalikan dengan kecepatan apa pun
final kecepatan = arah * 250;   // 250 piksel/detik ke arah itu

Kenapa ini penting? Perhatikan bug klasik ini:

dart
// ❌ Gerakan diagonal jadi lebih cepat
if (tekanKanan) posisi.x += 200 * dt;
if (tekanBawah) posisi.y += 200 * dt;
// Menekan keduanya: kecepatan efektif = √(200² + 200²) ≈ 283
// Pemain bergerak 41% lebih cepat secara diagonal!

// ✅ Kecepatan konsisten ke segala arah
final arah = Vector2(
  (tekanKanan ? 1 : 0) - (tekanKiri ? 1 : 0),
  (tekanBawah ? 1 : 0) - (tekanAtas ? 1 : 0),
);
if (!arah.isZero()) {
  arah.normalize();
  posisi += arah * 200 * dt;
}

Pemeriksaan isZero() penting: menormalisasi vektor nol menghasilkan NaN, yang akan membuat komponenmu menghilang dari layar tanpa error.

Operasi lain yang berguna

dart
final a = Vector2(10, 20);
final b = Vector2(5, 5);

a + b;                    // Vector2(15, 25)
a - b;                    // Vector2(5, 15)
a * 2;                    // Vector2(20, 40)
a.distanceTo(b);          // jarak antara dua titik
a.angleTo(b);             // sudut antara dua vektor
a.dot(b);                 // dot product

// Menyalin — PENTING
final c = a.clone();      // objek baru
final d = a;              // ❌ referensi yang SAMA

Vector2 adalah objek yang bisa diubah

Ini jebakan yang menyebabkan bug aneh:

dart
// ❌ Semua kotak berbagi vektor yang sama
final posisiAwal = Vector2(100, 100);
for (var i = 0; i < 5; i++) {
  add(Kotak(position: posisiAwal));   // objek yang SAMA
}
// Menggerakkan satu kotak menggerakkan semuanya!

// ✅ Setiap kotak punya vektornya sendiri
for (var i = 0; i < 5; i++) {
  add(Kotak(position: posisiAwal.clone()));
}

Ini persoalan yang sama dengan final yang mengunci alamat bukan isi, dibahas di Bab 12.

Menghitung arah menuju target

Pola yang sangat sering dipakai — musuh yang mengejar pemain:

dart
@override
void update(double dt) {
  super.update(dt);

  final arah = (targetPosisi - position)..normalize();
  position += arah * kecepatan * dt;
}

Notasi ..normalize() adalah cascade dari Bab 2: ia menormalisasi hasil pengurangan lalu mengembalikan objek itu sendiri.

Studi kasus: Squares

Mari bangun sesuatu yang lengkap. Beberapa kotak berwarna yang berputar, bergerak, memantul di tepi layar, dan berubah warna ketika diketuk.

dart
// lib/komponen/kotak.dart
import 'dart:math' as math;
import 'dart:ui';
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flutter/material.dart';

class Kotak extends PositionComponent with TapCallbacks {
  Kotak({
    required Vector2 posisi,
    required this.kecepatan,
    required this.kecepatanRotasi,
    required Color warna,
  })  : _warna = warna,
        super(
          position: posisi,
          size: Vector2.all(60),
          anchor: Anchor.center,   // agar berputar di tengahnya
        );

  final Vector2 kecepatan;
  final double kecepatanRotasi;

  Color _warna;
  late Paint _cat = Paint()..color = _warna;

  bool _terpilih = false;
  static final _acak = math.Random();

  @override
  void update(double dt) {
    super.update(dt);

    // Bergerak
    position += kecepatan * dt;

    // Berputar
    angle += kecepatanRotasi * dt;

    // Jaga sudut tetap dalam rentang 0–2π agar tidak membesar tanpa batas
    if (angle > 2 * math.pi) angle -= 2 * math.pi;

    _pantulkanDiTepi();
  }

  void _pantulkanDiTepi() {
    final batas = findGame()!.size;

    // Anchor di tengah, jadi setengah ukuran adalah jaraknya ke tepi
    final setengah = size.x / 2;

    if (position.x - setengah <= 0) {
      position.x = setengah;
      kecepatan.x = kecepatan.x.abs();
    } else if (position.x + setengah >= batas.x) {
      position.x = batas.x - setengah;
      kecepatan.x = -kecepatan.x.abs();
    }

    if (position.y - setengah <= 0) {
      position.y = setengah;
      kecepatan.y = kecepatan.y.abs();
    } else if (position.y + setengah >= batas.y) {
      position.y = batas.y - setengah;
      kecepatan.y = -kecepatan.y.abs();
    }
  }

  @override
  void render(Canvas canvas) {
    super.render(canvas);

    canvas.drawRect(size.toRect(), _cat);

    // Garis tepi kalau sedang terpilih
    if (_terpilih) {
      canvas.drawRect(
        size.toRect(),
        Paint()
          ..color = Colors.white
          ..style = PaintingStyle.stroke
          ..strokeWidth = 3,
      );
    }
  }

  // --- Deteksi ketukan ---

  @override
  void onTapDown(TapDownEvent event) {
    _terpilih = true;

    // Ganti ke warna acak
    _warna = Color.fromARGB(
      255,
      _acak.nextInt(256),
      _acak.nextInt(256),
      _acak.nextInt(256),
    );
    _cat = Paint()..color = _warna;
  }

  @override
  void onTapUp(TapUpEvent event) {
    _terpilih = false;
  }

  @override
  void onTapCancel(TapCancelEvent event) {
    _terpilih = false;
  }
}

Beberapa hal yang layak diperhatikan:

with TapCallbacks — mixin yang memberi komponen kemampuan mendeteksi ketukan. Flame memakai size dan position untuk menentukan apakah ketukan mengenai komponen ini.

findGame()! — cara komponen mengakses game induknya. Lebih aman daripada parent as FlameGame karena bekerja meskipun komponennya bersarang beberapa tingkat.

Membatasi angle — tanpa if (angle > 2 * math.pi), nilainya terus membesar. Setelah berjam-jam berjalan, presisi double mulai berkurang dan rotasi menjadi tersendat.

_cat dibuat ulang hanya saat warna berubah, bukan setiap frame — sesuai peringatan performa di Bab 42. Kotak putih di dalam render() memang dibuat setiap frame, tetapi hanya saat terpilih, sehingga dampaknya kecil.

Game-nya:

dart
// lib/game_squares.dart
import 'dart:math';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'komponen/kotak.dart';

class GameSquares extends FlameGame {
  final _acak = Random();

  @override
  Color backgroundColor() => const Color(0xFF1A1A2E);

  @override
  Future<void> onLoad() async {
    await super.onLoad();
    _buatKotak(8);
  }

  void _buatKotak(int jumlah) {
    for (var i = 0; i < jumlah; i++) {
      add(
        Kotak(
          posisi: Vector2(
            60 + _acak.nextDouble() * (size.x - 120),
            60 + _acak.nextDouble() * (size.y - 120),
          ),
          kecepatan: Vector2(
            (_acak.nextDouble() - 0.5) * 300,
            (_acak.nextDouble() - 0.5) * 300,
          ),
          kecepatanRotasi: (_acak.nextDouble() - 0.5) * 4,
          warna: Color.fromARGB(
            255,
            _acak.nextInt(200) + 55,
            _acak.nextInt(200) + 55,
            _acak.nextInt(200) + 55,
          ),
        ),
      );
    }
  }
}

Perhatikan _acak.nextInt(200) + 55 untuk warna — ini menghindari warna yang terlalu gelap sehingga tidak terlihat di latar gelap.

Komposabilitas: komponen di dalam komponen

Ini konsep yang membuat sistem komponen benar-benar berguna. Sebuah komponen bisa punya komponen anak, dan anak-anak itu mengikuti induknya.

Contoh yang dipakai kursus sumbernya: menambahkan bilah nyawa ke kotak.

dart
// lib/komponen/bilah_nyawa.dart
import 'dart:ui';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

class BilahNyawa extends PositionComponent {
  BilahNyawa({required Vector2 ukuranInduk})
      : super(
          // Posisi RELATIF terhadap induk
          position: Vector2(0, -14),
          size: Vector2(ukuranInduk.x, 6),
        );

  double _nyawa = 1.0;   // 0.0 sampai 1.0

  final _catLatar = Paint()..color = Colors.black54;
  final _catIsi = Paint()..color = Colors.greenAccent;

  set nyawa(double nilai) {
    _nyawa = nilai.clamp(0.0, 1.0);

    // Warna berubah sesuai kondisi
    _catIsi.color = switch (_nyawa) {
      > 0.6 => Colors.greenAccent,
      > 0.3 => Colors.orangeAccent,
      _ => Colors.redAccent,
    };
  }

  double get nyawa => _nyawa;

  @override
  void render(Canvas canvas) {
    super.render(canvas);

    // Latar
    canvas.drawRect(size.toRect(), _catLatar);

    // Isi sesuai nyawa
    canvas.drawRect(
      Rect.fromLTWH(0, 0, size.x * _nyawa, size.y),
      _catIsi,
    );
  }
}

Menambahkannya sebagai anak:

dart
class KotakBernyawa extends PositionComponent {
  late final BilahNyawa _bilah;

  @override
  Future<void> onLoad() async {
    await super.onLoad();

    _bilah = BilahNyawa(ukuranInduk: size);
    add(_bilah);   // ditambahkan sebagai ANAK
  }

  void terkenaSerangan(double kerusakan) {
    _bilah.nyawa -= kerusakan;

    if (_bilah.nyawa <= 0) {
      removeFromParent();
    }
  }
}

Karena bilah nyawa adalah anak, ia otomatis:

  • Bergerak mengikuti induknya.
  • Berputar mengikuti induknya.
  • Dihapus ketika induknya dihapus.
   Pohon komponen                    Di layar
   ─────────────────                 ──────────────────
   FlameGame                         ┌──────────────┐
   ├── KotakBernyawa                 │ ▓▓▓▓▓▓░░░░   │ ← bilah (anak)
   │   └── BilahNyawa                │ ┌──────────┐ │
   ├── KotakBernyawa                 │ │          │ │
   │   └── BilahNyawa                │ │  kotak   │ │ ← induk
   └── KotakBernyawa                 │ │          │ │
       └── BilahNyawa                │ └──────────┘ │
                                     └──────────────┘

Rotasi ikut diwariskan

Ini kadang tidak diinginkan. Kalau kotaknya berputar, bilah nyawanya ikut berputar — dan bilah nyawa yang miring sulit dibaca.

Solusinya: jangan jadikan bilah sebagai anak yang berputar. Taruh sebagai anak langsung game, lalu perbarui posisinya mengikuti kotak:

dart
@override
void update(double dt) {
  super.update(dt);
  _bilah.position = kotak.position + Vector2(0, -20);
}

Jenis komponen bawaan

Flame menyediakan beberapa komponen siap pakai:

dart
// Bentuk sederhana
CircleComponent(radius: 30, paint: cat);
RectangleComponent(size: Vector2(50, 50), paint: cat);
PolygonComponent([Vector2(0,0), Vector2(50,0), Vector2(25,50)]);

// Gambar
SpriteComponent(sprite: sprite, size: Vector2(64, 64));

// Animasi dari sprite sheet
SpriteAnimationComponent(animation: animasi, size: Vector2(64, 64));

// Teks
TextComponent(
  text: 'Skor: 100',
  textRenderer: TextPaint(
    style: const TextStyle(color: Colors.white, fontSize: 24),
  ),
);

// Wadah tanpa tampilan — untuk mengelompokkan
PositionComponent();

Memuat sprite:

dart
class Pemain extends SpriteComponent {
  Pemain() : super(size: Vector2(64, 64), anchor: Anchor.center);

  @override
  Future<void> onLoad() async {
    await super.onLoad();
    sprite = await Sprite.load('pemain.png');
  }
}

Flame mencari assets/images/pemain.png — konvensi yang dibahas di Bab 42.

Efek: animasi tanpa menulis update

Flame punya sistem efek yang mirip animasi implisit di Bab 11.

dart
import 'package:flame/effects.dart';

// Bergerak ke posisi tertentu
komponen.add(
  MoveToEffect(
    Vector2(300, 200),
    EffectController(duration: 1.5, curve: Curves.easeInOut),
  ),
);

// Berputar
komponen.add(
  RotateEffect.by(
    2 * math.pi,
    EffectController(duration: 2, infinite: true),
  ),
);

// Membesar-mengecil berulang
komponen.add(
  ScaleEffect.to(
    Vector2.all(1.3),
    EffectController(
      duration: 0.5,
      reverseDuration: 0.5,
      infinite: true,
    ),
  ),
);

// Memudar lalu hapus
komponen.add(
  OpacityEffect.fadeOut(
    EffectController(duration: 0.8),
    onComplete: () => komponen.removeFromParent(),
  ),
);

// Beberapa efek berurutan
komponen.add(
  SequenceEffect([
    MoveToEffect(Vector2(200, 100), EffectController(duration: 1)),
    ScaleEffect.to(Vector2.all(0), EffectController(duration: 0.3)),
    RemoveEffect(),
  ]),
);

Efek jauh lebih ringkas daripada menulis logika di update(), dan Flame mengurus pembersihannya sendiri.

Menambah dan menghapus komponen

dart
// Menambah
add(komponen);
addAll([a, b, c]);

// Menghapus
komponen.removeFromParent();
remove(komponen);
removeAll(children);

// Memeriksa
if (komponen.isMounted) { }
if (komponen.isRemoving) { }

Jangan menghapus saat sedang mengiterasi

dart
// ❌ Mengubah daftar sambil membacanya
for (final anak in children) {
  if (anak is Musuh && anak.mati) {
    anak.removeFromParent();   // 💥
  }
}

// ✅ Kumpulkan dulu, hapus setelahnya
final untukDihapus = children.whereType<Musuh>()
    .where((m) => m.mati)
    .toList();

for (final m in untukDihapus) {
  m.removeFromParent();
}

Sebenarnya Flame menunda penghapusan sampai akhir frame, sehingga kasus di atas sering tetap bekerja — tetapi bergantung pada perilaku itu membuat kodemu rapuh.

Menemukan komponen lain

dart
// Dari komponen, akses game
final game = findGame()!;

// Cari komponen berdasarkan tipe
final semuaMusuh = game.children.whereType<Musuh>();
final pemain = game.children.whereType<Pemain>().firstOrNull;

// Cari di seluruh pohon, termasuk yang bersarang
final semua = game.descendants().whereType<Peluru>();

Untuk game yang lebih besar, menyimpan referensi langsung lebih efisien daripada mencari setiap frame:

dart
class GameSaya extends FlameGame {
  late final Pemain pemain;

  @override
  Future<void> onLoad() async {
    await super.onLoad();
    pemain = Pemain();
    add(pemain);
  }
}

// Komponen lain mengaksesnya langsung
class Musuh extends PositionComponent
    with HasGameReference<GameSaya> {

  @override
  void update(double dt) {
    super.update(dt);
    final arah = (game.pemain.position - position)..normalize();
    position += arah * 80 * dt;
  }
}

Mixin HasGameReference<T> memberi properti game yang sudah bertipe benar — lebih bersih daripada findGame()! as GameSaya.

Latihan Mandiri

Kerjakan salah satu, beberapa, atau semuanya secara berurutan untuk melatih pemahamanmu sampai benar-benar lekat.

Variasi 1: Penjaga yang Berpatroli — ⭐⭐ · 40–60 menit

Tantangan: Buat sebuah komponen "penjaga" yang berpatroli di antara dua titik, dan ketika pemain (sebuah komponen yang bisa kamu geser dengan jari) mendekat dalam radius tertentu, ia berhenti berpatroli dan mengejar. Ketika pemain menjauh, ia kembali ke jalur patrolinya.

Bayangan tampilan (viewport game):

text
┌─────────────────────────────────┐
│  A┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄B           │
│       👹 (berpatroli)             │
│                                  │
│               ○ (radius deteksi)│
│               🙂 (pemain)        │
└─────────────────────────────────┘

Pemain masuk radius → 👹 berhenti berpatroli dan mengejar, tubuhnya berputar menghadap arah geraknya:

text
┌─────────────────────────────────┐
│  A┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄B           │
│          👹→ (mengejar)           │
│               🙂                 │
└─────────────────────────────────┘

Kriteria selesai:

  • Pengejaran memakai pola (target - position)..normalize() dikalikan kecepatan dan dt — bukan menggerakkan X dan Y secara terpisah.
  • Penjaga memakai Anchor.center, dan ia berputar menghadap arah geraknya.
  • isZero() diperiksa sebelum normalisasi, sehingga penjaga yang tepat berada di posisi pemain tidak menghilang.
  • Radius deteksi digambar sebagai lingkaran agar kamu bisa melihat kapan perilakunya berubah.
  • Tidak ada satu pun Vector2 yang dibagikan antar komponen tanpa .clone().

Petunjuk: Komponen yang menghilang tanpa error hampir selalu berarti posisinya menjadi NaN, dan NaN hampir selalu berasal dari normalisasi vektor nol — pembagian nol dengan nol. Itu terjadi tepat pada frame ketika penjaga persis menyentuh pemain, yang membuatnya terasa acak padahal sangat deterministik. Soal .clone(): Vector2 bisa diubah, jadi menugaskan position sebuah komponen dari position komponen lain membuat keduanya menunjuk objek yang sama — dan keduanya bergerak bersamaan seperti terikat. Untuk menghadap arah gerak, ingat bahwa angle dalam radian, dan atan2 menerima Y lebih dulu.

Variasi 2: Sistem Tata Surya — ⭐⭐ · 40–60 menit

Tantangan: Bangun tata surya sederhana: matahari di tengah, beberapa planet mengorbit dengan kecepatan berbeda, dan setidaknya satu planet punya bulan yang mengorbitnya. Bulan harus ikut terbawa ketika planetnya bergerak, tanpa kamu menghitung posisinya secara absolut.

Bayangan tampilan:

text
┌─────────────────────────────────┐
│               🌕 (bulan)          │
│           🪐 (planet, mengorbit)│
│                                  │
│          ☀️ (matahari, tengah)   │
│                                  │
│       🌍 (planet lain, orbit    │
│            lebih cepat)          │
└─────────────────────────────────┘

Geser 🪐 dengan jari → 🌕 (anak dari planet itu) ikut berpindah otomatis, tanpa kode tambahan untuk menghitung posisi bulan.

Kriteria selesai:

  • Bulan adalah anak dari planet, bukan komponen sejajar yang posisinya dihitung manual.
  • Menggeser planet dengan jari membawa bulannya ikut, tanpa kode tambahan.
  • Kecepatan orbit tiap planet berbeda, dan priority menentukan mana yang digambar di atas.
  • Sudut orbit dibatasi agar tidak membesar tanpa batas.

Petunjuk: Ini demonstrasi paling langsung dari komposabilitas: posisi anak selalu relatif terhadap induknya, jadi seluruh transformasi induk — posisi, rotasi, skala — otomatis berlaku bagi anak. Menyadari ini menghemat banyak matematika di game yang lebih besar. Untuk sudut yang membesar tanpa batas, biarkan ia berputar melewati dua pi dan kurangi kembali; angka pecahan yang sangat besar mulai kehilangan ketelitian setelah berjam-jam berjalan.

Variasi 3: Gerak Tanpa Update — ⭐⭐⭐ · 45–60 menit

Tantangan: Bangun animasi kartu yang dibagikan: lima kartu meluncur dari tumpukan ke posisinya masing-masing dengan jeda berurutan, membesar sedikit lalu kembali, dan berputar ke sudut akhirnya. Kerjakan seluruhnya dengan efek, tanpa menulis satu baris pun di update().

Bayangan tampilan:

text
┌─────────────────────────────────┐
│   [🂠][🂠][🂠][🂠][🂠]  ← tumpukan │
│                                  │
│    🂡    🂢    🂣    🂤    🂥      │
│  (meluncur berurutan, membesar  │
│   sedikit lalu kembali, lalu    │
│   berputar ke sudut akhir)      │
└─────────────────────────────────┘

Kriteria selesai:

  • Tidak ada update() yang di-override di komponen kartu.
  • Efek berurutan dan efek bersamaan keduanya dipakai, di tempat yang tepat.
  • Kurva pergerakan dipilih sadar, bukan linier.
  • Kartu yang animasinya selesai memicu satu tindakan lanjutan.

Petunjuk: Sistem efek Flame menangani hal-hal yang biasanya kamu tulis manual: interpolasi, kurva, durasi, dan pembersihan setelah selesai. Ada efek untuk bergerak, berputar, mengubah ukuran, dan mengubah opasitas, plus pembungkus untuk menjalankan beberapa efek berurutan atau bersamaan. Untuk tindakan setelah selesai, efek menerima callback penyelesaian — jauh lebih andal daripada menghitung waktu sendiri.

Variasi 4: Formasi Musuh — ⭐⭐⭐ · 45–60 menit

Tantangan: Bangun sekelompok musuh yang bergerak sebagai satu formasi: mereka turun bersama, bergeser ke samping saat mencapai tepi, dan formasi tetap utuh meski sebagian anggotanya dihancurkan. Formasi juga bergerak makin cepat seiring berkurangnya anggota.

Bayangan tampilan:

text
┌─────────────────────────────────┐
│   👾  👾  👾  👾  👾              │
│   👾  👾  ✗   👾  👾              │  ← satu hancur di tengah,
│                                  │     yang lain TIDAK bergeser
│   ← formasi geser ke samping    │
│     saat menyentuh tepi layar → │
└─────────────────────────────────┘

Kriteria selesai:

  • Formasi adalah satu komponen induk, dan tiap musuh adalah anaknya dengan posisi relatif.
  • Menghancurkan musuh di tengah tidak menggeser posisi musuh lain.
  • Kecepatan formasi dihitung dari jumlah anggota yang tersisa, dengan clamp.
  • Deteksi tepi memakai posisi absolut anggota terluar, bukan posisi induknya.

Petunjuk: Kriteria terakhir adalah tempat orang paling sering tersandung: posisi anak bersifat relatif, sehingga memeriksa apakah "formasi menyentuh tepi layar" butuh posisi absolut anggota yang paling kiri dan paling kanan. Komponen Flame menyediakan cara mendapatkan posisi absolut dari posisi relatif — carilah itu alih-alih menjumlahkan sendiri, karena penjumlahan manual akan salah begitu induknya juga berputar.

Variasi 5: Mengikuti Jalur — ⭐⭐⭐⭐ · 60–90 menit

Tantangan: Buat kendaraan yang mengikuti jalur berupa deretan titik: ia bergerak menuju titik berikutnya, berbelok halus alih-alih berputar seketika, dan melambat di tikungan tajam. Jalurnya bisa berulang atau bolak-balik.

Bayangan tampilan:

text
┌─────────────────────────────────┐
│   ●┄┄┄┄●                         │
│        ┆   🚗 (melambat di       │
│        ┆      tikungan tajam)   │
│        ●┄┄┄┄┄┄┄●                 │
│                     ┆            │
│                     ●            │
└─────────────────────────────────┘

Kriteria selesai:

  • Perpindahan ke titik berikutnya terjadi berdasarkan jarak ambang, bukan kesamaan posisi persis.
  • Belokan dihaluskan — sudut berubah bertahap menuju sudut target, bukan ditugaskan langsung.
  • Kendaraan melambat ketika perubahan sudut yang dibutuhkan besar.
  • Jalur bisa diganti saat berjalan tanpa kendaraan melompat.

Petunjuk: Membandingkan posisi dengan kesamaan persis hampir tidak pernah berhasil pada angka pecahan — kendaraan akan melewati titiknya lalu berputar-putar mencarinya. Periksa jaraknya di bawah ambang tertentu. Untuk belokan halus, interpolasi sudut punya jebakan tersendiri: sudut dari 350 derajat ke 10 derajat seharusnya berbelok sepuluh derajat, bukan tiga ratus empat puluh, jadi normalkan selisihnya ke rentang setengah putaran lebih dulu.

Ikhtisar

  • PositionComponent adalah induk hampir semua komponen visual. Lima properti kuncinya: position, size, angle, anchor, priority.
  • Sumbu Y menunjuk ke bawah — untuk bergerak ke atas, kurangi Y.
  • angle dalam radian, bukan derajat. Lingkaran penuh = 2π ≈ 6,283.
  • anchor mengubah arti position dan menentukan pusat rotasi. Untuk objek yang berputar, Anchor.center hampir selalu benar.
  • priority lebih tinggi digambar di atas — padanan Stack dengan angka.
  • Vector2 mewakili arah dan besaran sekaligus, bukan hanya koordinat.
  • Normalisasi mengubah vektor menjadi panjang 1 — ini yang mencegah gerakan diagonal menjadi 41% lebih cepat.
  • Periksa isZero() sebelum normalisasi, atau kamu mendapat NaN dan komponen menghilang tanpa error.
  • Vector2 bisa diubah. Pakai .clone() saat berbagi, atau semua komponen akan bergerak bersamaan.
  • (target - position)..normalize() adalah pola untuk mengejar target.
  • with TapCallbacks memberi komponen deteksi ketukan berdasarkan size dan position.
  • Batasi nilai angle agar tidak membesar tanpa batas.
  • Komponen anak mengikuti induknya — bergerak, berputar, dan ikut terhapus. Rotasi yang diwariskan kadang tidak diinginkan.
  • Gunakan efek (MoveToEffect, ScaleEffect, SequenceEffect) alih-alih menulis animasi manual di update().
  • HasGameReference<T> memberi akses bertipe ke game induk.
  • Kumpulkan dulu sebelum menghapus saat mengiterasi komponen.

Berikutnya: Bab 44 — Joystick & Peluru.

Transkrip asli

Disintesis dari 5_flutter_flame/5_case-study-1-flame-engine-essentials.md (14 video: Position Component, "Squares" Code Demo, Architectural UML Diagram, Coding "Squares", Importance of Vectors, Adding Vector Dynamics, Component Composability, Architecting Composability, Coding a Health Bar, dan latihan-latihannya). Lihat PDF Flame Engine.

Rangkuman pembelajaran pribadi, disusun ulang dari beberapa kursus Flutter.