Skip to content

Bab 49 — Particle System

Ledakan, percikan api, asap, kembang api, jejak peluru, debu langkah kaki — semua dibuat dengan teknik yang sama: banyak objek kecil berumur pendek yang bersama-sama membentuk satu efek.

Ini bab dengan bagian terpanjang di kursus sumbernya, dan alasannya masuk akal: particle adalah cara termurah membuat game terasa jauh lebih hidup.

Prinsipnya

Sebuah particle adalah objek yang sangat sederhana:

   Setiap particle punya:
   ┌────────────────────────────────────┐
   │  posisi      Vector2               │
   │  kecepatan   Vector2               │
   │  umur        double (detik)        │
   │  warna       Color                 │
   │  ukuran      double                │
   └────────────────────────────────────┘

   Setiap frame:
   1. posisi += kecepatan × dt
   2. umur -= dt
   3. kalau umur habis → hapus
   4. gambar

Satu particle tidak menarik. Lima ratus particle dengan kecepatan dan warna sedikit berbeda membentuk ledakan yang meyakinkan.

ParticleSystemComponent

Flame menyediakan sistem yang menangani seluruh siklus hidupnya.

dart
import 'package:flame/particles.dart';
import 'package:flame/components.dart';

add(
  ParticleSystemComponent(
    position: Vector2(200, 300),
    particle: CircleParticle(
      radius: 4,
      paint: Paint()..color = Colors.orange,
      lifespan: 1.0,
    ),
  ),
);

Ini menampilkan satu lingkaran yang menghilang setelah satu detik. Tidak menarik — tetapi ia adalah blok bangunan untuk semua yang berikutnya.

ParticleSystemComponent menghapus dirinya sendiri

Ketika seluruh particle di dalamnya habis umurnya, komponennya otomatis dilepas dari pohon. Kamu tidak perlu membersihkannya manual — berbeda dari peluru di Bab 44 yang harus kamu hapus sendiri.

Jenis particle

Flame menyediakan beberapa jenis yang bisa digabungkan.

dart
// Lingkaran
CircleParticle(
  radius: 3,
  paint: Paint()..color = Colors.amber,
  lifespan: 0.8,
);

// Gambar
ImageParticle(
  image: gambarPercikan,
  size: Vector2.all(16),
);

// Sprite
SpriteParticle(
  sprite: sprite,
  size: Vector2.all(24),
);

// Animasi sprite sheet
SpriteAnimationParticle(
  animation: animasiLedakan,
  size: Vector2.all(64),
);

// Menggambar sendiri — paling fleksibel
ComputedParticle(
  lifespan: 1.0,
  renderer: (canvas, particle) {
    // particle.progress: 0.0 di awal, 1.0 di akhir
    final opasitas = 1 - particle.progress;
    canvas.drawCircle(
      Offset.zero,
      6 * (1 - particle.progress),
      Paint()..color = Colors.orange.withValues(alpha: opasitas),
    );
  },
);

ComputedParticle adalah yang paling sering kamu pakai untuk efek kustom, karena particle.progress memberi kendali penuh atas bagaimana particle berubah sepanjang hidupnya.

Menggabungkan particle

Kekuatan sistem ini datang dari komposisi — membungkus particle di dalam particle lain.

dart
// Bergerak dengan kecepatan tertentu
MovingParticle(
  from: Vector2.zero(),
  to: Vector2(50, -80),
  child: CircleParticle(radius: 3, paint: cat),
);

// Bergerak dengan percepatan (gravitasi)
AcceleratedParticle(
  speed: Vector2(30, -120),        // kecepatan awal
  acceleration: Vector2(0, 200),   // gravitasi ke bawah
  child: CircleParticle(radius: 3, paint: cat),
);

// Banyak particle sekaligus
Particle.generate(
  count: 30,
  lifespan: 1.2,
  generator: (i) => AcceleratedParticle(
    speed: Vector2(0, -100),
    acceleration: Vector2(0, 300),
    child: CircleParticle(radius: 2, paint: cat),
  ),
);

// Berputar
RotatingParticle(
  from: 0,
  to: math.pi * 2,
  child: ImageParticle(image: gambar),
);

// Skala berubah
ScalingParticle(
  to: 0,   // mengecil sampai hilang
  child: CircleParticle(radius: 8, paint: cat),
);

// Beberapa efek sekaligus
ComposedParticle(
  children: [
    CircleParticle(radius: 4, paint: catMerah),
    CircleParticle(radius: 8, paint: catKuningTransparan),
  ],
);

