← 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 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>←</kbd><kbd>↑</kbd><kbd>→</kbd> fly ·
<kbd>SPACE</kbd> fire · <kbd>P</kbd> pause · <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>