Show code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Quadtree Collision Field</title>
<style>
html,body{margin:0;height:100%;background:#05070d;overflow:hidden}
canvas{display:block}
#hud{position:fixed;top:16px;left:18px;pointer-events:none;font:12px/1.75 ui-monospace,SFMono-Regular,Menlo,monospace;color:#7fd8ff;letter-spacing:.08em;text-shadow:0 0 14px rgba(50,190,255,.45)}
#hud h1{margin:0 0 6px;font:600 13px/1.4 ui-monospace,Menlo,monospace;color:#eafcff;letter-spacing:.22em;text-transform:uppercase}
#hud i{font-style:normal;color:#3d5f75}
#hud b{color:#fff;font-weight:600}
#bar{position:fixed;left:0;bottom:0;height:2px;background:linear-gradient(90deg,#2ad8ff,#ff5f8f);box-shadow:0 0 12px #2ad8ff}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="hud">
<h1>Quadtree · Broadphase</h1>
<div><i>balls</i> <b id="nb">0</b> <i>nodes</i> <b id="nn">0</b></div>
<div><i>pair tests</i> <b id="np">0</b> <i>brute</i> <i id="nx">0</i></div>
<div><i>hits</i> <b id="nh">0</b> <i>fps</i> <b id="nf">0</b></div>
</div>
<div id="bar"></div>
<script>
const cv=document.getElementById('c'),g=cv.getContext('2d'),CAP=4,MAXD=7,N=120,LIFE=780;
let W=0,H=0,balls=[],nodes=0,tests=0,hits=0,tick=0,flash=0,fps=60,last=performance.now();
function fit(){const d=Math.min(devicePixelRatio||1,2);W=innerWidth;H=innerHeight;cv.width=W*d;cv.height=H*d;cv.style.width=W+'px';cv.style.height=H+'px';g.setTransform(d,0,0,d,0,0);}
const R=(a,b)=>a+Math.random()*(b-a);
class QT{
constructor(x,y,w,h,d){this.x=x;this.y=y;this.w=w;this.h=h;this.d=d;this.p=[];this.k=null;this.hot=0;}
fits(o){return o.x>=this.x&&o.x<this.x+this.w&&o.y>=this.y&&o.y<this.y+this.h;}
split(){const w=this.w/2,h=this.h/2,d=this.d+1;this.k=[new QT(this.x,this.y,w,h,d),new QT(this.x+w,this.y,w,h,d),new QT(this.x,this.y+h,w,h,d),new QT(this.x+w,this.y+h,w,h,d)];nodes+=4;
for(const o of this.p)for(const c of this.k)if(c.insert(o))break;this.p.length=0;}
insert(o){if(!this.fits(o))return false;if(this.k){for(const c of this.k)if(c.insert(o))return true;return true;}
this.p.push(o);if(this.p.length>CAP&&this.d<MAXD)this.split();return true;}
query(x1,y1,x2,y2,out){if(x1>this.x+this.w||x2<this.x||y1>this.y+this.h||y2<this.y)return;this.hot++;
if(this.k){for(const c of this.k)c.query(x1,y1,x2,y2,out);}else{for(const o of this.p)out.push(o);}}
draw(){if(this.hot){const a=Math.min(.26,this.hot*.035);g.fillStyle='rgba(46,214,255,'+a+')';g.fillRect(this.x,this.y,this.w,this.h);}
if(this.k){g.strokeStyle='rgba(110,190,235,'+(.34-this.d*.033)+')';g.lineWidth=Math.max(.4,1.3-this.d*.14);
g.beginPath();g.moveTo(this.x+this.w/2,this.y);g.lineTo(this.x+this.w/2,this.y+this.h);g.moveTo(this.x,this.y+this.h/2);g.lineTo(this.x+this.w,this.y+this.h/2);g.stroke();
for(const c of this.k)c.draw();}}
}
function init(){balls=[];tick=0;flash=1;
for(let i=0;i<N;i++){const r=R(4,11),s=R(.5,2.4),a=R(0,Math.PI*2);
balls.push({i:i,x:R(r,W-r),y:R(r,H-r),vx:Math.cos(a)*s,vy:Math.sin(a)*s,r:r,m:r*r,h:R(0,360),c:0,gl:0});}}
function step(){
for(const b of balls){b.x+=b.vx;b.y+=b.vy;b.gl*=.86;b.c=0;
if(b.x<b.r){b.x=b.r;b.vx=Math.abs(b.vx);}else if(b.x>W-b.r){b.x=W-b.r;b.vx=-Math.abs(b.vx);}
if(b.y<b.r){b.y=b.r;b.vy=Math.abs(b.vy);}else if(b.y>H-b.r){b.y=H-b.r;b.vy=-Math.abs(b.vy);}}
nodes=1;tests=0;hits=0;
const root=new QT(-1,-1,W+2,H+2,0);
for(const b of balls)root.insert(b);
const out=[];
for(let i=0;i<balls.length;i++){const a=balls[i];out.length=0;
root.query(a.x-a.r-12,a.y-a.r-12,a.x+a.r+12,a.y+a.r+12,out);
for(const b of out){if(b.i<=i)continue;
tests++;const dx=b.x-a.x,dy=b.y-a.y,d2=dx*dx+dy*dy,rr=a.r+b.r;
if(d2>0&&d2<rr*rr){hits++;a.c=b.c=1;a.gl=b.gl=1;
const d=Math.sqrt(d2),nx=dx/d,ny=dy/d,ov=(rr-d)/2,tm=a.m+b.m;
a.x-=nx*ov;a.y-=ny*ov;b.x+=nx*ov;b.y+=ny*ov;
const p=2*((a.vx-b.vx)*nx+(a.vy-b.vy)*ny)/tm;
if(p>0){a.vx-=p*b.m*nx;a.vy-=p*b.m*ny;b.vx+=p*a.m*nx;b.vy+=p*a.m*ny;}}}}
return root;
}
function frame(t){
const dt=t-last;last=t;fps+=(1000/Math.max(dt,1)-fps)*.08;
const root=step();
g.fillStyle='rgba(5,7,13,.32)';g.fillRect(0,0,W,H);
root.draw();
g.strokeStyle='rgba(120,200,240,.5)';g.lineWidth=1;g.strokeRect(.5,.5,W-1,H-1);
for(const b of balls){
if(b.gl>.02){g.beginPath();g.arc(b.x,b.y,b.r+7*b.gl,0,6.2832);g.fillStyle='rgba(255,90,140,'+(b.gl*.3)+')';g.fill();}
g.beginPath();g.arc(b.x,b.y,b.r,0,6.2832);
g.fillStyle=b.c?'#ff5f8f':'hsl('+b.h+',72%,'+(52+b.gl*20)+'%)';g.fill();
g.strokeStyle=b.c?'rgba(255,255,255,.95)':'rgba(255,255,255,.16)';g.lineWidth=b.c?2:1;g.stroke();}
if(flash>0){g.fillStyle='rgba(180,240,255,'+flash*.55+')';g.fillRect(0,0,W,H);flash-=.05;}
nb.textContent=balls.length;nn.textContent=nodes;np.textContent=tests;
nx.textContent=(N*(N-1)/2);nh.textContent=hits;nf.textContent=fps.toFixed(0);
bar.style.width=(100-tick/LIFE*100).toFixed(2)+'%';
if(++tick>LIFE)init();
requestAnimationFrame(frame);
}
addEventListener('resize',()=>{fit();init();});
fit();init();requestAnimationFrame(frame);
</script>
</body>
</html>