← back to Wild Orbs Opus
index.html
2825 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
<meta name="theme-color" content="#04030a">
<meta name="description" content="Wild Orbs — an Asteroids-style arcade game where every orb breaks differently.">
<title>WILD ORBS — every orb breaks differently</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
html,body {
width:100%; height:100%; overflow:hidden; background:#04030a;
font-family: "Trebuchet MS", "Segoe UI", system-ui, sans-serif;
color:#dfe9ff; user-select:none; -webkit-user-select:none; touch-action:none;
overscroll-behavior:none;
}
#wrap { position:fixed; inset:0; display:block; }
canvas { display:block; width:100%; height:100%; touch-action:none; }
.overlay {
position:absolute; inset:0; display:flex; flex-direction:column;
align-items:center; justify-content:center; text-align:center;
background: radial-gradient(ellipse at 50% 40%, rgba(30,10,60,.62), rgba(2,2,8,.9) 72%);
-webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px);
z-index:10; padding:22px 18px; overflow-y:auto;
animation: fadein .22s ease;
}
@keyframes fadein { from { opacity:0 } to { opacity:1 } }
.hidden { display:none !important; }
h1 {
font-size: clamp(34px, 8.4vw, 88px);
letter-spacing: .14em; font-weight:900; line-height:1.04;
background: linear-gradient(180deg,#fff 0%,#8ef7ff 40%,#c56bff 75%,#ff4fd8 100%);
-webkit-background-clip:text; background-clip:text; color:transparent;
filter: drop-shadow(0 0 26px rgba(140,90,255,.5));
margin-bottom:2px;
}
.sub { font-size: clamp(10px,2.1vw,14px); letter-spacing:.42em; color:#8fa4d8; margin-bottom:18px; text-transform:uppercase; }
.panel {
max-width: 900px; width:100%;
border:1px solid rgba(120,170,255,.20);
background: rgba(10,10,26,.55);
border-radius:14px; padding:14px 16px; margin-bottom:14px;
box-shadow: 0 0 40px rgba(80,40,160,.22) inset;
}
.panel-title { letter-spacing:.3em; font-size:11px; color:#8fa4d8; margin-bottom:9px; text-transform:uppercase; }
.keys { display:flex; gap:12px 16px; flex-wrap:wrap; justify-content:center; }
.key { display:flex; align-items:center; gap:7px; font-size:12.5px; color:#b9c8ef; white-space:nowrap; }
kbd {
display:inline-block; min-width:24px; padding:3px 7px; font-size:10.5px; font-family:inherit;
background:#171a33; border:1px solid #39406e; border-bottom-width:3px; border-radius:5px; color:#dce6ff;
}
.orbgrid {
display:grid; grid-template-columns:repeat(auto-fill,minmax(196px,1fr));
gap:8px 12px; text-align:left;
}
.orbrow { display:flex; gap:9px; align-items:flex-start; font-size:11.5px; line-height:1.34; color:#a3b3dd; }
.dot { width:15px; height:15px; border-radius:50%; flex:0 0 15px; margin-top:2px; box-shadow:0 0 10px currentColor; }
.orbrow b { color:#fff; display:block; font-size:11.5px; letter-spacing:.05em; }
.tips { display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:6px 16px; text-align:left; font-size:12px; color:#93a3ce; }
.tips span b { color:#cfe0ff; }
.btnrow { display:flex; gap:12px; flex-wrap:wrap; justify-content:center; align-items:center; }
.btn {
font-family:inherit; font-weight:800; letter-spacing:.2em; font-size:15px;
padding:13px 38px; border-radius:40px; cursor:pointer; text-transform:uppercase;
color:#04030a; border:none; -webkit-tap-highlight-color:transparent;
background:linear-gradient(135deg,#7df9ff,#b06bff 55%,#ff5ec7);
box-shadow:0 0 26px rgba(160,90,255,.55), 0 6px 0 rgba(0,0,0,.35);
transition: transform .08s ease, box-shadow .2s ease;
}
.btn:hover { transform:translateY(-2px); box-shadow:0 0 40px rgba(180,110,255,.8), 0 8px 0 rgba(0,0,0,.35); }
.btn:active { transform:translateY(2px); box-shadow:0 0 20px rgba(160,90,255,.6), 0 2px 0 rgba(0,0,0,.35); }
.btn.ghost {
background:transparent; color:#b9c8ef; border:1px solid rgba(140,170,255,.35);
box-shadow:none; font-size:12px; padding:11px 24px; letter-spacing:.18em;
}
.btn.ghost:hover { color:#fff; border-color:rgba(180,210,255,.7); transform:translateY(-1px); box-shadow:0 0 18px rgba(120,90,220,.4); }
.hint { margin-top:12px; font-size:11.5px; color:#67739c; letter-spacing:.14em; }
#goTitle { font-size: clamp(30px,7vw,70px); }
.statline { font-size:14px; color:#9fb1dd; letter-spacing:.16em; }
.bigscore { font-size: clamp(30px,7vw,58px); font-weight:900; color:#fff; text-shadow:0 0 26px rgba(120,220,255,.7); line-height:1.1; }
.newbest { color:#ffd76b; font-size:13px; letter-spacing:.3em; min-height:16px; text-shadow:0 0 16px rgba(255,180,60,.7); }
.statgrid { display:grid; grid-template-columns:repeat(auto-fit,minmax(104px,1fr)); gap:10px 8px; }
.stat { text-align:center; }
.stat .v { font-size:20px; font-weight:900; color:#eaf3ff; }
.stat .k { font-size:9.5px; letter-spacing:.22em; color:#7787b3; text-transform:uppercase; margin-top:2px; }
.flashmsg { font-size:12.5px; color:#ffd76b; letter-spacing:.16em; min-height:16px; margin-top:8px; }
.toggles { display:flex; gap:10px; flex-wrap:wrap; justify-content:center; font-size:11px; letter-spacing:.16em; color:#8b9bc8; }
.tg { border:1px solid rgba(140,170,255,.25); border-radius:20px; padding:7px 14px; cursor:pointer; }
.tg:hover { color:#fff; border-color:rgba(180,210,255,.6); }
.tg b { color:#7df9ff; }
.tg.off b { color:#6b7699; }
@media (max-height: 560px) {
.panel { padding:10px 12px; margin-bottom:9px; }
h1 { font-size: clamp(26px,7vw,52px); }
.sub { margin-bottom:10px; }
#legendPanel { display:none; }
}
</style>
</head>
<body>
<div id="wrap">
<canvas id="cv"></canvas>
<!-- ============ START SCREEN ============ -->
<div id="startScreen" class="overlay">
<h1>WILD ORBS</h1>
<div class="sub">every orb breaks differently</div>
<div class="panel">
<div class="keys">
<span class="key"><kbd>←</kbd><kbd>→</kbd>/<kbd>A</kbd><kbd>D</kbd> rotate</span>
<span class="key"><kbd>↑</kbd>/<kbd>W</kbd> thrust</span>
<span class="key"><kbd>SPACE</kbd> fire</span>
<span class="key"><kbd>SHIFT</kbd> pulse bomb</span>
<span class="key"><kbd>H</kbd> hyperspace</span>
<span class="key"><kbd>P</kbd> pause</span>
<span class="key"><kbd>M</kbd> mute</span>
</div>
<div style="font-size:11.5px;color:#7d8cb8;letter-spacing:.08em;margin-top:9px">
Screen wraps. Inertia is real. Touch: left thumb steers & thrusts, right side fires.
</div>
</div>
<div class="panel" id="legendPanel">
<div class="panel-title">The thirteen wild orbs</div>
<div class="orbgrid" id="orbLegend"></div>
</div>
<div class="panel">
<div class="panel-title">How to survive</div>
<div class="tips">
<span><b>Know your target.</b> Some orbs punish the shot more than the miss.</span>
<span><b>Chain kills.</b> Fast consecutive breaks build a score multiplier.</span>
<span><b>Grab drops.</b> Broken orbs sometimes cough up spread, rapid, shields.</span>
<span><b>Save a pulse.</b> SHIFT clears the room when the swarm closes in.</span>
</div>
</div>
<div class="btnrow">
<button class="btn" id="startBtn">Launch</button>
</div>
<div class="hint">press SPACE or ENTER</div>
</div>
<!-- ============ GAME OVER ============ -->
<div id="overScreen" class="overlay hidden">
<h1 id="goTitle">GAME OVER</h1>
<div class="statline">FINAL SCORE</div>
<div class="bigscore" id="finalScore">0</div>
<div class="newbest" id="newBest"></div>
<div class="panel" style="max-width:680px;margin-top:10px">
<div class="statgrid">
<div class="stat"><div class="v" id="stWave">1</div><div class="k">Wave</div></div>
<div class="stat"><div class="v" id="stOrbs">0</div><div class="k">Orbs broken</div></div>
<div class="stat"><div class="v" id="stCombo">0</div><div class="k">Best chain</div></div>
<div class="stat"><div class="v" id="stAcc">0%</div><div class="k">Accuracy</div></div>
<div class="stat"><div class="v" id="stTime">0:00</div><div class="k">Survived</div></div>
</div>
<div class="flashmsg" id="finalMsg"></div>
<div class="statline" id="finalBest" style="font-size:12px;margin-top:6px">BEST 0</div>
</div>
<div class="btnrow">
<button class="btn" id="againBtn">Again</button>
<button class="btn ghost" id="menuBtn">Main menu</button>
</div>
<div class="hint">press SPACE or ENTER</div>
</div>
<!-- ============ PAUSE ============ -->
<div id="pauseScreen" class="overlay hidden">
<h1 style="font-size:clamp(28px,6.4vw,58px)">PAUSED</h1>
<div class="panel" style="max-width:560px">
<div class="toggles">
<div class="tg" id="tgSound">SOUND <b id="tgSoundV">ON</b></div>
<div class="tg" id="tgShake">SCREEN SHAKE <b id="tgShakeV">ON</b></div>
</div>
</div>
<div class="panel" style="max-width:900px">
<div class="panel-title">Orb bestiary</div>
<div class="orbgrid" id="orbLegend2"></div>
</div>
<div class="btnrow">
<button class="btn" id="resumeBtn">Resume</button>
<button class="btn ghost" id="quitBtn">Quit run</button>
</div>
<div class="hint">press P to resume</div>
</div>
</div>
<script>
"use strict";
/* ============================================================
WILD ORBS — single-file canvas arcade
Asteroids, except every orb detonates into a different disaster.
============================================================ */
/* ---------------- canvas + sizing ---------------- */
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d', { alpha:false });
const bgc = document.createElement('canvas'); // pre-rendered background
const bgx = bgc.getContext('2d');
let W = 0, H = 0, DPR = 1;
/* This game is fill-rate bound, not CPU bound: at devicePixelRatio 2 every
full-screen pass costs 4x. Rather than drop frames on weaker hardware we
scale the backing store and keep the motion at 60fps. */
let renderScale = 1, scaleCooldown = 0;
function applyBackingStore(){
const base = Math.min(window.devicePixelRatio || 1, 2);
DPR = clamp(base * renderScale, 0.6, 2);
cv.width = Math.max(1, Math.floor(W * DPR));
cv.height = Math.max(1, Math.floor(H * DPR));
cv.style.width = W + 'px';
cv.style.height = H + 'px';
ctx.setTransform(DPR,0,0,DPR,0,0);
bgc.width = cv.width; bgc.height = cv.height;
bgx.setTransform(DPR,0,0,DPR,0,0);
paintBackground();
buildScreenGrads();
}
function resize(){
W = Math.max(320, window.innerWidth);
H = Math.max(320, window.innerHeight);
buildStars();
applyBackingStore();
}
/** nudge the backing-store scale toward whatever holds ~60fps (with hysteresis) */
function adaptResolution(){
// A recovery step sets a deliberately long cooldown so the resolution can't
// oscillate. But the cooldown is shared, so a recent upscale used to lock out
// an urgently-needed DOWNSCALE for ~10s — the game sat at 29fps refusing to
// shed pixels. A clearly-dropping framerate now bypasses the cooldown.
const urgent = frameAvg > 30;
if(scaleCooldown>0 && !urgent){ scaleCooldown--; return; }
if(frameAvg > 23 && renderScale > 0.55){
renderScale = Math.max(0.55, renderScale - 0.15);
// Seed just UNDER the trigger, not down at a healthy 16.7: reseeding to a
// good value made the average re-climb from scratch after every step, so
// converging took ~9s of visible jank. Now a couple of still-slow frames
// step again immediately. Recovery stays deliberately slow (below).
scaleCooldown = 24; frameAvg = 21; applyBackingStore();
} else if(frameAvg < 13.5 && renderScale < 1){
renderScale = Math.min(1, renderScale + 0.1);
scaleCooldown = 300; frameAvg = 16.7; applyBackingStore();
}
}
window.addEventListener('resize', resize);
window.addEventListener('orientationchange', ()=> setTimeout(resize, 120));
/* ---------------- math helpers ---------------- */
const TAU = Math.PI*2;
const rnd = (a=1,b=0)=> b + Math.random()*(a-b);
const rndi = (a,b)=> Math.floor(rnd(a,b));
const pick = arr => arr[(Math.random()*arr.length)|0];
const clamp= (v,a,b)=> v<a?a:(v>b?b:v);
const lerp = (a,b,t)=> a+(b-a)*t;
function wrapPos(o){
const r = o.r || 0;
if(o.x < -r) o.x += W + r*2;
else if(o.x > W + r) o.x -= W + r*2;
if(o.y < -r) o.y += H + r*2;
else if(o.y > H + r) o.y -= H + r*2;
}
/** shortest delta from (x1,y1) to (x2,y2) across the wrapping playfield */
function wrapDelta(x1,y1,x2,y2){
let dx = x2-x1, dy = y2-y1;
if(dx > W/2) dx -= W; else if(dx < -W/2) dx += W;
if(dy > H/2) dy -= H; else if(dy < -H/2) dy += H;
return [dx,dy];
}
function wrapDist(a,b){ const [dx,dy]=wrapDelta(a.x,a.y,b.x,b.y); return Math.hypot(dx,dy); }
/** turn a wrapped delta + force magnitude into a velocity nudge on entity.
* dx,dy,d must already be the wrapDelta/hypot to the field; f is the
* caller's own (already strength+falloff scaled) force for this pull site —
* every well/hole/entity pairing keeps its own formula, this just centralizes
* the shared "divide by distance, scale by force, add to velocity" step. */
function applyPull(entity, dx, dy, d, f){
entity.vx += (dx/d)*f;
entity.vy += (dy/d)*f;
}
/** Draw fn once per screen-wrap copy so entities never pop at the edges. */
function wrapDraw(x,y,r,fn){
fn(0,0);
const ox = x < r ? W : (x > W-r ? -W : 0);
const oy = y < r ? H : (y > H-r ? -H : 0);
if(ox){ fn(ox,0); }
if(oy){ fn(0,oy); }
if(ox && oy){ fn(ox,oy); }
}
/* ---------------- color helpers ---------------- */
function hexA(hex,a){
const h = hex.replace('#','');
const n = parseInt(h.length===3? h.split('').map(c=>c+c).join('') : h, 16);
return `rgba(${(n>>16)&255},${(n>>8)&255},${n&255},${a})`;
}
function shade(hex,amt){
const h=hex.replace('#','');
const n=parseInt(h.length===3? h.split('').map(c=>c+c).join(''):h,16);
let r=(n>>16)&255,g=(n>>8)&255,b=n&255;
if(amt<0){ r=Math.round(r*(1+amt)); g=Math.round(g*(1+amt)); b=Math.round(b*(1+amt)); }
else { r=Math.round(r+(255-r)*amt); g=Math.round(g+(255-g)*amt); b=Math.round(b+(255-b)*amt); }
return `rgb(${r},${g},${b})`;
}
/* ------------------------------------------------------------
SPRITE + GRADIENT CACHES
Building a CanvasGradient costs real time, and the naive version of
this game built two per orb per frame (times up to 4 wrap copies).
Radial glows are baked once into small offscreen canvases and blitted;
body gradients are built in LOCAL space so they can be shared by every
orb of the same kind and size.
------------------------------------------------------------ */
const _sprites = new Map();
function sprite(key, size, painter){
let c = _sprites.get(key);
if(c) return c;
c = document.createElement('canvas');
c.width = c.height = size;
painter(c.getContext('2d'), size);
_sprites.set(key, c);
return c;
}
/** soft radial glow, baked small and scaled up at draw time */
function glowSprite(col, a0, a1){
return sprite('glow|'+col+'|'+a0+'|'+a1, 128, (g,S)=>{
const gr = g.createRadialGradient(S/2,S/2,0, S/2,S/2,S/2);
gr.addColorStop(0, hexA(col,a0));
gr.addColorStop(0.45, hexA(col,a1));
gr.addColorStop(1, hexA(col,0));
g.fillStyle = gr; g.fillRect(0,0,S,S);
});
}
/** draw a cached sprite centred on (x,y) with the given radius */
function blit(spr, x, y, r){ ctx.drawImage(spr, x-r, y-r, r*2, r*2); }
const _grads = new Map();
function orbBodyGrad(kind, r){
const key = kind+'|'+(Math.round(r/2)*2);
let g = _grads.get(key);
if(g) return g;
const T = ORB_TYPES[kind];
g = ctx.createRadialGradient(-r*0.34, -r*0.4, r*0.08, 0, 0, r*1.15);
g.addColorStop(0, '#ffffff');
g.addColorStop(0.18, T.c1);
g.addColorStop(0.72, T.c2);
g.addColorStop(1, shade(T.c2,-0.55));
_grads.set(key, g);
return g;
}
function sentryBodyGrad(r){
const key = 'sentry|'+Math.round(r);
let g = _grads.get(key);
if(g) return g;
g = ctx.createRadialGradient(-r*0.3,-r*0.3,2, 0,0, r);
g.addColorStop(0,'#dfe4ff'); g.addColorStop(0.6,'#3b41a4'); g.addColorStop(1,'#171a4a');
_grads.set(key,g);
return g;
}
function bulletSprite(){
return sprite('bullet', 64, (g,S)=>{
const gr = g.createRadialGradient(S/2,S/2,0, S/2,S/2,S/2);
gr.addColorStop(0,'rgba(255,255,255,1)');
gr.addColorStop(0.25,'rgba(150,245,255,.9)');
gr.addColorStop(1,'rgba(90,180,255,0)');
g.fillStyle=gr; g.fillRect(0,0,S,S);
});
}
function enemyShotSprite(){
return sprite('eshot', 64, (g,S)=>{
const gr = g.createRadialGradient(S/2,S/2,0, S/2,S/2,S/2);
gr.addColorStop(0,'rgba(255,255,255,.95)');
gr.addColorStop(0.3,'rgba(180,160,255,.85)');
gr.addColorStop(1,'rgba(120,80,255,0)');
g.fillStyle=gr; g.fillRect(0,0,S,S);
});
}
let vignetteGrad = null, dangerGrad = null, shipHullGrad = null, shipFlameGrad = null;
function buildScreenGrads(){
vignetteGrad = ctx.createRadialGradient(W/2,H/2,Math.min(W,H)*0.34, W/2,H/2, Math.max(W,H)*0.78);
vignetteGrad.addColorStop(0,'rgba(0,0,0,0)');
vignetteGrad.addColorStop(1,'rgba(0,0,0,.72)');
dangerGrad = ctx.createRadialGradient(W/2,H/2,Math.min(W,H)*0.3, W/2,H/2, Math.max(W,H)*0.7);
dangerGrad.addColorStop(0,'rgba(120,40,220,0)');
dangerGrad.addColorStop(1,'rgba(120,40,220,1)');
}
/* ------------------------------------------------------------
ADAPTIVE QUALITY — sheds particles rather than framerate
------------------------------------------------------------ */
let frameAvg = 16.7;
function partCap(){ return frameAvg > 32 ? 300 : frameAvg > 22 ? 550 : 1200; }
/* ============================================================
SETTINGS (persisted)
============================================================ */
const prefersReduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const Store = {
get(k,d){ try{ const v = localStorage.getItem('wildorbs_'+k); return v===null? d : JSON.parse(v); }catch(e){ return d; } },
set(k,v){ try{ localStorage.setItem('wildorbs_'+k, JSON.stringify(v)); }catch(e){} }
};
const SET = {
muted: Store.get('muted', false),
shake: Store.get('shake', !prefersReduced)
};
/* ============================================================
AUDIO — WebAudio synth, no assets
============================================================ */
const Snd = {
ctx:null, master:null, ready:false, voices:0,
init(){
if(this.ready) return;
try{
const AC = window.AudioContext || window.webkitAudioContext;
if(!AC) return;
this.ctx = new AC();
this.master = this.ctx.createGain();
this.master.gain.value = SET.muted ? 0 : 0.30;
const comp = this.ctx.createDynamicsCompressor();
comp.threshold.value=-17; comp.knee.value=22; comp.ratio.value=11;
comp.attack.value=.003; comp.release.value=.22;
this.master.connect(comp); comp.connect(this.ctx.destination);
this.ready = true;
}catch(e){ /* audio is optional; game runs silent */ }
},
resume(){ if(this.ctx && this.ctx.state==='suspended') this.ctx.resume().catch(()=>{}); },
applyMute(){ if(this.master) this.master.gain.value = SET.muted ? 0 : 0.30; Engine.set(Engine.level); },
t(){ return this.ctx.currentTime; },
live(){ return this.ready && !SET.muted; },
/** cheap voice budget so a 20-orb chain reaction can't detonate the mixer */
budget(){
const now = performance.now();
if(now - this._win > 100){ this._win = now; this.voices = 0; }
return (this.voices++) < 26;
},
_win:0,
env(v,at,dur,attack){
const g = this.ctx.createGain();
g.gain.setValueAtTime(0.0001, at);
g.gain.exponentialRampToValueAtTime(Math.max(v,0.0002), at+(attack||0.008));
g.gain.exponentialRampToValueAtTime(0.0001, at+dur);
g.connect(this.master);
return g;
},
tone(freq,dur,type,vol,at,slideTo,detune){
if(!this.live() || !this.budget()) return;
at = at===undefined? this.t() : at;
const o = this.ctx.createOscillator();
o.type = type||'sine';
o.frequency.setValueAtTime(Math.max(freq,1), at);
if(slideTo) o.frequency.exponentialRampToValueAtTime(Math.max(slideTo,1), at+dur);
if(detune) o.detune.value = detune;
const g = this.env(vol===undefined?.3:vol, at, dur);
o.connect(g); o.start(at); o.stop(at+dur+0.05);
},
noise(dur,vol,filterType,f0,f1,at,q){
if(!this.live() || !this.budget()) return;
at = at===undefined? this.t() : at;
const len = Math.max(1,Math.floor(this.ctx.sampleRate*Math.min(dur,2)));
const buf = this.ctx.createBuffer(1,len,this.ctx.sampleRate);
const d = buf.getChannelData(0);
for(let i=0;i<len;i++) d[i] = (Math.random()*2-1) * (1 - i/len);
const src = this.ctx.createBufferSource(); src.buffer = buf;
const bq = this.ctx.createBiquadFilter();
bq.type = filterType||'lowpass';
bq.frequency.setValueAtTime(f0||1200, at);
if(f1) bq.frequency.exponentialRampToValueAtTime(Math.max(f1,20), at+dur);
bq.Q.value = q||1;
const g = this.env(vol===undefined?.4:vol, at, dur);
src.connect(bq); bq.connect(g); src.start(at); src.stop(at+dur+0.05);
},
chord(freqs,dur,type,vol){
if(!this.live()) return;
const at=this.t();
freqs.forEach((f,i)=> this.tone(f,dur,type||'triangle',(vol||.16), at+i*0.012));
},
/* ---- named SFX ---- */
shoot(){ if(!this.live())return; const at=this.t();
this.tone(880,0.09,'square',.11,at,220);
this.noise(0.05,.08,'highpass',1800,900,at); },
hurt(){ if(!this.live())return; const at=this.t();
this.tone(180,0.5,'sawtooth',.33,at,40);
this.noise(0.45,.32,'lowpass',900,80,at); },
shieldBreak(){ if(!this.live())return; const at=this.t();
this.tone(1200,.3,'triangle',.22,at,300);
this.noise(.3,.2,'bandpass',2600,700,at,7); },
gameover(){ if(!this.live())return; const at=this.t();
[440,330,262,175].forEach((f,i)=> this.tone(f,0.5,'triangle',.22, at+i*0.16, f*0.6));
this.noise(1.4,.18,'lowpass',700,60,at); },
wave(n){ if(!this.live())return; const at=this.t();
[523.25,659.25,783.99,1046.5].forEach((f,i)=> this.tone(f*(1+((n-1)%4)*0.02),0.28,'triangle',.19, at+i*0.09)); },
extraLife(){ if(!this.live())return; const at=this.t();
[523,659,784,1047,1319].forEach((f,i)=> this.tone(f,0.22,'square',.13, at+i*0.06)); },
powerup(){ if(!this.live())return; const at=this.t();
[660,880,1180].forEach((f,i)=> this.tone(f,0.18,'triangle',.16, at+i*0.05, f*1.35));
this.noise(.14,.08,'bandpass',2600,3400,at,8); },
pulse(){ if(!this.live())return; const at=this.t();
this.tone(90,1.0,'sine',.4,at,26);
this.tone(420,.7,'sawtooth',.16,at,60);
this.noise(.9,.36,'lowpass',5200,120,at); },
hyper(){ if(!this.live())return; const at=this.t();
this.tone(220,.28,'sine',.22,at,2600);
this.noise(.26,.16,'bandpass',600,4200,at,6); },
bounce(){ if(!this.live())return; const at=this.t();
this.tone(rnd(900,520),.07,'triangle',.08,at, rnd(1500,900)); },
pop(){ if(!this.live())return; const at=this.t();
this.tone(rnd(1400,900),.06,'sine',.07,at,300); },
turretShot(){ if(!this.live())return; const at=this.t();
this.tone(300,.12,'square',.10,at,120);
this.noise(.1,.08,'bandpass',1400,600,at,5); },
orbHit(kind){
if(!this.live()) return;
const at = this.t();
switch(kind){
case 'swarm':
for(let i=0;i<7;i++) this.tone(500+i*130, .07,'square',.08, at+i*0.026, 1400);
this.noise(.22,.12,'bandpass',2600,900,at,6); break;
case 'gravity':
this.tone(300,.9,'sine',.28,at,38);
this.tone(150,.9,'triangle',.18,at,24);
this.noise(.8,.14,'lowpass',600,60,at); break;
case 'phase':
this.tone(1500,.16,'sine',.18,at,120);
this.tone(300,.20,'sine',.14,at,2400);
this.noise(.12,.10,'bandpass',3000,600,at,10); break;
case 'blackhole':
this.tone(70,1.6,'sine',.40,at,28);
this.tone(105,1.4,'triangle',.16,at,44);
this.noise(1.3,.20,'lowpass',400,40,at);
this.tone(1800,.9,'sine',.06,at,220); break;
case 'reverse':
for(let i=0;i<9;i++) this.tone(i%2? 340:620, .09,'sawtooth',.12, at+i*0.05);
break;
case 'shard':
this.noise(.5,.30,'highpass',5200,1400,at,2);
for(let i=0;i<9;i++) this.tone(rnd(2600,1100),.16,'triangle',.08, at+i*0.018); break;
case 'time':
this.chord([392,523.25,659.25,880],1.1,'sine',.12);
this.tone(1200,1.1,'sine',.08,at,300); break;
case 'chain':
this.tone(520,.22,'square',.22,at,90);
this.noise(.3,.28,'lowpass',2600,180,at); break;
case 'mirror':
this.tone(740,.35,'triangle',.15,at,740*1.5);
this.tone(740*1.005,.35,'triangle',.15,at+0.03,740*1.5);
this.noise(.2,.09,'bandpass',2200,2600,at,8); break;
case 'rubber':
this.tone(200,.28,'sine',.28,at,760);
this.tone(760,.20,'sine',.15,at+0.06,180); break;
case 'nova':
this.tone(120,1.0,'sawtooth',.32,at,30);
this.noise(.9,.36,'lowpass',4000,120,at);
this.chord([220,277,330,440],.7,'sawtooth',.09); break;
case 'sentry':
[740,560,930].forEach((f,i)=> this.tone(f,.12,'square',.13, at+i*0.07));
this.noise(.3,.14,'bandpass',900,2200,at,9); break;
case 'warp':
this.tone(180,.34,'sine',.24,at,2200);
this.tone(2400,.34,'sine',.14,at+0.05,160);
this.noise(.3,.16,'bandpass',400,3800,at,7); break;
default:
this.tone(360,.2,'square',.18,at,120); this.noise(.24,.18,'lowpass',1500,200,at);
}
}
};
/* engine hum — continuous, gated on thrust */
const Engine = {
src:null, sub:null, gain:null, filt:null, level:0,
start(){
if(!Snd.ready || this.src) return;
const c = Snd.ctx;
this.gain = c.createGain(); this.gain.gain.value = 0;
this.filt = c.createBiquadFilter(); this.filt.type='lowpass'; this.filt.frequency.value=760; this.filt.Q.value=3;
const len = Math.floor(c.sampleRate*2);
const buf = c.createBuffer(1,len,c.sampleRate);
const d = buf.getChannelData(0);
for(let i=0;i<len;i++) d[i] = Math.random()*2-1;
this.src = c.createBufferSource(); this.src.buffer=buf; this.src.loop=true;
this.sub = c.createOscillator(); this.sub.type='sawtooth'; this.sub.frequency.value=62;
const subG = c.createGain(); subG.gain.value=0.5;
this.src.connect(this.filt); this.sub.connect(subG); subG.connect(this.filt);
this.filt.connect(this.gain); this.gain.connect(Snd.master);
try{ this.src.start(); this.sub.start(); }catch(e){}
},
set(v){
this.level = v;
if(!this.gain || !Snd.ready) return;
const target = SET.muted ? 0 : v*0.19;
const now = Snd.ctx.currentTime;
this.gain.gain.setTargetAtTime(target, now, 0.06);
this.filt.frequency.setTargetAtTime(500 + v*900, now, 0.08);
}
};
/* ============================================================
ORB TYPES — the wild bestiary
============================================================ */
const ORB_TYPES = {
swarm: {
name:'SWARM ORB', c1:'#ffe27a', c2:'#ff9d2e', glow:'#ffb454', shape:'hive',
desc:'Bursts into homing hunters that chase your hull.', pts:110, hp:1
},
gravity: {
name:'GRAVITY WELL', c1:'#8de7ff', c2:'#1f6bd8', glow:'#57c8ff', shape:'ring',
desc:'Collapses into a well that drags your ship and bullets.', pts:140, hp:1
},
phase: {
name:'PHASE ORB', c1:'#c9a6ff', c2:'#6a2fd0', glow:'#b48bff', shape:'glitch',
desc:'Blinks elsewhere when hit. Takes three tags to kill.', pts:90, hp:3
},
blackhole: {
name:'VOID ORB', c1:'#2a2140', c2:'#0a0714', glow:'#a24fff', shape:'void',
desc:'Spawns a hungry black hole that eats everything near.', pts:220, hp:2
},
reverse: {
name:'CHAOS ORB', c1:'#ff8ad4', c2:'#c31f8a', glow:'#ff62c0', shape:'spiral',
desc:'Scrambles your steering for six seconds. Good luck.', pts:130, hp:1
},
shard: {
name:'SHARD ORB', c1:'#e8fbff', c2:'#7fb6c9', glow:'#c9f4ff', shape:'crystal',
desc:'Shatters into ricocheting glass that hurts on contact.', pts:120, hp:1
},
time: {
name:'CHRONO ORB', c1:'#9dffc9', c2:'#129c6a', glow:'#66ffb0', shape:'clock',
desc:'Drops a slow-time bubble. Everything inside crawls.', pts:150, hp:1
},
chain: {
name:'CHAIN BOMB', c1:'#ff6a5e', c2:'#a3131f', glow:'#ff7f5c', shape:'bomb',
desc:'Detonates and sets off every orb nearby. Chains hard.', pts:160, hp:1
},
mirror: {
name:'MIRROR ORB', c1:'#f2f6ff', c2:'#8e9ac4', glow:'#dfe9ff', shape:'mirror',
desc:'Clones itself twice. Kill the clones or drown in them.', pts:70, hp:1
},
rubber: {
name:'RUBBER ORB', c1:'#c3ff5e', c2:'#4d8a11', glow:'#a8ff45', shape:'rubber',
desc:'Never wraps — bounces off walls faster on every hit.', pts:100, hp:5
},
nova: {
name:'NOVA ORB', c1:'#fff3c4', c2:'#ff7a18', glow:'#ffb03a', shape:'star',
desc:'Erupts a shockwave ring that shatters whatever it crosses.', pts:180, hp:2
},
sentry: {
name:'SENTINEL ORB', c1:'#c3ccff', c2:'#2c318f', glow:'#7a86ff', shape:'eye',
desc:'Deploys turrets that track you and shoot back.', pts:170, hp:2
},
warp: {
name:'WARP ORB', c1:'#c9ffee', c2:'#00727a', glow:'#2fe8d5', shape:'portal',
desc:'Rips space open and swaps your ship into its place.', pts:120, hp:1
}
};
const ORB_KEYS = Object.keys(ORB_TYPES);
/* ============================================================
POWERUPS
============================================================ */
const POWERS = {
spread: { name:'SPREAD', col:'#7df9ff', glyph:'W', dur:900 },
rapid: { name:'RAPID', col:'#ffd76b', glyph:'R', dur:900 },
shield: { name:'SHIELD', col:'#8effc0', glyph:'S' },
pulse: { name:'PULSE +1',col:'#ff8ad4', glyph:'P' },
life: { name:'1UP', col:'#ff6a5e', glyph:'1' }
};
const POWER_BAG = ['spread','spread','rapid','rapid','shield','shield','pulse','pulse','life'];
/* ============================================================
STATE
============================================================ */
let orbs=[], bullets=[], parts=[], hunters=[], shards=[], wells=[], holes=[],
bubbles=[], rings=[], floats=[], waves=[], sentries=[], enemyShots=[],
powerups=[], starsDim=[], starsTwinkle=[];
const G = {
state:'start', // start | play | pause | over
score:0, wave:1, lives:3,
best: Store.get('best',0), bestWave: Store.get('bestWave',1),
shake:0, shakeX:0, shakeY:0,
time:0, flash:0, flashCol:'#fff',
reverseT:0, reverseMax:360, spreadT:0, rapidT:0,
pulses:1, hyperCool:0,
waveBannerT:0, waveBannerTxt:'', waveBannerSub:'',
spawnGuard:0, nextLife:5000,
hitStop:0, combo:0, comboT:0,
respawnT:0,
// run stats
orbsBroken:0, bestCombo:0, shots:0, hits:0, frames:0
};
/* ---------------- starfield / background ---------------- */
function buildStars(){
starsDim = []; starsTwinkle = [];
const n = clamp(Math.floor((W*H)/5200), 40, 460);
for(let i=0;i<n;i++){
const s = { x:rnd(W), y:rnd(H), z:rnd(1,.15), s:rnd(1.7,.4), tw:rnd(TAU), tws:rnd(3,.5) };
if(i % 3 === 0 && starsTwinkle.length < 150) starsTwinkle.push(s); else starsDim.push(s);
}
}
function paintBackground(){
const g = bgx.createLinearGradient(0,0,0,H);
g.addColorStop(0,'#080618'); g.addColorStop(0.55,'#0a0720'); g.addColorStop(1,'#050410');
bgx.fillStyle=g; bgx.fillRect(0,0,W,H);
bgx.save();
bgx.globalCompositeOperation='lighter';
const n1 = bgx.createRadialGradient(W*0.22,H*0.28,10,W*0.22,H*0.28,Math.max(W,H)*0.55);
n1.addColorStop(0,'rgba(70,30,140,.22)'); n1.addColorStop(1,'rgba(70,30,140,0)');
bgx.fillStyle=n1; bgx.fillRect(0,0,W,H);
const n2 = bgx.createRadialGradient(W*0.78,H*0.74,10,W*0.78,H*0.74,Math.max(W,H)*0.5);
n2.addColorStop(0,'rgba(10,80,140,.18)'); n2.addColorStop(1,'rgba(10,80,140,0)');
bgx.fillStyle=n2; bgx.fillRect(0,0,W,H);
const n3 = bgx.createRadialGradient(W*0.5,H*0.05,10,W*0.5,H*0.05,Math.max(W,H)*0.4);
n3.addColorStop(0,'rgba(120,20,90,.10)'); n3.addColorStop(1,'rgba(120,20,90,0)');
bgx.fillStyle=n3; bgx.fillRect(0,0,W,H);
bgx.restore();
for(const s of starsDim){
bgx.globalAlpha = 0.22 + 0.5*s.z;
bgx.fillStyle = s.z>0.7? '#dfeaff' : (s.z>0.4? '#9fb4e8' : '#6b7bb0');
bgx.fillRect(s.x, s.y, s.s, s.s);
}
bgx.globalAlpha = 1;
}
/* ============================================================
SHIP
============================================================ */
const ship = {
x:0,y:0,vx:0,vy:0,a:-Math.PI/2, r:13,
thrust:0, cool:0, inv:0, alive:true, shield:0,
trail:[], recoil:0
};
function resetShip(){
ship.x = W/2; ship.y = H/2; ship.vx=0; ship.vy=0; ship.a=-Math.PI/2;
ship.inv = 140; ship.alive=true; ship.trail.length=0; ship.cool=0;
G.reverseT = 0;
}
/* ============================================================
INPUT — keyboard + gamepad + touch
============================================================ */
const keys = Object.create(null);
const CODES = {
left:['ArrowLeft','KeyA'], right:['ArrowRight','KeyD'],
up:['ArrowUp','KeyW'], down:['ArrowDown','KeyS'], fire:['Space']
};
function keyDown(list){ for(const k of list) if(keys[k]) return true; return false; }
const touch = { on:false, stickId:-1, ox:0, oy:0, cx:0, cy:0, fireId:-1, btn:null, lastTapRight:0 };
function primeAudio(){ Snd.init(); Snd.resume(); Engine.start(); }
window.addEventListener('keydown', e=>{
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(e.code)) e.preventDefault();
keys[e.code]=true;
primeAudio();
if(e.repeat) return;
if(e.code==='KeyM'){ toggleSound(); }
if(e.code==='KeyK'){ toggleShake(); }
if(e.code==='KeyP' || e.code==='Escape'){
if(G.state==='play'||G.state==='pause') togglePause();
}
if(G.state==='play'){
if(e.code==='ShiftLeft'||e.code==='ShiftRight') firePulse();
if(e.code==='KeyH') hyperspace();
}
if(e.code==='Space'||e.code==='Enter'||e.code==='NumpadEnter'){
if(G.state==='start' || G.state==='over') startGame();
}
}, {passive:false});
window.addEventListener('keyup', e=>{ keys[e.code]=false; });
window.addEventListener('blur', ()=>{ for(const k in keys) keys[k]=false; if(G.state==='play') togglePause(); });
document.addEventListener('visibilitychange', ()=>{
if(document.hidden && G.state==='play') togglePause();
});
/* --- touch --- */
function touchButtons(){
const s = clamp(Math.min(W,H)*0.085, 44, 74);
const pad = s*0.55;
return [
{ id:'pulse', x: W - pad - s*0.5, y: H - pad - s*0.5, r: s*0.5, label:'PULSE' },
{ id:'warp', x: W - pad - s*1.75, y: H - pad - s*0.5, r: s*0.5, label:'WARP' }
];
}
function hitButton(x,y){
for(const b of touchButtons()) if(Math.hypot(x-b.x,y-b.y) < b.r*1.25) return b;
return null;
}
cv.addEventListener('pointerdown', e=>{
primeAudio();
if(e.pointerType==='mouse') return; // virtual stick is touch/pen only
touch.on = true;
const x = e.clientX, y = e.clientY;
if(G.state !== 'play') return;
const b = hitButton(x,y);
if(b){
touch.btn = b.id;
if(b.id==='pulse') firePulse(); else hyperspace();
return;
}
if(x < W*0.5 && touch.stickId===-1){
touch.stickId = e.pointerId; touch.ox=x; touch.oy=y; touch.cx=x; touch.cy=y;
} else if(touch.fireId===-1){
touch.fireId = e.pointerId;
}
// Capture last and defensively: it throws NotFoundError when the pointer is
// already gone (fast taps, browser gesture cancellation). Thrown from the top
// of this handler it silently swallowed the input that followed it.
try { cv.setPointerCapture && cv.setPointerCapture(e.pointerId); } catch(err){}
}, {passive:true});
cv.addEventListener('pointermove', e=>{
if(e.pointerId===touch.stickId){ touch.cx=e.clientX; touch.cy=e.clientY; }
}, {passive:true});
function endPointer(e){
if(e.pointerId===touch.stickId) touch.stickId=-1;
if(e.pointerId===touch.fireId) touch.fireId=-1;
touch.btn = null;
}
cv.addEventListener('pointerup', endPointer, {passive:true});
cv.addEventListener('pointercancel', endPointer, {passive:true});
cv.addEventListener('contextmenu', e=> e.preventDefault());
/* --- gamepad --- */
let padPrev = {};
function readGamepad(inp){
if(!navigator.getGamepads) return;
const pads = navigator.getGamepads();
for(const p of pads){
if(!p) continue;
const ax = p.axes[0]||0, ay = p.axes[1]||0;
if(ax < -0.28) inp.left = true;
if(ax > 0.28) inp.right = true;
const b = p.buttons;
const on = i => b[i] && (b[i].pressed || b[i].value>0.4);
if(on(0) || on(7) || ay < -0.5) inp.up = true;
if(on(2) || on(6)) inp.fire = true;
if(on(1) && !padPrev.hyper) hyperspace();
if(on(3) && !padPrev.pulse) firePulse();
if(on(9) && !padPrev.start) togglePause();
padPrev = { hyper:on(1), pulse:on(3), start:on(9) };
return;
}
}
function readInput(){
const inp = { left:false, right:false, up:false, down:false, fire:false, aim:null };
if(keyDown(CODES.left)) inp.left = true;
if(keyDown(CODES.right)) inp.right = true;
if(keyDown(CODES.up)) inp.up = true;
if(keyDown(CODES.down)) inp.down = true;
if(keyDown(CODES.fire)) inp.fire = true;
readGamepad(inp);
if(touch.stickId!==-1){
const dx = touch.cx-touch.ox, dy = touch.cy-touch.oy;
const m = Math.hypot(dx,dy);
if(m > 10){ inp.aim = Math.atan2(dy,dx); if(m > 26) inp.up = true; }
}
if(touch.fireId!==-1) inp.fire = true;
return inp;
}
/* ============================================================
PARTICLES / FX
============================================================ */
function spark(x,y,col,opt){
if(parts.length > partCap()) return;
opt = opt || {};
const life = opt.life || rnd(48,18);
parts.push({
x,y,
vx: opt.vx!==undefined? opt.vx : rnd(2.4,-2.4),
vy: opt.vy!==undefined? opt.vy : rnd(2.4,-2.4),
life, max:life, r: opt.r || rnd(3.2,.9),
col, drag: opt.drag===undefined? 0.975 : opt.drag, grav: opt.grav||0
});
}
function burst(x,y,n,col,spd,opt){
opt = opt || {};
for(let i=0;i<n;i++){
const a = rnd(TAU), s = rnd(spd, spd*0.15);
spark(x,y,col,Object.assign({}, opt, { vx:Math.cos(a)*s, vy:Math.sin(a)*s }));
}
}
function ringFx(x,y,col,r0,r1,life,width){
rings.push({x,y,col,r:r0,r1,cur:r0,life,max:life,w:width||3});
}
function floatText(x,y,txt,col,size){
// during a big cascade the same label fires dozens of times — collapse the
// duplicates instead of painting an unreadable stack
for(let i=floats.length-1;i>=0;i--){
const f=floats[i];
if(f.txt===txt && f.life>44 && Math.hypot(f.x-x,f.y-y)<110) return;
}
if(floats.length>14) floats.shift();
floats.push({x,y,txt,col,life:70,max:70,size:size||16,vy:-0.7});
}
function shake(v){ if(!SET.shake) return; G.shake = Math.min(40, G.shake + v); }
function flashScreen(col,amt){ G.flash = Math.max(G.flash, SET.shake? amt : amt*0.45); G.flashCol = col; }
function hitStop(f){ if(SET.shake) G.hitStop = Math.max(G.hitStop, f); }
let toastTxt='', toastT=0;
function toast(t){ toastTxt=t; toastT=95; }
/* ============================================================
SPAWNING
============================================================ */
function orbSizeFor(tier){ return tier===4? 74 : tier===3? 52 : tier===2? 34 : 21; }
function waveKinds(){
const w = G.wave;
const pool = ['swarm','shard','mirror','rubber'];
if(w>=2) pool.push('phase','chain');
if(w>=3) pool.push('gravity','time');
if(w>=4) pool.push('reverse','nova');
if(w>=5) pool.push('blackhole','warp');
if(w>=6) pool.push('sentry');
if(w>=7) return ORB_KEYS.slice();
return pool;
}
function makeOrb(x,y,tier,kind,vx,vy){
kind = kind || pick(waveKinds());
const T = ORB_TYPES[kind];
const r = orbSizeFor(tier);
const tierSpd = tier===4?0.62 : tier===3?0.85 : tier===2?1.05 : 1.35;
const spd = lerp(0.5,1.7,Math.random()) * (1 + (G.wave-1)*0.055) * tierSpd;
const a = rnd(TAU);
const hpMul = tier===4?3 : tier===3?1 : tier===2?0.8 : 0.6;
const o = {
kind, tier, r, x, y,
vx: vx!==undefined? vx : Math.cos(a)*spd,
vy: vy!==undefined? vy : Math.sin(a)*spd,
hp: tier===4 ? Math.max(5, Math.round(T.hp*hpMul)+2) : Math.max(1, Math.round(T.hp*hpMul)),
maxhp:0, elder: tier===4,
a: rnd(TAU), spin: rnd(0.03,-0.03),
pulse: rnd(TAU), t:0, wobble: rnd(TAU),
hitFlash:0, bounces:0, clones:0, chainT:-1,
verts: []
};
o.maxhp = o.hp;
const nv = 11 + tier*2;
for(let i=0;i<nv;i++) o.verts.push(rnd(1.14,0.84));
return o;
}
function edgeSpawn(minDistFromShip){
let x,y,guard=0;
do{
const edge = rndi(0,4);
if(edge===0){ x=rnd(W); y=-30; }
else if(edge===1){ x=W+30; y=rnd(H); }
else if(edge===2){ x=rnd(W); y=H+30; }
else { x=-30; y=rnd(H); }
guard++;
} while(guard<14 && Math.hypot(x-ship.x, y-ship.y) < (minDistFromShip||200));
return [x,y];
}
function spawnWave(){
const n = Math.min(12, 3 + Math.floor(G.wave*0.9));
for(let i=0;i<n;i++){
const [x,y] = edgeSpawn(200);
orbs.push(makeOrb(x,y,3));
}
const elder = G.wave % 5 === 0;
if(elder){
const [ex,ey] = edgeSpawn(300);
const o = makeOrb(ex,ey,4);
// rubber elders would be pinned to the bounds on spawn — keep them wrapping-friendly
if(o.kind==='rubber'){ o.x = clamp(o.x, o.r+4, W-o.r-4); o.y = clamp(o.y, o.r+4, H-o.r-4); }
orbs.push(o);
}
G.waveBannerT = 140;
G.waveBannerTxt = 'WAVE ' + G.wave;
G.waveBannerSub = elder ? 'AN ELDER ORB IS AWAKE' : '';
Snd.wave(G.wave);
}
/* ============================================================
SCORE
============================================================ */
function addScore(n, x, y){
const mult = 1 + Math.min(10,G.combo)*0.1;
const gained = Math.round(n*mult);
G.score += gained;
if(x!==undefined && n>=60) floatText(x, y-10, '+'+gained, '#ffffff', 15);
while(G.score >= G.nextLife){
G.lives++; G.nextLife += 6000;
Snd.extraLife(); toast('EXTRA LIFE');
flashScreen('#8effc0',.2);
}
}
function bumpCombo(){
G.combo++; G.comboT = 75;
if(G.combo > G.bestCombo) G.bestCombo = G.combo;
}
/* ============================================================
ORB DESTRUCTION — the wild behaviours
============================================================ */
function splitOrb(o){
if(o.tier<=1 || orbs.length>70) return;
for(let i=0;i<2;i++){
const a = rnd(TAU);
const s = rnd(2.1,0.8);
const kind = Math.random()<0.35 ? pick(waveKinds()) : o.kind;
const child = makeOrb(
o.x + Math.cos(a)*o.r*0.5, o.y + Math.sin(a)*o.r*0.5, o.tier-1, kind,
o.vx*0.5 + Math.cos(a)*s, o.vy*0.5 + Math.sin(a)*s);
if(child.kind==='rubber'){ child.x = clamp(child.x, child.r, W-child.r); child.y = clamp(child.y, child.r, H-child.r); }
orbs.push(child);
}
}
function dropPowerup(x,y,forced){
if(!forced && Math.random() > 0.11) return;
const kind = forced ? pick(['spread','rapid','shield','pulse']) : pick(POWER_BAG);
powerups.push({
x, y, vx:rnd(.7,-.7), vy:rnd(.7,-.7),
kind, r:14, life:780, ph:rnd(TAU), a:0
});
}
function spawnShockwave(x,y,rMax,dmg,speed,col){
waves.push({ x, y, r:8, rMax, dmg, speed, col:col||'#ffd9a0', band:22, hit:new Set() });
}
function destroyOrb(o, silentCombo){
const idx = orbs.indexOf(o);
if(idx<0) return;
orbs.splice(idx,1);
const T = ORB_TYPES[o.kind];
const M = o.elder ? 2 : 1; // elder orbs do everything twice as hard
const tierPts = o.tier===4?3 : o.tier===3?1 : o.tier===2?1.4 : 2.0;
addScore(Math.round(T.pts * tierPts), o.x, o.y);
G.orbsBroken++;
Snd.orbHit(o.kind);
ringFx(o.x,o.y,T.glow, o.r*0.5, o.r*3.4, 34, 3);
burst(o.x,o.y, 14 + o.tier*8, T.c1, 2.6+o.tier*0.5, {life:rnd(56,24)});
burst(o.x,o.y, 8 + o.tier*5, T.c2, 4.2+o.tier, {life:rnd(40,16), r:rnd(2.4,.8)});
shake(3 + o.tier*2.2);
if(o.elder){
flashScreen('#ffffff',.28); shake(18); hitStop(5);
floatText(o.x, o.y-o.r-18, 'ELDER DOWN', '#fff2b0', 22);
spawnShockwave(o.x,o.y, 320, 1, 9, T.glow);
dropPowerup(o.x,o.y,true);
} else {
dropPowerup(o.x,o.y,false);
}
let didSplit = false;
switch(o.kind){
/* 1. SWARM — homing hunters */
case 'swarm': {
const n = (3 + o.tier*2) * M;
for(let i=0;i<n;i++){
const a = (i/n)*TAU + rnd(.5);
hunters.push({
x:o.x+Math.cos(a)*o.r*0.6, y:o.y+Math.sin(a)*o.r*0.6,
vx:Math.cos(a)*2.4, vy:Math.sin(a)*2.4,
r:6, life:460, max:460, a, wig:rnd(TAU), spd: 0.095 + G.wave*0.0035
});
}
floatText(o.x,o.y,'SWARM!','#ffcb5e',18);
flashScreen('#ffb454',.16);
break;
}
/* 2. GRAVITY WELL */
case 'gravity': {
wells.push({ x:o.x, y:o.y, r:(210 + o.tier*40)*(o.elder?1.4:1), str:(0.55 + o.tier*0.18)*M, life:300, max:300, a:0 });
floatText(o.x,o.y,'GRAVITY WELL','#57c8ff',17);
ringFx(o.x,o.y,'#8de7ff', 10, 260, 60, 5);
flashScreen('#57c8ff',.18);
break;
}
/* 3. PHASE — the blinks happen on hit; death is a violet implosion */
case 'phase': {
for(let i=0;i<3;i++){
const a=rnd(TAU);
ringFx(o.x+Math.cos(a)*30,o.y+Math.sin(a)*30,'#c9a6ff',4,90,30,2);
}
floatText(o.x,o.y,'PHASE OUT','#b48bff',16);
break;
}
/* 4. BLACK HOLE */
case 'blackhole': {
holes.push({ x:o.x, y:o.y, r:26, rMax:(34 + o.tier*8), pull:(400 + o.tier*55)*(o.elder?1.3:1),
life:360, max:360, a:0, eaten:0 });
floatText(o.x,o.y,'VOID OPENED','#a24fff',19);
flashScreen('#7a2fff',.30); shake(16);
ringFx(o.x,o.y,'#a24fff', 10, 340, 70, 6);
break;
}
/* 5. REVERSE CONTROLS */
case 'reverse': {
G.reverseT = Math.max(G.reverseT, 360*M);
G.reverseMax = Math.max(G.reverseMax||1, G.reverseT);
floatText(ship.x, ship.y-40,'CONTROLS REVERSED!','#ff62c0',20);
flashScreen('#ff62c0',.30); shake(11);
for(let i=0;i<3;i++) ringFx(o.x,o.y,'#ff8ad4', 8+i*20, 200+i*40, 44, 3);
break;
}
/* 6. SHARDS — ricocheting glass */
case 'shard': {
const n = (5 + o.tier*3) * M;
for(let i=0;i<n;i++){
const a=(i/n)*TAU+rnd(.4);
const s=rnd(5.4,2.6);
shards.push({ x:clamp(o.x,6,W-6)+Math.cos(a)*o.r*0.4, y:clamp(o.y,6,H-6)+Math.sin(a)*o.r*0.4,
vx:Math.cos(a)*s, vy:Math.sin(a)*s, r:5, life:420, max:420,
rot:rnd(TAU), rs:rnd(.28,-.28), bounce:0 });
}
floatText(o.x,o.y,'SHATTER','#c9f4ff',17);
break;
}
/* 7. TIME BUBBLE */
case 'time': {
bubbles.push({ x:o.x, y:o.y, r:(150 + o.tier*45)*(o.elder?1.35:1), life:380, max:380, ph:0 });
floatText(o.x,o.y,'SLOW FIELD','#66ffb0',17);
ringFx(o.x,o.y,'#9dffc9', 8, 220, 60, 4);
flashScreen('#66ffb0',.14);
break;
}
/* 8. CHAIN BOMB */
case 'chain': {
const R = (190 + o.tier*45) * M;
ringFx(o.x,o.y,'#ff7f5c', 8, R, 30, 7);
burst(o.x,o.y,30,'#ffd08a',7,{life:rnd(50,20)});
shake(12); flashScreen('#ff7f5c',.24);
floatText(o.x,o.y,'CHAIN!','#ff8f6b',20);
let d=0;
for(const t of orbs){
if(t.chainT>=0) continue;
if(wrapDist(o,t) < R + t.r) t.chainT = 6 + (d++)*3; // staged for a proper cascade
}
break;
}
/* 9. MIRROR CLONE */
case 'mirror': {
if(o.clones < 2 && orbs.length < 46){
for(let i=0;i<2;i++){
const a = o.a + (i? 2.4 : -2.4);
const c = makeOrb(o.x + Math.cos(a)*(o.r+16), o.y + Math.sin(a)*(o.r+16),
Math.max(1,Math.min(3,o.tier)), 'mirror',
Math.cos(a)*rnd(2.4,1.2), Math.sin(a)*rnd(2.4,1.2));
c.clones = o.clones+1;
c.r = o.r*0.86; c.hp = 1; c.maxhp = 1;
orbs.push(c);
ringFx(c.x,c.y,'#dfe9ff',4,70,26,2);
}
floatText(o.x,o.y,'CLONED x2','#dfe9ff',17);
} else {
floatText(o.x,o.y,'MIRROR BROKE','#dfe9ff',15);
splitOrb(o); didSplit = true;
}
break;
}
/* 10. RUBBER — confetti pop */
case 'rubber': {
burst(o.x,o.y,26,'#c3ff5e',6.5,{life:rnd(60,24)});
floatText(o.x,o.y,'POP!','#a8ff45',20);
break;
}
/* 11. NOVA — expanding shockwave that shatters what it crosses */
case 'nova': {
// dmg 2 so it genuinely shatters what it crosses — and so novas chain
// into each other. Bounded: a wave damages each orb at most once.
spawnShockwave(o.x,o.y, (240 + o.tier*70)*M, 2, 7.5, '#ffb03a');
burst(o.x,o.y,34,'#fff3c4',8,{life:rnd(56,22)});
flashScreen('#ffb03a',.30); shake(15); hitStop(3);
floatText(o.x,o.y,'NOVA!','#ffd88a',22);
break;
}
/* 12. SENTINEL — deploys turrets */
case 'sentry': {
const n = (o.tier>=3?2:1) * M;
for(let i=0;i<n;i++){
const a = (i/n)*TAU + rnd(.6);
sentries.push({
x:o.x+Math.cos(a)*o.r*0.7, y:o.y+Math.sin(a)*o.r*0.7,
vx:Math.cos(a)*0.5, vy:Math.sin(a)*0.5,
r:15, a:0, hp:2, cool: 70 + i*26, life: 660, max:660, spin:rnd(.04,-.04)
});
}
floatText(o.x,o.y,'TURRETS DEPLOYED','#8f9bff',17);
flashScreen('#7a86ff',.18); shake(7);
break;
}
/* 13. WARP — swaps your ship into the orb's place */
case 'warp': {
if(ship.alive){
const sx = ship.x, sy = ship.y;
for(let i=0;i<3;i++) ringFx(sx,sy,'#2fe8d5', 6+i*14, 150+i*30, 34, 3);
ship.x = o.x; ship.y = o.y;
ship.inv = Math.max(ship.inv, 70); // never warp you straight into a death
ship.vx *= 0.4; ship.vy *= 0.4;
burst(ship.x,ship.y,26,'#c9ffee',5,{life:rnd(48,20)});
ringFx(ship.x,ship.y,'#2fe8d5',150,6,32,4);
floatText(ship.x, ship.y-40,'WARPED!','#2fe8d5',20);
flashScreen('#2fe8d5',.26); shake(10);
}
break;
}
}
// generic split for everything that didn't grow its own descendants
if(!didSplit && o.kind!=='mirror') splitOrb(o);
if(!silentCombo) bumpCombo();
}
function hitOrb(o, bx, by, dmg){
dmg = dmg || 1;
const T = ORB_TYPES[o.kind];
o.hitFlash = 12;
// PHASE — teleports on a non-lethal hit
if(o.kind==='phase' && o.hp > dmg){
o.hp -= dmg;
Snd.orbHit('phase');
burst(o.x,o.y,16,'#c9a6ff',3.4,{life:34});
ringFx(o.x,o.y,'#b48bff',4,120,26,3);
let nx,ny,g=0;
do{ nx = rnd(W*0.9, W*0.1); ny = rnd(H*0.9, H*0.1); g++; }
while(g<24 && Math.hypot(nx-ship.x,ny-ship.y) < 170);
o.x = nx; o.y = ny;
o.vx = rnd(2.4,-2.4); o.vy = rnd(2.4,-2.4);
burst(o.x,o.y,18,'#e0ccff',3.8,{life:38});
ringFx(o.x,o.y,'#c9a6ff',110,4,26,3);
floatText(o.x,o.y-o.r-8,'BLINK','#c9a6ff',14);
addScore(20,o.x,o.y);
return false;
}
// RUBBER — accelerates on every hit
if(o.kind==='rubber' && o.hp > dmg){
o.hp -= dmg;
o.vx *= 1.5; o.vy *= 1.5;
o.vx += rnd(.9,-.9); o.vy += rnd(.9,-.9);
const sp = Math.hypot(o.vx,o.vy);
if(sp < 1.4){ const a=rnd(TAU); o.vx=Math.cos(a)*1.8; o.vy=Math.sin(a)*1.8; }
o.bounces++;
Snd.orbHit('rubber');
burst(o.x,o.y,10,'#c3ff5e',3.2,{life:26});
ringFx(o.x,o.y,'#a8ff45',o.r*0.4,o.r*2.2,20,3);
floatText(o.x,o.y-o.r-6,'FASTER!','#c3ff5e',14);
addScore(15,o.x,o.y);
shake(2.5);
return false;
}
o.hp -= dmg;
if(o.hp>0){
Snd.pop();
burst(bx===undefined?o.x:bx, by===undefined?o.y:by, 7, T.c1, 2.6, {life:22});
addScore(10,o.x,o.y);
return false;
}
destroyOrb(o);
return true;
}
/* ============================================================
SHIP ACTIONS
============================================================ */
function fire(){
if(ship.cool>0 || !ship.alive || G.state!=='play') return;
const angles = G.spreadT>0 ? [-0.15, 0, 0.15] : [0];
for(const off of angles){
const a = ship.a + off;
const bx = ship.x + Math.cos(a)*(ship.r+4);
const by = ship.y + Math.sin(a)*(ship.r+4);
bullets.push({
x:bx, y:by,
vx: Math.cos(a)*8.4 + ship.vx*0.45,
vy: Math.sin(a)*8.4 + ship.vy*0.45,
life: 74, r:3.1, trail:[]
});
G.shots++;
burst(bx,by,3,'#9ff6ff',2.4,{life:14,r:1.6});
}
ship.cool = G.rapidT>0 ? 5 : 9;
ship.recoil = 4;
ship.vx -= Math.cos(ship.a)*0.22; ship.vy -= Math.sin(ship.a)*0.22;
Snd.shoot();
}
function firePulse(){
if(G.state!=='play' || !ship.alive || G.pulses<=0) return;
G.pulses--;
spawnShockwave(ship.x, ship.y, Math.max(W,H)*0.62, 3, 13, '#ff8ad4');
burst(ship.x,ship.y,50,'#ffd6f5',9,{life:rnd(60,26)});
ringFx(ship.x,ship.y,'#ff8ad4',10,Math.max(W,H)*0.6,44,7);
flashScreen('#ff8ad4',.4); shake(24); hitStop(6);
floatText(ship.x, ship.y-46,'PULSE','#ff8ad4',24);
Snd.pulse();
}
function hyperspace(){
if(G.state!=='play' || !ship.alive || G.hyperCool>0) return;
G.hyperCool = 260;
burst(ship.x,ship.y,24,'#b9e8ff',5,{life:34});
ringFx(ship.x,ship.y,'#8ef7ff',6,120,26,3);
let nx=W/2, ny=H/2, best=-1;
for(let i=0;i<26;i++){
const cx=rnd(W*0.92,W*0.08), cy=rnd(H*0.92,H*0.08);
let near=1e9;
for(const o of orbs) near = Math.min(near, Math.hypot(cx-o.x,cy-o.y)-o.r);
for(const h of holes) near = Math.min(near, Math.hypot(cx-h.x,cy-h.y)-h.pull*0.5);
if(near>best){ best=near; nx=cx; ny=cy; }
}
ship.x=nx; ship.y=ny; ship.vx*=0.25; ship.vy*=0.25;
ship.inv = Math.max(ship.inv, 60);
ship.trail.length = 0;
burst(ship.x,ship.y,24,'#dff4ff',5,{life:34});
ringFx(ship.x,ship.y,'#8ef7ff',120,6,26,3);
Snd.hyper();
}
function grantPower(kind,x,y){
const P = POWERS[kind];
switch(kind){
case 'spread': G.spreadT = Math.min(1800, G.spreadT + P.dur); break;
case 'rapid': G.rapidT = Math.min(1800, G.rapidT + P.dur); break;
case 'shield': ship.shield = 1; break;
case 'pulse': G.pulses = Math.min(3, G.pulses+1); break;
case 'life': G.lives++; break;
}
Snd.powerup();
floatText(x, y-16, P.name, P.col, 18);
ringFx(x,y,P.col,6,80,26,3);
burst(x,y,16,P.col,3.4,{life:32});
flashScreen(P.col,.14);
}
function killShip(){
if(G.state!=='play' || ship.inv>0 || !ship.alive) return;
if(ship.shield>0){
ship.shield = 0;
ship.inv = 100;
Snd.shieldBreak();
ringFx(ship.x,ship.y,'#8effc0',ship.r+8,150,32,4);
burst(ship.x,ship.y,26,'#8effc0',5,{life:38});
floatText(ship.x,ship.y-38,'SHIELD DOWN','#8effc0',18);
flashScreen('#8effc0',.26); shake(12);
return;
}
ship.alive=false;
G.lives--;
G.respawnT = 58;
Snd.hurt();
burst(ship.x,ship.y,60,'#ffd07a',6.5,{life:rnd(80,30)});
burst(ship.x,ship.y,34,'#ff5a3c',9,{life:rnd(60,24)});
burst(ship.x,ship.y,18,'#ffffff',3.5,{life:rnd(44,18),r:rnd(4,2)});
ringFx(ship.x,ship.y,'#ffae5c',6,200,44,5);
shake(26); flashScreen('#ff8a4c',.42); hitStop(7);
G.combo=0; G.spreadT=0; G.rapidT=0;
Engine.set(0);
}
/* ============================================================
GAME FLOW
============================================================ */
const startScreen = document.getElementById('startScreen');
const overScreen = document.getElementById('overScreen');
const pauseScreen = document.getElementById('pauseScreen');
function clearWorld(){
orbs.length=0; bullets.length=0; parts.length=0; hunters.length=0; shards.length=0;
wells.length=0; holes.length=0; bubbles.length=0; rings.length=0; floats.length=0;
waves.length=0; sentries.length=0; enemyShots.length=0; powerups.length=0;
}
function startGame(){
primeAudio();
clearWorld();
G.score=0; G.wave=1; G.lives=3; G.state='play';
G.reverseT=0; G.reverseMax=360; G.spreadT=0; G.rapidT=0; G.pulses=1; G.hyperCool=0;
G.shake=0; G.flash=0; G.nextLife=5000; G.combo=0; G.comboT=0; G.hitStop=0;
G.spawnGuard=0; G.respawnT=0;
G.orbsBroken=0; G.bestCombo=0; G.shots=0; G.hits=0; G.frames=0;
ship.shield=0;
toastT=0;
resetShip();
spawnWave();
startScreen.classList.add('hidden');
overScreen.classList.add('hidden');
pauseScreen.classList.add('hidden');
}
function toMenu(){
G.state='start';
clearWorld();
startScreen.classList.remove('hidden');
overScreen.classList.add('hidden');
pauseScreen.classList.add('hidden');
Engine.set(0);
}
function togglePause(){
if(G.state==='play'){ G.state='pause'; pauseScreen.classList.remove('hidden'); Engine.set(0); }
else if(G.state==='pause'){ G.state='play'; pauseScreen.classList.add('hidden'); }
}
function fmtTime(sec){
const m = Math.floor(sec/60), s = Math.floor(sec%60);
return m + ':' + String(s).padStart(2,'0');
}
function gameOver(){
G.state='over';
const isBest = G.score > G.best;
if(isBest){ G.best = G.score; Store.set('best', G.best); }
if(G.wave > G.bestWave){ G.bestWave = G.wave; Store.set('bestWave', G.bestWave); }
document.getElementById('finalScore').textContent = G.score.toLocaleString();
document.getElementById('newBest').textContent = isBest ? '★ NEW PERSONAL BEST ★' : '';
document.getElementById('stWave').textContent = G.wave;
document.getElementById('stOrbs').textContent = G.orbsBroken;
document.getElementById('stCombo').textContent = 'x'+G.bestCombo;
document.getElementById('stAcc').textContent = (G.shots? Math.round(G.hits/G.shots*100):0) + '%';
document.getElementById('stTime').textContent = fmtTime(G.frames/60);
document.getElementById('finalBest').textContent =
'BEST ' + G.best.toLocaleString() + ' · FURTHEST WAVE ' + G.bestWave;
document.getElementById('finalMsg').textContent =
G.wave>=12? 'The void filed a complaint.' :
G.wave>=8 ? 'Orb whisperer.' :
G.wave>=5 ? 'The void respects you.' :
G.wave>=3 ? 'Not bad. The orbs disagree.' : 'The orbs were unimpressed.';
overScreen.classList.remove('hidden');
Engine.set(0);
Snd.gameover();
}
/* ---------------- UI wiring ---------------- */
function toggleSound(){
SET.muted = !SET.muted; Store.set('muted', SET.muted);
Snd.applyMute(); syncToggles();
toast(SET.muted? 'MUTED' : 'SOUND ON');
}
function toggleShake(){
SET.shake = !SET.shake; Store.set('shake', SET.shake);
if(!SET.shake){ G.shake=0; G.hitStop=0; }
syncToggles();
toast('SCREEN SHAKE ' + (SET.shake? 'ON':'OFF'));
}
function syncToggles(){
const s = document.getElementById('tgSound'), k = document.getElementById('tgShake');
document.getElementById('tgSoundV').textContent = SET.muted? 'OFF':'ON';
document.getElementById('tgShakeV').textContent = SET.shake? 'ON':'OFF';
s.classList.toggle('off', SET.muted);
k.classList.toggle('off', !SET.shake);
}
document.getElementById('startBtn').addEventListener('click', startGame);
document.getElementById('againBtn').addEventListener('click', startGame);
document.getElementById('menuBtn').addEventListener('click', toMenu);
document.getElementById('resumeBtn').addEventListener('click', togglePause);
document.getElementById('quitBtn').addEventListener('click', ()=>{ pauseScreen.classList.add('hidden'); gameOver(); });
document.getElementById('tgSound').addEventListener('click', toggleSound);
document.getElementById('tgShake').addEventListener('click', toggleShake);
/* ============================================================
UPDATE
============================================================ */
function slowAt(x,y){
for(let i=0;i<bubbles.length;i++){
const b=bubbles[i];
if(Math.hypot(x-b.x, y-b.y) < b.r) return 0.34;
}
return 1;
}
function update(){
G.time++;
if(G.hitStop>0){ G.hitStop--; return; }
G.frames++;
const inp = readInput();
const rev = G.reverseT>0 ? -1 : 1;
if(G.reverseT>0) G.reverseT--;
if(G.spreadT>0) G.spreadT--;
if(G.rapidT>0) G.rapidT--;
if(G.hyperCool>0) G.hyperCool--;
let shipSlow = 1;
for(const b of bubbles){ if(Math.hypot(ship.x-b.x, ship.y-b.y) < b.r){ shipSlow = 0.42; break; } }
/* ---------- ship control ---------- */
if(ship.alive){
const turn = 0.062 * shipSlow;
if(inp.aim !== null){
// touch stick: steer toward the thumb direction (reverse flips the target)
const target = inp.aim + (rev<0 ? Math.PI : 0);
let diff = ((target - ship.a + Math.PI*3) % TAU) - Math.PI;
ship.a += clamp(diff, -turn*2.2, turn*2.2);
} else {
if(inp.left) ship.a -= turn*rev;
if(inp.right) ship.a += turn*rev;
}
const thrusting = inp.up;
ship.thrust = lerp(ship.thrust, thrusting?1:0, 0.22);
if(thrusting){
const acc = 0.185 * shipSlow;
ship.vx += Math.cos(ship.a)*acc;
ship.vy += Math.sin(ship.a)*acc;
for(let i=0;i<2;i++){
const ea = ship.a + Math.PI + rnd(.45,-.45);
const es = rnd(3.6,1.6);
spark(ship.x + Math.cos(ship.a+Math.PI)*ship.r*0.85,
ship.y + Math.sin(ship.a+Math.PI)*ship.r*0.85,
Math.random()<0.45? '#9ff0ff' : (Math.random()<0.5?'#ffd88a':'#ff8f4c'),
{ vx: Math.cos(ea)*es + ship.vx*0.4, vy: Math.sin(ea)*es + ship.vy*0.4,
life: rnd(26,10), r: rnd(3.4,1.2), drag:0.93 });
}
}
if(inp.down){ ship.vx *= 0.975; ship.vy *= 0.975; }
if(inp.fire) fire();
Engine.set(ship.thrust);
} else {
Engine.set(0);
if(G.respawnT>0 && --G.respawnT<=0){
if(G.lives<=0){ gameOver(); return; }
resetShip();
}
}
if(ship.cool>0) ship.cool--;
if(ship.inv>0) ship.inv--;
if(ship.recoil>0.05) ship.recoil *= 0.8; else ship.recoil = 0;
/* ---------- ship physics ---------- */
if(ship.alive){
ship.vx *= 0.9915; ship.vy *= 0.9915;
const sp = Math.hypot(ship.vx,ship.vy), MAX=8.6;
if(sp>MAX){ ship.vx = ship.vx/sp*MAX; ship.vy = ship.vy/sp*MAX; }
const px = ship.x, py = ship.y;
ship.x += ship.vx*shipSlow; ship.y += ship.vy*shipSlow;
wrapPos(ship);
// break the trail when the ship teleports across a wrap seam
const jumped = Math.abs(ship.x-px) > W*0.5 || Math.abs(ship.y-py) > H*0.5;
ship.trail.push({x:ship.x, y:ship.y, a:ship.a, cut:jumped});
if(ship.trail.length>16) ship.trail.shift();
}
/* ---------- gravity wells ---------- */
for(let i=wells.length-1;i>=0;i--){
const w = wells[i];
w.life--; w.a += 0.03;
if(ship.alive){
const [dx,dy] = wrapDelta(ship.x,ship.y,w.x,w.y);
const d = Math.hypot(dx,dy);
if(d < w.r && d>1){
applyPull(ship, dx, dy, d, w.str * (1 - d/w.r) * 0.9);
}
}
if(G.time%2===0){
const a=rnd(TAU), rr=w.r*rnd(1,.5);
spark(w.x+Math.cos(a)*rr, w.y+Math.sin(a)*rr, '#8de7ff',
{vx:-Math.cos(a)*2.4, vy:-Math.sin(a)*2.4, life:26, r:1.6, drag:1});
}
if(w.life<=0){ ringFx(w.x,w.y,'#8de7ff',w.r,4,26,3); wells.splice(i,1); }
}
/* ---------- black holes ---------- */
for(let i=holes.length-1;i>=0;i--){
const h = holes[i];
h.life--; h.a += 0.09;
h.r = h.rMax * (0.75 + 0.18*Math.sin(h.a*1.7));
if(h.life<50) h.r *= h.life/50;
if(ship.alive){
const [dx,dy] = wrapDelta(ship.x,ship.y,h.x,h.y);
const d = Math.hypot(dx,dy);
if(d < h.pull && d>1){
applyPull(ship, dx, dy, d, 0.85*(1 - d/h.pull)*1.1);
if(d < h.r + ship.r*0.4) killShip();
}
}
for(let j=orbs.length-1;j>=0;j--){
const o=orbs[j];
const [dx,dy]=wrapDelta(o.x,o.y,h.x,h.y); const d=Math.hypot(dx,dy)||1;
if(d < h.pull){
applyPull(o, dx, dy, d, 0.7*(1-d/h.pull));
if(d < h.r*0.9){
h.eaten++;
addScore(40,o.x,o.y);
G.orbsBroken++;
burst(o.x,o.y,10,ORB_TYPES[o.kind].c1,2.2,{life:24});
orbs.splice(j,1);
Snd.pop();
}
}
}
for(let k=0;k<2;k++){
const a=rnd(TAU), rr=h.pull*rnd(.9,.3);
spark(h.x+Math.cos(a)*rr, h.y+Math.sin(a)*rr, Math.random()<.5?'#c07bff':'#5a2fa8',
{vx:-Math.cos(a)*3.4 - Math.sin(a)*2.4, vy:-Math.sin(a)*3.4 + Math.cos(a)*2.4,
life:30, r:rnd(2.4,1), drag:0.99});
}
if(h.life<=0){
ringFx(h.x,h.y,'#a24fff',6,300,40,5);
burst(h.x,h.y,40,'#c07bff',7,{life:50});
shake(10); holes.splice(i,1);
}
}
/* ---------- time bubbles ---------- */
for(let i=bubbles.length-1;i>=0;i--){
const b=bubbles[i]; b.life--; b.ph += 0.04;
if(b.life<=0){ ringFx(b.x,b.y,'#9dffc9',b.r,6,26,3); bubbles.splice(i,1); }
}
/* ---------- shockwaves (nova + pulse bomb) ---------- */
for(let i=waves.length-1;i>=0;i--){
const sw = waves[i];
sw.r += sw.speed;
if(sw.r > sw.rMax){ waves.splice(i,1); continue; }
for(let j=orbs.length-1;j>=0;j--){
const o = orbs[j];
if(sw.hit.has(o)) continue;
const d = wrapDist(o,sw);
if(d < sw.r + o.r*0.7 && d > sw.r - sw.band - o.r*0.7){
sw.hit.add(o);
const [dx,dy] = wrapDelta(sw.x,sw.y,o.x,o.y);
const m = Math.hypot(dx,dy)||1;
o.vx += (dx/m)*2.2; o.vy += (dy/m)*2.2;
o.hitFlash = 12;
o.hp -= sw.dmg;
if(o.hp<=0) destroyOrb(o);
else { burst(o.x,o.y,6,sw.col,3,{life:20}); Snd.pop(); }
}
}
for(let j=hunters.length-1;j>=0;j--){
const hh=hunters[j], d=wrapDist(hh,sw);
if(d < sw.r && d > sw.r - sw.band - 8){ burst(hh.x,hh.y,8,'#ffcb5e',3,{life:22}); addScore(35,hh.x,hh.y); hunters.splice(j,1); }
}
for(let j=shards.length-1;j>=0;j--){
const sh=shards[j], d=wrapDist(sh,sw);
if(d < sw.r && d > sw.r - sw.band - 8){ burst(sh.x,sh.y,7,'#dff8ff',3,{life:20}); addScore(25,sh.x,sh.y); shards.splice(j,1); }
}
for(let j=sentries.length-1;j>=0;j--){
const st=sentries[j], d=wrapDist(st,sw);
if(d < sw.r && d > sw.r - sw.band - 12){
st.hp -= sw.dmg;
if(st.hp<=0){ burst(st.x,st.y,16,'#8f9bff',4,{life:30}); addScore(80,st.x,st.y); sentries.splice(j,1); }
}
}
for(let j=enemyShots.length-1;j>=0;j--){
const es=enemyShots[j], d=wrapDist(es,sw);
if(d < sw.r && d > sw.r - sw.band - 8){ burst(es.x,es.y,5,'#b4a0ff',2.4,{life:16}); enemyShots.splice(j,1); }
}
if(ship.alive){
const [dx,dy]=wrapDelta(sw.x,sw.y,ship.x,ship.y);
const d=Math.hypot(dx,dy)||1;
if(Math.abs(d - sw.r) < sw.band){
ship.vx += (dx/d)*0.9; ship.vy += (dy/d)*0.9; // pushes you, never kills you
}
}
for(let k=0;k<3;k++){
const a=rnd(TAU);
spark(sw.x+Math.cos(a)*sw.r, sw.y+Math.sin(a)*sw.r, sw.col,
{vx:Math.cos(a)*2.2, vy:Math.sin(a)*2.2, life:20, r:rnd(2.6,1)});
}
}
/* ---------- orbs ---------- */
for(let i=orbs.length-1;i>=0;i--){
const o = orbs[i];
const s = slowAt(o.x,o.y);
o.t++;
o.a += o.spin*s;
o.pulse += 0.07*s;
o.wobble += 0.05*s;
if(o.hitFlash>0) o.hitFlash--;
for(const w of wells){
const [dx,dy]=wrapDelta(o.x,o.y,w.x,w.y); const d=Math.hypot(dx,dy);
if(d<w.r && d>1){ applyPull(o, dx, dy, d, w.str*0.4*(1-d/w.r)); }
}
// clamp runaway speed (wells + shockwaves + rubber boosts can stack)
const osp = Math.hypot(o.vx,o.vy);
if(osp > 11){ o.vx = o.vx/osp*11; o.vy = o.vy/osp*11; }
o.x += o.vx*s; o.y += o.vy*s;
if(o.kind==='rubber'){
// rubber orbs refuse to wrap — they slam off the walls instead
if(o.x < o.r){ o.x=o.r; o.vx=Math.abs(o.vx)*1.02; o.bounces++; Snd.bounce(); burst(o.x,o.y,6,'#c3ff5e',2.4,{life:20}); }
if(o.x > W-o.r){ o.x=W-o.r; o.vx=-Math.abs(o.vx)*1.02; o.bounces++; Snd.bounce(); burst(o.x,o.y,6,'#c3ff5e',2.4,{life:20}); }
if(o.y < o.r){ o.y=o.r; o.vy=Math.abs(o.vy)*1.02; o.bounces++; Snd.bounce(); burst(o.x,o.y,6,'#c3ff5e',2.4,{life:20}); }
if(o.y > H-o.r){ o.y=H-o.r; o.vy=-Math.abs(o.vy)*1.02; o.bounces++; Snd.bounce(); burst(o.x,o.y,6,'#c3ff5e',2.4,{life:20}); }
} else {
wrapPos(o);
}
if(o.chainT>=0){
o.chainT--;
o.hitFlash = 8;
if(o.chainT<0){ destroyOrb(o,true); continue; }
}
if(ship.alive && ship.inv<=0 && wrapDist(o,ship) < o.r*0.86 + ship.r*0.7) killShip();
}
/* ---------- bullets ---------- */
for(let i=bullets.length-1;i>=0;i--){
const b = bullets[i];
let dead = false;
for(const w of wells){
const [dx,dy]=wrapDelta(b.x,b.y,w.x,w.y); const d=Math.hypot(dx,dy);
if(d<w.r && d>1){ applyPull(b, dx, dy, d, w.str*1.5*(1-d/w.r)); }
}
for(const h of holes){
const [dx,dy]=wrapDelta(b.x,b.y,h.x,h.y); const d=Math.hypot(dx,dy);
if(d<h.pull && d>1){
applyPull(b, dx, dy, d, 1.5*(1-d/h.pull));
if(d<h.r*0.8){ burst(h.x,h.y,4,'#c07bff',1.6,{life:16}); dead = true; break; }
}
}
if(dead){ bullets.splice(i,1); continue; }
const s = slowAt(b.x,b.y);
b.x += b.vx*s; b.y += b.vy*s;
b.life -= s;
b.trail.push({x:b.x,y:b.y});
if(b.trail.length>9) b.trail.shift();
wrapPos(b);
if(b.life<=0){ bullets.splice(i,1); continue; }
for(let j=orbs.length-1;j>=0 && !dead;j--){
const o=orbs[j];
if(wrapDist(o,b) < o.r*0.92 + b.r){ hitOrb(o,b.x,b.y); dead=true; G.hits++; }
}
for(let j=hunters.length-1;j>=0 && !dead;j--){
const hh=hunters[j];
if(wrapDist(hh,b) < hh.r + b.r + 2){
burst(hh.x,hh.y,10,'#ffcb5e',3.2,{life:26});
Snd.pop(); addScore(35,hh.x,hh.y);
hunters.splice(j,1); dead=true; G.hits++;
}
}
for(let j=shards.length-1;j>=0 && !dead;j--){
const sh=shards[j];
if(wrapDist(sh,b) < sh.r + b.r + 2){
burst(sh.x,sh.y,9,'#dff8ff',3.4,{life:24});
Snd.pop(); addScore(25,sh.x,sh.y);
shards.splice(j,1); dead=true; G.hits++;
}
}
for(let j=sentries.length-1;j>=0 && !dead;j--){
const st=sentries[j];
if(wrapDist(st,b) < st.r + b.r){
st.hp--; dead=true; G.hits++;
burst(b.x,b.y,8,'#c3ccff',3,{life:22});
if(st.hp<=0){
burst(st.x,st.y,20,'#8f9bff',4.6,{life:36});
ringFx(st.x,st.y,'#7a86ff',6,90,26,3);
addScore(80,st.x,st.y); Snd.pop();
sentries.splice(j,1);
} else Snd.pop();
}
}
for(let j=enemyShots.length-1;j>=0 && !dead;j--){
const es=enemyShots[j];
if(wrapDist(es,b) < es.r + b.r + 2){
burst(es.x,es.y,6,'#b4a0ff',2.6,{life:18});
addScore(15,es.x,es.y);
enemyShots.splice(j,1); dead=true; G.hits++;
}
}
if(dead) bullets.splice(i,1);
}
/* ---------- hunters ---------- */
for(let i=hunters.length-1;i>=0;i--){
const h=hunters[i];
const s = slowAt(h.x,h.y);
h.life -= s;
if(h.life<=0){ burst(h.x,h.y,8,'#ffb454',2.2,{life:20}); hunters.splice(i,1); continue; }
if(ship.alive){
const [dx,dy]=wrapDelta(h.x,h.y,ship.x,ship.y);
const d=Math.hypot(dx,dy)||1;
h.vx += (dx/d)*h.spd; h.vy += (dy/d)*h.spd;
}
h.wig += 0.3;
h.vx += Math.cos(h.wig)*0.06; h.vy += Math.sin(h.wig*1.3)*0.06;
const sp=Math.hypot(h.vx,h.vy), M=3.4;
if(sp>M){ h.vx=h.vx/sp*M; h.vy=h.vy/sp*M; }
h.x += h.vx*s; h.y += h.vy*s;
h.a = Math.atan2(h.vy,h.vx);
wrapPos(h);
if(G.time%3===0) spark(h.x,h.y,'#ff9d2e',{vx:rnd(.6,-.6),vy:rnd(.6,-.6),life:16,r:1.7,drag:.92});
if(ship.alive && ship.inv<=0 && wrapDist(h,ship) < h.r+ship.r*0.72) killShip();
}
/* ---------- shards ---------- */
for(let i=shards.length-1;i>=0;i--){
const sh=shards[i];
const s=slowAt(sh.x,sh.y);
sh.life-=s; sh.rot += sh.rs*s;
if(sh.life<=0){ burst(sh.x,sh.y,5,'#dff8ff',1.8,{life:16}); shards.splice(i,1); continue; }
sh.x += sh.vx*s; sh.y += sh.vy*s;
let bounced=false;
if(sh.x<sh.r){ sh.x=sh.r; sh.vx=Math.abs(sh.vx); bounced=true; }
if(sh.x>W-sh.r){ sh.x=W-sh.r; sh.vx=-Math.abs(sh.vx); bounced=true; }
if(sh.y<sh.r){ sh.y=sh.r; sh.vy=Math.abs(sh.vy); bounced=true; }
if(sh.y>H-sh.r){ sh.y=H-sh.r; sh.vy=-Math.abs(sh.vy); bounced=true; }
if(bounced){ sh.bounce++; Snd.bounce(); burst(sh.x,sh.y,4,'#e8fbff',2,{life:16,r:1.4}); }
if(G.time%4===0) spark(sh.x,sh.y,'#bfeeff',{vx:rnd(.3,-.3),vy:rnd(.3,-.3),life:14,r:1.2,drag:.9});
if(ship.alive && ship.inv<=0 && wrapDist(sh,ship) < sh.r+ship.r*0.7) killShip();
}
/* ---------- sentries ---------- */
for(let i=sentries.length-1;i>=0;i--){
const st=sentries[i];
const s=slowAt(st.x,st.y);
st.life-=s;
if(st.life<=0){ burst(st.x,st.y,12,'#8f9bff',3,{life:26}); sentries.splice(i,1); continue; }
st.x += st.vx*s*0.5; st.y += st.vy*s*0.5;
st.vx *= 0.995; st.vy *= 0.995;
wrapPos(st);
if(ship.alive){
const [dx,dy]=wrapDelta(st.x,st.y,ship.x,ship.y);
st.a = Math.atan2(dy,dx);
st.cool -= s;
if(st.cool<=0){
st.cool = Math.max(52, 100 - G.wave*3);
const sp = 3.6;
enemyShots.push({ x:st.x+Math.cos(st.a)*st.r, y:st.y+Math.sin(st.a)*st.r,
vx:Math.cos(st.a)*sp, vy:Math.sin(st.a)*sp, r:4.6, life:200 });
Snd.turretShot();
burst(st.x+Math.cos(st.a)*st.r, st.y+Math.sin(st.a)*st.r, 5, '#b4a0ff', 2.4, {life:16});
}
if(ship.inv<=0 && wrapDist(st,ship) < st.r + ship.r*0.7) killShip();
}
}
/* ---------- enemy shots ---------- */
for(let i=enemyShots.length-1;i>=0;i--){
const es=enemyShots[i];
const s=slowAt(es.x,es.y);
es.life-=s;
es.x += es.vx*s; es.y += es.vy*s;
wrapPos(es);
if(G.time%2===0) spark(es.x,es.y,'#9d86ff',{vx:rnd(.4,-.4),vy:rnd(.4,-.4),life:14,r:1.5,drag:.9});
if(es.life<=0){ enemyShots.splice(i,1); continue; }
if(ship.alive && ship.inv<=0 && wrapDist(es,ship) < es.r + ship.r*0.66){
enemyShots.splice(i,1); killShip();
}
}
/* ---------- powerups ---------- */
for(let i=powerups.length-1;i>=0;i--){
const p=powerups[i];
p.life--; p.ph += 0.09; p.a += 0.03;
p.x += p.vx; p.y += p.vy;
p.vx *= 0.995; p.vy *= 0.995;
wrapPos(p);
if(G.time%4===0) spark(p.x,p.y,POWERS[p.kind].col,{vx:rnd(.5,-.5),vy:rnd(.5,-.5),life:18,r:1.5,drag:.92});
if(p.life<=0){ burst(p.x,p.y,8,POWERS[p.kind].col,2,{life:20}); powerups.splice(i,1); continue; }
if(ship.alive && wrapDist(p,ship) < p.r + ship.r + 4){
grantPower(p.kind, p.x, p.y);
powerups.splice(i,1);
}
}
/* ---------- particles / rings / floats ---------- */
for(let i=parts.length-1;i>=0;i--){
const p=parts[i];
if(--p.life<=0){ parts.splice(i,1); continue; }
p.vy += p.grav;
p.x += p.vx; p.y += p.vy;
p.vx *= p.drag; p.vy *= p.drag;
}
for(let i=rings.length-1;i>=0;i--){
const r=rings[i];
if(--r.life<=0){ rings.splice(i,1); continue; }
const t = 1 - r.life/r.max;
r.cur = lerp(r.r, r.r1, 1-Math.pow(1-t,2.6));
}
for(let i=floats.length-1;i>=0;i--){
const f=floats[i]; f.life--; f.y += f.vy; f.vy *= 0.96;
if(f.life<=0) floats.splice(i,1);
}
if(G.comboT>0){ G.comboT--; if(G.comboT===0) G.combo=0; }
/* ---------- wave flow ---------- */
if(orbs.length===0 && G.spawnGuard<=0){
G.spawnGuard = 80;
G.wave++;
const bonus = 500 + G.wave*120;
addScore(bonus);
floatText(W/2,H/2-40,'WAVE CLEAR +'+bonus,'#8ef7ff',24);
if(G.wave % 3 === 0) G.pulses = Math.min(3, G.pulses+1);
}
if(G.spawnGuard>0){
G.spawnGuard--;
if(G.spawnGuard===0 && orbs.length===0) spawnWave();
}
/* ---------- decay ---------- */
G.shake *= 0.88;
if(G.shake < 0.15) G.shake = 0;
G.shakeX = G.shake? rnd(G.shake,-G.shake) : 0;
G.shakeY = G.shake? rnd(G.shake,-G.shake) : 0;
G.flash *= 0.88;
if(toastT>0) toastT--;
if(G.waveBannerT>0) G.waveBannerT--;
}
/* ============================================================
RENDER
============================================================ */
function drawTwinkle(){
ctx.save();
for(const s of starsTwinkle){
s.tw += 0.03*s.tws;
ctx.globalAlpha = 0.22 + 0.6*(0.5+0.5*Math.sin(s.tw))*s.z;
ctx.fillStyle = s.z>0.7? '#dfeaff' : (s.z>0.4? '#9fb4e8' : '#6b7bb0');
ctx.fillRect(s.x, s.y, s.s, s.s);
}
ctx.restore();
}
/** orb silhouette in LOCAL space — caller has already translated to the centre */
function orbPath(o){
ctx.beginPath();
const n = o.verts.length;
for(let i=0;i<=n;i++){
const idx = i%n;
const ang = (idx/n)*TAU + o.a;
const wob = 1 + Math.sin(o.wobble + idx*1.7)*0.05;
const rr = o.r * o.verts[idx] * wob;
const x = Math.cos(ang)*rr, y = Math.sin(ang)*rr;
if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
}
ctx.closePath();
}
function drawOrbAt(o, ox, oy){
const T = ORB_TYPES[o.kind];
const pulse = 0.5 + 0.5*Math.sin(o.pulse);
ctx.save();
ctx.translate(o.x+ox, o.y+oy);
// outer glow (cached sprite)
blit(glowSprite(T.glow, 0.50, 0.16), 0, 0, o.r*1.85);
// body (cached local-space gradient)
orbPath(o);
ctx.fillStyle = orbBodyGrad(o.kind, o.r);
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = hexA(T.c1, 0.85);
ctx.stroke();
// interior decoration
ctx.save();
ctx.rotate(o.a);
const R = o.r;
switch(T.shape){
case 'hive': {
ctx.strokeStyle = hexA('#7a4400',0.55); ctx.lineWidth=1.4;
for(let k=0;k<3;k++){
const rr = R*(0.28+k*0.22);
ctx.beginPath();
for(let i=0;i<6;i++){ const a=i/6*TAU + k*0.3;
const x=Math.cos(a)*rr,y=Math.sin(a)*rr; i?ctx.lineTo(x,y):ctx.moveTo(x,y); }
ctx.closePath(); ctx.stroke();
}
ctx.fillStyle = hexA('#5a2f00',0.5);
for(let i=0;i<5;i++){ const a=i/5*TAU + o.t*0.02;
ctx.beginPath(); ctx.arc(Math.cos(a)*R*0.5, Math.sin(a)*R*0.5, R*0.09,0,TAU); ctx.fill(); }
break;
}
case 'ring': {
for(let k=0;k<4;k++){
ctx.strokeStyle = hexA('#eaffff', 0.5 - k*0.1);
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.ellipse(0,0, R*(0.28+k*0.19), R*(0.28+k*0.19)*(0.35+0.2*Math.sin(o.pulse+k)), k*0.5,0,TAU);
ctx.stroke();
}
break;
}
case 'glitch': {
ctx.lineWidth=2.4;
for(let k=0;k<5;k++){
ctx.strokeStyle = hexA(k%2?'#ffffff':'#dcc6ff', 0.5+0.3*pulse);
const a0 = (k/5)*TAU + o.t*0.05;
ctx.beginPath(); ctx.arc(0,0,R*(0.35+ (k%3)*0.2), a0, a0+0.9); ctx.stroke();
}
break;
}
case 'void': {
const vg = ctx.createRadialGradient(0,0,0,0,0,R);
vg.addColorStop(0,'#000000'); vg.addColorStop(0.6,'#150a28'); vg.addColorStop(1,'#3a1a66');
ctx.fillStyle=vg; ctx.beginPath(); ctx.arc(0,0,R*0.92,0,TAU); ctx.fill();
ctx.strokeStyle = hexA('#c07bff', 0.6+0.35*pulse); ctx.lineWidth=2;
for(let k=0;k<3;k++){
ctx.beginPath();
ctx.ellipse(0,0,R*(0.5+k*0.16), R*0.18, o.t*0.03 + k*1.1, 0, TAU); ctx.stroke();
}
break;
}
case 'spiral': {
ctx.strokeStyle = hexA('#ffffff',0.6); ctx.lineWidth=2.2;
ctx.beginPath();
for(let i=0;i<70;i++){
const t=i/70, a=t*TAU*2.6 + o.t*0.04, rr=R*0.92*t;
const x=Math.cos(a)*rr, y=Math.sin(a)*rr;
i?ctx.lineTo(x,y):ctx.moveTo(x,y);
}
ctx.stroke();
break;
}
case 'crystal': {
ctx.strokeStyle = hexA('#ffffff',0.72); ctx.lineWidth=1.5;
for(let i=0;i<7;i++){
const a=i/7*TAU;
ctx.beginPath(); ctx.moveTo(0,0);
ctx.lineTo(Math.cos(a)*R*0.92, Math.sin(a)*R*0.92); ctx.stroke();
}
ctx.beginPath();
for(let i=0;i<7;i++){ const a=i/7*TAU + .45;
const x=Math.cos(a)*R*0.55,y=Math.sin(a)*R*0.55; i?ctx.lineTo(x,y):ctx.moveTo(x,y); }
ctx.closePath(); ctx.strokeStyle=hexA('#ffffff',0.5); ctx.stroke();
break;
}
case 'clock': {
ctx.strokeStyle=hexA('#04321f',0.7); ctx.lineWidth=2.6;
ctx.beginPath(); ctx.arc(0,0,R*0.62,0,TAU); ctx.stroke();
ctx.lineWidth=3;
const t1 = o.t*0.05, t2 = o.t*0.012;
ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(Math.cos(t1)*R*0.5, Math.sin(t1)*R*0.5); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(Math.cos(t2)*R*0.34, Math.sin(t2)*R*0.34); ctx.stroke();
ctx.fillStyle=hexA('#04321f',0.55);
for(let i=0;i<12;i++){ const a=i/12*TAU;
ctx.fillRect(Math.cos(a)*R*0.74-1, Math.sin(a)*R*0.74-1, 2.4,2.4); }
break;
}
case 'bomb': {
ctx.fillStyle = hexA('#ffe08a', 0.55+0.4*pulse);
ctx.beginPath(); ctx.arc(0,0,R*0.34*(0.85+0.25*pulse),0,TAU); ctx.fill();
ctx.strokeStyle=hexA('#ffd9b0',0.8); ctx.lineWidth=2.4;
for(let i=0;i<6;i++){ const a=i/6*TAU + o.t*0.03;
ctx.beginPath();
ctx.moveTo(Math.cos(a)*R*0.46, Math.sin(a)*R*0.46);
ctx.lineTo(Math.cos(a)*R*0.82, Math.sin(a)*R*0.82); ctx.stroke(); }
break;
}
case 'mirror': {
ctx.save();
ctx.beginPath(); ctx.arc(0,0,R*0.92,0,TAU); ctx.clip();
ctx.fillStyle = hexA('#ffffff',0.35);
ctx.beginPath(); ctx.moveTo(-R,-R); ctx.lineTo(R,-R); ctx.lineTo(-R,R); ctx.closePath(); ctx.fill();
ctx.strokeStyle=hexA('#ffffff',0.85); ctx.lineWidth=2;
ctx.beginPath(); ctx.moveTo(R*0.9,-R*0.9); ctx.lineTo(-R*0.9,R*0.9); ctx.stroke();
ctx.restore();
break;
}
case 'rubber': {
ctx.save();
ctx.beginPath(); ctx.arc(0,0,R*0.9,0,TAU); ctx.clip();
ctx.strokeStyle=hexA('#2f5c04',0.5); ctx.lineWidth=R*0.16;
for(let i=-3;i<=3;i++){ ctx.beginPath(); ctx.moveTo(-R, i*R*0.4); ctx.lineTo(R, i*R*0.4 + R*0.5); ctx.stroke(); }
ctx.restore();
break;
}
case 'star': {
ctx.fillStyle = hexA('#fff6d0', 0.5+0.45*pulse);
ctx.beginPath();
for(let i=0;i<12;i++){
const a = i/12*TAU + o.t*0.02;
const rr = R*(i%2? 0.30 : 0.86);
const x=Math.cos(a)*rr, y=Math.sin(a)*rr;
i?ctx.lineTo(x,y):ctx.moveTo(x,y);
}
ctx.closePath(); ctx.fill();
ctx.strokeStyle = hexA('#ff7a18',0.8); ctx.lineWidth=1.6; ctx.stroke();
ctx.fillStyle = hexA('#ffffff',0.85);
ctx.beginPath(); ctx.arc(0,0,R*0.18*(0.8+0.4*pulse),0,TAU); ctx.fill();
break;
}
case 'eye': {
ctx.fillStyle = hexA('#0c1030',0.85);
ctx.beginPath(); ctx.ellipse(0,0,R*0.78,R*0.5,0,0,TAU); ctx.fill();
ctx.strokeStyle = hexA('#c3ccff',0.75); ctx.lineWidth=2; ctx.stroke();
const look = o.t*0.03;
ctx.fillStyle = '#7a86ff';
ctx.beginPath(); ctx.arc(Math.cos(look)*R*0.26, Math.sin(look)*R*0.14, R*0.24,0,TAU); ctx.fill();
ctx.fillStyle = '#04061a';
ctx.beginPath(); ctx.arc(Math.cos(look)*R*0.26, Math.sin(look)*R*0.14, R*0.11,0,TAU); ctx.fill();
ctx.strokeStyle = hexA('#7a86ff',0.45); ctx.lineWidth=1.4;
for(let i=0;i<4;i++){
const a=i/4*TAU + o.t*0.02;
ctx.beginPath();
ctx.moveTo(Math.cos(a)*R*0.82, Math.sin(a)*R*0.82);
ctx.lineTo(Math.cos(a)*R*0.95, Math.sin(a)*R*0.95); ctx.stroke();
}
break;
}
case 'portal': {
ctx.strokeStyle = hexA('#ffffff',0.55); ctx.lineWidth=2;
for(let k=0;k<3;k++){
ctx.beginPath();
for(let i=0;i<48;i++){
const t=i/48, a=t*TAU*1.6 + o.t*0.06 + k*2.1, rr=R*0.9*(1-t*0.8);
const x=Math.cos(a)*rr, y=Math.sin(a)*rr;
i?ctx.lineTo(x,y):ctx.moveTo(x,y);
}
ctx.stroke();
}
const pg = ctx.createRadialGradient(0,0,0,0,0,R*0.5);
pg.addColorStop(0,'rgba(255,255,255,.9)'); pg.addColorStop(1,'rgba(47,232,213,0)');
ctx.fillStyle=pg; ctx.beginPath(); ctx.arc(0,0,R*0.5,0,TAU); ctx.fill();
break;
}
}
ctx.restore();
// specular
ctx.beginPath();
ctx.arc(-o.r*0.33, -o.r*0.38, o.r*0.2, 0, TAU);
ctx.fillStyle = 'rgba(255,255,255,.55)'; ctx.fill();
// elder crown
if(o.elder){
ctx.strokeStyle = `rgba(255,240,180,${0.45+0.35*Math.abs(Math.sin(G.time*0.06))})`;
ctx.lineWidth = 3;
ctx.setLineDash([10,9]);
ctx.beginPath(); ctx.arc(0,0,o.r+13,0,TAU); ctx.stroke();
ctx.setLineDash([]);
}
// hp pips
if(o.maxhp>1 && o.hp<o.maxhp){
const n=Math.min(o.maxhp,10);
for(let i=0;i<n;i++){
const a = -Math.PI/2 + (i-(n-1)/2)*0.24;
ctx.beginPath(); ctx.arc(Math.cos(a)*(o.r+11), Math.sin(a)*(o.r+11), 2.6, 0, TAU);
ctx.fillStyle = i<o.hp ? T.c1 : 'rgba(255,255,255,.16)';
ctx.fill();
}
}
// chain fuse warning
if(o.chainT>=0){
ctx.strokeStyle = `rgba(255,120,80,${0.4+0.6*Math.abs(Math.sin(G.time*0.5))})`;
ctx.lineWidth = 3;
ctx.beginPath(); ctx.arc(0,0,o.r+7,0,TAU); ctx.stroke();
}
// hit flash
if(o.hitFlash>0){
ctx.globalCompositeOperation='lighter';
ctx.fillStyle = `rgba(255,255,255,${o.hitFlash/22})`;
orbPath(o); ctx.fill();
ctx.globalCompositeOperation='source-over';
}
ctx.restore();
}
function drawOrbs(){
for(const o of orbs){
if(o.kind==='rubber') drawOrbAt(o,0,0); // never wraps, never needs a mirror copy
else wrapDraw(o.x,o.y,o.r*2.4,(ox,oy)=> drawOrbAt(o,ox,oy));
}
}
function shipPath(r){
ctx.beginPath();
ctx.moveTo(r*1.35, 0);
ctx.lineTo(-r*0.72, -r*0.82);
ctx.lineTo(-r*0.34, 0);
ctx.lineTo(-r*0.72, r*0.82);
ctx.closePath();
}
function drawShipAt(ox,oy){
const blink = ship.inv>0 && (Math.floor(G.time/4)%2===0);
ctx.save();
ctx.translate(ship.x+ox, ship.y+oy);
ctx.rotate(ship.a);
if(ship.recoil>0.2) ctx.translate(-ship.recoil,0);
blit(glowSprite('#78f0ff', 0.45, 0.16), 0, 0, 34);
if(ship.thrust>0.05){
const fl = (0.7+0.5*Math.random())*ship.thrust;
if(!shipFlameGrad){
shipFlameGrad = ctx.createLinearGradient(-ship.r, 0, -ship.r-30, 0);
shipFlameGrad.addColorStop(0,'rgba(255,255,255,.95)');
shipFlameGrad.addColorStop(0.3,'rgba(120,230,255,.85)');
shipFlameGrad.addColorStop(0.7,'rgba(255,150,60,.6)');
shipFlameGrad.addColorStop(1,'rgba(255,60,20,0)');
}
ctx.fillStyle=shipFlameGrad;
ctx.beginPath();
ctx.moveTo(-ship.r*0.62, -5.4);
ctx.lineTo(-ship.r - 30*fl, 0);
ctx.lineTo(-ship.r*0.62, 5.4);
ctx.closePath(); ctx.fill();
}
if(!blink){
shipPath(ship.r);
if(!shipHullGrad){
shipHullGrad = ctx.createLinearGradient(-ship.r,0,ship.r,0);
shipHullGrad.addColorStop(0,'#1a2a52'); shipHullGrad.addColorStop(0.55,'#dff2ff'); shipHullGrad.addColorStop(1,'#ffffff');
}
ctx.fillStyle=shipHullGrad; ctx.fill();
ctx.lineWidth=2; ctx.strokeStyle= G.reverseT>0? '#ff62c0' : '#7df9ff'; ctx.stroke();
ctx.beginPath(); ctx.ellipse(2,0,4.4,3,0,0,TAU);
ctx.fillStyle='#0d2b45'; ctx.fill();
ctx.strokeStyle='rgba(160,240,255,.9)'; ctx.lineWidth=1; ctx.stroke();
}
if(ship.shield>0){
const a = 0.35+0.28*Math.abs(Math.sin(G.time*0.08));
ctx.beginPath(); ctx.arc(0,0, ship.r+12, 0, TAU);
ctx.strokeStyle = `rgba(142,255,192,${a})`; ctx.lineWidth=2.6; ctx.stroke();
ctx.beginPath(); ctx.arc(0,0, ship.r+12, 0, TAU);
ctx.fillStyle = `rgba(142,255,192,${a*0.10})`; ctx.fill();
}
if(ship.inv>0){
ctx.beginPath(); ctx.arc(0,0, ship.r+9, 0, TAU);
ctx.strokeStyle = `rgba(140,240,255,${0.25+0.35*Math.abs(Math.sin(G.time*0.16))})`;
ctx.lineWidth=2; ctx.stroke();
}
ctx.restore();
}
function drawShip(){
if(!ship.alive) return;
// trail ghosts (wrap-safe: a segment flagged `cut` is skipped)
ctx.save();
for(let i=0;i<ship.trail.length;i+=2){
const t = ship.trail[i];
if(t.cut) continue;
if(Math.abs(t.x-ship.x)>W*0.5 || Math.abs(t.y-ship.y)>H*0.5) continue;
const a = (i/ship.trail.length)*0.20;
ctx.save(); ctx.translate(t.x,t.y); ctx.rotate(t.a);
ctx.globalAlpha=a; ctx.fillStyle='#5ce8ff';
shipPath(ship.r*0.9); ctx.fill(); ctx.restore();
}
ctx.restore();
wrapDraw(ship.x, ship.y, 46, drawShipAt);
}
function drawBullets(){
ctx.save();
ctx.globalCompositeOperation='lighter';
for(const b of bullets){
for(let i=0;i<b.trail.length;i++){
const t=b.trail[i];
const a=(i/b.trail.length);
if(Math.abs(t.x-b.x)>W/2 || Math.abs(t.y-b.y)>H/2) continue;
ctx.beginPath(); ctx.arc(t.x,t.y, b.r*a*0.95, 0, TAU);
ctx.fillStyle=`rgba(110,230,255,${a*0.42})`; ctx.fill();
}
blit(bulletSprite(), b.x, b.y, b.r*4.5);
ctx.fillStyle='#ffffff'; ctx.beginPath(); ctx.arc(b.x,b.y,b.r*0.8,0,TAU); ctx.fill();
}
ctx.restore();
}
function drawHunters(){
for(const h of hunters){
wrapDraw(h.x,h.y,22,(ox,oy)=>{
const fade = Math.min(1, h.life/70);
ctx.save(); ctx.translate(h.x+ox,h.y+oy); ctx.rotate(h.a);
ctx.globalAlpha = fade;
blit(glowSprite('#ffbe50', 0.7, 0.26), 0, 0, 16);
ctx.beginPath();
ctx.moveTo(9,0); ctx.lineTo(-5,-5.4); ctx.lineTo(-2.4,0); ctx.lineTo(-5,5.4);
ctx.closePath();
ctx.fillStyle='#ffd66b'; ctx.fill();
ctx.strokeStyle='#ff8c1e'; ctx.lineWidth=1.4; ctx.stroke();
ctx.beginPath(); ctx.arc(3,0,1.7,0,TAU); ctx.fillStyle='#5a1500'; ctx.fill();
ctx.restore();
});
}
}
function drawShards(){
for(const s of shards){
const fade=Math.min(1,s.life/70);
ctx.save(); ctx.translate(s.x,s.y); ctx.rotate(s.rot); ctx.globalAlpha=fade;
ctx.globalCompositeOperation='lighter';
blit(glowSprite('#dcfaff', 0.65, 0.24), 0, 0, 14);
ctx.globalCompositeOperation='source-over';
ctx.beginPath();
ctx.moveTo(0,-7.5); ctx.lineTo(5.6,1.4); ctx.lineTo(1.7,7.4); ctx.lineTo(-4.4,3.4); ctx.closePath();
const lg=ctx.createLinearGradient(-6,-6,6,6);
lg.addColorStop(0,'#ffffff'); lg.addColorStop(0.5,'#c9f4ff'); lg.addColorStop(1,'#6fa9c4');
ctx.fillStyle=lg; ctx.fill();
ctx.strokeStyle='rgba(255,255,255,.9)'; ctx.lineWidth=1; ctx.stroke();
ctx.restore();
}
}
function drawSentries(){
for(const s of sentries){
wrapDraw(s.x,s.y,40,(ox,oy)=>{
const fade = Math.min(1, s.life/70);
ctx.save(); ctx.translate(s.x+ox,s.y+oy); ctx.globalAlpha=fade;
blit(glowSprite('#7886ff', 0.34, 0.12), 0, 0, 34);
ctx.save();
ctx.rotate(s.a);
ctx.fillStyle='#39408f';
ctx.fillRect(0, -4, s.r+11, 8);
ctx.fillStyle='#8f9bff';
ctx.fillRect(s.r+3, -3, 8, 6);
ctx.restore();
ctx.beginPath(); ctx.arc(0,0,s.r,0,TAU);
ctx.fillStyle = sentryBodyGrad(s.r); ctx.fill();
ctx.strokeStyle='#c3ccff'; ctx.lineWidth=1.8; ctx.stroke();
const ready = s.cool < 22;
ctx.beginPath(); ctx.arc(0,0,s.r*0.36,0,TAU);
ctx.fillStyle = ready ? `rgba(255,90,120,${0.6+0.4*Math.abs(Math.sin(G.time*0.4))})` : 'rgba(160,175,255,.85)';
ctx.fill();
if(s.hp<2){
ctx.strokeStyle='rgba(255,120,120,.8)'; ctx.lineWidth=2;
ctx.beginPath(); ctx.arc(0,0,s.r+5,-0.6,1.4); ctx.stroke();
}
ctx.restore();
});
}
}
function drawEnemyShots(){
ctx.save();
ctx.globalCompositeOperation='lighter';
for(const e of enemyShots){
blit(enemyShotSprite(), e.x, e.y, e.r*3.4);
ctx.fillStyle='#efe6ff'; ctx.beginPath(); ctx.arc(e.x,e.y,e.r*0.6,0,TAU); ctx.fill();
}
ctx.restore();
}
function drawPowerups(){
for(const p of powerups){
const P = POWERS[p.kind];
const blink = p.life<120 && Math.floor(p.life/6)%2===0;
if(blink) continue;
wrapDraw(p.x,p.y,36,(ox,oy)=>{
const x=p.x+ox, y=p.y+oy;
const bob = Math.sin(p.ph)*2.5;
ctx.save();
ctx.translate(x,y+bob);
blit(glowSprite(P.col, 0.5, 0.18), 0, 0, 26);
ctx.rotate(p.a);
const s=p.r;
ctx.beginPath();
ctx.moveTo(-s,0); ctx.lineTo(0,-s); ctx.lineTo(s,0); ctx.lineTo(0,s); ctx.closePath();
ctx.fillStyle='rgba(8,10,26,.9)'; ctx.fill();
ctx.strokeStyle=P.col; ctx.lineWidth=2.2; ctx.stroke();
ctx.rotate(-p.a);
ctx.fillStyle=P.col;
ctx.font='900 13px "Trebuchet MS", sans-serif';
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText(P.glyph, 0, 0.5);
ctx.restore();
});
}
}
function drawWells(){
for(const w of wells){
const f = Math.min(1, w.life/60);
ctx.save();
ctx.globalAlpha = 0.85*f;
const g = ctx.createRadialGradient(w.x,w.y, w.r*0.05, w.x,w.y, w.r);
g.addColorStop(0,'rgba(190,250,255,.42)');
g.addColorStop(0.35,'rgba(70,180,255,.16)');
g.addColorStop(1,'rgba(30,80,220,0)');
ctx.fillStyle=g; ctx.beginPath(); ctx.arc(w.x,w.y,w.r,0,TAU); ctx.fill();
ctx.strokeStyle='rgba(140,235,255,.32)'; ctx.lineWidth=1.5;
for(let k=0;k<4;k++){
ctx.beginPath();
const rr = w.r*(0.22+k*0.2) * (0.9+0.1*Math.sin(w.a*2+k));
ctx.ellipse(w.x,w.y, rr, rr*0.55, w.a + k*0.7, 0, TAU); ctx.stroke();
}
const cg=ctx.createRadialGradient(w.x,w.y,0,w.x,w.y,22);
cg.addColorStop(0,'rgba(255,255,255,.9)'); cg.addColorStop(1,'rgba(90,200,255,0)');
ctx.fillStyle=cg; ctx.beginPath(); ctx.arc(w.x,w.y,22,0,TAU); ctx.fill();
ctx.restore();
}
}
function drawHoles(){
for(const h of holes){
ctx.save();
blit(glowSprite('#be6eff', 0.36, 0.14), h.x, h.y, h.pull*0.58);
ctx.globalCompositeOperation='lighter';
for(let k=0;k<5;k++){
ctx.strokeStyle=`rgba(${180+k*10},${90+k*20},255,${0.30-k*0.045})`;
ctx.lineWidth = 3.4-k*0.5;
ctx.beginPath();
const rr = h.r*(1.6+k*0.9);
ctx.ellipse(h.x,h.y, rr, rr*0.42, h.a*(1+k*0.3), 0.3, 0.3+TAU*0.78);
ctx.stroke();
}
ctx.globalCompositeOperation='source-over';
ctx.beginPath(); ctx.arc(h.x,h.y,Math.max(2,h.r),0,TAU);
ctx.fillStyle='#000'; ctx.fill();
ctx.strokeStyle='rgba(220,160,255,.85)'; ctx.lineWidth=2.4; ctx.stroke();
ctx.beginPath(); ctx.arc(h.x,h.y,Math.max(3,h.r*1.22),0,TAU);
ctx.strokeStyle=`rgba(255,220,255,${0.25+0.2*Math.sin(h.a*3)})`; ctx.lineWidth=1.4; ctx.stroke();
ctx.restore();
}
}
function drawBubbles(){
for(const b of bubbles){
const f = Math.min(1, b.life/80);
ctx.save();
ctx.globalAlpha = f;
const g=ctx.createRadialGradient(b.x,b.y,b.r*0.2,b.x,b.y,b.r);
g.addColorStop(0,'rgba(120,255,190,.05)');
g.addColorStop(0.75,'rgba(90,240,180,.10)');
g.addColorStop(0.96,'rgba(150,255,210,.28)');
g.addColorStop(1,'rgba(150,255,210,0)');
ctx.fillStyle=g; ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,TAU); ctx.fill();
ctx.strokeStyle=`rgba(160,255,215,${0.35+0.2*Math.sin(b.ph*2)})`;
ctx.lineWidth=2; ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,TAU); ctx.stroke();
ctx.strokeStyle='rgba(160,255,215,.22)'; ctx.lineWidth=1.4;
for(let i=0;i<16;i++){
const a=i/16*TAU + b.ph*0.3;
ctx.beginPath();
ctx.moveTo(b.x+Math.cos(a)*b.r*0.88, b.y+Math.sin(a)*b.r*0.88);
ctx.lineTo(b.x+Math.cos(a)*b.r*0.96, b.y+Math.sin(a)*b.r*0.96);
ctx.stroke();
}
ctx.restore();
}
}
function drawShockwaves(){
ctx.save();
ctx.globalCompositeOperation='lighter';
for(const sw of waves){
const t = 1 - sw.r/sw.rMax;
ctx.globalAlpha = clamp(t*1.1,0,1);
ctx.strokeStyle = sw.col;
ctx.lineWidth = 3 + 9*t;
ctx.beginPath(); ctx.arc(sw.x,sw.y,sw.r,0,TAU); ctx.stroke();
ctx.globalAlpha = clamp(t*0.5,0,1);
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(sw.x,sw.y,Math.max(1,sw.r - sw.band),0,TAU); ctx.stroke();
}
ctx.restore();
}
function drawParticles(){
ctx.save();
ctx.globalCompositeOperation='lighter';
for(const p of parts){
const a = p.life/p.max;
ctx.globalAlpha = Math.min(1,a*1.3);
ctx.fillStyle = p.col;
ctx.beginPath();
ctx.arc(p.x,p.y, Math.max(0.3, p.r*a), 0, TAU);
ctx.fill();
}
ctx.restore();
}
function drawRings(){
ctx.save();
ctx.globalCompositeOperation='lighter';
for(const r of rings){
const t = r.life/r.max;
ctx.globalAlpha = t*0.85;
ctx.strokeStyle = r.col;
ctx.lineWidth = Math.max(0.4, r.w*t);
ctx.beginPath(); ctx.arc(r.x,r.y, Math.max(1,r.cur), 0, TAU); ctx.stroke();
}
ctx.restore();
}
function drawFloats(){
ctx.save();
ctx.textAlign='center'; ctx.textBaseline='middle';
for(const f of floats){
const a = Math.min(1, f.life/28);
ctx.globalAlpha = a;
ctx.font = `900 ${f.size}px "Trebuchet MS", sans-serif`;
ctx.lineWidth=4; ctx.strokeStyle='rgba(0,0,0,.6)';
ctx.strokeText(f.txt, f.x, f.y);
ctx.fillStyle=f.col;
ctx.shadowColor=f.col; ctx.shadowBlur=14;
ctx.fillText(f.txt, f.x, f.y);
ctx.shadowBlur=0;
}
ctx.restore();
}
/* ---------------- HUD ---------------- */
function drawMeter(x,y,w,pct,col,label){
ctx.fillStyle='rgba(255,255,255,.12)';
ctx.fillRect(x,y,w,5);
ctx.fillStyle=col;
ctx.fillRect(x,y,w*clamp(pct,0,1),5);
ctx.font='700 9px "Trebuchet MS", sans-serif';
ctx.fillStyle=col; ctx.textAlign='left';
ctx.fillText(label, x, y-6);
}
function drawHUD(){
ctx.save();
ctx.textBaseline='top';
// score
const sc = String(G.score).padStart(6,'0');
ctx.font='900 30px "Trebuchet MS", sans-serif';
ctx.textAlign='left';
ctx.fillStyle='rgba(0,0,0,.5)'; ctx.fillText(sc, 21,19);
ctx.fillStyle='#eaf4ff'; ctx.shadowColor='#5ad4ff'; ctx.shadowBlur=14;
ctx.fillText(sc, 20,18);
ctx.shadowBlur=0;
ctx.font='700 12px "Trebuchet MS", sans-serif';
ctx.fillStyle='#7f8fbd';
ctx.fillText('BEST ' + G.best.toLocaleString(), 21, 52);
// wave + orb count
ctx.textAlign='right';
ctx.font='900 22px "Trebuchet MS", sans-serif';
ctx.fillStyle='#c9d8ff'; ctx.shadowColor='#8a6bff'; ctx.shadowBlur=12;
ctx.fillText('WAVE ' + G.wave, W-20, 20);
ctx.shadowBlur=0;
ctx.font='700 12px "Trebuchet MS", sans-serif';
ctx.fillStyle='#7f8fbd';
ctx.fillText(orbs.length + ' ORBS', W-21, 48);
// lives
const shown = Math.min(G.lives, 8);
for(let i=0;i<shown;i++){
ctx.save();
ctx.translate(30 + i*26, 84);
ctx.rotate(-Math.PI/2);
ctx.scale(0.72,0.72);
shipPath(11);
ctx.fillStyle='#9ff0ff'; ctx.fill();
ctx.strokeStyle='#2b7fa8'; ctx.lineWidth=1.6; ctx.stroke();
ctx.restore();
}
if(G.lives>8){
ctx.textAlign='left'; ctx.font='800 13px "Trebuchet MS", sans-serif';
ctx.fillStyle='#9ff0ff'; ctx.fillText('+'+(G.lives-8), 30+8*26, 78);
}
// pulse charges
ctx.textAlign='left';
for(let i=0;i<3;i++){
const x = 22 + i*17, y = 108;
ctx.beginPath();
ctx.moveTo(x, y+6); ctx.lineTo(x+6, y-6); ctx.lineTo(x+12, y+6); ctx.closePath();
ctx.fillStyle = i<G.pulses ? '#ff8ad4' : 'rgba(255,138,212,.16)';
ctx.fill();
}
ctx.font='700 9px "Trebuchet MS", sans-serif';
ctx.fillStyle='#8b6f9e'; ctx.fillText(touch.on ? 'PULSE' : 'PULSE (SHIFT)', 22, 118);
// active power meters
let my = 138;
if(G.spreadT>0){ drawMeter(22,my,90,G.spreadT/1800,'#7df9ff','SPREAD'); my+=22; }
if(G.rapidT>0){ drawMeter(22,my,90,G.rapidT/1800,'#ffd76b','RAPID'); my+=22; }
if(G.hyperCool>0){ drawMeter(22,my,90,1-G.hyperCool/260,'#8ef7ff','WARP DRIVE'); my+=22; }
if(ship.shield>0){
ctx.font='800 11px "Trebuchet MS", sans-serif';
ctx.fillStyle='#8effc0'; ctx.fillText('◇ SHIELD ACTIVE', 22, my); my+=18;
}
// combo
if(G.combo>1){
ctx.textAlign='left';
ctx.font='900 17px "Trebuchet MS", sans-serif';
ctx.globalAlpha = Math.min(1,G.comboT/30);
ctx.fillStyle='#ffd76b'; ctx.shadowColor='#ff9d2e'; ctx.shadowBlur=12;
ctx.fillText('x'+G.combo+' CHAIN', 22, my);
ctx.shadowBlur=0; ctx.globalAlpha=1;
}
// reverse warning
if(G.reverseT>0){
const a = 0.6+0.4*Math.sin(G.time*0.3);
ctx.textAlign='center';
ctx.font='900 clamp(16px,4vw,24px) "Trebuchet MS", sans-serif';
ctx.fillStyle=`rgba(255,98,192,${a})`;
ctx.shadowColor='#ff62c0'; ctx.shadowBlur=20;
ctx.fillText('CONTROLS REVERSED', W/2, H-58);
ctx.shadowBlur=0;
const bw=Math.min(240,W*0.6), pct=G.reverseT/G.reverseMax;
ctx.fillStyle='rgba(255,98,192,.22)'; ctx.fillRect(W/2-bw/2, H-26, bw, 6);
ctx.fillStyle='#ff62c0'; ctx.fillRect(W/2-bw/2, H-26, bw*clamp(pct,0,1), 6);
}
// wave banner
if(G.waveBannerT>0){
const t = G.waveBannerT/140;
const a = t>0.78? (1-t)/0.22 : Math.min(1, t/0.35);
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.globalAlpha = clamp(a,0,1);
const fs = Math.min(62, W*0.11);
ctx.font=`900 ${fs}px "Trebuchet MS", sans-serif`;
ctx.strokeStyle='rgba(0,0,0,.55)'; ctx.lineWidth=8;
ctx.strokeText(G.waveBannerTxt, W/2, H*0.30);
const grd = ctx.createLinearGradient(0,H*0.30-34,0,H*0.30+34);
grd.addColorStop(0,'#ffffff'); grd.addColorStop(.5,'#8ef7ff'); grd.addColorStop(1,'#c56bff');
ctx.fillStyle=grd; ctx.shadowColor='#7df9ff'; ctx.shadowBlur=26;
ctx.fillText(G.waveBannerTxt, W/2, H*0.30);
ctx.shadowBlur=0;
if(G.waveBannerSub){
ctx.font='900 15px "Trebuchet MS", sans-serif';
ctx.fillStyle='#ffd76b';
ctx.fillText(G.waveBannerSub, W/2, H*0.30 + fs*0.72);
}
ctx.globalAlpha=1;
}
// toast
if(toastT>0){
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.globalAlpha = Math.min(1,toastT/25);
ctx.font='800 15px "Trebuchet MS", sans-serif';
ctx.fillStyle='#9fe9ff';
ctx.fillText(toastTxt, W/2, 34);
ctx.globalAlpha=1;
}
// touch controls
if(touch.on){
ctx.globalAlpha = 0.5;
for(const b of touchButtons()){
ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,TAU);
ctx.strokeStyle = b.id==='pulse'? '#ff8ad4' : '#8ef7ff';
ctx.lineWidth=2; ctx.stroke();
ctx.fillStyle='rgba(10,12,28,.5)'; ctx.fill();
ctx.fillStyle = b.id==='pulse'? '#ff8ad4' : '#8ef7ff';
ctx.font='800 9px "Trebuchet MS", sans-serif';
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText(b.label, b.x, b.y);
}
if(touch.stickId!==-1){
const dx=touch.cx-touch.ox, dy=touch.cy-touch.oy;
const m=Math.min(Math.hypot(dx,dy),56), a=Math.atan2(dy,dx);
ctx.globalAlpha=0.35;
ctx.strokeStyle='#8ef7ff'; ctx.lineWidth=2;
ctx.beginPath(); ctx.arc(touch.ox,touch.oy,56,0,TAU); ctx.stroke();
ctx.fillStyle='rgba(142,247,255,.4)';
ctx.beginPath(); ctx.arc(touch.ox+Math.cos(a)*m, touch.oy+Math.sin(a)*m, 22,0,TAU); ctx.fill();
}
ctx.globalAlpha=1;
}
ctx.restore();
}
function drawVignette(){
ctx.fillStyle = vignetteGrad; ctx.fillRect(0,0,W,H);
if(holes.length){
ctx.save();
ctx.globalAlpha = 0.10 + 0.05*Math.sin(G.time*0.09);
ctx.fillStyle = dangerGrad; ctx.fillRect(0,0,W,H);
ctx.restore();
}
}
function render(){
ctx.drawImage(bgc, 0,0, W,H);
drawTwinkle();
ctx.save();
ctx.translate(G.shakeX, G.shakeY);
drawWells();
drawBubbles();
drawHoles();
drawParticles();
drawRings();
drawShockwaves();
drawOrbs();
drawPowerups();
drawShards();
drawSentries();
drawHunters();
drawEnemyShots();
drawBullets();
drawShip();
drawFloats();
ctx.restore();
if(G.flash>0.004){
ctx.save();
ctx.globalAlpha = Math.min(0.7, G.flash);
ctx.fillStyle = G.flashCol;
ctx.globalCompositeOperation='lighter';
ctx.fillRect(0,0,W,H);
ctx.restore();
}
drawVignette();
if(G.state==='play' || G.state==='pause') drawHUD();
}
/* ============================================================
ATTRACT MODE (start / game-over backdrop)
============================================================ */
function attractTick(){
if(orbs.length<8 && G.time%14===0){
orbs.push(makeOrbForAttract());
}
for(const o of orbs){
o.t++; o.a+=o.spin; o.pulse+=0.06; o.wobble+=0.04;
o.x+=o.vx*0.55; o.y+=o.vy*0.55;
wrapPos(o);
}
for(let i=parts.length-1;i>=0;i--){
const p=parts[i]; if(--p.life<=0){parts.splice(i,1);continue;}
p.x+=p.vx;p.y+=p.vy;p.vx*=p.drag;p.vy*=p.drag;
}
for(let i=rings.length-1;i>=0;i--){
const r=rings[i]; if(--r.life<=0){rings.splice(i,1);continue;}
const t=1-r.life/r.max; r.cur=lerp(r.r,r.r1,1-Math.pow(1-t,2.6));
}
G.shake*=0.9; G.shakeX=0; G.shakeY=0; G.flash*=0.9;
G.time++;
}
function makeOrbForAttract(){
const o = makeOrb(rnd(W), rnd(H), pick([2,3]), pick(ORB_KEYS));
o.chainT = -1;
return o;
}
function renderAttract(){
ctx.drawImage(bgc, 0,0, W,H);
drawTwinkle();
ctx.save(); ctx.globalAlpha=0.9;
drawParticles(); drawRings();
drawOrbs();
ctx.restore();
ctx.fillStyle = vignetteGrad; ctx.fillRect(0,0,W,H);
ctx.fillStyle = 'rgba(0,0,0,.22)'; ctx.fillRect(0,0,W,H);
}
/* ============================================================
LEGEND
============================================================ */
(function buildLegend(){
let html='';
for(const k of ORB_KEYS){
const T = ORB_TYPES[k];
html += '<div class="orbrow">'
+ '<span class="dot" style="background:linear-gradient(135deg,'+T.c1+','+T.c2+');color:'+T.glow+'"></span>'
+ '<span><b>'+T.name+'</b>'+T.desc+'</span></div>';
}
document.getElementById('orbLegend').innerHTML = html;
document.getElementById('orbLegend2').innerHTML = html;
})();
/* ============================================================
MAIN LOOP — fixed timestep, capped catch-up
============================================================ */
let last = performance.now(), acc = 0;
const STEP = 1000/60;
function loop(now){
requestAnimationFrame(loop);
let delta = now - last; last = now;
if(delta > 240) delta = 240;
frameAvg = frameAvg*0.9 + delta*0.1; // drives adaptive particles + resolution
adaptResolution();
acc += delta;
let steps = 0;
while(acc >= STEP && steps < 5){
acc -= STEP; steps++;
if(G.state==='play') update();
else if(G.state==='start' || G.state==='over') attractTick();
else break; // paused: burn the accumulator, run nothing
}
if(G.state==='pause') acc = 0;
if(G.state==='play' || G.state==='pause') render();
else renderAttract();
}
resize();
syncToggles();
requestAnimationFrame(loop);
</script>
</body>
</html>