← back to Games Agentabrams
games/rope-physics/index.html
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>