← back to Games Agentabrams

games/rope-physics/VerletRope.aa

350 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verlet Rope · Physics Toy</title>
<style>
  :root{
    --bg0:#0b0f1a; --bg1:#151b2e; --ink:#e8ecf7; --muted:#8a93ad;
    --accent:#5ad1ff; --accent2:#ff5a8a; --line:rgba(255,255,255,.08);
  }
  *{box-sizing:border-box}
  html,body{margin:0;height:100%;overflow:hidden;background:var(--bg0);color:var(--ink);
    font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
    -webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent}
  #stage{position:fixed;inset:0}
  canvas{display:block;width:100%;height:100%;touch-action:none;cursor:grab}
  canvas.cut{cursor:crosshair}
  canvas:active{cursor:grabbing}

  .hud{position:fixed;top:16px;left:16px;right:16px;display:flex;gap:14px;
    align-items:flex-start;justify-content:space-between;pointer-events:none;flex-wrap:wrap}
  .panel{pointer-events:auto;background:linear-gradient(180deg,rgba(21,27,46,.86),rgba(11,15,26,.86));
    backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);
    border:1px solid var(--line);border-radius:14px;padding:12px 14px;
    box-shadow:0 10px 40px rgba(0,0,0,.45)}
  .title{font-weight:700;letter-spacing:.3px;font-size:15px;display:flex;align-items:center;gap:9px}
  .dot{width:9px;height:9px;border-radius:50%;background:var(--accent);
    box-shadow:0 0 12px var(--accent)}
  .hint{color:var(--muted);font-size:12px;margin-top:5px;line-height:1.5;max-width:280px}
  .controls{display:flex;gap:9px;flex-wrap:wrap;align-items:center}
  button{font:inherit;font-size:13px;font-weight:600;color:var(--ink);cursor:pointer;
    border:1px solid var(--line);background:rgba(255,255,255,.05);
    padding:9px 14px;border-radius:10px;transition:.15s;display:inline-flex;align-items:center;gap:7px}
  button:hover{background:rgba(255,255,255,.11);transform:translateY(-1px)}
  button:active{transform:translateY(0)}
  button.on{background:var(--accent2);border-color:transparent;color:#fff;
    box-shadow:0 4px 18px rgba(255,90,138,.5)}
  button.on .ic{filter:none}
  .ic{font-size:15px;line-height:1}
  .seg{display:flex;gap:6px;align-items:center;color:var(--muted);font-size:12px}
  input[type=range]{-webkit-appearance:none;appearance:none;width:96px;height:4px;border-radius:4px;
    background:rgba(255,255,255,.18);outline:none}
  input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:15px;height:15px;border-radius:50%;
    background:var(--accent);cursor:pointer;box-shadow:0 0 8px var(--accent)}
  input[type=range]::-moz-range-thumb{width:15px;height:15px;border:none;border-radius:50%;
    background:var(--accent);cursor:pointer}
  .stats{position:fixed;bottom:14px;left:16px;color:var(--muted);font-size:11px;
    font-variant-numeric:tabular-nums;letter-spacing:.4px;pointer-events:none}
  kbd{background:rgba(255,255,255,.09);border:1px solid var(--line);border-bottom-width:2px;
    border-radius:5px;padding:1px 6px;font-family:inherit;font-size:11px}
</style>
</head>
<body>
<div id="stage">
  <canvas id="c"></canvas>
</div>

<div class="hud">
  <div class="panel">
    <div class="title"><span class="dot"></span>Verlet Rope Toy</div>
    <div class="hint">Drag any point to swing the rope. Toggle <b>Cut</b> and slice across
      strands to sever them. Everything is verlet-integrated cloth physics — no libraries.</div>
  </div>
  <div class="panel controls">
    <button id="cutBtn" title="Toggle cut tool"><span class="ic">✂️</span><span>Cut</span></button>
    <button id="resetBtn" title="Rebuild the rope"><span class="ic">↺</span><span>Reset</span></button>
    <div class="seg"><span>Gravity</span><input id="grav" type="range" min="0" max="200" value="70"></div>
    <div class="seg"><span>Wind</span><input id="wind" type="range" min="0" max="100" value="0"></div>
  </div>
</div>

<div class="stats" id="stats"></div>

