# Lubang, kawah, dan kerusakan per elemen

> Sistem Sprite Fusion — halaman = grid sel milik elemen; tiap elemen punya kekerasan (hp) berbeda, retak bertahap, dan bisa copot; lubang bertepi tak beraturan dengan gosong, bayangan kedalaman, dan serpihan berwarna asli.

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

Dinding bukan satu gambar polos. Di Sprite Fusion, halaman web dipecah menjadi **sel grid** (1 sel = 2 satuan dunia) dan setiap sel punya **pemilik** — elemen asalnya (navbar, gambar, kartu, tombol, teks, ikon). Karena itu kekerasan, retak, dan copotnya bisa berbeda untuk tiap elemen.

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

Pilih senjata (peluru, roket, bom) lalu klik elemen. Bar kecil di atas elemen = sisa hp. Tombol Reset membangun ulang halaman.

## 1. Data per elemen
Setiap elemen dibuat sekali saat halaman dimuat:

```js
hp        = 20 + k * Math.sqrt(luas)      // k = 0.4 (kotak, teks, tombol), 0.32 (gambar)
canDetach = luas < 0.28 * luasLayar       // elemen raksasa tidak bisa copot
damage = 0, lost = 0, cells = jumlah sel miliknya
```

Akibatnya kekerasan **bukan angka tetap**, tetapi mengikuti ukuran dan jenis:

| Elemen | Ukuran | hp (perkiraan) | Nasib |
|---|---|---|---|
| Tombol | 40×14 | ±39 | rapuh: 1 roket cukup |
| Ikon | 22×22 | ±38 | rapuh |
| Kartu | 88×46 | ±71 | 1 roket + sedikit sisa |
| Teks (paragraf) | 150×28 | ±72 | sedang |
| Navbar | 240×14 | ±66 | sedang, tipis, retak dulu |
| Gambar besar | 132×70 | ±82 | k = 0.32 (lebih lunak per luas), tetapi luasnya 28,5% layar → `canDetach = false`: hanya berlubang |
| Halaman (latar) | seluruh layar | tinggi | tidak pernah copot, hanya berlubang |

Elemen kecil lebih mudah hancur dan, ketika copot, **terlempar lebih jauh**: kecepatan `2600/√luas` (batas 15–380).

## 2. Membuat lubang: `carve(x, y, r, irr)`
Semua sel dalam radius dihapus. Radius bergantung sudut agar tidak bulat:

```
batas(θ) = r · (1 + irr · (K1·sin(3θ+φ1)·0.6 + K2·sin(7θ+φ2)·0.4 + sin(13θ+φ3)·0.25))
```

`φ` dan `K` acak per lubang. Tiap sel yang hilang memanggil `take()`: tandai kosong, tambah `lost` milik elemennya, dan (sebagian kecil) jadikan **serpihan 1 piksel dengan warna asli sel itu**. `carve` mengembalikan himpunan elemen yang terkena — itulah yang menerima damage.

`irr` menentukan karakter: ledakan `0.16` (hampir bulat, sedikit bergerigi), peluru `0.42` (kasar).

## 3. Peluru menembus
Setelah lubang utama (`irr 0.42`), dua lubang lanjutan di sepanjang arah tembak: pada `0.9·r` berradius `0.72·r`, lalu `1.75·r` berradius `0.5·r` — **hanya jika di titik itu masih ada sel padat**. Hasilnya terowongan menyempit yang berhenti saat bertemu ruang kosong.

## 4. Damage, retak, copot
```
damageBlock(blok, dmg):
  damage += dmg
  tiap melewati 30% / 55% / 80% hp  ->  retak (level 2, 3, 4)
  bila canDetach dan (damage >= hp  atau  lost >= 38% sel)  ->  copot
```
- **Retak**: jalan acak sepanjang `min(34, 5 + √luas · (0.2 + 0.1·level))` sel di dalam elemen itu, bercabang `level+1` kali, menggelapkan sel ×0.35. Level lebih tinggi = retakan lebih panjang.
- **Copot**: sel yang tersisa disalin jadi sprite, area asal dikosongkan, sprite diberi kecepatan menjauhi titik tumbuk, putaran `≤ min(3, 300/√luas)`, gravitasi, lalu memudar.
- Senjata: peluru 14 damage; roket 90 langsung + percik `0.7·dmg·(1 − jarak/radius percik)` ke elemen di sekitar; bom 170 dengan percik lebih luas. Elemen raksasa hanya akan berlubang karena `canDetach = false`.