Menyusunnya bertingkat inilah yang menghasilkan efek yang menarik:

dart
Particle.generate(
  count: 40,
  generator: (i) => AcceleratedParticle(
    speed: arahAcak() * 150,
    acceleration: Vector2(0, 260),
    child: ScalingParticle(
      to: 0,
      child: CircleParticle(radius: 3, paint: cat),
    ),
  ),
);

Particle ini: muncul, terlempar ke arah acak, jatuh karena gravitasi, dan mengecil sampai hilang.

Aliran particle

Efek berkelanjutan seperti asap knalpot atau jejak roket.

dart
// lib/komponen/jejak_roket.dart
import 'dart:math';
import 'dart:ui';
import 'package:flame/components.dart';
import 'package:flame/particles.dart';
import 'package:flutter/material.dart';

class JejakRoket extends Component with HasGameReference {
  JejakRoket({required this.pemilik});

  final PositionComponent pemilik;

  final _acak = Random();
  double _sisaJeda = 0;

  static const double _jedaAntarPartikel = 0.03;

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

    _sisaJeda -= dt;
    if (_sisaJeda > 0) return;
    _sisaJeda = _jedaAntarPartikel;

    // Arah keluar = kebalikan arah hadap pemilik
    final arahBelakang = Vector2(
      -sin(pemilik.angle),
      cos(pemilik.angle),
    );

    game.add(
      ParticleSystemComponent(
        position: pemilik.position + arahBelakang * 18,
        priority: pemilik.priority - 1,
        particle: Particle.generate(
          count: 3,
          lifespan: 0.5,
          generator: (i) {
            // Sebaran acak di sekitar arah belakang
            final sebaran = Vector2(
              (_acak.nextDouble() - 0.5) * 40,
              (_acak.nextDouble() - 0.5) * 40,
            );

            return AcceleratedParticle(
              speed: arahBelakang * 70 + sebaran,
              acceleration: Vector2.zero(),
              child: ComputedParticle(
                renderer: (canvas, particle) {
                  final p = particle.progress;

                  // Warna berubah: putih → kuning → oranye → merah
                  final warna = Color.lerp(
                    Color.lerp(Colors.white, Colors.amber, p * 2)!,
                    Colors.deepOrange,
                    (p - 0.5).clamp(0, 1) * 2,
                  )!;

                  canvas.drawCircle(
                    Offset.zero,
                    5 * (1 - p),
                    Paint()
                      ..color = warna.withValues(alpha: 1 - p),
                  );
                },
              ),
            );
          },
        ),
      ),
    );
  }
}

Dua teknik yang membuat efek ini terlihat baik:

Warna yang berubah sepanjang umur. Api sungguhan berubah dari putih panas ke merah saat mendingin. Color.lerp dengan particle.progress menirunya.

Ukuran yang mengecil. 5 * (1 - p) membuat particle menyusut sampai hilang, alih-alih menghilang mendadak.

Batasi laju pembuatan particle

_jedaAntarPartikel bukan sekadar estetika. Tanpa itu, komponen ini membuat tiga particle setiap frame — 180 particle per detik dari satu roket saja.

Dengan lima roket di layar, itu 900 particle per detik. FPS akan anjlok.

Efek ledakan

Efek yang paling sering dibutuhkan.

dart
// lib/efek/ledakan.dart
import 'dart:math';
import 'dart:ui';
import 'package:flame/components.dart';
import 'package:flame/particles.dart';
import 'package:flutter/material.dart';

final _acak = Random();

