# Tracer peluru, muzzle flash, dan percikan tumbukan

> Jejak peluru 3 tingkat (inti 1px, pasangan piksel, ekor gelap), kilatan moncong dari sinar acak, dan percikan berpantul.

Kategori: Efek (VFX) · URL: https://makespritewithclaude.pages.dev/articles/tracer-muzzle.html

## Anatomi tracer
Tracer yang baik terbaca di sudut apa pun karena **ketebalan** dan **kecerahan** berubah sepanjang jejak:

| Bagian | Piksel | Warna |
|---|---|---|
| Kepala | 1 piksel | inti hampir putih |
| 0–55% jejak | **pasangan** piksel (tebal) | warna tengah, terang |
| 55–100% | 1 piksel diagonal | warna gelap, alpha 1 → 0.4 |

Jejak pendek (maks. ±14 piksel pada kanvas 320×180) dan **tanpa glow** — glow lebar membuat peluru terlihat gemuk. Pasangan piksel digeser tegak lurus terhadap sumbu dominan (`vert ? x±1 : y±1`). Langkah jejak = 1 piksel sepanjang sumbu dominan (`ca/m, sa/m` dengan `m=max(|ca|,|sa|)`) sehingga garis rapat tanpa lubang.

## Muzzle flash
Hidup 2–4 frame (~70 ms): 5 sinar kuning + 3 sinar inti pendek dengan sudut acak ±0.65 rad dari arah tembak, plus kotak putih 3×3 di moncong. Moncong juga **recoil** mundur 2px lalu pulih.

## Glow tanpa gradient
Disc dither: piksel ke-`(x,y)` menyala jika `(1 - d/r) * alpha > bayer(x,y)`. Terlihat seperti cahaya, tetapi tetap piksel.

## Percikan
Saat mengenai permukaan: 9 partikel 1px, sudut = normal permukaan ± 1.1 rad, kecepatan 40–130 px/s, gravitasi 260, hidup 0.25–0.55 s. Warna inti → tengah menurut sisa umur.

> **Demo interaktif "tracer"**: buka halaman web artikel ini untuk menjalankannya. Kodenya ada di bawah.

Klik untuk menembak ke arah kursor; tahan untuk auto-fire.

## Kode

**Kode: `src/code/40-demo-tracer.js`**

```js
// Tracer + muzzle flash + percikan tumbukan. Klik = tembak ke arah kursor (idle: tembak otomatis).
PX.demos.tracer = (host) => {
  const W = 320, H = 180, GY = 152, cv = PX.canvas(host, W, H, 640, false), c = cv.getContext('2d'), p = PX.pointer(cv);
  const GUN = { x: 32, y: 120 }, CORE = '#fffbd0', MIDS = '#ffd24a', DARK = '#a8841f';
  const bl = [], sp = [], fl = []; let cd = 0, kick = 0, ang = 0;
  const rays = (x, y, a, n, len, col) => { for (let i = 0; i < n; i++) { const q = a + (Math.random() - 0.5) * 1.3, l = len * (0.4 + Math.random() * 0.6); PX.line(c, x, y, x + Math.cos(q) * l, y + Math.sin(q) * l, col); } };
  const fire = (a) => { bl.push({ x: GUN.x + Math.cos(a) * 18, y: GUN.y + Math.sin(a) * 18, a, trav: 0 }); fl.push({ t: 0, a }); kick = 3; };
  const tracer = (b) => {                       // kepala 1px terang -> pasangan piksel tebal -> piksel tunggal gelap memudar
    const ca = Math.cos(b.a), sa = Math.sin(b.a), len = Math.min(b.trav, 14), m = Math.max(Math.abs(ca), Math.abs(sa)),
      N = Math.max(3, Math.round(len * m)), vert = Math.abs(sa) >= Math.abs(ca), ox = vert ? (ca >= 0 ? -1 : 1) : 0, oy = vert ? 0 : (sa >= 0 ? -1 : 1);
    for (let k = N; k >= 0; k--) {
      const t = k / N, x = Math.round(b.x - (ca / m) * k), y = Math.round(b.y - (sa / m) * k);
      if (k === 0) { c.globalAlpha = 1; c.fillStyle = CORE; c.fillRect(x, y, 1, 1); }
      else if (t <= 0.55) { c.globalAlpha = 1; c.fillStyle = MIDS; c.fillRect(x, y, 1, 1); c.fillRect(x + ox, y + oy, 1, 1); }
      else { c.globalAlpha = Math.max(0.55, 1 - (t - 0.55) * 1.2); c.fillStyle = DARK; c.fillRect(x, y, 1, 1); }
    }
    c.globalAlpha = 1;
  };
  PX.loop(host, (dt, t) => {
    cd -= dt; kick = Math.max(0, kick - dt * 20);
    ang = p.in ? Math.atan2(p.y - GUN.y, p.x - GUN.x) : -0.12 + Math.sin(t * 0.9) * 0.2;
    while (p.clicks.length) { p.clicks.length = 0; fire(ang); }
    if (cd <= 0 && (p.down || !p.in)) { fire(ang + (Math.random() - 0.5) * 0.04); cd = p.in ? 0.09 : 0.35; }
    for (const b of bl) {
      const v = 600 * dt; b.x += Math.cos(b.a) * v; b.y += Math.sin(b.a) * v; b.trav += v;
      if (b.x >= W - 12 || b.y >= GY || b.y < 0) {
        b.dead = true; const base = b.y >= GY ? -Math.PI / 2 : Math.PI;
        for (let i = 0; i < 9; i++) { const q = base + (Math.random() - 0.5) * 2.2, s = 80 + Math.random() * 180; sp.push({ x: b.x, y: Math.min(b.y, GY - 1), vx: Math.cos(q) * s, vy: Math.sin(q) * s, life: 0.25 + Math.random() * 0.3 }); }
      }
    }
    for (let i = bl.length - 1; i >= 0; i--) if (bl[i].dead) bl.splice(i, 1);
    for (const s of sp) { s.x += s.vx * dt; s.y += s.vy * dt; s.vy += 520 * dt; s.life -= dt; }
    for (let i = sp.length - 1; i >= 0; i--) if (sp[i].life <= 0) sp.splice(i, 1);
    for (const f of fl) f.t += dt; while (fl.length && fl[0].t > 0.07) fl.shift();
    c.clearRect(0, 0, W, H); c.fillStyle = '#26263a'; c.fillRect(0, GY, W, H - GY); c.fillStyle = '#34344e'; c.fillRect(W - 12, 0, 12, GY);
    PX.line(c, GUN.x - Math.cos(ang) * kick, GUN.y - Math.sin(ang) * kick, GUN.x + Math.cos(ang) * 18 - Math.cos(ang) * kick, GUN.y + Math.sin(ang) * 18 - Math.sin(ang) * kick, '#8a8aa8');
    c.fillStyle = '#5c5c78'; c.fillRect(GUN.x - 6, GUN.y - 2, 10, 12);
    for (const f of fl) { const x = GUN.x + Math.cos(f.a) * 18, y = GUN.y + Math.sin(f.a) * 18; rays(x, y, f.a, 5, 12, MIDS); rays(x, y, f.a, 3, 6, CORE); c.fillStyle = '#fff'; c.fillRect(Math.round(x) - 1, Math.round(y) - 1, 3, 3); }
    bl.forEach(tracer);
    for (const s of sp) { c.fillStyle = s.life > 0.2 ? CORE : MIDS; c.fillRect(Math.round(s.x), Math.round(s.y), 1, 1); }
  });
};
```

