// 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();
}
});
})();