Spaces:
Running
Running
opencode
feat: add Wormholes scene, tour mode, keyboard shortcuts, URL permalinks, bucket upload
acfcfe6 | /* ============================================================ | |
| COSMIC GARDEN — main.js | |
| Self-contained generative art engine. | |
| No dependencies, no AI. Pure HTML5 Canvas + Web Audio. | |
| ============================================================ */ | |
| (() => { | |
| ; | |
| // ----------------- 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)=> v<a?a:v>b?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<n;i++){ | |
| this.particles.push({ | |
| x: rand(0,W), y: rand(0,H), | |
| vx:0, vy:0, life: rand(60,260), age:0, | |
| c: choice(pal()), w: rand(0.6, 2.4) | |
| }); | |
| } | |
| } | |
| step(dt){ | |
| this.t += dt * cfg.speed; | |
| const n = this.noise; | |
| for (const p of this.particles){ | |
| const a = (n(p.x*0.0025, p.y*0.0025 + this.t*0.05) + 1) * Math.PI; | |
| p.vx = Math.cos(a)*1.6; | |
| p.vy = Math.sin(a)*1.6; | |
| // gentle attraction to mouse when held | |
| if (cfg.mouse.down){ | |
| const dx = cfg.mouse.x - p.x, dy = cfg.mouse.y - p.y; | |
| p.vx += dx*0.0015; p.vy += dy*0.0015; | |
| } | |
| p.x += p.vx * cfg.speed; p.y += p.vy * cfg.speed; | |
| p.age += dt; | |
| if (p.age > 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;i<n;i++) this.boids.push(this.mkBoid()); | |
| for (let i=0;i<3;i++) this.predators.push(this.mkBoid(true)); | |
| } | |
| mkBoid(pred=false){ | |
| return { | |
| x: rand(0,W), y: rand(0,H), | |
| vx: rand(-1,1), vy: rand(-1,1), | |
| pred, c: pred ? [255,80,80] : choice(pal().slice(0,4)), | |
| size: pred ? 4 : rand(1.6,3.2) | |
| }; | |
| } | |
| step(dt){ | |
| const sp = cfg.speed; | |
| const bs = this.boids, ps = this.predators; | |
| for (const b of bs){ | |
| let ax=0, ay=0, cx=0, cy=0, cn=0, sx=0, sy=0; | |
| for (const o of bs){ | |
| if (o===b) continue; | |
| const dx=o.x-b.x, dy=o.y-b.y, d2=dx*dx+dy*dy; | |
| if (d2 < 60*60 && d2>0.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 (d<nd){ nd=d; nearest=b; } | |
| } | |
| if (nearest){ tx=nearest.x; ty=nearest.y; } | |
| } | |
| p.vx += (tx - p.x)*0.0003 + rand(-0.05,0.05); | |
| p.vy += (ty - p.y)*0.0003 + rand(-0.05,0.05); | |
| const sp2 = Math.hypot(p.vx,p.vy), max=3.4; | |
| if (sp2>max){ 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<N;i++){ | |
| const r = rand(0.4, 1.6); | |
| const th = rand(0, TAU); | |
| const ph = rand(-Math.PI/2, Math.PI/2); | |
| this.voxels.push({ | |
| r, th, ph, | |
| base: rand(0.8,1.2), | |
| wob: rand(0, TAU), | |
| c: choice(palc) | |
| }); | |
| } | |
| } | |
| step(dt){ | |
| this.t += dt * cfg.speed * 0.4; | |
| this.ang += dt * cfg.speed * 0.2; | |
| } | |
| project(x,y,z){ | |
| const cy = Math.cos(this.ang), sy = Math.sin(this.ang); | |
| const X = x*cy - z*sy; | |
| const Z = x*sy + z*cy; | |
| const cx = Math.cos(this.t*0.3), sx = Math.sin(this.t*0.3); | |
| const Y = y*cx - Z*sx; | |
| const Z2 = y*sx + Z*cx; | |
| const dist = 4; | |
| const scale = 220/(dist + Z2*0.3); | |
| return { x: W/2 + X*scale, y: H/2 + Y*scale*0.8, z: Z2, scale }; | |
| } | |
| draw(){ | |
| const R = Math.min(W,H)*0.28; | |
| // sort voxels back-to-front | |
| const list = this.voxels.map(v=>{ | |
| 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<this.cols&&y>=0&&y<this.rows) this.grid[y*this.cols+x] = 1; | |
| } | |
| this.timer = 0; | |
| } | |
| onResize(){ this.init(); } | |
| idx(x,y){ return y*this.cols+x; } | |
| neighbors(x,y){ | |
| let n=0; | |
| // hex-ish neighborhood (just 8 for simplicity, looks great) | |
| for (let dy=-1;dy<=1;dy++) for (let dx=-1;dx<=1;dx++){ | |
| if (!dx && !dy) continue; | |
| const nx = (x+dx+this.cols)%this.cols; | |
| const ny = (y+dy+this.rows)%this.rows; | |
| n += this.grid[this.idx(nx,ny)]; | |
| } | |
| return n; | |
| } | |
| step(dt){ | |
| if (!cfg.evolve) return; | |
| this.timer += dt * cfg.speed; | |
| const interval = 0.12; | |
| if (this.timer < interval) return; | |
| this.timer = 0; | |
| for (let y=0;y<this.rows;y++){ | |
| for (let x=0;x<this.cols;x++){ | |
| const n = this.neighbors(x,y); | |
| const i = this.idx(x,y); | |
| const cur = this.grid[i]; | |
| let nv = cur; | |
| if (cur){ nv = (n===2||n===3)?1:0; } | |
| else { nv = (n===3)?1:0; } | |
| this.next[i] = nv; | |
| if (nv && cur) this.age[i] = Math.min(this.age[i]+1, 40); | |
| if (!nv) this.age[i] = 0; | |
| } | |
| } | |
| const t = this.grid; this.grid = this.next; this.next = t; | |
| } | |
| draw(){ | |
| const cmap = pal(); | |
| const c0 = cmap[0], c1 = cmap[2], c2 = cmap[1]; | |
| for (let y=0;y<this.rows;y++){ | |
| for (let x=0;x<this.cols;x++){ | |
| const i = this.idx(x,y); | |
| if (!this.grid[i]) continue; | |
| const a = this.age[i]/40; | |
| let col; | |
| if (a<0.5) col = lerp(c0,c1,a*2); else col = lerp(c1,c2,(a-0.5)*2); | |
| ctx.fillStyle = rgba(col, 0.85); | |
| const ox = x*this.cell, oy = y*this.cell; | |
| ctx.fillRect(ox+1, oy+1, this.cell-2, this.cell-2); | |
| } | |
| } | |
| } | |
| onPointer(type,x,y){ | |
| if (type==='down' || type==='move'){ | |
| if (type==='move' && !cfg.mouse.down) return; | |
| const cx = (x/this.cell)|0, cy = (y/this.cell)|0; | |
| for (let dy=-2;dy<=2;dy++) for (let dx=-2;dx<=2;dx++){ | |
| const nx=cx+dx, ny=cy+dy; | |
| if (nx<0||ny<0||nx>=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<this.grid.length;i++) this.grid[i] = Math.random()<0.45?1:0; | |
| blip(180); | |
| } | |
| } | |
| count(){ let n=0; for (let i=0;i<this.grid.length;i++) n+=this.grid[i]; return n; } | |
| hint(){ return 'paint cells by clicking/dragging · double-click for chaos · Tune Evolve off to stop'; } | |
| } | |
| function lerp(a,b,t){ return [a[0]+(b[0]-a[0])*t|0, a[1]+(b[1]-a[1])*t|0, a[2]+(b[2]-a[2])*t|0]; } | |
| // ============================================================ | |
| // 5. STARFALL — parallax field + comets | |
| // ============================================================ | |
| class StarfallScene extends Scene{ | |
| init(){ | |
| this.stars = []; | |
| this.comets = []; | |
| const layers = 4; | |
| const n = cfg.density; | |
| for (let i=0;i<n;i++){ | |
| const layer = randInt(0, layers-1); | |
| this.stars.push({ | |
| x: rand(0,W), y: rand(0,H), | |
| z: layer+1, | |
| r: layer*0.5 + 0.4, | |
| tw: rand(0, TAU), | |
| c: choice(pal()) | |
| }); | |
| } | |
| this.tspawn = 0; | |
| } | |
| step(dt){ | |
| const sp = cfg.speed; | |
| for (const s of this.stars){ | |
| s.x -= (s.z*0.4 + 0.2) * sp; | |
| if (s.x < -5){ s.x = W+5; s.y = rand(0,H); } | |
| s.tw += dt*3; | |
| } | |
| for (const c of this.comets){ | |
| c.x += c.vx*sp; c.y += c.vy*sp; | |
| c.life -= dt; | |
| } | |
| this.comets = this.comets.filter(c=> c.life>0 && c.x>-200 && c.x<W+200 && c.y>-200 && c.y<H+200); | |
| this.tspawn -= dt; | |
| if (this.tspawn<=0){ | |
| this.tspawn = rand(0.4, 1.6); | |
| const fromLeft = Math.random()<0.5; | |
| this.comets.push({ | |
| x: fromLeft? -50 : W+50, y: rand(0, H*0.7), | |
| vx: (fromLeft?1:-1)*rand(4,9), | |
| vy: rand(1,3), | |
| life: rand(1.4, 2.5), | |
| c: choice(pal()), | |
| trail: [] | |
| }); | |
| } | |
| while (this.stars.length < cfg.density) { | |
| this.stars.push({ x:rand(0,W), y:rand(0,H), z:randInt(1,4), r:rand(0.4,2.2), tw:rand(0,TAU), c:choice(pal()) }); | |
| } | |
| while (this.stars.length > 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<c.trail.length;i++){ | |
| const a = i/c.trail.length; | |
| ctx.strokeStyle = rgba(c.c, a*0.8); | |
| ctx.lineWidth = a*3 + 0.3; | |
| ctx.beginPath(); | |
| ctx.moveTo(c.trail[i-1][0], c.trail[i-1][1]); | |
| ctx.lineTo(c.trail[i][0], c.trail[i][1]); | |
| ctx.stroke(); | |
| } | |
| ctx.fillStyle = rgba(c.c, 1); | |
| ctx.beginPath(); ctx.arc(c.x, c.y, 2.4, 0, TAU); ctx.fill(); | |
| } | |
| ctx.globalCompositeOperation = 'source-over'; | |
| } | |
| onPointer(type,x,y){ | |
| if (type==='down'){ | |
| for (let i=0;i<3;i++) this.comets.push({ | |
| x, y, vx: rand(-8,8), vy: rand(-8,8), | |
| life: rand(1,2), c: choice(pal()), trail:[] | |
| }); | |
| blip(rand(600,1200)); | |
| } | |
| } | |
| count(){ return this.stars.length + this.comets.length; } | |
| hint(){ return 'click to launch comets · stars drift on parallax layers'; } | |
| } | |
| // ============================================================ | |
| // 6. WORMHOLES — N-body gravity wells with orbiting particles | |
| // ============================================================ | |
| class WormholeScene extends Scene{ | |
| init(){ | |
| this.wells = []; | |
| this.particles = []; | |
| const n = clamp(cfg.density, 60, 360); | |
| for (let i=0;i<4;i++) this.spawnWell(); | |
| for (let i=0;i<n;i++) this.spawnParticle(); | |
| this.trails = true; | |
| } | |
| spawnWell(){ | |
| this.wells.push({ | |
| x: rand(W*.2, W*.8), y: rand(H*.2, H*.8), | |
| mass: rand(800, 2600), | |
| charge: Math.random()<0.5 ? 1 : -1, | |
| vx: rand(-0.4,0.4), vy: rand(-0.4,0.4), | |
| pulse: rand(0, TAU), | |
| c: choice(pal()) | |
| }); | |
| } | |
| spawnParticle(){ | |
| const well = choice(this.wells); | |
| const ang = rand(0, TAU); | |
| const r = rand(60, 200); | |
| const orbit = Math.sqrt(well.mass / r) * 0.5; | |
| this.particles.push({ | |
| x: well.x + Math.cos(ang)*r, | |
| y: well.y + Math.sin(ang)*r, | |
| // tangential orbit velocity (perpendicular to radius) | |
| vx: -Math.sin(ang)*orbit + well.vx, | |
| vy: Math.cos(ang)*orbit + well.vy, | |
| c: choice(pal()), | |
| life: rand(200, 600), | |
| age: 0 | |
| }); | |
| } | |
| step(dt){ | |
| const sp = cfg.speed; | |
| // wells attract / repel each other and bounce off walls | |
| for (let i=0;i<this.wells.length;i++){ | |
| const a = this.wells[i]; | |
| for (let j=i+1;j<this.wells.length;j++){ | |
| const b = this.wells[j]; | |
| const dx = b.x-a.x, dy = b.y-a.y; | |
| const d2 = Math.max(400, dx*dx+dy*dy); | |
| const d = Math.sqrt(d2); | |
| const f = (a.mass*b.mass/d2) * 0.0000025; | |
| const sign = a.charge*b.charge > 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<ps.length;i++){ | |
| const p = ps[i]; | |
| for (const w of this.wells){ | |
| const dx = w.x-p.x, dy = w.y-p.y; | |
| const d2 = Math.max(50, dx*dx+dy*dy); | |
| const d = Math.sqrt(d2); | |
| const f = (w.charge*w.mass / d2) * 0.0006 * sp; | |
| p.vx += dx/d * f; p.vy += dy/d * f; | |
| } | |
| p.x += p.vx*sp; p.y += p.vy*sp; | |
| p.age += dt; | |
| if (p.age > 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); | |
| })(); | |