← back to Playa Ignition
index.html
572 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Playa Ignition — an animated generative gallery of a Burning Man night: the neon Man effigy, fire-poi dancers, art cars and a desert starfield, all pulsing to an authored techno beat. Ignite the Man.">
<meta name="color-scheme" content="dark">
<title>Playa Ignition — A Burning Man Gallery</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' fill='%230a0a1e'/><path d='M8 2l3 6-3 6-3-6z' fill='%23ff7a2a'/></svg>">
<style>
:root{
--ink:#eae4f2; --ink-dim:#a99fc0; --neon:#ff2fd0; --neon2:#2fe6ff; --gold:#ffcf5a; --fire:#ff6a1a;
--panel:rgba(14,10,26,0.72); --panel-edge:rgba(120,90,220,0.32); --focus:#8fe0ff;
--serif:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif;
--sans:"Avenir Next","Segoe UI",system-ui,-apple-system,sans-serif;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:#05050f;color:var(--ink);font-family:var(--sans);overflow:hidden}
#stage{position:fixed;inset:0}
canvas{position:absolute;inset:0;width:100%;height:100%}
#orb{z-index:4;pointer-events:none}
.grain{position:fixed;inset:0;pointer-events:none;z-index:5;opacity:.08;mix-blend-mode:overlay;
background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 220 220' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")}
.skip{position:fixed;top:-60px;left:12px;z-index:20;background:var(--neon2);color:#04121a;padding:10px 16px;border-radius:8px;font-weight:600;text-decoration:none;transition:top .2s}
.skip:focus{top:12px}
.titleplate{position:fixed;top:74px;left:26px;z-index:6;max-width:min(52vw,560px);pointer-events:none;text-shadow:0 2px 18px rgba(0,0,0,.75)}
.titleplate .kicker{letter-spacing:.42em;text-transform:uppercase;font-size:11px;color:var(--neon2);opacity:.95;margin:0 0 6px 2px}
.titleplate h1{font-family:var(--serif);font-weight:600;font-style:italic;font-size:clamp(26px,4.4vw,52px);line-height:.98;margin:0;color:var(--ink)}
.titleplate .sub{font-family:var(--serif);font-size:14px;color:var(--ink-dim);margin-top:8px;font-style:italic}
.chrome-toggle{position:fixed;top:20px;left:20px;z-index:12;font-size:18px;padding:8px 13px;line-height:1}
.stagelink{position:fixed;top:22px;left:50%;transform:translateX(-50%);z-index:12;font-family:var(--serif);font-style:italic;font-size:12.5px;color:var(--ink-dim);text-decoration:none;background:var(--panel);border:1px solid var(--panel-edge);border-radius:999px;padding:6px 15px;backdrop-filter:blur(6px);transition:color .3s}
.stagelink:hover{color:var(--neon2)}
@media(max-width:820px){ .stagelink{display:none} }
#stage.hide-chrome .dock,#stage.hide-chrome .cams,#stage.hide-chrome .label-toggle,#stage.hide-chrome .museum{display:none!important}
.dock{position:fixed;left:50%;bottom:20px;transform:translateX(-50%);z-index:7;display:flex;gap:18px;align-items:stretch;flex-wrap:wrap;justify-content:center;max-width:min(96vw,1120px);
background:var(--panel);border:1px solid var(--panel-edge);border-radius:16px;padding:14px 20px;backdrop-filter:blur(9px);box-shadow:0 12px 40px rgba(0,0,0,.5)}
.group{display:flex;gap:12px;align-items:center}
.group + .group{border-left:1px solid rgba(120,90,220,.2);padding-left:18px}
.field{display:flex;flex-direction:column;gap:5px;min-width:92px}
.field label{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-dim)}
.field .val{font-family:var(--serif);font-style:italic;color:var(--neon2);font-size:12px}
input[type=range]{-webkit-appearance:none;appearance:none;height:4px;border-radius:4px;background:linear-gradient(90deg,var(--neon),var(--neon2));outline:none;width:100%}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;border-radius:50%;background:var(--ink);border:2px solid var(--neon2);cursor:pointer;box-shadow:0 0 8px rgba(47,230,255,.6)}
input[type=range]::-moz-range-thumb{width:16px;height:16px;border-radius:50%;background:var(--ink);border:2px solid var(--neon2);cursor:pointer}
.btn{font-family:var(--sans);font-size:12px;letter-spacing:.05em;background:rgba(30,18,54,.7);color:var(--ink);border:1px solid var(--panel-edge);border-radius:10px;padding:9px 13px;cursor:pointer;transition:background .2s,color .2s;display:inline-flex;align-items:center;gap:7px;white-space:nowrap}
.btn:hover{background:rgba(60,30,110,.9);color:var(--neon2)}
.btn[aria-pressed="true"]{background:var(--neon2);color:#04121a;border-color:var(--neon2);font-weight:600}
.btn.burn{border-color:var(--fire);color:var(--gold)}
.btn.burn:hover{background:rgba(120,40,10,.9);color:#fff}
.toggle{display:flex;flex-direction:column;gap:6px}
.cams{position:fixed;left:26px;bottom:20px;z-index:7;display:flex;gap:8px;flex-direction:column}
.cams .btn{justify-content:flex-start;min-width:118px}
.label-toggle{position:fixed;right:26px;bottom:20px;z-index:7}
.museum{position:fixed;right:26px;bottom:66px;z-index:8;width:min(88vw,368px);max-height:74vh;overflow:auto;
background:linear-gradient(180deg,rgba(18,12,34,.96),rgba(9,6,18,.96));border:1px solid var(--panel-edge);border-radius:14px;padding:20px 22px;
box-shadow:0 18px 50px rgba(0,0,0,.6);font-size:13px;line-height:1.55;color:var(--ink-dim)}
.museum[hidden]{display:none}
.museum h2{font-family:var(--serif);font-style:italic;color:var(--neon2);font-size:18px;margin:0 0 4px}
.museum .meta{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-dim);margin-bottom:12px;opacity:.8}
.museum p{margin:0 0 10px}
.museum strong{color:var(--ink)}
.museum .keys{margin:14px 0 0;padding:12px 0 0;border-top:1px solid rgba(120,90,220,.2);font-size:11px;line-height:1.7}
.museum .keys b{display:inline-block;min-width:74px;color:var(--neon2);font-family:var(--serif);font-style:italic}
:focus-visible{outline:3px solid var(--focus);outline-offset:2px;border-radius:6px}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
@media(max-width:820px){
.titleplate{max-width:64vw}
.cams{flex-direction:row;left:12px;bottom:100px}.cams .btn{min-width:auto;padding:7px 10px}
.label-toggle{bottom:100px;right:12px}.museum{bottom:148px;right:12px}
.dock{gap:10px;padding:10px 12px}.group + .group{padding-left:10px}.field{min-width:74px}
}
</style>
</head>
<body>
<a href="#dock" class="skip">Skip to controls</a>
<main id="stage" class="hide-chrome" role="application" aria-label="Playa Ignition — an animated Burning Man gallery you can steer">
<h1 class="sr">Playa Ignition — an animated generative Burning Man gallery</h1>
<canvas id="sky" aria-hidden="true"></canvas>
<canvas id="scene" aria-hidden="true"></canvas>
<canvas id="orb" aria-hidden="true"></canvas>
<div class="grain" aria-hidden="true"></div>
<div class="titleplate">
<p class="kicker">A Burning Man Gallery</p>
<h1 aria-hidden="true">Playa<br>Ignition</h1>
<p class="sub" id="phaseLabel">Deep Playa · after dark</p>
</div>
<button class="btn chrome-toggle" id="chromeBtn" aria-pressed="false" aria-label="Menu — show or hide controls">☰</button>
<a class="stagelink" href="https://stage.agentabrams.com" aria-label="Back to The Main Stage">← The Main Stage</a>
<div class="cams" role="group" aria-label="Camera view">
<button class="btn" data-cam="wide" aria-pressed="true">◈ The Playa</button>
<button class="btn" data-cam="man" aria-pressed="false">◈ The Man</button>
<button class="btn" data-cam="over" aria-pressed="false">◈ Overhead</button>
</div>
<div class="dock" id="dock" role="group" aria-label="Visual controls">
<div class="group">
<div class="toggle">
<button class="btn" id="playBtn" aria-pressed="true">❚❚ Pause</button>
<button class="btn" id="reduceBtn" aria-pressed="false">◐ Reduced Motion</button>
</div>
<div class="toggle">
<button class="btn burn" id="burnBtn">🔥 Ignite the Man</button>
<button class="btn" id="trailBtn" aria-pressed="true">✧ Light Trails</button>
</div>
<div class="toggle">
<button class="btn" id="soundBtn" aria-pressed="false">🔊 Sound</button>
</div>
</div>
<div class="group">
<div class="field"><label for="bpm">Beat <span class="val" id="bpmV">124 BPM</span></label><input type="range" id="bpm" min="60" max="180" value="124"></div>
<div class="field"><label for="vol">Volume <span class="val" id="volV">60%</span></label><input type="range" id="vol" min="0" max="100" value="60"></div>
<div class="field"><label for="neon">Neon <span class="val" id="neonV">Bright</span></label><input type="range" id="neon" min="0" max="100" value="75"></div>
<div class="field"><label for="crowd">Crowd <span class="val" id="crowdV">Full</span></label><input type="range" id="crowd" min="0" max="100" value="80"></div>
<div class="field"><label for="dust">Dust <span class="val" id="dustV">Drifting</span></label><input type="range" id="dust" min="0" max="100" value="55"></div>
<div class="field"><label for="bears">Dancing Bears <span class="val" id="bearsV">3</span></label><input type="range" id="bears" min="0" max="10" value="3" step="1"></div>
</div>
</div>
<button class="btn label-toggle" id="labelBtn" aria-expanded="false" aria-controls="museum">🏷 Museum Label</button>
<aside class="museum" id="museum" hidden aria-label="Museum label">
<h2>Playa Ignition</h2>
<div class="meta">Generative Canvas · Authored Visual Rhythm · Single File</div>
<p>A night on the open playa. The <strong>Man</strong> stands over the desert in cycling neon; fire-poi dancers trace glowing arcs, an art car drifts across the dust, and a colorful troupe dances beneath a vast desert starfield.</p>
<p><strong>The rhythm is authored, not measured.</strong> A built-in beat — set it in BPM — drives the neon flashes, the fire flares, and the dancers' hops. There is no audio; the pulse is written into the drawing loop itself, an original visual language of dust, light and fire.</p>
<p>Press <strong>Ignite the Man</strong> to set the effigy alight — flames climb, embers rise, the crowd surges — then the playa settles and the Man stands again. No borrowed logos, emblems, or art: an original homage to the spirit of the burn.</p>
<div class="keys">
<div><b>Space</b> pause / play</div>
<div><b>B</b> ignite the Man</div>
<div><b>M</b> reduced motion</div>
<div><b>T</b> light trails</div>
<div><b>C</b> show / hide controls</div>
<div><b>1 2 3</b> playa · man · overhead</div>
<div><b>Click</b> spark a flare</div>
<div><b>L</b> this museum label</div>
</div>
</aside>
<p class="sr" aria-live="polite" id="live">The playa at night. Set the beat and ignite the Man.</p>
</main>
<script>
(() => {
"use strict";
const sky=document.getElementById('sky'), scene=document.getElementById('scene');
const sctx=sky.getContext('2d'), ctx=scene.getContext('2d');
const live=document.getElementById('live');
const S={ t:0, playing:true, reduced:false, trails:true,
bpm:124, neon:0.75, crowd:0.8, dust:0.55, bears:3,
cam:'wide', burn:0, pulse:0, beatCount:0, _beatPhase:0, _eighthPhase:0,
sound:false, volume:0.6,
W:0,H:0,DPR:1 };
try{ window.__playaS=S; }catch(e){} // expose state to the Three.js orb
if(window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches) S.reduced=true;
function clamp(v,a,b){return Math.max(a,Math.min(b,v));}
function announce(m){ live.textContent=m; }
// ---------- synthesized techno beat (original, no audio files) ----------
let actx=null, master=null, analyser=null, freqData=null;
function ensureAudio(){ if(actx) return true; try{
actx=new (window.AudioContext||window.webkitAudioContext)();
master=actx.createGain(); master.gain.value=S.volume;
analyser=actx.createAnalyser(); analyser.fftSize=128; analyser.smoothingTimeConstant=0.75;
freqData=new Uint8Array(analyser.frequencyBinCount);
master.connect(analyser); master.connect(actx.destination); // tap for the live graph
try{ window.__playaAnalyser=analyser; }catch(e){}
return true;
}catch(e){ return false; } }
// melodic synth pluck (the "synthesizer") — minor-pentatonic arp note
function synth(freq){ if(!actx) return; const t=actx.currentTime, o=actx.createOscillator(), g=actx.createGain(), lp=actx.createBiquadFilter();
o.type='square'; o.frequency.value=freq; lp.type='lowpass';
lp.frequency.setValueAtTime(2400,t); lp.frequency.exponentialRampToValueAtTime(600,t+0.25);
g.gain.setValueAtTime(0.0001,t); g.gain.linearRampToValueAtTime(0.10,t+0.01); g.gain.exponentialRampToValueAtTime(0.001,t+0.28);
o.connect(lp); lp.connect(g); g.connect(master); o.start(t); o.stop(t+0.3); }
const ARP=[220,261.63,293.66,349.23,392,349.23,293.66,261.63]; // A minor pentatonic run
let arpIdx=0;
function kick(){ if(!actx) return; const t=actx.currentTime, o=actx.createOscillator(), g=actx.createGain();
o.frequency.setValueAtTime(165,t); o.frequency.exponentialRampToValueAtTime(48,t+0.12);
g.gain.setValueAtTime(1,t); g.gain.exponentialRampToValueAtTime(0.001,t+0.20);
o.connect(g); g.connect(master); o.start(t); o.stop(t+0.22); }
function bass(){ if(!actx) return; const t=actx.currentTime, o=actx.createOscillator(), g=actx.createGain(), lp=actx.createBiquadFilter();
o.type='sawtooth'; o.frequency.value=55; lp.type='lowpass'; lp.frequency.value=420;
g.gain.setValueAtTime(0.0001,t); g.gain.linearRampToValueAtTime(0.16,t+0.02); g.gain.exponentialRampToValueAtTime(0.001,t+0.22);
o.connect(lp); lp.connect(g); g.connect(master); o.start(t); o.stop(t+0.24); }
function hat(){ if(!actx) return; const t=actx.currentTime, n=Math.floor(actx.sampleRate*0.045);
const buf=actx.createBuffer(1,n,actx.sampleRate), d=buf.getChannelData(0);
for(let i=0;i<n;i++) d[i]=(Math.random()*2-1)*Math.pow(1-i/n,3);
const src=actx.createBufferSource(); src.buffer=buf; const hp=actx.createBiquadFilter(); hp.type='highpass'; hp.frequency.value=7500;
const g=actx.createGain(); g.gain.value=0.22; src.connect(hp); hp.connect(g); g.connect(master); src.start(t); }
// cutout helper (flat fill + bold outline)
const OUTLINE='rgba(6,4,14,0.92)';
function cut(fill,w){ ctx.fillStyle=fill; ctx.strokeStyle=OUTLINE; ctx.lineWidth=w||2.4; ctx.lineJoin='round'; ctx.lineCap='round'; }
function fo(){ ctx.fill(); ctx.stroke(); }
// ---------- resize + starfield ----------
let stars=[];
function resize(){
S.DPR=Math.min(2,window.devicePixelRatio||1); S.W=innerWidth; S.H=innerHeight;
[sky,scene].forEach(c=>{ c.width=Math.round(S.W*S.DPR); c.height=Math.round(S.H*S.DPR); c.getContext('2d').setTransform(S.DPR,0,0,S.DPR,0,0); });
stars=[]; const n=Math.round(S.W*S.H/2600);
for(let i=0;i<n;i++) stars.push({x:Math.random()*S.W, y:Math.random()*S.H*0.68, r:Math.random()*1.4+0.2, tw:Math.random()*6.28});
skyDirty=true;
}
addEventListener('resize',resize);
// ---------- sky ----------
let skyDirty=true;
function drawSky(){
const W=S.W,H=S.H;
const g=sctx.createLinearGradient(0,0,0,H);
g.addColorStop(0,'#07061a'); g.addColorStop(0.45,'#160f2e'); g.addColorStop(0.72,'#2a1836'); g.addColorStop(0.86,'#43303a'); g.addColorStop(1,'#5a4436');
sctx.clearRect(0,0,W,H); sctx.fillStyle=g; sctx.fillRect(0,0,W,H);
// milky way band
sctx.save(); sctx.globalAlpha=0.10; sctx.fillStyle='#9fb0ff';
sctx.beginPath(); sctx.ellipse(W*0.62,H*0.22,W*0.5,H*0.09,-0.5,0,7); sctx.fill(); sctx.restore();
// stars
for(const s of stars){ const tw=0.5+0.5*Math.abs(Math.sin(s.tw)); sctx.globalAlpha=tw; sctx.fillStyle='#eef0ff'; sctx.beginPath(); sctx.arc(s.x,s.y,s.r,0,7); sctx.fill(); }
sctx.globalAlpha=1;
// moon
const mx=W*0.8,my=H*0.16,mr=40;
const gl=sctx.createRadialGradient(mx,my,4,mx,my,mr*3); gl.addColorStop(0,'rgba(230,235,255,0.5)'); gl.addColorStop(1,'rgba(0,0,0,0)');
sctx.fillStyle=gl; sctx.beginPath(); sctx.arc(mx,my,mr*3,0,7); sctx.fill();
sctx.fillStyle='#e8ecff'; sctx.beginPath(); sctx.arc(mx,my,mr,0,7); sctx.fill();
skyDirty=false;
}
// ---------- ground / playa ----------
function drawPlaya(){
const W=S.W,H=S.H, top=H*0.72;
const g=ctx.createLinearGradient(0,top,0,H);
g.addColorStop(0,'#4a3a34'); g.addColorStop(0.4,'#6a5040'); g.addColorStop(1,'#8a6a4a');
ctx.fillStyle=g; ctx.fillRect(0,top,W,H-top);
// dust haze at the horizon
const hz=ctx.createLinearGradient(0,top-40,0,top+50); hz.addColorStop(0,'rgba(180,150,110,0)'); hz.addColorStop(1,'rgba(180,150,110,0.28)');
ctx.fillStyle=hz; ctx.fillRect(0,top-40,W,90);
// playa cracks
ctx.strokeStyle='rgba(30,20,14,0.25)'; ctx.lineWidth=1;
for(let i=0;i<6;i++){ const y=top+20+i*((H-top)/6); ctx.beginPath(); for(let x=0;x<=W;x+=60){ ctx.lineTo(x, y+Math.sin(x*0.02+i)*3);} ctx.stroke(); }
}
// distant neon art on the horizon
function drawInstallations(){
const W=S.W,H=S.H, hy=H*0.71, glow=S.neon*(0.7+(S.pulse||0)*0.6);
const cols=['#ff2fd0','#2fe6ff','#ffcf5a','#7affa0'];
const items=[[0.12,'ring'],[0.28,'tri'],[0.5,'dome'],[0.68,'ring'],[0.9,'tri']];
ctx.save(); ctx.globalCompositeOperation='lighter';
items.forEach((it,i)=>{
const x=W*it[0], c=cols[i%cols.length], sc=Math.min(W,H)/26;
ctx.strokeStyle=c; ctx.shadowColor=c; ctx.shadowBlur=14*glow; ctx.globalAlpha=0.35+0.4*glow; ctx.lineWidth=2;
if(it[1]==='ring'){ ctx.beginPath(); ctx.arc(x,hy-sc*0.6,sc*0.6,0,7); ctx.stroke(); }
else if(it[1]==='tri'){ ctx.beginPath(); ctx.moveTo(x,hy-sc*1.3); ctx.lineTo(x-sc*0.7,hy); ctx.lineTo(x+sc*0.7,hy); ctx.closePath(); ctx.stroke(); }
else { ctx.beginPath(); for(let a=0;a<=6;a++){ const an=Math.PI*a/6; ctx.lineTo(x+Math.cos(an)*sc*0.9, hy-Math.sin(an)*sc*0.9);} ctx.stroke();
for(let a=0;a<6;a++){ const an=Math.PI*a/6; ctx.beginPath(); ctx.moveTo(x,hy); ctx.lineTo(x+Math.cos(an)*sc*0.9,hy-Math.sin(an)*sc*0.9); ctx.stroke(); } }
});
ctx.restore(); ctx.shadowBlur=0; ctx.globalAlpha=1;
}
// ---------- The Man ----------
let embers=[];
function manTransform(){
const W=S.W,H=S.H; let cx=W*0.5, base=H*0.78, scale=Math.min(W,H)/620;
if(S.cam==='man'){ scale=Math.min(W,H)/330; base=H*0.84; }
if(S.cam==='over'){ base=H*0.6; scale=Math.min(W,H)/560; }
return {cx,base,scale};
}
function drawTheMan(){
const {cx,base,scale}=manTransform();
const burn=S.burn, glow=S.neon*(0.7+(S.pulse||0)*0.7);
const neonCol=`hsl(${(S.t*40)%360},90%,62%)`;
ctx.save(); ctx.translate(cx,base); ctx.scale(scale,scale);
// stepped base (pyramid)
cut('rgba(40,28,22,0.98)',3);
for(let i=0;i<3;i++){ const w=150-i*36, h=18; ctx.beginPath(); ctx.rect(-w,-i*h-h,w*2,h); fo(); }
// the figure (drawn on top of base, origin at base top ~ y=-54)
ctx.save(); ctx.translate(0,-54);
const sway=S.reduced?0:Math.sin(S.t*0.6)*0.02;
ctx.rotate(sway);
// body geometry as glowing neon truss
ctx.strokeStyle = burn>0.05 ? '#ffd24a' : neonCol;
ctx.shadowColor = ctx.strokeStyle; ctx.shadowBlur = 18*glow*(1+burn); ctx.lineWidth=6; ctx.lineCap='round';
ctx.globalAlpha=0.9;
// legs
ctx.beginPath(); ctx.moveTo(0,-40); ctx.lineTo(-34,0); ctx.moveTo(0,-40); ctx.lineTo(34,0); ctx.stroke();
// body trapezoid
ctx.beginPath(); ctx.moveTo(-22,-40); ctx.lineTo(22,-40); ctx.lineTo(16,-104); ctx.lineTo(-16,-104); ctx.closePath(); ctx.stroke();
// arms raised in a V
ctx.beginPath(); ctx.moveTo(-16,-100); ctx.lineTo(-52,-150); ctx.moveTo(16,-100); ctx.lineTo(52,-150); ctx.stroke();
// head
ctx.beginPath(); ctx.arc(0,-124,14,0,7); ctx.stroke();
ctx.shadowBlur=0; ctx.globalAlpha=1;
// burning: flames engulf the figure
if(burn>0.02){
ctx.globalCompositeOperation='lighter';
for(let i=0;i<26;i++){
const fx=(Math.random()-0.5)*70, fyBase=-Math.random()*150*burn;
const fl=(0.5+Math.random()*0.7)*burn;
const g=ctx.createRadialGradient(fx,fyBase,0,fx,fyBase,20*fl);
g.addColorStop(0,`rgba(255,${180+Math.random()*60|0},80,${0.5*fl})`); g.addColorStop(1,'rgba(255,60,0,0)');
ctx.fillStyle=g; ctx.beginPath(); ctx.arc(fx,fyBase,20*fl,0,7); ctx.fill();
}
ctx.globalCompositeOperation='source-over';
}
ctx.restore();
ctx.restore();
// spawn embers from the burn (in world space)
if(burn>0.3 && !S.reduced && Math.random()<0.6){
embers.push({x:cx+(Math.random()-0.5)*80*scale, y:base-120*scale, vx:(Math.random()-0.5)*20, vy:-(40+Math.random()*60), life:1});
}
}
// ---------- fire-poi dancers ----------
function fireDancer(x,y,s,ph){
const t=S.t, spin=t*4+ph, glow=0.6+(S.pulse||0)*0.6;
ctx.save(); ctx.translate(x,y); ctx.scale(s,s);
// body silhouette
cut('rgba(24,16,30,0.96)',2);
ctx.beginPath(); ctx.ellipse(0,-14,4,8,0,0,7); fo(); // torso
ctx.beginPath(); ctx.arc(0,-24,3.4,0,7); fo(); // head
ctx.beginPath(); ctx.moveTo(-2,-4); ctx.lineTo(-3-Math.sin(t*2)*2,10); ctx.moveTo(2,-4); ctx.lineTo(3+Math.sin(t*2)*2,10); ctx.strokeStyle=OUTLINE; ctx.lineWidth=2.4; ctx.stroke();
// two poi on trails
ctx.globalCompositeOperation='lighter';
for(const dir of [1,-1]){
const r=16;
ctx.strokeStyle=`rgba(255,150,40,${0.28*glow})`; ctx.lineWidth=3; ctx.shadowColor='#ff8a2a'; ctx.shadowBlur=10*glow;
ctx.beginPath(); for(let a=0;a<=20;a++){ const an=spin*dir - a*0.28; ctx.lineTo(dir*8+Math.cos(an)*r, -16+Math.sin(an)*r); } ctx.stroke();
const hx=dir*8+Math.cos(spin*dir)*r, hy=-16+Math.sin(spin*dir)*r;
const g=ctx.createRadialGradient(hx,hy,0,hx,hy,7); g.addColorStop(0,'rgba(255,230,120,0.95)'); g.addColorStop(1,'rgba(255,90,0,0)');
ctx.fillStyle=g; ctx.beginPath(); ctx.arc(hx,hy,7,0,7); ctx.fill();
}
ctx.shadowBlur=0; ctx.globalCompositeOperation='source-over';
ctx.restore();
}
// ---------- art car ----------
function drawArtCar(){
if(S.reduced) return;
const W=S.W,H=S.H, y=H*0.75, x=((S.t*26)%(W+400))-200;
const glow=S.neon;
ctx.save(); ctx.translate(x,y);
cut('rgba(20,14,26,0.98)',3);
ctx.beginPath(); ctx.moveTo(-70,0); ctx.lineTo(-56,-30); ctx.lineTo(40,-30); ctx.lineTo(56,-8); ctx.lineTo(70,0); ctx.closePath(); fo(); // body
ctx.beginPath(); ctx.rect(-30,-52,54,24); fo(); // cabin
// neon underglow + trim
ctx.globalCompositeOperation='lighter';
ctx.strokeStyle=`rgba(47,230,255,${0.6*glow})`; ctx.shadowColor='#2fe6ff'; ctx.shadowBlur=14*glow; ctx.lineWidth=2.4;
ctx.beginPath(); ctx.moveTo(-72,4); ctx.lineTo(72,4); ctx.stroke();
ctx.strokeStyle=`rgba(255,47,208,${0.6*glow})`; ctx.shadowColor='#ff2fd0';
ctx.beginPath(); ctx.rect(-30,-52,54,24); ctx.stroke();
ctx.shadowBlur=0; ctx.globalCompositeOperation='source-over';
// wheels
cut('rgba(10,8,14,0.98)',2);
ctx.beginPath(); ctx.arc(-44,4,10,0,7); fo(); ctx.beginPath(); ctx.arc(40,4,10,0,7); fo();
ctx.restore();
}
// ---------- dancing bears (colorful, beat-synced) ----------
const BEAR_COLORS=['rgba(255,79,140,0.98)','rgba(255,180,60,0.98)','rgba(80,220,200,0.98)','rgba(150,120,255,0.98)','rgba(255,120,70,0.98)','rgba(120,220,120,0.98)','rgba(255,140,210,0.98)','rgba(90,180,255,0.98)','rgba(230,210,70,0.98)','rgba(180,110,255,0.98)'];
function bear(x,y,s,ph,FUR){
const beat=S.pulse||0, e=1+beat*1.0;
const sway=Math.sin(S.t*2.8+ph)*0.30*e;
const bob=Math.abs(Math.sin(S.t*5.4+ph))*3 + beat*13;
const step=Math.sin(S.t*2.8+ph);
const armL=Math.sin(S.t*3.6+ph), armR=Math.sin(S.t*3.6+ph+Math.PI);
const t=S.t;
ctx.save(); ctx.translate(x,y-bob); ctx.scale(s*1.2,s*1.2); ctx.rotate(sway);
function limb(px,py,ang,len,w,foot){ ctx.save(); ctx.translate(px,py); ctx.rotate(ang);
ctx.lineCap='round'; ctx.strokeStyle=OUTLINE; ctx.lineWidth=w+2.4; ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(0,len); ctx.stroke();
ctx.strokeStyle=FUR; ctx.lineWidth=w; ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(0,len); ctx.stroke();
if(foot){ cut(FUR,1.8); ctx.beginPath(); ctx.ellipse(0,len,3.2,2,0,0,7); fo(); } ctx.restore(); }
limb(-3.5,-3,0.12+step*0.7,12,6.5,true); limb(3.5,-3,0.12-step*0.7,12,6.5,true);
cut(FUR,2.6); ctx.beginPath(); ctx.ellipse(0,-12,8.5,11,0,0,7); fo();
ctx.fillStyle='rgba(255,255,255,0.22)'; ctx.beginPath(); ctx.ellipse(0,-10,4.5,6,0,0,7); ctx.fill();
limb(-7,-18,2.3+armL*0.7+beat*0.45,11,5,true); limb(7,-18,-2.3+armR*0.7-beat*0.45,11,5,true);
cut(FUR,2.4); ctx.beginPath(); ctx.arc(0,-30,7,0,7); fo();
ctx.beginPath(); ctx.arc(-6,-36,3,0,7); fo(); ctx.beginPath(); ctx.arc(6,-36,3,0,7); fo();
cut('rgba(210,170,140,0.98)',1.5); ctx.beginPath(); ctx.ellipse(0,-27,3.6,2.7,0,0,7); fo();
ctx.fillStyle=OUTLINE; ctx.beginPath(); ctx.arc(0,-29,1.1,0,7); ctx.fill();
ctx.restore();
}
// ---------- dust ----------
let dustP=[];
function drawDust(){
const W=S.W,H=S.H, want=Math.round(S.dust*90);
if(!S.reduced && S.playing) while(dustP.length<want){ dustP.push({x:Math.random()*W,y:H*0.72+Math.random()*(H*0.28),vx:8+Math.random()*22,r:0.6+Math.random()*1.8,a:0.1+Math.random()*0.25}); }
ctx.save(); ctx.fillStyle='#d8c0a0';
for(let i=dustP.length-1;i>=0;i--){ const p=dustP[i]; if(!S.reduced&&S.playing){ p.x+=p.vx*0.016*(1+(S.pulse||0)); if(p.x>W+10)p.x=-10; }
if(i>=want){ dustP.splice(i,1); continue; } ctx.globalAlpha=p.a; ctx.beginPath(); ctx.arc(p.x,p.y,p.r,0,7); ctx.fill(); }
ctx.restore(); ctx.globalAlpha=1;
}
// ---------- click flares ----------
let flares=[];
function spawnFlare(x,y){ if(flares.length>500) return; const n=18+((Math.random()*12)|0); const hue=(Math.random()*360)|0;
for(let i=0;i<n;i++){ const a=(i/n)*6.28+Math.random()*0.2, sp=1.4+Math.random()*2.6; flares.push({x,y,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp,life:1,hue}); } }
function drawFlares(dt){ if(!flares.length) return; ctx.save(); ctx.globalCompositeOperation='lighter';
for(let i=flares.length-1;i>=0;i--){ const p=flares[i]; p.x+=p.vx; p.y+=p.vy; p.vy+=0.03; p.vx*=0.98; p.vy*=0.98; p.life-=dt*0.85;
if(p.life<=0){ flares.splice(i,1); continue; } ctx.globalAlpha=Math.max(0,p.life); ctx.fillStyle=`hsl(${p.hue},90%,60%)`; ctx.beginPath(); ctx.arc(p.x,p.y,2.2*p.life+0.6,0,7); ctx.fill(); }
ctx.restore(); ctx.globalAlpha=1; }
// live music graph — spectrum of the synthesized audio
function drawSpectrum(){
if(!analyser || !S.sound) return;
analyser.getByteFrequencyData(freqData);
const W=S.W,H=S.H, n=freqData.length, bw=W/n, baseY=H-4;
ctx.save(); ctx.globalCompositeOperation='lighter';
for(let i=0;i<n;i++){ const v=freqData[i]/255, bh=v*H*0.20, hue=(200+i/n*160)%360;
ctx.fillStyle=`hsla(${hue},90%,60%,0.85)`; ctx.fillRect(i*bw+1, baseY-bh, bw-2, bh); }
ctx.restore();
}
function drawEmbers(dt){ if(!embers.length) return; ctx.save(); ctx.globalCompositeOperation='lighter';
for(let i=embers.length-1;i>=0;i--){ const p=embers[i]; p.x+=p.vx*dt; p.y+=p.vy*dt; p.vy+=6*dt; p.life-=dt*0.5;
if(p.life<=0){ embers.splice(i,1); continue; } ctx.globalAlpha=Math.max(0,p.life); ctx.fillStyle='#ffb347'; ctx.beginPath(); ctx.arc(p.x,p.y,2*p.life+0.5,0,7); ctx.fill(); }
ctx.restore(); ctx.globalAlpha=1; }
// ---------- populate the crowd ----------
function drawCrowd(){
const W=S.W,H=S.H, gy=H*0.79, d=S.crowd;
// fire-poi dancers ring around the Man
const nP=Math.round(2+d*5);
for(let i=0;i<nP;i++){ const a=(i/nP)*Math.PI*2 + S.t*0.1; const rx=Math.cos(a)*W*0.22, rz=Math.sin(a); fireDancer(W*0.5+rx, gy+rz*20, 0.9+rz*0.15, i*1.7); }
}
// ---------- main loop ----------
let last=performance.now();
function frame(now){
const dt=Math.min(0.05,(now-last)/1000); last=now;
if(S.playing){ S.t += dt*1.0; }
// authored beat clock -> pulse
if(S.playing && !S.reduced){
S._beatPhase += dt*(S.bpm/60);
if(S._beatPhase>=1){ S._beatPhase-=1; S.pulse=1; S.beatCount++; if(S.sound){ kick(); bass(); hat(); } }
S._eighthPhase += dt*(S.bpm/60)*2; // eighth-note grid
if(S._eighthPhase>=1){ S._eighthPhase-=1; if(S.sound){ if(S.beatCount&1) hat(); synth(ARP[arpIdx%ARP.length]); arpIdx++; } }
S.pulse *= Math.pow(0.06, dt*4); // decay per beat
} else S.pulse=0;
// burn decay
if(S.burn>0){ S.burn=Math.max(0,S.burn-dt*0.18); }
if(skyDirty) drawSky();
const W=S.W,H=S.H;
if(S.trails && !S.reduced && S.playing){ ctx.fillStyle='rgba(5,4,14,0.18)'; ctx.fillRect(0,0,W,H); }
else ctx.clearRect(0,0,W,H);
drawPlaya();
drawInstallations();
drawDust();
drawArtCar();
drawTheMan();
drawCrowd();
// dancing bears troupe across the playa foreground
const N=Math.max(0,Math.min(10,Math.round(S.bears)));
if(N>0){ const margin=W*0.06, span=W-2*margin, size=Math.max(0.9,Math.min(2.0,13/(N+2.5)));
for(let i=0;i<N;i++){ const x=(N===1)?W*0.16:margin+span*(i/(N-1)); bear(x, H*0.86+((i*53)%9-4), size, i*1.3, BEAR_COLORS[i%BEAR_COLORS.length]); } }
drawEmbers(dt);
drawFlares(dt);
drawSpectrum();
requestAnimationFrame(frame);
}
// ---------- UI ----------
const neonWords=['Dark','Dim','Warm','Bright','Blazing'], crowdWords=['Empty','Sparse','Lively','Full','Teeming'], dustWords=['Still','Faint','Drifting','Gusting','Whiteout'];
function word(a,v){return a[Math.min(a.length-1,Math.floor(v*a.length))];}
function slider(id,valId,arr,set,fmt){ const el=document.getElementById(id), v=document.getElementById(valId);
el.addEventListener('input',e=>{ const val=+e.target.value; set(val); v.textContent=fmt?fmt(val):word(arr,val/100); }); }
document.getElementById('bpm').addEventListener('input',e=>{ S.bpm=+e.target.value; document.getElementById('bpmV').textContent=S.bpm+' BPM'; });
slider('neon','neonV',neonWords,v=>S.neon=v/100);
slider('crowd','crowdV',crowdWords,v=>S.crowd=v/100);
slider('dust','dustV',dustWords,v=>S.dust=v/100);
document.getElementById('bears').addEventListener('input',e=>{ S.bears=+e.target.value; document.getElementById('bearsV').textContent=String(S.bears); });
const playBtn=document.getElementById('playBtn');
playBtn.addEventListener('click',()=>{ S.playing=!S.playing; playBtn.setAttribute('aria-pressed',S.playing); playBtn.textContent=S.playing?'❚❚ Pause':'► Play'; announce(S.playing?'Playing.':'Paused.'); });
const reduceBtn=document.getElementById('reduceBtn');
reduceBtn.addEventListener('click',()=>{ S.reduced=!S.reduced; reduceBtn.setAttribute('aria-pressed',S.reduced); if(S.reduced) ctx.clearRect(0,0,S.W,S.H); announce(S.reduced?'Reduced motion on.':'Reduced motion off.'); });
if(S.reduced) reduceBtn.setAttribute('aria-pressed','true');
const trailBtn=document.getElementById('trailBtn');
trailBtn.addEventListener('click',()=>{ S.trails=!S.trails; trailBtn.setAttribute('aria-pressed',S.trails); if(!S.trails) ctx.clearRect(0,0,S.W,S.H); announce(S.trails?'Light trails on.':'Light trails off.'); });
const burnBtn=document.getElementById('burnBtn');
function ignite(){ S.burn=1; announce('The Man is alight — the burn begins.'); }
burnBtn.addEventListener('click',ignite);
const soundBtn=document.getElementById('soundBtn');
soundBtn.addEventListener('click',()=>{
if(!S.sound){ if(!ensureAudio()){ announce('Audio unavailable in this browser.'); return; } if(actx.state==='suspended') actx.resume(); }
S.sound=!S.sound; soundBtn.setAttribute('aria-pressed',S.sound);
announce(S.sound?'Sound on — the beat plays.':'Sound off.');
});
document.getElementById('vol').addEventListener('input',e=>{ S.volume=+e.target.value/100; if(master) master.gain.value=S.volume; document.getElementById('volV').textContent=e.target.value+'%'; });
document.querySelectorAll('[data-cam]').forEach(b=>{ b.addEventListener('click',()=>{ S.cam=b.dataset.cam; document.querySelectorAll('[data-cam]').forEach(x=>x.setAttribute('aria-pressed',x===b)); ctx.clearRect(0,0,S.W,S.H); const nm={wide:'The playa',man:'The Man',over:'Overhead'}; announce(nm[S.cam]+' view.'); }); });
const labelBtn=document.getElementById('labelBtn'), museum=document.getElementById('museum');
labelBtn.addEventListener('click',()=>{ const open=museum.hidden; museum.hidden=!open; labelBtn.setAttribute('aria-expanded',String(open)); });
const stageEl=document.getElementById('stage'), chromeBtn=document.getElementById('chromeBtn');
function toggleChrome(){ const hidden=stageEl.classList.toggle('hide-chrome'); chromeBtn.setAttribute('aria-pressed',String(!hidden)); chromeBtn.textContent=hidden?'☰':'✕'; announce(hidden?'Controls hidden.':'Controls shown.'); }
chromeBtn.addEventListener('click',toggleChrome);
stageEl.addEventListener('pointerdown',e=>{ if(e.target.closest('button,input,select,a,.dock,.cams,.museum,.label-toggle')) return; const r=scene.getBoundingClientRect(); spawnFlare(e.clientX-r.left,e.clientY-r.top); });
addEventListener('keydown',e=>{
if(e.target.matches('input,button')&&(e.key===' '||e.key==='Enter')) return;
switch(e.key.toLowerCase()){
case ' ': e.preventDefault(); playBtn.click(); break;
case 'b': ignite(); break;
case 'm': reduceBtn.click(); break;
case 't': trailBtn.click(); break;
case 'c': toggleChrome(); break;
case 'l': labelBtn.click(); break;
case '1': document.querySelector('[data-cam="wide"]').click(); break;
case '2': document.querySelector('[data-cam="man"]').click(); break;
case '3': document.querySelector('[data-cam="over"]').click(); break;
}
});
resize();
requestAnimationFrame(frame);
})();
</script>
<!-- 3D spinning orb (Three.js). Classic build so it runs from file://; skips silently if offline. -->
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/build/three.min.js"></script>
<script>
(function(){
if(!window.THREE) return; // offline: 2D scene still works
const canvas=document.getElementById('orb'); if(!canvas) return;
let W=innerWidth,H=innerHeight;
const renderer=new THREE.WebGLRenderer({canvas,alpha:true,antialias:true});
renderer.setPixelRatio(Math.min(2,devicePixelRatio||1)); renderer.setSize(W,H,false);
const scene=new THREE.Scene();
const camera=new THREE.PerspectiveCamera(50,W/H,0.1,100); camera.position.z=5;
const group=new THREE.Group(); group.position.y=0.35; scene.add(group);
const wire=new THREE.Mesh(new THREE.IcosahedronGeometry(1.45,2), new THREE.MeshBasicMaterial({wireframe:true,color:0x2fe6ff,transparent:true,opacity:0.85}));
const core=new THREE.Mesh(new THREE.IcosahedronGeometry(1.15,3), new THREE.MeshStandardMaterial({color:0x150022,emissive:0xff2fd0,emissiveIntensity:0.7,roughness:0.35,metalness:0.6,transparent:true,opacity:0.85}));
group.add(core,wire);
const l1=new THREE.PointLight(0xff2fd0,1.4,60); l1.position.set(4,3,5); scene.add(l1);
const l2=new THREE.PointLight(0x2fe6ff,1.2,60); l2.position.set(-4,-2,4); scene.add(l2);
scene.add(new THREE.AmbientLight(0x404060,0.9));
const col=new THREE.Color();
addEventListener('resize',()=>{ W=innerWidth;H=innerHeight; camera.aspect=W/H; camera.updateProjectionMatrix(); renderer.setSize(W,H,false); });
function loop(){ requestAnimationFrame(loop);
const s=window.__playaS||{}, pulse=s.pulse||0, paused=(s.playing===false)||s.reduced, t=performance.now()/1000;
if(!paused) group.rotation.y+=0.012;
group.rotation.x=Math.sin(t*0.3)*0.22;
group.scale.setScalar(1+pulse*0.20);
core.material.emissiveIntensity=0.5+pulse*1.4;
const hue=(t*30)%360;
wire.material.color.setHSL(hue/360,0.9,0.62);
col.setHSL(((hue+130)%360)/360,0.9,0.55); core.material.emissive.copy(col);
renderer.render(scene,camera);
}
loop();
})();
</script>
</body>
</html>