/// Ledakan tiga lapis: kilatan, serpihan, dan asap.
ParticleSystemComponent buatLedakan({
  required Vector2 posisi,
  double skala = 1.0,
  Color warnaInti = Colors.amber,
}) {
  return ParticleSystemComponent(
    position: posisi,
    particle: ComposedParticle(
      children: [
        // --- Lapisan 1: kilatan awal, cepat dan terang ---
        ComputedParticle(
          lifespan: 0.18,
          renderer: (canvas, particle) {
            final p = particle.progress;
            canvas.drawCircle(
              Offset.zero,
              50 * skala * p,   // membesar cepat
              Paint()
                ..color = Colors.white.withValues(alpha: (1 - p) * 0.9)
                ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8),
            );
          },
        ),

        // --- Lapisan 2: serpihan yang terlempar ---
        Particle.generate(
          count: (28 * skala).round(),
          lifespan: 0.9,
          generator: (i) {
            final sudut = _acak.nextDouble() * pi * 2;
            final laju = (80 + _acak.nextDouble() * 220) * skala;

            return AcceleratedParticle(
              speed: Vector2(cos(sudut), sin(sudut)) * laju,
              acceleration: Vector2(0, 220),   // gravitasi
              child: ComputedParticle(
                renderer: (canvas, particle) {
                  final p = particle.progress;

                  final warna = Color.lerp(
                    warnaInti,
                    Colors.red.shade900,
                    p,
                  )!;

                  canvas.drawCircle(
                    Offset.zero,
                    (3.5 - 3 * p) * skala,
                    Paint()..color = warna.withValues(alpha: 1 - p * p),
                  );
                },
              ),
            );
          },
        ),

        // --- Lapisan 3: asap yang naik perlahan ---
        Particle.generate(
          count: (10 * skala).round(),
          lifespan: 1.6,
          generator: (i) {
            final sudut = _acak.nextDouble() * pi * 2;

            return AcceleratedParticle(
              speed: Vector2(cos(sudut), sin(sudut)) * 35 * skala,
              acceleration: Vector2(0, -25),   // naik
              child: ComputedParticle(
                renderer: (canvas, particle) {
                  final p = particle.progress;

                  canvas.drawCircle(
                    Offset.zero,
                    (10 + 22 * p) * skala,   // membesar seiring waktu
                    Paint()
                      ..color = Colors.grey.shade800
                          .withValues(alpha: (1 - p) * 0.35)
                      ..maskFilter =
                          const MaskFilter.blur(BlurStyle.normal, 6),
                  );
                },
              ),
            );
          },
        ),
      ],
    ),
  );
}

Memakainya:

dart
void _hancur() {
  game.add(buatLedakan(posisi: position, skala: 1.2));
  PengaturanAudio.instance.putarSfx('ledakan.wav');
  removeFromParent();
}

Struktur tiga lapis itu yang membuatnya meyakinkan:

   Waktu →

   0,0s ─── Kilatan putih membesar & memudar (0,18 detik)

   0,1s ─── Serpihan terlempar ke segala arah, jatuh (0,9 detik)

   0,3s ─── Asap naik dan mengembang (1,6 detik)

   1,6s ─── Selesai, komponen dihapus otomatis

Setiap lapisan punya durasi berbeda, sehingga efeknya berkembang alih-alih muncul dan hilang sekaligus.

Kembang api

Efek dua tahap: naik, lalu meledak.

dart
// lib/efek/kembang_api.dart
import 'dart:math';
import 'dart:ui';
import 'package:flame/components.dart';
import 'package:flame/particles.dart';
import 'package:flutter/material.dart';

class KembangApi extends Component with HasGameReference {
  KembangApi({required this.posisiAwal, required this.tinggiTarget});

  final Vector2 posisiAwal;
  final double tinggiTarget;

  static final _acak = Random();

  late Vector2 _posisi;
  late Vector2 _kecepatan;
  bool _sudahMeledak = false;

  late final Color _warna;

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

    _posisi = posisiAwal.clone();
    _kecepatan = Vector2(
      (_acak.nextDouble() - 0.5) * 40,
      -(260 + _acak.nextDouble() * 90),
    );

    const palet = [
      Color(0xFFFF5252),
      Color(0xFF40C4FF),
      Color(0xFF69F0AE),
      Color(0xFFFFD740),
      Color(0xFFE040FB),
    ];
    _warna = palet[_acak.nextInt(palet.length)];
  }

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

    if (_sudahMeledak) return;

    // Naik dengan gravitasi
    _kecepatan.y += 120 * dt;
    _posisi += _kecepatan * dt;

    // Jejak saat naik
    game.add(
      ParticleSystemComponent(
        position: _posisi.clone(),
        particle: CircleParticle(
          radius: 1.6,
          paint: Paint()..color = _warna.withValues(alpha: 0.7),
          lifespan: 0.35,
        ),
      ),
    );

    // Meledak saat mencapai puncak atau ketinggian target
    if (_kecepatan.y >= 0 || _posisi.y <= tinggiTarget) {
      _meledak();
    }
  }

  void _meledak() {
    _sudahMeledak = true;

    const jumlah = 60;

    game.add(
      ParticleSystemComponent(
        position: _posisi.clone(),
        particle: Particle.generate(
          count: jumlah,
          lifespan: 1.6,
          generator: (i) {
            // Sebar merata dalam lingkaran, bukan acak —
            // menghasilkan bola yang rapi
            final sudut = (i / jumlah) * pi * 2;
            final laju = 90 + _acak.nextDouble() * 70;

            return AcceleratedParticle(
              speed: Vector2(cos(sudut), sin(sudut)) * laju,
              acceleration: Vector2(0, 90),
              child: ComputedParticle(
                renderer: (canvas, particle) {
                  final p = particle.progress;

                  // Berkelip di akhir
                  final kelip = p > 0.6
                      ? (sin(p * 60) * 0.5 + 0.5)
                      : 1.0;

                  canvas.drawCircle(
                    Offset.zero,
                    2.4 * (1 - p * 0.6),
                    Paint()
                      ..color = _warna.withValues(
                        alpha: (1 - p) * kelip,
                      ),
                  );
                },
              ),
            );
          },
        ),
      ),
    );

    PengaturanAudio.instance.putarSfx('kembang_api.wav', pengali: 0.6);

    removeFromParent();
  }
}

