// Grounding Ball — 12 auto-pauses every 30s, each 3s; total ≈ 6:36 (function () { const canvas = document.getElementById('c'); const ctx = canvas.getContext('2d'); // --- responsive HiDPI setup --- let cssW, cssH, DPR; function resize() { cssW = canvas.clientWidth; cssH = canvas.clientHeight; DPR = Math.max(1, Math.min(3, window.devicePixelRatio || 1)); canvas.width = Math.round(cssW * DPR); canvas.height = Math.round(cssH * DPR); ctx.setTransform(DPR, 0, 0, DPR, 0, 0); } window.addEventListener('resize', resize); resize(); // --- parameters --- const R = 25; const dirTravelTime = 1.1; // edge -> edge const edgeHold = 0.12; // brief wall dwell const cyclePauseEvery = 30; // auto pause cadence (seconds) const pauseDuration = 3; // 3s hold at center const totalRun = 396; // 12*(30s cadence) + 12*3s = 396s ≈ 6:36 const ease = (t) => 0.5 - 0.5 * Math.cos(Math.PI * t); const clamp01 = (x) => Math.max(0, Math.min(1, x)); // --- smooth color wash (NaN-proof) --- const hues = [0, 220, 50, 140]; const washLegDur = (dirTravelTime + edgeHold) || 1.2; let hueTime = 0, hueLastTick = 0; function shortestHueDelta(a, b) { const d = ((b - a + 540) % 360) - 180; return Number.isFinite(d) ? d : 0; } function smoothstep(t) { t = clamp01(t); return t * t * (3 - 2 * t); } function currentHue() { const leg = (Number.isFinite(washLegDur) && washLegDur > 0) ? washLegDur : 1.2; const prog = hueTime / leg; const cycles = Math.max(0, Math.floor(prog)); const t = smoothstep((prog - cycles) || 0); const len = hues.length || 1; const idx = ((cycles % len) + len) % len; const nextIdx = (idx + 1) % len; const base = Number.isFinite(hues[idx]) ? hues[idx] : 0; const target = Number.isFinite(hues[nextIdx]) ? hues[nextIdx] : base; const hue = base + shortestHueDelta(base, target) * t; const lum = 45 + 6 * Math.sin(t * Math.PI); return { hue, lum }; } function drawBallAt(x, hue, lum, y) { // shadow ctx.globalAlpha = 0.18; ctx.beginPath(); ctx.ellipse(x, y + R + 6, R * 0.9, R * 0.35, 0, 0, Math.PI * 2); ctx.fillStyle = '#000'; ctx.fill(); ctx.globalAlpha = 1; // inner glow gradient const grad = ctx.createRadialGradient(x, y - R * 0.35, 6, x, y, R); grad.addColorStop(0, `hsl(${hue}, 80%, ${lum + 10}%)`); grad.addColorStop(0.55, `hsl(${hue}, 76%, ${lum}%)`); grad.addColorStop(1, `hsl(${hue}, 70%, ${lum - 9}%)`); ctx.beginPath(); ctx.arc(x, y, R, 0, Math.PI * 2); ctx.fillStyle = grad; ctx.shadowColor = `hsla(${hue}, 70%, 20%, 0.35)`; ctx.shadowBlur = 6; ctx.fill(); ctx.shadowBlur = 0; } // --- grounded tone (failsafe if sandbox blocks audio) --- let audioCtx = null, audioEnabled = true; function safeAudioCtx() { if (!audioEnabled) return null; try { if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); return audioCtx; } catch { audioEnabled = false; return null; } } function playBounce() { const ac = safeAudioCtx(); if (!ac) return; try { const t = ac.currentTime, F2 = 87.31, F3 = 174.61; const mk = (f, pan, gain) => { const o = ac.createOscillator(); o.type = 'sine'; o.frequency.value = f; const g = ac.createGain(); g.gain.value = gain; const p = ac.createStereoPanner(); p.pan.value = pan; o.connect(g); g.connect(p); return { o, p }; }; const n1 = mk(F3, -0.15, 1), n2 = mk(F3, +0.15, 1), n3 = mk(F2, -0.05, 0.35), n4 = mk(F2, +0.05, 0.35); const mix = ac.createGain(); mix.gain.setValueAtTime(0.0001, t); mix.gain.exponentialRampToValueAtTime(0.30, t + 0.06); mix.gain.exponentialRampToValueAtTime(0.0001, t + 0.85); const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.Q.value = 0.9; lp.frequency.setValueAtTime(480, t); lp.frequency.linearRampToValueAtTime(720, t + 0.20); lp.frequency.linearRampToValueAtTime(520, t + 0.85); [n1.p, n2.p, n3.p, n4.p].forEach(p => p.connect(mix)); mix.connect(lp); lp.connect(ac.destination); [n1.o, n2.o, n3.o, n4.o].forEach(o => { o.start(t); o.stop(t + 0.95); }); } catch { audioEnabled = false; } } // --- motion & pause state --- let dir = +1; let segStart = 0, segPhase0 = 0, prevPhase = 0; let started = false, realStart = 0; let paused = false, nextPauseTrigger = cyclePauseEvery, pendingPause = false, pauseEndAt = null; let edgeHolding = false, edgeHoldUntil = 0, edgeHoldX = 0; let userPaused = false, userPauseStart = 0, userFrozenX = 0; function colorTick(now) { if (hueLastTick === 0 || !Number.isFinite(hueLastTick)) hueLastTick = now; const dt = now - hueLastTick; const frozen = edgeHolding || paused || userPaused; if (!frozen && Number.isFinite(dt) && dt >= 0) { hueTime += dt; if (!Number.isFinite(hueTime) || hueTime < 0) hueTime = 0; } hueLastTick = now; } function beginSeg(now, phase0) { segStart = now; segPhase0 = phase0; prevPhase = phase0; } function startEdgeHold(now, x) { playBounce(); edgeHolding = true; edgeHoldUntil = now + edgeHold; edgeHoldX = x; } function finishEdgeHold(now) { edgeHolding = false; dir *= -1; beginSeg(now, 0); } function toggleUserPause(now) { if (!userPaused) { // Freeze at current x const leftX = R, rightX = cssW - R; const segElapsed = now - segStart; const raw = (segElapsed / dirTravelTime) + segPhase0; const p = ease(clamp01(raw)); const edgeA = (dir === +1) ? leftX : rightX, edgeB = (dir === +1) ? rightX : leftX; userFrozenX = edgeA + (edgeB - edgeA) * p; userPaused = true; userPauseStart = now; } else { userPaused = false; // countdown continues; no time compensation } } function frame(ts) { if (!started) { requestAnimationFrame(frame); return; } const now = ts / 1000; // responsive geometry each frame const leftX = R, rightX = cssW - R, centerX = cssW / 2, y = cssH * 0.25; // hard stop at totalRun if (now - realStart >= totalRun) { ctx.clearRect(0, 0, cssW, cssH); const { hue, lum } = currentHue(); drawBallAt(centerX, hue, lum, y); return; } colorTick(now); // manual pause render if (userPaused) { ctx.clearRect(0, 0, cssW, cssH); const { hue, lum } = currentHue(); drawBallAt(userFrozenX || centerX, hue, lum, y); requestAnimationFrame(frame); return; } // schedule auto pause every 30s real time if (!paused && !pendingPause && (now - realStart) >= nextPauseTrigger) { pendingPause = true; nextPauseTrigger += cyclePauseEvery; } // wall dwell if (edgeHolding) { if (now >= edgeHoldUntil) { finishEdgeHold(now); } else { ctx.clearRect(0, 0, cssW, cssH); const { hue, lum } = currentHue(); drawBallAt(edgeHoldX, hue, lum, y); requestAnimationFrame(frame); return; } } // auto center pause if (paused) { if (now >= pauseEndAt) { paused = false; beginSeg(pauseEndAt, 0.5); } else { ctx.clearRect(0, 0, cssW, cssH); const { hue, lum } = currentHue(); drawBallAt(centerX, hue, lum, y); requestAnimationFrame(frame); return; } } // segment progress const segElapsed = now - segStart; let rawPhase = (segElapsed / dirTravelTime) + segPhase0; // hit wall -> dwell if (rawPhase >= 1) { const edgeA = (dir === +1) ? leftX : rightX, edgeB = (dir === +1) ? rightX : leftX; startEdgeHold(now, edgeB); ctx.clearRect(0, 0, cssW, cssH); const { hue, lum } = currentHue(); drawBallAt(edgeB, hue, lum, y); requestAnimationFrame(frame); return; } // trigger auto pause at natural midpoint crossing if (!paused && pendingPause) { const crossed = (dir === +1 && prevPhase < 0.5 && rawPhase >= 0.5) || (dir === -1 && prevPhase > 0.5 && rawPhase <= 0.5); if (crossed) { pendingPause = false; paused = true; pauseEndAt = now + pauseDuration; ctx.clearRect(0, 0, cssW, cssH); const { hue, lum } = currentHue(); drawBallAt(centerX, hue, lum, y); prevPhase = 0.5; requestAnimationFrame(frame); return; } } // normal draw const p = ease(clamp01(rawPhase)); const edgeA = (dir === +1) ? leftX : rightX, edgeB = (dir === +1) ? rightX : leftX; const x = edgeA + (edgeB - edgeA) * p; ctx.clearRect(0, 0, cssW, cssH); const { hue, lum } = currentHue(); drawBallAt(x, hue, lum, y); prevPhase = rawPhase; requestAnimationFrame(frame); } // start on first gesture; Space/P toggles manual pause let startedOnce = false; function startNow() { if (startedOnce) return; startedOnce = true; const ac = safeAudioCtx(); if (ac && ac.state === 'suspended') { ac.resume().catch(() => {}); } const now = performance.now() / 1000; hueTime = 0; hueLastTick = now; beginSeg(now, 0); started = true; realStart = now; requestAnimationFrame(frame); } window.addEventListener('pointerdown', startNow, { once: true }); window.addEventListener('keydown', (e) => { if (e.code === 'Space' || (e.key && e.key.toLowerCase() === 'p')) { e.preventDefault(); if (!startedOnce) startNow(); else toggleUserPause(performance.now() / 1000); } else if (!startedOnce) { startNow(); } }); })();