Make Sprite With Claude

Efek (VFX)

Hujan prosedural 3 lapis kedalaman

Tetes dua warna, tiga lapis kecepatan, kabut tanah ber-noise, percikan setengah elips, dan lapis depan yang menutupi karakter.

Versi mentah untuk AI: md/rain.md

Ide diambil dari rain.ts oleh Sprite Fusion (lisensi MIT, © 2026 Hugo Duprez). Implementasi di bawah ditulis ulang untuk situs ini.

Mengapa terlihat bagus

  1. Tiga lapis kedalaman. Jauh / tengah / dekat berbeda dalam kecepatan (380 / 525 / 725), panjang (3 / 6 / 12 piksel), dan warna. Semakin dekat, semakin cepat, panjang, dan terang.
  2. Tetes = dua segmen. 60% ekor warna gelap + 40% kepala warna terang. Satu garis polos terlihat datar; dua tone memberi arah gerak.
  3. Lapis jauh berhenti di udara. Tiap tetes lapis jauh punya stop acak 5–70 piksel di atas tanah lalu respawn. Tidak ada percikan, sehingga terkesan jauh di belakang.
  4. Hanya lapis depan menyentuh tanah dengan percikan penuh (lapis tengah 60%). Percikan = cincin setengah elips (ry = 0.25·rx) yang melebar rx = 1 + 5·√u, digambar 3 lintasan radius (±0.5) agar tebal dan alpha turun. Elips berada di bawah garis tanah, jadi tanah harus digambar sebelum hujan belakang, bukan sesudahnya.
  5. Alpha menjadi dither. dot(x,y,c,a) menggambar hanya jika a > bayer(x,y). Tidak ada piksel semi-transparan.
  6. Kabut tanah: noise 1D dua frekuensi yang bergerak berlawanan arah, tinggi 9 piksel, alpha ≤ 0.32 → dither.
  7. Serpihan air: 0–2 partikel 1px per tetes dengan gravitasi, hidup 0.18–0.32 detik.
  8. Tanah tak rata. ground boleh angka atau fungsi x → y. Tetes, percikan, serpihan, dan kabut semuanya membaca gy(x), jadi hujan mengikuti bukit dan tangga.
  9. Urutan gambar: latar + tanah → lapis jauh/tengah + kabut + percikan → karakter → lapis depan. Tetes yang lewat di depan karakter memberi kesan ruang.
  10. Reda bertahap. stop() menghentikan respawn; tetes yang sudah ada habis sendiri dan done bernilai true saat semuanya hilang, tanpa memotong hujan mendadak.
  11. Sebaran mendatar mengikuti kemiringan. Margin spawn |slant|·(tinggi+110) ditaruh di sisi hulu angin, sehingga hujan miring kanan tidak meninggalkan sisi kiri kosong.

Semua tetes direspawn dari atas (reset(d, true)), jadi jumlah partikel konstan, tanpa alokasi per frame.

Demo interaktif butuh JavaScript. Kode lengkap ada di bawah.

Kode

Kode: src/code/47-demo-rain.js
// Hujan prosedural 3 lapis kedalaman: kecepatan/panjang/warna beda per lapis, kabut tanah ber-noise + Bayer,
// percikan setengah elips yang melebar, serpihan air berparabola, tanah tak rata (ground = fungsi x), stop() bertahap.
// Urutan gambar: latar + tanah -> drawBack (tetes jauh/tengah, kabut, percikan) -> karakter -> drawFront (tetes dekat).
PX.noise1 = (x, s = 0) => { const i = Math.floor(x), f = x - i, u = f * f * (3 - 2 * f), a = PX.hash(i, 0, s), b = PX.hash(i + 1, 0, s); return (a + (b - a) * u) * 2 - 1; };

