creative-canvas / main.js
opencode
feat: add Wormholes scene, tour mode, keyboard shortcuts, URL permalinks, bucket upload
acfcfe6
Raw
History Blame Contribute Delete
33.3 kB
/* ============================================================
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)=> 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);
})();