## 5. Tampilan lubang
1. **Dinding belakang**: sel kosong ditampilkan sebagai pola gelap, bukan transparan.
2. **Kedalaman**: untuk tiap sel lubang, cari sel utuh di atas-kiri (`x−((q+1)>>1), y−q`, `q=2..5`). `q≤2` → gelapkan padat; `q=3` → dither 60%; `q=4` → dither 30%. Bayangan jatuh di bawah bibir atas.
3. **Gosong**: jarak `d` dari tepi lubang. `d≤1.5` → hitam pekat tipis; lalu `0.9·(1−t)^1.15` dibandingkan ambang Bayer → memudar keluar tanpa gradient.
4. **Bara**: sebagian sel cincin pertama berpendar oranye 0.75 detik.

Semua kumulatif: lubang yang bertumpuk, retak, dan gosong tetap konsisten karena disimpan di grid.

## Kode

**Kode: `src/code/42-demo-crater.js`**

```js
// Lubang & kerusakan berbasis ELEMEN (sistem Sprite Fusion). Halaman = grid sel; tiap sel punya pemilik (elemen).
// - carve(x,y,r,irr): hapus sel dalam radius tak beraturan; tiap sel hilang menambah blok.lost.
// - Kekerasan elemen: hp = 20 + k*sqrt(luas dunia), k = 0.4 (kotak/teks) atau 0.32 (gambar). Elemen kecil rapuh, besar kokoh.
// - damageBlock: damage += dmg; retak di 30/55/80% hp; lepas jadi serpihan jatuh bila damage>=hp ATAU lost>=38% sel (jika canDetach).
// - canDetach=false untuk elemen sangat besar (luas >= 28% layar): hanya berlubang, tidak pernah copot.
PX.demos.crater = (host) => {
  const W = 240, H = 135, E = 2, cv = PX.canvas(host, W, H, 640), c = cv.getContext('2d'), p = PX.pointer(cv), rnd = PX.rng(11), R = Math.random;
  const N = W * H, img = new ImageData(W, H), back = new Uint8ClampedArray(N * 4), owner = new Int16Array(N), hole = new Uint8Array(N);
  let blocks, debris = [], heat = [], chunks = [], dmgTxt = [];
  const hash = PX.hash;
  const mul = (i, k) => { for (let j = 0; j < 3; j++) img.data[i * 4 + j] *= k; };
  const rect = (x, y, w, h, col, o, nz = 6) => { for (let j = y; j < y + h; j++) for (let i = x; i < x + w; i++) { if (i < 0 || j < 0 || i >= W || j >= H) continue; const k = j * W + i, n = (hash(i, j, 5) - 0.5) * nz; img.data.set([col[0] + n, col[1] + n, col[2] + n, 255], k * 4); owner[k] = o; } };
  function build() {
    img.data.fill(0); owner.fill(-1); hole.fill(0); debris = []; heat = []; chunks = [];
    const B = (kind, x, y, w, h, extra = {}) => ({ kind, x, y, w, h, damage: 0, lost: 0, alive: true, cells: 0, flash: 0, ...extra });
    blocks = [B('halaman', 0, 0, W, H), B('navbar', 0, 0, W, 14), B('gambar', 6, 22, 132, 70), B('kartu', 146, 22, 88, 46), B('tombol', 146, 76, 40, 14), B('tombol', 194, 76, 40, 14), B('teks', 8, 98, 150, 28), B('ikon', 170, 100, 22, 22), B('ikon', 204, 100, 28, 24)];
    blocks.forEach((b, i) => {
      const a = b.w * E * b.h * E;                                        // luas dalam satuan dunia (1 sel = E satuan)
      b.hp = 20 + (b.kind === 'gambar' ? 0.32 : 0.4) * Math.sqrt(a);
      b.canDetach = a < 0.28 * W * E * H * E;                             // elemen sangat besar tidak bisa copot
    });
    rect(0, 0, W, H, [34, 34, 52], 0, 8);
    rect(0, 0, W, 14, [58, 64, 110], 1); rect(0, 13, W, 1, [30, 34, 70], 1); rect(10, 5, 30, 4, [220, 220, 240], 1); [120, 150, 180, 210].forEach((x) => rect(x, 5, 18, 4, [150, 156, 200], 1));
    const g = blocks[2]; for (let y = 0; y < g.h; y++) for (let x = 0; x < g.w; x++) { const t = y / g.h, k = (g.y + y) * W + g.x + x; const ridge = 38 + Math.sin(x * 0.09) * 9 + Math.sin(x * 0.23) * 4; const col = y > ridge ? [40 + (y - ridge) * 0.4, 94 - (y - ridge) * 0.3, 70] : [120 + t * 90, 160 + t * 50, 230 - t * 20]; img.data.set([...col.map((v) => PX.clamp(v + (hash(x, y, 3) - 0.5) * 8, 0, 255)), 255], k * 4); owner[k] = 2; }
    for (let y = -7; y <= 7; y++) for (let x = -7; x <= 7; x++) if (x * x + y * y <= 49) { const k = (32 + y) * W + 100 + x; img.data.set([255, 224, 120, 255], k * 4); owner[k] = 2; }
    rect(146, 22, 88, 46, [226, 222, 210], 3); rect(146, 22, 88, 8, [200, 90, 60], 3); rect(152, 36, 50, 3, [90, 86, 80], 3); for (let i = 0; i < 4; i++) rect(152, 44 + i * 5, 60 + ((i * 17) % 20), 2, [150, 146, 138], 3);
    for (const i of [4, 5]) { const b = blocks[i]; rect(b.x, b.y, b.w, b.h, i === 4 ? [255, 138, 42] : [110, 160, 255], i); rect(b.x + 4, b.y + 5, b.w - 8, 3, [255, 255, 255], i, 0); }
    rect(8, 98, 150, 28, [52, 52, 76], 6); for (let i = 0; i < 4; i++) rect(12, 102 + i * 6, 100 + ((i * 29) % 40), 2, [180, 180, 205], 6);
    rect(170, 100, 22, 22, [120, 220, 150], 7); rect(176, 106, 10, 10, [30, 90, 50], 7); rect(204, 100, 28, 24, [230, 120, 180], 8); rect(212, 108, 12, 8, [120, 30, 70], 8);
    for (let i = 0; i < N; i++) { const o = owner[i]; if (o >= 0) blocks[o].cells++; const f = 0.85; for (let j = 0; j < 3; j++) back[i * 4 + j] = 44 * f + ((hash(i % W, (i / W) | 0, 8) - 0.5) * 7) + (j === 2 ? 8 : 0); back[i * 4 + 3] = 255; }
  }
  const alive = (x, y) => x >= 0 && y >= 0 && x < W && y < H && !hole[y * W + x];
  function shade(x0, y0, x1, y1) {                                        // dinding belakang + bayangan di bawah bibir atas
    for (let y = Math.max(0, y0); y <= Math.min(H - 1, y1); y++) for (let x = Math.max(0, x0); x <= Math.min(W - 1, x1); x++) {
      const i = y * W + x; if (!hole[i]) continue;
      let k = 0; for (let q = 2; q <= 5; q++) if (alive(x - ((q + 1) >> 1), y - q)) { k = q; break; }
      const s = k && k <= 2 ? 0.55 : k === 3 && 0.6 > PX.bayer(x, y) ? 0.55 : k === 4 && 0.3 > PX.bayer(x, y) ? 0.55 : 1;
      for (let j = 0; j < 3; j++) img.data[i * 4 + j] = back[i * 4 + j] * s; img.data[i * 4 + 3] = 255;
    }
  }
  function take(i, spall) {                                               // satu sel hilang (take() Sprite Fusion)
    if (hole[i]) return false; hole[i] = 1; const o = owner[i]; if (o >= 0) blocks[o].lost++;
    if (spall && debris.length < 260 && R() < 0.3) { const x = i % W, y = (i / W) | 0, a = R() * 6.283, v = 30 + R() * 90; debris.push({ x, y, vx: Math.cos(a) * v, vy: Math.sin(a) * v - 40, life: 0.5 + R() * 0.6, c: Array.from(img.data.slice(i * 4, i * 4 + 3)) }); }
    return true;
  }
  function carve(cx, cy, r, irr, spall = true) {                          // tepi tak beraturan: 3 sinus (3, 7, 13 lobus) dengan fase acak
    const X = R() * 6.28, Q = R() * 6.28, Z = R() * 6.28, K = 0.4 + R() * 0.6, K2 = 0.4 + R() * 0.6, J = r * (1 + irr * 1.6) + 1, hit = new Set(), carved = [];
    for (let y = Math.max(0, Math.floor(cy - J)); y <= Math.min(H - 1, Math.ceil(cy + J)); y++) for (let x = Math.max(0, Math.floor(cx - J)); x <= Math.min(W - 1, Math.ceil(cx + J)); x++) {
      const dx = x + 0.5 - cx, dy = y + 0.5 - cy, d = Math.hypot(dx, dy); if (d > J) continue;
      const a = Math.atan2(dy, dx), kt = r * (1 + irr * (K * Math.sin(3 * a + X) * 0.6 + K2 * Math.sin(7 * a + Q) * 0.4 + Math.sin(13 * a + Z) * 0.25));
      if (d <= kt) { const i = y * W + x, o = owner[i]; if (take(i, spall)) { carved.push(i); if (o >= 0) hit.add(o); } }
    }
    return { hit, carved, J };
  }
  function scorch(cx, cy, r, wid) {                                       // cincin hitam tipis + dither memudar keluar (hanya sel yang masih utuh)
    const E2 = Math.ceil(r + wid + 2);
    for (let y = Math.max(0, cy - E2); y <= Math.min(H - 1, cy + E2); y++) for (let x = Math.max(0, cx - E2); x <= Math.min(W - 1, cx + E2); x++) {
      const i = y * W + x; if (hole[i]) continue;
      let md = 99; for (let j = -3; j <= 3 && md > 1.5; j++) for (let k = -3; k <= 3; k++) if (alive(x + k, y + j) === false && x + k >= 0 && y + j >= 0 && x + k < W && y + j < H) md = Math.min(md, Math.hypot(j, k));
      if (md > wid) continue;
      if (md <= 1.5) { mul(i, 0.2); if (R() < 0.5) heat.push({ i, t: R() * 0.1 }); } else { const t = (md - 1.5) / (wid - 1.5); if (0.9 * Math.pow(1 - t, 1.15) > PX.bayer(x, y)) mul(i, 0.4); }
    }
  }
  function crack(x, y, bi, level, len) {                                  // retakan: jalan acak di dalam elemen, bercabang sesuai level
    for (let b = 0; b < level + 1; b++) { let px = x, py = y, a = R() * 6.283; for (let s = 0; s < len; s++) { a += (R() - 0.5) * 0.9; px += Math.cos(a); py += Math.sin(a); const i = Math.round(py) * W + Math.round(px); if (Math.round(px) < 0 || Math.round(py) < 0 || Math.round(px) >= W || Math.round(py) >= H) break; if (owner[i] === bi && !hole[i]) mul(i, 0.35); } }
  }
  function detach(bi, ix, iy, ex) {                                       // elemen copot: sel tersisa jadi sprite yang jatuh berputar
    const b = blocks[bi]; b.alive = false; const sp = document.createElement('canvas'); sp.width = b.w; sp.height = b.h; const sc = sp.getContext('2d'), id = sc.createImageData(b.w, b.h);
    for (let y = 0; y < b.h; y++) for (let x = 0; x < b.w; x++) { const i = (b.y + y) * W + b.x + x; if (owner[i] === bi && !hole[i]) { id.data.set(img.data.subarray(i * 4, i * 4 + 4), (y * b.w + x) * 4); hole[i] = 1; } }
    sc.putImageData(id, 0, 0); shade(b.x - 6, b.y - 6, b.x + b.w + 6, b.y + b.h + 6);
    const area = Math.max(1, b.w * E * b.h * E), sp0 = Math.max(15, Math.min(380, 2600 / Math.sqrt(area))) / E, dx = b.x + b.w / 2 - ix, dy = b.y + b.h / 2 - iy, d = Math.hypot(dx, dy) || 1;
    chunks.push({ sp, w: b.w, h: b.h, x: b.x + b.w / 2, y: b.y + b.h / 2, vx: (dx / d) * sp0 * 1.4, vy: (dy / d) * sp0 * 1.4 - 60, a: 0, av: (R() * 3 - 1.5) * Math.min(3, 300 / Math.sqrt(area)), t: 0 });
    for (let k = 0; k < 14; k++) debris.push({ x: b.x + R() * b.w, y: b.y + R() * b.h, vx: (R() - 0.5) * 60, vy: -R() * 50, life: 0.5 + R() * 0.5, c: [200, 200, 210] });
  }
  function damageBlock(bi, dmg, ix, iy) {
    const b = blocks[bi]; if (!b || !b.alive) return;
    const u0 = b.damage / b.hp; b.damage += dmg; const u1 = b.damage / b.hp; b.flash = 0.25;
    [0.3, 0.55, 0.8].forEach((th, lv) => { if (u0 < th && u1 >= th) crack(PX.clamp(ix, b.x + 3, b.x + b.w - 3), PX.clamp(iy, b.y + 3, b.y + b.h - 3), bi, 2 + lv, Math.min(34, 5 + (Math.sqrt(b.w * b.h) * (0.2 + lv * 0.1)))); });
    if (!b.canDetach || (b.damage < b.hp && b.lost < b.cells * 0.38)) return;
    detach(bi, ix, iy);
  }
  const WEAPONS = { peluru: { r: 2.4, irr: 0.42, dmg: 14, tunnel: true }, roket: { r: 11, irr: 0.16, dmg: 90, splash: 34 }, bom: { r: 20, irr: 0.16, dmg: 170, splash: 60 } };
  let weapon = 'roket';
  function fire(x, y, wk = weapon) {
    const w = WEAPONS[wk], res = carve(x, y, w.r, w.irr), hit = res.hit;
    if (w.tunnel) {                                                       // peluru: tembus selama ada sel padat (2 lubang lanjutan makin kecil)
      const gx = 6, gy = H - 8, d = Math.hypot(x - gx, y - gy) || 1, ux = (x - gx) / d, uy = (y - gy) / d;
      for (const [j, t] of [[0.9, 0.72], [1.75, 0.5]]) { const ex = x + ux * w.r * j * 2.2, ey = y + uy * w.r * j * 2.2; if (alive(Math.floor(ex), Math.floor(ey))) { const r2 = carve(ex, ey, w.r * t, 0.5); r2.hit.forEach((o) => hit.add(o)); } }
      scorch(Math.round(x), Math.round(y), w.r, 2);
    } else scorch(Math.round(x), Math.round(y), w.r, 7);
    shade(Math.floor(x - w.r * 3), Math.floor(y - w.r * 3), Math.ceil(x + w.r * 3), Math.ceil(y + w.r * 3));
    hit.forEach((o) => damageBlock(o, w.dmg, x, y));
    if (w.splash) blocks.forEach((b, o) => { if (!b.alive || hit.has(o) || o === 0) return; const dx = Math.max(b.x - x, 0, x - (b.x + b.w)), dy = Math.max(b.y - y, 0, y - (b.y + b.h)), d = Math.hypot(dx, dy); if (d < w.splash) damageBlock(o, w.dmg * 0.7 * (1 - d / w.splash), x, y); });
    if (w.splash) for (let i = 0; i < 6; i++) { const a = R() * 6.283, v = 40 + R() * 100; debris.push({ x, y, vx: Math.cos(a) * v, vy: Math.sin(a) * v - 40, life: 0.3 + R() * 0.4, c: [255, 190 + R() * 60, 80] }); }
  }
  build();
  const ui = PX.ui(host);
  ui.select('Senjata', Object.keys(WEAPONS), 'roket', (v) => (weapon = v)); ui.button('Reset', build);
  const sx = document.createElement('canvas'); sx.width = W; sx.height = H; const sc = sx.getContext('2d'); let idle = 1, rst = 0;
  PX.loop(host, (dt) => {
    for (const k of p.clicks.splice(0)) { fire(k.x, k.y); rst = 0; }
    idle -= dt; rst += dt; if (idle <= 0 && !p.in) { idle = 1.4; const b = blocks[2 + ((R() * 7) | 0)]; if (b && b.alive) fire(b.x + R() * b.w, b.y + R() * b.h, ['peluru', 'roket', 'bom'][(R() * 3) | 0]); }
    if (rst > 22 && !p.in) { build(); rst = 0; }
    sc.putImageData(img, 0, 0); c.drawImage(sx, 0, 0);
    for (const h of heat) { h.t += dt; if (h.t < 0.75 && !hole[h.i]) { c.fillStyle = h.t < 0.3 ? '#ffb24a' : '#d9561c'; c.globalAlpha = (1 - h.t / 0.75) * 0.9; c.fillRect(h.i % W, (h.i / W) | 0, 1, 1); } }
    c.globalAlpha = 1; while (heat.length && heat[0].t > 0.75) heat.shift();
    c.imageSmoothingEnabled = false;
    for (let i = chunks.length - 1; i >= 0; i--) { const q = chunks[i]; q.t += dt; q.x += q.vx * dt; q.y += q.vy * dt; q.vy += 320 * dt; q.a += q.av * dt; if (q.t > 2.2 || q.y > H + 60) { chunks.splice(i, 1); continue; } c.save(); c.globalAlpha = Math.min(1, (2.2 - q.t) * 2); c.translate(Math.round(q.x), Math.round(q.y)); c.rotate(q.a); c.drawImage(q.sp, -q.w / 2, -q.h / 2); c.restore(); }
    for (let i = debris.length - 1; i >= 0; i--) { const d = debris[i]; d.x += d.vx * dt; d.y += d.vy * dt; d.vy += 260 * dt; d.life -= dt; if (d.life <= 0) { debris.splice(i, 1); continue; } c.fillStyle = PX.rgb(d.c); c.fillRect(Math.round(d.x), Math.round(d.y), 1, 1); }
    for (const b of blocks) { if (!b.alive || b.kind === 'halaman') continue; b.flash = Math.max(0, b.flash - dt); const k = Math.max(0, 1 - b.damage / b.hp);   // bar hp: terlihat saat baru kena
      if (b.damage > 0 && b.flash > 0 || (b.damage > 0 && k < 0.999)) { c.globalAlpha = b.flash > 0 ? 1 : 0.55; c.fillStyle = '#000'; c.fillRect(b.x + 1, b.y - 3 < 0 ? b.y + b.h + 1 : b.y - 3, Math.min(b.w - 2, 30) + 2, 3); c.fillStyle = k > 0.5 ? '#6dff9a' : k > 0.25 ? '#ffd24a' : '#ff5a4a'; c.fillRect(b.x + 2, b.y - 3 < 0 ? b.y + b.h + 2 : b.y - 2, Math.round(Math.min(b.w - 2, 30) * k), 1); c.globalAlpha = 1; } }
  });
};
```