Dua detail yang membuatnya terlihat bagus:

Sudut merata alih-alih acak. (i / jumlah) * 2π menyebarkan particle secara merata membentuk lingkaran sempurna. Sudut acak menghasilkan gumpalan yang tidak rapi.

Kelip di akhir. sin(p * 60) membuat particle berkedip cepat menjelang menghilang — meniru bagaimana kembang api sungguhan berkelip.

Memicunya:

dart
add(
  TimerComponent(
    period: 0.8,
    repeat: true,
    onTick: () {
      add(
        KembangApi(
          posisiAwal: Vector2(
            _acak.nextDouble() * size.x,
            size.y,
          ),
          tinggiTarget: size.y * (0.2 + _acak.nextDouble() * 0.3),
        ),
      );
    },
  ),
);

Particle dari sprite sheet

Untuk efek yang butuh detail lebih, animasi sprite sering lebih baik daripada bentuk sederhana.

dart
class GameSaya extends FlameGame {
  late final SpriteAnimation _animasiLedakan;

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

    final gambar = await images.load('ledakan_sheet.png');

    _animasiLedakan = SpriteAnimation.fromFrameData(
      gambar,
      SpriteAnimationData.sequenced(
        amount: 16,                    // jumlah frame
        stepTime: 0.045,               // durasi tiap frame
        textureSize: Vector2.all(128), // ukuran satu frame
        loop: false,
      ),
    );
  }

  void ledakanSprite(Vector2 posisi) {
    add(
      ParticleSystemComponent(
        position: posisi,
        particle: SpriteAnimationParticle(
          animation: _animasiLedakan,
          size: Vector2.all(96),
        ),
      ),
    );
  }
}

Sprite sheet berisi seluruh frame animasi dalam satu gambar:

   ledakan_sheet.png (2048 × 128)
   ┌────┬────┬────┬────┬────┬────┬────┬────┐
   │ 1  │ 2  │ 3  │ 4  │ 5  │ 6  │ 7  │ 8  │ ...
   └────┴────┴────┴────┴────┴────┴────┴────┘
    128px per frame, 16 frame

Kapan sprite, kapan bentuk

  • Bentuk sederhana (CircleParticle, ComputedParticle) — fleksibel, bisa diwarnai secara dinamis, tidak butuh aset. Cocok untuk percikan, jejak, debu.
  • Sprite sheet — detail visual jauh lebih kaya, tetapi tetap dan butuh aset. Cocok untuk ledakan besar, efek sihir, api.

Banyak game memakai keduanya: sprite untuk efek utama, bentuk untuk detail pendukung.

Efek kecil yang sering dilupakan

Efek particle tidak harus besar. Yang kecil sering lebih berdampak karena muncul terus-menerus.

dart
/// Debu saat karakter mendarat
void debuMendarat(Vector2 posisi) {
  game.add(
    ParticleSystemComponent(
      position: posisi,
      particle: Particle.generate(
        count: 8,
        lifespan: 0.4,
        generator: (i) => AcceleratedParticle(
          speed: Vector2(
            (_acak.nextDouble() - 0.5) * 90,
            -_acak.nextDouble() * 40,
          ),
          acceleration: Vector2(0, 160),
          child: ComputedParticle(
            renderer: (canvas, particle) {
              canvas.drawCircle(
                Offset.zero,
                3 * (1 - particle.progress),
                Paint()
                  ..color = Colors.brown.shade300
                      .withValues(alpha: (1 - particle.progress) * 0.7),
              );
            },
          ),
        ),
      ),
    ),
  );
}

