← back to Wild Orbs

index.html

1760 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<title>WILD ORBS</title>
<style>
  :root{
    --bg:#05060e;
    --fg:#e9f1ff;
    --dim:#7d8aa8;
    --accent:#38f5d0;
    --accent2:#ff2f6e;
  }
  *{box-sizing:border-box}
  html,body{margin:0;padding:0;height:100%;width:100%;overflow:hidden;background:var(--bg);
    font-family:"SF Mono",ui-monospace,Menlo,Consolas,"Courier New",monospace;color:var(--fg);
    -webkit-font-smoothing:antialiased;}
  canvas#c{display:block;position:fixed;inset:0;}
  .screen{
    position:fixed;inset:0;display:none;flex-direction:column;justify-content:center;align-items:center;
    text-align:center;padding:24px;z-index:10;
    background:radial-gradient(ellipse at 50% 45%, rgba(8,10,26,.60) 0%, rgba(3,4,10,.90) 72%);
    backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px);
    animation:fade .28s ease both;
  }
  .screen.on{display:flex}
  @keyframes fade{from{opacity:0}to{opacity:1}}
  h1{
    margin:0;font-size:clamp(38px,9vw,104px);letter-spacing:.14em;font-weight:800;
    background:linear-gradient(180deg,#fff 0%,#8fe9ff 42%,#38f5d0 62%,#0f7f8c 100%);
    -webkit-background-clip:text;background-clip:text;color:transparent;
    text-shadow:0 0 60px rgba(56,245,208,.28);
    line-height:1;
  }
  h1 .w{color:transparent}
  .tag{margin:12px 0 0;color:var(--dim);letter-spacing:.32em;font-size:clamp(9px,1.6vw,13px);text-transform:uppercase}
  .keys{margin:22px 0 4px;color:#b9c6e2;font-size:13px;line-height:1.9;letter-spacing:.06em}
  kbd{
    display:inline-block;min-width:26px;padding:2px 7px;margin:0 2px;border-radius:5px;
    background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.16);
    border-bottom-width:2px;font-size:11px;color:#fff
  }
  .bestiary{
    display:grid;gap:8px 14px;margin:22px auto 6px;max-width:min(920px,94vw);
    grid-template-columns:repeat(auto-fit,minmax(190px,1fr));
    max-height:38vh;overflow:auto;padding:4px 6px;
  }
  .bestiary::-webkit-scrollbar{width:6px}
  .bestiary::-webkit-scrollbar-thumb{background:rgba(255,255,255,.14);border-radius:3px}
  .chip{display:flex;align-items:center;gap:9px;text-align:left;opacity:.94}
  .chip canvas{width:34px;height:34px;flex:0 0 34px}
  .chip .n{font-size:10.5px;letter-spacing:.14em;font-weight:700}
  .chip .d{font-size:10px;color:var(--dim);letter-spacing:.02em;line-height:1.35}
  .cta{
    margin-top:20px;font-size:13px;letter-spacing:.26em;color:var(--accent);
    animation:pulse 1.5s ease-in-out infinite;text-transform:uppercase
  }
  @keyframes pulse{0%,100%{opacity:.45}50%{opacity:1}}
  button{
    margin-top:16px;font:inherit;font-size:13px;letter-spacing:.2em;text-transform:uppercase;
    padding:13px 30px;border-radius:999px;cursor:pointer;color:#04120f;font-weight:700;
    background:linear-gradient(180deg,#7dffe6,#25d6b4);border:none;
    box-shadow:0 6px 30px rgba(56,245,208,.32);transition:transform .12s ease, box-shadow .12s ease;
  }
  button:hover{transform:translateY(-2px);box-shadow:0 10px 40px rgba(56,245,208,.45)}
  button:active{transform:translateY(0)}
  .big{font-size:clamp(30px,7vw,64px);letter-spacing:.1em;margin:0;color:var(--accent2);
    text-shadow:0 0 44px rgba(255,47,110,.45);font-weight:800}
  .stats{display:flex;gap:34px;margin:24px 0 4px;flex-wrap:wrap;justify-content:center}
  .stat .v{font-size:clamp(22px,4vw,38px);font-weight:700;letter-spacing:.04em}
  .stat .k{font-size:10px;letter-spacing:.24em;color:var(--dim);text-transform:uppercase;margin-top:4px}
  .newbest{color:#ffd75e;font-size:12px;letter-spacing:.24em;margin-top:8px}
  .foot{position:fixed;bottom:14px;left:0;right:0;text-align:center;color:#3d4762;font-size:10px;letter-spacing:.2em}
</style>
</head>
<body>
<canvas id="c"></canvas>

<div class="screen on" id="startScreen">
  <h1>WILD&nbsp;ORBS</h1>
  <p class="tag">every orb breaks the rules differently</p>
  <div class="keys">
    <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> / <kbd>&larr;</kbd><kbd>&uarr;</kbd><kbd>&rarr;</kbd> fly &nbsp;·&nbsp;
    <kbd>SPACE</kbd> fire &nbsp;·&nbsp; <kbd>P</kbd> pause &nbsp;·&nbsp; <kbd>M</kbd> mute
  </div>
  <div class="bestiary" id="bestiary"></div>
  <button onclick="startGame()">LAUNCH</button>
  <div class="cta">press space to start</div>
</div>

<div class="screen" id="pauseScreen">
  <h1 style="font-size:clamp(28px,6vw,64px)">PAUSED</h1>
  <div class="cta">press P to resume</div>
</div>

<div class="screen" id="overScreen">
  <p class="big">GAME OVER</p>
  <div class="stats">
    <div class="stat"><div class="v" id="finalScore">0</div><div class="k">score</div></div>
    <div class="stat"><div class="v" id="finalWave">1</div><div class="k">wave</div></div>
    <div class="stat"><div class="v" id="finalBest">0</div><div class="k">best</div></div>
  </div>
  <div class="newbest" id="newbest" style="display:none">◆ NEW PERSONAL BEST ◆</div>
  <button onclick="startGame()">RUN IT BACK</button>
  <div class="cta">press space to restart</div>
</div>

<div class="foot">WILD ORBS · single-file canvas arcade</div>

<script>
'use strict';
/* ==========================================================================
   WILD ORBS — an Asteroids-shaped shooter where every orb misbehaves.
   Single file. No assets, no CDNs. WebAudio synthesis, canvas rendering.
   ========================================================================== */

/* ---------------------------------------------------------------- canvas -- */
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d', { alpha:false });
let W = 0, H = 0, DPR = 1;

function resize(){
  DPR = Math.min(window.devicePixelRatio || 1, 2);
  W = window.innerWidth; H = window.innerHeight;
  canvas.width  = Math.max(1, Math.floor(W * DPR));
  canvas.height = Math.max(1, Math.floor(H * DPR));
  canvas.style.width = W + 'px';
  canvas.style.height = H + 'px';
  ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
  makeStars();
}
addEventListener('resize', resize);

/* ------------------------------------------------------------------ math -- */
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 pick    = a => a[(Math.random() * a.length) | 0];
const clamp   = (v,a,b) => v < a ? a : v > b ? b : v;
const lerp    = (a,b,t) => a + (b - a) * t;

// toroidal (screen-wrapped) shortest delta — makes homing/gravity feel right
function wd(d, size){ if (d >  size * 0.5) d -= size; else if (d < -size * 0.5) d += size; return d; }
function dxy(ax, ay, bx, by){ return [ wd(bx - ax, W), wd(by - ay, H) ]; }
function dist(ax, ay, bx, by){ const dx = wd(bx-ax,W), dy = wd(by-ay,H); return Math.hypot(dx,dy); }
function wrapPos(o){
  if (o.x < 0) o.x += W; else if (o.x >= W) o.x -= W;
  if (o.y < 0) o.y += H; else if (o.y >= H) o.y -= H;
}
function hexA(h, a){
  const n = parseInt(h.slice(1), 16);
  return 'rgba(' + ((n>>16)&255) + ',' + ((n>>8)&255) + ',' + (n&255) + ',' + a + ')';
}

/* --------------------------------------------------------- glow sprites --- */
/* Pre-rendered radial-gradient sprites. Way cheaper than shadowBlur, and the
   whole game is drawn additively on top of them.                             */
const glowCache = new Map();
function glowSprite(color, r){
  r = Math.max(4, Math.round(r / 4) * 4);         // quantize → bounded cache
  const key = color + '|' + r;
  let cv = glowCache.get(key);
  if (cv) return cv;
  cv = document.createElement('canvas');
  const s = r * 2;
  cv.width = cv.height = s;
  const g = cv.getContext('2d');
  const grd = g.createRadialGradient(r, r, 0, r, r, r);
  grd.addColorStop(0,    hexA(color, 1));
  grd.addColorStop(0.28, hexA(color, 0.55));
  grd.addColorStop(0.62, hexA(color, 0.16));
  grd.addColorStop(1,    hexA(color, 0));
  g.fillStyle = grd;
  g.fillRect(0, 0, s, s);
  glowCache.set(key, cv);
  return cv;
}
function drawGlow(g, x, y, r, color, alpha){
  const sp = glowSprite(color, r);
  g.globalAlpha = alpha;
  g.drawImage(sp, x - r, y - r, r * 2, r * 2);
  g.globalAlpha = 1;
}

/* ----------------------------------------------------------------- audio -- */
const Snd = {
  ctx:null, master:null, musicGain:null, sfxGain:null,
  noiseBuf:null, thrustGain:null, muted:false, ready:false,
  nextBeat:0, beat:0,

  init(){
    if (this.ready) return;
    const AC = window.AudioContext || window.webkitAudioContext;
    if (!AC) return;
    this.ctx = new AC();
    this.master = this.ctx.createGain(); this.master.gain.value = 0.62;
    this.master.connect(this.ctx.destination);
    this.sfxGain = this.ctx.createGain(); this.sfxGain.gain.value = 1; this.sfxGain.connect(this.master);
    this.musicGain = this.ctx.createGain(); this.musicGain.gain.value = 0.34; this.musicGain.connect(this.master);

    // shared noise buffer
    const len = this.ctx.sampleRate * 2;
    this.noiseBuf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);
    const d = this.noiseBuf.getChannelData(0);
    for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;

    // continuous thrust rumble, gain-gated
    const src = this.ctx.createBufferSource();
    src.buffer = this.noiseBuf; src.loop = true;
    const bp = this.ctx.createBiquadFilter();
    bp.type = 'bandpass'; bp.frequency.value = 320; bp.Q.value = 0.9;
    const lp = this.ctx.createBiquadFilter();
    lp.type = 'lowpass'; lp.frequency.value = 900;
    this.thrustGain = this.ctx.createGain(); this.thrustGain.gain.value = 0;
    src.connect(bp); bp.connect(lp); lp.connect(this.thrustGain); this.thrustGain.connect(this.sfxGain);
    src.start();

    this.nextBeat = this.ctx.currentTime + 0.2;
    this.ready = true;
  },
  resume(){ if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume(); },
  toggleMute(){
    this.muted = !this.muted;
    if (this.master) this.master.gain.setTargetAtTime(this.muted ? 0 : 0.62, this.ctx.currentTime, 0.02);
  },
  thrust(on){
    if (!this.ready) return;
    this.thrustGain.gain.setTargetAtTime(on ? 0.16 : 0, this.ctx.currentTime, on ? 0.03 : 0.09);
  },

  /* one-shot oscillator with a pitch envelope */
  tone(o){
    if (!this.ready) return;
    const t = this.ctx.currentTime, dur = o.dur ?? 0.14;
    const osc = this.ctx.createOscillator();
    osc.type = o.type || 'square';
    osc.frequency.setValueAtTime(o.f0, t);
    if (o.f1 != null){
      if (o.exp === false) osc.frequency.linearRampToValueAtTime(o.f1, t + dur);
      else osc.frequency.exponentialRampToValueAtTime(Math.max(1, o.f1), t + dur);
    }
    if (o.detune) osc.detune.value = o.detune;
    const g = this.ctx.createGain();
    const v = (o.vol ?? 0.2);
    g.gain.setValueAtTime(0.0001, t + (o.at ?? 0));
    g.gain.exponentialRampToValueAtTime(v, t + (o.at ?? 0) + 0.008);
    g.gain.exponentialRampToValueAtTime(0.0001, t + (o.at ?? 0) + dur);
    let node = osc;
    if (o.filter){
      const f = this.ctx.createBiquadFilter();
      f.type = o.filter; f.frequency.value = o.ff ?? 1200; f.Q.value = o.fq ?? 1;
      osc.connect(f); node = f;
    }
    node.connect(g);
    if (o.pan != null && this.ctx.createStereoPanner){
      const p = this.ctx.createStereoPanner(); p.pan.value = o.pan;
      g.connect(p); p.connect(o.bus || this.sfxGain);
    } else g.connect(o.bus || this.sfxGain);
    osc.start(t + (o.at ?? 0));
    osc.stop(t + (o.at ?? 0) + dur + 0.05);
  },

  /* one-shot filtered noise burst */
  noise(o){
    if (!this.ready) return;
    const t = this.ctx.currentTime, dur = o.dur ?? 0.2;
    const src = this.ctx.createBufferSource();
    src.buffer = this.noiseBuf;
    src.playbackRate.value = o.rate ?? 1;
    const f = this.ctx.createBiquadFilter();
    f.type = o.filter || 'lowpass';
    f.frequency.setValueAtTime(o.f0 ?? 2000, t + (o.at ?? 0));
    if (o.f1 != null) f.frequency.exponentialRampToValueAtTime(Math.max(20, o.f1), t + (o.at ?? 0) + dur);
    f.Q.value = o.q ?? 1;
    const g = this.ctx.createGain();
    g.gain.setValueAtTime(0.0001, t + (o.at ?? 0));
    g.gain.exponentialRampToValueAtTime(o.vol ?? 0.25, t + (o.at ?? 0) + 0.01);
    g.gain.exponentialRampToValueAtTime(0.0001, t + (o.at ?? 0) + dur);
    src.connect(f); f.connect(g); g.connect(this.sfxGain);
    src.start(t + (o.at ?? 0));
    src.stop(t + (o.at ?? 0) + dur + 0.05);
  },

  /* --- named sfx --- */
  shoot(){ this.tone({ type:'square', f0:900, f1:260, dur:0.09, vol:0.10 });
           this.noise({ filter:'highpass', f0:1800, dur:0.05, vol:0.05 }); },
  playerHit(){
    this.noise({ filter:'lowpass', f0:1400, f1:60, dur:0.9, vol:0.4, rate:0.7 });
    this.tone({ type:'sawtooth', f0:180, f1:28, dur:0.8, vol:0.24 });
  },
  respawn(){ this.tone({ type:'triangle', f0:220, f1:880, dur:0.35, vol:0.14 }); },
  waveUp(){
    [0,0.09,0.18].forEach((at,i)=> this.tone({ type:'triangle', f0:[440,660,880][i], dur:0.24, vol:0.13, at }));
  },
  extraLife(){ [0,0.08,0.16,0.24].forEach((at,i)=> this.tone({type:'square',f0:[523,659,784,1047][i],dur:0.18,vol:0.1,at})); },
  gameOver(){
    [0,0.16,0.32,0.5].forEach((at,i)=> this.tone({ type:'sawtooth', f0:[330,262,196,130][i], f1:[300,240,180,60][i], dur:0.5, vol:0.16, at, filter:'lowpass', ff:900 }));
  },

  /* --- pulsing bassline; tempo climbs with the wave --- */
  music(){
    if (!this.ready || state !== 'playing') return;
    const t = this.ctx.currentTime;
    const bpm = Math.min(150, 82 + wave * 5);
    const spb = 60 / bpm;
    if (this.nextBeat < t) this.nextBeat = t + 0.02;   // never schedule into the past
    while (this.nextBeat < t + 0.12){
      const at = Math.max(0, this.nextBeat - t);
      const step = this.beat % 8;
      const notes = [55, 55, 82.4, 55, 65.4, 55, 73.4, 61.7];
      this.tone({ type:'triangle', f0:notes[step], dur:spb*0.85, vol:0.13, at, bus:this.musicGain, filter:'lowpass', ff:420 });
      if (step % 4 === 0){
        this.noise({ filter:'lowpass', f0:220, f1:50, dur:0.16, vol:0.16, at });
      }
      if (step === 6) this.noise({ filter:'highpass', f0:6000, dur:0.05, vol:0.035, at });
      this.beat++; this.nextBeat += spb;
    }
  }
};

/* ------------------------------------------------------------ orb design -- */
/* Each type = a colour identity, a silhouette, and a rule it breaks.         */
const TYPES = {
  splitter: {
    name:'SPLITTER', c:'#ff2f4e', c2:'#ffc0c8', r:34, hp:1, sc:100, spd:1,
    blurb:'Cracks into smaller, faster splitters',
    sfx(){ Snd.noise({filter:'bandpass', f0:1400, f1:300, q:2, dur:0.28, vol:0.24});
           Snd.tone({type:'square', f0:420, f1:120, dur:0.16, vol:0.12}); },
    destroy(o){
      if (o.tier <= 1) return;
      const n = o.tier === 3 ? 3 : 2;
      for (let i = 0; i < n; i++){
        const a = rand(0, TAU);
        const ch = new Orb(o.x + Math.cos(a) * o.r * 0.6, o.y + Math.sin(a) * o.r * 0.6, 'splitter', o.tier - 1);
        const sp = Math.hypot(o.vx, o.vy) * 1.25 + 60;
        ch.vx = Math.cos(a) * sp; ch.vy = Math.sin(a) * sp;
        orbs.push(ch);
      }
    }
  },

  swarm: {
    name:'HIVE', c:'#8dff3a', c2:'#e6ffb8', r:32, hp:1, sc:150, spd:0.85,
    blurb:'Bursts into a homing swarm that hunts you',
    sfx(){ Snd.tone({type:'sawtooth', f0:180, f1:1400, dur:0.3, vol:0.14, filter:'bandpass', ff:900, fq:6});
           Snd.noise({filter:'bandpass', f0:2600, q:8, dur:0.35, vol:0.1}); },
    destroy(o){
      for (let i = 0; i < 5; i++){
        const a = i / 5 * TAU + rand(-0.3, 0.3);
        const s = new Orb(o.x + Math.cos(a) * 24, o.y + Math.sin(a) * 24, 'swarmling', 1);
        s.vx = Math.cos(a) * 150; s.vy = Math.sin(a) * 150;
        orbs.push(s);
      }
    }
  },

  swarmling: {
    name:'SWARMLING', c:'#b6ff5e', c2:'#f2ffd6', r:11, hp:1, sc:40, spd:1, hidden:true, life:14,
    blurb:'', sfx(){ Snd.tone({type:'square', f0:1200, f1:400, dur:0.07, vol:0.07}); },
    move(o, dt){
      if (!ship || ship.dead) return;
      const [dx, dy] = dxy(o.x, o.y, ship.x, ship.y);
      const d = Math.max(1, Math.hypot(dx, dy));
      o.vx += (dx / d) * 300 * dt;
      o.vy += (dy / d) * 300 * dt;
      const sp = Math.hypot(o.vx, o.vy), max = 235;
      if (sp > max){ o.vx = o.vx / sp * max; o.vy = o.vy / sp * max; }
    }
  },

  gravity: {
    name:'WELL', c:'#3b6bff', c2:'#b9cdff', r:33, hp:1, sc:200, spd:0.8,
    blurb:'Collapses into a gravity well that eats your shots',
    sfx(){ Snd.tone({type:'sine', f0:420, f1:38, dur:0.9, vol:0.3});
           Snd.noise({filter:'lowpass', f0:800, f1:80, dur:0.7, vol:0.15}); },
    destroy(o){ effects.push(new GravityWell(o.x, o.y)); }
  },

  phase: {
    name:'PHASE', c:'#ffe14d', c2:'#fffbdc', r:30, hp:3, sc:250, spd:1.15,
    blurb:'Blinks somewhere else every time you clip it',
    sfx(){ Snd.tone({type:'sine', f0:1600, f1:300, dur:0.18, vol:0.15});
           Snd.tone({type:'sine', f0:1180, f1:2400, dur:0.18, vol:0.1, detune:12}); },
    hurt(o){
      spawnRing(o.x, o.y, o.r * 1.6, '#ffe14d', 0.35);
      burst(o.x, o.y, '#ffe14d', '#fffbdc', o.r * 0.6, 12);
      let tries = 0, nx, ny;
      do { nx = rand(60, W - 60); ny = rand(60, H - 60); tries++; }
      while (ship && dist(nx, ny, ship.x, ship.y) < 200 && tries < 24);
      o.x = nx; o.y = ny; o.ghostT = 0.5;
      spawnRing(o.x, o.y, o.r * 2.2, '#ffe14d', 0.45);
      Snd.tone({type:'sine', f0:300, f1:1700, dur:0.16, vol:0.12});
    }
  },

  blackhole: {
    name:'SINGULARITY', c:'#a24bff', c2:'#e7c6ff', r:34, hp:1, sc:300, spd:0.7,
    blurb:'Opens a black hole that devours everything near it',
    sfx(){ Snd.noise({filter:'lowpass', f0:400, f1:30, dur:1.4, vol:0.36, rate:0.5});
           Snd.tone({type:'sine', f0:160, f1:18, dur:1.4, vol:0.28}); },
    destroy(o){ effects.push(new BlackHole(o.x, o.y)); }
  },

  inverter: {
    name:'INVERTER', c:'#ff9a1f', c2:'#3bd7ff', r:31, hp:1, sc:200, spd:1,
    blurb:'Flips your controls for six long seconds',
    sfx(){ Snd.tone({type:'sawtooth', f0:700, f1:180, dur:0.5, vol:0.2, filter:'lowpass', ff:1400});
           Snd.tone({type:'sawtooth', f0:180, f1:700, dur:0.5, vol:0.16, filter:'lowpass', ff:1400}); },
    destroy(o){
      reverseT = 6;
      spawnRing(o.x, o.y, 300, '#ff9a1f', 0.7);
      addShake(0.45);
      floaters.push(new Floater(o.x, o.y - 30, 'CONTROLS REVERSED!', '#ff9a1f', 1.7, 20));
    }
  },

  shatter: {
    name:'SHATTER', c:'#ff43e0', c2:'#ffd4f7', r:31, hp:1, sc:150, spd:1.1,
    blurb:'Explodes into shards that ricochet off the walls',
    sfx(){ Snd.noise({filter:'highpass', f0:2600, dur:0.4, vol:0.24});
           [0,0.05,0.1].forEach((at,i)=>Snd.tone({type:'triangle', f0:[2400,1900,3100][i], f1:900, dur:0.1, vol:0.08, at})); },
    destroy(o){
      const n = 8;
      for (let i = 0; i < n; i++){
        const a = i / n * TAU + rand(-0.2, 0.2);
        shards.push(new Shard(o.x + Math.cos(a) * o.r * 0.5, o.y + Math.sin(a) * o.r * 0.5, a, rand(240, 400)));
      }
    }
  },

  timebubble: {
    name:'STASIS', c:'#28e6ff', c2:'#d3f8ff', r:32, hp:1, sc:220, spd:0.95,
    blurb:'Drops a bubble where time crawls',
    sfx(){ Snd.tone({type:'sine', f0:1500, f1:220, dur:0.9, vol:0.2, filter:'lowpass', ff:2400});
           Snd.tone({type:'sine', f0:760, f1:110, dur:1.1, vol:0.13, detune:-20}); },
    destroy(o){ effects.push(new TimeBubble(o.x, o.y)); }
  },

  chain: {
    name:'CHAIN BOMB', c:'#ff6a00', c2:'#ffdda8', r:32, hp:1, sc:180, spd:0.95,
    blurb:'Detonates — and sets off every orb nearby',
    sfx(){ Snd.noise({filter:'lowpass', f0:1800, f1:70, dur:0.5, vol:0.34, rate:0.8});
           Snd.tone({type:'triangle', f0:260, f1:40, dur:0.4, vol:0.2}); },
    destroy(o){ effects.push(new Detonation(o.x, o.y, 200, 0.07)); }
  },

  mirror: {
    name:'MIRROR', c:'#cfe4ff', c2:'#ffffff', r:31, hp:1, sc:200, spd:1,
    blurb:'Clones itself across the screen when broken',
    sfx(){ Snd.tone({type:'square', f0:1400, f1:900, dur:0.14, vol:0.11, pan:-0.8});
           Snd.tone({type:'square', f0:1400, f1:900, dur:0.14, vol:0.11, pan:0.8, at:0.07}); },
    destroy(o){
      if (o.gen >= 2) return;
      const nx = W - o.x, ny = H - o.y;
      const cl = new Orb(nx, ny, 'mirror', 3, o.gen + 1);
      cl.vx = -o.vx; cl.vy = -o.vy;
      orbs.push(cl);
      spawnRing(nx, ny, 90, '#cfe4ff', 0.5);
    }
  },

  rubber: {
    name:'RUBBER', c:'#00ffa2', c2:'#ccffec', r:30, hp:4, sc:280, spd:1.05, bounce:true,
    blurb:'Bounces off walls — faster with every hit it takes',
    sfx(){ Snd.tone({type:'sine', f0:180, f1:900, dur:0.16, vol:0.16});
           Snd.tone({type:'sine', f0:900, f1:200, dur:0.2, vol:0.13, at:0.13}); },
    hurt(o){
      const sp = Math.hypot(o.vx, o.vy) * 1.5 + 40;
      const a = Math.atan2(o.vy, o.vx) + rand(-0.4, 0.4);
      o.vx = Math.cos(a) * sp; o.vy = Math.sin(a) * sp;
      o.squish = 1;
      spawnRing(o.x, o.y, o.r * 1.7, '#00ffa2', 0.3);
      Snd.tone({type:'sine', f0:300, f1:1100, dur:0.1, vol:0.12});
    }
  },

  nova: {
    name:'NOVA', c:'#ffd23d', c2:'#ffffff', r:33, hp:1, sc:240, spd:0.9,
    blurb:'Erupts in a shockwave that guts everything it touches',
    sfx(){ Snd.noise({filter:'bandpass', f0:400, f1:5000, q:1.2, dur:0.55, vol:0.3});
           [0,0.02,0.04].forEach((at,i)=>Snd.tone({type:'sawtooth', f0:[220,330,440][i], f1:[110,165,220][i], dur:0.6, vol:0.12, at, filter:'lowpass', ff:2200})); },
    destroy(o){ effects.push(new Shockwave(o.x, o.y, 380, '#ffd23d')); addShake(0.6); }
  }
};
const SPAWNABLE = Object.keys(TYPES).filter(k => !TYPES[k].hidden);

/* Which orbs show up, wave by wave. */
const UNLOCKS = [
  ['splitter','shatter'],
  ['chain','swarm'],
  ['rubber','phase'],
  ['gravity','mirror'],
  ['timebubble','nova'],
  ['blackhole','inverter']
];
function poolFor(w){
  const p = [];
  for (let i = 0; i < UNLOCKS.length; i++) if (w >= i + 1) p.push(...UNLOCKS[i]);
  return p.length ? p : UNLOCKS[0];
}

/* ------------------------------------------------------- orb silhouettes -- */
/* Pure drawing, no globals — reused by the game and by the start-screen chips */
function drawOrbBody(g, key, r, t, o){
  const T = TYPES[key];
  g.save();
  switch(key){

  case 'splitter': {
    g.rotate(t * 0.6);
    g.beginPath();
    const n = 10;
    for (let i = 0; i < n; i++){
      const a = i / n * TAU;
      const rr = r * (i % 2 ? 0.6 : 1);
      i ? g.lineTo(Math.cos(a)*rr, Math.sin(a)*rr) : g.moveTo(Math.cos(a)*rr, Math.sin(a)*rr);
    }
    g.closePath();
    const grd = g.createRadialGradient(0,0,r*0.1,0,0,r);
    grd.addColorStop(0, T.c2); grd.addColorStop(0.5, T.c); grd.addColorStop(1, '#5a0512');
    g.fillStyle = grd; g.fill();
    g.strokeStyle = T.c2; g.lineWidth = 1.6; g.stroke();
    g.beginPath(); g.arc(0,0,r*0.26,0,TAU); g.fillStyle = '#fff'; g.globalAlpha = 0.85; g.fill();
    break;
  }

  case 'swarm': {
    g.beginPath(); g.arc(0,0,r*0.44,0,TAU);
    const grd = g.createRadialGradient(0,0,0,0,0,r*0.44);
    grd.addColorStop(0,'#fff'); grd.addColorStop(1,T.c);
    g.fillStyle = grd; g.fill();
    for (let i = 0; i < 6; i++){
      const a = t * 1.7 + i / 6 * TAU;
      const rr = r * (0.78 + 0.1 * Math.sin(t * 3 + i));
      const x = Math.cos(a)*rr, y = Math.sin(a)*rr;
      g.beginPath(); g.moveTo(0,0); g.lineTo(x,y);
      g.strokeStyle = hexA(T.c, 0.28); g.lineWidth = 1; g.stroke();
      g.beginPath(); g.arc(x, y, r*0.17, 0, TAU);
      g.fillStyle = T.c2; g.fill();
    }
    break;
  }

  case 'swarmling': {
    g.rotate(o ? Math.atan2(o.vy, o.vx) : t * 3);
    g.beginPath(); g.moveTo(r*1.1, 0); g.lineTo(-r*0.7, r*0.7); g.lineTo(-r*0.3, 0); g.lineTo(-r*0.7, -r*0.7);
    g.closePath(); g.fillStyle = T.c; g.fill();
    g.strokeStyle = '#fff'; g.lineWidth = 0.8; g.stroke();
    break;
  }

  case 'gravity': {
    g.beginPath(); g.arc(0,0,r,0,TAU);
    const grd = g.createRadialGradient(0,0,r*0.15,0,0,r);
    grd.addColorStop(0,'#0a0f2e'); grd.addColorStop(0.7,'#12205e'); grd.addColorStop(1, T.c);
    g.fillStyle = grd; g.fill();
    for (let i = 0; i < 3; i++){
      const ph = ((t * 0.55) + i / 3) % 1;
      const rr = r * (1.35 - ph * 1.15);
      g.beginPath(); g.arc(0, 0, Math.max(1, rr), 0, TAU);
      g.strokeStyle = hexA(T.c2, 0.75 * ph); g.lineWidth = 1.8; g.stroke();
    }
    g.beginPath(); g.arc(0,0,r*0.2,0,TAU); g.fillStyle = '#fff'; g.fill();
    break;
  }

  case 'phase': {
    for (let i = 1; i <= 2; i++){
      const off = Math.sin(t * 5 + i) * r * 0.5;
      g.beginPath(); g.arc(off, -off * 0.4, r * 0.9, 0, TAU);
      g.fillStyle = hexA(T.c, 0.13); g.fill();
    }
    g.beginPath(); g.arc(0,0,r,0,TAU);
    const grd = g.createRadialGradient(0,0,0,0,0,r);
    grd.addColorStop(0, hexA(T.c2, 0.9)); grd.addColorStop(0.6, hexA(T.c, 0.5)); grd.addColorStop(1, hexA(T.c, 0.08));
    g.fillStyle = grd; g.fill();
    g.save(); g.rotate(t * 1.4);
    g.setLineDash([6, 7]); g.beginPath(); g.arc(0,0,r*0.95,0,TAU);
    g.strokeStyle = T.c2; g.lineWidth = 1.8; g.stroke();
    g.restore();
    break;
  }

  case 'blackhole': {
    g.save(); g.rotate(t * 1.5);
    for (let i = 0; i < 24; i++){
      const a0 = i / 24 * TAU;
      g.beginPath();
      g.arc(0, 0, r * 0.86, a0, a0 + 0.2);
      g.strokeStyle = hexA(i % 2 ? T.c2 : T.c, 0.35 + 0.6 * Math.abs(Math.sin(a0 * 2 + t)));
      g.lineWidth = r * 0.24; g.stroke();
    }
    g.restore();
    g.beginPath(); g.arc(0,0,r*0.62,0,TAU); g.fillStyle = '#000'; g.fill();
    g.strokeStyle = hexA(T.c2, 0.9); g.lineWidth = 1.4; g.stroke();
    break;
  }

  case 'inverter': {
    g.rotate(Math.sin(t * 1.5) * 0.55);
    for (const half of [0, 1]){
      g.beginPath(); g.moveTo(0, 0);
      g.arc(0, 0, r * 0.9, -Math.PI/2 + half * Math.PI, Math.PI/2 + half * Math.PI);
      g.closePath();
      const grd = g.createLinearGradient(0, -r, 0, r);
      grd.addColorStop(0, half ? T.c2 : T.c);
      grd.addColorStop(1, half ? '#07394d' : '#4a2400');
      g.fillStyle = grd; g.fill();
    }
    g.save(); g.rotate(t * 2.2);
    g.beginPath(); g.arc(0,0,r*0.62, 0.35, 2.5); g.strokeStyle = T.c2; g.lineWidth = r*0.15; g.stroke();
    g.restore();
    g.save(); g.rotate(-t * 2.7);
    g.beginPath(); g.arc(0,0,r*0.62, 0.35 + Math.PI, 2.5 + Math.PI); g.strokeStyle = T.c; g.lineWidth = r*0.15; g.stroke();
    g.restore();
    g.fillStyle = '#fff';
    for (const s of [-1, 1]){
      g.beginPath();
      g.moveTo(s * r * 0.16, -r * 0.22); g.lineTo(s * r * 0.46, 0); g.lineTo(s * r * 0.16, r * 0.22);
      g.closePath(); g.fill();
    }
    break;
  }

  case 'shatter': {
    g.rotate(t * 0.4);
    const pts = [];
    const JAG = [1, 0.62, 0.94, 0.55, 1.05, 0.68, 0.86];
    for (let i = 0; i < JAG.length; i++){
      const a = i / JAG.length * TAU;
      const rr = r * JAG[i];
      pts.push([Math.cos(a)*rr, Math.sin(a)*rr]);
    }
    g.beginPath(); pts.forEach((p,i)=> i ? g.lineTo(p[0],p[1]) : g.moveTo(p[0],p[1])); g.closePath();
    const grd = g.createLinearGradient(-r,-r,r,r);
    grd.addColorStop(0, T.c2); grd.addColorStop(0.45, T.c); grd.addColorStop(1, '#520045');
    g.fillStyle = grd; g.fill();
    g.strokeStyle = T.c2; g.lineWidth = 1.4; g.stroke();
    g.beginPath();
    pts.forEach(p => { g.moveTo(0,0); g.lineTo(p[0],p[1]); });
    g.strokeStyle = hexA(T.c2, 0.4); g.lineWidth = 0.8; g.stroke();
    break;
  }

  case 'timebubble': {
    g.beginPath(); g.arc(0,0,r,0,TAU);
    const grd = g.createRadialGradient(-r*0.3,-r*0.3,r*0.05,0,0,r);
    grd.addColorStop(0,'#ffffff'); grd.addColorStop(0.35,hexA(T.c,0.75)); grd.addColorStop(1,hexA(T.c,0.16));
    g.fillStyle = grd; g.fill();
    g.strokeStyle = hexA('#ffffff',0.75); g.lineWidth = 1.4; g.stroke();
    g.strokeStyle = '#04303a'; g.lineWidth = 2.2; g.lineCap = 'round';
    g.beginPath(); g.moveTo(0,0); g.lineTo(Math.cos(t*2 - 1.57) * r * 0.55, Math.sin(t*2 - 1.57) * r * 0.55); g.stroke();
    g.beginPath(); g.moveTo(0,0); g.lineTo(Math.cos(t*0.5 - 1.57) * r * 0.34, Math.sin(t*0.5 - 1.57) * r * 0.34); g.stroke();
    break;
  }

  case 'chain': {
    const bl = 0.5 + 0.5 * Math.sin(t * 9);
    g.beginPath(); g.arc(0,0,r*0.72,0,TAU);
    const grd = g.createRadialGradient(0,0,0,0,0,r*0.72);
    grd.addColorStop(0,'#fff8e6'); grd.addColorStop(0.4, T.c2); grd.addColorStop(1, T.c);
    g.fillStyle = grd; g.fill();
    for (let i = 0; i < 2; i++){
      const ph = ((t * 1.1) + i / 2) % 1;
      g.beginPath(); g.arc(0,0, r * (0.75 + ph * 0.5), 0, TAU);
      g.strokeStyle = hexA(T.c, (1 - ph) * 0.8); g.lineWidth = 2; g.stroke();
    }
    g.beginPath(); g.arc(0,0,r*0.28,0,TAU);
    g.fillStyle = 'rgba(255,255,255,' + (0.35 + bl * 0.65) + ')'; g.fill();
    break;
  }

  case 'mirror': {
    g.rotate(t * 0.5);
    g.beginPath(); g.arc(0,0,r,0,TAU);
    const grd = g.createLinearGradient(-r,-r,r,r);
    grd.addColorStop(0,'#ffffff'); grd.addColorStop(0.3,'#7f97b8');
    grd.addColorStop(0.5,'#ffffff'); grd.addColorStop(0.7,'#5d7395'); grd.addColorStop(1,'#ffffff');
    g.fillStyle = grd; g.fill();
    g.beginPath(); g.moveTo(0,-r); g.lineTo(0,r);
    g.strokeStyle = 'rgba(10,16,30,.55)'; g.lineWidth = 1.6; g.stroke();
    const sw = ((t * 0.6) % 1) * 2 - 1;
    g.save(); g.beginPath(); g.arc(0,0,r,0,TAU); g.clip();
    g.beginPath(); g.moveTo(sw*r - r*0.2, -r); g.lineTo(sw*r + r*0.2, -r); g.lineTo(sw*r + r*0.5, r); g.lineTo(sw*r + r*0.1, r);
    g.closePath(); g.fillStyle = 'rgba(255,255,255,.55)'; g.fill();
    g.restore();
    g.beginPath(); g.arc(0,0,r,0,TAU); g.strokeStyle = '#fff'; g.lineWidth = 1.3; g.stroke();
    break;
  }

  case 'rubber': {
    const sq = o ? (o.squish || 0) : 0.35 + 0.35 * Math.sin(t * 4);
    const a = o ? Math.atan2(o.vy, o.vx) : t;
    g.rotate(a);
    g.beginPath();
    g.ellipse(0, 0, r * (1 + sq * 0.35), r * (1 - sq * 0.3), 0, 0, TAU);
    const grd = g.createRadialGradient(-r*0.3,-r*0.3,r*0.08,0,0,r);
    grd.addColorStop(0,'#ffffff'); grd.addColorStop(0.35, T.c); grd.addColorStop(1,'#00543a');
    g.fillStyle = grd; g.fill();
    g.strokeStyle = T.c2; g.lineWidth = 1.6; g.stroke();
    if (o && o.hp != null){
      for (let i = 0; i < o.hp; i++){
        g.beginPath(); g.arc(-r*0.35 + i * r*0.26, r*0.42, r*0.09, 0, TAU);
        g.fillStyle = '#022'; g.fill();
      }
    }
    break;
  }

  case 'nova': {
    g.rotate(t * 0.9);
    const spikes = 8;
    g.beginPath();
    for (let i = 0; i < spikes * 2; i++){
      const a = i / (spikes * 2) * TAU;
      const rr = i % 2 ? r * 0.42 : r * (1 + 0.14 * Math.sin(t * 6 + i));
      i ? g.lineTo(Math.cos(a)*rr, Math.sin(a)*rr) : g.moveTo(Math.cos(a)*rr, Math.sin(a)*rr);
    }
    g.closePath();
    const grd = g.createRadialGradient(0,0,0,0,0,r);
    grd.addColorStop(0,'#ffffff'); grd.addColorStop(0.4, T.c2); grd.addColorStop(1, T.c);
    g.fillStyle = grd; g.fill();
    g.beginPath(); g.arc(0,0,r*0.3,0,TAU); g.fillStyle = '#fff'; g.fill();
    break;
  }

  default: {
    g.beginPath(); g.arc(0,0,r,0,TAU); g.fillStyle = T.c; g.fill();
  }}
  g.restore();
}

/* ---------------------------------------------------------------- world --- */
let state = 'start';           // start | playing | paused | gameover
let score = 0, lives = 3, wave = 0, nextExtra = 5000;
let best = parseInt(localStorage.getItem('wildorbs.best') || '0', 10) || 0;
let orbs = [], bullets = [], shards = [], particles = [], effects = [], floaters = [], stars = [];
let ship = null;
let shake = 0, reverseT = 0, hitFlash = 0, waveTransition = 0, gameOverDelay = 0;
let combo = 0, comboTimer = 0, elapsed = 0;
let bubbles = [];              // cached time-bubbles for the slow-field lookup

const PARTICLE_CAP = 900;
function addShake(v){ shake = clamp(shake + v, 0, 1.2); }
function comboMult(){ return clamp(1 + Math.floor(combo / 4), 1, 8); }

/* ------------------------------------------------------------------ ship -- */
class Ship {
  constructor(){
    this.x = W/2; this.y = H/2; this.a = -Math.PI/2;
    this.vx = 0; this.vy = 0; this.r = 13;
    this.cool = 0; this.invuln = 3; this.dead = false; this.deadT = 0;
    this.thrusting = false; this.flame = 0;
  }
  update(dt){
    if (this.dead){
      this.deadT -= dt;
      if (this.deadT <= 0){
        if (lives <= 0){ endGame(); }
        else { this.respawn(); }
      }
      return;
    }
    const rev = reverseT > 0 ? -1 : 1;
    let turn = 0;
    if (keys['ArrowLeft']  || keys['KeyA']) turn -= 1;
    if (keys['ArrowRight'] || keys['KeyD']) turn += 1;
    this.a += turn * rev * 3.7 * dt;

    this.thrusting = !!(keys['ArrowUp'] || keys['KeyW']);
    const brake = !!(keys['ArrowDown'] || keys['KeyS']);
    if (this.thrusting){
      const acc = 430 * rev;
      this.vx += Math.cos(this.a) * acc * dt;
      this.vy += Math.sin(this.a) * acc * dt;
      this.flame = Math.min(1, this.flame + dt * 8);
      if (Math.random() < dt * 60){
        const ba = this.a + Math.PI + rand(-0.35, 0.35);
        addParticle(this.x + Math.cos(ba) * 14, this.y + Math.sin(ba) * 14,
          Math.cos(ba) * rand(70, 190) + this.vx * 0.3, Math.sin(ba) * rand(70, 190) + this.vy * 0.3,
          rand(2.5, 5), rand(0.25, 0.5), rev > 0 ? '#38f5d0' : '#ff9a1f');
      }
    } else this.flame = Math.max(0, this.flame - dt * 6);
    Snd.thrust(this.thrusting && state === 'playing');

    if (brake){ this.vx *= Math.pow(0.15, dt); this.vy *= Math.pow(0.15, dt); }

    applyFields(this, dt, 'ship');

    const drag = Math.pow(0.55, dt);
    this.vx *= drag; this.vy *= drag;
    const sp = Math.hypot(this.vx, this.vy), max = 520;
    if (sp > max){ this.vx = this.vx / sp * max; this.vy = this.vy / sp * max; }

    const tf = slowAt(this.x, this.y, true);
    this.x += this.vx * dt * tf;
    this.y += this.vy * dt * tf;
    wrapPos(this);

    if (this.invuln > 0) this.invuln -= dt;
    if (this.cool > 0) this.cool -= dt;
    if (keys['Space'] && this.cool <= 0) this.fire();
  }
  fire(){
    this.cool = 0.155;
    const nx = Math.cos(this.a), ny = Math.sin(this.a);
    const b = new Bullet(this.x + nx * 16, this.y + ny * 16,
                         nx * 660 + this.vx * 0.35, ny * 660 + this.vy * 0.35);
    bullets.push(b);
    this.vx -= nx * 26; this.vy -= ny * 26;               // recoil
    for (let i = 0; i < 5; i++){
      const a = this.a + rand(-0.5, 0.5);
      addParticle(this.x + nx * 16, this.y + ny * 16,
        Math.cos(a) * rand(80, 260), Math.sin(a) * rand(80, 260), rand(1.6, 3), rand(0.1, 0.22), '#ffe98a');
    }
    Snd.shoot();
  }
  kill(){
    if (this.dead || this.invuln > 0) return;
    this.dead = true; this.deadT = 1.5;
    lives--;
    hitFlash = 1; addShake(1.0);
    Snd.playerHit(); Snd.thrust(false);
    for (let i = 0; i < 70; i++){
      const a = rand(0, TAU), s = rand(60, 520);
      addParticle(this.x, this.y, Math.cos(a)*s, Math.sin(a)*s, rand(2, 6), rand(0.5, 1.3),
        pick(['#ffffff','#38f5d0','#ff2f6e','#ffe98a']));
    }
    spawnRing(this.x, this.y, 240, '#ffffff', 0.8);
    combo = 0;
  }
  respawn(){
    this.dead = false; this.x = W/2; this.y = H/2;
    this.vx = this.vy = 0; this.a = -Math.PI/2; this.invuln = 3;
    Snd.respawn();
    spawnRing(this.x, this.y, 140, '#38f5d0', 0.6);
  }
  draw(g){
    if (this.dead) return;
    const blink = this.invuln > 0 && (Math.floor(elapsed * 12) % 2 === 0);
    g.save();
    g.translate(this.x, this.y);
    g.rotate(this.a);
    if (this.flame > 0.02){
      const fl = this.flame * (0.7 + Math.random() * 0.5);
      g.beginPath();
      g.moveTo(-9, -5.5); g.lineTo(-9 - 26 * fl, 0); g.lineTo(-9, 5.5);
      g.closePath();
      const grd = g.createLinearGradient(-9, 0, -9 - 30 * fl, 0);
      grd.addColorStop(0, reverseT > 0 ? '#ffd08a' : '#bffff2');
      grd.addColorStop(0.4, reverseT > 0 ? '#ff9a1f' : '#38f5d0');
      grd.addColorStop(1, 'rgba(0,0,0,0)');
      g.fillStyle = grd; g.fill();
    }
    if (!blink){
      g.beginPath();
      g.moveTo(17, 0); g.lineTo(-11, 10.5); g.lineTo(-5, 0); g.lineTo(-11, -10.5);
      g.closePath();
      const grd = g.createLinearGradient(-11, 0, 17, 0);
      grd.addColorStop(0, '#123a4d'); grd.addColorStop(0.6, '#d8fbff'); grd.addColorStop(1, '#ffffff');
      g.fillStyle = grd; g.fill();
      g.strokeStyle = '#8ff6ff'; g.lineWidth = 1.4; g.stroke();
      g.beginPath(); g.arc(2, 0, 3, 0, TAU); g.fillStyle = '#38f5d0'; g.fill();
    }
    g.restore();
    if (this.invuln > 0){
      const p = 0.35 + 0.25 * Math.sin(elapsed * 9);
      g.beginPath(); g.arc(this.x, this.y, 26 + Math.sin(elapsed * 5) * 2, 0, TAU);
      g.strokeStyle = hexA('#38f5d0', p); g.lineWidth = 1.6; g.stroke();
    }
  }
}

/* --------------------------------------------------------------- bullet --- */
class Bullet {
  constructor(x, y, vx, vy){
    this.x = x; this.y = y; this.vx = vx; this.vy = vy;
    this.life = 1.25; this.dead = false; this.trail = [];
  }
  update(dt){
    applyFields(this, dt, 'bullet');
    const tf = slowAt(this.x, this.y);
    this.x += this.vx * dt * tf; this.y += this.vy * dt * tf;
    this.trail.push(this.x, this.y);
    if (this.trail.length > 12) this.trail.splice(0, 2);
    wrapPos(this);
    this.life -= dt;
    if (this.life <= 0) this.dead = true;
  }
  draw(g){
    const n = this.trail.length;
    if (n >= 6){
      g.beginPath(); let started = false;
      for (let i = 0; i < n; i += 2){
        const px = this.trail[i], py = this.trail[i+1];
        if (Math.abs(px - this.x) > W * 0.5 || Math.abs(py - this.y) > H * 0.5){ started = false; continue; }
        if (started) g.lineTo(px, py); else { g.moveTo(px, py); started = true; }
      }
      g.lineTo(this.x, this.y);
      g.strokeStyle = 'rgba(255,233,138,.30)'; g.lineWidth = 2.2; g.lineCap = 'round'; g.stroke();
    }
    for (let i = 0; i < n; i += 2){
      const t = (i / Math.max(2, n - 2));
      const px = this.trail[i], py = this.trail[i+1];
      if (Math.abs(px - this.x) > W * 0.5 || Math.abs(py - this.y) > H * 0.5) continue;
      drawGlow(g, px, py, 4 + t * 4, '#ffe98a', 0.1 + t * 0.25);
    }
    drawGlow(g, this.x, this.y, 14, '#fff3b0', 0.85);
    g.beginPath(); g.arc(this.x, this.y, 2.4, 0, TAU);
    g.fillStyle = '#ffffff'; g.fill();
  }
}

/* ---------------------------------------------------------------- shard --- */
class Shard {
  constructor(x, y, a, sp){
    this.x = x; this.y = y;
    this.vx = Math.cos(a) * sp; this.vy = Math.sin(a) * sp;
    this.r = 7; this.rot = rand(0, TAU); this.spin = rand(-9, 9);
    this.life = 7.5; this.dead = false;
  }
  update(dt){
    const tf = slowAt(this.x, this.y);
    applyFields(this, dt, 'orb');
    this.x += this.vx * dt * tf; this.y += this.vy * dt * tf;
    this.rot += this.spin * dt * tf;
    if (this.x < this.r){ this.x = this.r; this.vx = Math.abs(this.vx); this.bounce(); }
    if (this.x > W - this.r){ this.x = W - this.r; this.vx = -Math.abs(this.vx); this.bounce(); }
    if (this.y < this.r){ this.y = this.r; this.vy = Math.abs(this.vy); this.bounce(); }
    if (this.y > H - this.r){ this.y = H - this.r; this.vy = -Math.abs(this.vy); this.bounce(); }
    this.life -= dt;
    if (this.life <= 0){ this.dead = true; burst(this.x, this.y, '#ff43e0', '#ffd4f7', 8, 6); }
    if (Math.random() < dt * 22)
      addParticle(this.x, this.y, rand(-30,30), rand(-30,30), rand(1,2.4), rand(0.15,0.35), '#ff43e0');
  }
  bounce(){
    Snd.tone({type:'square', f0:1500, f1:600, dur:0.05, vol:0.045});
    burst(this.x, this.y, '#ff43e0', '#ffd4f7', 8, 4);
  }
  draw(g){
    g.save(); g.translate(this.x, this.y); g.rotate(this.rot);
    g.beginPath(); g.moveTo(9, 0); g.lineTo(-5, 6); g.lineTo(-2, 0); g.lineTo(-5, -6);
    g.closePath();
    g.fillStyle = '#ff43e0'; g.fill();
    g.strokeStyle = '#ffd4f7'; g.lineWidth = 1; g.stroke();
    g.restore();
    drawGlow(g, this.x, this.y, 16, '#ff43e0', 0.35);
  }
}

/* ------------------------------------------------------------------ orb --- */
const TIER_SCALE = { 1:0.48, 2:0.72, 3:1 };
class Orb {
  constructor(x, y, type, tier = 3, gen = 0){
    const T = TYPES[type];
    this.x = x; this.y = y; this.type = type; this.tier = tier; this.gen = gen;
    this.r = T.r * (TIER_SCALE[tier] || 1);
    this.hp = T.hp; this.maxhp = T.hp;
    this.spin = rand(0, TAU); this.spinV = rand(-0.9, 0.9);
    this.flash = 0; this.dead = false; this.squish = 0; this.ghostT = 0;
    this.life = T.life || 0;
    const a = rand(0, TAU);
    const base = rand(42, 92) * (T.spd || 1) * (1 + wave * 0.05) * (tier < 3 ? 1.25 : 1);
    this.vx = Math.cos(a) * base; this.vy = Math.sin(a) * base;
    this.born = 0.35;                                        // brief spawn-in
  }
  update(dt){
    const T = TYPES[this.type];
    if (T.move) T.move(this, dt);
    applyFields(this, dt, 'orb');
    const tf = slowAt(this.x, this.y);
    this.x += this.vx * dt * tf;
    this.y += this.vy * dt * tf;
    this.spin += this.spinV * dt * tf;
    if (T.bounce){
      if (this.x < this.r){ this.x = this.r; this.vx = Math.abs(this.vx); this.wallHit(); }
      if (this.x > W - this.r){ this.x = W - this.r; this.vx = -Math.abs(this.vx); this.wallHit(); }
      if (this.y < this.r){ this.y = this.r; this.vy = Math.abs(this.vy); this.wallHit(); }
      if (this.y > H - this.r){ this.y = H - this.r; this.vy = -Math.abs(this.vy); this.wallHit(); }
    } else wrapPos(this);
    if (this.flash > 0) this.flash -= dt;
    if (this.ghostT > 0) this.ghostT -= dt;
    if (this.squish > 0) this.squish = Math.max(0, this.squish - dt * 2.4);
    if (this.born > 0) this.born -= dt;
    if (this.life > 0){
      this.life -= dt;
      if (this.life <= 0){ this.dead = true; burst(this.x, this.y, TYPES[this.type].c, TYPES[this.type].c2, this.r, 10); }
    }
    if (this.type === 'swarmling' && Math.random() < dt * 30)
      addParticle(this.x, this.y, -this.vx*0.2, -this.vy*0.2, rand(1,2.2), rand(0.15,0.3), '#b6ff5e');
  }
  wallHit(){
    this.squish = 1;
    burst(this.x, this.y, TYPES[this.type].c, TYPES[this.type].c2, this.r * 0.5, 6);
    Snd.tone({type:'sine', f0:420, f1:180, dur:0.08, vol:0.07});
  }
  draw(g){
    const T = TYPES[this.type];
    const spawnScale = this.born > 0 ? lerp(0.2, 1, 1 - this.born / 0.35) : 1;
    const r = this.r * spawnScale;
    drawGlow(g, this.x, this.y, r * 2.3, T.c, 0.30 + (this.flash > 0 ? 0.4 : 0));
    if (this.ghostT > 0) drawGlow(g, this.x, this.y, r * 3.4, T.c2, this.ghostT * 0.5);
    g.save();
    g.translate(this.x, this.y);
    g.scale(spawnScale, spawnScale);
    drawOrbBody(g, this.type, this.r, elapsed + this.spin, this);
    if (this.flash > 0){
      g.globalCompositeOperation = 'lighter';
      g.beginPath(); g.arc(0, 0, this.r, 0, TAU);
      g.fillStyle = 'rgba(255,255,255,' + clamp(this.flash * 5, 0, 0.75) + ')'; g.fill();
      g.globalCompositeOperation = 'source-over';
    }
    g.restore();
    // hp pips for multi-hit orbs
    if (this.maxhp > 1 && this.type !== 'rubber'){
      for (let i = 0; i < this.maxhp; i++){
        g.beginPath();
        g.arc(this.x - (this.maxhp - 1) * 4 + i * 8, this.y - r - 10, 2.2, 0, TAU);
        g.fillStyle = i < this.hp ? T.c2 : 'rgba(255,255,255,.18)';
        g.fill();
      }
    }
  }
}

/* --------------------------------------------------------------- effects -- */
class GravityWell {
  constructor(x, y){ this.x = x; this.y = y; this.t = 0; this.life = 6.5; this.R = 340; this.dead = false; }
  update(dt){
    this.t += dt;
    if (this.t > this.life) this.dead = true;
    if (Math.random() < dt * 30){
      const a = rand(0, TAU), d = rand(this.R * 0.5, this.R);
      addParticle(this.x + Math.cos(a)*d, this.y + Math.sin(a)*d, -Math.cos(a)*160, -Math.sin(a)*160,
        rand(1.5,3), rand(0.5,1.0), '#7fa4ff');
    }
  }
  field(e, dt, kind){
    const [dx, dy] = dxy(e.x, e.y, this.x, this.y);
    const d = Math.hypot(dx, dy);
    if (d > this.R || d < 1) return;
    const fall = 1 - d / this.R;
    const k = kind === 'bullet' ? 1500 : kind === 'ship' ? 470 : 300;
    const f = k * fall * fall * dt;
    e.vx += (dx / d) * f; e.vy += (dy / d) * f;
  }
  draw(g){
    const fade = clamp(Math.min(this.t * 3, (this.life - this.t) * 2), 0, 1);
    drawGlow(g, this.x, this.y, this.R * 0.8, '#3b6bff', 0.14 * fade);
    for (let i = 0; i < 4; i++){
      const ph = ((this.t * 0.5) + i / 4) % 1;
      g.beginPath(); g.arc(this.x, this.y, this.R * (1 - ph), 0, TAU);
      g.strokeStyle = hexA('#8fb0ff', 0.32 * ph * fade); g.lineWidth = 1.6; g.stroke();
    }
    g.save(); g.translate(this.x, this.y); g.rotate(this.t * 1.6);
    for (let i = 0; i < 3; i++){
      g.beginPath();
      const rr = 26 + i * 9;
      g.arc(0, 0, rr, i * 2, i * 2 + 2.2);
      g.strokeStyle = hexA('#cddcff', 0.7 * fade); g.lineWidth = 2; g.stroke();
    }
    g.restore();
    drawGlow(g, this.x, this.y, 40, '#dbe6ff', 0.6 * fade);
  }
}

class BlackHole {
  constructor(x, y){ this.x = x; this.y = y; this.t = 0; this.life = 5; this.R = 330; this.core = 30; this.dead = false; }
  update(dt){
    this.t += dt;
    // devour
    for (const o of orbs){
      if (o.dead) continue;
      if (dist(o.x, o.y, this.x, this.y) < this.core * 1.1){
        addScore(TYPES[o.type].sc, o.x, o.y);
        o.dead = true;
        burst(o.x, o.y, TYPES[o.type].c, '#ffffff', o.r, 16);
        Snd.tone({type:'sine', f0:600, f1:60, dur:0.25, vol:0.14});
      }
    }
    for (const b of bullets)
      if (!b.dead && dist(b.x, b.y, this.x, this.y) < this.core) b.dead = true;
    for (const s of shards)
      if (!s.dead && dist(s.x, s.y, this.x, this.y) < this.core) s.dead = true;
    if (ship && !ship.dead && ship.invuln <= 0 && dist(ship.x, ship.y, this.x, this.y) < this.core * 0.8) ship.kill();

    if (Math.random() < dt * 90){
      const a = rand(0, TAU), d = rand(this.core, this.R);
      addParticle(this.x + Math.cos(a)*d, this.y + Math.sin(a)*d,
        -Math.cos(a) * 280 + Math.cos(a + 1.57) * 200, -Math.sin(a) * 280 + Math.sin(a + 1.57) * 200,
        rand(1.5, 3.4), rand(0.4, 0.9), pick(['#a24bff','#e7c6ff','#ffffff']));
    }
    if (this.t > this.life){
      this.dead = true;
      effects.push(new Shockwave(this.x, this.y, 300, '#a24bff'));
      addShake(0.7);
      Snd.noise({filter:'bandpass', f0:120, f1:3000, dur:0.5, vol:0.3});
    }
  }
  field(e, dt, kind){
    const [dx, dy] = dxy(e.x, e.y, this.x, this.y);
    const d = Math.hypot(dx, dy);
    if (d > this.R || d < 1) return;
    const fall = 1 - d / this.R;
    const k = kind === 'bullet' ? 2100 : kind === 'ship' ? 900 : 1100;
    const f = k * fall * fall * dt;
    e.vx += (dx / d) * f; e.vy += (dy / d) * f;
    // tangential swirl, so things spiral rather than fall straight in
    e.vx += (-dy / d) * f * 0.55; e.vy += (dx / d) * f * 0.55;
  }
  draw(g){
    const fade = clamp(Math.min(this.t * 4, (this.life - this.t) * 3), 0, 1);
    drawGlow(g, this.x, this.y, this.R * 0.7, '#7a1fff', 0.18 * fade);
    g.save(); g.translate(this.x, this.y); g.rotate(this.t * 2.2);
    for (let i = 0; i < 30; i++){
      const a = i / 30 * TAU;
      g.beginPath(); g.arc(0, 0, this.core * 1.5, a, a + 0.16);
      g.strokeStyle = hexA(i % 2 ? '#e7c6ff' : '#a24bff', (0.25 + 0.7 * Math.abs(Math.sin(a * 3 + this.t * 4))) * fade);
      g.lineWidth = this.core * 0.5; g.stroke();
    }
    g.restore();
    const prevOp = g.globalCompositeOperation;
    g.globalCompositeOperation = 'source-over';
    g.beginPath(); g.arc(this.x, this.y, this.core * fade, 0, TAU);
    g.fillStyle = '#000'; g.fill();
    g.strokeStyle = hexA('#ffffff', 0.85 * fade); g.lineWidth = 1.6; g.stroke();
    g.globalCompositeOperation = prevOp;
  }
}

class TimeBubble {
  constructor(x, y){ this.x = x; this.y = y; this.t = 0; this.life = 7.5; this.R = 195; this.dead = false; }
  update(dt){
    this.t += dt;
    if (this.t > this.life) this.dead = true;
    if (Math.random() < dt * 18){
      const a = rand(0, TAU), d = rand(0, this.R);
      addParticle(this.x + Math.cos(a)*d, this.y + Math.sin(a)*d, rand(-12,12), rand(-22,-4),
        rand(1.4,2.8), rand(0.9,1.6), '#28e6ff');
    }
  }
  draw(g){
    const fade = clamp(Math.min(this.t * 3, (this.life - this.t) * 2), 0, 1);
    const rr = this.R * (this.t < 0.3 ? this.t / 0.3 : 1);
    drawGlow(g, this.x, this.y, rr, '#28e6ff', 0.10 * fade);
    g.beginPath(); g.arc(this.x, this.y, rr, 0, TAU);
    g.fillStyle = hexA('#28e6ff', 0.05 * fade); g.fill();
    g.strokeStyle = hexA('#a8f2ff', 0.5 * fade); g.lineWidth = 1.8; g.stroke();
    for (let i = 0; i < 2; i++){
      const ph = ((this.t * 0.35) + i / 2) % 1;
      g.beginPath(); g.arc(this.x, this.y, rr * (0.3 + ph * 0.7), 0, TAU);
      g.strokeStyle = hexA('#d3f8ff', 0.22 * (1 - ph) * fade); g.lineWidth = 1; g.stroke();
    }
    // slow clock in the middle
    g.save(); g.translate(this.x, this.y);
    g.beginPath(); g.arc(0, 0, 15, 0, TAU);
    g.strokeStyle = hexA('#d3f8ff', 0.45 * fade); g.lineWidth = 1.4; g.stroke();
    g.beginPath(); g.moveTo(0,0);
    g.lineTo(Math.cos(this.t * 1.1 - 1.57) * 11, Math.sin(this.t * 1.1 - 1.57) * 11);
    g.strokeStyle = hexA('#ffffff', 0.6 * fade); g.lineWidth = 1.6; g.stroke();
    g.restore();
  }
}

class Shockwave {
  constructor(x, y, maxR = 380, color = '#ff2f6e'){
    this.x = x; this.y = y; this.r = 10; this.maxR = maxR;
    this.color = color; this.hit = new Set(); this.dead = false;
  }
  update(dt){
    const prev = this.r;
    this.r += 780 * dt;
    if (this.r > this.maxR) this.dead = true;
    for (const o of orbs){
      if (o.dead || this.hit.has(o)) continue;
      const d = dist(o.x, o.y, this.x, this.y);
      if (d <= this.r && d >= prev - o.r - 20){
        this.hit.add(o);
        const [dx, dy] = dxy(this.x, this.y, o.x, o.y);
        const dd = Math.max(1, Math.hypot(dx, dy));
        o.vx += dx / dd * 200; o.vy += dy / dd * 200;
        hitOrb(o);
      }
    }
    for (const s of shards){
      if (s.dead || this.hit.has(s)) continue;
      const d = dist(s.x, s.y, this.x, this.y);
      if (d <= this.r){ this.hit.add(s); s.dead = true; addScore(25, s.x, s.y); }
    }
    if (ship && !ship.dead){
      const d = dist(ship.x, ship.y, this.x, this.y);
      if (Math.abs(d - this.r) < 30){
        const [dx, dy] = dxy(this.x, this.y, ship.x, ship.y);
        const dd = Math.max(1, Math.hypot(dx, dy));
        ship.vx += dx / dd * 620 * 0.016; ship.vy += dy / dd * 620 * 0.016;
      }
    }
  }
  draw(g){
    const p = this.r / this.maxR;
    g.beginPath(); g.arc(this.x, this.y, this.r, 0, TAU);
    g.strokeStyle = hexA(this.color, (1 - p) * 0.9); g.lineWidth = 10 * (1 - p) + 1.5; g.stroke();
    g.beginPath(); g.arc(this.x, this.y, this.r * 0.86, 0, TAU);
    g.strokeStyle = hexA('#ffffff', (1 - p) * 0.5); g.lineWidth = 2; g.stroke();
  }
}

class Detonation {
  constructor(x, y, R, delay){
    this.x = x; this.y = y; this.R = R; this.delay = delay;
    this.t = 0; this.fired = false; this.dead = false;
  }
  update(dt){
    this.t += dt;
    if (!this.fired && this.t >= this.delay){
      this.fired = true;
      addShake(0.45);
      burst(this.x, this.y, '#ff6a00', '#ffdda8', this.R * 0.25, 40);
      spawnRing(this.x, this.y, this.R, '#ff6a00', 0.6);
      for (const o of orbs){
        if (o.dead) continue;
        if (dist(o.x, o.y, this.x, this.y) < this.R){
          const [dx, dy] = dxy(this.x, this.y, o.x, o.y);
          const dd = Math.max(1, Math.hypot(dx, dy));
          o.vx += dx / dd * 160; o.vy += dy / dd * 160;
          hitOrb(o);
        }
      }
      for (const s of shards)
        if (!s.dead && dist(s.x, s.y, this.x, this.y) < this.R){ s.dead = true; addScore(25, s.x, s.y); }
      if (ship && !ship.dead && dist(ship.x, ship.y, this.x, this.y) < this.R * 0.55) ship.kill();
    }
    if (this.t > this.delay + 0.45) this.dead = true;
  }
  draw(g){
    if (!this.fired) return;
    const p = clamp((this.t - this.delay) / 0.45, 0, 1);
    drawGlow(g, this.x, this.y, this.R * (0.4 + p * 0.7), '#ff6a00', (1 - p) * 0.55);
  }
}

class Ripple {
  constructor(x, y, maxR, color, str){
    this.x = x; this.y = y; this.r = 2; this.maxR = maxR;
    this.color = color; this.str = str; this.dead = false;
  }
  update(dt){ this.r += (this.maxR * 2.4) * dt; if (this.r > this.maxR) this.dead = true; }
  draw(g){
    const p = this.r / this.maxR;
    g.beginPath(); g.arc(this.x, this.y, this.r, 0, TAU);
    g.strokeStyle = hexA(this.color, (1 - p) * this.str); g.lineWidth = 3 * (1 - p) + 0.6; g.stroke();
  }
}
function spawnRing(x, y, r, color, str){ effects.push(new Ripple(x, y, r, color, str)); }

/* Every effect that exerts a force gets applied here. */
function applyFields(e, dt, kind){
  for (let i = 0; i < effects.length; i++){
    const f = effects[i];
    if (f.field) f.field(e, dt, kind);
  }
}
/* Time-bubble slow factor at a point. */
function slowAt(x, y, isShip){
  let f = 1;
  for (let i = 0; i < bubbles.length; i++){
    const b = bubbles[i];
    if (dist(x, y, b.x, b.y) < b.R) f = Math.min(f, isShip ? 0.82 : 0.3);
  }
  return f;
}

/* ------------------------------------------------------------ particles --- */
function addParticle(x, y, vx, vy, r, life, color){
  if (particles.length >= PARTICLE_CAP) particles.shift();
  particles.push({ x, y, vx, vy, r, life, max:life, color, dead:false });
}
function burst(x, y, c1, c2, size, n){
  n = n || Math.round(14 + size * 0.7);
  for (let i = 0; i < n; i++){
    const a = rand(0, TAU), s = rand(50, 90 + size * 9);
    addParticle(x, y, Math.cos(a)*s, Math.sin(a)*s, rand(1.6, 2 + size*0.13),
      rand(0.3, 0.85), Math.random() < 0.55 ? c1 : c2);
  }
}
function updateParticles(dt){
  for (let i = particles.length - 1; i >= 0; i--){
    const p = particles[i];
    applyFields(p, dt, 'particle');
    const tf = slowAt(p.x, p.y);
    p.x += p.vx * dt * tf; p.y += p.vy * dt * tf;
    const drag = Math.pow(0.22, dt);
    p.vx *= drag; p.vy *= drag;
    p.life -= dt;
    if (p.life <= 0) particles.splice(i, 1);
  }
}
function drawParticles(g){
  g.globalCompositeOperation = 'lighter';
  for (let i = 0; i < particles.length; i++){
    const p = particles[i];
    const t = p.life / p.max;
    drawGlow(g, p.x, p.y, p.r * (1 + (1 - t) * 2.4), p.color, t * 0.85);
  }
  g.globalCompositeOperation = 'source-over';
}

/* ------------------------------------------------------------- floaters --- */
class Floater {
  constructor(x, y, text, color, life, size){
    this.x = x; this.y = y; this.text = text; this.color = color;
    this.life = life || 0.9; this.max = this.life; this.size = size || 13; this.dead = false;
  }
  update(dt){ this.y -= 34 * dt; this.life -= dt; if (this.life <= 0) this.dead = true; }
  draw(g){
    const t = this.life / this.max;
    g.globalAlpha = clamp(t * 1.6, 0, 1);
    g.font = '700 ' + this.size + 'px ui-monospace, Menlo, monospace';
    g.textAlign = 'center';
    g.fillStyle = this.color;
    g.fillText(this.text, this.x, this.y);
    g.globalAlpha = 1;
    g.textAlign = 'left';
  }
}

/* ---------------------------------------------------------------- stars --- */
function makeStars(){
  stars = [];
  const n = Math.round((W * H) / 9000);
  for (let i = 0; i < n; i++){
    stars.push({ x: rand(0, W), y: rand(0, H), r: rand(0.4, 1.5), z: rand(0.15, 1), tw: rand(0, TAU) });
  }
}
function drawStars(g, dt){
  for (let i = 0; i < stars.length; i++){
    const s = stars[i];
    s.x -= s.z * 7 * dt;
    if (s.x < 0) { s.x += W; s.y = rand(0, H); }
    const a = 0.22 + 0.5 * s.z * (0.6 + 0.4 * Math.sin(elapsed * 1.6 + s.tw));
    g.fillStyle = 'rgba(180,205,255,' + a.toFixed(3) + ')';
    g.fillRect(s.x, s.y, s.r, s.r);
  }
}

/* ---------------------------------------------------------- game control -- */
function hitOrb(o){
  if (o.dead) return;
  const T = TYPES[o.type];
  o.flash = 0.13;
  o.hp--;
  burst(o.x, o.y, T.c, T.c2, o.r * 0.4, 8);
  if (o.hp > 0){
    if (T.hurt) T.hurt(o);
    else Snd.tone({type:'square', f0:700, f1:400, dur:0.06, vol:0.07});
    addScore(20, o.x, o.y, false);
    return;
  }
  destroyOrb(o);
}
function destroyOrb(o){
  if (o.dead) return;
  o.dead = true;
  const T = TYPES[o.type];
  burst(o.x, o.y, T.c, T.c2, o.r);
  spawnRing(o.x, o.y, o.r * 3.4, T.c, 0.55);
  addShake(clamp(o.r / 90, 0.08, 0.5));
  if (T.sfx) T.sfx(o);
  combo++; comboTimer = 2.4;
  addScore(Math.round(T.sc * (TIER_SCALE[o.tier] || 1)), o.x, o.y);
  if (T.destroy) T.destroy(o);
}
function addScore(v, x, y, showFloat = true){
  const m = comboMult();
  const gained = v * m;
  score += gained;
  if (showFloat && x != null){
    floaters.push(new Floater(x, y - 14, (m > 1 ? '+' + gained + ' ×' + m : '+' + gained),
      m > 1 ? '#ffd75e' : '#cfe4ff', 0.9, m > 1 ? 15 : 12));
  }
  grantExtraLives();
}
function grantExtraLives(){
  let n = 0;
  while (score >= nextExtra && n < 20){ nextExtra += 5000; lives++; n++; }
  if (n){
    floaters.push(new Floater(ship ? ship.x : W/2, (ship ? ship.y : H/2) - 40,
      n > 1 ? 'EXTRA LIFE ×' + n : 'EXTRA LIFE', '#38f5d0', 1.8, 20));
    Snd.extraLife();
  }
}

function spawnWave(w){
  const pool = poolFor(w);
  const n = Math.min(22, 3 + Math.round(w * 1.6));
  for (let i = 0; i < n; i++){
    let x, y, tries = 0;
    do {
      if (Math.random() < 0.5){ x = Math.random() < 0.5 ? rand(-20, 40) : rand(W - 40, W + 20); y = rand(0, H); }
      else { x = rand(0, W); y = Math.random() < 0.5 ? rand(-20, 40) : rand(H - 40, H + 20); }
      tries++;
    } while (ship && dist(x, y, ship.x, ship.y) < 240 && tries < 20);
    x = clamp(x, 10, W - 10); y = clamp(y, 10, H - 10);
    const o = new Orb(x, y, pick(pool), 3);
    o.born = 0.5;
    orbs.push(o);
    spawnRing(x, y, 70, TYPES[o.type].c, 0.5);
  }
}

function startGame(){
  Snd.init(); Snd.resume();
  score = 0; lives = 3; wave = 0; nextExtra = 5000;
  orbs = []; bullets = []; shards = []; particles = []; effects = []; floaters = []; bubbles = [];
  combo = 0; comboTimer = 0; reverseT = 0; shake = 0; hitFlash = 0;
  waveTransition = 0; gameOverDelay = 0;
  ship = new Ship();
  state = 'playing';
  showScreen(null);
}
function endGame(){
  state = 'gameover';
  gameOverDelay = 0.8;
  Snd.thrust(false); Snd.gameOver();
  const isBest = score > best;
  if (isBest){ best = score; localStorage.setItem('wildorbs.best', String(best)); }
  document.getElementById('finalScore').textContent = score.toLocaleString();
  document.getElementById('finalWave').textContent = Math.max(1, wave);
  document.getElementById('finalBest').textContent = best.toLocaleString();
  document.getElementById('newbest').style.display = isBest ? 'block' : 'none';
  showScreen('overScreen');
}
function showScreen(id){
  for (const s of ['startScreen','pauseScreen','overScreen'])
    document.getElementById(s).classList.toggle('on', s === id);
  document.querySelector('.foot').style.display = (id === 'startScreen') ? 'block' : 'none';
}

/* ----------------------------------------------------------- collisions -- */
function collide(){
  // bullets vs orbs
  for (let i = bullets.length - 1; i >= 0; i--){
    const b = bullets[i];
    if (b.dead) continue;
    for (let j = 0; j < orbs.length; j++){
      const o = orbs[j];
      if (o.dead || o.born > 0.2) continue;
      if (dist(b.x, b.y, o.x, o.y) < o.r + 3){
        b.dead = true;
        hitOrb(o);
        break;
      }
    }
    if (b.dead) continue;
    // bullets vs shards
    for (let j = 0; j < shards.length; j++){
      const s = shards[j];
      if (s.dead) continue;
      if (dist(b.x, b.y, s.x, s.y) < s.r + 4){
        b.dead = true; s.dead = true;
        addScore(25, s.x, s.y);
        burst(s.x, s.y, '#ff43e0', '#ffd4f7', 10, 12);
        Snd.tone({type:'triangle', f0:1800, f1:700, dur:0.08, vol:0.08});
        break;
      }
    }
  }
  if (!ship || ship.dead) return;
  // ship vs orbs
  for (let j = 0; j < orbs.length; j++){
    const o = orbs[j];
    if (o.dead || o.born > 0.2) continue;
    if (dist(ship.x, ship.y, o.x, o.y) < o.r + ship.r * 0.8){
      if (ship.invuln > 0) continue;
      ship.kill();
      destroyOrb(o);                  // it still does its wild thing on the way out
      break;
    }
  }
  // ship vs shards
  for (let j = 0; j < shards.length; j++){
    const s = shards[j];
    if (s.dead) continue;
    if (dist(ship.x, ship.y, s.x, s.y) < s.r + ship.r * 0.8){
      if (ship.invuln > 0) continue;
      s.dead = true;
      ship.kill();
      break;
    }
  }
}

/* ------------------------------------------------------------- the loop --- */
const keys = {};
let last = performance.now();

function update(dt){
  elapsed += dt;
  if (shake > 0) shake = Math.max(0, shake - dt * 1.9);
  if (hitFlash > 0) hitFlash = Math.max(0, hitFlash - dt * 2.4);

  // cache slow-field sources once per frame
  bubbles = effects.filter(e => e instanceof TimeBubble);

  if (state === 'playing'){
    if (reverseT > 0) reverseT -= dt;
    if (comboTimer > 0){ comboTimer -= dt; if (comboTimer <= 0) combo = 0; }
    ship.update(dt);

    if (waveTransition > 0){
      waveTransition -= dt;
      if (waveTransition <= 0) spawnWave(wave);
    } else if (orbs.length === 0){
      wave++;
      waveTransition = 1.7;
      if (wave > 1){
        const bonus = 250 * (wave - 1);
        score += bonus; grantExtraLives();
        floaters.push(new Floater(W/2, H/2 + 46, 'WAVE CLEAR  +' + bonus, '#38f5d0', 2.0, 17));
      }
      Snd.waveUp();
    }
  } else if (state === 'start'){
    // attract mode: keep a lazy drift of orbs behind the menu
    if (orbs.length < 11 && Math.random() < dt * 5){
      const o = new Orb(rand(0, W), rand(0, H), pick(SPAWNABLE), 3);
      o.vx *= 0.5; o.vy *= 0.5;
      orbs.push(o);
    }
  }

  for (const o of orbs) if (!o.dead) o.update(dt);
  for (const b of bullets) if (!b.dead) b.update(dt);
  for (const s of shards) if (!s.dead) s.update(dt);
  for (const e of effects) if (!e.dead) e.update(dt);
  for (const f of floaters) if (!f.dead) f.update(dt);
  updateParticles(dt);

  if (state === 'playing') collide();

  if (orbs.length)    orbs    = orbs.filter(o => !o.dead);
  if (bullets.length) bullets = bullets.filter(b => !b.dead);
  if (shards.length)  shards  = shards.filter(s => !s.dead);
  if (effects.length) effects = effects.filter(e => !e.dead);
  if (floaters.length)floaters= floaters.filter(f => !f.dead);

  if (gameOverDelay > 0) gameOverDelay -= dt;
  Snd.music();
}

function render(dt){
  // motion-trail fade instead of a hard clear
  ctx.fillStyle = 'rgba(5,6,14,0.30)';
  ctx.fillRect(0, 0, W, H);

  ctx.save();
  if (shake > 0.001){
    const s = shake * shake;
    ctx.translate(rand(-1,1) * s * 16, rand(-1,1) * s * 16);
    ctx.rotate(rand(-1,1) * s * 0.014);
  }

  drawStars(ctx, dt);

  ctx.globalCompositeOperation = 'lighter';
  for (const e of effects) e.draw(ctx);
  ctx.globalCompositeOperation = 'source-over';

  for (const o of orbs) o.draw(ctx);
  for (const s of shards) s.draw(ctx);

  ctx.globalCompositeOperation = 'lighter';
  for (const b of bullets) b.draw(ctx);
  ctx.globalCompositeOperation = 'source-over';

  if (ship && state !== 'start') ship.draw(ctx);
  drawParticles(ctx);
  for (const f of floaters) f.draw(ctx);

  ctx.restore();

  // reversed-controls warning tint
  if (reverseT > 0){
    const p = (0.10 + 0.06 * Math.sin(elapsed * 9)) * clamp(reverseT, 0, 1);
    const g = ctx.createRadialGradient(W/2, H/2, Math.min(W,H) * 0.25, W/2, H/2, Math.max(W,H) * 0.72);
    g.addColorStop(0, 'rgba(255,154,31,0)');
    g.addColorStop(1, 'rgba(255,154,31,' + p.toFixed(3) + ')');
    ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
  }
  if (hitFlash > 0){
    ctx.fillStyle = 'rgba(255,255,255,' + (hitFlash * 0.32).toFixed(3) + ')';
    ctx.fillRect(0, 0, W, H);
  }

  if (state === 'playing' || state === 'paused') drawHUD();
  if (state === 'start') drawChips();
}

/* ------------------------------------------------------------------ HUD --- */
function shipGlyph(g, x, y, s){
  g.save(); g.translate(x, y); g.scale(s, s);
  g.beginPath(); g.moveTo(8, 0); g.lineTo(-6, 5.5); g.lineTo(-3, 0); g.lineTo(-6, -5.5);
  g.closePath();
  g.fillStyle = '#d8fbff'; g.fill();
  g.restore();
}
function drawHUD(){
  const g = ctx;
  g.font = '700 13px ui-monospace, Menlo, monospace';
  g.textAlign = 'left';

  g.fillStyle = 'rgba(150,170,205,.55)';
  g.fillText('SCORE', 22, 30);
  g.font = '700 26px ui-monospace, Menlo, monospace';
  g.fillStyle = '#ffffff';
  g.fillText(score.toLocaleString(), 22, 58);

  g.font = '700 11px ui-monospace, Menlo, monospace';
  g.fillStyle = 'rgba(150,170,205,.5)';
  g.fillText('BEST ' + Math.max(best, score).toLocaleString(), 22, 76);

  for (let i = 0; i < Math.min(lives, 8); i++) shipGlyph(g, 30 + i * 20, 96, 1.15);
  if (lives > 8){
    g.fillStyle = '#d8fbff';
    g.fillText('+' + (lives - 8), 30 + 8 * 20, 100);
  }

  // wave + combo, right side
  g.textAlign = 'right';
  g.font = '700 11px ui-monospace, Menlo, monospace';
  g.fillStyle = 'rgba(150,170,205,.55)';
  g.fillText('WAVE', W - 22, 30);
  g.font = '700 26px ui-monospace, Menlo, monospace';
  g.fillStyle = '#38f5d0';
  g.fillText(String(Math.max(1, wave)), W - 22, 58);

  const m = comboMult();
  if (m > 1){
    g.font = '700 15px ui-monospace, Menlo, monospace';
    g.fillStyle = '#ffd75e';
    g.fillText('COMBO ×' + m, W - 22, 82);
    const wpx = 90, pct = clamp(comboTimer / 2.4, 0, 1);
    g.fillStyle = 'rgba(255,215,94,.22)'; g.fillRect(W - 22 - wpx, 90, wpx, 3);
    g.fillStyle = '#ffd75e'; g.fillRect(W - 22 - wpx, 90, wpx * pct, 3);
  }
  g.textAlign = 'left';

  // status banner
  if (reverseT > 0){
    g.textAlign = 'center';
    g.font = '700 15px ui-monospace, Menlo, monospace';
    g.fillStyle = 'rgba(255,154,31,' + (0.55 + 0.45 * Math.sin(elapsed * 8)).toFixed(2) + ')';
    g.fillText('<-> CONTROLS REVERSED  ' + reverseT.toFixed(1) + 's', W/2, H - 34);
    g.textAlign = 'left';
  }

  // wave banner
  if (waveTransition > 0){
    const p = 1 - Math.abs(waveTransition / 1.7 - 0.5) * 2;
    g.save();
    g.globalAlpha = clamp(p * 1.8, 0, 1);
    g.textAlign = 'center';
    g.font = '800 ' + Math.round(clamp(W * 0.07, 28, 64)) + 'px ui-monospace, Menlo, monospace';
    g.fillStyle = '#ffffff';
    g.fillText('WAVE ' + wave, W/2, H/2 - 6);
    g.font = '700 13px ui-monospace, Menlo, monospace';
    g.fillStyle = '#38f5d0';
    const nu = UNLOCKS[wave - 1];
    g.fillText(nu ? 'NEW: ' + nu.map(k => TYPES[k].name).join('  ·  ') : 'INCOMING', W/2, H/2 + 26);
    g.restore();
    g.textAlign = 'left';
  }

  if (Snd.muted){
    g.font = '700 10px ui-monospace, Menlo, monospace';
    g.fillStyle = 'rgba(150,170,205,.45)';
    g.textAlign = 'center';
    g.fillText('MUTED', W/2, 24);
    g.textAlign = 'left';
  }
}

/* ------------------------------------------------- start-screen bestiary -- */
const chips = [];
function buildBestiary(){
  const host = document.getElementById('bestiary');
  host.innerHTML = '';
  for (const key of SPAWNABLE){
    const T = TYPES[key];
    const el = document.createElement('div');
    el.className = 'chip';
    const cv = document.createElement('canvas');
    cv.width = cv.height = 34 * 2; cv.style.width = cv.style.height = '34px';
    const txt = document.createElement('div');
    txt.innerHTML = '<div class="n" style="color:' + T.c + '">' + T.name + '</div>' +
                    '<div class="d">' + T.blurb + '</div>';
    el.appendChild(cv); el.appendChild(txt);
    host.appendChild(el);
    const g = cv.getContext('2d');
    g.setTransform(2, 0, 0, 2, 0, 0);
    chips.push({ g, key });
  }
}
function drawChips(){
  for (const c of chips){
    c.g.clearRect(0, 0, 40, 40);
    c.g.save();
    c.g.translate(17, 17);
    c.g.globalCompositeOperation = 'lighter';
    drawGlow(c.g, 0, 0, 17, TYPES[c.key].c, 0.4);
    c.g.globalCompositeOperation = 'source-over';
    drawOrbBody(c.g, c.key, 12, elapsed, null);
    c.g.restore();
  }
}

/* ----------------------------------------------------------------- input -- */
const PREVENT = ['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'];
addEventListener('keydown', e => {
  if (PREVENT.includes(e.code)) e.preventDefault();
  keys[e.code] = true;
  if (e.repeat) return;
  Snd.init(); Snd.resume();

  if (e.code === 'KeyM'){ Snd.toggleMute(); return; }

  if (state === 'start'){
    if (e.code === 'Space' || e.code === 'Enter') startGame();
  } else if (state === 'gameover'){
    if ((e.code === 'Space' || e.code === 'Enter') && gameOverDelay <= 0) startGame();
  } else if (state === 'playing'){
    if (e.code === 'KeyP' || e.code === 'Escape'){ state = 'paused'; Snd.thrust(false); showScreen('pauseScreen'); }
  } else if (state === 'paused'){
    if (e.code === 'KeyP' || e.code === 'Escape' || e.code === 'Space'){ state = 'playing'; showScreen(null); }
  }
});
addEventListener('keyup', e => { keys[e.code] = false; });
addEventListener('blur', () => { for (const k in keys) keys[k] = false; Snd.thrust(false); });
addEventListener('pointerdown', () => { Snd.init(); Snd.resume(); });

/* ------------------------------------------------------------------ boot -- */
function frame(now){
  let dt = (now - last) / 1000;
  last = now;
  if (dt > 0.05) dt = 0.05;                 // never let a stall teleport anything
  if (dt < 0) dt = 0;
  if (state !== 'paused') update(dt);
  render(dt);
  requestAnimationFrame(frame);
}

resize();
buildBestiary();
ctx.fillStyle = '#05060e'; ctx.fillRect(0, 0, W, H);
requestAnimationFrame(frame);
</script>
</body>
</html>