Add gravity_flow: 500 glowing particles on a noise flow field, click-placed gravity wells and repulsors, gravity slider, long-exposure trails
0ffbd66 Download gravity_flow.html from zxbc2023/Toybox: direct link, hf CLI and curl.
- Browser
- Download file 12.8 kB
-
https://huggingface.co/spaces/zxbc2023/Toybox/resolve/main/gravity_flow.html
- Command line
-
hf download hf://spaces/zxbc2023/Toybox/gravity_flow.html
-
curl -L -o gravity_flow.html https://huggingface.co/spaces/zxbc2023/Toybox/resolve/main/gravity_flow.html
12.8 kB
| <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 · 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> | |
| ; | |
| // ============================================================ | |
| // 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> | |