/// Kilau saat memungut item
void kilauPungut(Vector2 posisi, Color warna) {
  game.add(
    ParticleSystemComponent(
      position: posisi,
      particle: Particle.generate(
        count: 12,
        lifespan: 0.6,
        generator: (i) {
          final sudut = (i / 12) * pi * 2;
          return MovingParticle(
            from: Vector2.zero(),
            to: Vector2(cos(sudut), sin(sudut)) * 30,
            child: ComputedParticle(
              renderer: (canvas, particle) {
                canvas.drawCircle(
                  Offset.zero,
                  2.5 * (1 - particle.progress),
                  Paint()
                    ..color = warna
                        .withValues(alpha: 1 - particle.progress),
                );
              },
            ),
          );
        },
      ),
    ),
  );
}

/// Angka kerusakan yang melayang naik
void angkaKerusakan(Vector2 posisi, int nilai) {
  final teks = TextComponent(
    text: '-$nilai',
    position: posisi.clone(),
    anchor: Anchor.center,
    priority: 100,
    textRenderer: TextPaint(
      style: const TextStyle(
        color: Colors.redAccent,
        fontSize: 18,
        fontWeight: FontWeight.bold,
      ),
    ),
  );

  teks.add(
    MoveEffect.by(
      Vector2(0, -40),
      EffectController(duration: 0.7, curve: Curves.easeOut),
    ),
  );
  teks.add(
    OpacityEffect.fadeOut(
      EffectController(duration: 0.7),
      onComplete: teks.removeFromParent,
    ),
  );

  game.add(teks);
}

Ketiganya sederhana, tetapi bersama-sama membuat game terasa jauh lebih responsif — pemain mendapat umpan balik visual untuk setiap tindakan.

Performa

Particle adalah bagian yang paling mudah membuat FPS anjlok, karena jumlahnya mudah membengkak tanpa disadari.

Batasi jumlah total

dart
class PengelolaPartikel extends Component with HasGameReference {
  static const int _maksSistem = 25;

  bool bolehTambah() {
    final jumlah =
        game.children.whereType<ParticleSystemComponent>().length;
    return jumlah < _maksSistem;
  }

  void tambah(ParticleSystemComponent sistem) {
    if (!bolehTambah()) return;
    game.add(sistem);
  }
}

Sesuaikan dengan kemampuan perangkat

dart
enum KualitasEfek { rendah, sedang, tinggi }

class PengaturanGrafis {
  static KualitasEfek kualitas = KualitasEfek.sedang;

  static int jumlahPartikel(int dasar) => switch (kualitas) {
        KualitasEfek.rendah => (dasar * 0.35).round(),
        KualitasEfek.sedang => (dasar * 0.7).round(),
        KualitasEfek.tinggi => dasar,
      };
}

// Memakainya
Particle.generate(
  count: PengaturanGrafis.jumlahPartikel(40),
  // ...
);

Sediakan pengaturan ini di menu, seperti pengaturan audio di Bab 45. Pemain dengan perangkat lama akan berterima kasih.

Hindari blur berlebihan

dart
// ❌ MaskFilter sangat mahal — hindari pada particle yang banyak
Paint()..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8)

// ✅ Untuk particle yang banyak, cukup transparansi
Paint()..color = warna.withValues(alpha: 0.5)

Blur tetap boleh dipakai untuk satu atau dua particle besar seperti kilatan ledakan, tetapi tidak untuk lima puluh serpihan.

Buat Paint sekali

dart
// ❌ Objek Paint baru untuk setiap particle, setiap frame
renderer: (canvas, particle) {
  canvas.drawCircle(
    Offset.zero,
    3,
    Paint()..color = Colors.orange,   // 💥
  );
}

// ✅ Buat sekali di luar
final _catPartikel = Paint()..color = Colors.orange;

renderer: (canvas, particle) {
  canvas.drawCircle(Offset.zero, 3, _catPartikel);
}

Untuk particle yang warnanya berubah, kamu memang harus membuat Paint baru — tetapi setidaknya batasi jumlah particlenya.

Ukur dampaknya

dart
add(FpsTextComponent(position: Vector2(10, 40)));

Picu efek terberat dan amati FPS. Kalau turun di bawah 50, kurangi jumlah particle sebelum melanjutkan.

Uji dengan skenario terburuk

Satu ledakan terlihat mulus di mana pun. Yang perlu kamu uji adalah sepuluh ledakan bersamaan dengan lima roket yang meninggalkan jejak — situasi yang pasti terjadi di tengah permainan yang ramai.

Uji dalam mode --profile di perangkat kelas bawah, sesuai peringatan di Bab 13.

