quantdoom / index.html
PeetPedro's picture
QUANTDOOM — quantum raycaster; shooting is a Born-rule measurement, specters tunnel through walls
db2a693 verified
Raw
History Blame Contribute Delete
22.1 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>QUANTDOOM</title>
<meta name="description" content="A quantum raycaster. Specters pulse between ghost and solid; your shot is a measurement and your hit chance is their amplitude squared — the Born rule as a trigger-timing skill. Fire when they collapse.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Anton&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root{--bg:#08040a;--ink:#f2e9ee;--dim:#9a7f92;--hot:#ff2f4e;--cool:#25e0ff;--gold:#ffcf3f;--mono:'JetBrains Mono',ui-monospace,monospace;--disp:'Anton',Impact,sans-serif;}
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;user-select:none}
html,body{height:100%;overflow:hidden;background:#000;color:var(--ink);font-family:var(--mono)}
#stage{position:fixed;inset:0;background:#000}
canvas{position:absolute;inset:0;width:100%;height:100%;image-rendering:pixelated}
#hud{position:absolute;inset:0;pointer-events:none;z-index:5}
.top{position:absolute;top:0;left:0;right:0;display:flex;justify-content:space-between;padding:14px 18px}
.stat .l{font-size:9px;letter-spacing:.24em;color:var(--dim);text-transform:uppercase}
.stat .v{font-family:var(--disp);font-size:30px;line-height:.9;font-variant-numeric:tabular-nums}
#kills .v{color:var(--gold)} .stat.r{text-align:right}
#coh-wrap{position:absolute;left:18px;bottom:16px;width:min(46vw,240px)}
#coh-wrap .l{font-size:9px;letter-spacing:.22em;color:var(--dim);text-transform:uppercase;margin-bottom:4px;display:flex;justify-content:space-between}
#coh-bar{height:12px;background:rgba(255,47,78,.14);border:1px solid rgba(255,47,78,.4);border-radius:7px;overflow:hidden}
#coh-fill{height:100%;width:100%;background:linear-gradient(90deg,var(--hot),#ff7a3f);transition:width .12s}
#msg{position:absolute;top:22%;left:50%;transform:translateX(-50%);font-family:var(--disp);font-size:clamp(28px,7vw,60px);letter-spacing:.04em;opacity:0;text-align:center;text-shadow:0 0 30px currentColor}
/* touch controls */
#pad{position:absolute;inset:0;z-index:6}
.btn{position:absolute;pointer-events:auto;font-family:var(--disp);color:#fff;background:rgba(37,224,255,.10);border:1.5px solid rgba(37,224,255,.32);border-radius:50%;display:grid;place-items:center;backdrop-filter:blur(2px)}
.btn:active{background:rgba(37,224,255,.28)}
#b-l{left:16px;bottom:96px;width:62px;height:62px;font-size:26px}
#b-r{left:104px;bottom:96px;width:62px;height:62px;font-size:26px}
#b-f{left:60px;bottom:172px;width:62px;height:62px;font-size:22px}
#b-fire{right:22px;bottom:110px;width:96px;height:96px;font-size:18px;background:rgba(255,47,78,.16);border-color:rgba(255,47,78,.5);color:var(--hot)}
#b-fire:active{background:rgba(255,47,78,.34)}
.hint{position:absolute;bottom:6px;left:0;right:0;text-align:center;font-size:9.5px;letter-spacing:.1em;color:var(--dim)}
/* overlays */
.screen{position:absolute;inset:0;z-index:10;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:22px;background:radial-gradient(120% 90% at 50% 25%,rgba(255,47,78,.10),transparent 60%),rgba(6,3,8,.86);pointer-events:auto}
.screen.hide{display:none}
h1{font-family:var(--disp);font-size:clamp(52px,17vw,150px);line-height:.82;letter-spacing:.02em;background:linear-gradient(180deg,var(--cool),#7b6bff 45%,var(--hot));-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;filter:drop-shadow(0 4px 40px rgba(255,47,78,.3))}
.tag{color:var(--dim);max-width:32rem;margin:14px 0 2px;font-size:13.5px;line-height:1.6}.tag b{color:var(--ink);font-weight:500}
.how{display:flex;gap:12px;flex-wrap:wrap;justify-content:center;margin:20px 0 4px;max-width:36rem}
.card{flex:1 1 150px;min-width:145px;background:rgba(30,14,26,.5);border:1px solid #2c1730;border-radius:12px;padding:13px 12px;font-size:11.5px;line-height:1.5;color:var(--dim)}
.card b{display:block;font-family:var(--disp);font-size:15px;letter-spacing:.03em;margin-bottom:5px;color:var(--ink)}
.card .g{color:var(--cool)}.card .s{color:var(--hot)}
button.go{font-family:var(--disp);font-size:22px;letter-spacing:.06em;cursor:pointer;color:#12040a;background:linear-gradient(180deg,var(--hot),#c81232);border:0;border-radius:12px;padding:14px 40px;margin-top:24px;box-shadow:0 6px 30px rgba(255,47,78,.4)}
button.go:active{transform:translateY(2px)}
.fine{margin-top:20px;font-size:10px;letter-spacing:.14em;color:#6a4a5c;text-transform:uppercase}.fine .f{color:var(--hot)}
#go-score{font-family:var(--disp);font-size:clamp(56px,16vw,120px);color:var(--gold);line-height:.9;margin:2px 0}
.kbd{font-size:10px;letter-spacing:.1em;color:#6a4a5c;margin-top:8px}
@media(hover:hover){#pad{display:none}}
</style>
</head>
<body>
<div id="stage">
<canvas id="c"></canvas>
<div id="hud">
<div class="top">
<div class="stat" id="kills"><span class="l">specters collapsed</span><br><span class="v" id="kills-v">0</span></div>
<div class="stat r" id="best"><span class="l">best</span><br><span class="v" id="best-v">0</span></div>
</div>
<div id="coh-wrap"><div class="l"><span>coherence</span><span id="coh-pct">100%</span></div><div id="coh-bar"><div id="coh-fill"></div></div></div>
<div id="msg"></div>
</div>
<div id="pad">
<div class="btn" id="b-l"></div><div class="btn" id="b-r"></div><div class="btn" id="b-f"></div><div class="btn" id="b-fire">FIRE</div>
</div>
<div class="screen" id="start">
<h1>QUANT<br>DOOM</h1>
<p class="tag">The specters are in <b>superposition</b> — pulsing between <span style="color:var(--cool)">ghost</span> and <span style="color:var(--hot)">collapsed</span>. Your shot is a <b>measurement</b>. Hit chance = their <b>amplitude²</b> (the Born rule). So fire when they turn solid — that's also exactly when they can hurt you.</p>
<div class="how">
<div class="card"><b>SEE</b>A <span class="g">faint cyan</span> specter is a ghost — it can't touch you and can't be killed. A <span class="s">solid red</span> one has collapsed: lethal, and vulnerable.</div>
<div class="card"><b>SHOOT</b>Firing collapses its wavefunction. The more solid it is (brighter), the likelier your shot lands. Fire at a ghost and it just phases away.</div>
<div class="card"><b>SURVIVE</b>They <span class="g">tunnel</span> straight through walls toward you. Keep your coherence up. It never really ends — chase the count.</div>
</div>
<button class="go" id="start-btn">▶ ENTER THE FIELD</button>
<p class="kbd">desktop: <b>W/S</b> move &middot; <b>A/D</b> or <b>←/→</b> turn &middot; <b>SPACE/click</b> fire &nbsp;|&nbsp; touch: on-screen pads</p>
<p class="fine">🜂 born rule as a trigger &middot; measurement is a weapon &middot; <span class="f">ahogy a dolgok vannak</span></p>
</div>
<div class="screen hide" id="over">
<p class="tag" style="margin-bottom:0">coherence lost &middot; you decohered</p>
<div id="go-score">0</div>
<p class="tag" style="margin-top:0">specters collapsed &middot; best <b id="go-best">0</b></p>
<button class="go" id="again-btn">▶ RE-ENTER</button>
<p class="fine">PeetPedro &middot; entropy-om &middot; <span class="f">quantum play</span></p>
</div>
</div>
<script>
(function(){
"use strict";
var cv=document.getElementById('c'), ctx=cv.getContext('2d',{alpha:false});
var W=0,H=0,RAYS=0,colW=0, DPR=1;
var reduce=matchMedia('(prefers-reduced-motion: reduce)').matches;
// ---------- map ----------
var MAP=[
"################",
"#..............#",
"#..##....##....#",
"#..#......#..#.#",
"#..#..##..#..#.#",
"#.....##.......#",
"#..............#",
"#.####....####.#",
"#.#..........#.#",
"#.#..######..#.#",
"#....#....#....#",
"#..#.#....#.#..#",
"#..#......#.#..#",
"#..#.####.....##",
"#..............#",
"################"];
var MW=MAP[0].length, MH=MAP.length;
function cell(x,y){ if(x<0||y<0||x>=MW||y>=MH) return '#'; return MAP[y|0][x|0]; }
function isWall(x,y){ return cell(x,y)==='#'; }
// ---------- state ----------
var player={x:2.5,y:2.5,ang:0.4}, FOV=Math.PI/3;
var enemies=[], zbuf=[], running=false, kills=0, coherence=100, best=+(localStorage.getItem('qd_best')||0);
var spawnT=0, spawnEvery=2.2, wave=0, muzzle=0, shake=0, vign=0, dmgCooldown=0;
var keys={}, touch={l:false,r:false,f:false};
document.getElementById('best-v').textContent=best;
// ---------- audio ----------
var actx=null;
function A(){ if(!actx){ try{actx=new (window.AudioContext||window.webkitAudioContext)();}catch(e){} } if(actx&&actx.state==='suspended')actx.resume(); }
function zap(){ if(!actx)return; var n=actx.currentTime,o=actx.createOscillator(),g=actx.createGain();
o.type='square'; o.frequency.setValueAtTime(880,n); o.frequency.exponentialRampToValueAtTime(140,n+.12);
g.gain.setValueAtTime(.14,n); g.gain.exponentialRampToValueAtTime(.0008,n+.14); o.connect(g);g.connect(actx.destination);o.start(n);o.stop(n+.15); }
function pop(hit){ if(!actx)return; var n=actx.currentTime,o=actx.createOscillator(),g=actx.createGain();
o.type=hit?'triangle':'sine'; o.frequency.setValueAtTime(hit?520:180,n); o.frequency.exponentialRampToValueAtTime(hit?1400:90,n+.18);
g.gain.setValueAtTime(.18,n); g.gain.exponentialRampToValueAtTime(.0008,n+.25); o.connect(g);g.connect(actx.destination);o.start(n);o.stop(n+.26); }
function hurt(){ if(!actx)return; var n=actx.currentTime,o=actx.createOscillator(),g=actx.createGain();
o.type='sawtooth'; o.frequency.setValueAtTime(120,n); o.frequency.exponentialRampToValueAtTime(40,n+.3);
g.gain.setValueAtTime(.2,n); g.gain.exponentialRampToValueAtTime(.001,n+.32); o.connect(g);g.connect(actx.destination);o.start(n);o.stop(n+.33); }
// ---------- setup ----------
function resize(){ DPR=Math.min(2,window.devicePixelRatio||1);
W=Math.floor(innerWidth); H=Math.floor(innerHeight);
cv.width=Math.floor(W*0.6); cv.height=Math.floor(H*0.6); // render at 60% then CSS-upscale for speed + chunky look
RAYS=cv.width; colW=1; ctx.imageSmoothingEnabled=false; }
addEventListener('resize',resize);
function spawnEnemy(){
// spawn at a floor cell far from the player
for(var tries=0;tries<40;tries++){
var x=1+Math.random()*(MW-2), y=1+Math.random()*(MH-2);
if(isWall(x,y)) continue;
var dx=x-player.x, dy=y-player.y; if(dx*dx+dy*dy<25) continue;
enemies.push({x:x,y:y, amp:0.5, rate:0.7+Math.random()*1.1, ph:Math.random()*6.28, dead:false, hitFlash:0, killT:0});
return;
}
}
function start(){
running=true; kills=0; coherence=100; enemies=[]; spawnT=0; spawnEvery=2.2; wave=0; muzzle=0; shake=0; vign=0;
player.x=2.5; player.y=2.5; player.ang=0.4;
document.getElementById('start').classList.add('hide'); document.getElementById('over').classList.add('hide');
document.getElementById('kills-v').textContent='0'; setCoh(100);
for(var i=0;i<3;i++) spawnEnemy();
}
function gameOver(){ running=false;
if(kills>best){ best=kills; localStorage.setItem('qd_best',best); document.getElementById('best-v').textContent=best; }
document.getElementById('go-score').textContent=kills;
document.getElementById('go-best').textContent=best;
document.getElementById('over').classList.remove('hide');
}
function setCoh(v){ coherence=Math.max(0,Math.min(100,v));
document.getElementById('coh-fill').style.width=coherence+'%';
document.getElementById('coh-pct').textContent=Math.round(coherence)+'%'; }
function flashMsg(t,col){ var m=document.getElementById('msg'); m.textContent=t; m.style.color=col||'#fff';
m.style.transition='none'; m.style.opacity='1';
requestAnimationFrame(function(){ m.style.transition='opacity .7s'; m.style.opacity='0'; }); }
// ---------- shooting ----------
function fire(){
if(!running) return;
muzzle=1; zap(); shake=Math.max(shake,3);
// find the enemy closest to screen-centre (crosshair), in front, within range, roughly unoccluded
var bestE=null, bestScore=1e9;
for(var i=0;i<enemies.length;i++){ var e=enemies[i]; if(e.dead) continue;
var dx=e.x-player.x, dy=e.y-player.y, dist=Math.hypot(dx,dy);
if(dist>11) continue;
var ang=Math.atan2(dy,dx)-player.ang; while(ang<-Math.PI)ang+=6.283; while(ang>Math.PI)ang-=6.283;
if(Math.abs(ang)>0.14) continue; // must be near the crosshair
// no wall check: specters are delocalised — they tunnel, so they're shootable through walls
var sc=Math.abs(ang)*4+dist*0.05;
if(sc<bestScore){ bestScore=sc; bestE=e; }
}
if(!bestE){ return; }
var amp=bestE.amp;
var p=amp*amp; // Born rule: |amplitude|^2
if(Math.random()<p){ // collapsed to |hit>
bestE.dead=true; bestE.killT=performance.now(); kills++;
document.getElementById('kills-v').textContent=kills;
pop(true);
if(kills%10===0) flashMsg(kills+' COLLAPSED', '#ffcf3f');
} else { // collapsed to |ghost> — it phases out and survives
bestE.amp=0.06; bestE.ph=Math.PI; // force ghostly for a beat
pop(false);
}
}
function wallBetween(x0,y0,x1,y1){
var steps=Math.ceil(Math.hypot(x1-x0,y1-y0)*4);
for(var s=1;s<steps;s++){ var t=s/steps; if(isWall(x0+(x1-x0)*t, y0+(y1-y0)*t)) return true; }
return false;
}
// ---------- update ----------
function update(dt){
// turn + move
var turn=(keys.a||keys.ArrowLeft||touch.l?-1:0)+(keys.d||keys.ArrowRight||touch.r?1:0);
player.ang += turn*2.6*dt;
var mv=(keys.w||keys.ArrowUp||touch.f?1:0)+(keys.s||keys.ArrowDown?-1:0);
if(mv){ var nx=player.x+Math.cos(player.ang)*mv*2.8*dt, ny=player.y+Math.sin(player.ang)*mv*2.8*dt;
if(!isWall(nx,player.y)) player.x=nx; if(!isWall(player.x,ny)) player.y=ny; }
// enemies: pulse (superposition) + tunnel toward player
spawnT+=dt; if(spawnT>spawnEvery && enemies.filter(function(e){return!e.dead;}).length<10){ spawnT=0; spawnEvery=Math.max(0.9,spawnEvery-0.04); spawnEnemy(); }
var melee=false;
for(var i=0;i<enemies.length;i++){ var e=enemies[i]; if(e.dead){ e.killT2=1; continue; }
e.ph+=e.rate*dt*2.2;
var tgt=0.5+0.5*Math.sin(e.ph);
e.amp += (tgt-e.amp)*Math.min(1,dt*6); // ease toward the pulsing target
if(e.hitFlash>0) e.hitFlash-=dt*3;
var dx=player.x-e.x, dy=player.y-e.y, d=Math.hypot(dx,dy)||1;
var spd=0.9+Math.min(1.2,kills*0.03); // ramps up as you rack collapses
e.x+=dx/d*spd*dt; e.y+=dy/d*spd*dt; // tunnels through walls
if(d<0.7 && e.amp>0.6){ melee=true; } // only a COLLAPSED specter can hurt you
}
enemies=enemies.filter(function(e){ return !e.dead || (performance.now()-e.killT)<260; });
// damage
dmgCooldown-=dt;
if(melee){ setCoh(coherence-38*dt); vign=Math.min(1,vign+dt*3); shake=Math.max(shake,4); if(dmgCooldown<=0){ hurt(); dmgCooldown=0.4; } if(coherence<=0) gameOver(); }
else vign=Math.max(0,vign-dt*1.6);
if(muzzle>0) muzzle=Math.max(0,muzzle-dt*6);
if(shake>0) shake=Math.max(0,shake-dt*22);
wave+=dt*0.03;
}
// ---------- render ----------
function draw(){
var w=cv.width, h=cv.height;
// ceiling / floor
var g=ctx.createLinearGradient(0,0,0,h);
g.addColorStop(0,'#120a18'); g.addColorStop(0.5,'#05030a'); g.addColorStop(0.5,'#0a0710'); g.addColorStop(1,'#1a0e16');
ctx.fillStyle=g; ctx.fillRect(0,0,w,h);
var sx=0,sy=0; if(shake>0){ sx=(Math.random()*2-1)*shake; sy=(Math.random()*2-1)*shake; }
ctx.save(); ctx.translate(sx,sy);
var dirx=Math.cos(player.ang), diry=Math.sin(player.ang);
var planex=-diry*Math.tan(FOV/2), planey=dirx*Math.tan(FOV/2);
zbuf=new Float32Array(w);
// raycast walls (DDA)
for(var x=0;x<w;x++){
var camx=2*x/w-1, rdx=dirx+planex*camx, rdy=diry+planey*camx;
var mapx=player.x|0, mapy=player.y|0;
var ddx=Math.abs(1/rdx), ddy=Math.abs(1/rdy);
var stepx,stepy,sidex,sidey;
if(rdx<0){stepx=-1;sidex=(player.x-mapx)*ddx;}else{stepx=1;sidex=(mapx+1-player.x)*ddx;}
if(rdy<0){stepy=-1;sidey=(player.y-mapy)*ddy;}else{stepy=1;sidey=(mapy+1-player.y)*ddy;}
var hit=0,side=0,guard=0;
while(!hit && guard++<64){ if(sidex<sidey){sidex+=ddx;mapx+=stepx;side=0;}else{sidey+=ddy;mapy+=stepy;side=1;} if(cell(mapx,mapy)==='#')hit=1; }
var pd = side===0 ? (sidex-ddx) : (sidey-ddy); if(pd<0.01)pd=0.01;
zbuf[x]=pd;
var lh=h/pd, y0=(h-lh)/2, y1=y0+lh;
// wall shading: distance fog + side + subtle vertical banding
var shade=Math.max(0,1-pd/12);
var base= side? 62:96;
var r=Math.round(base*shade*0.55+8), gg=Math.round(base*shade*0.75+8), b=Math.round((base+40)*shade+12);
ctx.fillStyle='rgb('+r+','+gg+','+b+')';
ctx.fillRect(x, y0<0?0:y0, 1, (y1>h?h:y1)-(y0<0?0:y0));
// grid seam highlight
if((side===0 && (player.y+pd*rdy)%1<0.06) || (side===1 && (player.x+pd*rdx)%1<0.06)){
ctx.fillStyle='rgba(37,224,255,'+(0.12*shade)+')'; ctx.fillRect(x,y0<0?0:y0,1,(y1>h?h:y1)-(y0<0?0:y0));
}
}
// sprites (enemies), far to near
var vis=enemies.slice().sort(function(a,b){ return dist2(b)-dist2(a); });
for(var i=0;i<vis.length;i++){
var e=vis[i];
var ex=e.x-player.x, ey=e.y-player.y;
var inv=1/(planex*diry-dirx*planey);
var tx=inv*(diry*ex-dirx*ey);
var ty=inv*(-planey*ex+planex*ey); // depth (forward)
if(ty<=0.15) continue;
var scr=(w/2)*(1+tx/ty);
var size=Math.min(h*1.4, h/ty);
if(scr< -size || scr>w+size) continue; // off-screen
// specters glow THROUGH walls (they're tunnelling) — a wall only dims them a little
var occl = ty>zbuf[Math.max(0,Math.min(w-1,scr|0))] ? 0.45 : 1;
drawSpecter(scr, h/2, size, e, ty, occl);
}
ctx.restore();
// ---------- weapon + fx (screen space) ----------
drawGun(w,h);
// crosshair
ctx.strokeStyle='rgba(37,224,255,.7)'; ctx.lineWidth=Math.max(1,w*0.0018);
var cxp=w/2, cyp=h/2, cr=w*0.012;
ctx.beginPath(); ctx.moveTo(cxp-cr,cyp);ctx.lineTo(cxp-cr*0.4,cyp); ctx.moveTo(cxp+cr*0.4,cyp);ctx.lineTo(cxp+cr,cyp);
ctx.moveTo(cxp,cyp-cr);ctx.lineTo(cxp,cyp-cr*0.4); ctx.moveTo(cxp,cyp+cr*0.4);ctx.lineTo(cxp,cyp+cr); ctx.stroke();
// muzzle flash
if(muzzle>0){ ctx.fillStyle='rgba(255,'+(200)+',80,'+(0.5*muzzle)+')'; ctx.fillRect(0,0,w,h); }
// damage vignette
if(vign>0){ var vg=ctx.createRadialGradient(w/2,h/2,h*0.2,w/2,h/2,h*0.75);
vg.addColorStop(0,'rgba(255,47,78,0)'); vg.addColorStop(1,'rgba(255,47,78,'+(0.55*vign)+')');
ctx.fillStyle=vg; ctx.fillRect(0,0,w,h); }
}
function dist2(e){ var dx=e.x-player.x,dy=e.y-player.y; return dx*dx+dy*dy; }
function drawSpecter(cx,cy,size,e,ty,occl){
var solid=e.dead?1:e.amp;
var t=e.dead? Math.min(1,(performance.now()-e.killT)/260):0;
var alpha=(e.dead? (1-t) : (0.18+0.82*solid*solid)) * (occl||1);
var hue = e.dead? 50 : (200 - 160*solid); // cyan(ghost) -> red(solid)
var s=size*0.5, top=cy-s*0.9, bot=cy+s*0.95;
ctx.save(); ctx.globalAlpha=alpha;
// body glow
var gr=ctx.createRadialGradient(cx,cy,0,cx,cy,s);
gr.addColorStop(0,'hsla('+hue+',100%,'+(58+ (e.dead?20:0))+'%,0.95)');
gr.addColorStop(0.6,'hsla('+hue+',100%,45%,0.5)');
gr.addColorStop(1,'hsla('+hue+',100%,40%,0)');
ctx.fillStyle=gr;
ctx.beginPath(); ctx.ellipse(cx,cy,s*0.7,s*0.95,0,0,7); ctx.fill();
if(e.dead){ // collapse burst
ctx.strokeStyle='rgba(255,220,120,'+(1-t)+')'; ctx.lineWidth=size*0.03;
ctx.beginPath(); ctx.arc(cx,cy,s*(0.5+t*1.1),0,7); ctx.stroke();
} else {
// eyes — brighter when solid (more "there")
var er=s*0.12, ey=cy-s*0.15, eb=0.4+0.6*solid;
ctx.fillStyle='rgba(255,255,255,'+eb+')';
ctx.beginPath(); ctx.arc(cx-s*0.24,ey,er,0,7); ctx.arc(cx+s*0.24,ey,er,0,7); ctx.fill();
ctx.fillStyle='rgba('+(solid>0.6?255:40)+',20,40,'+eb+')';
ctx.beginPath(); ctx.arc(cx-s*0.24,ey,er*0.5,0,7); ctx.arc(cx+s*0.24,ey,er*0.5,0,7); ctx.fill();
}
ctx.restore();
}
function drawGun(w,h){
// a simple "collapser" barrel bobbing at bottom centre
var bob=Math.sin(performance.now()/220)*(h*0.006);
var gw=w*0.16, gh=h*0.26, gx=w/2-gw/2, gy=h-gh+bob;
ctx.fillStyle='#14161f'; ctx.fillRect(gx,gy,gw,gh);
ctx.fillStyle='#20d0e0'; ctx.fillRect(gx+gw*0.4,gy-h*0.02,gw*0.2,gh*0.5);
ctx.fillStyle=muzzle>0?'rgba(255,230,120,'+muzzle+')':'#0a3a44';
ctx.beginPath(); ctx.arc(w/2,gy-h*0.02, w*0.02*(1+muzzle*2),0,7); ctx.fill();
ctx.fillStyle='rgba(37,224,255,.25)'; ctx.fillRect(gx,gy,gw,h*0.01);
}
// ---------- loop ----------
var last=performance.now();
function frame(now){ var dt=Math.min(0.05,(now-last)/1000); last=now; if(running)update(dt); draw(); requestAnimationFrame(frame); }
// ---------- input ----------
addEventListener('keydown',function(e){ var k=e.key.length===1?e.key.toLowerCase():e.key; keys[k]=true;
if(k===' '||k==='Spacebar'){ e.preventDefault(); A(); fire(); } });
addEventListener('keyup',function(e){ var k=e.key.length===1?e.key.toLowerCase():e.key; keys[k]=false; });
cv.addEventListener('pointerdown',function(e){ A(); if(running) fire(); });
function hold(id,on){ var el=document.getElementById(id);
el.addEventListener('pointerdown',function(e){e.preventDefault();A();on(true);});
['pointerup','pointerleave','pointercancel'].forEach(function(ev){el.addEventListener(ev,function(){on(false);});}); }
hold('b-l',function(v){touch.l=v;}); hold('b-r',function(v){touch.r=v;}); hold('b-f',function(v){touch.f=v;});
document.getElementById('b-fire').addEventListener('pointerdown',function(e){e.preventDefault();A();fire();});
document.getElementById('start-btn').addEventListener('click',function(){A();start();});
document.getElementById('again-btn').addEventListener('click',function(){A();start();});
// smoke-test hook (harmless)
window.__qd={ start:start, fire:fire, turn:function(a){player.ang+=a;}, state:function(){return{running:running,kills:kills,coh:coherence,enemies:enemies.length};} };
resize(); requestAnimationFrame(frame);
})();
</script>
</body>
</html>