← 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>