<script>
(() => {
  "use strict";
  const canvas = document.getElementById('c');
  const ctx = canvas.getContext('2d');
  const statsEl = document.getElementById('stats');
  const cutBtn = document.getElementById('cutBtn');
  const resetBtn = document.getElementById('resetBtn');
  const gravSlider = document.getElementById('grav');
  const windSlider = document.getElementById('wind');

  let W = 0, H = 0, DPR = 1;
  function resize(){
    DPR = Math.min(window.devicePixelRatio || 1, 2);
    W = window.innerWidth; H = window.innerHeight;
    canvas.width = W * DPR; canvas.height = H * DPR;
    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
  }
  window.addEventListener('resize', () => { resize(); rebuild(); });

  // ---------- Physics model ----------
  // Points: {x,y,px,py,pinned}   Sticks: {a,b,len,live}
  let points = [], sticks = [];
  const FRICTION = 0.995;          // velocity damping
  const BOUNCE = 0.9;
  let gravity = 70, wind = 0;

  function makePoint(x, y, pinned=false){
    return { x, y, px:x, py:y, pinned, ox:x, oy:y };
  }
  function makeStick(a, b){
    const p = points[a], q = points[b];
    const len = Math.hypot(p.x - q.x, p.y - q.y);
    return { a, b, len, live:true };
  }

  // Build a hanging rope grid: several ropes pinned at the top,
  // cross-linked into a light cloth-like lattice.
  function rebuild(){
    points = []; sticks = [];
    const cols = Math.max(5, Math.min(11, Math.round(W / 90)));
    const rows = Math.max(9, Math.min(20, Math.round(H / 55)));
    const spanW = Math.min(W * 0.72, cols * 70);
    const spacing = spanW / (cols - 1);
    const startX = (W - spanW) / 2;
    const startY = H * 0.14;

    const idx = (r, c) => r * cols + c;
    for (let r = 0; r < rows; r++){
      for (let c = 0; c < cols; c++){
        const pinned = (r === 0) && (c % 2 === 0);
        points.push(makePoint(startX + c * spacing, startY + r * spacing * 0.8, pinned));
      }
    }
    for (let r = 0; r < rows; r++){
      for (let c = 0; c < cols; c++){
        if (c < cols - 1) sticks.push(makeStick(idx(r,c), idx(r,c+1)));   // horizontal
        if (r < rows - 1) sticks.push(makeStick(idx(r,c), idx(r+1,c)));   // vertical
      }
    }
  }

  // ---------- Interaction ----------
  let cutMode = false;
  let dragPoint = null;
  const mouse = { x:0, y:0, px:0, py:0, down:false };

  function pointerPos(e){
    const t = e.touches ? e.touches[0] : e;
    return { x: t.clientX, y: t.clientY };
  }
  function nearestPoint(x, y, maxD){
    let best = null, bd = maxD * maxD;
    for (const p of points){
      const dx = p.x - x, dy = p.y - y, d = dx*dx + dy*dy;
      if (d < bd){ bd = d; best = p; }
    }
    return best;
  }

  function down(e){
    e.preventDefault();
    const {x,y} = pointerPos(e);
    mouse.x = mouse.px = x; mouse.y = mouse.py = y; mouse.down = true;
    if (!cutMode){
      dragPoint = nearestPoint(x, y, 40);
    }
  }
  function move(e){
    const {x,y} = pointerPos(e);
    mouse.px = mouse.x; mouse.py = mouse.y;
    mouse.x = x; mouse.y = y;
    if (cutMode && mouse.down) sliceAt(mouse.px, mouse.py, x, y);
  }
  function up(){ mouse.down = false; dragPoint = null; }

  // Segment intersection for the cutting blade
  function segHit(x1,y1,x2,y2, x3,y3,x4,y4){
    const d = (x2-x1)*(y4-y3) - (y2-y1)*(x4-x3);
    if (d === 0) return false;
    const t = ((x3-x1)*(y4-y3) - (y3-y1)*(x4-x3)) / d;
    const u = ((x3-x1)*(y2-y1) - (y3-y1)*(x2-x1)) / d;
    return t >= 0 && t <= 1 && u >= 0 && u <= 1;
  }
  let cutFlashes = [];
  function sliceAt(x1,y1,x2,y2){
    for (const s of sticks){
      if (!s.live) continue;
      const p = points[s.a], q = points[s.b];
      if (segHit(x1,y1,x2,y2, p.x,p.y, q.x,q.y)){
        s.live = false;
        cutFlashes.push({ x:(p.x+q.x)/2, y:(p.y+q.y)/2, life:1 });
      }
    }
  }

  canvas.addEventListener('mousedown', down);
  window.addEventListener('mousemove', move);
  window.addEventListener('mouseup', up);
  canvas.addEventListener('touchstart', down, {passive:false});
  canvas.addEventListener('touchmove', e => { e.preventDefault(); move(e); }, {passive:false});
  window.addEventListener('touchend', up);

  cutBtn.addEventListener('click', () => {
    cutMode = !cutMode;
    cutBtn.classList.toggle('on', cutMode);
    canvas.classList.toggle('cut', cutMode);
  });
  resetBtn.addEventListener('click', rebuild);
  gravSlider.addEventListener('input', () => gravity = +gravSlider.value);
  windSlider.addEventListener('input', () => wind = +windSlider.value);

  window.addEventListener('keydown', e => {
    if (e.key === 'c' || e.key === 'C') cutBtn.click();
    if (e.key === 'r' || e.key === 'R') rebuild();
  });

  // ---------- Simulation ----------
  let t = 0;
  function integrate(dt){
    const g = gravity * dt;
    const windForce = wind * 0.02 * Math.sin(t * 1.7) * dt;
    for (const p of points){
      if (p.pinned) continue;
      let vx = (p.x - p.px) * FRICTION;
      let vy = (p.y - p.py) * FRICTION;
      p.px = p.x; p.py = p.y;
      p.x += vx + windForce;
      p.y += vy + g;
    }
  }
  function constrain(){
    // Drag: pull the grabbed point toward the mouse
    if (dragPoint && mouse.down){
      dragPoint.x = mouse.x; dragPoint.y = mouse.y;
      dragPoint.px = mouse.x; dragPoint.py = mouse.y;
    }
    // Solve stick constraints several times for stiffness
    const iterations = 12;
    for (let k = 0; k < iterations; k++){
      for (const s of sticks){
        if (!s.live) continue;
        const p = points[s.a], q = points[s.b];
        let dx = q.x - p.x, dy = q.y - p.y;
        let d = Math.hypot(dx, dy) || 0.0001;
        const diff = (s.len - d) / d * 0.5;
        const ox = dx * diff, oy = dy * diff;
        if (!p.pinned){ p.x -= ox; p.y -= oy; }
        if (!q.pinned){ q.x += ox; q.y += oy; }
      }
      // keep points on screen
      for (const p of points){
        if (p.pinned) continue;
        if (p.x < 0){ p.x = 0; p.px = p.x + (p.x - p.px)*BOUNCE; }
        else if (p.x > W){ p.x = W; p.px = p.x + (p.x - p.px)*BOUNCE; }
        if (p.y > H){ p.y = H; p.py = p.y + (p.y - p.py)*BOUNCE; }
      }
    }
  }

  // ---------- Render ----------
  function render(){
    ctx.clearRect(0, 0, W, H);

    // subtle vignette background
    const bg = ctx.createRadialGradient(W/2, H*0.3, 50, W/2, H*0.5, Math.max(W,H)*0.8);
    bg.addColorStop(0, 'rgba(30,40,66,.55)');
    bg.addColorStop(1, 'rgba(6,9,17,0)');
    ctx.fillStyle = bg;
    ctx.fillRect(0,0,W,H);

    // strands
    ctx.lineCap = 'round';
    ctx.lineWidth = 2.4;
    let live = 0;
    for (const s of sticks){
      if (!s.live) continue;
      live++;
      const p = points[s.a], q = points[s.b];
      const d = Math.hypot(q.x-p.x, q.y-p.y);
      // color from tension (stretched = warmer)
      const stretch = Math.min(1, Math.max(0, (d - s.len) / (s.len)));
      const hue = 195 - stretch * 200;      // cyan -> pink when taut
      const sat = 70 + stretch * 30;
      ctx.strokeStyle = `hsla(${hue},${sat}%,${62 - stretch*10}%,0.9)`;
      ctx.beginPath();
      ctx.moveTo(p.x, p.y);
      ctx.lineTo(q.x, q.y);
      ctx.stroke();
    }

    // nodes
    for (const p of points){
      if (p.pinned){
        ctx.fillStyle = '#ffd166';
        ctx.beginPath(); ctx.arc(p.x, p.y, 4.5, 0, Math.PI*2); ctx.fill();
        ctx.strokeStyle = 'rgba(255,209,102,.35)';
        ctx.lineWidth = 1;
        ctx.beginPath(); ctx.arc(p.x, p.y, 8, 0, Math.PI*2); ctx.stroke();
      } else {
        ctx.fillStyle = 'rgba(200,225,255,.55)';
        ctx.beginPath(); ctx.arc(p.x, p.y, 2.2, 0, Math.PI*2); ctx.fill();
      }
    }

    // highlight the point under the cursor / being dragged
    if (dragPoint){
      ctx.strokeStyle = 'var(--accent)';
      ctx.strokeStyle = '#5ad1ff';
      ctx.lineWidth = 2;
      ctx.beginPath(); ctx.arc(dragPoint.x, dragPoint.y, 12, 0, Math.PI*2); ctx.stroke();
    }

    // cut blade trail
    if (cutMode && mouse.down){
      ctx.strokeStyle = 'rgba(255,90,138,.9)';
      ctx.lineWidth = 2;
      ctx.beginPath(); ctx.moveTo(mouse.px, mouse.py); ctx.lineTo(mouse.x, mouse.y); ctx.stroke();
    }

    // cut flashes
    for (const f of cutFlashes){
      ctx.fillStyle = `rgba(255,120,160,${f.life*0.8})`;
      ctx.beginPath(); ctx.arc(f.x, f.y, (1-f.life)*14 + 3, 0, Math.PI*2); ctx.fill();
      f.life -= 0.06;
    }
    cutFlashes = cutFlashes.filter(f => f.life > 0);

    statsEl.textContent = `${points.length} points · ${live} strands · ` +
      `gravity ${gravity} · wind ${wind}  —  keys: C cut · R reset`;
  }

  // ---------- Loop ----------
  let lastT = performance.now();
  function frame(now){
    let dt = (now - lastT) / 1000;
    lastT = now;
    dt = Math.min(dt, 0.033);       // clamp big gaps
    t += dt;
    // fixed-ish substeps for stability
    const steps = 2;
    for (let i = 0; i < steps; i++){
      integrate(dt * 55 / steps);   // scale so units feel right
      constrain();
    }
    render();
    requestAnimationFrame(frame);
  }

  resize();
  rebuild();
  requestAnimationFrame(frame);
})();
</script>
</body>
</html>