Prinsip desain efek

Beberapa hal yang membedakan efek yang terlihat profesional dari yang terlihat amatir:

Selalu memudar. Particle yang menghilang mendadak terlihat murah. Kurangi opasitas dan ukuran seiring progress.

Variasi acak. Kecepatan, ukuran, dan umur yang sedikit berbeda membuat efek terasa organik. Nilai yang identik terlihat mekanis.

Beberapa lapisan. Efek nyata jarang punya satu jenis particle saja — ledakan punya kilatan, serpihan, dan asap.

Durasi berbeda per lapisan. Ini yang membuat efek berkembang alih-alih muncul dan hilang sekaligus.

Warna yang berubah. Api mendingin dari putih ke merah. Color.lerp dengan progress menirunya dengan mudah.

Padukan dengan suara dan guncangan kamera. Efek visual saja terasa datar. Ledakan yang disertai suara dan guncangan singkat terasa jauh lebih berbobot.

dart
void ledakanPenuh(Vector2 posisi) {
  game.add(buatLedakan(posisi: posisi, skala: 1.3));

  PengaturanAudio.instance.putarSfxAcak([
    'ledakan_1.wav',
    'ledakan_2.wav',
    'ledakan_3.wav',
  ]);

  game.camera.viewfinder.add(
    MoveEffect.by(
      Vector2(6, 0),
      NoiseEffectController(duration: 0.25, frequency: 24),
    ),
  );
}

Tiga elemen itu bersama-sama — visual, audio, guncangan — adalah yang membuat sebuah momen terasa berdampak.

Latihan Mandiri

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

Variasi 1: Tiga Efek Berbeda — ⭐⭐ · 45–60 menit

Tantangan: Buat tiga efek partikel yang jelas berbeda karakternya: sebuah ledakan, sebuah jejak yang mengikuti objek bergerak, dan sebuah efek "berhasil" yang terasa menyenangkan. Lalu buat pengaturan kualitas yang mengurangi jumlah partikel untuk perangkat lemah.

Bayangan tampilan (tiga efek):

text
   Ledakan              Jejak                 Berhasil
    ✦✧✦          🚀·°·°·°            ⭐↗  ⭐↑  ⭐↖
   ✧☆✦✧         (mengikuti objek       ⭐→   ✨   ⭐←
    ✦✧✦          bergerak, laju        ⭐↘  ⭐↓  ⭐↙
 (3 lapisan:      dibatasi)          (sudut MERATA,
  kilat, serpih,                      bukan acak)
  asap — durasi
  beda-beda)

Kriteria selesai:

  • Ledakan tersusun dari beberapa lapisan berdurasi berbeda — kilatan cepat, serpihan sedang, asap lambat.
  • Setiap partikel memudar dalam opasitas dan ukuran seiring progress, tidak ada yang hilang mendadak.
  • Setidaknya satu efek memakai Color.lerp sehingga warnanya berubah sepanjang umur.
  • Efek jejak punya pembatas laju pembuatan, sehingga satu objek yang bergerak tidak menghasilkan ratusan partikel per detik.
  • Pengaturan kualitas benar-benar mengubah jumlah partikel, dan kamu sudah mengukur bedanya dengan penghitung frame.
  • Efek "berhasil" memakai sudut yang merata, bukan acak.

Petunjuk: Perbedaan antara efek partikel yang terlihat murah dan yang terasa mahal hampir seluruhnya terletak pada dua hal: pemudaran dan pelapisan. Partikel yang menghilang mendadak selalu terlihat seperti bug, sekalipun disengaja. Dan ledakan yang seluruh partikelnya punya umur sama terlihat seperti satu kejadian tunggal, sementara ledakan sungguhan punya beberapa hal yang terjadi pada kecepatan berbeda. Untuk pola melingkar yang rapi, (i / jumlah) * 2π memberi sudut yang merata; sudut acak menghasilkan gumpalan dan celah yang justru terlihat kurang alami untuk efek perayaan. ComputedParticle adalah alat yang kamu butuhkan begitu efeknya lebih rumit dari sekadar bergerak lurus — di sana particle.progress memberimu kendali penuh atas apa pun yang berubah sepanjang umur.

Variasi 2: Cuaca di Layar — ⭐⭐ · 40–60 menit

Tantangan: Bangun tiga jenis cuaca sebagai efek partikel berkelanjutan: hujan yang jatuh miring, salju yang melayang berputar, dan daun yang tertiup angin. Ketiganya harus bisa dinyalakan bersamaan tanpa membuat game tersendat.

