RSLAB

shitlab · 01

Water Tank

Click to pour water from the cursor, push it around with the mouse. Particle physics on a 2d canvas, goo filter for the liquid look. One file, zero dependencies, copy it and mess with it.

clique pour verser · bouge pour pousser

How to rebuild it

components/WaterPlayground.tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";

type Particle = { x: number; y: number; vx: number; vy: number };

const RADIUS = 6;
const MIN_DIST = RADIUS * 1.8;

export default function WaterPlayground() {
  const t = useTranslations("minimal");
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const wrapRef = useRef<HTMLDivElement>(null);
  const [interacted, setInteracted] = useState(false);
  const [canReset, setCanReset] = useState(false);
  const canResetRef = useRef(false);
  const resetRef = useRef<() => void>(() => {});

  useEffect(() => {
    const canvas = canvasRef.current;
    const wrap = wrapRef.current;
    if (!canvas || !wrap) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    let w = 0;
    let h = 0;
    let raf = 0;
    let running = false;
    let maxParts = 500;
    const parts: Particle[] = [];
    const mouse = { x: 0, y: 0, px: 0, py: 0, in: false };
    let pouring = false;
    let liquid = "#422419";

    const readColors = () => {
      liquid = document.documentElement.classList.contains("dark")
        ? "#4FB6F0"
        : "#38A2E8";
    };
    readColors();
    resetRef.current = () => {
      parts.length = 0;
      canResetRef.current = false;
      setCanReset(false);
    };
    const themeObs = new MutationObserver(readColors);
    themeObs.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });

    const resize = () => {
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      const r = wrap.getBoundingClientRect();
      w = r.width;
      h = r.height;
      canvas.width = Math.round(w * dpr);
      canvas.height = Math.round(h * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      // enough particles to fill the whole container when poured
      maxParts = Math.min(
        2600,
        Math.ceil(((w * h) / (MIN_DIST * MIN_DIST)) * 1.15)
      );
    };
    resize();
    const resObs = new ResizeObserver(resize);
    resObs.observe(wrap);

    const step = () => {
      const mvx = mouse.x - mouse.px;
      const mvy = mouse.y - mouse.py;
      mouse.px = mouse.x;
      mouse.py = mouse.y;

      if (pouring) {
        // tap stream: a thin jet falling from just below the cursor
        for (let k = 0; k < 3; k++) {
          if (parts.length >= maxParts) break;
          parts.push({
            x: mouse.x + (Math.random() - 0.5) * 5,
            y: mouse.y + 10 + Math.random() * 6,
            vx: (Math.random() - 0.5) * 0.5,
            vy: 1.8 + Math.random() * 1.4,
          });
        }
      }

      // forces
      for (const p of parts) {
        p.vy += 0.22;
        if (mouse.in) {
          const dx = p.x - mouse.x;
          const dy = p.y - mouse.y;
          const R = 48;
          const d2 = dx * dx + dy * dy;
          if (d2 < R * R) {
            const d = Math.sqrt(d2) || 1;
            const f = 1 - d / R;
            p.vx += (dx / d) * f * 1.5 + mvx * 0.28 * f;
            p.vy += (dy / d) * f * 1.5 + mvy * 0.28 * f;
          }
        }
        p.vx *= 0.96;
        p.vy *= 0.985;
        p.x += p.vx;
        p.y += p.vy;
      }

      // particle separation via spatial hash
      const cell = RADIUS * 2;
      const grid = new Map<string, number[]>();
      for (let i = 0; i < parts.length; i++) {
        const k = `${(parts[i].x / cell) | 0},${(parts[i].y / cell) | 0}`;
        const a = grid.get(k);
        if (a) a.push(i);
        else grid.set(k, [i]);
      }
      const minDist = MIN_DIST;
      for (let i = 0; i < parts.length; i++) {
        const p = parts[i];
        const cx = (p.x / cell) | 0;
        const cy = (p.y / cell) | 0;
        for (let ox = -1; ox <= 1; ox++) {
          for (let oy = -1; oy <= 1; oy++) {
            const bucket = grid.get(`${cx + ox},${cy + oy}`);
            if (!bucket) continue;
            for (const j of bucket) {
              if (j <= i) continue;
              const q = parts[j];
              const dx = q.x - p.x;
              const dy = q.y - p.y;
              const d2 = dx * dx + dy * dy;
              if (d2 > 0 && d2 < minDist * minDist) {
                const d = Math.sqrt(d2);
                const push = ((minDist - d) / d) * 0.2;
                const px = dx * push;
                const py = dy * push;
                p.x -= px;
                p.y -= py;
                q.x += px;
                q.y += py;
              }
            }
          }
        }
      }

      // walls + rest state so still water actually stays still
      for (const p of parts) {
        if (p.x < RADIUS) {
          p.x = RADIUS;
          p.vx *= -0.3;
        } else if (p.x > w - RADIUS) {
          p.x = w - RADIUS;
          p.vx *= -0.3;
        }
        if (p.y > h - RADIUS) {
          p.y = h - RADIUS;
          p.vy = Math.abs(p.vy) < 0.6 ? 0 : p.vy * -0.2;
          p.vx *= 0.9;
        } else if (p.y < RADIUS) {
          p.y = RADIUS;
          p.vy = 0;
        }
        if (p.vx * p.vx + p.vy * p.vy < 0.012) {
          p.vx = 0;
          p.vy = 0;
        }
      }

      // reset affordance once there is a real amount of water
      if (!canResetRef.current && parts.length > maxParts * 0.12) {
        canResetRef.current = true;
        setCanReset(true);
      }

      // render
      ctx.clearRect(0, 0, w, h);
      ctx.fillStyle = liquid;
      ctx.beginPath();
      for (const p of parts) {
        ctx.moveTo(p.x + RADIUS + 1, p.y);
        ctx.arc(p.x, p.y, RADIUS + 1, 0, Math.PI * 2);
      }
      ctx.fill();

      raf = requestAnimationFrame(step);
    };

    const start = () => {
      if (!running) {
        running = true;
        raf = requestAnimationFrame(step);
      }
    };
    const stop = () => {
      running = false;
      cancelAnimationFrame(raf);
    };

    const io = new IntersectionObserver(
      ([e]) => (e.isIntersecting ? start() : stop()),
      { threshold: 0 }
    );
    io.observe(wrap);

    const toLocal = (e: PointerEvent) => {
      const r = wrap.getBoundingClientRect();
      mouse.x = e.clientX - r.left;
      mouse.y = e.clientY - r.top;
    };
    const onDown = (e: PointerEvent) => {
      if ((e.target as HTMLElement).closest("button")) return;
      toLocal(e);
      mouse.px = mouse.x;
      mouse.py = mouse.y;
      mouse.in = true;
      pouring = true;
      wrap.setPointerCapture(e.pointerId);
    };
    const onMove = (e: PointerEvent) => {
      toLocal(e);
      mouse.in = true;
    };
    const onUp = () => {
      pouring = false;
    };
    const onLeave = () => {
      pouring = false;
      mouse.in = false;
    };
    wrap.addEventListener("pointerdown", onDown);
    wrap.addEventListener("pointermove", onMove);
    wrap.addEventListener("pointerup", onUp);
    wrap.addEventListener("pointerleave", onLeave);

    return () => {
      stop();
      io.disconnect();
      resObs.disconnect();
      themeObs.disconnect();
      wrap.removeEventListener("pointerdown", onDown);
      wrap.removeEventListener("pointermove", onMove);
      wrap.removeEventListener("pointerup", onUp);
      wrap.removeEventListener("pointerleave", onLeave);
    };
  }, []);

  return (
    <div
      ref={wrapRef}
      onPointerDown={() => setInteracted(true)}
      className="relative h-[280px] touch-none select-none overflow-hidden rounded-2xl border border-border bg-card"
    >
      <svg aria-hidden className="absolute h-0 w-0">
        <defs>
          <filter id="goo-water">
            <feGaussianBlur in="SourceGraphic" stdDeviation="7" result="blur" />
            <feColorMatrix
              in="blur"
              mode="matrix"
              values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 19 -9"
            />
          </filter>
        </defs>
      </svg>
      <canvas
        ref={canvasRef}
        className="absolute inset-0 h-full w-full"
        style={{ filter: "url(#goo-water)" }}
      />
      <span
        className={`pointer-events-none absolute inset-x-0 bottom-3 text-center font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground transition-opacity duration-500 ${
          interacted ? "opacity-0" : "opacity-100"
        }`}
      >
        {t("playgroundHint")}
      </span>
      <button
        type="button"
        aria-label="Reset water"
        onPointerDown={(e) => e.stopPropagation()}
        onClick={(e) => {
          e.stopPropagation();
          resetRef.current();
        }}
        className={`absolute right-2.5 top-2.5 flex h-7 w-7 items-center justify-center rounded-full border border-border bg-background/80 text-sm leading-none text-muted-foreground backdrop-blur transition-all duration-300 hover:text-foreground ${
          canReset ? "scale-100 opacity-100" : "pointer-events-none scale-75 opacity-0"
        }`}
      >
        ×
      </button>
    </div>
  );
}
rselmi.com