← back to Games Agentabrams
games/wild-orbs/WildOrbs.aa
1753 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ORBFALL — Wild Orb Protocol</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;}
html,body{width:100%;height:100%;overflow:hidden;background:#03030a;
font-family:"Trebuchet MS","Segoe UI",system-ui,sans-serif;-webkit-font-smoothing:antialiased;}
#wrap{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;
background:radial-gradient(ellipse at 50% 40%,#0b0b22 0%,#04040e 55%,#000 100%);}
canvas{display:block;touch-action:none;}
#ui{position:absolute;inset:0;pointer-events:none;overflow:hidden;}
.screen{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;
text-align:center;color:#e8f6ff;pointer-events:auto;overflow-y:auto;padding:24px 16px;
background:radial-gradient(ellipse at 50% 45%,rgba(22,10,62,.74) 0%,rgba(3,3,12,.95) 70%);
backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);
animation:fadeIn .35s ease both;}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
.hidden{display:none!important;}
h1{font-size:clamp(40px,9.4vw,104px);letter-spacing:.15em;font-weight:900;line-height:1;
background:linear-gradient(180deg,#ffffff 0%,#cfeaff 34%,#8be9ff 52%,#b06cff 100%);
-webkit-background-clip:text;background-clip:text;color:transparent;
filter:drop-shadow(0 0 26px rgba(120,180,255,.42));margin-bottom:.08em;}
h1 .sub{display:block;font-size:.185em;letter-spacing:.6em;color:#8fd6ff;
-webkit-text-fill-color:#8fd6ff;margin-top:.55em;opacity:.9;filter:none;}
.tag{font-size:clamp(12px,1.7vw,16px);color:#9fb4d8;letter-spacing:.2em;text-transform:uppercase;margin-bottom:20px;}
.keys{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:18px;max-width:min(780px,94vw);}
.key{border:1px solid rgba(140,190,255,.26);background:rgba(20,32,64,.48);padding:7px 13px;border-radius:8px;
font-size:12.5px;color:#cfe4ff;letter-spacing:.07em;}
.key b{color:#7ff0d0;}
.orblist{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:7px;max-width:min(940px,95vw);margin-bottom:22px;}
.orbcard{display:flex;align-items:center;gap:9px;background:rgba(12,18,40,.5);
border:1px solid rgba(120,170,255,.14);border-radius:10px;padding:7px 10px;font-size:11px;
color:#a9bfe0;text-align:left;letter-spacing:.02em;transition:border-color .2s,transform .2s,background .2s;}
.orbcard:hover{border-color:rgba(150,200,255,.4);transform:translateY(-2px);background:rgba(18,26,54,.65);}
.dot{width:16px;height:16px;border-radius:50%;flex:0 0 auto;background:currentColor;
box-shadow:0 0 12px currentColor,inset 0 0 6px rgba(255,255,255,.55);}
.btn{pointer-events:auto;cursor:pointer;border:none;border-radius:999px;padding:15px 46px;font-size:18px;
font-weight:800;letter-spacing:.2em;color:#04121a;font-family:inherit;text-transform:uppercase;
background:linear-gradient(135deg,#7ff0d0,#63c8ff 55%,#b06cff);
box-shadow:0 0 34px rgba(99,200,255,.5),0 8px 26px rgba(0,0,0,.5);
transition:transform .12s ease,box-shadow .12s ease;}
.btn:hover{transform:translateY(-2px) scale(1.03);box-shadow:0 0 54px rgba(140,220,255,.8),0 10px 30px rgba(0,0,0,.55);}
.btn:active{transform:translateY(1px) scale(.98);}
.small{margin-top:16px;font-size:11.5px;color:#6f84a8;letter-spacing:.13em;}
#gameover h2{font-size:clamp(34px,7vw,74px);letter-spacing:.12em;color:#ff6f8f;font-weight:900;
text-shadow:0 0 34px rgba(255,80,120,.6);margin-bottom:.12em;}
.statrow{display:flex;gap:30px;flex-wrap:wrap;justify-content:center;margin:16px 0 22px;}
.stat{min-width:104px;}
.stat .v{font-size:31px;font-weight:800;color:#8be9ff;text-shadow:0 0 18px rgba(100,200,255,.55);}
.stat .l{font-size:10.5px;letter-spacing:.22em;color:#7f93b6;text-transform:uppercase;margin-top:3px;}
#newbest{color:#ffe066;font-weight:900;letter-spacing:.25em;font-size:13px;margin-bottom:6px;
text-shadow:0 0 20px rgba(255,220,80,.7);animation:pulseBest 1.1s ease-in-out infinite;}
@keyframes pulseBest{0%,100%{opacity:.55}50%{opacity:1}}
#toast{position:absolute;left:50%;top:13%;transform:translateX(-50%);font-size:clamp(15px,3vw,29px);
font-weight:900;letter-spacing:.16em;text-transform:uppercase;color:#fff;opacity:0;
text-shadow:0 0 24px currentColor;white-space:nowrap;}
#wavebanner{position:absolute;left:50%;top:42%;transform:translate(-50%,-50%);font-size:clamp(28px,7.6vw,82px);
font-weight:900;letter-spacing:.3em;color:#fff;opacity:0;text-shadow:0 0 44px #63c8ff;white-space:nowrap;}
#pausetag{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);font-size:clamp(26px,6vw,60px);
font-weight:900;letter-spacing:.4em;color:#cfe6ff;text-shadow:0 0 40px #63c8ff;display:none;}
</style>
</head>
<body>
<div id="wrap">
<canvas id="cv"></canvas>
<div id="ui">
<div id="toast"></div>
<div id="wavebanner"></div>
<div id="pausetag">PAUSED</div>
<div id="start" class="screen">
<h1>ORBFALL<span class="sub">wild orb protocol</span></h1>
<div class="tag">Ten orbs. Ten kinds of chaos. Shoot carefully.</div>
<div class="keys">
<div class="key"><b>← →</b> / <b>A D</b> rotate</div>
<div class="key"><b>↑</b> / <b>W</b> thrust</div>
<div class="key"><b>SPACE</b> fire</div>
<div class="key"><b>SHIFT</b> blink</div>
<div class="key"><b>P</b> pause</div>
<div class="key"><b>M</b> mute</div>
</div>
<div class="orblist" id="orblist"></div>
<button class="btn" id="startBtn">Launch</button>
<div class="small">Screen wraps · inertia is real · chain your pops for combo</div>
</div>
<div id="gameover" class="screen hidden">
<h2>SIGNAL LOST</h2>
<div class="tag" id="goSub">the orbs got you</div>
<div id="newbest" style="display:none">◆ NEW RECORD ◆</div>
<div class="statrow">
<div class="stat"><div class="v" id="goScore">0</div><div class="l">Score</div></div>
<div class="stat"><div class="v" id="goWave">1</div><div class="l">Wave</div></div>
<div class="stat"><div class="v" id="goPopped">0</div><div class="l">Orbs Popped</div></div>
<div class="stat"><div class="v" id="goCombo">0</div><div class="l">Best Combo</div></div>
<div class="stat"><div class="v" id="goBest">0</div><div class="l">Best</div></div>
</div>
<button class="btn" id="againBtn">Again</button>
<div class="small">Press ENTER to relaunch</div>
</div>
</div>
</div>
<script>
"use strict";
/* =====================================================================
ORBFALL — single-file canvas arcade. No assets, no CDNs.
===================================================================== */
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d', {alpha:false});
let W=0, H=0, DPR=1;
function resize(){
DPR = Math.min(window.devicePixelRatio||1, 2);
W = window.innerWidth; H = window.innerHeight;
cv.width = Math.floor(W*DPR);
cv.height = Math.floor(H*DPR);
cv.style.width = W+'px';
cv.style.height = H+'px';
ctx.setTransform(DPR,0,0,DPR,0,0);
buildStars();
buildNebula();
}
window.addEventListener('resize', resize);
/* ---------------- math ---------------- */
const TAU=Math.PI*2;
const rand=(a=1,b=0)=> b+Math.random()*(a-b);
const randi=(a,b)=> Math.floor(rand(a,b));
const pick=a=> a[(Math.random()*a.length)|0];
const clamp=(v,a,b)=> v<a?a:v>b?b:v;
const lerp=(a,b,t)=> a+(b-a)*t;
const easeOut=t=> 1-Math.pow(1-t,3);
function wrapDelta(a,b,size){ let d=b-a; if(d>size/2) d-=size; if(d<-size/2) d+=size; return d; }
function wrapPos(o,pad){
const p = pad||40;
if(o.x < -p) o.x += W+p*2; else if(o.x > W+p) o.x -= W+p*2;
if(o.y < -p) o.y += H+p*2; else if(o.y > H+p) o.y -= H+p*2;
}
function torDist(ax,ay,bx,by){
const dx=wrapDelta(ax,bx,W), dy=wrapDelta(ay,by,H);
return Math.hypot(dx,dy);
}
/* ---------------- audio ---------------- */
const Sound = (()=>{
let A=null, master=null, comp=null, muted=false, started=false;
function ensure(){
if(A) return A;
try{
A = new (window.AudioContext||window.webkitAudioContext)();
comp = A.createDynamicsCompressor();
comp.threshold.value=-14; comp.knee.value=22; comp.ratio.value=8;
comp.attack.value=0.004; comp.release.value=0.22;
master = A.createGain(); master.gain.value = 0.46;
master.connect(comp); comp.connect(A.destination);
}catch(e){ A=null; }
return A;
}
function res(){ if(A && A.state==='suspended') A.resume(); }
function tone(o){
const a=ensure(); if(!a||muted) return; res();
const t0=a.currentTime+(o.delay||0), dur=o.dur||0.2;
const osc=a.createOscillator(), g=a.createGain();
osc.type=o.type||'sine';
osc.frequency.setValueAtTime(o.f0||440,t0);
if(o.f1!=null){
if(o.exp) osc.frequency.exponentialRampToValueAtTime(Math.max(1,o.f1), t0+dur);
else osc.frequency.linearRampToValueAtTime(o.f1, t0+dur);
}
const vol=(o.vol==null?0.18:o.vol);
g.gain.setValueAtTime(0.0001,t0);
g.gain.exponentialRampToValueAtTime(Math.max(0.0002,vol), t0+(o.atk||0.008));
g.gain.exponentialRampToValueAtTime(0.0001, t0+dur);
let node=osc;
if(o.filter){
const f=a.createBiquadFilter(); f.type=o.filter; f.frequency.value=o.fc||900; f.Q.value=o.q||1;
node.connect(f); f.connect(g);
} else node.connect(g);
g.connect(master);
osc.start(t0); osc.stop(t0+dur+0.06);
}
function noise(o){
const a=ensure(); if(!a||muted) return; res();
const dur=o.dur||0.3, len=Math.max(1,Math.floor(a.sampleRate*dur));
const buf=a.createBuffer(1,len,a.sampleRate), d=buf.getChannelData(0);
const dec=o.decay||2;
for(let i=0;i<len;i++) d[i]=(Math.random()*2-1)*Math.pow(1-i/len,dec);
const s=a.createBufferSource(); s.buffer=buf;
const f=a.createBiquadFilter(); f.type=o.filter||'lowpass';
const t0=a.currentTime+(o.delay||0);
f.frequency.setValueAtTime(o.fc||1200,t0);
if(o.fc1!=null) f.frequency.exponentialRampToValueAtTime(Math.max(40,o.fc1), t0+dur);
const g=a.createGain();
g.gain.setValueAtTime(o.vol==null?0.22:o.vol,t0);
g.gain.exponentialRampToValueAtTime(0.0001,t0+dur);
s.connect(f); f.connect(g); g.connect(master);
s.start(t0); s.stop(t0+dur+0.03);
}
return {
ensure, unlock(){ ensure(); res(); started=true; },
toggle(){ muted=!muted; if(master) master.gain.value = muted?0:0.46; return muted; },
shoot(p){ tone({type:'square',f0:820+p*120,f1:170,dur:0.075,vol:0.055,exp:true,filter:'lowpass',fc:2600}); },
thrust(){ noise({dur:0.06,fc:460,vol:0.028,decay:1}); },
pop(hue,c){ tone({type:'triangle',f0:280+hue*1.3+c*24,f1:88,dur:0.2,vol:0.11,exp:true});
noise({dur:0.2,fc:2600,fc1:220,vol:0.085}); },
boom(){ noise({dur:0.72,fc:1500,fc1:55,vol:0.3,decay:1.6}); tone({type:'sine',f0:130,f1:28,dur:0.62,vol:0.2,exp:true}); },
hurt(){ tone({type:'sawtooth',f0:210,f1:46,dur:0.5,vol:0.19,exp:true}); noise({dur:0.42,fc:820,fc1:70,vol:0.19}); },
blackhole(){ tone({type:'sine',f0:38,f1:230,dur:1.25,vol:0.16,exp:true});
noise({dur:1.25,fc:110,fc1:1900,vol:0.09,decay:0.6}); },
swarm(){ tone({type:'sawtooth',f0:500,f1:1500,dur:0.3,vol:0.08,exp:true,filter:'bandpass',fc:1200,q:3}); },
phase(){ tone({type:'sine',f0:1500,f1:270,dur:0.26,vol:0.1,exp:true});
tone({type:'sine',f0:680,f1:2100,dur:0.26,vol:0.06,exp:true,delay:0.05}); },
reverse(){ tone({type:'square',f0:155,f1:540,dur:0.44,vol:0.1,exp:true});
tone({type:'square',f0:540,f1:155,dur:0.44,vol:0.08,exp:true,delay:0.12}); },
shard(){ for(let i=0;i<5;i++) tone({type:'square',f0:880+i*230,f1:390,dur:0.09,vol:0.04,exp:true,delay:i*0.028}); },
slow(){ tone({type:'sine',f0:640,f1:110,dur:0.95,vol:0.12,exp:true}); },
chain(i){ tone({type:'triangle',f0:380+i*95,f1:150,dur:0.15,vol:0.085,exp:true}); },
mirror(){ tone({type:'triangle',f0:780,f1:1620,dur:0.19,vol:0.085,exp:true});
tone({type:'triangle',f0:1620,f1:780,dur:0.19,vol:0.065,exp:true,delay:0.04}); },
rubber(){ tone({type:'sine',f0:210,f1:720,dur:0.2,vol:0.11,exp:true}); },
bounce(){ tone({type:'sine',f0:520,f1:260,dur:0.08,vol:0.05,exp:true}); },
gravity(){ tone({type:'sine',f0:940,f1:66,dur:0.8,vol:0.13,exp:true}); },
wave(){ [523,659,784,1046].forEach((f,i)=>tone({type:'triangle',f0:f,dur:0.3,vol:0.1,delay:i*0.085})); },
extra(){ [660,880,1320,1760].forEach((f,i)=>tone({type:'square',f0:f,dur:0.15,vol:0.09,delay:i*0.065})); },
over(){ [440,330,247,165].forEach((f,i)=>tone({type:'sawtooth',f0:f,f1:f*0.5,dur:0.5,vol:0.13,exp:true,delay:i*0.16})); },
blink(){ tone({type:'sine',f0:1900,f1:400,dur:0.17,vol:0.075,exp:true}); },
combo(n){ tone({type:'triangle',f0:520+Math.min(n,12)*70,dur:0.1,vol:0.06}); }
};
})();
/* ---------------- orb types ---------------- */
const ORB_TYPES = [
{key:'swarm', name:'Swarmling', hue:135, c1:'#8dff9f', c2:'#0d4d28', desc:'Splits into a homing swarm'},
{key:'gravity', name:'Sinkhole', hue:280, c1:'#c58bff', c2:'#320c60', desc:'Bursts into a gravity well'},
{key:'phase', name:'Phaser', hue:190, c1:'#7ff0ff', c2:'#083f52', desc:'Blinks out and teleports'},
{key:'void', name:'Voidcore', hue:352, c1:'#ff5b7f', c2:'#20030c', desc:'Spawns a hungry black hole'},
{key:'reverse', name:'Invertex', hue:42, c1:'#ffd166', c2:'#5c3800', desc:'Reverses your controls'},
{key:'shard', name:'Glassbone', hue:212, c1:'#bcd8ff', c2:'#1c2942', desc:'Shatters into ricochet shards'},
{key:'slow', name:'Chronobub', hue:168, c1:'#7ff0d0', c2:'#09453c', desc:'Drops a time-slow bubble'},
{key:'chain', name:'Chromabomb', hue:322, c1:'#ff8bd8', c2:'#4f0937', desc:'Chain-reaction colour bomb'},
{key:'mirror', name:'Twinshell', hue:62, c1:'#f6ff8b', c2:'#4c5000', desc:'Mirror-clones across the void'},
{key:'rubber', name:'Bounceball', hue:22, c1:'#ffa46b', c2:'#511c00', desc:'Rubber-bands off hard walls'}
];
const TYPE_BY_KEY = {};
ORB_TYPES.forEach(t=> TYPE_BY_KEY[t.key]=t);
(function buildLegend(){
const el=document.getElementById('orblist');
ORB_TYPES.forEach(t=>{
const d=document.createElement('div');
d.className='orbcard';
d.innerHTML='<span class="dot" style="color:'+t.c1+'"></span>'+
'<span><b style="color:'+t.c1+'">'+t.name+'</b><br>'+t.desc+'</span>';
el.appendChild(d);
});
})();
/* ---------------- input ---------------- */
const keys = Object.create(null);
window.addEventListener('keydown', e=>{
const k=e.key.toLowerCase();
if([' ','arrowup','arrowdown','arrowleft','arrowright'].indexOf(k)>=0) e.preventDefault();
if(keys[k]) return;
keys[k]=true;
Sound.unlock();
if(k==='p' && G.state==='play'){ G.paused=!G.paused; document.getElementById('pausetag').style.display=G.paused?'block':'none'; }
if(k==='m'){ toast(Sound.toggle()?'MUTED':'SOUND ON','#9fb4d8'); }
if(k==='enter'){ if(G.state==='start'||G.state==='over') startGame(); }
if(k==='shift' && G.state==='play' && !G.paused) blink();
}, {passive:false});
window.addEventListener('keyup', e=>{ keys[e.key.toLowerCase()]=false; });
window.addEventListener('blur', ()=>{ for(const k in keys) keys[k]=false; });
/* ---------------- game state ---------------- */
const G = {
state:'start', paused:false,
score:0, disp:0, best:+(localStorage.getItem('orbfall_best')||0),
lives:3, wave:0, popped:0, nextExtra:5000,
combo:0, comboT:0, bestCombo:0,
shake:0, shakeX:0, shakeY:0, rot:0,
slowT:0, reverseT:0,
flash:0, flashCol:'#fff',
chroma:0,
t:0, waveClearT:0, respawnT:0
};
let ship=null;
let bullets=[], orbs=[], parts=[], shards=[], swarm=[], wells=[], holes=[],
bubbles=[], ripples=[], floats=[], sparks=[], debris=[];
let stars=[], neb=[];
function buildStars(){
stars=[];
const n=Math.floor((W*H)/5200);
for(let i=0;i<n;i++){
const layer=randi(0,3);
stars.push({x:rand(W),y:rand(H),r:rand(0.4+layer*0.55,0.25),
a:rand(0.95,0.18),tw:rand(TAU),ts:rand(2.4,0.5),l:layer,
h:pick([200,205,215,45,280])});
}
}
function buildNebula(){
neb=[];
for(let i=0;i<5;i++){
neb.push({x:rand(W),y:rand(H),r:rand(Math.max(W,H)*0.55,Math.max(W,H)*0.22),
h:pick([258,196,318,168]),a:rand(0.09,0.035),vx:rand(0.06,-0.06),vy:rand(0.05,-0.05)});
}
}
resize();
/* ---------------- HUD helpers ---------------- */
const toastEl=document.getElementById('toast');
let toastT=0;
function toast(txt,col){
toastEl.textContent=txt;
toastEl.style.color=col||'#fff';
toastT=1.6;
}
const bannerEl=document.getElementById('wavebanner');
let bannerT=0;
function banner(txt){ bannerEl.textContent=txt; bannerT=1.9; }
function addFloat(x,y,txt,col,size){
floats.push({x,y,txt,col:col||'#fff',size:size||16,life:1,vy:-38,vx:rand(16,-16)});
}
/* ---------------- particles ---------------- */
function P(x,y,vx,vy,life,size,col,glow,drag,kind){
parts.push({x,y,vx,vy,life,max:life,size,col,glow:glow!==false,drag:drag||0.985,kind:kind||0,rot:rand(TAU),vr:rand(6,-6)});
}
function burst(x,y,n,col,spd,size,life){
for(let i=0;i<n;i++){
const a=rand(TAU), s=rand(spd,spd*0.15);
P(x,y,Math.cos(a)*s,Math.sin(a)*s,rand(life||0.8,(life||0.8)*0.4),rand(size||3,1),col);
}
}
function ring(x,y,col,r0,r1,life,w){
ripples.push({x,y,col,r:r0,r1:r1,life:life||0.6,max:life||0.6,w:w||3});
}
function sparkLine(x,y,ang,len,col,life){
sparks.push({x,y,ang,len,col,life:life||0.25,max:life||0.25});
}
function shakeIt(v,rotAmt){ G.shake=Math.min(38,G.shake+v); if(rotAmt) G.rot+=rotAmt; }
function flash(col,amt){ G.flash=Math.max(G.flash,amt||0.5); G.flashCol=col||'#fff'; }
/* ---------------- ship ---------------- */
function makeShip(){
return {x:W/2,y:H/2,vx:0,vy:0,a:-Math.PI/2,r:13,
cd:0, inv:2.6, alive:true, thrust:0, blinkCd:0, trail:[], eng:0, recoil:0};
}
function blink(){
if(!ship||!ship.alive||ship.blinkCd>0) return;
const d=132;
const ox=ship.x, oy=ship.y;
ship.x+=Math.cos(ship.a)*d; ship.y+=Math.sin(ship.a)*d;
wrapPos(ship,20);
ship.blinkCd=3.4; ship.inv=Math.max(ship.inv,0.65);
Sound.blink();
ring(ox,oy,'#8be9ff',6,44,0.5,3);
ring(ship.x,ship.y,'#b06cff',40,6,0.45,3);
for(let i=0;i<16;i++){
const a=rand(TAU);
P(ox,oy,Math.cos(a)*rand(120,20),Math.sin(a)*rand(120,20),0.5,rand(3,1),'#8be9ff');
P(ship.x,ship.y,Math.cos(a)*rand(60,10),Math.sin(a)*rand(60,10),0.4,rand(2.5,1),'#c9a4ff');
}
addFloat(ship.x,ship.y-24,'BLINK','#8be9ff',13);
}
/* ---------------- orbs ---------------- */
function spawnOrb(type,x,y,size,vx,vy,gen){
const t = type || pick(ORB_TYPES).key;
const sz = size || rand(46,34);
const spd = rand(52,16)*(1+G.wave*0.045);
const ang = rand(TAU);
const o = {
type:t, x:(x==null?0:x), y:(y==null?0:y),
vx:(vx==null? Math.cos(ang)*spd : vx),
vy:(vy==null? Math.sin(ang)*spd : vy),
r:sz, gen:gen||0, hp:sz>34?2:1,
a:rand(TAU), spin:rand(1.4,-1.4),
pulse:rand(TAU), wob:rand(TAU),
hit:0, born:0, ghost:0, seed:rand(1000),
verts:[]
};
const n = 11 + ((sz/9)|0);
for(let i=0;i<n;i++) o.verts.push(rand(1.12,0.86));
if(x==null){
// spawn away from ship
let tries=0;
do{
o.x=rand(W); o.y=rand(H); tries++;
} while(ship && torDist(o.x,o.y,ship.x,ship.y) < 210 && tries<60);
}
orbs.push(o);
return o;
}
function startWave(){
G.wave++;
const n = Math.min(4+Math.floor(G.wave*1.35), 17);
for(let i=0;i<n;i++) spawnOrb();
banner('WAVE '+G.wave);
Sound.wave();
ring(W/2,H/2,'#63c8ff',20,Math.max(W,H)*0.6,1.1,4);
}
/* ---------------- scoring ---------------- */
function addScore(v,x,y,label){
const mult = 1 + Math.min(G.combo,15)*0.12;
const gain = Math.round(v*mult);
G.score += gain;
if(x!=null) addFloat(x,y,'+'+gain,'#ffe9a8',14+Math.min(8,G.combo));
if(label) addFloat(x,y-20,label,'#ffffff',12);
while(G.score >= G.nextExtra){
G.nextExtra += 5000 + G.lives*1500;
G.lives++;
Sound.extra();
toast('EXTRA LIFE','#7ff0d0');
flash('#7ff0d0',0.35);
}
}
function bumpCombo(){
G.combo++; G.comboT=1.8;
if(G.combo>G.bestCombo) G.bestCombo=G.combo;
if(G.combo>2){ Sound.combo(G.combo); }
if(G.combo===5) toast('COMBO x5','#ffd166');
if(G.combo===10) toast('COMBO x10 !','#ff8bd8');
if(G.combo===18) toast('UNREAL x18','#7ff0ff');
}
/* ---------------- WILD EFFECTS ---------------- */
function popOrb(o, idx, srcX, srcY, chainDepth){
const T = TYPE_BY_KEY[o.type];
orbs.splice(idx,1);
G.popped++;
bumpCombo();
Sound.pop(T.hue, Math.min(G.combo,10));
burst(o.x,o.y,Math.floor(o.r*0.85),T.c1,rand(230,110),3.4,0.85);
burst(o.x,o.y,Math.floor(o.r*0.45),'#ffffff',rand(150,60),2.1,0.5);
ring(o.x,o.y,T.c1,o.r*0.5,o.r*2.5,0.5,3);
for(let i=0;i<5;i++){
debris.push({x:o.x,y:o.y,vx:rand(120,-120),vy:rand(120,-120),
r:rand(o.r*0.26,o.r*0.10),a:rand(TAU),vr:rand(5,-5),life:1.1,max:1.1,col:T.c2,edge:T.c1});
}
shakeIt(3.5+o.r*0.06);
addScore(Math.round(80 + (60-o.r)*3), o.x, o.y-o.r*0.6);
const gen=o.gen;
switch(o.type){
case 'swarm': {
Sound.swarm();
const n = o.r>36?6:4;
for(let i=0;i<n;i++){
const a=(i/n)*TAU+rand(0.5);
swarm.push({x:o.x+Math.cos(a)*o.r*0.5, y:o.y+Math.sin(a)*o.r*0.5,
vx:Math.cos(a)*160, vy:Math.sin(a)*160, r:6.5, life:9.5, max:9.5,
hue:T.hue, wig:rand(TAU), trail:[]});
}
ring(o.x,o.y,'#8dff9f',10,o.r*3,0.6,2);
addFloat(o.x,o.y-o.r,'SWARM!','#8dff9f',15);
break;
}
case 'gravity': {
Sound.gravity();
wells.push({x:o.x,y:o.y,r:150+o.r*2.2,str:1500,life:5.2,max:5.2,a:0});
ring(o.x,o.y,'#c58bff',o.r,200,0.9,4);
ring(o.x,o.y,'#8be9ff',o.r*0.4,150,1.2,2);
addFloat(o.x,o.y-o.r,'GRAVITY WELL','#c58bff',15);
flash('#7a3ccf',0.18);
break;
}
case 'phase': {
Sound.phase();
if(gen<2){
const nx=rand(W), ny=rand(H);
const c = spawnOrb('phase', nx, ny, Math.max(20,o.r*0.86), rand(90,-90), rand(90,-90), gen+1);
c.ghost=0.6; c.born=0.35;
ring(o.x,o.y,'#7ff0ff',o.r,4,0.5,3);
ring(nx,ny,'#7ff0ff',4,o.r*2.2,0.6,3);
for(let i=0;i<20;i++){
const a=rand(TAU);
P(nx,ny,Math.cos(a)*rand(180,40),Math.sin(a)*rand(180,40),0.55,rand(3,1),'#7ff0ff');
}
// ghost trail between the two
for(let i=0;i<10;i++){
const t=i/10;
P(lerp(o.x,nx,t),lerp(o.y,ny,t),0,0,0.4,rand(4,2),'#7ff0ff',true,0.9);
}
addFloat(o.x,o.y-o.r,'PHASED','#7ff0ff',15);
}
break;
}
case 'void': {
Sound.blackhole();
holes.push({x:o.x,y:o.y,r:22,rTarget:44,life:7.5,max:7.5,a:0,pull:2600,eaten:0});
flash('#ff5b7f',0.32);
shakeIt(16,0.02);
ring(o.x,o.y,'#ff5b7f',6,260,1.0,5);
ring(o.x,o.y,'#ffffff',4,120,0.6,2);
addFloat(o.x,o.y-o.r,'BLACK HOLE','#ff5b7f',17);
break;
}
case 'reverse': {
Sound.reverse();
G.reverseT = Math.max(G.reverseT, 6.5);
toast('CONTROLS REVERSED','#ffd166');
flash('#ffd166',0.25);
ring(o.x,o.y,'#ffd166',o.r,240,0.8,4);
for(let i=0;i<26;i++){
const a=rand(TAU);
P(o.x,o.y,Math.cos(a)*rand(200,50),Math.sin(a)*rand(200,50),0.9,rand(4,1.5),'#ffd166');
}
break;
}
case 'shard': {
Sound.shard();
const n=9;
for(let i=0;i<n;i++){
const a=(i/n)*TAU+rand(0.4);
const sp=rand(430,270);
shards.push({x:o.x,y:o.y,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp,
life:4.2,max:4.2,bounces:0,a:rand(TAU),vr:rand(14,-14),len:rand(17,10),trail:[]});
}
ring(o.x,o.y,'#bcd8ff',o.r*0.4,o.r*3,0.45,2);
addFloat(o.x,o.y-o.r,'SHATTER','#bcd8ff',15);
break;
}
case 'slow': {
Sound.slow();
bubbles.push({x:o.x,y:o.y,r:16,rTarget:210+o.r*1.6,life:6.5,max:6.5,a:0});
ring(o.x,o.y,'#7ff0d0',10,220,1.0,3);
addFloat(o.x,o.y-o.r,'TIME BUBBLE','#7ff0d0',15);
break;
}
case 'chain': {
const depth=chainDepth||0;
Sound.chain(depth);
const R = 230;
ring(o.x,o.y,'#ff8bd8',10,R,0.55,4);
flash('#ff8bd8',0.14);
shakeIt(5);
let hitAny=0;
for(let j=orbs.length-1;j>=0;j--){
const q=orbs[j];
if(torDist(o.x,o.y,q.x,q.y) < R + q.r){
hitAny++;
const qq=q, jj=j;
// slight cascade delay via scheduled pop
scheduled.push({t:0.09*(hitAny), fn:()=>{
const k=orbs.indexOf(qq);
if(k>=0) popOrb(qq,k,o.x,o.y,depth+1);
}});
}
}
if(hitAny) addFloat(o.x,o.y-o.r,'CHAIN x'+hitAny,'#ff8bd8',16);
else addFloat(o.x,o.y-o.r,'BOOM','#ff8bd8',15);
// colour shrapnel
for(let i=0;i<34;i++){
const a=rand(TAU);
P(o.x,o.y,Math.cos(a)*rand(340,90),Math.sin(a)*rand(340,90),rand(0.9,0.4),rand(4,1.5),
'hsl('+randi(280,360)+',100%,72%)');
}
break;
}
case 'mirror': {
Sound.mirror();
if(gen<2 && o.r>19){
const nr=o.r*0.72;
const mk=(mx,my)=>{
const c=spawnOrb('mirror',mx,my,nr,rand(140,-140),rand(140,-140),gen+1);
c.born=0.4;
ring(mx,my,'#f6ff8b',4,nr*3,0.55,3);
};
mk((o.x+W*0.5)%W, o.y);
mk(o.x, (o.y+H*0.5)%H);
mk((o.x+W*0.5)%W, (o.y+H*0.5)%H);
addFloat(o.x,o.y-o.r,'MIRROR x3','#f6ff8b',15);
}
break;
}
case 'rubber': {
Sound.rubber();
const n=3;
for(let i=0;i<n;i++){
const a=rand(TAU), sp=rand(300,190);
const c=spawnOrb('rubber',o.x,o.y,Math.max(15,o.r*0.5),Math.cos(a)*sp,Math.sin(a)*sp,gen+1);
c.bouncy=true; c.hp=1; c.born=0.25;
}
ring(o.x,o.y,'#ffa46b',o.r*0.5,o.r*3.2,0.5,3);
addFloat(o.x,o.y-o.r,'RUBBER!','#ffa46b',15);
break;
}
}
}
/* scheduled callbacks (chain cascade) */
let scheduled=[];
/* ---------------- damage ---------------- */
function killShip(){
if(!ship.alive || ship.inv>0) return;
ship.alive=false;
G.lives--;
Sound.boom(); Sound.hurt();
flash('#ff6f8f',0.6);
shakeIt(34,0.045);
burst(ship.x,ship.y,74,'#ffd9e2',rand(380,120),4,1.3);
burst(ship.x,ship.y,40,'#8be9ff',rand(280,80),3,1.0);
ring(ship.x,ship.y,'#ff6f8f',8,240,0.9,5);
for(let i=0;i<8;i++){
debris.push({x:ship.x,y:ship.y,vx:rand(200,-200),vy:rand(200,-200),
r:rand(9,4),a:rand(TAU),vr:rand(8,-8),life:1.6,max:1.6,col:'#233',edge:'#9fe9ff'});
}
G.combo=0;
if(G.lives<=0){ gameOver(); }
else { G.respawnT=1.5; toast(G.lives+' SHIP'+(G.lives>1?'S':'')+' LEFT','#ff9fb4'); }
}
function respawn(){
ship=makeShip();
ship.inv=2.8;
ring(ship.x,ship.y,'#7ff0d0',80,10,0.7,3);
}
/* ---------------- game flow ---------------- */
function startGame(){
G.state='play'; G.paused=false;
G.score=0; G.disp=0; G.lives=3; G.wave=0; G.popped=0; G.nextExtra=5000;
G.combo=0; G.comboT=0; G.bestCombo=0;
G.slowT=0; G.reverseT=0; G.shake=0; G.rot=0; G.flash=0; G.chroma=0;
G.waveClearT=0; G.respawnT=0;
bullets=[];orbs=[];parts=[];shards=[];swarm=[];wells=[];holes=[];
bubbles=[];ripples=[];floats=[];sparks=[];debris=[];scheduled=[];
ship=makeShip();
document.getElementById('start').classList.add('hidden');
document.getElementById('gameover').classList.add('hidden');
document.getElementById('pausetag').style.display='none';
Sound.unlock();
startWave();
}
function gameOver(){
G.state='over';
Sound.over();
const isBest = G.score>G.best;
if(isBest){ G.best=G.score; localStorage.setItem('orbfall_best',String(G.best)); }
document.getElementById('goScore').textContent=G.score;
document.getElementById('goWave').textContent=G.wave;
document.getElementById('goPopped').textContent=G.popped;
document.getElementById('goCombo').textContent=G.bestCombo;
document.getElementById('goBest').textContent=G.best;
document.getElementById('newbest').style.display=isBest?'block':'none';
document.getElementById('goSub').textContent = isBest? 'a new personal record' : pick([
'the orbs got you','chaos wins this round','signal terminated','try not to shoot the voidcores']);
setTimeout(()=>document.getElementById('gameover').classList.remove('hidden'),620);
}
document.getElementById('startBtn').addEventListener('click',startGame);
document.getElementById('againBtn').addEventListener('click',startGame);
/* ---------------- update ---------------- */
function update(dt){
G.t+=dt;
// scheduled cascades
for(let i=scheduled.length-1;i>=0;i--){
scheduled[i].t-=dt;
if(scheduled[i].t<=0){ const f=scheduled[i].fn; scheduled.splice(i,1); f(); }
}
// time scale from bubbles
let ts=1;
if(ship){
for(let i=0;i<bubbles.length;i++){
const b=bubbles[i];
if(torDist(ship.x,ship.y,b.x,b.y) < b.r) { ts=0.42; break; }
}
}
G.slowT = lerp(G.slowT, ts<1?1:0, 1-Math.pow(0.001,dt));
const wdt = dt*ts; // world dt (affected by slow bubbles)
if(G.comboT>0){ G.comboT-=dt; if(G.comboT<=0) G.combo=0; }
if(G.reverseT>0){ G.reverseT-=dt; if(G.reverseT<=0) toast('CONTROLS RESTORED','#7ff0d0'); }
G.chroma = lerp(G.chroma, (G.reverseT>0?1:0)*0.6 + G.flash*0.8, 0.12);
/* ---- ship ---- */
if(ship && ship.alive){
const rev = G.reverseT>0 ? -1 : 1;
const left = keys['arrowleft']||keys['a'];
const right = keys['arrowright']||keys['d'];
const up = keys['arrowup']||keys['w'];
let turn=0;
if(left) turn-=1;
if(right) turn+=1;
ship.a += turn*rev*3.5*dt;
ship.thrust = lerp(ship.thrust, up?1:0, 1-Math.pow(0.0008,dt));
if(up){
const acc = 335*(G.reverseT>0?1:1);
ship.vx += Math.cos(ship.a)*acc*dt;
ship.vy += Math.sin(ship.a)*acc*dt;
ship.eng+=dt;
if(ship.eng>0.045){
ship.eng=0;
Sound.thrust();
const bx=ship.x-Math.cos(ship.a)*15, by=ship.y-Math.sin(ship.a)*15;
for(let i=0;i<2;i++){
const sp=rand(190,90), a=ship.a+Math.PI+rand(0.45,-0.45);
P(bx,by,Math.cos(a)*sp+ship.vx*0.35,Math.sin(a)*sp+ship.vy*0.35,
rand(0.45,0.2),rand(3.6,1.6),
pick(['#8be9ff','#7ff0d0','#ffffff','#b06cff']),true,0.93);
}
}
}
const drag=Math.pow(0.994,dt*60);
ship.vx*=drag; ship.vy*=drag;
const sp=Math.hypot(ship.vx,ship.vy), MAX=560;
if(sp>MAX){ ship.vx=ship.vx/sp*MAX; ship.vy=ship.vy/sp*MAX; }
ship.x+=ship.vx*dt; ship.y+=ship.vy*dt;
wrapPos(ship,20);
ship.trail.push({x:ship.x,y:ship.y,a:ship.a});
if(ship.trail.length>14) ship.trail.shift();
if(ship.cd>0) ship.cd-=dt;
if(ship.inv>0) ship.inv-=dt;
if(ship.blinkCd>0) ship.blinkCd-=dt;
if(ship.recoil>0) ship.recoil-=dt*6;
if(keys[' '] && ship.cd<=0){
ship.cd=0.135;
ship.recoil=1;
const mx=ship.x+Math.cos(ship.a)*17, my=ship.y+Math.sin(ship.a)*17;
bullets.push({x:mx,y:my,
vx:Math.cos(ship.a)*620+ship.vx*0.45,
vy:Math.sin(ship.a)*620+ship.vy*0.45,
life:1.25,r:3.1,trail:[],hue:190+Math.min(G.combo,10)*8});
ship.vx-=Math.cos(ship.a)*22; ship.vy-=Math.sin(ship.a)*22;
Sound.shoot(Math.min(G.combo,8)/8);
sparkLine(mx,my,ship.a,18,'#cffcff',0.12);
for(let i=0;i<3;i++){
const a=ship.a+rand(0.5,-0.5);
P(mx,my,Math.cos(a)*rand(200,60),Math.sin(a)*rand(200,60),0.2,rand(2.4,1),'#cffcff');
}
}
} else if(G.respawnT>0){
G.respawnT-=dt;
if(G.respawnT<=0 && G.lives>0) respawn();
}
/* ---- gravity sources act on ship + bullets + orbs ---- */
function applyPull(o,src,strength,radius,dtx){
const dx=wrapDelta(o.x,src.x,W), dy=wrapDelta(o.y,src.y,H);
const d=Math.hypot(dx,dy);
if(d<1 || d>radius) return d;
const f=strength/(d*d+800);
o.vx+=dx*f*dtx; o.vy+=dy*f*dtx;
return d;
}
for(let i=wells.length-1;i>=0;i--){
const w=wells[i];
w.life-=dt; w.a+=dt*2.2;
if(w.life<=0){ wells.splice(i,1); ring(w.x,w.y,'#c58bff',w.r,4,0.4,2); continue; }
const k=Math.min(1,w.life/0.7);
if(ship&&ship.alive) applyPull(ship,w,w.str*k,w.r,dt);
for(const b of bullets) applyPull(b,w,w.str*1.7*k,w.r,dt);
for(const o of orbs) applyPull(o,w,w.str*0.55*k,w.r,wdt);
for(const s of shards) applyPull(s,w,w.str*0.9*k,w.r,wdt);
if(Math.random()<0.6){
const a=rand(TAU), rr=w.r*rand(1,0.55);
P(w.x+Math.cos(a)*rr,w.y+Math.sin(a)*rr,-Math.cos(a)*90,-Math.sin(a)*90,0.6,rand(2.6,1),'#c58bff');
}
}
for(let i=holes.length-1;i>=0;i--){
const h=holes[i];
h.life-=dt; h.a+=dt*3.2;
h.r=lerp(h.r,h.rTarget*(h.life<1?h.life:1),0.08);
if(h.life<=0){
holes.splice(i,1);
ring(h.x,h.y,'#ff5b7f',h.r*3,4,0.6,4);
flash('#ff5b7f',0.2); shakeIt(9);
burst(h.x,h.y,40,'#ff9fb4',rand(320,120),3,0.9);
continue;
}
const R=430;
if(ship&&ship.alive){
const d=applyPull(ship,h,h.pull,R,dt);
if(d<h.r*0.65){ killShip(); }
}
for(let j=bullets.length-1;j>=0;j--){
const b=bullets[j];
const d=applyPull(b,h,h.pull*1.6,R,dt);
if(d<h.r*0.6){ bullets.splice(j,1); h.eaten++; burst(h.x,h.y,4,'#ff9fb4',60,2,0.35); }
}
for(let j=orbs.length-1;j>=0;j--){
const o=orbs[j];
const d=applyPull(o,h,h.pull*0.8,R,wdt);
if(d<h.r*0.55){
orbs.splice(j,1); G.popped++;
addScore(50,o.x,o.y,'CONSUMED');
burst(o.x,o.y,16,TYPE_BY_KEY[o.type].c1,120,2.4,0.5);
h.rTarget=Math.min(80,h.rTarget+2.5);
}
}
for(let j=swarm.length-1;j>=0;j--){
const s=swarm[j];
const d=applyPull(s,h,h.pull,R,wdt);
if(d<h.r*0.6){ swarm.splice(j,1); burst(h.x,h.y,6,'#8dff9f',80,2,0.4); }
}
// accretion glitter
for(let k=0;k<2;k++){
const a=rand(TAU), rr=h.r*rand(3.4,1.5);
P(h.x+Math.cos(a)*rr,h.y+Math.sin(a)*rr,
Math.cos(a+1.5)*90-Math.cos(a)*140, Math.sin(a+1.5)*90-Math.sin(a)*140,
0.55,rand(2.4,1),pick(['#ff5b7f','#ffd0dd','#b06cff']));
}
}
/* ---- bubbles ---- */
for(let i=bubbles.length-1;i>=0;i--){
const b=bubbles[i];
b.life-=dt; b.a+=dt;
b.r=lerp(b.r,b.rTarget*(b.life<1?b.life:1),0.07);
if(b.life<=0){ bubbles.splice(i,1); ring(b.x,b.y,'#7ff0d0',b.r,6,0.5,3); }
}
/* ---- bullets ---- */
for(let i=bullets.length-1;i>=0;i--){
const b=bullets[i];
b.life-=dt;
b.x+=b.vx*dt; b.y+=b.vy*dt;
wrapPos(b,10);
b.trail.push({x:b.x,y:b.y});
if(b.trail.length>9) b.trail.shift();
if(b.life<=0){ bullets.splice(i,1); continue; }
let hit=false;
for(let j=orbs.length-1;j>=0;j--){
const o=orbs[j];
if(o.born>0) continue;
if(torDist(b.x,b.y,o.x,o.y) < o.r*0.92){
bullets.splice(i,1); hit=true;
o.hp--; o.hit=1;
burst(b.x,b.y,7,'#ffffff',rand(160,60),2,0.34);
if(o.hp<=0) popOrb(o,j,b.x,b.y,0);
else {
Sound.bounce();
shakeIt(2);
const T=TYPE_BY_KEY[o.type];
burst(b.x,b.y,9,T.c1,rand(190,70),2.4,0.45);
o.vx+=b.vx*0.055; o.vy+=b.vy*0.055;
}
break;
}
}
if(hit) continue;
// bullets kill swarmlings
for(let j=swarm.length-1;j>=0;j--){
const s=swarm[j];
if(torDist(b.x,b.y,s.x,s.y) < s.r+3){
swarm.splice(j,1); bullets.splice(i,1);
addScore(30,s.x,s.y);
burst(s.x,s.y,14,'#8dff9f',rand(200,70),2.4,0.5);
bumpCombo();
Sound.pop(135,3);
break;
}
}
}
/* ---- orbs ---- */
for(let i=orbs.length-1;i>=0;i--){
const o=orbs[i];
if(o.born>0) o.born-=dt;
o.a+=o.spin*wdt;
o.pulse+=wdt*2.4;
o.wob+=wdt*1.6;
if(o.hit>0) o.hit-=dt*3.2;
if(o.bouncy){
o.x+=o.vx*wdt; o.y+=o.vy*wdt;
let bounced=false;
if(o.x<o.r){ o.x=o.r; o.vx=Math.abs(o.vx)*1.02; bounced=true; }
if(o.x>W-o.r){ o.x=W-o.r; o.vx=-Math.abs(o.vx)*1.02; bounced=true; }
if(o.y<o.r){ o.y=o.r; o.vy=Math.abs(o.vy)*1.02; bounced=true; }
if(o.y>H-o.r){ o.y=H-o.r; o.vy=-Math.abs(o.vy)*1.02; bounced=true; }
if(bounced){
Sound.rubber(); shakeIt(2.5);
ring(o.x,o.y,'#ffa46b',o.r*0.5,o.r*2.4,0.35,3);
burst(o.x,o.y,10,'#ffa46b',rand(220,80),2.4,0.4);
}
const s=Math.hypot(o.vx,o.vy);
if(s>420){ o.vx=o.vx/s*420; o.vy=o.vy/s*420; }
} else {
o.x+=o.vx*wdt; o.y+=o.vy*wdt;
wrapPos(o,o.r+8);
}
if(o.type==='phase'){
o.ghost = 0.5+0.5*Math.sin(G.t*2.2+o.seed);
if(Math.random()<0.004 && o.gen<2){
// spontaneous micro-blink
const ox=o.x, oy=o.y;
o.x=rand(W); o.y=rand(H);
ring(ox,oy,'#7ff0ff',o.r,4,0.35,2);
ring(o.x,o.y,'#7ff0ff',4,o.r*2,0.4,2);
}
}
// ship collision
if(ship && ship.alive && ship.inv<=0 && o.born<=0){
if(torDist(ship.x,ship.y,o.x,o.y) < o.r*0.82+ship.r*0.7) killShip();
}
}
/* ---- swarmlings ---- */
for(let i=swarm.length-1;i>=0;i--){
const s=swarm[i];
s.life-=wdt; s.wig+=wdt*9;
if(s.life<=0){ swarm.splice(i,1); burst(s.x,s.y,8,'#8dff9f',80,2,0.4); continue; }
if(ship&&ship.alive){
const dx=wrapDelta(s.x,ship.x,W), dy=wrapDelta(s.y,ship.y,H);
const d=Math.hypot(dx,dy)||1;
const acc=340;
s.vx+=(dx/d)*acc*wdt; s.vy+=(dy/d)*acc*wdt;
s.vx+=Math.cos(s.wig)*36*wdt; s.vy+=Math.sin(s.wig*1.3)*36*wdt;
}
const sp=Math.hypot(s.vx,s.vy), M=250;
if(sp>M){ s.vx=s.vx/sp*M; s.vy=s.vy/sp*M; }
s.vx*=Math.pow(0.99,wdt*60); s.vy*=Math.pow(0.99,wdt*60);
s.x+=s.vx*wdt; s.y+=s.vy*wdt;
wrapPos(s,12);
s.trail.push({x:s.x,y:s.y});
if(s.trail.length>7) s.trail.shift();
if(Math.random()<0.25) P(s.x,s.y,rand(24,-24),rand(24,-24),0.35,rand(2.2,0.8),'#8dff9f');
if(ship&&ship.alive&&ship.inv<=0 && torDist(s.x,s.y,ship.x,ship.y)<s.r+ship.r*0.7) killShip();
}
/* ---- shards ---- */
for(let i=shards.length-1;i>=0;i--){
const s=shards[i];
s.life-=wdt; s.a+=s.vr*wdt;
if(s.life<=0){ shards.splice(i,1); continue; }
s.x+=s.vx*wdt; s.y+=s.vy*wdt;
let b=false;
if(s.x<0){ s.x=0; s.vx=Math.abs(s.vx); b=true; }
if(s.x>W){ s.x=W; s.vx=-Math.abs(s.vx); b=true; }
if(s.y<0){ s.y=0; s.vy=Math.abs(s.vy); b=true; }
if(s.y>H){ s.y=H; s.vy=-Math.abs(s.vy); b=true; }
if(b){
s.bounces++;
Sound.bounce();
burst(s.x,s.y,7,'#bcd8ff',rand(190,70),2.2,0.35);
ring(s.x,s.y,'#bcd8ff',2,26,0.3,2);
if(s.bounces>7){ shards.splice(i,1); continue; }
}
s.trail.push({x:s.x,y:s.y});
if(s.trail.length>8) s.trail.shift();
// shards damage orbs
for(let j=orbs.length-1;j>=0;j--){
const o=orbs[j];
if(o.born>0) continue;
if(torDist(s.x,s.y,o.x,o.y)<o.r*0.85){
shards.splice(i,1);
o.hp--; o.hit=1;
if(o.hp<=0) popOrb(o,j,s.x,s.y,0);
else burst(s.x,s.y,8,'#bcd8ff',150,2,0.4);
break;
}
}
if(ship&&ship.alive&&ship.inv<=0 && torDist(s.x,s.y,ship.x,ship.y)<ship.r*0.8) killShip();
}
/* ---- particles / fx ---- */
for(let i=parts.length-1;i>=0;i--){
const p=parts[i];
p.life-=dt;
if(p.life<=0){ parts.splice(i,1); continue; }
p.x+=p.vx*dt; p.y+=p.vy*dt;
p.vx*=Math.pow(p.drag,dt*60); p.vy*=Math.pow(p.drag,dt*60);
p.rot+=p.vr*dt;
}
for(let i=debris.length-1;i>=0;i--){
const d=debris[i];
d.life-=dt;
if(d.life<=0){ debris.splice(i,1); continue; }
d.x+=d.vx*dt; d.y+=d.vy*dt; d.a+=d.vr*dt;
d.vx*=Math.pow(0.975,dt*60); d.vy*=Math.pow(0.975,dt*60);
}
for(let i=ripples.length-1;i>=0;i--){
const r=ripples[i]; r.life-=dt;
if(r.life<=0){ ripples.splice(i,1); continue; }
const t=1-r.life/r.max;
r.cur = lerp(r.r, r.r1, easeOut(t));
}
for(let i=sparks.length-1;i>=0;i--){ sparks[i].life-=dt; if(sparks[i].life<=0) sparks.splice(i,1); }
for(let i=floats.length-1;i>=0;i--){
const f=floats[i]; f.life-=dt*1.25;
if(f.life<=0){ floats.splice(i,1); continue; }
f.y+=f.vy*dt; f.x+=f.vx*dt; f.vy*=Math.pow(0.94,dt*60);
}
/* ---- camera / flash ---- */
G.shake*=Math.pow(0.0009,dt);
if(G.shake<0.05) G.shake=0;
const s=G.shake;
G.shakeX=rand(s,-s); G.shakeY=rand(s,-s);
G.rot*=Math.pow(0.002,dt);
G.flash*=Math.pow(0.0022,dt);
G.disp += (G.score-G.disp)*Math.min(1,dt*7);
/* ---- toast / banner ---- */
if(toastT>0){ toastT-=dt; toastEl.style.opacity=String(clamp(toastT>1.2?(1.6-toastT)/0.4:toastT/1.2,0,1)); }
else toastEl.style.opacity='0';
if(bannerT>0){
bannerT-=dt;
const k=clamp(bannerT>1.4?(1.9-bannerT)/0.5:bannerT/1.4,0,1);
bannerEl.style.opacity=String(k);
bannerEl.style.transform='translate(-50%,-50%) scale('+(1+(1-k)*0.16)+')';
} else bannerEl.style.opacity='0';
/* ---- wave clear ---- */
if(G.state==='play' && orbs.length===0 && G.respawnT<=0){
G.waveClearT+=dt;
if(G.waveClearT>1.1){
G.waveClearT=0;
addScore(400+G.wave*120, W/2, H*0.4, 'WAVE CLEAR');
startWave();
}
} else G.waveClearT=0;
/* ---- stars ---- */
for(const st of stars){ st.tw+=dt*st.ts; }
for(const n of neb){
n.x+=n.vx*dt*10; n.y+=n.vy*dt*10;
if(n.x<-n.r) n.x=W+n.r; if(n.x>W+n.r) n.x=-n.r;
if(n.y<-n.r) n.y=H+n.r; if(n.y>H+n.r) n.y=-n.r;
}
}
/* ---------------- drawing ---------------- */
function drawBG(){
ctx.fillStyle='#04040c';
ctx.fillRect(0,0,W,H);
// nebula clouds
ctx.globalCompositeOperation='lighter';
for(const n of neb){
const g=ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,n.r);
g.addColorStop(0,'hsla('+n.h+',85%,58%,'+n.a+')');
g.addColorStop(0.55,'hsla('+n.h+',85%,45%,'+(n.a*0.35)+')');
g.addColorStop(1,'hsla('+n.h+',85%,40%,0)');
ctx.fillStyle=g;
ctx.beginPath(); ctx.arc(n.x,n.y,n.r,0,TAU); ctx.fill();
}
ctx.globalCompositeOperation='source-over';
// stars with parallax based on ship velocity
let px=0, py=0;
if(ship){ px=-ship.vx*0.012; py=-ship.vy*0.012; }
for(const st of stars){
const tw=0.55+0.45*Math.sin(st.tw);
const a=st.a*tw;
const sx=st.x+px*(st.l+1), sy=st.y+py*(st.l+1);
ctx.fillStyle='hsla('+st.h+',80%,'+(72+st.l*8)+'%,'+a+')';
ctx.beginPath();
ctx.arc(((sx%W)+W)%W, ((sy%H)+H)%H, st.r, 0, TAU);
ctx.fill();
}
// vignette
const vg=ctx.createRadialGradient(W/2,H/2,Math.min(W,H)*0.3,W/2,H/2,Math.max(W,H)*0.78);
vg.addColorStop(0,'rgba(0,0,0,0)');
vg.addColorStop(1,'rgba(0,0,0,0.72)');
ctx.fillStyle=vg; ctx.fillRect(0,0,W,H);
}
/* draw with toroidal repetition when near edges */
function wrapDraw(x,y,r,fn){
const offs=[[0,0]];
if(x<r) offs.push([W,0]); else if(x>W-r) offs.push([-W,0]);
if(y<r) offs.push([0,H]); else if(y>H-r) offs.push([0,-H]);
if(offs.length===3) offs.push([offs[1][0],offs[2][1]]);
for(let i=0;i<offs.length;i++){
ctx.save(); ctx.translate(offs[i][0],offs[i][1]); fn(); ctx.restore();
}
}
function drawOrb(o){
const T=TYPE_BY_KEY[o.type];
const pulse=1+Math.sin(o.pulse)*0.045;
const R=o.r*pulse*(o.born>0? (1-o.born/0.5)*0.5+0.5 : 1);
const alpha = o.type==='phase' ? 0.45+0.55*o.ghost : 1;
ctx.save();
ctx.globalAlpha=alpha;
ctx.translate(o.x,o.y);
ctx.rotate(o.a);
// outer glow
ctx.globalCompositeOperation='lighter';
const gg=ctx.createRadialGradient(0,0,R*0.35,0,0,R*2.15);
gg.addColorStop(0,hexA(T.c1,0.36));
gg.addColorStop(0.5,hexA(T.c1,0.12));
gg.addColorStop(1,hexA(T.c1,0));
ctx.fillStyle=gg;
ctx.beginPath(); ctx.arc(0,0,R*2.15,0,TAU); ctx.fill();
ctx.globalCompositeOperation='source-over';
// body blob
ctx.beginPath();
const n=o.verts.length;
for(let i=0;i<=n;i++){
const idx=i%n;
const a=(i/n)*TAU;
const wob=1+Math.sin(o.wob+idx*1.7)*0.035;
const rr=R*o.verts[idx]*wob;
const x=Math.cos(a)*rr, y=Math.sin(a)*rr;
if(i===0) ctx.moveTo(x,y);
else{
const pa=((i-1)/n)*TAU;
const pidx=(i-1)%n;
const prr=R*o.verts[pidx]*(1+Math.sin(o.wob+pidx*1.7)*0.035);
const cx=(Math.cos(pa)*prr+x)/2, cy=(Math.sin(pa)*prr+y)/2;
ctx.quadraticCurveTo(Math.cos((pa+a)/2)*((prr+rr)/2)*1.06, Math.sin((pa+a)/2)*((prr+rr)/2)*1.06, x,y);
void cx; void cy;
}
}
ctx.closePath();
const bg=ctx.createRadialGradient(-R*0.32,-R*0.36,R*0.06,0,0,R*1.15);
bg.addColorStop(0,'#ffffff');
bg.addColorStop(0.14,T.c1);
bg.addColorStop(0.62,T.c2);
bg.addColorStop(1,'#04040c');
ctx.fillStyle=bg;
ctx.fill();
ctx.lineWidth=1.8;
ctx.strokeStyle=hexA(T.c1,0.85);
ctx.stroke();
// hit flash
if(o.hit>0){
ctx.globalCompositeOperation='lighter';
ctx.fillStyle='rgba(255,255,255,'+(o.hit*0.5)+')';
ctx.fill();
ctx.globalCompositeOperation='source-over';
}
// type-specific interior glyph
ctx.globalCompositeOperation='lighter';
ctx.lineWidth=1.6;
ctx.strokeStyle=hexA(T.c1,0.95);
const g=G.t;
switch(o.type){
case 'swarm':
for(let i=0;i<5;i++){
const a=g*1.6+i*TAU/5, rr=R*0.5;
ctx.beginPath(); ctx.arc(Math.cos(a)*rr,Math.sin(a)*rr,R*0.13,0,TAU);
ctx.fillStyle=hexA(T.c1,0.9); ctx.fill();
}
break;
case 'gravity':
for(let i=0;i<3;i++){
ctx.beginPath();
for(let k=0;k<28;k++){
const t=k/27, aa=t*TAU*1.5+g*1.5+i*2.1, rr=R*0.82*(1-t*0.85);
const x=Math.cos(aa)*rr, y=Math.sin(aa)*rr;
k?ctx.lineTo(x,y):ctx.moveTo(x,y);
}
ctx.stroke();
}
break;
case 'phase':
for(let i=0;i<3;i++){
ctx.globalAlpha=alpha*(0.5-i*0.13);
ctx.beginPath(); ctx.arc(Math.sin(g*3+i)*R*0.2,0,R*(0.7-i*0.17),0,TAU); ctx.stroke();
}
ctx.globalAlpha=alpha;
break;
case 'void':
ctx.fillStyle='#000';
ctx.globalCompositeOperation='source-over';
ctx.beginPath(); ctx.arc(0,0,R*0.44,0,TAU); ctx.fill();
ctx.globalCompositeOperation='lighter';
ctx.beginPath(); ctx.arc(0,0,R*0.46,-g*2,-g*2+2.4); ctx.stroke();
ctx.beginPath(); ctx.arc(0,0,R*0.62,-g*1.4+3, -g*1.4+5); ctx.stroke();
break;
case 'reverse':
ctx.beginPath();
ctx.arc(0,0,R*0.5,0.6,0.6+4.2); ctx.stroke();
ctx.beginPath();
ctx.moveTo(Math.cos(0.6+4.2)*R*0.5,Math.sin(0.6+4.2)*R*0.5);
ctx.lineTo(Math.cos(0.6+4.2)*R*0.5+R*0.2,Math.sin(0.6+4.2)*R*0.5-R*0.06);
ctx.lineTo(Math.cos(0.6+4.2)*R*0.5+R*0.03,Math.sin(0.6+4.2)*R*0.5+R*0.2);
ctx.closePath(); ctx.fillStyle=hexA(T.c1,0.9); ctx.fill();
break;
case 'shard':
for(let i=0;i<5;i++){
const a=i*TAU/5+g*0.4;
ctx.beginPath();
ctx.moveTo(0,0);
ctx.lineTo(Math.cos(a)*R*0.82,Math.sin(a)*R*0.82);
ctx.stroke();
}
break;
case 'slow': {
ctx.beginPath(); ctx.arc(0,0,R*0.55,0,TAU); ctx.stroke();
const ha=-Math.PI/2+ (g*0.55%TAU);
const ma=-Math.PI/2+ (g*3.1%TAU);
ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(Math.cos(ha)*R*0.3,Math.sin(ha)*R*0.3); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(Math.cos(ma)*R*0.5,Math.sin(ma)*R*0.5); ctx.stroke();
break;
}
case 'chain':
for(let i=0;i<4;i++){
ctx.strokeStyle='hsla('+((g*80+i*70)%360)+',100%,72%,0.9)';
ctx.beginPath();
ctx.arc(0,0,R*(0.24+i*0.16),g*(i%2?1.5:-1.5),g*(i%2?1.5:-1.5)+2.0);
ctx.stroke();
}
break;
case 'mirror':
ctx.beginPath();
ctx.moveTo(0,-R*0.75); ctx.lineTo(0,R*0.75); ctx.stroke();
ctx.beginPath();
ctx.moveTo(-R*0.75,0); ctx.lineTo(R*0.75,0); ctx.stroke();
ctx.globalAlpha=alpha*0.5;
ctx.beginPath(); ctx.arc(-R*0.34,0,R*0.24,0,TAU); ctx.stroke();
ctx.beginPath(); ctx.arc(R*0.34,0,R*0.24,0,TAU); ctx.stroke();
ctx.globalAlpha=alpha;
break;
case 'rubber':
for(let i=0;i<3;i++){
ctx.beginPath();
ctx.arc(0,0,R*(0.28+i*0.2)*(1+Math.sin(g*4+i)*0.07),0,TAU);
ctx.stroke();
}
break;
}
ctx.globalCompositeOperation='source-over';
// specular highlight
ctx.globalAlpha=alpha*0.55;
const hg=ctx.createRadialGradient(-R*0.38,-R*0.42,0,-R*0.38,-R*0.42,R*0.55);
hg.addColorStop(0,'rgba(255,255,255,0.9)');
hg.addColorStop(1,'rgba(255,255,255,0)');
ctx.fillStyle=hg;
ctx.beginPath(); ctx.arc(-R*0.34,-R*0.38,R*0.55,0,TAU); ctx.fill();
ctx.restore();
}
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 drawShip(){
if(!ship) return;
const blink = ship.inv>0 && (Math.floor(G.t*14)%2===0);
ctx.save();
ctx.translate(ship.x,ship.y);
// engine plume
if(ship.thrust>0.02){
ctx.save();
ctx.rotate(ship.a);
ctx.globalCompositeOperation='lighter';
const L=(26+Math.sin(G.t*40)*7)*ship.thrust;
const pg=ctx.createLinearGradient(-12,0,-12-L,0);
pg.addColorStop(0,'rgba(255,255,255,'+(0.85*ship.thrust)+')');
pg.addColorStop(0.28,'rgba(140,235,255,'+(0.6*ship.thrust)+')');
pg.addColorStop(1,'rgba(176,108,255,0)');
ctx.fillStyle=pg;
ctx.beginPath();
ctx.moveTo(-11,-6.5); ctx.lineTo(-12-L,0); ctx.lineTo(-11,6.5); ctx.closePath();
ctx.fill();
ctx.restore();
}
ctx.rotate(ship.a);
const rec = ship.recoil>0? -ship.recoil*2.4 : 0;
ctx.translate(rec,0);
ctx.globalAlpha = blink? 0.32 : 1;
// hull glow
ctx.globalCompositeOperation='lighter';
const g=ctx.createRadialGradient(0,0,2,0,0,26);
g.addColorStop(0,'rgba(140,235,255,0.4)');
g.addColorStop(1,'rgba(140,235,255,0)');
ctx.fillStyle=g;
ctx.beginPath(); ctx.arc(0,0,26,0,TAU); ctx.fill();
ctx.globalCompositeOperation='source-over';
// hull
ctx.beginPath();
ctx.moveTo(17,0);
ctx.lineTo(-9,-11);
ctx.lineTo(-5,0);
ctx.lineTo(-9,11);
ctx.closePath();
const hg=ctx.createLinearGradient(-10,0,17,0);
hg.addColorStop(0,'#16324a');
hg.addColorStop(0.55,'#9fe9ff');
hg.addColorStop(1,'#ffffff');
ctx.fillStyle=hg;
ctx.fill();
ctx.lineWidth=1.7;
ctx.strokeStyle= G.reverseT>0? '#ffd166' : '#cff6ff';
ctx.stroke();
// cockpit
ctx.beginPath(); ctx.ellipse(4,0,4.6,2.9,0,0,TAU);
ctx.fillStyle= G.reverseT>0? 'rgba(255,209,102,.95)' : 'rgba(127,240,208,.95)';
ctx.fill();
// shield ring when invulnerable
ctx.restore();
if(ship.inv>0){
ctx.save();
ctx.translate(ship.x,ship.y);
ctx.globalCompositeOperation='lighter';
const k=clamp(ship.inv/2.8,0,1);
ctx.strokeStyle='rgba(127,240,208,'+(0.35+0.3*Math.sin(G.t*9))*k+')';
ctx.lineWidth=2;
ctx.beginPath(); ctx.arc(0,0,20+Math.sin(G.t*5)*2.4,0,TAU); ctx.stroke();
ctx.restore();
}
// blink ready pip
if(ship.blinkCd<=0){
ctx.save();
ctx.globalCompositeOperation='lighter';
ctx.fillStyle='rgba(139,233,255,'+(0.4+0.3*Math.sin(G.t*6))+')';
ctx.beginPath(); ctx.arc(ship.x,ship.y,3.2,0,TAU); ctx.fill();
ctx.restore();
}
}
function draw(){
drawBG();
ctx.save();
ctx.translate(W/2+G.shakeX, H/2+G.shakeY);
ctx.rotate(G.rot);
ctx.translate(-W/2,-H/2);
ctx.globalCompositeOperation='lighter';
/* bubbles (time slow) */
for(const b of bubbles){
const k=clamp(b.life/b.max,0,1);
const g=ctx.createRadialGradient(b.x,b.y,b.r*0.1,b.x,b.y,b.r);
g.addColorStop(0,'rgba(127,240,208,0.02)');
g.addColorStop(0.75,'rgba(127,240,208,'+(0.06*k)+')');
g.addColorStop(0.98,'rgba(127,240,208,'+(0.28*k)+')');
g.addColorStop(1,'rgba(127,240,208,0)');
ctx.fillStyle=g;
ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,TAU); ctx.fill();
ctx.strokeStyle='rgba(180,255,235,'+(0.3*k)+')';
ctx.lineWidth=1.4;
ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,TAU); ctx.stroke();
// clock ticks
for(let i=0;i<12;i++){
const a=i*TAU/12+b.a*0.4;
ctx.strokeStyle='rgba(127,240,208,'+(0.22*k)+')';
ctx.beginPath();
ctx.moveTo(b.x+Math.cos(a)*b.r*0.9,b.y+Math.sin(a)*b.r*0.9);
ctx.lineTo(b.x+Math.cos(a)*b.r,b.y+Math.sin(a)*b.r);
ctx.stroke();
}
}
/* gravity wells */
for(const w of wells){
const k=clamp(w.life/w.max,0,1);
const g=ctx.createRadialGradient(w.x,w.y,2,w.x,w.y,w.r);
g.addColorStop(0,'rgba(197,139,255,'+(0.5*k)+')');
g.addColorStop(0.35,'rgba(140,90,220,'+(0.18*k)+')');
g.addColorStop(1,'rgba(90,40,180,0)');
ctx.fillStyle=g;
ctx.beginPath(); ctx.arc(w.x,w.y,w.r,0,TAU); ctx.fill();
ctx.lineWidth=1.3;
for(let i=0;i<4;i++){
ctx.strokeStyle='rgba(197,139,255,'+(0.28*k)+')';
ctx.beginPath();
for(let s=0;s<40;s++){
const t=s/39;
const aa=t*TAU*1.6 + w.a*(i%2?1:-1) + i*1.57;
const rr=w.r*(0.12+t*0.86);
const x=w.x+Math.cos(aa)*rr, y=w.y+Math.sin(aa)*rr;
s?ctx.lineTo(x,y):ctx.moveTo(x,y);
}
ctx.stroke();
}
}
/* black holes */
for(const h of holes){
const k=clamp(h.life/h.max,0,1);
// accretion disk
ctx.save();
ctx.translate(h.x,h.y); ctx.rotate(h.a*0.5);
for(let i=0;i<3;i++){
ctx.strokeStyle='rgba(255,'+(80+i*50)+','+(130+i*40)+','+(0.5*k)+')';
ctx.lineWidth=2.6-i*0.6;
ctx.beginPath();
ctx.ellipse(0,0,h.r*(2.0+i*0.7),h.r*(0.62+i*0.24),h.a*0.3,0,TAU);
ctx.stroke();
}
ctx.restore();
const g=ctx.createRadialGradient(h.x,h.y,h.r*0.5,h.x,h.y,h.r*3.4);
g.addColorStop(0,'rgba(255,91,127,'+(0.5*k)+')');
g.addColorStop(0.4,'rgba(176,108,255,'+(0.16*k)+')');
g.addColorStop(1,'rgba(90,20,90,0)');
ctx.fillStyle=g;
ctx.beginPath(); ctx.arc(h.x,h.y,h.r*3.4,0,TAU); ctx.fill();
}
ctx.globalCompositeOperation='source-over';
for(const h of holes){
// event horizon (true black)
ctx.fillStyle='#000';
ctx.beginPath(); ctx.arc(h.x,h.y,h.r*0.9,0,TAU); ctx.fill();
ctx.strokeStyle='rgba(255,255,255,0.5)';
ctx.lineWidth=1.2;
ctx.beginPath(); ctx.arc(h.x,h.y,h.r*0.9,0,TAU); ctx.stroke();
}
ctx.globalCompositeOperation='lighter';
/* ripples */
for(const r of ripples){
const k=clamp(r.life/r.max,0,1);
ctx.strokeStyle=hexA(r.col,k*0.85);
ctx.lineWidth=r.w*k+0.4;
ctx.beginPath(); ctx.arc(r.x,r.y,r.cur||r.r,0,TAU); ctx.stroke();
}
/* debris chunks */
ctx.globalCompositeOperation='source-over';
for(const d of debris){
const k=clamp(d.life/d.max,0,1);
ctx.save();
ctx.globalAlpha=k;
ctx.translate(d.x,d.y); ctx.rotate(d.a);
ctx.beginPath();
ctx.moveTo(d.r,0);
ctx.lineTo(-d.r*0.6,d.r*0.8);
ctx.lineTo(-d.r*0.3,-d.r*0.7);
ctx.closePath();
ctx.fillStyle=d.col; ctx.fill();
ctx.strokeStyle=d.edge; ctx.lineWidth=1; ctx.stroke();
ctx.restore();
}
ctx.globalAlpha=1;
/* orbs */
for(const o of orbs) wrapDraw(o.x,o.y,o.r*2.4,()=>drawOrb(o));
/* swarmlings */
ctx.globalCompositeOperation='lighter';
for(const s of swarm){
const k=clamp(s.life/s.max,0,1);
for(let i=0;i<s.trail.length;i++){
const p=s.trail[i], t=i/s.trail.length;
if(Math.abs(p.x-s.x)>W/2||Math.abs(p.y-s.y)>H/2) continue;
ctx.fillStyle='rgba(141,255,159,'+(t*0.35*k)+')';
ctx.beginPath(); ctx.arc(p.x,p.y,s.r*t*0.8,0,TAU); ctx.fill();
}
const g=ctx.createRadialGradient(s.x,s.y,0,s.x,s.y,s.r*3);
g.addColorStop(0,'rgba(220,255,225,'+(0.95*k)+')');
g.addColorStop(0.28,'rgba(141,255,159,'+(0.6*k)+')');
g.addColorStop(1,'rgba(141,255,159,0)');
ctx.fillStyle=g;
ctx.beginPath(); ctx.arc(s.x,s.y,s.r*3,0,TAU); ctx.fill();
ctx.fillStyle='#eaffe9';
ctx.beginPath(); ctx.arc(s.x,s.y,s.r*0.55,0,TAU); ctx.fill();
}
/* shards */
for(const s of shards){
const k=clamp(s.life/s.max,0,1);
for(let i=1;i<s.trail.length;i++){
const a=s.trail[i-1], b=s.trail[i];
if(Math.abs(a.x-b.x)>W/2||Math.abs(a.y-b.y)>H/2) continue;
ctx.strokeStyle='rgba(188,216,255,'+(i/s.trail.length*0.35*k)+')';
ctx.lineWidth=2.2*(i/s.trail.length);
ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke();
}
ctx.save();
ctx.translate(s.x,s.y); ctx.rotate(s.a);
ctx.fillStyle='rgba(226,240,255,'+(0.95*k)+')';
ctx.beginPath();
ctx.moveTo(s.len,0); ctx.lineTo(-s.len*0.34,s.len*0.3); ctx.lineTo(-s.len*0.2,-s.len*0.28);
ctx.closePath(); ctx.fill();
ctx.strokeStyle='rgba(255,255,255,'+(0.8*k)+')';
ctx.lineWidth=1; ctx.stroke();
ctx.restore();
}
/* bullets */
for(const b of bullets){
for(let i=1;i<b.trail.length;i++){
const p=b.trail[i-1], q=b.trail[i], t=i/b.trail.length;
if(Math.abs(p.x-q.x)>W/2||Math.abs(p.y-q.y)>H/2) continue;
ctx.strokeStyle='hsla('+b.hue+',100%,'+(60+t*30)+'%,'+(t*0.6)+')';
ctx.lineWidth=b.r*2*t;
ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(p.x,p.y); ctx.lineTo(q.x,q.y); ctx.stroke();
}
const g=ctx.createRadialGradient(b.x,b.y,0,b.x,b.y,b.r*4.5);
g.addColorStop(0,'#ffffff');
g.addColorStop(0.3,'hsla('+b.hue+',100%,72%,0.85)');
g.addColorStop(1,'hsla('+b.hue+',100%,60%,0)');
ctx.fillStyle=g;
ctx.beginPath(); ctx.arc(b.x,b.y,b.r*4.5,0,TAU); ctx.fill();
}
/* particles */
for(const p of parts){
const k=clamp(p.life/p.max,0,1);
ctx.globalAlpha=k;
ctx.fillStyle=p.col;
ctx.beginPath(); ctx.arc(p.x,p.y,p.size*(0.35+k*0.75),0,TAU); ctx.fill();
}
ctx.globalAlpha=1;
/* muzzle sparks */
for(const s of sparks){
const k=clamp(s.life/s.max,0,1);
ctx.strokeStyle=hexA(s.col,k);
ctx.lineWidth=2.4*k;
ctx.beginPath();
ctx.moveTo(s.x,s.y);
ctx.lineTo(s.x+Math.cos(s.ang)*s.len*k,s.y+Math.sin(s.ang)*s.len*k);
ctx.stroke();
}
ctx.globalCompositeOperation='source-over';
/* ship */
if(ship && ship.alive) wrapDraw(ship.x,ship.y,34,drawShip);
/* floating score text */
ctx.textAlign='center';
for(const f of floats){
const k=clamp(f.life,0,1);
ctx.globalAlpha=k;
ctx.font='900 '+f.size+'px "Trebuchet MS",system-ui,sans-serif';
ctx.shadowColor=f.col; ctx.shadowBlur=14;
ctx.fillStyle=f.col;
ctx.fillText(f.txt,f.x,f.y);
ctx.shadowBlur=0;
}
ctx.globalAlpha=1;
ctx.restore();
drawHUD();
/* full-screen flash */
if(G.flash>0.004){
ctx.globalCompositeOperation='lighter';
ctx.fillStyle=hexA(G.flashCol,Math.min(0.6,G.flash*0.55));
ctx.fillRect(0,0,W,H);
ctx.globalCompositeOperation='source-over';
}
/* reverse-mode tint */
if(G.reverseT>0){
const k=Math.min(1,G.reverseT/1.5)*0.10*(0.7+0.3*Math.sin(G.t*8));
ctx.fillStyle='rgba(255,190,60,'+k+')';
ctx.fillRect(0,0,W,H);
// edge bars
ctx.fillStyle='rgba(255,209,102,'+(0.16+0.08*Math.sin(G.t*10))+')';
ctx.fillRect(0,0,W,4); ctx.fillRect(0,H-4,W,4);
}
/* slow-mo tint */
if(G.slowT>0.02){
ctx.fillStyle='rgba(127,240,208,'+(G.slowT*0.055)+')';
ctx.fillRect(0,0,W,H);
}
/* scanlines */
ctx.globalAlpha=0.045;
ctx.fillStyle='#000';
for(let y=0;y<H;y+=3) ctx.fillRect(0,y,W,1);
ctx.globalAlpha=1;
}
/* ---------------- HUD ---------------- */
function drawHUD(){
if(G.state!=='play') return;
const pad=22;
ctx.save();
ctx.textAlign='left';
ctx.textBaseline='top';
// score
ctx.font='900 34px "Trebuchet MS",system-ui,sans-serif';
ctx.shadowColor='rgba(120,210,255,.85)'; ctx.shadowBlur=18;
ctx.fillStyle='#dff2ff';
ctx.fillText(String(Math.round(G.disp)).padStart(6,'0'), pad, pad-4);
ctx.shadowBlur=0;
ctx.font='700 11px "Trebuchet MS",system-ui,sans-serif';
ctx.fillStyle='#6f88ad';
ctx.fillText('SCORE BEST '+G.best, pad+2, pad+32);
// lives (ship icons)
for(let i=0;i<Math.min(G.lives,8);i++){
ctx.save();
ctx.translate(pad+10+i*24, pad+62);
ctx.rotate(-Math.PI/2);
ctx.beginPath();
ctx.moveTo(9,0); ctx.lineTo(-5,-6); ctx.lineTo(-2.6,0); ctx.lineTo(-5,6);
ctx.closePath();
ctx.fillStyle='#9fe9ff';
ctx.shadowColor='#63c8ff'; ctx.shadowBlur=10;
ctx.fill();
ctx.restore();
}
ctx.shadowBlur=0;
// wave + orbs right
ctx.textAlign='right';
ctx.font='900 26px "Trebuchet MS",system-ui,sans-serif';
ctx.fillStyle='#cfe4ff';
ctx.shadowColor='rgba(176,108,255,.7)'; ctx.shadowBlur=14;
ctx.fillText('WAVE '+G.wave, W-pad, pad);
ctx.shadowBlur=0;
ctx.font='700 11px "Trebuchet MS",system-ui,sans-serif';
ctx.fillStyle='#6f88ad';
ctx.fillText(orbs.length+' ORBS · '+G.popped+' POPPED', W-pad, pad+30);
// combo meter
if(G.combo>1){
const k=clamp(G.comboT/1.8,0,1);
const cx=W/2, cy=pad+16;
ctx.textAlign='center';
ctx.font='900 '+(20+Math.min(G.combo,14))+'px "Trebuchet MS",system-ui,sans-serif';
const hue=(140+G.combo*14)%360;
ctx.fillStyle='hsl('+hue+',100%,72%)';
ctx.shadowColor='hsl('+hue+',100%,60%)'; ctx.shadowBlur=18;
ctx.fillText('x'+G.combo+' COMBO', cx, cy-8);
ctx.shadowBlur=0;
// bar
const bw=150;
ctx.fillStyle='rgba(255,255,255,.12)';
ctx.fillRect(cx-bw/2, cy+22, bw, 4);
ctx.fillStyle='hsl('+hue+',100%,66%)';
ctx.fillRect(cx-bw/2, cy+22, bw*k, 4);
}
// blink cooldown ring (bottom-left)
if(ship){
const bx=pad+18, by=H-pad-18;
const rdy = ship.blinkCd<=0;
ctx.beginPath(); ctx.arc(bx,by,14,0,TAU);
ctx.strokeStyle='rgba(255,255,255,.16)'; ctx.lineWidth=3; ctx.stroke();
const p = rdy?1:1-(ship.blinkCd/3.4);
ctx.beginPath(); ctx.arc(bx,by,14,-Math.PI/2,-Math.PI/2+TAU*p);
ctx.strokeStyle= rdy? '#8be9ff' : '#4a6a86';
ctx.lineWidth=3;
if(rdy){ ctx.shadowColor='#8be9ff'; ctx.shadowBlur=12; }
ctx.stroke(); ctx.shadowBlur=0;
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.font='900 10px "Trebuchet MS",system-ui,sans-serif';
ctx.fillStyle= rdy? '#cff4ff':'#5c7a92';
ctx.fillText('⇧',bx,by+1);
ctx.textBaseline='top';
ctx.font='700 9px "Trebuchet MS",system-ui,sans-serif';
ctx.fillStyle='#5b7characters'.slice(0,7)==='#5b7cha'?'#5b7c96':'#5b7c96';
ctx.fillText('BLINK',bx,by+20);
}
// active-effect chips (bottom-right)
const chips=[];
if(G.reverseT>0) chips.push({t:'REVERSED '+G.reverseT.toFixed(1),c:'#ffd166'});
if(bubbles.length) chips.push({t:'TIME BUBBLE',c:'#7ff0d0'});
if(holes.length) chips.push({t:'BLACK HOLE',c:'#ff5b7f'});
if(wells.length) chips.push({t:'GRAVITY WELL',c:'#c58bff'});
if(swarm.length) chips.push({t:'SWARM '+swarm.length,c:'#8dff9f'});
ctx.textAlign='right'; ctx.textBaseline='middle';
ctx.font='800 11px "Trebuchet MS",system-ui,sans-serif';
for(let i=0;i<chips.length;i++){
const y=H-pad-10-i*20;
ctx.fillStyle=hexA(chips[i].c,0.14);
const w=ctx.measureText(chips[i].t).width+20;
ctx.beginPath();
if(ctx.roundRect) { ctx.roundRect(W-pad-w, y-8, w, 16, 8); ctx.fill(); }
else ctx.fillRect(W-pad-w,y-8,w,16);
ctx.fillStyle=chips[i].c;
ctx.fillText(chips[i].t, W-pad-10, y);
}
ctx.restore();
}
/* ---------------- loop ---------------- */
let last=performance.now();
function frame(now){
let dt=(now-last)/1000; last=now;
if(dt>0.05) dt=0.05;
if(G.state==='play' && !G.paused) update(dt);
else {
// idle ambience on menus
G.t+=dt;
for(const st of stars) st.tw+=dt*st.ts;
for(const n of neb){
n.x+=n.vx*dt*10; n.y+=n.vy*dt*10;
if(n.x<-n.r) n.x=W+n.r; if(n.x>W+n.r) n.x=-n.r;
if(n.y<-n.r) n.y=H+n.r; if(n.y>H+n.r) n.y=-n.r;
}
for(let i=parts.length-1;i>=0;i--){
const p=parts[i]; p.life-=dt;
if(p.life<=0){ parts.splice(i,1); continue; }
p.x+=p.vx*dt; p.y+=p.vy*dt;
p.vx*=Math.pow(p.drag,dt*60); p.vy*=Math.pow(p.drag,dt*60);
}
G.flash*=Math.pow(0.0022,dt);
G.shake*=Math.pow(0.0009,dt);
G.shakeX=rand(G.shake,-G.shake); G.shakeY=rand(G.shake,-G.shake);
}
draw();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
/* menu ambience: idle orbs drifting behind the start screen */
(function ambient(){
for(let i=0;i<7;i++){
const o=spawnOrb();
o.vx*=0.5; o.vy*=0.5;
}
})();
</script>
</body>
</html>