Bayangan tampilan (tiga cuaca):

text
   Hujan              Salju                Daun
  ╲ ╲ ╲ ╲              ❄   ❆   ❅          🍂     🍂
   ╲ ╲ ╲ ╲               ❅   ❄             🍂
  (jatuh miring)      (melayang,          (tertiup angin,
                        gerakan tak         melayang tak
                        seragam)             beraturan)

Geser intensitas ke 0 → tidak ada partikel baru yang dibuat sama sekali, bukan partikel yang tetap dibuat tapi transparan.

Kriteria selesai:

  • Partikel yang keluar layar didaur ulang atau dihapus, tidak menumpuk.
  • Salju melayang dengan gerakan menyamping yang tidak seragam antar partikel.
  • Intensitas cuaca bisa diatur, dan intensitas nol berarti tidak ada partikel yang dibuat sama sekali.
  • Frame rate tetap stabil dengan ketiganya aktif pada intensitas penuh.

Petunjuk: Efek berkelanjutan berbeda dari efek sekali pakai: sistem partikel yang menghapus dirinya sendiri setelah selesai tidak cocok di sini, karena kamu butuh aliran yang tidak berhenti. Buat partikel secara berkala dengan laju yang dibatasi, dan pastikan setiap partikel punya umur terbatas. Untuk gerakan salju yang tidak seragam, beri tiap partikel fase awal acak pada gerakan menyampingnya — tanpa itu, semuanya berayun serempak dan terlihat seperti mesin.

Variasi 3: Api yang Hidup — ⭐⭐⭐ · 45–60 menit

Tantangan: Bangun api unggun yang meyakinkan: lidah api yang naik dan memudar dari kuning ke merah ke abu-abu, percikan yang melompat acak, dan asap yang naik lebih lambat lalu melebar. Api harus bereaksi ketika "ditiup" dengan ketukan.

Bayangan tampilan:

text
      💨💨   (asap, melebar naik, paling lambat)
       🔥    (lidah api, kuning→merah→abu, menyempit)
      ·˙·    (percikan, melompat acak)
     ▓▓▓▓▓   (kayu bakar)

Ketuk (tiup) → seluruh lapisan miring, tapi tidak sama rata: asap paling terpengaruh, percikan paling sedikit bergeser.

Kriteria selesai:

  • Warna berubah sepanjang umur partikel lewat interpolasi bertahap, bukan dua warna saja.
  • Ketiga lapisan punya umur, ukuran, dan kecepatan yang berbeda.
  • Asap melebar seiring naik, dan lidah api justru menyempit.
  • Meniup api memiringkan seluruh lapisan dengan intensitas berbeda — asap paling terpengaruh, percikan paling sedikit.

Petunjuk: Api yang meyakinkan hampir seluruhnya soal gradasi warna sepanjang umur, dan ComputedParticle dengan particle.progress adalah tempat kamu mengendalikannya. Interpolasi bertahap antara tiga warna atau lebih memakai pola yang sama seperti siklus siang-malam: cari dua warna yang mengapit progres sekarang, lalu interpolasi di antaranya. Kriteria terakhir menambahkan kesan massa — benda ringan lebih terpengaruh angin daripada benda berat, dan otak langsung mengenali itu.

Variasi 4: Partikel Bergambar — ⭐⭐⭐ · 45–60 menit

Tantangan: Ganti partikel berbentuk lingkaran polos dengan partikel dari sprite: pecahan kaca yang berputar saat jatuh, kelopak bunga, dan animasi ledakan dari sprite sheet. Bandingkan biaya menggambarnya dengan versi bentuk sederhana.

Bayangan tampilan:

text
 Sebelum (bentuk sederhana)      Sesudah (sprite)
    ●   ●   ●                     🔷   🌸   💥
    ●   ●   ●                     🔷   🌸   💥
 (lingkaran polos)              (pecahan kaca berputar,
                                  kelopak bunga, ledakan
                                  dari sprite sheet)

Kriteria selesai:

  • Sprite dimuat sekali dan dipakai ulang oleh seluruh partikel.
  • Pecahan berputar dengan kecepatan putar yang berbeda-beda tiap partikel.
  • Animasi sprite sheet berjalan tepat sekali sepanjang umur partikel, tidak berulang atau terpotong.
  • Kamu mencatat perbedaan frame rate antara versi sprite dan versi bentuk sederhana.