PX.Rain = class {
  constructor({ w = 160, ground = 76, density = 1, slant = -0.22, seed = 1 } = {}) {
    this.w = w; this.gy = typeof ground === 'function' ? ground : () => ground;   // ground: angka, atau fungsi x -> y permukaan tanah
    this.G = 0; for (let x = 0; x < w; x++) this.G = Math.max(this.G, this.gy(x));
    this.slant = slant; this.t = 0; this.stopped = false; this.R = PX.rng(seed);
    this.C = { far: '#38446e', farH: '#56659a', mid: '#5f74ab', midH: '#9ab0e0', near: '#6f86bd', nearH: '#d6e5ff', mist: '#4b5a8c' };
    for (const k in this.C) this.C[k] = PX.hex(this.C[k]);
    this.SPEED = [380, 525, 725]; this.LEN = [3, 6, 12];
    const m = Math.abs(slant) * (this.G + 110) + 10;      // margin spawn mendatar: sebaran tetap merata untuk kemiringan apa pun
    this.x0 = slant > 0 ? -m : 0; this.x1 = w + (slant < 0 ? m : 0);
    this.drops = []; this.splashes = []; this.bits = [];
    [[0, 120], [1, 64], [2, 20]].forEach(([layer, n]) => { for (let i = 0; i < Math.round(n * density); i++) { const d = { layer }; this.reset(d, false); this.drops.push(d); } });
  }
  rg(a, b) { return a + (b - a) * this.R(); }
  reset(d, top) {
    d.x = this.rg(this.x0, this.x1);
    const g = this.gy(d.x);
    d.y = top ? -this.rg(0, 100) : this.rg(-100, g);
    d.sp = this.SPEED[d.layer] * this.rg(0.9, 1.1);
    d.stop = g - this.rg(5, 70);                          // lapis jauh berhenti di udara = ilusi kedalaman
  }
  again(i) { if (this.stopped) this.drops.splice(i, 1); else this.reset(this.drops[i], true); }
  stop() { this.stopped = true; }                         // tetes tidak direspawn -> hujan reda bertahap
  get done() { return this.stopped && !this.drops.length && !this.bits.length; }
  update(dt) {
    this.t += dt;
    const L = Math.hypot(this.slant, 1), ux = this.slant / L, uy = 1 / L;
    for (let i = this.drops.length - 1; i >= 0; i--) {
      const d = this.drops[i]; d.x += ux * d.sp * dt; d.y += uy * d.sp * dt;
      const g = this.gy(d.x);
      if (d.layer === 0 && d.y > d.stop) this.again(i);
      else if (d.y >= g) {
        if (d.x > -10 && d.x < this.w + 10) {
          if (d.layer === 2 || this.R() < 0.6) this.splashes.push({ x: d.x, t: this.t });
          for (let k = 0, n = d.layer === 2 ? 3 : Math.floor(this.rg(0, 2.99)); k < n; k++) this.bits.push({ x: d.x, y: g - 1, vx: this.rg(-45, 35), vy: -this.rg(55, 115), life: this.rg(0.18, 0.32) });
        }
        this.again(i);
      }
    }
    for (let i = this.bits.length - 1; i >= 0; i--) { const b = this.bits[i]; b.life -= dt; b.vy += 950 * dt; b.x += b.vx * dt; b.y += b.vy * dt; if (b.life <= 0 || b.y > this.gy(b.x)) this.bits.splice(i, 1); }
    this.splashes = this.splashes.filter((s) => this.t - s.t <= 0.4);
  }
  dot(buf, x, y, c, a = 1) { const px = Math.floor(x), py = Math.floor(y); if (a >= 1 || a > PX.bayer(px, py)) buf.px(px, py, c); }   // alpha -> dither
  line(buf, x0, y0, x1, y1, c) { const n = Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0)))); for (let i = 0; i <= n; i++) buf.px(x0 + ((x1 - x0) * i) / n, y0 + ((y1 - y0) * i) / n, c); }
  splash(buf, cx, cy, rx, ry, c, a) {                     // setengah elips bawah garis tanah, 3 lintasan radius -> tebal ~1 piksel
    const n = Math.ceil(Math.max(rx, ry) * Math.PI * 2 * 1.3);
    for (let j = 0; j < n; j++) {
      const ang = (j / n) * Math.PI * 2, ca = Math.cos(ang), sa = Math.sin(ang);
      if (sa < -0.2) continue;
      for (let o = -0.5; o <= 0.5; o += 0.5) this.dot(buf, cx + ca * (rx + o), cy + sa * (ry + o * (ry / Math.max(1, rx))), c, a);
    }
  }
  layer(buf, k) {
    const L = Math.hypot(this.slant, 1), ux = this.slant / L, uy = 1 / L, len = this.LEN[k], body = [this.C.far, this.C.mid, this.C.near][k], head = [this.C.farH, this.C.midH, this.C.nearH][k];
    for (const d of this.drops) if (d.layer === k) {      // tetes = ekor gelap (60%) + kepala terang (40%)
      this.line(buf, d.x - ux * len, d.y - uy * len, d.x - ux * len * 0.4, d.y - uy * len * 0.4, body);
      this.line(buf, d.x - ux * len * 0.4, d.y - uy * len * 0.4, d.x, d.y, head);
    }
  }
  drawBack(buf) {
    this.layer(buf, 0); this.layer(buf, 1);
    const t = this.t;
    for (let x = 0; x < this.w; x++) {                    // kabut tanah mengikuti kontur tanah
      const fy = Math.round(this.gy(x)), m = (PX.noise1(x * 0.06 + t * 0.5, 3) + PX.noise1(x * 0.15 - t * 0.8, 4) * 0.5 + 1) / 2.5;
      if (PX.hash(x, Math.floor(t * 8)) < 0.04) this.dot(buf, x, fy, this.C.midH);
      for (let r = 0; r < 9; r++) this.dot(buf, x, fy - r, this.C.mist, m * 0.32 * (1 - r / 9));
    }
    for (const s of this.splashes) { const u = (t - s.t) / 0.4, rx = 1 + 5 * Math.sqrt(u); this.splash(buf, s.x, this.gy(s.x), rx, rx * 0.25, u < 0.3 ? this.C.midH : this.C.mid, 1 - u); }
    for (const b of this.bits) this.dot(buf, b.x, b.y, this.C.nearH);
  }
  drawFront(buf) { this.layer(buf, 2); }
};

