← back to Games Agentabrams
Add 3 newest Model Arena game winners: DVD Bounce, Verlet Rope, Particle Word
9a1b82f5246777f4003b7ef2ff96da4ae0ddaa83 · 2026-07-24 18:54:44 -0700 · Steve
Files touched
M games.jsonA games/dvd-bounce/index.htmlA games/particle-text/index.htmlA games/rope-physics/index.html
Diff
commit 9a1b82f5246777f4003b7ef2ff96da4ae0ddaa83
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Jul 24 18:54:44 2026 -0700
Add 3 newest Model Arena game winners: DVD Bounce, Verlet Rope, Particle Word
---
games.json | 24 +++
games/dvd-bounce/index.html | 312 ++++++++++++++++++++++++++++++++++++
games/particle-text/index.html | 143 +++++++++++++++++
games/rope-physics/index.html | 350 +++++++++++++++++++++++++++++++++++++++++
4 files changed, 829 insertions(+)
diff --git a/games.json b/games.json
index 427b947..ed96445 100644
--- a/games.json
+++ b/games.json
@@ -1,5 +1,29 @@
{
"games": [
+ {
+ "id": "dvd-bounce",
+ "file": "DVDBounce.aa",
+ "title": "DVD Bounce — Elevated",
+ "desc": "The classic bouncing-logo screensaver, elevated — glowing trails, corner-hit celebration, color shifts on every wall bounce. An ambient toy, not a scored game. Model Arena winner (Claude). Pure Canvas 2D, zero dependencies.",
+ "path": "games/dvd-bounce/",
+ "added": "2026-07-24"
+ },
+ {
+ "id": "rope-physics",
+ "file": "VerletRope.aa",
+ "title": "Verlet Rope",
+ "desc": "A physics toy — drag to swing a Verlet-integrated rope, watch it settle and whip under gravity. A generative-physics playground, not a scored game. Model Arena winner (Claude). Pure Canvas 2D, zero dependencies.",
+ "path": "games/rope-physics/",
+ "added": "2026-07-24"
+ },
+ {
+ "id": "particle-text",
+ "file": "ParticleWord.aa",
+ "title": "Particle Word",
+ "desc": "Type a word and watch it assemble from a swarm of particles that scatter and reform. An interactive typography toy, not a scored game. Model Arena winner (Qwen3-14B). Pure Canvas 2D, zero dependencies.",
+ "path": "games/particle-text/",
+ "added": "2026-07-24"
+ },
{
"id": "rubiks-cube",
"file": "AbramsCube.aa",
diff --git a/games/dvd-bounce/index.html b/games/dvd-bounce/index.html
new file mode 100644
index 0000000..4d24ee9
--- /dev/null
+++ b/games/dvd-bounce/index.html
@@ -0,0 +1,312 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>DVD Bounce — Elevated</title>
+<style>
+ :root{
+ --bg:#05060a;
+ --ink:#e8ecf5;
+ --accent:#7c5cff;
+ }
+ *{box-sizing:border-box;margin:0;padding:0}
+ html,body{height:100%;overflow:hidden}
+ body{
+ background:radial-gradient(120% 120% at 50% 0%, #0d1020 0%, #04050a 60%, #000 100%);
+ color:var(--ink);
+ font-family:"Segoe UI",system-ui,-apple-system,sans-serif;
+ -webkit-font-smoothing:antialiased;
+ }
+ #stage{position:fixed;inset:0;display:block}
+ /* HUD */
+ .hud{
+ position:fixed;top:16px;left:16px;z-index:10;
+ display:flex;flex-direction:column;gap:10px;
+ pointer-events:none;
+ }
+ .panel{
+ pointer-events:auto;
+ background:rgba(18,20,34,.55);
+ backdrop-filter:blur(12px) saturate(140%);
+ -webkit-backdrop-filter:blur(12px) saturate(140%);
+ border:1px solid rgba(255,255,255,.09);
+ border-radius:14px;
+ padding:14px 16px;
+ box-shadow:0 10px 40px rgba(0,0,0,.45);
+ }
+ .stats{display:flex;gap:22px;align-items:center}
+ .stat{display:flex;flex-direction:column;line-height:1.1}
+ .stat .num{font-size:26px;font-weight:800;letter-spacing:.5px;font-variant-numeric:tabular-nums}
+ .stat .lbl{font-size:10px;text-transform:uppercase;letter-spacing:2px;opacity:.55;margin-top:3px}
+ .corner .num{color:#ffd35c;text-shadow:0 0 18px rgba(255,211,92,.55)}
+ .controls{display:flex;gap:8px;flex-wrap:wrap}
+ button{
+ pointer-events:auto;
+ font:inherit;font-size:12px;font-weight:600;letter-spacing:.4px;
+ color:var(--ink);
+ background:linear-gradient(180deg,rgba(124,92,255,.35),rgba(124,92,255,.15));
+ border:1px solid rgba(124,92,255,.5);
+ border-radius:10px;
+ padding:9px 14px;cursor:pointer;
+ transition:transform .08s ease, background .15s ease, box-shadow .15s ease;
+ }
+ button:hover{background:linear-gradient(180deg,rgba(124,92,255,.55),rgba(124,92,255,.25));box-shadow:0 0 22px rgba(124,92,255,.4)}
+ button:active{transform:translateY(1px) scale(.98)}
+ button.ghost{background:rgba(255,255,255,.05);border-color:rgba(255,255,255,.14)}
+ button.ghost:hover{background:rgba(255,255,255,.1);box-shadow:none}
+ .hint{
+ position:fixed;bottom:14px;right:16px;z-index:10;
+ font-size:11px;opacity:.4;letter-spacing:.5px;pointer-events:none;
+ }
+ .flash{animation:pop .4s ease}
+ @keyframes pop{0%{transform:scale(1)}35%{transform:scale(1.28)}100%{transform:scale(1)}}
+</style>
+</head>
+<body>
+<canvas id="stage"></canvas>
+
+<div class="hud">
+ <div class="panel stats">
+ <div class="stat corner">
+ <span class="num" id="cornerCount">0</span>
+ <span class="lbl">Corners</span>
+ </div>
+ <div class="stat">
+ <span class="num" id="wallCount">0</span>
+ <span class="lbl">Wall hits</span>
+ </div>
+ <div class="stat">
+ <span class="num" id="logoCount">1</span>
+ <span class="lbl">Logos</span>
+ </div>
+ </div>
+ <div class="panel controls">
+ <button id="addBtn">+ Add logo</button>
+ <button id="chaosBtn">Chaos ×5</button>
+ <button class="ghost" id="resetBtn">Reset</button>
+ </div>
+</div>
+
+<div class="hint">corner hits trigger the confetti · click canvas to spawn</div>
+
+<script>
+(() => {
+ const canvas = document.getElementById('stage');
+ const ctx = canvas.getContext('2d');
+
+ const cornerEl = document.getElementById('cornerCount');
+ const wallEl = document.getElementById('wallCount');
+ const logoEl = document.getElementById('logoCount');
+
+ let W = 0, H = 0, DPR = 1;
+ function resize() {
+ DPR = Math.min(window.devicePixelRatio || 1, 2);
+ W = window.innerWidth;
+ H = window.innerHeight;
+ canvas.width = Math.floor(W * DPR);
+ canvas.height = Math.floor(H * DPR);
+ canvas.style.width = W + 'px';
+ canvas.style.height = H + 'px';
+ ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
+ }
+ window.addEventListener('resize', resize);
+ resize();
+
+ // ---- palette ----
+ const PALETTE = [
+ '#ff4d6d','#ffd35c','#4ade80','#38bdf8','#7c5cff',
+ '#f472b6','#fb923c','#22d3ee','#a3e635','#e879f9'
+ ];
+ const rand = (a, b) => a + Math.random() * (b - a);
+ const pick = arr => arr[(Math.random() * arr.length) | 0];
+ const nextColor = cur => { let c; do { c = pick(PALETTE); } while (c === cur); return c; };
+
+ // ---- state ----
+ let logos = [];
+ let particles = [];
+ let cornerHits = 0, wallHits = 0;
+
+ // The DVD wordmark, drawn once to an offscreen canvas and tinted per-logo.
+ function makeLogoSprite() {
+ const w = 150, h = 78;
+ const c = document.createElement('canvas');
+ c.width = w; c.height = h;
+ const g = c.getContext('2d');
+
+ g.fillStyle = '#fff';
+ g.textAlign = 'center';
+ g.textBaseline = 'middle';
+
+ // "DVD"
+ g.font = 'italic 900 52px "Arial Black", Arial, sans-serif';
+ g.save();
+ g.translate(w/2, h*0.36);
+ g.scale(1, 0.82);
+ g.fillText('DVD', 0, 0);
+ g.restore();
+
+ // squashed ellipse under it (the classic logo oval)
+ g.beginPath();
+ g.ellipse(w/2, h*0.70, 46, 12, 0, 0, Math.PI*2);
+ g.fill();
+ // knock out the label bar
+ g.save();
+ g.globalCompositeOperation = 'destination-out';
+ g.fillRect(w/2 - 34, h*0.66, 68, 8);
+ g.restore();
+ // small "video" text on the bar
+ g.font = '900 11px Arial, sans-serif';
+ g.fillText('V I D E O', w/2, h*0.70);
+
+ return c;
+ }
+ const SPRITE = makeLogoSprite();
+ const LW = SPRITE.width, LH = SPRITE.height;
+
+ function spawnLogo(x, y) {
+ const speed = rand(2.2, 3.6);
+ const ang = rand(0, Math.PI * 2);
+ logos.push({
+ x: x != null ? x : rand(0, Math.max(0, W - LW)),
+ y: y != null ? y : rand(0, Math.max(0, H - LH)),
+ vx: Math.cos(ang) * speed * (Math.random() < .5 ? 1 : -1),
+ vy: Math.sin(ang) * speed * (Math.random() < .5 ? 1 : -1),
+ color: pick(PALETTE),
+ trail: []
+ });
+ updateHUD();
+ }
+
+ function burst(x, y, big) {
+ const n = big ? 90 : 22;
+ for (let i = 0; i < n; i++) {
+ const a = Math.random() * Math.PI * 2;
+ const sp = rand(big ? 2 : 1, big ? 9 : 4);
+ particles.push({
+ x, y,
+ vx: Math.cos(a) * sp,
+ vy: Math.sin(a) * sp - (big ? 1.5 : 0),
+ life: 1,
+ decay: rand(0.012, 0.03),
+ size: rand(2, big ? 6 : 4),
+ color: pick(PALETTE),
+ spin: rand(-0.3, 0.3),
+ rot: Math.random() * Math.PI
+ });
+ }
+ }
+
+ function flash(el){ el.classList.remove('flash'); void el.offsetWidth; el.classList.add('flash'); }
+ function updateHUD() {
+ cornerEl.textContent = cornerHits;
+ wallEl.textContent = wallHits;
+ logoEl.textContent = logos.length;
+ }
+
+ // ---- main loop ----
+ function frame() {
+ // fade previous frame → motion trail
+ ctx.fillStyle = 'rgba(4,5,10,0.20)';
+ ctx.fillRect(0, 0, W, H);
+
+ const maxX = W - LW, maxY = H - LH;
+
+ for (const L of logos) {
+ L.x += L.vx;
+ L.y += L.vy;
+
+ let hitX = false, hitY = false;
+ if (L.x <= 0) { L.x = 0; L.vx = Math.abs(L.vx); hitX = true; }
+ else if (L.x >= maxX) { L.x = maxX; L.vx = -Math.abs(L.vx); hitX = true; }
+ if (L.y <= 0) { L.y = 0; L.vy = Math.abs(L.vy); hitY = true; }
+ else if (L.y >= maxY) { L.y = maxY; L.vy = -Math.abs(L.vy); hitY = true; }
+
+ if (hitX || hitY) {
+ L.color = nextColor(L.color);
+ if (hitX && hitY) {
+ // CORNER!
+ cornerHits++;
+ burst(L.x + LW/2, L.y + LH/2, true);
+ flash(cornerEl);
+ } else {
+ wallHits++;
+ burst(hitX ? (L.x <= 0 ? 0 : W) : L.x + LW/2,
+ hitY ? (L.y <= 0 ? 0 : H) : L.y + LH/2, false);
+ }
+ updateHUD();
+ }
+
+ // trail
+ L.trail.push({ x: L.x, y: L.y });
+ if (L.trail.length > 14) L.trail.shift();
+ for (let i = 0; i < L.trail.length; i++) {
+ const t = L.trail[i];
+ const alpha = (i / L.trail.length) * 0.28;
+ drawSprite(t.x, t.y, L.color, alpha);
+ }
+
+ drawSprite(L.x, L.y, L.color, 1);
+ }
+
+ // particles
+ for (let i = particles.length - 1; i >= 0; i--) {
+ const p = particles[i];
+ p.vy += 0.08; // gravity
+ p.vx *= 0.99;
+ p.x += p.vx;
+ p.y += p.vy;
+ p.rot += p.spin;
+ p.life -= p.decay;
+ if (p.life <= 0) { particles.splice(i, 1); continue; }
+
+ ctx.save();
+ ctx.globalAlpha = Math.max(0, p.life);
+ ctx.translate(p.x, p.y);
+ ctx.rotate(p.rot);
+ ctx.fillStyle = p.color;
+ ctx.shadowColor = p.color;
+ ctx.shadowBlur = 8;
+ const s = p.size;
+ ctx.fillRect(-s/2, -s/2, s, s * 1.6);
+ ctx.restore();
+ }
+
+ requestAnimationFrame(frame);
+ }
+
+ function drawSprite(x, y, color, alpha) {
+ ctx.save();
+ ctx.globalAlpha = alpha;
+ // tint: draw white sprite then multiply the color through
+ ctx.drawImage(SPRITE, x, y);
+ ctx.globalCompositeOperation = 'source-atop';
+ ctx.globalAlpha = alpha;
+ ctx.fillStyle = color;
+ ctx.fillRect(x, y, LW, LH);
+ ctx.restore();
+ }
+
+ // ---- controls ----
+ document.getElementById('addBtn').addEventListener('click', () => spawnLogo());
+ document.getElementById('chaosBtn').addEventListener('click', () => {
+ for (let i = 0; i < 5; i++) spawnLogo();
+ });
+ document.getElementById('resetBtn').addEventListener('click', () => {
+ logos = []; particles = [];
+ cornerHits = 0; wallHits = 0;
+ spawnLogo(W/2 - LW/2, H/2 - LH/2);
+ });
+ canvas.addEventListener('pointerdown', e => spawnLogo(
+ Math.min(Math.max(0, e.clientX - LW/2), W - LW),
+ Math.min(Math.max(0, e.clientY - LH/2), H - LH)
+ ));
+
+ // ---- go ----
+ spawnLogo(W/2 - LW/2, H/2 - LH/2);
+ updateHUD();
+ requestAnimationFrame(frame);
+})();
+</script>
+</body>
+</html>
\ No newline at end of file
diff --git a/games/particle-text/index.html b/games/particle-text/index.html
new file mode 100644
index 0000000..36408d4
--- /dev/null
+++ b/games/particle-text/index.html
@@ -0,0 +1,143 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <title>Particle Word</title>
+ <style>
+ html, body {
+ margin: 0;
+ padding: 0;
+ overflow: hidden;
+ background: #000;
+ height: 100%;
+ width: 100%;
+ font-family: 'Arial', sans-serif;
+ }
+ #word {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ color: #fff;
+ font-size: 48px;
+ pointer-events: none;
+ text-transform: uppercase;
+ text-align: center;
+ }
+ #controls {
+ position: absolute;
+ top: 20px;
+ left: 50%;
+ transform: translateX(-50%);
+ color: #fff;
+ font-size: 18px;
+ }
+ </style>
+</head>
+<body>
+ <div id="word">TYPE HERE</div>
+ <div id="controls">Type a word to see particles form it</div>
+ <canvas id="canvas"></canvas>
+ <script>
+ const canvas = document.getElementById('canvas');
+ const ctx = canvas.getContext('2d');
+ let width, height;
+ let particles = [];
+ let targetWord = 'TYPE HERE';
+ let isHovering = false;
+
+ function resize() {
+ width = canvas.width = window.innerWidth;
+ height = canvas.height = window.innerHeight;
+ }
+ window.addEventListener('resize', resize);
+ resize();
+
+ function createParticles(word, count = 2000) {
+ particles = [];
+ const chars = word.split('');
+ const totalChars = chars.length;
+ for (let i = 0; i < count; i++) {
+ const charIndex = Math.floor(i / (count / totalChars));
+ const char = chars[charIndex % totalChars];
+ const angle = (Math.random() * Math.PI * 2);
+ const radius = Math.random() * 150 + 100;
+ const x = width / 2 + Math.cos(angle) * radius;
+ const y = height / 2 + Math.sin(angle) * radius;
+ const size = Math.random() * 2 + 1;
+ const speed = Math.random() * 0.5 + 0.2;
+ particles.push({
+ x,
+ y,
+ targetX: width / 2 + (char.charCodeAt(0) - 65) * 30,
+ targetY: height / 2 + (Math.floor(i / totalChars)) * 30,
+ size,
+ speed,
+ vx: (Math.random() - 0.5) * 2,
+ vy: (Math.random() - 0.5) * 2,
+ char: char
+ });
+ }
+ }
+
+ function updateParticles() {
+ for (let p of particles) {
+ const dx = p.targetX - p.x;
+ const dy = p.targetY - p.y;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+ if (dist < 2) {
+ p.x = p.targetX;
+ p.y = p.targetY;
+ } else {
+ const angle = Math.atan2(dy, dx);
+ p.x += Math.cos(angle) * p.speed;
+ p.y += Math.sin(angle) * p.speed;
+ }
+ if (isHovering) {
+ const nx = (Math.random() - 0.5) * 10;
+ const ny = (Math.random() - 0.5) * 10;
+ p.x += nx;
+ p.y += ny;
+ }
+ }
+ }
+
+ function drawParticles() {
+ ctx.clearRect(0, 0, width, height);
+ ctx.fillStyle = '#00ffcc';
+ for (let p of particles) {
+ ctx.beginPath();
+ ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ }
+
+ function animate() {
+ updateParticles();
+ drawParticles();
+ requestAnimationFrame(animate);
+ }
+
+ function resetParticles() {
+ createParticles(targetWord);
+ }
+
+ window.addEventListener('mousemove', () => {
+ isHovering = true;
+ setTimeout(() => {
+ isHovering = false;
+ }, 500);
+ });
+
+ window.addEventListener('keydown', (e) => {
+ if (e.key.length === 1 && e.key.match(/[a-zA-Z]/)) {
+ targetWord = e.key.toUpperCase();
+ resetParticles();
+ }
+ });
+
+ createParticles(targetWord);
+ animate();
+ </script>
+</body>
+</html>
\ No newline at end of file
diff --git a/games/rope-physics/index.html b/games/rope-physics/index.html
new file mode 100644
index 0000000..265a9c3
--- /dev/null
+++ b/games/rope-physics/index.html
@@ -0,0 +1,350 @@
+<!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>
\ No newline at end of file
← aeeb0bf Add 4 arcade games: Pong 2P, Emoji Memory, Spirograph, Const
·
back to Games Agentabrams
·
Add 8 new arcade games: Asteroids, Minesweeper, Simon, Conne 94a2079 →