Petunjuk: Memuat gambar di dalam pembuatan partikel adalah kesalahan yang langsung terasa: seratus partikel berarti seratus permintaan pemuatan. Muat di onLoad() dan bagikan referensinya. Untuk menyelaraskan animasi sprite sheet dengan umur partikel, hitung indeks frame dari progress — dengan begitu ia otomatis pas berapa pun umur yang kamu tetapkan.

Variasi 5: Anggaran Partikel — ⭐⭐⭐⭐ · 60–90 menit

Tantangan: Bangun sistem yang menjaga jumlah partikel aktif tetap di bawah anggaran tertentu, apa pun yang terjadi di layar. Ketika anggaran hampir habis, efek baru dibuat dengan kualitas lebih rendah alih-alih ditolak. Tambahkan penyesuaian kualitas otomatis berdasarkan frame rate.

Bayangan perbandingan:

text
 Tanpa anggaran (FPS jatuh)        Dengan anggaran dijaga
 ✦✧✦✧✦✧✦✧✦✧✦✧✦✧✦✧✦✧                ✦ · ✦ · ✦ · ✦ · ✦ ·
 ✦✧✦✧✦✧✦✧✦✧✦✧✦✧✦✧✦✧                (partikel lebih sedikit/
 FPS: ~18 (tersendat)               sederhana, tapi efek TETAP
                                     terlihat, bukan hilang)
                                    FPS: ~58 (stabil)

Kriteria selesai:

  • Jumlah partikel aktif dilacak dan tidak pernah melewati anggaran.
  • Efek yang dibuat saat anggaran menipis tetap terlihat, hanya lebih sederhana — bukan hilang sama sekali.
  • Kualitas turun otomatis ketika frame rate jatuh, dan naik lagi ketika membaik.
  • Penyesuaian otomatis tidak berosilasi — kualitas tidak naik-turun setiap detik.

Petunjuk: Kriteria terakhir adalah masalah yang sama dengan pergantian musik di Bab 45: sistem yang bereaksi terhadap ambang akan berosilasi tepat di sekitar ambang itu. Pakai ambang berbeda untuk turun dan naik, dan beri jeda minimal sebelum perubahan berikutnya. Untuk mengukur frame rate secara andal, rata-ratakan beberapa frame terakhir alih-alih memakai satu frame — satu frame yang tersendat karena hal lain tidak seharusnya menurunkan kualitas seluruh game.

Ikhtisar

  • Particle adalah objek sederhana berumur pendek: posisi, kecepatan, umur, warna, ukuran. Kekuatannya datang dari jumlah.
  • ParticleSystemComponent otomatis menghapus dirinya ketika seluruh particle habis umurnya.
  • ComputedParticle paling fleksibel — particle.progress (0 → 1) memberi kendali penuh atas perubahan sepanjang umur.
  • Komposisi adalah kuncinya: Particle.generate + AcceleratedParticle + ComputedParticle bertingkat.
  • Efek yang meyakinkan punya beberapa lapisan dengan durasi berbeda — kilatan cepat, serpihan sedang, asap lambat.
  • Selalu memudarkan opasitas dan ukuran seiring progress. Particle yang hilang mendadak terlihat murah.
  • Warna yang berubah dengan Color.lerp meniru api yang mendingin.
  • Untuk pola melingkar, pakai sudut merata (i / jumlah) * 2π, bukan acak.
  • Batasi laju pembuatan pada efek berkelanjutan — tanpa jeda, satu roket menghasilkan 180 particle per detik.
  • Batasi jumlah sistem particle aktif dan sediakan pengaturan kualitas untuk perangkat lama.
  • MaskFilter.blur sangat mahal — pakai hanya untuk satu-dua particle besar, bukan puluhan.
  • Buat objek Paint di luar fungsi renderer bila memungkinkan.
  • Uji skenario terburuk — sepuluh ledakan bersamaan, bukan satu.
  • Padukan visual, audio, dan guncangan kamera. Ketiganya bersama yang membuat momen terasa berdampak.

Berikutnya: Bab 50 — Studi Kasus: Asteroids, yang menggabungkan seluruh Bagian VI.

Transkrip asli

Disintesis dari 5_flutter_flame/11_case-study-7-particle-system.md (18 video: Introduction to Particle System, Creating Simple Particle Simulations, Coding a Particle Stream Simulation, Designing & Coding an "explosion" Particle Simulation, Designing & Coding a "fireworks" Particle Simulation, Working with SpriteSheet, dan seluruh solusi latihannya). Lihat PDF Flame Engine.

Rangkuman pembelajaran pribadi, disusun ulang dari beberapa kursus Flutter.