Toybox / gravity_flow.html
Hermes-Agent's picture
Add gravity_flow: 500 glowing particles on a noise flow field, click-placed gravity wells and repulsors, gravity slider, long-exposure trails
0ffbd66
Raw History Blame Contribute Delete
12.8 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>gravity flow</title>
<style>
html, body { margin:0; padding:0; overflow:hidden; background:#04060e; }
canvas { display:block; }
#ui {
position: fixed; top:16px; left:16px; z-index:10;
width:230px; padding:12px 14px;
font:11px/1.6 ui-monospace,"SF Mono",Menlo,Consolas,monospace;
letter-spacing:.05em; color:rgba(205,218,242,.85);
background:rgba(8,12,24,.55); border:1px solid rgba(120,150,220,.18);
border-radius:10px; backdrop-filter:blur(8px);
user-select:none;
}
#ui .title {
font-size:11px; font-weight:600; letter-spacing:.14em; text-transform:uppercase;
color:#e8eefc; margin-bottom:6px;
}
#ui label { display:block; margin:4px 0 2px; }
#ui output { color:#8fd3ff; }
#ui input[type=range] { width:100%; accent-color:#5aa9ff; }
#ui .rowline { display:flex; align-items:center; gap:8px; margin-top:8px; }
#ui button {
cursor:pointer; font:inherit; color:#cfd8ea;
background:rgba(90,140,255,.12); border:1px solid rgba(120,150,220,.3);
border-radius:6px; padding:3px 9px;
}
#ui button:hover { background:rgba(90,140,255,.24); }
#fields { color:rgba(150,175,220,.7); font-size:10px; }
#hint {
position:fixed; bottom:14px; left:50%; transform:translateX(-50%); z-index:10;
font:11px ui-monospace,"SF Mono",Menlo,Consolas,monospace;
letter-spacing:.07em; color:rgba(180,200,240,.5);
pointer-events:none; white-space:nowrap;
transition:opacity 1.2s ease;
}
#hint.dim { opacity:0; }
#hint .k {
display:inline-block; padding:0 5px; margin:0 3px;
border:1px solid rgba(214,224,255,.28); border-radius:3px;
color:rgba(230,238,255,.75);
}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="ui">
<div class="title">gravity flow</div>
<label for="g">gravity strength <output id="gval">1.40</output></label>
<input id="g" type="range" min="0" max="3" step="0.01" value="1.4">
<div class="rowline">
<button id="clear" type="button">clear</button>
<span id="fields">0 wells &middot; 0 repulsors</span>
</div>
</div>
<div id="hint"><span class="k">left click</span>gravity well <span class="k">right click</span>repulsor <span class="k">C</span>clear</div>
<script>
"use strict";
// ============================================================
// GRAVITY FLOW — 500 glowing particles drifting on an evolving
// simplex-noise flow field. Left-click drops a gravity well that
// particles spiral into (attraction + tangential swirl); right-
// click drops a repulsor they flee. A slider scales gravity
// strength. Additive glow dots over a slow per-frame fade leave
// long-exposure luminous trails. No libraries.
// ============================================================
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
// ---- tuning ----
const COUNT = 500;
const NOISE_SCALE = 0.0016; // noise coords per px
const Z_DRIFT = 0.045; // field evolution (noise z units / s)
const FLOW = 0.05; // flow-field accel (px/frame^2)
const DAMP = 0.985; // velocity damping per frame
const MAX_SPEED = 3.4; // px per frame
const FADE = 0.07; // long-exposure wash per frame
const MARGIN = 32; // wrap margin
const MAX_FIELDS = 12; // cap per field kind
const WELL_K = 22000; // gravity constant
const SWIRL = 0.6; // tangential fraction of well pull
const REPULSE_K = 28000; // repulsor constant
const REPULSE_G = 1.25; // repulsor strength as multiple of G
const CAPTURE_D2 = 36; // particles this close to a well respawn
let W = 0, H = 0, dpr = 1;
let t = 0, last = 0, G = 1.4;
let wells = [], repulsors = [];
// ---- particles (structure-of-arrays) ----
const px = new Float32Array(COUNT), py = new Float32Array(COUNT);
const vx = new Float32Array(COUNT), vy = new Float32Array(COUNT);
const rad = new Float32Array(COUNT), hueIdx = new Uint8Array(COUNT);
// ---- sprites: pre-rendered radial glow per hue ----
function makeSprite(hue) {
const c = document.createElement('canvas');
c.width = c.height = 64;
const g = c.getContext('2d');
const grad = g.createRadialGradient(32, 32, 0, 32, 32, 32);
grad.addColorStop(0, 'rgba(255,255,255,.85)');
grad.addColorStop(0.2, 'hsla(' + hue + ',100%,72%,.75)');
grad.addColorStop(0.55, 'hsla(' + hue + ',100%,55%,.28)');
grad.addColorStop(1, 'hsla(' + hue + ',100%,50%,0)');
g.fillStyle = grad;
g.fillRect(0, 0, 64, 64);
return c;
}
const HUES = [190, 265, 315, 45, 165];
const sprites = HUES.map(makeSprite);
const WELL_SPRITE = makeSprite(215);
const REP_SPRITE = makeSprite(15);
// ---- seeded 3D simplex noise (Gustavson) ----
const noise3 = (() => {
const p = new Uint8Array(512);
const perm = new Uint8Array(256);
for (let i = 0; i < 256; i++) perm[i] = i;
let s = 1337;
const rnd = () => (s = s * 16807 % 2147483647) / 2147483647;
for (let i = 255; i > 0; i--) {
const j = (rnd() * (i + 1)) | 0;
const tmp = perm[i]; perm[i] = perm[j]; perm[j] = tmp;
}
for (let i = 0; i < 512; i++) p[i] = perm[i & 255];
const g3 = new Float32Array([1,1,0,-1,1,0,1,-1,0,-1,-1,0,1,0,1,-1,0,1,1,0,-1,-1,0,-1,0,1,1,0,-1,1,0,1,-1,0,-1,-1]);
const F3 = 1 / 3, G3 = 1 / 6;
return (xin, yin, zin) => {
let n0 = 0, n1 = 0, n2 = 0, n3 = 0;
const sk = (xin + yin + zin) * F3;
const i = Math.floor(xin + sk), j = Math.floor(yin + sk), k = Math.floor(zin + sk);
const t3 = (i + j + k) * G3;
const x0 = xin - i + t3, y0 = yin - j + t3, z0 = zin - k + t3;
let i1, j1, k1, i2, j2, k2;
if (x0 >= y0) {
if (y0 >= z0) { i1=1; j1=0; k1=0; i2=1; j2=1; k2=0; }
else if (x0 >= z0) { i1=1; j1=0; k1=0; i2=1; j2=0; k2=1; }
else { i1=0; j1=0; k1=1; i2=1; j2=0; k2=1; }
} else {
if (y0 < z0) { i1=0; j1=0; k1=1; i2=0; j2=1; k2=1; }
else if (x0 < z0) { i1=0; j1=1; k1=0; i2=0; j2=1; k2=1; }
else { i1=0; j1=1; k1=0; i2=1; j2=1; k2=0; }
}
const x1 = x0 - i1 + G3, y1 = y0 - j1 + G3, z1 = z0 - k1 + G3;
const x2 = x0 - i2 + 2*G3, y2 = y0 - j2 + 2*G3, z2 = z0 - k2 + 2*G3;
const x3 = x0 - 1 + 3*G3, y3 = y0 - 1 + 3*G3, z3 = z0 - 1 + 3*G3;
const ii = i & 255, jj = j & 255, kk = k & 255;
let tt = 0.6 - x0*x0 - y0*y0 - z0*z0;
if (tt > 0) { const gi = (p[ii + p[jj + p[kk]]] % 12) * 3; tt *= tt; n0 = tt * tt * (g3[gi]*x0 + g3[gi+1]*y0 + g3[gi+2]*z0); }
tt = 0.6 - x1*x1 - y1*y1 - z1*z1;
if (tt > 0) { const gi = (p[ii+i1 + p[jj+j1 + p[kk+k1]]] % 12) * 3; tt *= tt; n1 = tt * tt * (g3[gi]*x1 + g3[gi+1]*y1 + g3[gi+2]*z1); }
tt = 0.6 - x2*x2 - y2*y2 - z2*z2;
if (tt > 0) { const gi = (p[ii+i2 + p[jj+j2 + p[kk+k2]]] % 12) * 3; tt *= tt; n2 = tt * tt * (g3[gi]*x2 + g3[gi+1]*y2 + g3[gi+2]*z2); }
tt = 0.6 - x3*x3 - y3*y3 - z3*z3;
if (tt > 0) { const gi = (p[ii+1 + p[jj+1 + p[kk+1]]] % 12) * 3; tt *= tt; n3 = tt * tt * (g3[gi]*x3 + g3[gi+1]*y3 + g3[gi+2]*z3); }
return 32 * (n0 + n1 + n2 + n3);
};
})();
// ---- layout / particles ----
function resize() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
W = window.innerWidth; H = window.innerHeight;
canvas.width = W * dpr; canvas.height = H * dpr;
canvas.style.width = W + 'px'; canvas.style.height = H + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = '#04060e';
ctx.fillRect(0, 0, W, H);
}
function spawn(i) {
px[i] = Math.random() * W;
py[i] = Math.random() * H;
vx[i] = (Math.random() - .5) * 1.2;
vy[i] = (Math.random() - .5) * 1.2;
rad[i] = 4 + Math.random() * 7;
hueIdx[i] = (Math.random() * HUES.length) | 0;
}
function seedParticles() { for (let i = 0; i < COUNT; i++) spawn(i); }
// ---- interaction ----
const fieldsEl = document.getElementById('fields');
const hintEl = document.getElementById('hint');
let uiTimer = null;
function updateFields() {
fieldsEl.textContent = wells.length + ' well' + (wells.length === 1 ? '' : 's')
+ ' \u00B7 ' + repulsors.length + ' repulsor' + (repulsors.length === 1 ? '' : 's');
}
function dimHint() {
hintEl.classList.add('dim');
clearTimeout(uiTimer);
uiTimer = setTimeout(() => hintEl.classList.remove('dim'), 4000);
}
function addField(kind, x, y) {
const arr = kind === 'well' ? wells : repulsors;
arr.push({ x: x, y: y, ph: Math.random() * 7 });
if (arr.length > MAX_FIELDS) arr.shift();
updateFields();
dimHint();
}
function clearFields() { wells.length = 0; repulsors.length = 0; updateFields(); }
canvas.addEventListener('mousedown', e => {
if (e.button === 0) addField('well', e.clientX, e.clientY);
else if (e.button === 2) addField('repulsor', e.clientX, e.clientY);
});
window.addEventListener('contextmenu', e => e.preventDefault());
document.addEventListener('keydown', e => {
if (e.key === 'c' || e.key === 'C') clearFields();
});
document.getElementById('clear').addEventListener('click', clearFields);
const gSlider = document.getElementById('g');
const gOut = document.getElementById('gval');
gSlider.addEventListener('input', () => {
G = +gSlider.value;
gOut.textContent = G.toFixed(2);
});
// ---- field markers ----
function drawMarkers() {
for (let k = 0; k < wells.length; k++) {
const w = wells[k];
const r = 9 + Math.sin(t * 2.2 + w.ph) * 2.5;
ctx.drawImage(WELL_SPRITE, w.x - 26, w.y - 26, 52, 52);
ctx.strokeStyle = 'hsla(215,95%,78%,.55)';
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(w.x, w.y, r, t * 1.4 + w.ph, t * 1.4 + w.ph + 4.6);
ctx.stroke();
}
ctx.setLineDash([4, 5]);
for (let k = 0; k < repulsors.length; k++) {
const w = repulsors[k];
const r = 10 + Math.sin(t * 2.6 + w.ph) * 2.5;
ctx.drawImage(REP_SPRITE, w.x - 26, w.y - 26, 52, 52);
ctx.strokeStyle = 'hsla(15,95%,62%,.6)';
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(w.x, w.y, r, -t * 1.1 + w.ph, -t * 1.1 + w.ph + 6.28);
ctx.stroke();
}
ctx.setLineDash([]);
}
// ---- main loop ----
function frame(now) {
const dt = Math.min(Math.max((now - last) / 16.667, 0.4), 2.5);
last = now;
t = now / 1000;
// long-exposure fade: wash the canvas toward the background
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'rgba(4,6,14,' + FADE + ')';
ctx.fillRect(0, 0, W, H);
// everything below glows additively
ctx.globalCompositeOperation = 'lighter';
drawMarkers();
const tz = t * Z_DRIFT;
const dmp = Math.pow(DAMP, dt);
const nW = wells.length, nR = repulsors.length;
for (let i = 0; i < COUNT; i++) {
// noise flow field
const a = noise3(px[i] * NOISE_SCALE, py[i] * NOISE_SCALE, tz) * Math.PI * 3;
let ax = Math.cos(a) * FLOW;
let ay = Math.sin(a) * FLOW;
// gravity wells: radial pull + tangential swirl => spiral
for (let k = 0; k < nW; k++) {
const dx = wells[k].x - px[i], dy = wells[k].y - py[i];
const d2 = dx*dx + dy*dy + 900;
const f = Math.min(G * WELL_K / d2, 0.55) / Math.sqrt(d2);
ax += dx * f - dy * f * SWIRL;
ay += dy * f + dx * f * SWIRL;
}
// repulsors: flee radially
for (let k = 0; k < nR; k++) {
const dx = px[i] - repulsors[k].x, dy = py[i] - repulsors[k].y;
const d2 = dx*dx + dy*dy + 900;
const f = Math.min(G * REPULSE_G * REPULSE_K / d2, 0.8) / Math.sqrt(d2);
ax += dx * f;
ay += dy * f;
}
vx[i] = (vx[i] + ax * dt) * dmp;
vy[i] = (vy[i] + ay * dt) * dmp;
const s2 = vx[i]*vx[i] + vy[i]*vy[i];
if (s2 > MAX_SPEED * MAX_SPEED) {
const s = MAX_SPEED / Math.sqrt(s2);
vx[i] *= s; vy[i] *= s;
}
px[i] += vx[i] * dt;
py[i] += vy[i] * dt;
// captured by a well -> recycle elsewhere so the field keeps flowing
if (nW > 0) {
let cap = false;
for (let k = 0; k < nW; k++) {
const dx = px[i] - wells[k].x, dy = py[i] - wells[k].y;
if (dx*dx + dy*dy < CAPTURE_D2) { cap = true; break; }
}
if (cap) { spawn(i); continue; }
}
// wrap with margin (dots only, so no streaks across the wrap)
if (px[i] < -MARGIN) px[i] += W + 2*MARGIN;
else if (px[i] > W + MARGIN) px[i] -= W + 2*MARGIN;
if (py[i] < -MARGIN) py[i] += H + 2*MARGIN;
else if (py[i] > H + MARGIN) py[i] -= H + 2*MARGIN;
const r = rad[i];
ctx.drawImage(sprites[hueIdx[i]], px[i] - r, py[i] - r, r * 2, r * 2);
}
ctx.globalCompositeOperation = 'source-over';
requestAnimationFrame(frame);
}
// ---- boot ----
window.addEventListener('resize', resize);
resize();
seedParticles();
updateFields();
requestAnimationFrame(now => { last = now; frame(now); });
</script>
</body>
</html>