/* ============================================================ COSMIC GARDEN — main.js Self-contained generative art engine. No dependencies, no AI. Pure HTML5 Canvas + Web Audio. ============================================================ */ (() => { 'use strict'; // ----------------- canvas + state ----------------- const cvs = document.getElementById('stage'); const ctx = cvs.getContext('2d'); let W = 0, H = 0, DPR = Math.min(window.devicePixelRatio || 1, 2); function resize(){ DPR = Math.min(window.devicePixelRatio || 1, 2); W = window.innerWidth; H = window.innerHeight; cvs.width = W * DPR; cvs.height = H * DPR; cvs.style.width = W + 'px'; cvs.style.height = H + 'px'; ctx.setTransform(DPR, 0, 0, DPR, 0, 0); if (current && current.onResize) current.onResize(); } window.addEventListener('resize', resize); // shared UI handles const $ = id => document.getElementById(id); const hint = $('hint'), statFps = $('statFps'), statCount = $('statCount'); // global config const cfg = { scene: 'aurora', theme: 'amethyst', speed: 1, density: 140, evolve: true, paused: false, sound: false, mouse: { x:W/2, y:H/2, px:W/2, py:H/2, down:false, double:false } }; // theme palettes (RGB triplets) ---------------------------------- const THEMES = { amethyst:[[196,163,255],[255,139,216],[124,109,255],[255,90,160],[225,200,255]], ember :[[255,154, 77],[255, 61,107],[255,200, 90],[255,120, 50],[255,230,180]], reef :[[ 94,242,214],[ 61,181,255],[120,255,200],[200,255,255],[ 60,240,180]], mono :[[255,255,255],[200,200,200],[160,160,160],[230,230,230],[120,120,120]], }; function pal(){ return THEMES[cfg.theme]; } function rgba(rgb, a){ return `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`; } // apply theme attribute on root for CSS function applyTheme(){ document.documentElement.dataset.theme = cfg.theme; } // ----------------- helpers ----------------- const TAU = Math.PI * 2; const rand = (a,b)=> a + Math.random()*(b-a); const randInt = (a,b)=> Math.floor(rand(a,b+1)); const clamp = (v,a,b)=> vb?b:v; function choice(arr){ return arr[(Math.random()*arr.length)|0]; } // deterministic-ish noise (cheap value noise) function makeNoise(seed){ const perm = new Uint8Array(512); let s = seed || 1; const rng = () => (s = (s*1664525 + 1013904223) >>> 0)/4294967296; for (let i=0;i<256;i++) perm[i] = i; for (let i=255;i>0;i--){ const j=(rng()*(i+1))|0; const t=perm[i]; perm[i]=perm[j]; perm[j]=t; } for (let i=0;i<256;i++) perm[256+i]=perm[i]; const fade = t=> t*t*(3-2*t); const grad = (h,x,y)=> { const u = h<4 ? x : y; const v = h<4 ? y : x; return ((h&1)?-u:u) + ((h&2)?-2*v:2*v); }; return (x,y)=>{ const X = Math.floor(x)&255, Y = Math.floor(y)&255; x -= Math.floor(x); y -= Math.floor(y); const u = fade(x), v = fade(y); const aa = perm[perm[X]+Y], ab = perm[perm[X]+Y+1]; const ba = perm[perm[X+1]+Y], bb = perm[perm[X+1]+Y+1]; const x1 = grad(aa,x,y) + u*(grad(ba,x-1,y) - grad(aa,x,y)); const x2 = grad(ab,x,y-1) + u*(grad(bb,x-1,y-1) - grad(ab,x,y-1)); return x1 + v*(x2-x1); }; } // ----------------- Web Audio ambient ----------------- let audioCtx = null, masterGain = null, engineNodes = []; function ensureAudio(){ if (audioCtx) return; try{ const AC = window.AudioContext || window.webkitAudioContext; audioCtx = new AC(); masterGain = audioCtx.createGain(); masterGain.gain.value = 0.18; masterGain.connect(audioCtx.destination); $('audioBadge').classList.add('hide'); }catch(e){ /* silent */ } } function stopAudio(){ engineNodes.forEach(n=>{ try{ n.stop && n.stop(); n.disconnect(); }catch(e){} }); engineNodes = []; } function tone(freq, dur, type='sine', vol=0.15, when=0){ if (!audioCtx) return; const t = audioCtx.currentTime + when; const osc = audioCtx.createOscillator(); const g = audioCtx.createGain(); osc.type = type; osc.frequency.value = freq; g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(vol, t+0.02); g.gain.exponentialRampToValueAtTime(0.0001, t+dur); osc.connect(g).connect(masterGain); osc.start(t); osc.stop(t+dur+0.05); engineNodes.push(osc, g); } function pad(freqs, dur){ freqs.forEach((f,i)=> tone(f, dur, 'sine', 0.06, i*0.08)); } function blip(freq){ tone(freq, 0.15, 'triangle', 0.18); } // ============================================================ // SCENE BASE // ============================================================ class Scene{ constructor(){ } init(){} step(dt){} draw(){} onResize(){} onPointer(type, x, y){} count(){ return 0; } hint(){ return 'interact with the canvas'; } } // ============================================================ // 1. AURORA — flow field ribbons // ============================================================ class AuroraScene extends Scene{ init(){ this.noise = makeNoise(rand(1,9999)); this.t = 0; this.particles = []; this.spawn(cfg.density); this.ribbons = []; } spawn(n){ for (let i=0;i p.life || p.x<-20 || p.x>W+20 || p.y<-20 || p.y>H+20){ p.x = rand(0,W); p.y = rand(0,H); p.age = 0; p.c = choice(pal()); } } // keep counts roughly matching density const target = cfg.density; while (this.particles.length < target) this.spawn(1); if (this.particles.length > target*1.4) this.particles.length = Math.floor(target*1.2); } draw(){ ctx.globalCompositeOperation = 'lighter'; for (const p of this.particles){ const a = 0.4 * (1 - p.age/p.life); ctx.strokeStyle = rgba(p.c, a*0.6); ctx.lineWidth = p.w; ctx.beginPath(); ctx.moveTo(p.x - p.vx*4, p.y - p.vy*4); ctx.lineTo(p.x, p.y); ctx.stroke(); } ctx.globalCompositeOperation = 'source-over'; } onPointer(type,x,y){ if (type==='down'){ for (let i=0;i<40;i++) this.particles.push({ x, y, vx:rand(-2,2), vy:rand(-2,2), life:rand(80,200), age:0, c: choice(pal()), w: rand(0.8,3) }); blip(rand(220,660)); } } count(){ return this.particles.length; } hint(){ return 'click to seed ribbons · drag to attract · drift through flow field'; } } // ============================================================ // 2. FLOCK — boids with predators // ============================================================ class FlockScene extends Scene{ init(){ this.boids = []; this.predators = []; const n = cfg.density; for (let i=0;i0.01){ const d = Math.sqrt(d2); if (d < 18){ sx -= dx/d; sy -= dy/d; } // separation cx += o.x; cy += o.y; cn++; // cohesion ax += o.vx; ay += o.vy; // alignment } } if (cn){ ax/=cn; ay/=cn; cx/=cn - b.x; cy/=cn - b.y; } b.vx += (ax - b.vx)*0.04 + cx*0.0008 + sx*0.06; b.vy += (ay - b.vy)*0.04 + cy*0.0008 + sy*0.06; // flee predators for (const p of ps){ const dx = b.x-p.x, dy = b.y-p.y, d2 = dx*dx+dy*dy; if (d2 < 110*110 && d2>0.01){ const d = Math.sqrt(d2); b.vx += dx/d * (1 - d/110) * 0.8; b.vy += dy/d * (1 - d/110) * 0.8; } } // mouse attraction when held if (cfg.mouse.down){ const dx = cfg.mouse.x - b.x, dy = cfg.mouse.y - b.y; b.vx += dx*0.0006; b.vy += dy*0.0006; } const sp2 = Math.hypot(b.vx,b.vy); const max = 2.6; if (sp2 > max){ b.vx = b.vx/sp2*max; b.vy = b.vy/sp2*max; } b.x += b.vx*sp; b.y += b.vy*sp; this.wrap(b); } for (const p of ps){ let tx = W/2, ty = H/2; if (bs.length){ let nearest=null, nd=1e9; for (const b of bs){ const d = (b.x-p.x)**2 + (b.y-p.y)**2; if (dmax){ p.vx=p.vx/sp2*max; p.vy=p.vy/sp2*max; } p.x += p.vx*sp; p.y += p.vy*sp; this.wrap(p); } while (this.boids.length < cfg.density) this.boids.push(this.mkBoid()); while (this.boids.length > cfg.density*1.15) this.boids.pop(); } wrap(b){ if (b.x<-10) b.x = W+10; else if (b.x>W+10) b.x = -10; if (b.y<-10) b.y = H+10; else if (b.y>H+10) b.y = -10; } draw(){ ctx.globalCompositeOperation = 'lighter'; for (const b of this.boids){ const a = Math.atan2(b.vy, b.vx); const sz = b.size; ctx.fillStyle = rgba(b.c, 0.85); ctx.beginPath(); ctx.moveTo(b.x + Math.cos(a)*sz*2, b.y + Math.sin(a)*sz*2); ctx.lineTo(b.x - Math.cos(a)*sz, b.y - Math.sin(a)*sz + 3); ctx.lineTo(b.x - Math.cos(a)*sz, b.y - Math.sin(a)*sz - 3); ctx.closePath(); ctx.fill(); } for (const p of this.predators){ ctx.fillStyle = rgba([255,40,60], 0.95); ctx.beginPath(); ctx.arc(p.x, p.y, 5, 0, TAU); ctx.fill(); } ctx.globalCompositeOperation = 'source-over'; // very subtle fade so trails persist a moment } onPointer(type,x,y){ if (type==='down'){ for (let i=0;i<20;i++) this.boids.push(this.mkBoid()); blip(360); } else if (type==='dbl'){ this.predators.push(this.mkBoid(true)); blip(120); } } count(){ return this.boids.length + this.predators.length; } hint(){ return 'click to add prey · double-click to summon a predator · drag to herd'; } } // ============================================================ // 3. VOXEL — rotating 3D sculpture // ============================================================ class VoxelScene extends Scene{ init(){ this.t = 0; this.ang = 0; this.build(); } build(){ // map coordinates {r,theta,phi} -> voxel with color this.voxels = []; const palc = pal(); const N = clamp(cfg.density*.7, 40, 320); for (let i=0;i{ const wob = cfg.evolve ? Math.sin(this.t*1.2 + v.wob)*0.22 : 0; const r = v.r * R * (v.base + wob); const x = r * Math.cos(v.th) * Math.cos(v.ph); const y = r * Math.sin(v.ph) * 1.4; const z = r * Math.sin(v.th) * Math.cos(v.ph); const p = this.project(x,y,z); return { p, v }; }); list.sort((a,b)=> a.p.z - b.p.z); ctx.globalCompositeOperation = 'lighter'; for (const {p,v} of list){ const s = clamp(2 + p.scale*4, 1.5, 12); const a = 0.4 + (p.z+2)/4*0.5; ctx.fillStyle = rgba(v.c, clamp(a,0.2,0.95)); ctx.fillRect(p.x - s/2, p.y - s/2, s, s); } ctx.globalCompositeOperation = 'source-over'; } onResize(){} onPointer(type,x,y){ if (type==='down'){ this.build(); blip(440); } } count(){ return this.voxels.length; } hint(){ return 'click to rebuild the sculpture · let it morph through time'; } } // ============================================================ // 4. LATTICE — cellular automata on hex grid // ============================================================ class LatticeScene extends Scene{ init(){ this.cell = 18; this.cols = Math.ceil(W/this.cell) + 2; this.rows = Math.ceil(H/this.cell) + 2; this.grid = new Uint8Array(this.cols*this.rows); this.next = new Uint8Array(this.cols*this.rows); this.age = new Uint16Array(this.cols*this.rows); // seed center bloom const cx = (this.cols/2)|0, cy = (this.rows/2)|0; for (let i=0;i<600;i++){ const x=(cx + (Math.random()*20-10))|0; const y=(cy + (Math.random()*20-10))|0; if (x>=0&&x=0&&y=this.cols||ny>=this.rows) continue; if (Math.random()<0.55) this.grid[this.idx(nx,ny)] = 1; } } else if (type==='dbl'){ // chaos: random fill for (let i=0;i c.life>0 && c.x>-200 && c.x-200 && c.y cfg.density*1.2) this.stars.pop(); } draw(){ ctx.globalCompositeOperation = 'lighter'; for (const s of this.stars){ const tw = 0.5 + Math.sin(s.tw)*0.5; ctx.fillStyle = rgba(s.c, 0.35 + tw*0.55); ctx.beginPath(); ctx.arc(s.x, s.y, s.r * (1 + tw*0.6), 0, TAU); ctx.fill(); } for (const c of this.comets){ c.trail.push([c.x, c.y]); if (c.trail.length > 18) c.trail.shift(); for (let i=1;i 0 ? -1 : 1; // same sign repels a.vx += sign*dx/d * f; a.vy += sign*dy/d * f; b.vx -= sign*dx/d * f; b.vy -= sign*dy/d * f; } // mouse wells: when down, push all wells away if (cfg.mouse.down){ const dx = a.x-cfg.mouse.x, dy = a.y-cfg.mouse.y; const d = Math.max(40, Math.hypot(dx,dy)); a.vx += dx/d * 0.4; a.vy += dy/d * 0.4; } a.vx *= 0.995; a.vy *= 0.995; a.x += a.vx*sp; a.y += a.vy*sp; // soft bounce off edges if (a.x<80){ a.x=80; a.vx*=-0.6; } if (a.x>W-80){ a.x=W-80; a.vx*=-0.6; } if (a.y<80){ a.y=80; a.vy*=-0.6; } if (a.y>H-80){ a.y=H-80; a.vy*=-0.6; } a.pulse += dt*2; } // particles follow gravity from all wells const ps = this.particles; for (let i=0;i p.life || p.x<-50 || p.x>W+50 || p.y<-50 || p.y>H+50){ // respawn near a well Object.assign(p, this._respawn()); } } while (this.particles.length < cfg.density) this.spawnParticle(); while (this.particles.length > cfg.density*1.2) this.particles.pop(); } _respawn(){ const well = choice(this.wells); const ang = rand(0, TAU); const r = rand(60, 200); const orbit = Math.sqrt(well.mass / r) * 0.5; return { x: well.x + Math.cos(ang)*r, y: well.y + Math.sin(ang)*r, vx: -Math.sin(ang)*orbit + well.vx, vy: Math.cos(ang)*orbit + well.vy, life: rand(200, 600), age: 0, c: choice(pal()) }; } draw(){ ctx.globalCompositeOperation = 'lighter'; // wells: glowing nuclei + event horizon ring for (const w of this.wells){ const pr = 40 + Math.sin(w.pulse)*4; const grd = ctx.createRadialGradient(w.x, w.y, 0, w.x, w.y, pr); grd.addColorStop(0, rgba(w.c, 0.9)); grd.addColorStop(0.5, rgba(w.c, 0.18)); grd.addColorStop(1, rgba(w.c, 0)); ctx.fillStyle = grd; ctx.beginPath(); ctx.arc(w.x, w.y, pr, 0, TAU); ctx.fill(); // accretion ring ctx.strokeStyle = rgba(w.c, 0.5); ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(w.x, w.y, 14, 0, TAU); ctx.stroke(); } // particles: thin streaks for (const p of this.particles){ const a = 0.4 * (1 - p.age/p.life); ctx.strokeStyle = rgba(p.c, a); ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(p.x - p.vx*3, p.y - p.vy*3); ctx.lineTo(p.x, p.y); ctx.stroke(); } ctx.globalCompositeOperation = 'source-over'; } onPointer(type,x,y){ if (type==='down'){ this.wells.push({ x, y, mass: rand(800,2600), charge: Math.random()<0.5?1:-1, vx:0, vy:0, pulse: 0, c: choice(pal()) }); if (this.wells.length > 9) this.wells.shift(); blip(rand(120, 320)); } else if (type==='dbl'){ // chaos: invert all charges for (const w of this.wells) w.charge *= -1; blip(80); } } count(){ return this.particles.length + this.wells.length; } hint(){ return 'click to drop a gravity well · double-click to invert charges · emergent orbits'; } } // ----------------- scene registry ----------------- const SCENES = { aurora: AuroraScene, flock: FlockScene, voxel: VoxelScene, lattice: LatticeScene, starfall:StarfallScene, wormhole:WormholeScene, }; let current = null; let suppressHash = false; // don't write back when reading from hash function setScene(name){ const Klass = SCENES[name] || AuroraScene; cfg.scene = name; current = new Klass(); current.init(); hint.textContent = current.hint(); rebuildAudio(); document.querySelectorAll('#sceneTabs button').forEach(b=>{ b.classList.toggle('active', b.dataset.scene === name); }); writeHash(); } // per-scene ambient audio (cheap pad chords) function rebuildAudio(){ stopAudio(); if (!audioCtx) return; const chords = { aurora: [220, 277.18, 329.63, 440], flock: [196, 246.94, 293.66], voxel: [174.61, 220, 261.63, 349.23], lattice: [110, 138.59, 164.81], starfall: [261.63, 329.63, 392, 523.25], wormhole: [82.41, 110, 138.59, 164.81, 220], }; loopChord(chords[cfg.scene] || chords.aurora); } let chordTimer = null; function loopChord(freqs){ if (!audioCtx) return; const play = ()=>{ pad(freqs, 4.5); chordTimer = setTimeout(play, 4000); }; play(); } // ----------------- pointer ----------------- cvs.addEventListener('pointerdown', e=>{ ensureAudio(); cfg.mouse.down = true; cfg.mouse.x = e.clientX; cfg.mouse.y = e.clientY; current.onPointer('down', e.clientX, e.clientY); }); cvs.addEventListener('pointermove', e=>{ cfg.mouse.px = cfg.mouse.x; cfg.mouse.py = cfg.mouse.y; cfg.mouse.x = e.clientX; cfg.mouse.y = e.clientY; current.onPointer('move', e.clientX, e.clientY); }); cvs.addEventListener('pointerup', ()=> cfg.mouse.down=false ); cvs.addEventListener('pointerleave', ()=> cfg.mouse.down=false ); cvs.addEventListener('dblclick', e=>{ current.onPointer('dbl', e.clientX, e.clientY); }); // ----------------- UI bindings ----------------- $('sceneTabs').addEventListener('click', e=>{ const btn = e.target.closest('button[data-scene]'); if (btn) setScene(btn.dataset.scene); }); $('theme').addEventListener('change', e=>{ cfg.theme = e.target.value; applyTheme(); // recolor particles for instant feedback if (current && current.particles) for (const p of current.particles) p.c = choice(pal()); }); $('speed').addEventListener('input', e=>{ cfg.speed = parseFloat(e.target.value); writeHash(); }); $('density').addEventListener('input', e=>{ cfg.density = parseInt(e.target.value); writeHash(); }); $('evolve').addEventListener('change', e=> cfg.evolve = e.target.checked ); $('btnSeed').addEventListener('click', ()=>{ setScene(cfg.scene); blip(880); }); $('btnPause').addEventListener('click', togglePause); function togglePause(){ cfg.paused = !cfg.paused; $('pauseLbl').textContent = cfg.paused ? 'Play' : 'Pause'; writeHash(); } $('btnSound').addEventListener('click', ()=>{ ensureAudio(); cfg.sound = !cfg.sound; $('soundLbl').textContent = cfg.sound ? 'Sound' : 'Muted'; if (cfg.sound){ masterGain.gain.value = 0.18; rebuildAudio(); } else { masterGain.gain.value = 0.0001; stopAudio(); } }); $('btnExport').addEventListener('click', exportPng); function exportPng(){ const a = document.createElement('a'); a.download = 'cosmic-garden-' + cfg.scene + '-' + Date.now() + '.png'; a.href = cvs.toDataURL('image/png'); a.click(); flashButton('btnExport'); } // ------- Tour mode: cycle every 12s ------- const SCENE_ORDER = ['aurora','flock','voxel','lattice','starfall','wormhole']; let tourTimer = null; $('btnTour').addEventListener('click', toggleTour); function toggleTour(){ if (tourTimer){ stopTour(); } else { startTour(); } } function startTour(){ tourTimer = setInterval(()=>{ const i = SCENE_ORDER.indexOf(cfg.scene); setScene(SCENE_ORDER[(i+1) % SCENE_ORDER.length]); }, 12000); $('tourLbl').textContent = 'Stop'; } function stopTour(){ if (tourTimer){ clearInterval(tourTimer); tourTimer = null; } $('tourLbl').textContent = 'Tour'; } // ------- Help modal ------- const helpModal = $('helpModal'); function toggleHelp(val){ helpModal.classList.toggle('hidden', val===undefined ? !helpModal.classList.contains('hidden') : !val); } $('btnHelp').addEventListener('click', ()=> toggleHelp()); $('btnCloseHelp').addEventListener('click', ()=> toggleHelp(false)); // ------- Upload snapshot to HF bucket ------- // Free, anonymous-friendly (uses navigator.sendBeacon via fetch with HF_TOKEN? No — public // writes need an authenticated token. We use the user's session via the HF UI by linking // to a pre-filled commit URL the user can click. This works without exposing tokens client-side. $('btnUpload').addEventListener('click', uploadToBucket); function uploadToBucket(){ flashButton('btnUpload'); const data = cvs.toDataURL('image/png'); const ts = new Date().toISOString().replace(/[:.]/g,'-').slice(0,19); const filename = `snapshot-${cfg.scene}-${ts}.png`; // Strip the data: prefix for body and prepend the markdown embed commit form. // We can't push to the hub anonymously from the browser; instead we offer a download + // a link to upload to the bucket repo's "Add file" view. const a = document.createElement('a'); a.href = data; a.download = filename; a.click(); setTimeout(()=>{ const ok = confirm( 'Snapshot saved locally as ' + filename + '.\n\n' + 'To publish it to the shared HF bucket dataset:\n' + 'kenqtade/creative-canvas-assets\n\n' + 'Click OK to open the upload page in a new tab. (No token leaves your browser.)' ); if (ok) window.open('https://huggingface.co/datasets/kenqtade/creative-canvas-assets/new/main', '_blank'); }, 100); } function flashButton(id){ const el = $(id); if (!el) return; el.style.boxShadow = '0 0 0 3px var(--accent)'; setTimeout(()=> el.style.boxShadow = '', 320); } // ------- Keyboard shortcuts ------- window.addEventListener('keydown', e=>{ if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return; const k = e.key.toLowerCase(); if (k === 'escape'){ toggleHelp(false); stopTour(); return; } if (k === '?'){ toggleHelp(); return; } if (k >= '1' && k <= '6'){ setScene(SCENE_ORDER[parseInt(k,10)-1]); return; } if (k === 'r'){ setScene(cfg.scene); blip(880); return; } if (k === ' '){ e.preventDefault(); togglePause(); return; } if (k === 't'){ toggleTour(); return; } if (k === 's'){ $('btnSound').click(); return; } if (k === 'e'){ exportPng(); return; } if (k === 'u'){ uploadToBucket(); return; } if (k === '+' || k === '='){ cfg.speed = clamp(cfg.speed + 0.1, 0, 2); $('speed').value = cfg.speed; writeHash(); return; } if (k === '-' || k === '_'){ cfg.speed = clamp(cfg.speed - 0.1, 0, 2); $('speed').value = cfg.speed; writeHash(); return; } }); // ------- URL hash permalink ------- function writeHash(){ if (suppressHash) return; const params = new URLSearchParams(); params.set('scene', cfg.scene); params.set('theme', cfg.theme); params.set('speed', cfg.speed.toFixed(2)); params.set('density', cfg.density); if (cfg.paused) params.set('paused', '1'); history.replaceState(null, '', '#' + params.toString()); } function readHash(){ const raw = location.hash.replace(/^#/, ''); if (!raw) return; const p = new URLSearchParams(raw); suppressHash = true; try { if (p.has('theme')) { cfg.theme = p.get('theme'); $('theme').value = cfg.theme; applyTheme(); } if (p.has('speed')) { cfg.speed = clamp(parseFloat(p.get('speed'))||1, 0, 2); $('speed').value = cfg.speed; } if (p.has('density')){ cfg.density = clamp(parseInt(p.get('density'))||140, 20, 400); $('density').value = cfg.density; } if (p.has('scene')) { setScene(p.get('scene')); } if (p.get('paused') === '1'){ cfg.paused = true; $('pauseLbl').textContent = 'Play'; } } finally { suppressHash = false; } } window.addEventListener('hashchange', readHash); // ----------------- main loop ----------------- let last = performance.now(); let fps = 60, fpsAcc = 0, fpsCnt = 0, fpsT = 0; function loop(now){ const dt = Math.min(0.05, (now - last)/1000); last = now; // fade trail effect (per-frame translucent wash) ctx.fillStyle = `rgba(${hexToRgb(getComputedStyle(document.documentElement).getPropertyValue('--bg'))}, 0.22)`; ctx.fillRect(0,0,W,H); if (!cfg.paused) current.step(dt); current.draw(); // fps meter fpsT += dt; fpsCnt++; if (fpsT >= 0.5){ fps = (fpsCnt / fpsT) | 0; fpsCnt = 0; fpsT = 0; statFps.textContent = fps; statCount.textContent = current.count(); } requestAnimationFrame(loop); } function hexToRgb(h){ h = (h||'#0b0420').trim().replace('#',''); if (h.length===3) h = h.split('').map(c=>c+c).join(''); const n = parseInt(h,16); return [(n>>16)&255, (n>>8)&255, n&255].join(','); } // ----------------- boot ----------------- applyTheme(); resize(); setScene('aurora'); readHash(); requestAnimationFrame(loop); })();