PX.demos.rain = (host) => {
  const W = 160, H = 90, cv = PX.canvas(host, W, H, 640, false, 'Urutan gambar: latar + tanah → hujan belakang (tetes jauh/tengah, kabut, percikan) → karakter → hujan depan.'), c = cv.getContext('2d');
  const gy = (x) => 72 + Math.round(2.5 * Math.sin(x * 0.045) + 1.5 * Math.sin(x * 0.11 + 1.3));   // tanah tak rata
  const mk = () => { const e = document.createElement('canvas'); e.width = W; e.height = H; return e; }, bg = mk(), ch = mk(), oc = mk(), o = oc.getContext('2d');
  { // latar statis: langit dither, dua lapis bukit, tanah
    const b = new PX.Buf(W, H), hill = (x, a, p, q) => a + Math.round(6 * Math.sin(x * p + q) + 3 * Math.sin(x * p * 3));
    for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
      let col = Math.min(1, y / (H * 0.85)) > PX.bayer(x, y) ? [38, 50, 92] : [14, 18, 40];
      if (y >= hill(x, 58, 0.03, 1)) col = [24, 30, 62];
      if (y >= hill(x, 66, 0.05, 2)) col = [20, 26, 54];
      const g = gy(x);
      if (y >= g) col = y === g ? [58, 69, 111] : PX.hash(x, y) < 0.08 ? [34, 40, 66] : [27, 32, 54];
      b.px(x, y, col, 1);
    }
    b.flush(bg.getContext('2d'));
  }
  { // karakter berjubah memakai PX.drawSprite (outline + bayangan)
    const cloak = PX.fromAscii(['..cccc..', '.cccccc.', '.cefecc.', '.cffffc.', '..cccc..', '.cccccc.', 'cccccccc', 'cccccccc', 'cccccccc', 'cccccccc', 'cccccccc', '.cc..cc.', '.bb..bb.'],
      { c: { col: [58, 64, 104] }, f: { col: [40, 44, 70] }, e: { col: [143, 243, 255] }, b: { col: [34, 36, 58] } });
    const b = new PX.Buf(W, H); PX.drawSprite(b, cloak, 76 - 4, gy(76) - cloak.h + 1, { outline: [14, 12, 28], shadow: 0.28 }); b.flush(ch.getContext('2d'));
  }
  const back = new PX.Buf(W, H), front = new PX.Buf(W, H), opt = { density: 1, slant: -0.22 };
  let rain, btn;
  const rebuild = () => { rain = new PX.Rain({ w: W, ground: gy, ...opt }); if (btn) btn.textContent = 'Hentikan hujan'; };
  rebuild();
  const ui = PX.ui(host);
  ui.range('Kepadatan', 0.5, 2.5, 0.5, 1, (v) => { opt.density = v; rebuild(); });
  ui.select('Kemiringan', [{ label: 'miring kiri', value: -0.22 }, { label: 'tegak', value: 0 }, { label: 'miring kanan', value: 0.3 }], -0.22, (v) => { opt.slant = +v; rebuild(); });
  btn = ui.button('Hentikan hujan', () => { if (rain.stopped) rebuild(); else { rain.stop(); btn.textContent = 'Mulai hujan'; } });
  const flush = (b) => { b.flush(o); c.drawImage(oc, 0, 0); };
  PX.loop(host, (dt) => {
    rain.update(dt); back.clear(); front.clear(); rain.drawBack(back); rain.drawFront(front);
    c.drawImage(bg, 0, 0); flush(back); c.drawImage(ch, 0, 0); flush(front);
  });
};