← RTS results

RTS

Deepseek v4.1 Flash · full

Initial view of RTS

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 111,860 bytes · SHA-256 a2f42bacf2f0
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<title>FRONTIER WAR</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
html,body{width:100%;height:100%;overflow:hidden;background:#05070c;font-family:"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:#cfe6f5;user-select:none;-webkit-user-select:none}
canvas{display:block;position:absolute;top:0;left:0}
#gl{z-index:1}
#fx{z-index:2;pointer-events:none}
#hud{position:absolute;inset:0;z-index:3;pointer-events:none}
#hud *{pointer-events:auto}
.panel{background:linear-gradient(180deg,rgba(12,22,34,.94),rgba(8,14,22,.96));border:1px solid #1d3d55;box-shadow:0 0 0 1px rgba(0,0,0,.6),inset 0 0 24px rgba(30,120,170,.09);border-radius:3px}
#topbar{position:absolute;top:0;left:0;right:0;height:38px;display:flex;align-items:center;gap:14px;padding:0 12px;background:linear-gradient(180deg,rgba(10,20,30,.96),rgba(6,11,18,.92));border-bottom:1px solid #1d3d55;font-size:13px;letter-spacing:.4px}
.stat{display:flex;align-items:center;gap:6px;white-space:nowrap}
.stat b{font-weight:600;color:#eaf7ff;font-variant-numeric:tabular-nums}
.ico{width:12px;height:12px;display:inline-block;border-radius:2px}
.icocry{background:linear-gradient(135deg,#7ff0ff,#1c8fb5);box-shadow:0 0 8px #35c8f0}
.icosup{background:linear-gradient(135deg,#ffd479,#c07a10);box-shadow:0 0 8px #e8a020}
.spacer{flex:1}
.tbtn{background:#12293c;border:1px solid #24536f;color:#a8d6ee;padding:5px 11px;border-radius:3px;cursor:pointer;font-size:12px;letter-spacing:.6px}
.tbtn:hover{background:#1b3d57;color:#e6f7ff;border-color:#3d86ad}
.tbtn.on{background:#1d5a7a;color:#eafaff;border-color:#57b6e0}
#warn{position:absolute;top:44px;left:50%;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;gap:4px;pointer-events:none}
.toast{background:rgba(90,20,20,.9);border:1px solid #b8453a;color:#ffd9d2;padding:4px 14px;border-radius:3px;font-size:12.5px;animation:fade 3s forwards}
.toast.good{background:rgba(14,60,50,.9);border-color:#2ea88a;color:#c9fff0}
@keyframes fade{0%{opacity:0;transform:translateY(-6px)}10%{opacity:1;transform:none}80%{opacity:1}100%{opacity:0}}
#bottombar{position:absolute;left:0;right:0;bottom:0;height:168px;display:flex;gap:8px;padding:8px;background:linear-gradient(0deg,rgba(6,12,20,.97),rgba(8,16,26,.86));border-top:1px solid #1d3d55}
#mmwrap{position:relative;width:152px;height:152px;flex:0 0 152px;border:1px solid #24536f;border-radius:3px;overflow:hidden;background:#04070b}
#minimap{width:150px;height:150px;image-rendering:pixelated;cursor:crosshair}
#selpanel{flex:1 1 auto;min-width:200px;display:flex;flex-direction:column;gap:6px;padding:8px 10px;overflow:hidden}
#selhead{font-size:13px;color:#8fd0ef;letter-spacing:1px;text-transform:uppercase;border-bottom:1px solid #1a3348;padding-bottom:5px;display:flex;justify-content:space-between;align-items:center}
#selbody{flex:1;overflow-y:auto;display:flex;flex-wrap:wrap;gap:5px;align-content:flex-start}
#selbody::-webkit-scrollbar{width:8px}#selbody::-webkit-scrollbar-thumb{background:#1d3d55;border-radius:4px}
.ucard{width:100%;display:flex;flex-direction:column;gap:3px}
.urow{display:flex;justify-content:space-between;font-size:12px;color:#b6d8ea}
.bar{height:7px;background:#0d1a26;border:1px solid #234358;border-radius:2px;overflow:hidden;position:relative}
.bar>i{display:block;height:100%;background:linear-gradient(90deg,#2fd07a,#7ef0b0);transition:width .15s}
.bar.hp>i{background:linear-gradient(90deg,#2fd07a,#7ef0b0)}
.bar.pr>i{background:linear-gradient(90deg,#2f8fd0,#7ed0f0)}
.bar.rs>i{background:linear-gradient(90deg,#d0a02f,#f0d07e)}
#actions{flex:0 0 372px;display:flex;flex-wrap:wrap;gap:6px;align-content:flex-start;padding:8px;overflow-y:auto}
#actions::-webkit-scrollbar{width:8px}#actions::-webkit-scrollbar-thumb{background:#1d3d55;border-radius:4px}
.abtn{width:86px;height:60px;background:linear-gradient(180deg,#12293c,#0c1c29);border:1px solid #24536f;border-radius:3px;cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;position:relative;overflow:hidden;transition:background .1s}
.abtn:hover{background:linear-gradient(180deg,#1d4a68,#12324a);border-color:#4f9fc8}
.abtn.dis{opacity:.42;cursor:not-allowed;filter:grayscale(.7)}
.abtn .an{font-size:10.5px;color:#d3ecfa;text-align:center;line-height:1.1;padding:0 2px;letter-spacing:.2px}
.abtn .ac{font-size:10px;color:#7fd0f0;font-weight:600}
.abtn .hk{position:absolute;top:2px;left:4px;font-size:9px;color:#5d8aa5}
.abtn .pg{position:absolute;left:0;bottom:0;height:3px;background:#3fd0ff;width:0%}
.abadge{position:absolute;top:2px;right:3px;font-size:9px;background:#1d5a7a;color:#cdf3ff;border-radius:2px;padding:0 3px}
#selbox{position:absolute;border:1px solid #5fe0ff;background:rgba(60,200,255,.12);display:none;z-index:4;pointer-events:none}
#overlay{position:absolute;inset:0;z-index:8;display:flex;align-items:center;justify-content:center;background:radial-gradient(ellipse at 50% 40%,rgba(10,26,40,.86),rgba(2,4,8,.97))}
#overlay.hide{display:none}
.card{width:min(760px,92vw);max-height:92vh;overflow-y:auto;padding:26px 30px;background:linear-gradient(180deg,rgba(12,24,36,.97),rgba(6,12,20,.98));border:1px solid #2a5c7c;border-radius:5px;box-shadow:0 0 60px rgba(20,120,180,.25),inset 0 0 60px rgba(20,120,180,.06)}
.card::-webkit-scrollbar{width:9px}#.card::-webkit-scrollbar-thumb{background:#1d3d55}
h1{font-size:38px;letter-spacing:9px;font-weight:300;color:#dff4ff;text-shadow:0 0 26px rgba(63,208,255,.55);margin-bottom:2px}
h2{font-size:15px;letter-spacing:4px;color:#5fb6dd;font-weight:400;margin-bottom:16px;text-transform:uppercase}
h3{font-size:13px;letter-spacing:2px;color:#7fd0f0;margin:14px 0 6px;text-transform:uppercase;border-bottom:1px solid #1a3348;padding-bottom:4px}
p,li{font-size:13px;line-height:1.6;color:#a9c9dc}
ul{padding-left:18px}
.cols{display:grid;grid-template-columns:1fr 1fr;gap:0 26px}
kbd{background:#12293c;border:1px solid #2a5c7c;border-bottom-width:2px;border-radius:3px;padding:0 5px;font-size:11.5px;color:#cdeeff;font-family:inherit}
.big{display:block;width:100%;margin-top:20px;padding:13px;background:linear-gradient(180deg,#1a6b93,#0f4460);border:1px solid #4fb0dc;color:#eafaff;font-size:15px;letter-spacing:4px;cursor:pointer;border-radius:3px;text-transform:uppercase}
.big:hover{background:linear-gradient(180deg,#2389b8,#14587c)}
.flexrow{display:flex;gap:10px;margin-top:20px}
.flexrow .big{margin-top:0}
.tag{display:inline-block;font-size:10.5px;padding:1px 6px;border-radius:2px;background:#12324a;border:1px solid #24536f;color:#8fd0ef;margin-right:5px}
#help{position:absolute;right:8px;top:46px;width:330px;max-height:calc(100% - 230px);overflow-y:auto;padding:12px 14px;font-size:12px;line-height:1.55;display:none}
#help.show{display:block}
#help b{color:#8fd0ef}
#help .k{color:#ffd479}
</style>
</head>
<body>
<canvas id="gl"></canvas>
<canvas id="fx"></canvas>
<div id="hud">
  <div id="topbar">
    <div class="stat"><span class="ico icocry"></span>CRYSTAL <b id="sCry">0</b></div>
    <div class="stat"><span class="ico icosup"></span>SUPPLY <b id="sSup">0 / 0</b></div>
    <div class="stat" id="sPop">UNITS <b>0</b></div>
    <div class="spacer"></div>
    <div class="stat" id="sTime">00:00</div>
    <button class="tbtn" id="bHelp">HELP (H)</button>
    <button class="tbtn" id="bMute">SOUND: ON</button>
    <button class="tbtn" id="bPause">PAUSE (P)</button>
  </div>
  <div id="warn"></div>
  <div id="help" class="panel"></div>
  <div id="bottombar">
    <div id="mmwrap"><canvas id="minimap" width="150" height="150"></canvas></div>
    <div id="selpanel" class="panel">
      <div id="selhead"><span id="seltitle">NO SELECTION</span><span id="selmeta"></span></div>
      <div id="selbody"></div>
    </div>
    <div id="actions" class="panel"></div>
  </div>
</div>
<div id="selbox"></div>
<div id="overlay"><div class="card" id="ocard"></div></div>

<script>
"use strict";
/* ============================================================
   FRONTIER WAR — single-file 3D RTS
   Custom WebGL2 instanced renderer, no external dependencies.
   ============================================================ */

/* ---------------- math ---------------- */
const clamp=(v,a,b)=>v<a?a:(v>b?b:v);
const lerp=(a,b,t)=>a+(b-a)*t;
const dist2=(ax,az,bx,bz)=>{const dx=ax-bx,dz=az-bz;return dx*dx+dz*dz;};
const TAU=Math.PI*2;
function m4mul(a,b,o){
  for(let c=0;c<4;c++){
    const b0=b[c*4],b1=b[c*4+1],b2=b[c*4+2],b3=b[c*4+3];
    o[c*4+0]=a[0]*b0+a[4]*b1+a[8]*b2+a[12]*b3;
    o[c*4+1]=a[1]*b0+a[5]*b1+a[9]*b2+a[13]*b3;
    o[c*4+2]=a[2]*b0+a[6]*b1+a[10]*b2+a[14]*b3;
    o[c*4+3]=a[3]*b0+a[7]*b1+a[11]*b2+a[15]*b3;
  }
  return o;
}
function m4persp(o,fovy,asp,n,f){
  const t=1/Math.tan(fovy/2);
  o[0]=t/asp;o[1]=0;o[2]=0;o[3]=0;
  o[4]=0;o[5]=t;o[6]=0;o[7]=0;
  o[8]=0;o[9]=0;o[10]=(f+n)/(n-f);o[11]=-1;
  o[12]=0;o[13]=0;o[14]=2*f*n/(n-f);o[15]=0;
  return o;
}
function m4look(o,ex,ey,ez,cx,cy,cz,ux,uy,uz){
  let zx=ex-cx,zy=ey-cy,zz=ez-cz;
  let l=Math.hypot(zx,zy,zz)||1; zx/=l;zy/=l;zz/=l;
  let xx=uy*zz-uz*zy, xy=uz*zx-ux*zz, xz=ux*zy-uy*zx;
  l=Math.hypot(xx,xy,xz)||1; xx/=l;xy/=l;xz/=l;
  const yx=zy*xz-zz*xy, yy=zz*xx-zx*xz, yz=zx*xy-zy*xx;
  o[0]=xx;o[1]=yx;o[2]=zx;o[3]=0;
  o[4]=xy;o[5]=yy;o[6]=zy;o[7]=0;
  o[8]=xz;o[9]=yz;o[10]=zz;o[11]=0;
  o[12]=-(xx*ex+xy*ey+xz*ez);
  o[13]=-(yx*ex+yy*ey+yz*ez);
  o[14]=-(zx*ex+zy*ey+zz*ez);
  o[15]=1;
  return o;
}
function m4inv(m,o){
  const a00=m[0],a01=m[1],a02=m[2],a03=m[3],a10=m[4],a11=m[5],a12=m[6],a13=m[7],
        a20=m[8],a21=m[9],a22=m[10],a23=m[11],a30=m[12],a31=m[13],a32=m[14],a33=m[15];
  const b00=a00*a11-a01*a10,b01=a00*a12-a02*a10,b02=a00*a13-a03*a10,b03=a01*a12-a02*a11,
        b04=a01*a13-a03*a11,b05=a02*a13-a03*a12,b06=a20*a31-a21*a30,b07=a20*a32-a22*a30,
        b08=a20*a33-a23*a30,b09=a21*a32-a22*a31,b10=a21*a33-a23*a31,b11=a22*a33-a23*a32;
  let d=b00*b11-b01*b10+b02*b09+b03*b08-b04*b07+b05*b06;
  if(!d)return null; d=1/d;
  o[0]=(a11*b11-a12*b10+a13*b09)*d; o[1]=(a02*b10-a01*b11-a03*b09)*d;
  o[2]=(a31*b05-a32*b04+a33*b03)*d; o[3]=(a22*b04-a21*b05-a23*b03)*d;
  o[4]=(a12*b08-a10*b11-a13*b07)*d; o[5]=(a00*b11-a02*b08+a03*b07)*d;
  o[6]=(a32*b02-a30*b05-a33*b01)*d; o[7]=(a20*b05-a22*b02+a23*b01)*d;
  o[8]=(a10*b10-a11*b08+a13*b06)*d; o[9]=(a01*b08-a00*b10-a03*b06)*d;
  o[10]=(a30*b04-a31*b02+a33*b00)*d; o[11]=(a21*b02-a20*b04-a23*b00)*d;
  o[12]=(a11*b07-a10*b09-a12*b06)*d; o[13]=(a00*b09-a01*b07+a02*b06)*d;
  o[14]=(a31*b01-a30*b03-a32*b00)*d; o[15]=(a20*b03-a21*b01+a22*b00)*d;
  return o;
}
/* seeded rng */
let _seed=1337;
function srand(s){_seed=s>>>0;}
function rnd(){_seed=(_seed*1664525+1013904223)>>>0;return _seed/4294967296;}
function rrange(a,b){return a+rnd()*(b-a);}
function pick(arr){return arr[(rnd()*arr.length)|0];}

/* ---------------- GL core ---------------- */
const canvas=document.getElementById("gl");
const gl=canvas.getContext("webgl2",{antialias:true,alpha:false,powerPreference:"high-performance"});
if(!gl){document.body.innerHTML="<div style='color:#fff;padding:40px;font:16px sans-serif'>WebGL2 is required to run FRONTIER WAR.</div>";throw new Error("no webgl2");}
gl.enable(gl.DEPTH_TEST);
gl.enable(gl.CULL_FACE);
gl.cullFace(gl.BACK);

function compile(vs,fs){
  const p=gl.createProgram();
  const a=gl.createShader(gl.VERTEX_SHADER);gl.shaderSource(a,vs);gl.compileShader(a);
  if(!gl.getShaderParameter(a,gl.COMPILE_STATUS))throw new Error(gl.getShaderInfoLog(a));
  const b=gl.createShader(gl.FRAGMENT_SHADER);gl.shaderSource(b,fs);gl.compileShader(b);
  if(!gl.getShaderParameter(b,gl.COMPILE_STATUS))throw new Error(gl.getShaderInfoLog(b));
  gl.attachShader(p,a);gl.attachShader(p,b);gl.linkProgram(p);
  if(!gl.getProgramParameter(p,gl.LINK_STATUS))throw new Error(gl.getProgramInfoLog(p));
  return p;
}
/* --- geometry --- */
function tri(P,N,a,b,c,n){
  P.push(a[0],a[1],a[2],b[0],b[1],b[2],c[0],c[1],c[2]);
  for(let i=0;i<3;i++)N.push(n[0],n[1],n[2]);
}
function makeBox(){
  const P=[],N=[],h=0.5;
  const F=[
    {n:[0,0,1], v:[[-h,-h,h],[h,-h,h],[h,h,h],[-h,h,h]]},
    {n:[0,0,-1],v:[[h,-h,-h],[-h,-h,-h],[-h,h,-h],[h,h,-h]]},
    {n:[1,0,0], v:[[h,-h,h],[h,-h,-h],[h,h,-h],[h,h,h]]},
    {n:[-1,0,0],v:[[-h,-h,-h],[-h,-h,h],[-h,h,h],[-h,h,-h]]},
    {n:[0,1,0], v:[[-h,h,h],[h,h,h],[h,h,-h],[-h,h,-h]]},
    {n:[0,-1,0],v:[[-h,-h,-h],[h,-h,-h],[h,-h,h],[-h,-h,h]]}
  ];
  for(const f of F){
    const a=f.v[0],b=f.v[1],c=f.v[2],d=f.v[3];
    tri(P,N,a,b,c,f.n); tri(P,N,a,c,d,f.n);
  }
  return {pos:new Float32Array(P),nrm:new Float32Array(N)};
}
function makeCyl(seg){
  const P=[],N=[],h=0.5,r=0.5;
  for(let i=0;i<seg;i++){
    const a0=i/seg*TAU,a1=(i+1)/seg*TAU;
    const x0=Math.cos(a0)*r,z0=Math.sin(a0)*r,x1=Math.cos(a1)*r,z1=Math.sin(a1)*r;
    const n0=[Math.cos(a0),0,Math.sin(a0)],n1=[Math.cos(a1),0,Math.sin(a1)];
    tri(P,N,[x0,-h,z0],[x1,-h,z1],[x1,h,z1],n0);
    tri(P,N,[x0,-h,z0],[x1,h,z1],[x0,h,z0],n0);
    tri(P,N,[0,h,0],[x0,h,z0],[x1,h,z1],[0,1,0]);
    tri(P,N,[0,-h,0],[x1,-h,z1],[x0,-h,z0],[0,-1,0]);
  }
  return {pos:new Float32Array(P),nrm:new Float32Array(N)};
}
function makeCone(seg){
  const P=[],N=[],h=0.5,r=0.5;
  const apex=[0,h,0];
  for(let i=0;i<seg;i++){
    const a0=i/seg*TAU,a1=(i+1)/seg*TAU;
    const x0=Math.cos(a0)*r,z0=Math.sin(a0)*r,x1=Math.cos(a1)*r,z1=Math.sin(a1)*r;
    const nm=[Math.cos((a0+a1)/2),0.45,Math.sin((a0+a1)/2)];
    tri(P,N,[x0,-h,z0],[x1,-h,z1],apex,nm);
    tri(P,N,[0,-h,0],[x1,-h,z1],[x0,-h,z0],[0,-1,0]);
  }
  return {pos:new Float32Array(P),nrm:new Float32Array(N)};
}
function makeSphere(seg,rings){
  const P=[],N=[],r=0.5;
  for(let j=0;j<rings;j++){
    const p0=j/rings*Math.PI,p1=(j+1)/rings*Math.PI;
    for(let i=0;i<seg;i++){
      const t0=i/seg*TAU,t1=(i+1)/seg*TAU;
      const v=(p,t)=>[Math.sin(p)*Math.cos(t)*r,Math.cos(p)*r,Math.sin(p)*Math.sin(t)*r];
      const a=v(p0,t0),b=v(p1,t0),c=v(p1,t1),d=v(p0,t1);
      const nn=(q)=>[q[0]/r,q[1]/r,q[2]/r];
      tri(P,N,a,b,c,nn(a)); tri(P,N,a,c,d,nn(a));
    }
  }
  return {pos:new Float32Array(P),nrm:new Float32Array(N)};
}
function makeDisc(seg){
  const P=[],N=[],r=0.5;
  for(let i=0;i<seg;i++){
    const a0=i/seg*TAU,a1=(i+1)/seg*TAU;
    tri(P,N,[0,0,0],[Math.cos(a0)*r,0,Math.sin(a0)*r],[Math.cos(a1)*r,0,Math.sin(a1)*r],[0,1,0]);
  }
  return {pos:new Float32Array(P),nrm:new Float32Array(N)};
}
function makeRing(seg,ri){
  const P=[],N=[];
  for(let i=0;i<seg;i++){
    const a0=i/seg*TAU,a1=(i+1)/seg*TAU;
    const x0=Math.cos(a0),z0=Math.sin(a0),x1=Math.cos(a1),z1=Math.sin(a1);
    tri(P,N,[x0*ri,0,z0*ri],[x0,0,z0],[x1,0,z1],[0,1,0]);
    tri(P,N,[x0*ri,0,z0*ri],[x1,0,z1],[x1*ri,0,z1*ri],[0,1,0]);
  }
  return {pos:new Float32Array(P),nrm:new Float32Array(N)};
}
function axisPermute(g,axis){
  if(axis==="y")return g;
  const P=g.pos,N=g.nrm;
  for(let i=0;i<P.length;i+=3){
    if(axis==="x"){const y=P[i+1];P[i+1]=P[i];P[i]=y; const n=N[i+1];N[i+1]=N[i];N[i]=n;}
    else{const y=P[i+1];P[i+1]=P[i+2];P[i+2]=y; const n=N[i+1];N[i+1]=N[i+2];N[i+2]=n;}
  }
  return g;
}
const GEOMS=[
  makeBox(),                       // 0 box
  makeCyl(14),                     // 1 cyl Y
  axisPermute(makeCyl(14),"x"),    // 2 cyl X
  axisPermute(makeCyl(14),"z"),    // 3 cyl Z
  makeCone(12),                    // 4 cone Y
  makeSphere(12,8),                // 5 sphere
  makeDisc(24),                    // 6 disc
  makeRing(28,0.82)                // 7 ring
];
const GBOX=0,GCYL=1,GCYLX=2,GCYLZ=3,GCONE=4,GSPH=5,GDISC=6,GRING=7;

/* --- instanced program --- */
const MAXI=16000;
const instVS=`
attribute vec3 aPos; attribute vec3 aNrm;
attribute vec4 aM0; attribute vec4 aM1; attribute vec4 aM2; attribute vec4 aM3;
attribute vec4 aCol;
uniform mat4 uVP;
varying vec3 vN; varying vec4 vC; varying vec3 vW;
void main(){
  mat4 m=mat4(aM0,aM1,aM2,aM3);
  vec4 wp=m*vec4(aPos,1.0);
  vW=wp.xyz;
  vN=normalize((m*vec4(aNrm,0.0)).xyz);
  vC=aCol;
  gl_Position=uVP*wp;
}`;
const instFS=`
precision mediump float;
uniform vec3 uLight; uniform vec3 uCam; uniform vec3 uFogCol;
varying vec3 vN; varying vec4 vC; varying vec3 vW;
void main(){
  vec3 n=normalize(vN);
  float d=max(dot(n,normalize(uLight)),0.0);
  float sky=0.5+0.5*n.y;
  vec3 lit=vC.rgb*(0.26+0.62*d+0.20*sky);
  vec3 vd=normalize(uCam-vW);
  float rim=pow(1.0-max(dot(n,vd),0.0),3.0)*0.30;
  lit+=vC.rgb*rim;
  vec3 c=mix(lit,vC.rgb,vC.a);
  gl_FragColor=vec4(c,1.0);
}`;
const instProg=compile(instVS,instFS);
const IU={VP:gl.getUniformLocation(instProg,"uVP"),L:gl.getUniformLocation(instProg,"uLight"),C:gl.getUniformLocation(instProg,"uCam")};
const IA={pos:gl.getAttribLocation(instProg,"aPos"),nrm:gl.getAttribLocation(instProg,"aNrm"),
  m0:gl.getAttribLocation(instProg,"aM0"),m1:gl.getAttribLocation(instProg,"aM1"),
  m2:gl.getAttribLocation(instProg,"aM2"),m3:gl.getAttribLocation(instProg,"aM3"),
  col:gl.getAttribLocation(instProg,"aCol")};

class Batch{
  constructor(geom){
    this.geom=geom; this.count=0;
    this.data=new Float32Array(MAXI*20);
    this.vbo=gl.createBuffer();
    this.vao=gl.createVertexArray();
    gl.bindVertexArray(this.vao);
    const pb=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,pb);
    gl.bufferData(gl.ARRAY_BUFFER,geom.pos,gl.STATIC_DRAW);
    gl.enableVertexAttribArray(IA.pos); gl.vertexAttribPointer(IA.pos,3,gl.FLOAT,false,0,0);
    const nb=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,nb);
    gl.bufferData(gl.ARRAY_BUFFER,geom.nrm,gl.STATIC_DRAW);
    gl.enableVertexAttribArray(IA.nrm); gl.vertexAttribPointer(IA.nrm,3,gl.FLOAT,false,0,0);
    gl.bindBuffer(gl.ARRAY_BUFFER,this.vbo);
    gl.bufferData(gl.ARRAY_BUFFER,this.data.byteLength,gl.DYNAMIC_DRAW);
    const S=20*4;
    for(let i=0;i<4;i++){
      gl.enableVertexAttribArray(IA.m0+i);
      gl.vertexAttribPointer(IA.m0+i,4,gl.FLOAT,false,S,i*16);
      gl.vertexAttribDivisor(IA.m0+i,1);
    }
    gl.enableVertexAttribArray(IA.col);
    gl.vertexAttribPointer(IA.col,4,gl.FLOAT,false,S,64);
    gl.vertexAttribDivisor(IA.col,1);
    gl.bindVertexArray(null);
    this.vcount=geom.pos.length/3;
  }
  reset(){this.count=0;}
  add(m,r,g,b,a){
    if(this.count>=MAXI)return;
    const o=this.count*20,d=this.data;
    d[o]=m[0];d[o+1]=m[1];d[o+2]=m[2];d[o+3]=m[3];
    d[o+4]=m[4];d[o+5]=m[5];d[o+6]=m[6];d[o+7]=m[7];
    d[o+8]=m[8];d[o+9]=m[9];d[o+10]=m[10];d[o+11]=m[11];
    d[o+12]=m[12];d[o+13]=m[13];d[o+14]=m[14];d[o+15]=m[15];
    d[o+16]=r;d[o+17]=g;d[o+18]=b;d[o+19]=a;
    this.count++;
  }
  flush(){
    if(!this.count)return;
    gl.bindVertexArray(this.vao);
    gl.bindBuffer(gl.ARRAY_BUFFER,this.vbo);
    gl.bufferSubData(gl.ARRAY_BUFFER,0,this.data.subarray(0,this.count*20));
    gl.drawArraysInstanced(gl.TRIANGLES,0,this.vcount,this.count);
  }
}
const BATCHES=GEOMS.map(g=>new Batch(g));

/* --- terrain program --- */
const MAP=256, HALF=MAP/2;
const terrVS=`
attribute vec3 aPos; attribute vec3 aNrm; attribute vec3 aCol;
uniform mat4 uVP;
varying vec3 vN; varying vec3 vC; varying vec2 vUv;
void main(){ vN=aNrm; vC=aCol; vUv=aPos.xz/256.0; gl_Position=uVP*vec4(aPos,1.0); }`;
const terrFS=`
precision mediump float;
uniform sampler2D uFog; uniform vec3 uLight;
varying vec3 vN; varying vec3 vC; varying vec2 vUv;
void main(){
  float d=max(dot(normalize(vN),normalize(uLight)),0.0);
  vec3 c=vC*(0.42+0.58*d);
  vec2 g=abs(fract(vUv*128.0)-0.5);
  float gline=1.0-smoothstep(0.44,0.5,min(g.x,g.y));
  c+=vec3(0.035,0.06,0.08)*gline;
  vec4 f=texture2D(uFog,vUv);
  c=mix(c,f.rgb,f.a);
  gl_FragColor=vec4(c,1.0);
}`;
const terrProg=compile(terrVS,terrFS);
const TU={VP:gl.getUniformLocation(terrProg,"uVP"),F:gl.getUniformLocation(terrProg,"uFog"),L:gl.getUniformLocation(terrProg,"uLight")};
const TA={pos:gl.getAttribLocation(terrProg,"aPos"),nrm:gl.getAttribLocation(terrProg,"aNrm"),col:gl.getAttribLocation(terrProg,"aCol")};

/* --- fog of war texture --- */
const FOGN=128, FOGC=MAP/FOGN;
const fogVis=new Uint8Array(FOGN*FOGN);
const fogExp=new Uint8Array(FOGN*FOGN);
const fogTex=new Uint8Array(FOGN*FOGN*4);
const fogTexObj=gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D,fogTexObj);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);
function fogIndex(x,z){return ((z/FOGC)|0)*FOGN+((x/FOGC)|0);}
function isVisible(x,z){
  const cx=(x/FOGC)|0, cz=(z/FOGC)|0;
  if(cx<0||cz<0||cx>=FOGN||cz>=FOGN)return false;
  return fogVis[cz*FOGN+cx]>0;
}
function isExplored(x,z){
  const cx=(x/FOGC)|0, cz=(z/FOGC)|0;
  if(cx<0||cz<0||cx>=FOGN||cz>=FOGN)return 0;
  return fogExp[cz*FOGN+cx];
}
function markVision(x,z,r,arr){
  const r2=r*r;
  const x0=clamp(((x-r)/FOGC)|0,0,FOGN-1),x1=clamp(((x+r)/FOGC)|0,0,FOGN-1);
  const z0=clamp(((z-r)/FOGC)|0,0,FOGN-1),z1=clamp(((z+r)/FOGC)|0,0,FOGN-1);
  for(let cz=z0;cz<=z1;cz++){
    const wz=cz*FOGC+FOGC*0.5, dz=wz-z;
    for(let cx=x0;cx<=x1;cx++){
      const wx=cx*FOGC+FOGC*0.5, dx=wx-x;
      if(dx*dx+dz*dz<=r2)arr[cz*FOGN+cx]=1;
    }
  }
}
function markExplored(x,z,r){
  const r2=r*r;
  const x0=clamp(((x-r)/FOGC)|0,0,FOGN-1),x1=clamp(((x+r)/FOGC)|0,0,FOGN-1);
  const z0=clamp(((z-r)/FOGC)|0,0,FOGN-1),z1=clamp(((z+r)/FOGC)|0,0,FOGN-1);
  for(let cz=z0;cz<=z1;cz++){
    const wz=cz*FOGC+FOGC*0.5, dz=wz-z;
    for(let cx=x0;cx<=x1;cx++){
      const wx=cx*FOGC+FOGC*0.5, dx=wx-x;
      if(dx*dx+dz*dz<=r2)fogExp[cz*FOGN+cx]=1;
    }
  }
}
let fogDirty=true;
function rebuildFogTex(){
  for(let i=0;i<FOGN*FOGN;i++){
    const v=fogVis[i],e=fogExp[i];
    let r,g,b,a;
    if(v){r=0;g=0;b=0;a=0;}
    else if(e){r=0.03;g=0.055;b=0.10;a=0.72;}
    else {r=0.008;g=0.014;b=0.028;a=1.0;}
    fogTex[i*4]=r*255;fogTex[i*4+1]=g*255;fogTex[i*4+2]=b*255;fogTex[i*4+3]=a*255;
  }
  gl.bindTexture(gl.TEXTURE_2D,fogTexObj);
  gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,FOGN,FOGN,0,gl.RGBA,gl.UNSIGNED_BYTE,fogTex);
  fogDirty=false;
}

/* --- terrain generation --- */
function hash2(x,y){
  let n=(x*374761393+y*668265263)|0;
  n=(n^(n>>13))*1274126177|0;
  return ((n^(n>>16))>>>0)/4294967295;
}
function vnoise(x,y){
  const xi=Math.floor(x),yi=Math.floor(y),xf=x-xi,yf=y-yi;
  const u=xf*xf*(3-2*xf),v=yf*yf*(3-2*yf);
  const a=hash2(xi,yi),b=hash2(xi+1,yi),c=hash2(xi,yi+1),d=hash2(xi+1,yi+1);
  return lerp(lerp(a,b,u),lerp(c,d,u),v);
}
const START_P={x:46,z:46}, START_A={x:210,z:210};
function baseFlat(x,z){
  let h=0;
  const dp=Math.hypot(x-START_P.x,z-START_P.z);
  const da=Math.hypot(x-START_A.x,z-START_A.z);
  if(dp<34)h=Math.max(h,1-(dp/34));
  if(da<34)h=Math.max(h,1-(da/34));
  return h;
}
function terrainH(x,z){
  let h=vnoise(x*0.011,z*0.011)*7.0+vnoise(x*0.028,z*0.028)*2.4+vnoise(x*0.075,z*0.075)*0.9;
  h-=3.4;
  const f=baseFlat(x,z);
  h=lerp(h,2.2,f*f);
  const edge=Math.min(Math.min(x,z),Math.min(MAP-x,MAP-z));
  h+=Math.max(0,(14-edge))*0.30;
  return h;
}
/* grid */
const GN=64, CELL=MAP/GN;
const passable=new Uint8Array(GN*GN);
const heightGrid=new Float32Array((GN+1)*(GN+1));
for(let z=0;z<=GN;z++)for(let x=0;x<=GN;x++)heightGrid[z*(GN+1)+x]=terrainH(x*CELL,z*CELL);
function gH(x,z){
  const gx=clamp(x/CELL,0,GN-0.001),gz=clamp(z/CELL,0,GN-0.001);
  const x0=gx|0,z0=gz|0,fx=gx-x0,fz=gz-z0;
  const i=z0*(GN+1)+x0;
  return lerp(lerp(heightGrid[i],heightGrid[i+1],fx),lerp(heightGrid[i+GN+1],heightGrid[i+GN+2],fx),fz);
}
function terrainNormal(x,z){
  const e=1.2;
  const hL=gH(x-e,z),hR=gH(x+e,z),hD=gH(x,z-e),hU=gH(x,z+e);
  const nx=hL-hR,ny=2*e,nz=hD-hU;
  const l=Math.hypot(nx,ny,nz)||1;
  return [nx/l,ny/l,nz/l];
}
/* obstacles: rock formations */
const rocks=[];
function addRock(cx,cz,r,scale){
  rocks.push({x:cx,z:cz,r:r});
  const c0=clamp(((cx-r)/CELL)|0,0,GN-1),c1=clamp(((cx+r)/CELL)|0,0,GN-1);
  const d0=clamp(((cz-r)/CELL)|0,0,GN-1),d1=clamp(((cz+r)/CELL)|0,0,GN-1);
  for(let gz=d0;gz<=d1;gz++)for(let gx=c0;gx<=c1;gx++){
    const wx=gx*CELL+CELL/2,wz=gz*CELL+CELL/2;
    if(dist2(wx,wz,cx,cz)<r*r)passable[gz*GN+gx]=0;
  }
  const n=Math.max(3,(r*1.3)|0);
  for(let i=0;i<n;i++){
    const a=rnd()*TAU,rr=rnd()*r*0.85;
    const px=cx+Math.cos(a)*rr,pz=cz+Math.sin(a)*rr;
    rocks.push({x:px,z:pz,r:0,deco:true,sz:rrange(0.9,2.6)*scale,ry:rnd()*TAU,h:gH(px,pz)});
  }
}
function genMap(){
  srand(20240613);
  rocks.length=0;
  passable.fill(1);
  for(let i=0;i<GN;i++){passable[i]=0;passable[(GN-1)*GN+i]=0;passable[i*GN]=0;passable[i*GN+GN-1]=0;}
  const blobs=[];
  for(let i=0;i<34;i++){
    let x,z,ok=false,tries=0;
    while(!ok&&tries++<60){
      x=rrange(24,MAP-24);z=rrange(24,MAP-24);
      ok=Math.hypot(x-START_P.x,z-START_P.z)>42&&Math.hypot(x-START_A.x,z-START_A.z)>42;
      for(const b of blobs)if(dist2(x,z,b.x,b.z)<(b.r+14)*(b.r+14))ok=false;
    }
    if(!ok)continue;
    const r=rrange(5,11);
    blobs.push({x,z,r});
    addRock(x,z,r,rrange(0.8,1.4));
  }
  /* chokepoint ridges */
  const ridges=[
    {x:110,z:70,w:46,h:5},
    {x:150,z:186,w:40,h:5},
    {x:70,z:150,w:38,h:5},
    {x:190,z:96,w:34,h:5}
  ];
  for(const rg of ridges){
    for(let t=-rg.w/2;t<rg.w/2;t+=CELL){
      const x=rg.x+t,z=rg.z+Math.sin(t*0.11)*4;
      if(Math.hypot(x-START_P.x,z-START_P.z)<38||Math.hypot(x-START_A.x,z-START_A.z)<38)continue;
      addRock(x,z,rg.h,0.9);
    }
  }
  /* resource nodes */
  resNodes.length=0;
  const spots=[
    {x:62,z:62,n:2},{x:70,z:34,n:1},{x:34,z:74,n:1},
    {x:196,z:196,n:2},{x:186,z:224,n:1},{x:226,z:186,n:1},
    {x:112,z:118,n:2},{x:146,z:140,n:2},{x:128,z:76,n:1},{x:76,z:132,n:1},
    {x:180,z:118,n:1},{x:118,z:182,n:1},{x:96,z:206,n:1},{x:206,z:96,n:1},
    {x:160,z:60,n:1},{x:60,z:160,n:1}
  ];
  for(const s of spots){
    for(let i=0;i<s.n;i++){
      const a=rnd()*TAU,rr=s.n>1?rrange(6,10):0;
      let x=s.x+Math.cos(a)*rr,z=s.z+Math.sin(a)*rr;
      x=clamp(x,10,MAP-10);z=clamp(z,10,MAP-10);
      const cr=[];
      const nc=4+((rnd()*3)|0);
      for(let k=0;k<nc;k++){
        const aa=rnd()*TAU, rr=rnd()*1.7;
        cr.push({dx:Math.cos(aa)*rr,dz:Math.sin(aa)*rr,h:rrange(1.3,2.7),r:rrange(0.8,1.7),ry:rnd()*TAU});
      }
      resNodes.push({x,z,amt:2400,max:2400,r:2.6,id:resNodes.length,crystals:cr});
      const c0=clamp(((x-3)/CELL)|0,0,GN-1),c1=clamp(((x+3)/CELL)|0,0,GN-1);
      const d0=clamp(((z-3)/CELL)|0,0,GN-1),d1=clamp(((z+3)/CELL)|0,0,GN-1);
      for(let gz=d0;gz<=d1;gz++)for(let gx=c0;gx<=c1;gx++){
        const wx=gx*CELL+CELL/2,wz=gz*CELL+CELL/2;
        if(dist2(wx,wz,x,z)<9)passable[gz*GN+gx]=0;
      }
    }
  }
}
const resNodes=[];
function buildTerrainMesh(){
  const N=128, step=MAP/N;
  const verts=(N+1)*(N+1);
  const pos=new Float32Array(verts*3),nrm=new Float32Array(verts*3),col=new Float32Array(verts*3);
  for(let z=0;z<=N;z++)for(let x=0;x<=N;x++){
    const i=z*(N+1)+x, wx=x*step, wz=z*step;
    const h=terrainH(wx,wz);
    pos[i*3]=wx;pos[i*3+1]=h;pos[i*3+2]=wz;
    const n=terrainNormal(wx,wz);
    nrm[i*3]=n[0];nrm[i*3+1]=n[1];nrm[i*3+2]=n[2];
    const t=clamp((h+1)/9,0,1);
    let r=0.16+0.10*t,g=0.20+0.16*t,b=0.22+0.10*t;
    const nz=vnoise(wx*0.09,wz*0.09);
    r+=nz*0.05;g+=nz*0.06;b+=nz*0.03;
    if(h>6.0){r=0.42;g=0.44;b=0.47;}
    col[i*3]=r;col[i*3+1]=g;col[i*3+2]=b;
  }
  const idx=[];
  for(let z=0;z<N;z++)for(let x=0;x<N;x++){
    const a=z*(N+1)+x,b=a+1,c=a+(N+1),d=c+1;
    idx.push(a,c,b,b,c,d);
  }
  const vao=gl.createVertexArray();
  gl.bindVertexArray(vao);
  function buf(data,loc,size){
    const b=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,b);
    gl.bufferData(gl.ARRAY_BUFFER,data,gl.STATIC_DRAW);
    gl.enableVertexAttribArray(loc);gl.vertexAttribPointer(loc,size,gl.FLOAT,false,0,0);
  }
  buf(pos,TA.pos,3);buf(nrm,TA.nrm,3);buf(col,TA.col,3);
  const ib=gl.createBuffer();gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,ib);
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,new Uint32Array(idx),gl.STATIC_DRAW);
  gl.bindVertexArray(null);
  return {vao,count:idx.length};
}
/* ============================================================
   ENTITY DEFINITIONS
   ============================================================ */
const COL={
  metal:[0.44,0.48,0.54], dark:[0.19,0.22,0.27], amber:[0.95,0.72,0.25],
  glass:[0.30,0.72,0.92], glow:[0.60,0.96,1.0], accent:[0.90,0.55,0.14],
  rock:[0.34,0.34,0.38], crystal:[0.42,0.92,1.0], crystal2:[0.85,0.55,0.95],
  white:[0.92,0.95,1.0], team:null
};
const TEAMCOL=[null,[0.16,0.78,1.0],[1.0,0.30,0.16]];
const TEAMDARK=[null,[0.05,0.26,0.36],[0.34,0.09,0.05]];

function partM(p){
  const g=p[0],x=p[1],y=p[2],z=p[3],sx=p[4],sy=p[5],sz=p[6],ry=p[7]||0;
  const c=Math.cos(ry),s=Math.sin(ry);
  const m=new Float32Array(16);
  m[0]=c*sx; m[1]=0; m[2]=-s*sx; m[3]=0;
  m[4]=0;    m[5]=sy;m[6]=0;      m[7]=0;
  m[8]=s*sz; m[9]=0; m[10]=c*sz;  m[11]=0;
  m[12]=x;   m[13]=y;m[14]=z;     m[15]=1;
  return m;
}
function buildParts(def){
  def._parts=def.parts.map(p=>({g:p[0],m:partM(p),c:p[8]||"team"}));
  return def;
}

/* ---------- unit definitions ---------- */
const UNIT_DEFS={
  hauler:{
    name:"Hauler", key:"hauler", hot:"1", role:"Worker / Harvester",
    hp:50, armor:"light", dmgType:"kinetic", dmg:5, range:1.3, cd:1.0,
    speed:5.4, sight:11, supply:1, cost:50, bt:9, radius:0.62,
    tGround:1, tAir:0, isAir:0, isWorker:1, desc:"Mines crystal, constructs buildings. Weak in a fight.",
    parts:[
      [GBOX,0,0.34,0, 0.62,0.30,0.80,0,"team"],
      [GBOX,0,0.60,-0.14, 0.44,0.26,0.34, 0,"metal"],
      [GBOX,0,0.62,0.26, 0.44,0.30,0.34, 0,"amber"],
      [GBOX,-0.34,0.20,0, 0.14,0.24,0.86, 0,"dark"],
      [GBOX,0.34,0.20,0, 0.14,0.24,0.86, 0,"dark"],
      [GBOX,0,0.78,-0.14, 0.30,0.10,0.26, 0,"glass"]
    ]
  },
  trooper:{
    name:"Trooper", key:"trooper", hot:"2", role:"Ranged Infantry",
    hp:72, armor:"light", dmgType:"kinetic", dmg:9, range:7.5, cd:0.85,
    speed:4.5, sight:13, supply:1, cost:75, bt:11, radius:0.52,
    tGround:1, tAir:1, isAir:0, desc:"Cheap rifleman. Kinetic rounds shred light armour and can hit aircraft.",
    parts:[
      [GBOX,0,0.22,0, 0.30,0.44,0.30, 0,"dark"],
      [GBOX,0,0.62,0, 0.44,0.42,0.32, 0,"team"],
      [GBOX,0,0.90,0, 0.22,0.22,0.22, 0,"metal"],
      [GCYLZ,0.17,0.66,0.36, 0.09,0.09,0.60, 0,"dark"],
      [GBOX,-0.30,0.66,0, 0.14,0.36,0.14, 0,"team"]
    ]
  },
  ravager:{
    name:"Ravager", key:"ravager", hot:"3", role:"Assault Walker",
    hp:200, armor:"heavy", dmgType:"kinetic", dmg:28, range:1.7, cd:1.05,
    speed:5.1, sight:11, supply:2, cost:140, bt:16, radius:0.82,
    tGround:1, tAir:0, isAir:0, desc:"Heavy close-combat walker. Crushes infantry and shrugs off kinetic fire.",
    parts:[
      [GBOX,0,0.72,0, 0.92,0.52,1.10, 0,"team"],
      [GBOX,0,0.74,0.72, 0.54,0.42,0.44, 0,"metal"],
      [GBOX,-0.42,0.30,-0.34, 0.20,0.62,0.20, 0,"dark"],
      [GBOX,0.42,0.30,-0.34, 0.20,0.62,0.20, 0,"dark"],
      [GBOX,-0.42,0.30,0.36, 0.20,0.62,0.20, 0,"dark"],
      [GBOX,0.42,0.30,0.36, 0.20,0.62,0.20, 0,"dark"],
      [GCONE,-0.26,1.12,0, 0.22,0.42,0.22, 0,"accent"],
      [GCONE,0.26,1.12,0, 0.22,0.42,0.22, 0,"accent"],
      [GCONE,0,0.80,1.00, 0.16,0.34,0.16, 0,"accent"]
    ]
  },
  bastion:{
    name:"Bastion", key:"bastion", hot:"4", role:"Siege Tank",
    hp:320, armor:"heavy", dmgType:"explosive", dmg:36, range:9.5, cd:2.0,
    speed:2.9, sight:14, supply:3, cost:240, bt:26, radius:1.02,
    tGround:1, tAir:0, isAir:0, desc:"Armoured artillery. Explosive shells melt heavy targets. Cannot hit air.",
    parts:[
      [GBOX,0,0.46,0, 1.10,0.44,1.50, 0,"team"],
      [GBOX,-0.66,0.26,0, 0.30,0.34,1.50, 0,"dark"],
      [GBOX,0.66,0.26,0, 0.30,0.34,1.50, 0,"dark"],
      [GBOX,0,0.82,-0.10, 0.80,0.38,0.90, 0,"metal"],
      [GCYLZ,0,0.86,0.72, 0.15,0.15,1.20, 0,"dark"],
      [GCYLZ,0,0.86,1.32, 0.20,0.20,0.24, 0,"accent"],
      [GBOX,0,1.06,-0.10, 0.40,0.16,0.44, 0,"glass"]
    ]
  },
  wasp:{
    name:"Wasp", key:"wasp", hot:"5", role:"Gunship",
    hp:115, armor:"light", dmgType:"explosive", dmg:19, range:6.5, cd:1.0,
    speed:7.6, sight:15, supply:2, cost:170, bt:20, radius:0.85,
    tGround:1, tAir:1, isAir:1, desc:"Fast attack aircraft. Ignores terrain. Strong against armour, fragile to flak.",
    parts:[
      [GBOX,0,0,0, 0.50,0.22,0.90, 0,"team"],
      [GBOX,-0.62,0.06,0, 0.90,0.06,0.30, 0,"metal"],
      [GBOX,0.62,0.06,0, 0.90,0.06,0.30, 0,"metal"],
      [GCYL,-0.62,0.18,0, 0.62,0.05,0.62, 0,"dark"],
      [GCYL,0.62,0.18,0, 0.62,0.05,0.62, 0,"dark"],
      [GCONE,0,-0.02,-0.66, 0.20,0.36,0.20, 0,"accent"],
      [GBOX,0,0.16,0.30, 0.26,0.14,0.30, 0,"glass"]
    ]
  }
};
for(const k in UNIT_DEFS)buildParts(UNIT_DEFS[k]);

/* ---------- building definitions ---------- */
const BLD_DEFS={
  hq:{
    name:"Command Nexus", key:"hq", size:5, hp:2400, armor:"heavy", sight:17,
    cost:500, bt:55, supply:20, dropoff:1, produces:["hauler"], prereq:null,
    desc:"Headquarters. Trains Haulers, accepts crystal, provides 20 supply. Losing every Nexus loses the war.",
    parts:[
      [GCYL,0,0.55,0, 7.0,1.10,7.0, 0,"dark"],
      [GBOX,0,1.60,0, 4.6,2.10,4.6, 0,"team"],
      [GBOX,0,2.90,0, 3.0,0.70,3.0, 0,"metal"],
      [GCONE,0,3.80,0, 3.2,1.60,3.2, 0,"accent"],
      [GCYL,-2.5,2.4,0, 0.7,2.6,0.7, 0,"metal"],
      [GCYL,2.5,2.4,0, 0.7,2.6,0.7, 0,"metal"],
      [GCYL,0,2.4,-2.5, 0.7,2.6,0.7, 0,"metal"],
      [GCYL,0,2.4,2.5, 0.7,2.6,0.7, 0,"metal"],
      [GSPH,0,4.70,0, 1.1,1.1,1.1, 0,"glow"]
    ]
  },
  depot:{
    name:"Supply Relay", key:"depot", size:3, hp:750, armor:"heavy", sight:9,
    cost:110, bt:18, supply:10, dropoff:0, produces:[], prereq:null,
    desc:"Increases supply capacity by 10. Build many to sustain a large army.",
    parts:[
      [GBOX,0,0.55,0, 3.0,1.10,3.0, 0,"dark"],
      [GBOX,0,1.35,0, 2.4,0.70,2.4, 0,"team"],
      [GCYL,-0.8,2.05,0, 0.9,1.4,0.9, 0,"metal"],
      [GCYL,0.8,2.05,0, 0.9,1.4,0.9, 0,"metal"],
      [GCONE,0,3.00,0, 0.9,0.9,0.9, 0,"accent"]
    ]
  },
  barracks:{
    name:"Foundry", key:"barracks", size:4, hp:1200, armor:"heavy", sight:10,
    cost:220, bt:30, supply:0, dropoff:0, produces:["trooper","ravager","bastion"], prereq:null,
    desc:"Ground production. Trains Troopers, Ravagers and Bastions. Set a rally point with right-click.",
    parts:[
      [GBOX,0,0.60,0, 4.0,1.20,3.4, 0,"dark"],
      [GBOX,0,1.70,0, 3.2,1.00,2.8, 0,"team"],
      [GCYL,0,2.60,-1.0, 0.8,1.4,0.8, 0,"metal"],
      [GCYL,0,2.60,1.0, 0.8,1.4,0.8, 0,"metal"],
      [GBOX,0,1.00,1.75, 1.4,1.4,0.20, 0,"amber"],
      [GCONE,-1.2,3.05,0, 0.7,1.0,0.7, 0,"accent"]
    ]
  },
  airpad:{
    name:"Aerie", key:"airpad", size:4, hp:1050, armor:"heavy", sight:11,
    cost:260, bt:34, supply:0, dropoff:0, produces:["wasp"], prereq:"barracks",
    desc:"Air production. Builds Wasp gunships. Requires a Foundry.",
    parts:[
      [GCYL,0,0.35,0, 4.2,0.70,4.2, 0,"dark"],
      [GRING,0,0.74,0, 3.6,1,3.6, 0,"team"],
      [GBOX,-1.8,1.20,0, 0.5,1.6,0.5, 0,"metal"],
      [GBOX,1.8,1.20,0, 0.5,1.6,0.5, 0,"metal"],
      [GBOX,0,1.20,-1.8, 0.5,1.6,0.5, 0,"metal"],
      [GBOX,0,1.20,1.8, 0.5,1.6,0.5, 0,"metal"],
      [GCYL,0,2.05,0, 1.0,0.5,1.0, 0,"accent"]
    ]
  },
  lab:{
    name:"Research Spire", key:"lab", size:3, hp:950, armor:"heavy", sight:10,
    cost:260, bt:32, supply:0, dropoff:0, produces:[], prereq:"barracks",
    desc:"Unlocks permanent upgrades for your whole army. Requires a Foundry.",
    parts:[
      [GBOX,0,0.60,0, 3.0,1.20,3.0, 0,"dark"],
      [GCYL,0,1.90,0, 1.8,1.40,1.8, 0,"team"],
      [GSPH,0,3.05,0, 1.6,1.6,1.6, 0,"accent"],
      [GBOX,-1.4,1.00,0, 0.4,1.2,1.2, 0,"metal"],
      [GBOX,1.4,1.00,0, 0.4,1.2,1.2, 0,"metal"]
    ]
  },
  turret:{
    name:"Sentry Gun", key:"turret", size:2, hp:850, armor:"heavy", sight:13,
    cost:150, bt:22, supply:0, dropoff:0, produces:[], prereq:null,
    dmgType:"explosive", dmg:24, range:11.5, cd:1.3, tGround:1, tAir:1,
    desc:"Automated defensive emplacement. Fires at ground and air targets.",
    parts:[
      [GCYL,0,0.40,0, 1.8,0.80,1.8, 0,"dark"],
      [GBOX,0,1.10,0, 1.2,0.90,1.2, 0,"team"],
      [GCYLZ,0,1.25,0.90, 0.22,0.22,1.40, 0,"metal"],
      [GCYLZ,0,1.25,1.62, 0.30,0.30,0.30, 0,"accent"],
      [GSPH,0,1.72,0, 0.5,0.5,0.5, 0,"glow"]
    ]
  }
};
for(const k in BLD_DEFS)buildParts(BLD_DEFS[k]);

/* ---------- upgrades ---------- */
const UPGRADES=[
  {key:"weapon",name:"Weapon Calibration",cost:250,bt:45,desc:"+20% damage for all units and turrets.",icon:"W"},
  {key:"armor", name:"Armour Plating",   cost:250,bt:45,desc:"+20% maximum health for all units.",icon:"A"},
  {key:"servo", name:"Drive Servos",     cost:200,bt:40,desc:"+25% movement speed for all units.",icon:"S"},
  {key:"logi",  name:"Logistics Grid",   cost:200,bt:40,desc:"+30% crystal gathered per trip.",icon:"L"}
];

/* ---------- damage table ---------- */
const ARMOR={light:0,heavy:1};
function damageMult(dt,armor){
  const a=armor==="heavy"?1:0;
  if(dt==="kinetic")return a?0.70:1.30;
  return a?1.35:0.75;
}

/* ============================================================
   GAME STATE
   ============================================================ */
const TEAM_P=1, TEAM_A=2;
let units=[], buildings=[], projectiles=[], effects=[];
let nextId=1;
let gameTime=0, running=false, paused=false, gameOver=0;
const teamState=[
  null,
  {crystal:900, supply:0, supplyCap:0, upgrades:{}, ai:false},
  {crystal:900, supply:0, supplyCap:0, upgrades:{}, ai:true}
];
const knownEnemyBld=new Map();  /* id -> {x,z,key,team,t} last-known silhouettes (player view) */
const aiKnownBld=new Map();     /* id -> {x,z,key,t} AI memory */
const aiKnownUnits=new Map();
let aiMemoryTimer=0;

function mkUnit(type,team,x,z,yaw){
  const d=UNIT_DEFS[type];
  const u={
    id:nextId++, kind:"u", team, type, d,
    x, z, y:0, yaw:yaw||0, hp:d.hp*(1+0.2*(teamState[team].upgrades.armor?1:0)), maxHp:0,
    cd:rnd()*d.cd, target:null, order:null, path:null, pathI:0, dest:null,
    gatherNode:null, carry:0, gatherT:0, state:"idle", flash:0, dead:false,
    repathT:0, stuckT:0, lx:x, lz:z, sepX:0, sepZ:0, anim:rnd()*10, homeX:x, homeZ:z,
    hold:false, atkMove:false, buildTgt:null, sight:d.sight, rally:null, lastHit:0
  };
  u.maxHp=u.hp;
  units.push(u);
  return u;
}
function mkBuilding(type,team,x,z,complete){
  const d=BLD_DEFS[type];
  const b={
    id:nextId++, kind:"b", team, type, d, x, z, yaw:0,
    hp:complete?d.hp:d.hp*0.15, maxHp:d.hp, built:complete?1:0, buildP:complete?1:0,
    queue:[], target:null, cd:rnd()*2, flash:0, dead:false, sight:d.sight,
    rally:null, builders:0, lastHit:0
  };
  b.maxHp=d.hp;
  buildings.push(b);
  return b;
}
function unitAt(x,z,r,team){
  let best=null,bd=r*r;
  for(const u of units){
    if(u.dead)continue;
    if(team&&u.team!==team)continue;
    const d=dist2(u.x,u.z,x,z);
    if(d<bd){bd=d;best=u;}
  }
  return best;
}
function buildingAt(x,z,team){
  for(const b of buildings){
    if(b.dead)continue;
    if(team&&b.team!==team)continue;
    const h=b.d.size*CELL*0.5;
    if(Math.abs(x-b.x)<=h&&Math.abs(z-b.z)<=h)return b;
  }
  return null;
}
function supplyUsed(team){
  let s=0;
  for(const u of units)if(!u.dead&&u.team===team)s+=u.d.supply;
  for(const b of buildings)if(!b.dead&&b.team===team&&b.queue.length){
    for(const q of b.queue){const ud=UNIT_DEFS[q.type];if(ud)s+=ud.supply;}
  }
  return s;
}
function supplyCap(team){
  let s=0;
  for(const b of buildings)if(!b.dead&&b.team===team&&b.built)s+=b.d.supply||0;
  return s;
}
function hasBuilding(team,type){
  for(const b of buildings)if(!b.dead&&b.team===team&&b.type===type&&b.built)return true;
  return false;
}
function teamHasHQ(team){
  for(const b of buildings)if(!b.dead&&b.team===team&&b.type==="hq")return true;
  return false;
}
function nearestDropoff(team,x,z){
  let best=null,bd=1e18;
  for(const b of buildings){
    if(b.dead||b.team!==team||!b.built||!b.d.dropoff)continue;
    const d=dist2(b.x,b.z,x,z);
    if(d<bd){bd=d;best=b;}
  }
  return best;
}
function nearestRes(x,z,maxD){
  let best=null,bd=maxD*maxD;
  for(const n of resNodes){
    if(n.amt<=0)continue;
    const d=dist2(n.x,n.z,x,z);
    if(d<bd){bd=d;best=n;}
  }
  return best;
}
function spawnProjectile(team,sx,sy,sz,tx,ty,tz,dmg,dt,arc,color){
  const dx=tx-sx,dy=ty-sy,dz=tz-sz;
  const d=Math.hypot(dx,dy,dz)||1;
  const sp=arc?46:130;
  projectiles.push({team,x:sx,y:sy,z:sz,vx:dx/d*sp,vy:dy/d*sp,vz:dz/d*sp,
    life:d/sp+0.02,dmg,dt,arc,color,tx,ty,tz,target:null,homing:arc});
}
function addEffect(x,y,z,r,g,b,life,size){
  effects.push({x,y,z,r,g,b,life,max:life,size});
}
/* ============================================================
   SPATIAL HASH
   ============================================================ */
const SH_CELL=8, SH_N=Math.ceil(MAP/SH_CELL)+1;
const shBuckets=new Array(SH_N*SH_N);
for(let i=0;i<shBuckets.length;i++)shBuckets[i]=[];
function shKey(x,z){
  const cx=clamp((x/SH_CELL)|0,0,SH_N-1), cz=clamp((z/SH_CELL)|0,0,SH_N-1);
  return cz*SH_N+cx;
}
function rebuildHash(){
  for(let i=0;i<shBuckets.length;i++)if(shBuckets[i].length)shBuckets[i].length=0;
  for(const u of units){ if(u.dead)continue; shBuckets[shKey(u.x,u.z)].push(u); }
}
function queryUnits(x,z,r,cb){
  const r2=r*r;
  const c0=clamp(((x-r)/SH_CELL)|0,0,SH_N-1),c1=clamp(((x+r)/SH_CELL)|0,0,SH_N-1);
  const d0=clamp(((z-r)/SH_CELL)|0,0,SH_N-1),d1=clamp(((z+r)/SH_CELL)|0,0,SH_N-1);
  for(let cz=d0;cz<=d1;cz++)for(let cx=c0;cx<=c1;cx++){
    const arr=shBuckets[cz*SH_N+cx];
    for(let i=0;i<arr.length;i++){
      const u=arr[i];
      if(dist2(u.x,u.z,x,z)<=r2)cb(u);
    }
  }
}

/* ============================================================
   PATHFINDING (A* on coarse grid)
   ============================================================ */
const pathBlocked=new Uint8Array(GN*GN);
function rebuildPathGrid(){
  for(let i=0;i<GN*GN;i++)pathBlocked[i]=passable[i]?0:1;
  for(const b of buildings){
    if(b.dead)continue;
    const h=b.d.size*0.5;
    const c0=clamp(((b.x-h)/CELL)|0,0,GN-1),c1=clamp(((b.x+h)/CELL)|0,0,GN-1);
    const d0=clamp(((b.z-h)/CELL)|0,0,GN-1),d1=clamp(((b.z+h)/CELL)|0,0,GN-1);
    for(let gz=d0;gz<=d1;gz++)for(let gx=c0;gx<=c1;gx++)pathBlocked[gz*GN+gx]=1;
  }
}
const _g=new Float32Array(GN*GN);
const _f=new Float32Array(GN*GN);
const _from=new Int32Array(GN*GN);
const _closed=new Uint8Array(GN*GN);
const _stamp=new Int32Array(GN*GN);
let _stampV=0;
const _heap=new Int32Array(GN*GN+4);
let _hn=0;
function hpush(i){
  _heap[_hn]=i; let c=_hn++;
  while(c>0){const p=(c-1)>>1; if(_f[_heap[p]]<=_f[_heap[c]])break; const t=_heap[p];_heap[p]=_heap[c];_heap[c]=t;c=p;}
}
function hpop(){
  const top=_heap[0]; _hn--;
  if(_hn>0){
    _heap[0]=_heap[_hn]; let p=0;
    for(;;){const l=2*p+1,r=l+1;let m=p;
      if(l<_hn&&_f[_heap[l]]<_f[_heap[m]])m=l;
      if(r<_hn&&_f[_heap[r]]<_f[_heap[m]])m=r;
      if(m===p)break;
      const t=_heap[p];_heap[p]=_heap[m];_heap[m]=t;p=m;}
  }
  return top;
}
function cellFree(gx,gz){
  if(gx<0||gz<0||gx>=GN||gz>=GN)return false;
  return !pathBlocked[gz*GN+gx];
}
function nearestFree(gx,gz){
  if(cellFree(gx,gz))return gz*GN+gx;
  for(let r=1;r<10;r++){
    for(let dz=-r;dz<=r;dz++)for(let dx=-r;dx<=r;dx++){
      if(Math.abs(dx)!==r&&Math.abs(dz)!==r)continue;
      if(cellFree(gx+dx,gz+dz))return (gz+dz)*GN+(gx+dx);
    }
  }
  return -1;
}
/* Exact grid traversal (Amanatides-Woo DDA) — samples every cell the
   segment actually crosses, so string-pulled paths never clip corners
   of blocked cells. */
function losClear(x0,z0,x1,z1){
  let gx=(x0/CELL)|0, gz=(z0/CELL)|0;
  const ex=(x1/CELL)|0, ez=(z1/CELL)|0;
  if(!cellFree(gx,gz)||!cellFree(ex,ez))return false;
  const dx=x1-x0, dz=z1-z0;
  const stepX=dx>0?1:(dx<0?-1:0);
  const stepZ=dz>0?1:(dz<0?-1:0);
  const tdx=dx!==0?Math.abs(CELL/dx):Infinity;
  const tdz=dz!==0?Math.abs(CELL/dz):Infinity;
  let tmx=dx!==0?Math.abs(((gx+(stepX>0?1:0))*CELL-x0)/dx):Infinity;
  let tmz=dz!==0?Math.abs(((gz+(stepZ>0?1:0))*CELL-z0)/dz):Infinity;
  let guard=0;
  while((gx!==ex||gz!==ez)&&guard++<512){
    if(tmx<tmz){tmx+=tdx;gx+=stepX;}
    else{tmz+=tdz;gz+=stepZ;}
    if(!cellFree(gx,gz))return false;
  }
  return true;
}
function findPath(sx,sz,tx,tz){
  const sgx0=clamp((sx/CELL)|0,0,GN-1), sgz0=clamp((sz/CELL)|0,0,GN-1);
  const sn=nearestFree(sgx0,sgz0);
  if(sn<0)return null;
  const sgx=sn%GN, sgz=(sn/GN)|0;
  let tgx=clamp((tx/CELL)|0,0,GN-1), tgz=clamp((tz/CELL)|0,0,GN-1);
  const tn=nearestFree(tgx,tgz);
  if(tn<0)return null;
  tgx=tn%GN; tgz=(tn/GN)|0;
  const start=sgz*GN+sgx, goal=tgz*GN+tgx;
  if(start===goal)return [{x:tx,z:tz}];
  _stampV++;
  _hn=0;
  _g[start]=0; _f[start]=Math.hypot(tgx-sgx,tgz-sgz); _from[start]=-1; _closed[start]=0; _stamp[start]=_stampV;
  hpush(start);
  let found=false, expanded=0;
  const CAP=4200;
  while(_hn>0&&expanded<CAP){
    const cur=hpop();
    if(_stamp[cur]!==_stampV||_closed[cur])continue;
    _closed[cur]=1; expanded++;
    if(cur===goal){found=true;break;}
    const cx=cur%GN, cz=(cur/GN)|0;
    for(let dz=-1;dz<=1;dz++)for(let dx=-1;dx<=1;dx++){
      if(!dx&&!dz)continue;
      const nx=cx+dx,nz=cz+dz;
      if(!cellFree(nx,nz))continue;
      if(dx&&dz&&(!cellFree(cx+dx,cz)||!cellFree(cx,cz+dz)))continue;
      const ni=nz*GN+nx;
      if(_stamp[ni]===_stampV&&_closed[ni])continue;
      const step=(dx&&dz)?1.4142:1.0;
      const ng=_g[cur]+step;
      if(_stamp[ni]!==_stampV||ng<_g[ni]){
        _stamp[ni]=_stampV; _g[ni]=ng; _from[ni]=cur; _closed[ni]=0;
        const hx=Math.abs(nx-tgx),hz=Math.abs(nz-tgz);
        _f[ni]=ng+(hx>hz?(hx-hz+hz*1.4142):(hz-hx+hx*1.4142));
        hpush(ni);
      }
    }
  }
  if(!found)return null;
  const pts=[];
  let cur=goal;
  while(cur!==-1){
    pts.push({x:(cur%GN)*CELL+CELL/2, z:((cur/GN)|0)*CELL+CELL/2});
    cur=_from[cur];
  }
  pts.reverse();
  pts[pts.length-1]={x:tx,z:tz};
  /* string pull */
  const out=[];
  let i=0;
  let guard=0;
  while(i<pts.length&&guard++<400){
    let j=pts.length-1;
    while(j>i+1&&!losClear(pts[i].x,pts[i].z,pts[j].x,pts[j].z))j--;
    out.push(pts[j]);
    i=j;
    if(j===pts.length-1)break;
  }
  if(!out.length)out.push({x:tx,z:tz});
  return out;
}

/* ============================================================
   COMBAT
   ============================================================ */
function canTarget(a,b){
  if(!b||b.dead)return false;
  if(b.kind==="u"&&b.d.isAir&&!a.d.tAir)return false;
  if(b.kind==="u"&&!b.d.isAir&&!a.d.tGround)return false;
  if(b.kind==="b"&&!a.d.tGround)return false;
  return true;
}
function applyDamage(tgt,dmg,dt,attacker){
  if(!tgt||tgt.dead)return;
  const m=damageMult(dt,tgt.d.armor);
  tgt.hp-=dmg*m;
  tgt.flash=0.16;
  tgt.lastHit=gameTime;
  if(tgt.hp<=0)killEntity(tgt,attacker);
}
function killEntity(e,attacker){
  if(e.dead)return;
  e.dead=true;
  const y=e.kind==="u"?e.y+0.8:1.6;
  const big=e.kind==="b";
  const n=big?14:6;
  for(let i=0;i<n;i++){
    addEffect(e.x+rrange(-1,1)*(big?2:0.6), y+rrange(-0.5,1)*(big?2:0.8), e.z+rrange(-1,1)*(big?2:0.6),
      1,0.55+rnd()*0.3,0.15, rrange(0.35,0.8), rrange(0.4,1.1)*(big?2.2:1));
  }
  sfx("explode",big);
  if(e.kind==="b"){
    if(e.type==="hq")toast((e.team===TEAM_P?"Your":"Enemy")+" Command Nexus destroyed",e.team===TEAM_P?"bad":"good");
    rebuildPathGrid();
    /* push units out of footprint */
    const h=e.d.size*0.5+0.6;
    for(const u of units){
      if(u.dead||u.d.isAir)continue;
      if(Math.abs(u.x-e.x)<h&&Math.abs(u.z-e.z)<h){
        const dx=u.x-e.x,dz=u.z-e.z;
        const l=Math.hypot(dx,dz)||1;
        u.x=e.x+dx/l*(h+0.5); u.z=e.z+dz/l*(h+0.5);
      }
    }
  }
  checkVictory();
}
function acquireTarget(u){
  const d=u.d;
  let best=null,bd=d.sight*d.sight;
  queryUnits(u.x,u.z,d.sight,(o)=>{
    if(o.team===u.team||o.dead)return;
    if(!canTarget(u,o))return;
    if(u.team===TEAM_P){if(!isVisible(o.x,o.z))return;}
    else{if(!aiVisible(o.x,o.z))return;}
    const dd=dist2(u.x,u.z,o.x,o.z);
    if(dd<bd){bd=dd;best=o;}
  });
  if(!best){
    for(const b of buildings){
      if(b.dead||b.team===u.team)continue;
      if(!canTarget(u,b))continue;
      if(u.team===TEAM_P){if(!isVisible(b.x,b.z))continue;}
      else{if(!aiVisible(b.x,b.z))continue;}
      const dd=dist2(u.x,u.z,b.x,b.z);
      if(dd<bd){bd=dd;best=b;}
    }
  }
  return best;
}
function entityRadius(e){return e.kind==="u"?e.d.radius:e.d.size*CELL*0.5;}
function inRange(u,t){
  const r=u.d.range+entityRadius(t)*0.85;
  return dist2(u.x,u.z,t.x,t.z)<=r*r;
}
function doAttack(u,t){
  const d=u.d;
  const dmg=d.dmg*(1+0.2*(teamState[u.team].upgrades.weapon?1:0));
  const ty=t.kind==="u"?(t.d.isAir?t.y+0.2:t.y+0.7):1.5;
  if(d.range>3.2){
    const col=d.dmgType==="explosive"?[1.0,0.62,0.16]:[1.0,0.94,0.55];
    projectiles.push({team:u.team,x:u.x,y:u.y+1.15,z:u.z,vx:0,vy:0,vz:0,
      life:3.0,dmg,dt:d.dmgType,arc:d.dmgType==="explosive",color:col,tgt:t,homing:true,speed:d.dmgType==="explosive"?42:150});
    sfx(d.dmgType==="explosive"?"boom":"shot",false);
  }else{
    applyDamage(t,dmg,d.dmgType,u);
    addEffect(t.x,ty,t.z,1,0.85,0.4,0.18,0.7);
    sfx("hit",false);
  }
  u.cd=d.cd;
}
function buildingAttack(b,t){
  const d=b.d;
  const dmg=d.dmg*(1+0.2*(teamState[b.team].upgrades.weapon?1:0));
  const ty=t.kind==="u"?(t.d.isAir?t.y+0.2:t.y+0.7):1.5;
  projectiles.push({team:b.team,x:b.x,y:gH(b.x,b.z)+1.6,z:b.z,vx:0,vy:0,vz:0,
    life:3.0,dmg,dt:d.dmgType,arc:false,color:[1.0,0.62,0.16],tgt:t,homing:true,speed:120});
  b.cd=d.cd;
  sfx("shot",false);
}

/* ============================================================
   VISION
   ============================================================ */
let visTimer=0;
function updateVision(){
  fogVis.fill(0);
  for(const u of units){
    if(u.dead||u.team!==TEAM_P)continue;
    markVision(u.x,u.z,u.d.sight,fogVis);
    markExplored(u.x,u.z,u.d.sight);
  }
  for(const b of buildings){
    if(b.dead||b.team!==TEAM_P)continue;
    const r=b.d.sight*(b.built?1:0.6);
    markVision(b.x,b.z,r,fogVis);
    markExplored(b.x,b.z,r);
  }
  /* last-known enemy buildings */
  for(const b of buildings){
    if(b.dead||b.team!==TEAM_A)continue;
    if(isVisible(b.x,b.z))knownEnemyBld.set(b.id,{x:b.x,z:b.z,key:b.type,t:gameTime,built:b.built});
  }
  for(const [id,rec] of knownEnemyBld){
    let alive=false;
    for(const b of buildings)if(b.id===id&&!b.dead){alive=true;break;}
    if(!alive)knownEnemyBld.delete(id);
  }
  fogDirty=true;
}
function aiVision(){
  aiVis.fill(0);
  const seenB=new Set(), seenU=new Set();
  const check=(x,z,r)=>{
    markVision(x,z,r,aiVis);
    markExpArr(x,z,r,aiExp);
    for(const b of buildings){
      if(b.dead||b.team!==TEAM_P)continue;
      if(dist2(x,z,b.x,b.z)<r*r){seenB.add(b.id);aiKnownBld.set(b.id,{x:b.x,z:b.z,key:b.type,t:gameTime});}
    }
    for(const u of units){
      if(u.dead||u.team!==TEAM_P)continue;
      if(dist2(x,z,u.x,u.z)<r*r){seenU.add(u.id);aiKnownUnits.set(u.id,{x:u.x,z:u.z,type:u.type,t:gameTime});}
    }
  };
  for(const u of units)if(!u.dead&&u.team===TEAM_A)check(u.x,u.z,u.d.sight);
  for(const b of buildings)if(!b.dead&&b.team===TEAM_A)check(b.x,b.z,b.d.sight);
  for(const [id,rec] of aiKnownBld){
    let alive=false;
    for(const b of buildings)if(b.id===id&&!b.dead){alive=true;break;}
    if(!alive)aiKnownBld.delete(id);
  }
  for(const [id,rec] of aiKnownUnits){
    let alive=false;
    for(const u of units)if(u.id===id&&!u.dead){alive=true;break;}
    if(!alive)aiKnownUnits.delete(id);
  }
}
/* ============================================================
   MOVEMENT HELPERS
   ============================================================ */
function setPathTo(u,tx,tz){
  u.pathDestX=tx; u.pathDestZ=tz;
  if(u.d.isAir){u.path=[{x:tx,z:tz}];u.pathI=0;return;}
  const p=findPath(u.x,u.z,tx,tz);
  if(p&&p.length){u.path=p;u.pathI=0;}else{u.path=null;}
}
function needPath(u,tx,tz){
  return !u.path||u.pathI>=u.path.length||u.pathDestX===undefined||
    Math.abs(u.pathDestX-tx)>2.6||Math.abs(u.pathDestZ-tz)>2.6;
}
function moveAlong(u,dt){
  if(!u.path||u.pathI>=u.path.length)return true;
  const wp=u.path[u.pathI];
  const dx=wp.x-u.x,dz=wp.z-u.z;
  const dd=Math.hypot(dx,dz);
  const last=u.pathI===u.path.length-1;
  if(dd<(last?0.45:0.85)){u.pathI++;if(u.pathI>=u.path.length){u.path=null;return true;}return false;}
  const sp=u.d.speed*(1+0.25*(teamState[u.team].upgrades.servo?1:0));
  const step=Math.min(dd,sp*dt);
  const px=u.x,pz=u.z;
  u.x+=dx/dd*step; u.z+=dz/dd*step;
  u.yaw=Math.atan2(dx,dz);
  if(!u.d.isAir){
    const gx=(u.x/CELL)|0,gz=(u.z/CELL)|0;
    const ogx=(px/CELL)|0,ogz=(pz/CELL)|0;
    if(!cellFree(gx,gz)&&cellFree(ogx,ogz)){
      /* slide along the free axis before giving up */
      if(cellFree(gx,ogz)){u.z=pz;}
      else if(cellFree(ogx,gz)){u.x=px;}
      else{u.x=px;u.z=pz;u.path=null;u.pathDestX=undefined;}
    }
  }
  u.x=clamp(u.x,2,MAP-2); u.z=clamp(u.z,2,MAP-2);
  return false;
}
function separate(u,dt){
  let px=0,pz=0,n=0;
  queryUnits(u.x,u.z,3.2,(o)=>{
    if(o===u||o.dead)return;
    if(o.d.isAir!==u.d.isAir)return;
    const dx=u.x-o.x,dz=u.z-o.z;
    const d2=dx*dx+dz*dz;
    const md=u.d.radius+o.d.radius;
    if(d2<md*md&&d2>0.0004){
      const dd=Math.sqrt(d2);
      const f=(md-dd)/md;
      px+=dx/dd*f; pz+=dz/dd*f; n++;
    }
  });
  if(n){
    const k=Math.min(1,dt*7);
    u.x+=px*k*0.9; u.z+=pz*k*0.9;
    u.x=clamp(u.x,2,MAP-2); u.z=clamp(u.z,2,MAP-2);
  }
}

/* ============================================================
   UNIT ORDERS / BEHAVIOUR
   ============================================================ */
function orderMove(u,x,z,queue){
  u.cmd="move"; u.dest={x,z}; u.target=null; u.node=null; u.buildTgt=null;
  u.pathDestX=undefined;
  setPathTo(u,x,z);
}
function orderAttackMove(u,x,z){
  u.cmd="amove"; u.dest={x,z}; u.target=null; u.node=null; u.buildTgt=null;
  u.pathDestX=undefined;
  setPathTo(u,x,z);
}
function orderAttack(u,t){
  u.cmd="attack"; u.target=t; u.dest={x:t.x,z:t.z}; u.node=null; u.buildTgt=null;
  u.pathDestX=undefined;
}
function orderGather(u,node){
  u.cmd="gather"; u.node=node; u.target=null; u.buildTgt=null; u.path=null; u.pathDestX=undefined;
}
function orderBuild(u,b){
  u.cmd="build"; u.buildTgt=b; u.target=null; u.node=null; u.pathDestX=undefined;
  setPathTo(u,b.x,b.z);
}
function orderStop(u){
  u.cmd="hold"; u.path=null; u.target=null; u.node=null; u.buildTgt=null; u.dest=null;
}
function updateGather(u,dt){
  const d=u.d;
  if(u.carry>0){
    const dp=nearestDropoff(u.team,u.x,u.z);
    if(!dp){u.carry=0;return;}
    const reach=dp.d.size*CELL*0.5+2.0;
    if(dist2(dp.x,dp.z,u.x,u.z)<reach*reach){
      teamState[u.team].crystal+=u.carry;
      u.carry=0; u.gatherT=0; u.path=null; u.pathDestX=undefined;
      if(u.team===TEAM_P)sfx("deposit",false);
    }else{
      if(needPath(u,dp.x,dp.z))setPathTo(u,dp.x,dp.z);
      moveAlong(u,dt);
    }
    return;
  }
  if(!u.node||u.node.amt<=0){
    u.node=nearestRes(u.x,u.z,70);
    if(!u.node){u.cmd="hold";return;}
    u.path=null;u.pathDestX=undefined;
  }
  const nd=u.node;
  const reach=nd.r+1.5;
  if(dist2(nd.x,nd.z,u.x,u.z)<reach*reach){
    u.path=null;
    u.yaw=Math.atan2(nd.x-u.x,nd.z-u.z);
    u.gatherT+=dt;
    if(u.gatherT>1.1){
      const amt=Math.min(9*(1+0.3*(teamState[u.team].upgrades.logi?1:0)),nd.amt);
      nd.amt-=amt; u.carry=amt; u.gatherT=0;
      addEffect(nd.x,gH(nd.x,nd.z)+1.2,nd.z,0.35,0.9,1.0,0.35,0.45);
      u.path=null;u.pathDestX=undefined;
    }
  }else{
    if(needPath(u,nd.x,nd.z))setPathTo(u,nd.x,nd.z);
    moveAlong(u,dt);
  }
}
function updateBuildOrder(u,dt){
  const b=u.buildTgt;
  if(!b||b.dead||b.built){
    u.buildTgt=null;
    /* return to mining automatically once the structure is up */
    const n=nearestRes(u.x,u.z,110);
    if(n)orderGather(u,n); else u.cmd="hold";
    return;
  }
  const reach=b.d.size*CELL*0.5+2.0;
  if(dist2(b.x,b.z,u.x,u.z)>reach*reach){
    if(needPath(u,b.x,b.z))setPathTo(u,b.x,b.z);
    moveAlong(u,dt);
  }else{
    u.path=null;
    u.yaw=Math.atan2(b.x-u.x,b.z-u.z);
    u.anim+=dt*3;
  }
}
function updateCombat(u,dt){
  let tgt=u.target;
  if(tgt&&tgt.dead)tgt=null;
  if(!tgt&&(u.cmd==="idle"||u.cmd==="hold"||u.cmd==="amove"||u.cmd==="attack")){
    tgt=acquireTarget(u); u.target=tgt;
  }
  if(tgt&&!canTarget(u,tgt)){tgt=null;u.target=null;}
  if(!tgt)return false;
  if(inRange(u,tgt)){
    u.path=null;
    u.yaw=Math.atan2(tgt.x-u.x,tgt.z-u.z);
    if(u.cd<=0)doAttack(u,tgt);
    return true;
  }
  if(u.cmd==="attack"||u.cmd==="amove"){
    if(needPath(u,tgt.x,tgt.z))setPathTo(u,tgt.x,tgt.z);
    moveAlong(u,dt);
    return true;
  }
  u.target=null;
  return false;
}
function updateUnit(u,dt){
  if(u.dead)return;
  u.flash=Math.max(0,u.flash-dt);
  u.cd-=dt;
  u.anim+=dt;
  const d=u.d;
  if(d.isAir){
    const gh=gH(u.x,u.z);
    u.y=lerp(u.y,gh+7.5+Math.sin(u.anim*2.2)*0.3,Math.min(1,dt*3));
  }else{
    u.y=gH(u.x,u.z);
  }
  if(d.isWorker&&u.cmd==="gather"){updateGather(u,dt);separate(u,dt);return;}
  if(u.cmd==="build"&&u.buildTgt){updateBuildOrder(u,dt);separate(u,dt);return;}
  if(updateCombat(u,dt)){separate(u,dt);return;}
  if(u.cmd==="move"||u.cmd==="amove"){
    if(u.path){
      const arrived=moveAlong(u,dt);
      if(arrived){u.cmd="hold";u.dest=null;}
    }else if(u.dest){
      /* path lost (blocked/revert) — re-route unless we are basically there */
      if(dist2(u.x,u.z,u.dest.x,u.dest.z)<9){u.cmd="hold";u.dest=null;}
      else setPathTo(u,u.dest.x,u.dest.z);
    }else{u.cmd="hold";}
  }else if(u.cmd==="idle"){
    u.cmd="hold";
  }
  separate(u,dt);
  /* stuck detection */
  if(u.path){
    const moved=dist2(u.x,u.z,u.lx,u.lz);
    if(moved<0.02){u.stuckT+=dt;}else{u.stuckT=0;}
    if(u.stuckT>1.4){
      u.stuckT=0;
      const wp=u.path[u.path.length-1];
      setPathTo(u,wp.x,wp.z);
    }
  }else u.stuckT=0;
  u.lx=u.x; u.lz=u.z;
}

/* ============================================================
   BUILDINGS
   ============================================================ */
function finishBuilding(b){
  b.built=1; b.buildP=1;
  rebuildPathGrid();
  for(const u of units){
    if(u.buildTgt===b){u.buildTgt=null;u.cmd="hold";}
  }
  addEffect(b.x,gH(b.x,b.z)+2,b.z,0.4,0.9,1,0.6,2.4);
  sfx("build",false);
  if(b.team===TEAM_P)toast(b.d.name+" online","good");
}
function updateBuilding(b,dt){
  if(b.dead)return;
  b.flash=Math.max(0,b.flash-dt);
  b.cd-=dt;
  if(!b.built){
    let n=0;
    for(const u of units){
      if(u.dead||u.team!==b.team||!u.d.isWorker||u.buildTgt!==b)continue;
      const reach=b.d.size*CELL*0.5+2.4;
      if(dist2(u.x,u.z,b.x,b.z)<reach*reach)n++;
    }
    if(n>0){
      b.buildP+=dt/b.d.bt*Math.min(n,3)*0.95;
      if(b.buildP>=1)finishBuilding(b);
    }
    return;
  }
  /* production */
  if(b.queue.length){
    const q=b.queue[0];
    const cap=supplyCap(b.team), used=supplyUsed(b.team);
    if(cap<used){
      q.blocked=true;
      if(b.team===TEAM_P&&!b._warned){toast("Supply blocked — build a Supply Relay","bad");b._warned=true;}
    }else{
      q.blocked=false; b._warned=false;
      q.t+=dt;
      if(q.t>=q.bt){
        b.queue.shift();
        if(q.type==="__up"){
          teamState[b.team].upgrades[q.up]=true;
          if(q.up==="armor"){
            for(const u of units)if(u.team===b.team){u.maxHp=u.d.hp*1.2;u.hp*=1.2;}
          }
          if(b.team===TEAM_P){toast(UPGRADES.find(x=>x.key===q.up).name+" complete","good");sfx("build",false);}
          return;
        }
        const ud=UNIT_DEFS[q.type];
        const ang=rnd()*TAU;
        const off=b.d.size*CELL*0.5+1.6;
        let sx=b.x+Math.cos(ang)*off, sz=b.z+Math.sin(ang)*off;
        if(!cellFree((sx/CELL)|0,(sz/CELL)|0)){sx=b.x+off;sz=b.z;}
        const nu=mkUnit(q.type,b.team,clamp(sx,3,MAP-3),clamp(sz,3,MAP-3),rnd()*TAU);
        nu.cmd="hold";
        if(b.rally)orderMove(nu,b.rally.x,b.rally.z);
        if(b.team===TEAM_P)sfx("ready",false);
      }
    }
  }
  /* turret combat */
  if(b.d.dmg){
    let tgt=b.target;
    if(tgt&&tgt.dead)tgt=null;
    if(!tgt){
      let best=null,bd=b.d.range*b.d.range;
      queryUnits(b.x,b.z,b.d.range,(o)=>{
        if(o.team===b.team||o.dead)return;
        if(o.d.isAir&&!b.d.tAir)return;
        if(!o.d.isAir&&!b.d.tGround)return;
        const dd=dist2(b.x,b.z,o.x,o.z);
        if(dd<bd){bd=dd;best=o;}
      });
      tgt=best;
    }
    b.target=tgt;
    if(tgt&&b.cd<=0)buildingAttack(b,tgt);
  }
}

/* ============================================================
   PROJECTILES & EFFECTS
   ============================================================ */
function updateProjectiles(dt){
  for(let i=projectiles.length-1;i>=0;i--){
    const p=projectiles[i];
    p.life-=dt;
    const t=p.tgt;
    if(t&&!t.dead){
      const ty=t.kind==="u"?(t.d.isAir?t.y+0.2:t.y+0.75):1.5;
      const dx=t.x-p.x,dy=ty-p.y,dz=t.z-p.z;
      const dd=Math.hypot(dx,dy,dz)||1;
      if(p.homing){
        const sp=p.speed;
        p.vx=lerp(p.vx,dx/dd*sp,Math.min(1,dt*8));
        p.vy=lerp(p.vy,dy/dd*sp,Math.min(1,dt*8));
        p.vz=lerp(p.vz,dz/dd*sp,Math.min(1,dt*8));
      }
      if(dd<1.1){
        applyDamage(t,p.dmg,p.dt,null);
        addEffect(p.x,p.y,p.z,p.color[0],p.color[1],p.color[2],0.22,p.arc?1.3:0.6);
        if(p.arc)sfx("boom",false);
        projectiles.splice(i,1);
        continue;
      }
    }
    p.x+=p.vx*dt; p.y+=p.vy*dt; p.z+=p.vz*dt;
    if(p.arc)p.vy-=26*dt;
    if(p.life<=0||p.y<gH(p.x,p.z)-1)projectiles.splice(i,1);
  }
}
function updateEffects(dt){
  for(let i=effects.length-1;i>=0;i--){
    const e=effects[i];
    e.life-=dt;
    if(e.life<=0)effects.splice(i,1);
  }
}

/* ============================================================
   MAIN SIM STEP
   ============================================================ */
let compactT=0;
function stepSim(dt){
  gameTime+=dt;
  rebuildHash();
  for(let i=0;i<units.length;i++)updateUnit(units[i],dt);
  for(let i=0;i<buildings.length;i++)updateBuilding(buildings[i],dt);
  updateProjectiles(dt);
  updateEffects(dt);
  visTimer-=dt;
  if(visTimer<=0){visTimer=0.22;updateVision();}
  aiMemoryTimer-=dt;
  if(aiMemoryTimer<=0){aiMemoryTimer=0.5;aiVision();}
  compactT-=dt;
  if(compactT<=0){
    compactT=2.0;
    units=units.filter(u=>!u.dead);
    buildings=buildings.filter(b=>!b.dead);
  }
  updateAI(dt);
  checkVictory();
}
/* ============================================================
   CAMERA
   ============================================================ */
const cam={x:46,z:46,dist:78,yaw:0.75,pitch:0.82,tx:46,tz:46};
let W=1,H=1;
const mVP=new Float32Array(16),mP=new Float32Array(16),mV=new Float32Array(16),mIVP=new Float32Array(16);
function camEye(){
  const cp=Math.cos(cam.pitch),sp=Math.sin(cam.pitch);
  const ty=gH(cam.x,cam.z)+2;
  return [cam.x+Math.sin(cam.yaw)*cp*cam.dist, ty+sp*cam.dist, cam.z+Math.cos(cam.yaw)*cp*cam.dist, ty];
}
function updateCameraMatrices(){
  const e=camEye();
  m4persp(mP,0.62,W/H,1.5,900);
  m4look(mV,e[0],e[1],e[2],cam.x,e[3],cam.z,0,1,0);
  m4mul(mP,mV,mVP);
  m4inv(mVP,mIVP);
}
function unproject(nx,ny,nz){
  const m=mIVP;
  const x=nx,y=ny,z=nz;
  const w=m[3]*x+m[7]*y+m[11]*z+m[15];
  const iw=1/(w||1e-6);
  return [(m[0]*x+m[4]*y+m[8]*z+m[12])*iw,
          (m[1]*x+m[5]*y+m[9]*z+m[13])*iw,
          (m[2]*x+m[6]*y+m[10]*z+m[14])*iw];
}
function screenToGround(sx,sy){
  const nx=(sx/W)*2-1, ny=1-(sy/H)*2;
  const a=unproject(nx,ny,-1), b=unproject(nx,ny,1);
  const dx=b[0]-a[0],dy=b[1]-a[1],dz=b[2]-a[2];
  if(Math.abs(dy)<1e-6)return null;
  let h=2;
  for(let i=0;i<5;i++){
    const t=(h-a[1])/dy;
    if(t<0)return null;
    const x=a[0]+dx*t,z=a[2]+dz*t;
    h=gH(x,z);
    if(i===4)return {x:clamp(x,0,MAP),z:clamp(z,0,MAP)};
  }
  return null;
}
function worldToScreen(x,y,z){
  const m=mVP;
  const w=m[3]*x+m[7]*y+m[11]*z+m[15];
  if(w<=0.001)return null;
  const iw=1/w;
  return {x:(m[0]*x+m[4]*y+m[8]*z+m[12])*iw*0.5*W+0.5*W,
          y:0.5*H-(m[1]*x+m[5]*y+m[9]*z+m[13])*iw*0.5*H, w};
}

/* ============================================================
   INPUT
   ============================================================ */
const keys={};
let mouseX=0,mouseY=0,overHUD=false;
let dragStart=null,dragging=false;
let placing=null;   /* {type, valid} */
let pendingCmd=null; /* 'amove' */
let selBox=document.getElementById("selbox");
let selected=[];
let controlGroups={};

function isHUD(t){
  while(t&&t!==document.body){
    if(t.id==="topbar"||t.id==="bottombar"||t.id==="help"||t.id==="overlay")return true;
    t=t.parentElement;
  }
  return false;
}
canvas.addEventListener("mousemove",e=>{
  mouseX=e.clientX; mouseY=e.clientY;
  if(dragging&&dragStart){
    const x=Math.min(dragStart.x,e.clientX),y=Math.min(dragStart.y,e.clientY);
    const w=Math.abs(e.clientX-dragStart.x),h=Math.abs(e.clientY-dragStart.y);
    if(w>6||h>6){
      selBox.style.display="block";
      selBox.style.left=x+"px";selBox.style.top=y+"px";
      selBox.style.width=w+"px";selBox.style.height=h+"px";
    }
  }
});
window.addEventListener("mouseleave",()=>{overHUD=true;});
canvas.addEventListener("mousedown",e=>{
  if(e.button===0){
    if(placing){tryPlace(mouseX,mouseY);return;}
    if(pendingCmd){issuePending(mouseX,mouseY);return;}
    dragStart={x:e.clientX,y:e.clientY,shift:e.shiftKey};dragging=true;
  }else if(e.button===1){
    dragStart={rot:true,x:e.clientX,y:e.clientY};
  }else if(e.button===2){
    if(placing){placing=null;refreshActions();return;}
    if(pendingCmd){pendingCmd=null;refreshActions();return;}
    issueOrder(mouseX,mouseY);
  }
});
window.addEventListener("mouseup",e=>{
  if(e.button===0&&dragging){
    dragging=false;selBox.style.display="none";
    const w=Math.abs(e.clientX-dragStart.x),h=Math.abs(e.clientY-dragStart.y);
    if(w>6||h>6)boxSelect(Math.min(dragStart.x,e.clientX),Math.min(dragStart.y,e.clientY),w,h,dragStart.shift);
    else pickAt(e.clientX,e.clientY,dragStart.shift);
    dragStart=null;
  }
  if(e.button===1)dragStart=null;
});
canvas.addEventListener("dblclick",e=>{
  const g=screenToGround(e.clientX,e.clientY);
  if(!g)return;
  const u=unitAt(g.x,g.z,2.0,TEAM_P);
  if(u)selectSameType(u);
});
canvas.addEventListener("contextmenu",e=>e.preventDefault());
canvas.addEventListener("wheel",e=>{
  e.preventDefault();
  cam.dist=clamp(cam.dist*(1+Math.sign(e.deltaY)*0.12),26,150);
},{passive:false});
window.addEventListener("keydown",e=>{
  const k=e.key.toLowerCase();
  keys[k]=true;
  if(k==="p"){togglePause();e.preventDefault();return;}
  if(k==="f1"){toggleHelp();e.preventDefault();return;}
  if(k==="m"){toggleMute();e.preventDefault();return;}
  if(k==="escape"){placing=null;pendingCmd=null;refreshActions();return;}
  if(e.key>="1"&&e.key<="9"){
    const n=parseInt(e.key);
    if(e.ctrlKey||e.metaKey){controlGroups[n]=selected.slice();toast("Control group "+n+" set","good");}
    else if(controlGroups[n]&&controlGroups[n].length){
      selected=controlGroups[n].filter(u=>!u.dead);
      refreshSelection();
    }
    e.preventDefault();return;
  }
  if(k==="a"){pendingCmd="amove";refreshActions();e.preventDefault();return;}
  if(k==="s"){cmdStop();e.preventDefault();return;}
  if(k==="h"){cmdHold();e.preventDefault();return;}
  if(k===" "){centerOnSelection();e.preventDefault();return;}
  if(k==="arrowleft"||k==="arrowright"||k==="arrowup"||k==="arrowdown")e.preventDefault();
});
window.addEventListener("keyup",e=>{keys[e.key.toLowerCase()]=false;});
window.addEventListener("blur",()=>{for(const k in keys)keys[k]=false;});

function updateCamera(dt){
  const sp=(30+cam.dist*0.55)*dt;
  const fx=-Math.sin(cam.yaw),fz=-Math.cos(cam.yaw);
  const rx=-Math.cos(cam.yaw),rz=Math.sin(cam.yaw);
  let mx=0,mz=0;
  if(keys["arrowup"]){mx+=fx;mz+=fz;}
  if(keys["arrowdown"]){mx-=fx;mz-=fz;}
  if(keys["arrowleft"]){mx-=rx;mz-=rz;}
  if(keys["arrowright"]){mx+=rx;mz+=rz;}
  if(!overHUD){
    const M=14;
    if(mouseX<M){mx-=rx;mz-=rz;}
    if(mouseX>W-M){mx+=rx;mz+=rz;}
    if(mouseY<M+38){mx+=fx;mz+=fz;}
    if(mouseY>H-M-160){mx-=fx;mz-=fz;}
  }
  const l=Math.hypot(mx,mz);
  if(l>0){cam.x+=mx/l*sp;cam.z+=mz/l*sp;}
  if(keys["q"])cam.yaw+=dt*1.5;
  if(keys["e"])cam.yaw-=dt*1.5;
  if(dragStart&&dragStart.rot){
    cam.yaw-=(mouseX-dragStart.x)*0.006;
    cam.pitch=clamp(cam.pitch+(mouseY-dragStart.y)*0.004,0.35,1.35);
    dragStart.x=mouseX;dragStart.y=mouseY;
  }
  cam.x=clamp(cam.x,0,MAP);cam.z=clamp(cam.z,0,MAP);
}
function centerOnSelection(){
  if(!selected.length)return;
  let x=0,z=0;
  for(const s of selected){x+=s.x;z+=s.z;}
  cam.x=x/selected.length;cam.z=z/selected.length;
}

/* ============================================================
   SELECTION
   ============================================================ */
function pickAt(sx,sy,add){
  let best=null,bestScore=1e9;
  for(const u of units){
    if(u.dead)continue;
    if(u.team!==TEAM_P&&!isVisible(u.x,u.z))continue;
    const s=worldToScreen(u.x,u.y+0.8,u.z);
    if(!s)continue;
    const dx=s.x-sx,dy=s.y-sy;
    const d=Math.hypot(dx,dy);
    const px=Math.max(14,(u.d.radius/s.w)*W*0.55);
    if(d<px&&s.w<bestScore){bestScore=s.w;best=u;}
  }
  if(!best){
    for(const b of buildings){
      if(b.dead)continue;
      if(b.team!==TEAM_P&&!isVisible(b.x,b.z))continue;
      const s=worldToScreen(b.x,gH(b.x,b.z)+1.6,b.z);
      if(!s)continue;
      const dx=s.x-sx,dy=s.y-sy;
      const d=Math.hypot(dx,dy);
      const px=Math.max(20,(b.d.size*CELL*0.5/s.w)*W*0.75);
      if(d<px&&s.w<bestScore){bestScore=s.w;best=b;}
    }
  }
  if(!best){if(!add){selected=[];refreshSelection();}return;}
  if(add){
    const i=selected.indexOf(best);
    if(i>=0)selected.splice(i,1);else selected.push(best);
  }else selected=[best];
  refreshSelection();
}
function boxSelect(x0,y0,w,h,add){
  const x1=x0+w,y1=y0+h;
  const found=[];
  for(const u of units){
    if(u.dead||u.team!==TEAM_P)continue;
    const s=worldToScreen(u.x,u.y+0.8,u.z);
    if(!s)continue;
    if(s.x>=x0&&s.x<=x1&&s.y>=y0&&s.y<=y1)found.push(u);
  }
  if(!found.length){
    for(const b of buildings){
      if(b.dead||b.team!==TEAM_P)continue;
      const s=worldToScreen(b.x,gH(b.x,b.z)+1.6,b.z);
      if(!s)continue;
      if(s.x>=x0&&s.x<=x1&&s.y>=y0&&s.y<=y1)found.push(b);
    }
  }
  if(add)selected=selected.concat(found.filter(f=>selected.indexOf(f)<0));
  else selected=found;
  refreshSelection();
}
function selectSameType(ref){
  if(!ref||ref.kind!=="u")return;
  const vis=[];
  for(const u of units){
    if(u.dead||u.team!==TEAM_P||u.type!==ref.type)continue;
    const s=worldToScreen(u.x,u.y+0.8,u.z);
    if(s&&s.x>0&&s.x<W&&s.y>0&&s.y<H)vis.push(u);
  }
  selected=vis;refreshSelection();
}

/* ============================================================
   ORDERS
   ============================================================ */
function issueOrder(sx,sy){
  if(!selected.length)return;
  const g=screenToGround(sx,sy);
  if(!g)return;
  let node=null;
  for(const n of resNodes){ if(n.amt>0&&dist2(n.x,n.z,g.x,g.z)<(n.r+2.2)*(n.r+2.2)){node=n;break;} }
  const tgtU=unitAt(g.x,g.z,1.6,0);
  const tgtB=buildingAt(g.x,g.z,0);
  let enemy=null;
  if(tgtU&&tgtU.team!==TEAM_P&&isVisible(tgtU.x,tgtU.z))enemy=tgtU;
  else if(tgtB&&tgtB.team!==TEAM_P&&isVisible(tgtB.x,tgtB.z))enemy=tgtB;
  let any=false;
  const workers=selected.filter(s=>s.kind==="u"&&s.d.isWorker);
  const fighters=selected.filter(s=>s.kind==="u"&&!s.d.isWorker);
  const blds=selected.filter(s=>s.kind==="b");
  if(enemy){
    for(const s of fighters)orderAttack(s,enemy);
    for(const s of workers)orderAttack(s,enemy);
    any=true;
  }else if(node&&workers.length){
    for(const s of workers)orderGather(s,node);
    for(const s of fighters)orderMove(s,g.x,g.z);
    any=true;
  }else{
    /* formation offsets */
    const list=selected.filter(s=>s.kind==="u");
    const n=list.length;
    const cols=Math.ceil(Math.sqrt(n));
    list.forEach((s,i)=>{
      const ox=((i%cols)-(cols-1)/2)*2.0;
      const oz=((i/cols|0)-((Math.ceil(n/cols))-1)/2)*2.0;
      orderMove(s,clamp(g.x+ox,2,MAP-2),clamp(g.z+oz,2,MAP-2));
    });
    for(const s of blds){
      s.rally={x:g.x,z:g.z};
      addEffect(g.x,gH(g.x,g.z)+0.4,g.z,0.3,0.9,1,0.7,1.2);
    }
    any=true;
  }
  if(any){
    addEffect(g.x,gH(g.x,g.z)+0.3,g.z,0.35,0.9,1.0,0.5,1.4);
    sfx("order",false);
  }
}
function issuePending(sx,sy){
  const g=screenToGround(sx,sy);
  pendingCmd=null;
  if(!g)return;
  for(const s of selected)if(s.kind==="u")orderAttackMove(s,g.x,g.z);
  addEffect(g.x,gH(g.x,g.z)+0.3,g.z,1,0.4,0.3,0.6,1.6);
  refreshActions();
}
function cmdStop(){
  for(const s of selected)if(s.kind==="u")orderStop(s);
  sfx("order",false);
}
function cmdHold(){
  for(const s of selected)if(s.kind==="u"){s.cmd="hold";s.path=null;s.dest=null;s.target=null;s.node=null;s.buildTgt=null;}
  sfx("order",false);
}
function tryPlace(sx,sy){
  const g=screenToGround(sx,sy);
  if(!g)return;
  const def=BLD_DEFS[placing];
  const snapX=Math.round(g.x/CELL)*CELL, snapZ=Math.round(g.z/CELL)*CELL;
  const ok=canPlace(placing,snapX,snapZ);
  if(!ok){toast("Cannot build there","bad");return;}
  if(teamState[TEAM_P].crystal<def.cost){toast("Not enough crystal","bad");return;}
  teamState[TEAM_P].crystal-=def.cost;
  const b=mkBuilding(placing,TEAM_P,snapX,snapZ,false);
  b.buildP=0.02;
  rebuildPathGrid();
  /* auto-assign nearest idle worker */
  let best=null,bd=1e9;
  for(const u of units){
    if(u.dead||u.team!==TEAM_P||!u.d.isWorker)continue;
    if(u.cmd!=="hold"&&u.cmd!=="idle"&&u.cmd!=="gather")continue;
    const d=dist2(u.x,u.z,snapX,snapZ);
    if(d<bd){bd=d;best=u;}
  }
  if(best)orderBuild(best,b);
  placing=null;
  sfx("place",false);
  refreshActions();
}
function canPlace(type,x,z){
  const def=BLD_DEFS[type];
  const h=def.size*0.5;
  if(x-h<2||z-h<2||x+h>MAP-2||z+h>MAP-2)return false;
  const c0=((x-h)/CELL)|0,c1=((x+h)/CELL)|0,d0=((z-h)/CELL)|0,d1=((z+h)/CELL)|0;
  for(let gz=d0;gz<=d1;gz++)for(let gx=c0;gx<=c1;gx++){
    if(gx<0||gz<0||gx>=GN||gz>=GN)return false;
    if(!passable[gz*GN+gx])return false;
  }
  for(const b of buildings){
    if(b.dead)continue;
    const bh=(b.d.size+def.size)*0.5*CELL*0.5;
    if(Math.abs(b.x-x)<bh&&Math.abs(b.z-z)<bh)return false;
  }
  for(const u of units){
    if(u.dead||u.d.isAir)continue;
    if(Math.abs(u.x-x)<h+0.6&&Math.abs(u.z-z)<h+0.6)return false;
  }
  return true;
}
/* ============================================================
   AUDIO (procedural)
   ============================================================ */
let actx=null,muted=false,sfxBudget=0,sfxFrame=0;
function ensureAudio(){
  if(actx)return true;
  try{actx=new (window.AudioContext||window.webkitAudioContext)();}catch(e){return false;}
  return true;
}
function tone(freq,dur,type,gain,slideTo){
  const t0=actx.currentTime;
  const o=actx.createOscillator(),g=actx.createGain();
  o.type=type||"square";
  o.frequency.setValueAtTime(freq,t0);
  if(slideTo)o.frequency.exponentialRampToValueAtTime(Math.max(20,slideTo),t0+dur);
  g.gain.setValueAtTime(0.0001,t0);
  g.gain.exponentialRampToValueAtTime(gain||0.05,t0+0.008);
  g.gain.exponentialRampToValueAtTime(0.0001,t0+dur);
  o.connect(g);g.connect(actx.destination);o.start(t0);o.stop(t0+dur+0.02);
}
function noiseBurst(dur,gain,filt){
  const n=(actx.sampleRate*dur)|0;
  const buf=actx.createBuffer(1,n,actx.sampleRate);
  const d=buf.getChannelData(0);
  for(let i=0;i<n;i++)d[i]=(Math.random()*2-1)*Math.pow(1-i/n,2.2);
  const s=actx.createBufferSource();s.buffer=buf;
  const f=actx.createBiquadFilter();f.type="lowpass";f.frequency.value=filt||900;
  const g=actx.createGain();g.gain.value=gain||0.08;
  s.connect(f);f.connect(g);g.connect(actx.destination);s.start();
}
function sfx(type,big){
  if(muted||!running)return;
  if(sfxFrame>6)return;
  sfxFrame++;
  if(!ensureAudio())return;
  switch(type){
    case "shot": tone(680,0.05,"square",0.018,300);break;
    case "boom": noiseBurst(big?0.55:0.22,big?0.22:0.09,big?520:1400);break;
    case "hit": tone(220,0.05,"sawtooth",0.02,120);break;
    case "explode": noiseBurst(big?0.8:0.35,big?0.3:0.12,big?380:1100);if(big)tone(90,0.5,"sine",0.14,40);break;
    case "order": tone(880,0.05,"sine",0.03,1200);break;
    case "place": tone(300,0.14,"triangle",0.06,600);break;
    case "build": tone(440,0.12,"sine",0.05,880);setTimeout(()=>{if(actx&&!muted)tone(660,0.16,"sine",0.05,1100);},110);break;
    case "ready": tone(520,0.09,"sine",0.05,780);break;
    case "deposit": tone(1200,0.05,"sine",0.022,1500);break;
    case "alarm": tone(300,0.35,"sawtooth",0.07,180);break;
    case "win": tone(523,0.3,"sine",0.09);setTimeout(()=>tone(659,0.3,"sine",0.09),180);setTimeout(()=>tone(784,0.6,"sine",0.1),360);break;
    case "lose": tone(330,0.5,"sawtooth",0.09,110);setTimeout(()=>tone(220,0.9,"sawtooth",0.09,80),300);break;
  }
}
function toggleMute(){
  muted=!muted;
  document.getElementById("bMute").textContent="SOUND: "+(muted?"OFF":"ON");
  if(!muted)ensureAudio();
}

/* ============================================================
   TOASTS
   ============================================================ */
let toastCount=0;
function toast(msg,kind){
  const w=document.getElementById("warn");
  if(w.childElementCount>4)return;
  const d=document.createElement("div");
  d.className="toast"+(kind==="good"?" good":"");
  d.textContent=msg;
  w.appendChild(d);
  setTimeout(()=>d.remove(),3000);
}

/* ============================================================
   UI: SELECTION PANEL
   ============================================================ */
const elTitle=document.getElementById("seltitle"),elMeta=document.getElementById("selmeta"),elBody=document.getElementById("selbody");
const elActions=document.getElementById("actions");
let actionButtons=[];
function refreshSelection(){
  const n=selected.length;
  if(!n){elTitle.textContent="NO SELECTION";elMeta.textContent="";elBody.innerHTML="";refreshActions();return;}
  if(n===1){
    const s=selected[0];
    elTitle.textContent=s.d.name;
    elMeta.textContent=s.kind==="u"?s.d.role:"Structure";
  }else{
    const kinds={};
    for(const s of selected)kinds[s.d.name]=(kinds[s.d.name]||0)+1;
    elTitle.textContent=n+" UNITS SELECTED";
    elMeta.textContent=Object.keys(kinds).map(k=>kinds[k]+"× "+k).join(", ").slice(0,90);
  }
  elBody.innerHTML="";
  if(n<=14){
    for(const s of selected){
      const c=document.createElement("div");c.className="ucard";
      const r=document.createElement("div");r.className="urow";
      const nm=document.createElement("span");nm.textContent=s.d.name;
      const hp=document.createElement("span");hp.textContent=Math.max(0,Math.ceil(s.hp))+" / "+Math.ceil(s.maxHp);
      r.appendChild(nm);r.appendChild(hp);
      const bar=document.createElement("div");bar.className="bar hp";
      const i=document.createElement("i");i.style.width=(clamp(s.hp/s.maxHp,0,1)*100)+"%";
      bar.appendChild(i);
      c.appendChild(r);c.appendChild(bar);
      if(s.kind==="b"&&s.queue.length){
        for(const q of s.queue.slice(0,4)){
          const qr=document.createElement("div");qr.className="urow";
          const qn=q.type==="__up"?UPGRADES.find(x=>x.key===q.up).name:UNIT_DEFS[q.type].name;
          qr.innerHTML="<span>"+qn+(q.blocked?" (blocked)":"")+"</span><span>"+Math.floor(q.t/q.bt*100)+"%</span>";
          const qb=document.createElement("div");qb.className="bar pr";
          const qi=document.createElement("i");qi.style.width=(q.t/q.bt*100)+"%";qb.appendChild(qi);
          c.appendChild(qr);c.appendChild(qb);
        }
      }
      if(s.kind==="b"&&!s.built){
        const qr=document.createElement("div");qr.className="urow";
        qr.innerHTML="<span>Constructing</span><span>"+Math.floor(s.buildP*100)+"%</span>";
        const qb=document.createElement("div");qb.className="bar rs";
        const qi=document.createElement("i");qi.style.width=(s.buildP*100)+"%";qb.appendChild(qi);
        c.appendChild(qr);c.appendChild(qb);
      }
      elBody.appendChild(c);
    }
  }else{
    const groups={};
    for(const s of selected){
      const k=s.d.name;
      if(!groups[k])groups[k]={def:s.d,list:[],kind:s.kind};
      groups[k].list.push(s);
    }
    for(const k in groups){
      const g=groups[k];
      let hp=0,mhp=0;
      for(const s of g.list){hp+=Math.max(0,s.hp);mhp+=s.maxHp;}
      const c=document.createElement("div");c.className="ucard";
      const r=document.createElement("div");r.className="urow";
      r.innerHTML="<span>"+g.list.length+"× "+k+"</span><span>"+Math.ceil(hp)+" / "+Math.ceil(mhp)+"</span>";
      const bar=document.createElement("div");bar.className="bar hp";
      const i=document.createElement("i");i.style.width=(clamp(hp/mhp,0,1)*100)+"%";
      bar.appendChild(i);c.appendChild(r);c.appendChild(bar);
      elBody.appendChild(c);
    }
  }
  refreshActions();
}
function mkBtn(label,sub,hot,enabled,onClick,title){
  const b=document.createElement("div");
  b.className="abtn"+(enabled?"":" dis");
  b.title=title||"";
  b.innerHTML='<span class="hk">'+(hot||"")+'</span><span class="an">'+label+'</span>'+
    (sub?'<span class="ac">'+sub+'</span>':'')+'<span class="pg"></span>';
  if(enabled)b.addEventListener("mousedown",ev=>{ev.stopPropagation();onClick();});
  elActions.appendChild(b);
  return b;
}
function refreshActions(){
  elActions.innerHTML="";actionButtons=[];
  if(!selected.length){
    elActions.innerHTML='<div style="font-size:12px;color:#5d8aa5;padding:8px;line-height:1.6">'+
      'Select a unit or building.<br>Drag to box-select · Shift adds · Right-click orders.<br>'+
      'Keys: <b>A</b> attack-move · <b>S</b> stop · <b>H</b> hold · <b>Ctrl+1-9</b> groups</div>';
    return;
  }
  const uSel=selected.filter(s=>s.kind==="u");
  const bSel=selected.filter(s=>s.kind==="b");
  const cry=teamState[TEAM_P].crystal;
  if(uSel.length){
    mkBtn("ATTACK-MOVE","A","A",true,()=>{pendingCmd="amove";toast("Click a destination","good");},"Move and engage anything encountered");
    mkBtn("STOP","S","S",true,cmdStop,"Cancel current orders");
    mkBtn("HOLD","H","H",true,cmdHold,"Hold position, fire only in range");
  }
  if(uSel.some(s=>s.d.isWorker)){
    const list=[
      ["hq","Command Nexus",500,"Q"],["depot","Supply Relay",110,"W"],
      ["barracks","Foundry",220,"E"],["airpad","Aerie",260,"R"],
      ["lab","Research Spire",260,"T"],["turret","Sentry Gun",150,"Y"]
    ];
    for(const [t,nm,cost,hk] of list){
      const def=BLD_DEFS[t];
      const pre=def.prereq&&!hasBuilding(TEAM_P,def.prereq);
      const ok=cry>=cost&&!pre;
      const b=mkBtn(nm,cost,hk,ok,()=>{placing=t;pendingCmd=null;refreshActions();},
        pre?("Requires "+BLD_DEFS[def.prereq].name):def.desc);
      if(ok)b.addEventListener("mouseenter",()=>{});
    }
  }
  for(const b of bSel){
    if(b.d.produces&&b.d.produces.length){
      const hk=["Q","W","E","R"];
      b.d.produces.forEach((t,i)=>{
        const ud=UNIT_DEFS[t];
        const ok=cry>=ud.cost;
        mkBtn(ud.name,ud.cost,hk[i],ok,()=>queueUnit(b,t),
          ud.desc+" · "+ud.supply+" supply · "+ud.bt+"s");
      });
    }
    if(b.type==="lab"){
      const hk=["Q","W","E","R"];
      UPGRADES.forEach((u,i)=>{
        const done=teamState[TEAM_P].upgrades[u.key];
        const ok=!done&&cry>=u.cost&&!b.queue.length;
        mkBtn(u.name,u.cost,hk[i],ok,()=>queueUpgrade(b,u),
          done?"Already researched":u.desc);
      });
    }
    if(b.queue.length){
      mkBtn("CANCEL","queue","X",true,()=>cancelQueue(b),"Refund the first queued item (75%)");
    }
  }
}
function queueUnit(b,type){
  const ud=UNIT_DEFS[type];
  if(teamState[b.team].crystal<ud.cost){toast("Not enough crystal","bad");return;}
  if(b.queue.length>=8){toast("Queue full","bad");return;}
  teamState[b.team].crystal-=ud.cost;
  b.queue.push({type,t:0,bt:ud.bt,cost:ud.cost,blocked:false});
  if(b.team===TEAM_P){sfx("order",false);refreshSelection();}
}
function queueUpgrade(b,u){
  if(teamState[b.team].crystal<u.cost)return;
  teamState[b.team].crystal-=u.cost;
  b.queue.push({type:"__up",up:u.key,t:0,bt:u.bt,cost:u.cost,blocked:false});
  if(b.team===TEAM_P)refreshSelection();
}
function cancelQueue(b){
  if(!b.queue.length)return;
  const q=b.queue.shift();
  teamState[b.team].crystal+=Math.floor(q.cost*0.75);
  refreshSelection();
}
function updateTopBar(){
  document.getElementById("sCry").textContent=Math.floor(teamState[TEAM_P].crystal);
  const cap=supplyCap(TEAM_P),used=supplyUsed(TEAM_P);
  const sup=document.getElementById("sSup");
  sup.textContent=used+" / "+cap;
  sup.style.color=used>cap?"#ff7a6a":(used>cap*0.9?"#ffd479":"#eaf7ff");
  let n=0;
  for(const u of units)if(!u.dead&&u.team===TEAM_P)n++;
  document.getElementById("sPop").innerHTML="UNITS <b>"+n+"</b>";
  const t=Math.floor(gameTime);
  document.getElementById("sTime").textContent=String((t/60)|0).padStart(2,"0")+":"+String(t%60).padStart(2,"0");
}

/* ============================================================
   MINIMAP
   ============================================================ */
const mm=document.getElementById("minimap"),mmc=mm.getContext("2d");
const MMN=128,MMS=150/MAP;
let mmBase=null;
const fogCanvas=document.createElement("canvas");fogCanvas.width=MMN;fogCanvas.height=MMN;
const fogCtx=fogCanvas.getContext("2d");
function buildMinimapBase(){
  mmBase=document.createElement("canvas");mmBase.width=MMN;mmBase.height=MMN;
  const c=mmBase.getContext("2d");
  const img=c.createImageData(MMN,MMN);
  for(let z=0;z<MMN;z++)for(let x=0;x<MMN;x++){
    const wx=x*2,wz=z*2,h=terrainH(wx,wz);
    const t=clamp((h+1)/9,0,1);
    let r=(0.16+0.10*t)*255,g=(0.20+0.16*t)*255,b=(0.22+0.10*t)*255;
    if(h>6){r=100;g=105;b=114;}
    const i=(z*MMN+x)*4;
    img.data[i]=r;img.data[i+1]=g;img.data[i+2]=b;img.data[i+3]=255;
  }
  c.putImageData(img,0,0);
}
function updateFogCanvas(){
  const img=fogCtx.createImageData(MMN,MMN);
  for(let i=0;i<MMN*MMN;i++){
    const v=fogVis[i],e=fogExp[i];
    let a;
    if(v)a=0;else if(e)a=190;else a=252;
    img.data[i*4]=4;img.data[i*4+1]=8;img.data[i*4+2]=14;img.data[i*4+3]=a;
  }
  fogCtx.putImageData(img,0,0);
}
function drawMinimap(){
  mmc.clearRect(0,0,150,150);
  mmc.drawImage(mmBase,0,0,150,150);
  mmc.drawImage(fogCanvas,0,0,150,150);
  for(const n of resNodes){
    if(n.amt<=0||!isExplored(n.x,n.z))continue;
    mmc.fillStyle="#5fe0ff";
    mmc.fillRect(n.x*MMS-1.5,n.z*MMS-1.5,3,3);
  }
  for(const b of buildings){
    if(b.dead)continue;
    if(b.team===TEAM_P){mmc.fillStyle="#3fd0ff";mmc.fillRect(b.x*MMS-3,b.z*MMS-3,6,6);}
    else if(isVisible(b.x,b.z)){mmc.fillStyle="#ff5533";mmc.fillRect(b.x*MMS-3,b.z*MMS-3,6,6);}
  }
  for(const u of units){
    if(u.dead)continue;
    if(u.team===TEAM_P){mmc.fillStyle="#9fe8ff";mmc.fillRect(u.x*MMS-1,u.z*MMS-1,2,2);}
    else if(isVisible(u.x,u.z)){mmc.fillStyle="#ff8866";mmc.fillRect(u.x*MMS-1,u.z*MMS-1,2,2);}
  }
  /* camera footprint */
  const pts=[];
  for(const p of [[0,0],[W,0],[W,H],[0,H]]){
    const g=screenToGround(p[0],p[1]);
    if(g)pts.push([g.x*MMS,g.z*MMS]);
  }
  if(pts.length===4){
    mmc.strokeStyle="rgba(180,240,255,.85)";mmc.lineWidth=1;
    mmc.beginPath();mmc.moveTo(pts[0][0],pts[0][1]);
    for(let i=1;i<4;i++)mmc.lineTo(pts[i][0],pts[i][1]);
    mmc.closePath();mmc.stroke();
  }
  /* selection */
  mmc.fillStyle="#ffffff";
  for(const s of selected)mmc.fillRect(s.x*MMS-1,s.z*MMS-1,2,2);
}
function mmCoords(e){
  const r=mm.getBoundingClientRect();
  return {x:clamp((e.clientX-r.left)/r.width*MAP,0,MAP), z:clamp((e.clientY-r.top)/r.height*MAP,0,MAP)};
}
mm.addEventListener("mousedown",e=>{
  e.preventDefault();
  const g=mmCoords(e);
  if(e.button===0){cam.x=g.x;cam.z=g.z;}
  else if(e.button===2){
    const savedX=mouseX,savedY=mouseY;
    const s=worldToScreen(g.x,gH(g.x,g.z)+1,g.z);
    if(s){mouseX=s.x;mouseY=s.y;issueOrder(s.x,s.y);mouseX=savedX;mouseY=savedY;}
  }
});
mm.addEventListener("contextmenu",e=>e.preventDefault());

/* ============================================================
   RENDER
   ============================================================ */
const fxc=document.getElementById("fx"),fxx=fxc.getContext("2d");
const _wm=new Float32Array(16),_im=new Float32Array(16);
function setWorld(x,y,z,yaw){
  const c=Math.cos(yaw),s=Math.sin(yaw);
  _wm[0]=c;_wm[1]=0;_wm[2]=-s;_wm[3]=0;
  _wm[4]=0;_wm[5]=1;_wm[6]=0;_wm[7]=0;
  _wm[8]=s;_wm[9]=0;_wm[10]=c;_wm[11]=0;
  _wm[12]=x;_wm[13]=y;_wm[14]=z;_wm[15]=1;
}
function mulW(p,o){
  const c=_wm[0],s=_wm[8],tx=_wm[12],ty=_wm[13],tz=_wm[14];
  for(let i=0;i<4;i++){
    const p0=p[i*4],p1=p[i*4+1],p2=p[i*4+2],p3=p[i*4+3];
    o[i*4+0]=c*p0+s*p2;
    o[i*4+1]=p1;
    o[i*4+2]=-s*p0+c*p2;
    o[i*4+3]=p3;
  }
  o[12]+=tx;o[13]+=ty;o[14]+=tz;
}
function partColor(team,pc){
  if(pc==="team")return TEAMCOL[team];
  return COL[pc]||COL.metal;
}
function drawParts(parts,team,flash,scaleY){
  for(let i=0;i<parts.length;i++){
    const pt=parts[i];
    mulW(pt.m,_im);
    if(scaleY!==1){_im[5]*=scaleY;_im[13]*=scaleY;}
    const c=partColor(team,pt.c);
    let r=c[0],g=c[1],b=c[2];
    if(flash>0){r=lerp(r,1,flash);g=lerp(g,1,flash);b=lerp(b,1,flash);}
    BATCHES[pt.g].add(_im,r,g,b,0);
  }
}
const rockInst=[];
function buildRockInstances(){
  rockInst.length=0;
  for(const r of rocks){
    if(!r.deco)continue;
    const m=partM([GBOX,0,r.sz*0.5,0, r.sz*1.1,r.sz,r.sz*1.1, r.ry]);
    rockInst.push({g:GBOX,m,x:r.x,z:r.z,c:[0.30,0.31,0.34]});
    if(rnd()<0.6){
      const m2=partM([GCONE,r.sz*0.3,r.sz*0.9,r.sz*0.2, r.sz*0.7,r.sz*0.9,r.sz*0.7, r.ry]);
      rockInst.push({g:GCONE,m:m2,x:r.x,z:r.z,c:[0.36,0.37,0.41]});
    }
  }
}
function buildScene(){
  for(const b of BATCHES)b.reset();
  const t=performance.now()*0.001;
  for(const r of rockInst){
    if(!isExplored(r.x,r.z))continue;
    BATCHES[r.g].add(r.m,r.c[0],r.c[1],r.c[2],0);
  }
  /* resource nodes */
  for(const n of resNodes){
    if(n.amt<=0||!isExplored(n.x,n.z))continue;
    const f=clamp(n.amt/n.max,0.15,1);
    const y=gH(n.x,n.z);
    for(const c of n.crystals){
      setWorld(n.x+c.dx,y,n.z+c.dz,c.ry);
      const hh=c.h*f;
      const col=n.amt/n.max>0.5?COL.crystal:COL.crystal2;
      /* faceted prism body */
      let m=partM([GCYL,0,hh*0.32,0, c.r,hh*0.64,c.r, 0]);
      mulW(m,_im);
      BATCHES[GCYL].add(_im,col[0]*0.85,col[1]*0.85,col[2]*0.9,0.22);
      /* bright tip */
      m=partM([GCONE,0,hh*0.64+hh*0.26,0, c.r*0.98,hh*0.55,c.r*0.98, 0]);
      mulW(m,_im);
      BATCHES[GCONE].add(_im,col[0],col[1],col[2],0.42);
    }
    /* faint ground glow */
    const gm=partM([GDISC,n.x,y+0.12,n.z, 5.2,1,5.2, 0]);
    BATCHES[GDISC].add(gm,0.13,0.52,0.70,0.42);
  }
  /* buildings */
  for(const b of buildings){
    if(b.dead)continue;
    let ghost=false;
    if(b.team!==TEAM_P){
      if(isVisible(b.x,b.z)){}
      else if(knownEnemyBld.has(b.id)){ghost=true;}
      else continue;
    }
    const y=gH(b.x,b.z);
    setWorld(b.x,y,b.z,b.yaw);
    const sy=b.built?1:(0.22+0.78*b.buildP);
    if(ghost){
      for(const pt of b.d._parts){
        mulW(pt.m,_im);
        _im[5]*=sy;_im[13]*=sy;
        BATCHES[pt.g].add(_im,0.10,0.13,0.19,1);
      }
      continue;
    }
    drawParts(b.d._parts,b.team,b.flash,sy);
    if(!b.built){
      const m=partM([GBOX,0,1.2,0, b.d.size*CELL*0.95,2.4,b.d.size*CELL*0.95,0]);
      BATCHES[GBOX].add(m,0.15,0.55,0.75,0.85);
    }
  }
  /* units */
  for(const u of units){
    if(u.dead)continue;
    if(u.team!==TEAM_P&&!isVisible(u.x,u.z))continue;
    setWorld(u.x,u.y,u.z,u.yaw);
    const bob=u.d.isAir?0:0;
    drawParts(u.d._parts,u.team,u.flash,1);
  }
  /* projectiles */
  for(const p of projectiles){
    const m=partM([GSPH,p.x,p.y,p.z, p.arc?0.34:0.22,p.arc?0.34:0.22,p.arc?0.34:0.22,0]);
    BATCHES[GSPH].add(m,p.color[0],p.color[1],p.color[2],1);
    if(!p.arc){
      const m2=partM([GCYLZ,p.x-p.vx*0.02,p.y,p.z-p.vz*0.02, 0.10,0.10,0.9,0]);
      BATCHES[GCYLZ].add(m2,p.color[0],p.color[1],p.color[2],1);
    }
  }
  /* effects */
  for(const e of effects){
    const k=1-e.life/e.max;
    const s=e.size*(0.5+k*1.6);
    const m=partM([GSPH,e.x,e.y,e.z, s,s,s,0]);
    const a=clamp(e.life/e.max,0,1);
    BATCHES[GSPH].add(m,e.r,e.g,e.b,a*0.9+0.1);
  }
  /* selection rings */
  for(const s of selected){
    if(s.dead)continue;
    const y=gH(s.x,s.z)+0.09;
    const sc=s.kind==="u"?s.d.radius*2.6:s.d.size*CELL*0.62;
    const m=partM([GRING,s.x,y,s.z, sc,1,sc,0]);
    const c=TEAMCOL[TEAM_P];
    BATCHES[GRING].add(m,c[0],c[1],c[2],1);
  }
  /* order destinations */
  for(const s of selected){
    if(s.dead||s.kind!=="u"||!s.dest)continue;
    const y=gH(s.dest.x,s.dest.z)+0.09;
    const m=partM([GRING,s.dest.x,y,s.dest.z, 1.6,1,1.6,0]);
    BATCHES[GRING].add(m,0.35,0.95,1.0,1);
  }
  /* placement preview */
  if(placing){
    const g=screenToGround(mouseX,mouseY);
    if(g){
      const sx=Math.round(g.x/CELL)*CELL, sz=Math.round(g.z/CELL)*CELL;
      const ok=canPlace(placing,sx,sz)&&teamState[TEAM_P].crystal>=BLD_DEFS[placing].cost;
      const def=BLD_DEFS[placing];
      setWorld(sx,gH(sx,sz),sz,0);
      for(const pt of def._parts){
        mulW(pt.m,_im);
        if(ok)BATCHES[pt.g].add(_im,0.25,0.95,0.55,0.85);
        else BATCHES[pt.g].add(_im,1.0,0.25,0.2,0.85);
      }
      const m=partM([GBOX,sx,gH(sx,sz)+0.06,sz, def.size*CELL,0.12,def.size*CELL,0]);
      BATCHES[GBOX].add(m,ok?0.2:1.0,ok?0.9:0.2,ok?0.5:0.2,0.9);
    }
  }
  if(pendingCmd){
    const g=screenToGround(mouseX,mouseY);
    if(g){
      const m=partM([GRING,g.x,gH(g.x,g.z)+0.1,g.z, 3.0,1,3.0,0]);
      BATCHES[GRING].add(m,1.0,0.35,0.3,1);
    }
  }
}
function render(){
  if(!terrain)return;
  gl.viewport(0,0,canvas.width,canvas.height);
  gl.clearColor(0.02,0.03,0.05,1);
  gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);
  updateCameraMatrices();
  buildScene();
  gl.useProgram(terrProg);
  gl.uniformMatrix4fv(TU.VP,false,mVP);
  gl.uniform3f(TU.L,0.42,0.84,0.34);
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_2D,fogTexObj);
  gl.uniform1i(TU.F,0);
  gl.enable(gl.CULL_FACE);
  gl.bindVertexArray(terrain.vao);
  gl.drawElements(gl.TRIANGLES,terrain.count,gl.UNSIGNED_INT,0);
  gl.useProgram(instProg);
  gl.uniformMatrix4fv(IU.VP,false,mVP);
  gl.uniform3f(IU.L,0.42,0.84,0.34);
  const e=camEye();
  gl.uniform3f(IU.C,e[0],e[1],e[2]);
  gl.disable(gl.CULL_FACE);
  for(const b of BATCHES)b.flush();
  gl.enable(gl.CULL_FACE);
}
function drawOverlay(){
  fxx.clearRect(0,0,W,H);
  const drawBar=(x,y,w,frac,col)=>{
    fxx.fillStyle="rgba(0,0,0,.62)";fxx.fillRect(x-1,y-1,w+2,5);
    fxx.fillStyle=col;fxx.fillRect(x,y,w*clamp(frac,0,1),3);
  };
  const list=[];
  for(const u of units){
    if(u.dead)continue;
    const friendly=u.team===TEAM_P;
    if(!friendly&&!isVisible(u.x,u.z))continue;
    if(friendly&&u.hp>=u.maxHp&&selected.indexOf(u)<0)continue;
    if(!friendly&&u.hp>=u.maxHp&&!friendly)continue;
    list.push(u);
  }
  for(const b of buildings){
    if(b.dead)continue;
    if(b.team!==TEAM_P&&!isVisible(b.x,b.z))continue;
    if(b.team===TEAM_P&&b.built&&b.hp>=b.maxHp&&selected.indexOf(b)<0)continue;
    list.push(b);
  }
  let count=0;
  for(const o of list){
    if(count>260)break;
    const yTop=o.kind==="u"?(o.d.isAir?o.y+1.3:o.y+1.35):gH(o.x,o.z)+o.d.size*CELL*0.55+1.0;
    const s=worldToScreen(o.x,yTop,o.z);
    if(!s||s.w>260)continue;
    count++;
    const w=o.kind==="u"?Math.max(16,26/s.w*14):Math.max(24,o.d.size*CELL/s.w*14);
    const frac=o.hp/o.maxHp;
    const col=o.team===TEAM_P?(frac>0.5?"#3fe08a":"#e0c03f"):"#ff5533";
    drawBar(s.x-w/2,s.y,w,frac,col);
  }
  /* selection markers */
  fxx.strokeStyle="rgba(120,230,255,.9)";
  fxx.lineWidth=1;
  for(const s of selected){
    if(s.dead)continue;
    const s2=worldToScreen(s.x,s.kind==="u"?s.y+0.1:gH(s.x,s.z)+0.1,s.z);
    if(!s2)continue;
    fxx.beginPath();fxx.arc(s2.x,s2.y,3,0,TAU);fxx.stroke();
  }
}
/* ============================================================
   AI OPPONENT
   ============================================================ */
const aiExp=new Uint8Array(FOGN*FOGN);
const aiVis=new Uint8Array(FOGN*FOGN);
function aiVisible(x,z){
  const cx=(x/FOGC)|0, cz=(z/FOGC)|0;
  if(cx<0||cz<0||cx>=FOGN||cz>=FOGN)return false;
  return aiVis[cz*FOGN+cx]>0;
}
function markExpArr(x,z,r,arr){
  const r2=r*r;
  const x0=clamp(((x-r)/FOGC)|0,0,FOGN-1),x1=clamp(((x+r)/FOGC)|0,0,FOGN-1);
  const z0=clamp(((z-r)/FOGC)|0,0,FOGN-1),z1=clamp(((z+r)/FOGC)|0,0,FOGN-1);
  for(let cz=z0;cz<=z1;cz++){
    const wz=cz*FOGC+FOGC*0.5,dz=wz-z;
    for(let cx=x0;cx<=x1;cx++){
      const wx=cx*FOGC+FOGC*0.5,dx=wx-x;
      if(dx*dx+dz*dz<=r2)arr[cz*FOGN+cx]=1;
    }
  }
}
const ai={think:1.5,attackT:75,waveSize:6,wave:0,expandT:120,scoutT:8,aggro:0};
function aiBase(){
  let x=0,z=0,n=0;
  for(const b of buildings)if(!b.dead&&b.team===TEAM_A&&b.type==="hq"){x+=b.x;z+=b.z;n++;}
  if(!n)return {x:START_A.x,z:START_A.z};
  return {x:x/n,z:z/n};
}
function aiPlace(type,cx,cz,rad){
  for(let i=0;i<90;i++){
    const a=rnd()*TAU,r=rrange(5,rad);
    const x=clamp(cx+Math.cos(a)*r,12,MAP-12);
    const z=clamp(cz+Math.sin(a)*r,12,MAP-12);
    const sx=Math.round(x/CELL)*CELL,sz=Math.round(z/CELL)*CELL;
    if(canPlace(type,sx,sz))return {x:sx,z:sz};
  }
  return null;
}
function aiBuild(type,cx,cz,rad){
  const def=BLD_DEFS[type];
  if(teamState[TEAM_A].crystal<def.cost)return null;
  const p=aiPlace(type,cx,cz,rad);
  if(!p)return null;
  teamState[TEAM_A].crystal-=def.cost;
  const b=mkBuilding(type,TEAM_A,p.x,p.z,false);
  b.buildP=0.02;
  rebuildPathGrid();
  let best=null,bd=1e9;
  for(const u of units){
    if(u.dead||u.team!==TEAM_A||!u.d.isWorker)continue;
    if(u.cmd==="build")continue;
    const d=dist2(u.x,u.z,p.x,p.z);
    if(d<bd){bd=d;best=u;}
  }
  if(best)orderBuild(best,b);
  return b;
}
function aiPickTarget(){
  const base=aiBase();
  let best=null,bd=1e9;
  for(const [id,rec] of aiKnownBld){
    const d=dist2(rec.x,rec.z,base.x,base.z);
    if(d<bd){bd=d;best=rec;}
  }
  return best;
}
function aiRandomUnexplored(){
  for(let i=0;i<40;i++){
    const cx=(rnd()*FOGN)|0,cz=(rnd()*FOGN)|0;
    if(!aiExp[cz*FOGN+cx])return {x:cx*FOGC+FOGC/2,z:cz*FOGC+FOGC/2};
  }
  return {x:rrange(20,MAP-20),z:rrange(20,MAP-20)};
}
function updateAI(dt){
  if(gameOver)return;
  ai.think-=dt;
  if(ai.think>0)return;
  ai.think=1.0;
  aiThink();
}
function aiThink(){
  const T=TEAM_A,S=teamState[T];
  const myU=[],workers=[],army=[];
  for(const u of units){
    if(u.dead||u.team!==T)continue;
    myU.push(u);
    if(u.d.isWorker)workers.push(u);else army.push(u);
  }
  const myB=[],hqs=[],barracks=[],airpads=[],labs=[],depots=[],turrets=[];
  for(const b of buildings){
    if(b.dead||b.team!==T)continue;
    myB.push(b);
    if(b.type==="hq")hqs.push(b);
    else if(b.type==="barracks")barracks.push(b);
    else if(b.type==="airpad")airpads.push(b);
    else if(b.type==="lab")labs.push(b);
    else if(b.type==="depot")depots.push(b);
    else if(b.type==="turret")turrets.push(b);
  }
  const base=aiBase();
  const cap=supplyCap(T),used=supplyUsed(T);
  /* --- worker assignment --- */
  for(const w of workers){
    if(w.cmd==="hold"||w.cmd==="idle"){
      if(w.carry>0){w.cmd="gather";}
      else{const n=nearestRes(w.x,w.z,120);if(n)orderGather(w,n);}
    }
  }
  /* --- economy --- */
  const targetWorkers=Math.min(34,6+hqs.length*5);
  const hqIdle=hqs.some(h=>h.built&&h.queue.length===0);
  if(workers.length<targetWorkers&&hqIdle&&S.crystal>=50){
    const h=hqs.find(x=>x.built&&x.queue.length===0);
    if(h)queueUnit(h,"hauler");
  }
  /* supply */
  if(cap-used<8&&S.crystal>=110){
    const d=aiBuild("depot",base.x,base.z,26);
    if(!d&&S.crystal>=200){/* retry later */}
  }
  /* production chain */
  if(!barracks.length&&S.crystal>=220)aiBuild("barracks",base.x,base.z,24);
  if(barracks.length&&!labs.length&&S.crystal>=460)aiBuild("lab",base.x,base.z,26);
  if(barracks.length&&!airpads.length&&S.crystal>=560)aiBuild("airpad",base.x,base.z,28);
  if(barracks.length>=1&&barracks.length<3&&S.crystal>=700&&workers.length>=10)aiBuild("barracks",base.x,base.z,30);
  if(turrets.length<3+hqs.length&&S.crystal>=420&&barracks.length)aiBuild("turret",base.x,base.z,30);
  /* expansion */
  ai.expandT-=1;
  if(ai.expandT<=0&&S.crystal>=900&&workers.length>=10){
    let bestNode=null,bd=1e9;
    for(const n of resNodes){
      if(n.amt<600)continue;
      let near=false;
      for(const h of hqs)if(dist2(h.x,h.z,n.x,n.z)<900)near=true;
      if(near)continue;
      const d=dist2(n.x,n.z,base.x,base.z);
      if(d<bd){bd=d;bestNode=n;}
    }
    if(bestNode){
      const b=aiBuild("hq",bestNode.x,bestNode.z,14);
      if(b){ai.expandT=150;if(isExplored(b.x,b.z))toast("Enemy expansion detected","bad");}
      else ai.expandT=40;
    }else ai.expandT=60;
  }
  /* --- production --- */
  for(const b of barracks){
    if(!b.built||b.queue.length>=3)continue;
    const r=rnd();
    let t="trooper";
    if(r<0.34)t="trooper";
    else if(r<0.72)t="ravager";
    else t="bastion";
    if(S.crystal>=UNIT_DEFS[t].cost)queueUnit(b,t);
  }
  for(const b of airpads){
    if(!b.built||b.queue.length>=2)continue;
    if(S.crystal>=UNIT_DEFS.wasp.cost)queueUnit(b,"wasp");
  }
  for(const b of labs){
    if(!b.built||b.queue.length)continue;
    const order=["weapon","armor","logi","servo"];
    for(const k of order){
      if(!S.upgrades[k]){
        const u=UPGRADES.find(x=>x.key===k);
        if(S.crystal>=u.cost+120){queueUpgrade(b,u);}
        break;
      }
    }
  }
  /* --- defence --- */
  let threat=null;
  for(const b of myB){
    if(gameTime-b.lastHit<6){threat=b;break;}
  }
  if(threat){
    for(const u of army){
      if(u.cmd==="hold"||u.cmd==="idle"){
        orderAttackMove(u,threat.x+rrange(-6,6),threat.z+rrange(-6,6));
      }
    }
    ai.attackT=Math.max(ai.attackT,30);
    return;
  }
  /* --- scouting --- */
  ai.scoutT-=1;
  if(ai.scoutT<=0){
    ai.scoutT=14;
    const scouts=army.filter(u=>u.cmd==="hold"&&u.d.speed>4);
    const dest=aiRandomUnexplored();
    if(scouts.length){
      const s=scouts[0];
      orderMove(s,dest.x,dest.z);
    }
  }
  /* --- attack waves --- */
  ai.attackT-=1;
  const idle=army.filter(u=>u.cmd==="hold"||u.cmd==="idle");
  if(ai.attackT<=0&&idle.length>=ai.waveSize){
    const tgt=aiPickTarget();
    let tx,tz;
    if(tgt){tx=tgt.x;tz=tgt.z;}
    else{const d=aiRandomUnexplored();tx=d.x;tz=d.z;}
    let sent=0;
    for(const u of idle){
      orderAttackMove(u,tx+rrange(-8,8),tz+rrange(-8,8));
      sent++;
      if(sent>44)break;
    }
    ai.wave++;
    ai.waveSize=Math.min(38,ai.waveSize+3);
    ai.attackT=Math.max(38,70-ai.wave*4);
    toast("Enemy assault force incoming","bad");
    sfx("alarm",false);
  }
  /* rally idle army toward base */
  for(const u of army){
    if(u.cmd==="hold"&&dist2(u.x,u.z,base.x,base.z)>60*60){
      orderMove(u,base.x+rrange(-10,10),base.z+rrange(-10,10));
    }
  }
}

/* ============================================================
   VICTORY / SCREENS
   ============================================================ */
function checkVictory(){
  if(gameOver||!running)return;
  if(!teamHasHQ(TEAM_A)){
    gameOver=1;sfx("win",false);showEnd(true);
  }else if(!teamHasHQ(TEAM_P)){
    gameOver=2;sfx("lose",false);showEnd(false);
  }
}
const overlay=document.getElementById("overlay"),ocard=document.getElementById("ocard");
function showCard(html){ocard.innerHTML=html;overlay.classList.remove("hide");}
function hideCard(){overlay.classList.add("hide");}
function showStart(){
  running=false;
  showCard(`
  <h1>FRONTIER WAR</h1>
  <h2>Skirmish on Kepler-9 Frontier</h2>
  <p>You command a <b style="color:#4fd8ff">Terran Expedition</b>. A rival <b style="color:#ff6a4a">Crimson Legion</b> force landed on the opposite side of the frontier. Both sides are harvesting the same finite crystal fields.</p>
  <div class="cols">
    <div>
      <h3>Victory</h3>
      <ul>
        <li>Destroy <b>every</b> enemy Command Nexus.</li>
        <li>You lose if all of your own Nexuses fall.</li>
        <li>Build extra Nexuses to expand and to survive.</li>
      </ul>
      <h3>Economy</h3>
      <ul>
        <li><b>Haulers</b> mine crystal and return it to a Nexus.</li>
        <li><b>Supply Relays</b> raise your supply cap (+10 each).</li>
        <li>Crystal deposits are finite — expand to new fields.</li>
      </ul>
      <h3>Counters</h3>
      <ul>
        <li><b>Kinetic</b> (Trooper, Ravager) beats light armour.</li>
        <li><b>Explosive</b> (Bastion, Wasp, Sentry) beats heavy armour.</li>
        <li>Troopers, Wasps and Sentries can hit aircraft. Bastions cannot.</li>
      </ul>
    </div>
    <div>
      <h3>Controls</h3>
      <ul>
        <li><kbd>LMB</kbd> select · drag to box-select · <kbd>Shift</kbd> adds</li>
        <li><kbd>RMB</kbd> move / attack / gather / set rally</li>
        <li><kbd>A</kbd> attack-move · <kbd>S</kbd> stop · <kbd>H</kbd> hold</li>
        <li>Arrows or screen edge: pan · drag the minimap to jump</li>
        <li><kbd>Q</kbd><kbd>E</kbd> or middle-drag: rotate · wheel: zoom</li>
        <li><kbd>Ctrl</kbd>+<kbd>1-9</kbd> set group · <kbd>1-9</kbd> recall</li>
        <li><kbd>Space</kbd> centre on selection · <kbd>P</kbd> pause · <kbd>F1</kbd> help</li>
        <li>Click the minimap to jump · right-click it to order</li>
      </ul>
      <h3>Opening moves</h3>
      <ul>
        <li>Queue a couple of Haulers, then a Supply Relay.</li>
        <li>Build a Foundry, then a Research Spire or Aerie.</li>
        <li>Scout — the enemy base is hidden in the fog.</li>
      </ul>
    </div>
  </div>
  <button class="big" id="btnStart">BEGIN OPERATION</button>`);
  document.getElementById("btnStart").onclick=()=>{ensureAudio();hideCard();resetGame();};
}
function showEnd(win){
  running=false;
  showCard(`
  <h1 style="color:${win?"#7ff0c0":"#ff8a70"}">${win?"FRONTIER SECURED":"EXPEDITION LOST"}</h1>
  <h2>${win?"All Crimson Legion Nexuses destroyed":"Your command structure was annihilated"}</h2>
  <p>Match time: <b>${Math.floor(gameTime/60)}m ${Math.floor(gameTime%60)}s</b> · Units fielded: <b>${units.filter(u=>!u.dead&&u.team===TEAM_P).length}</b> surviving · Crystal banked: <b>${Math.floor(teamState[TEAM_P].crystal)}</b></p>
  <div class="flexrow">
    <button class="big" id="btnAgain">PLAY AGAIN</button>
    <button class="big" id="btnMenu" style="background:linear-gradient(180deg,#2a3f52,#16222e);border-color:#3d6a86">MAIN MENU</button>
  </div>`);
  document.getElementById("btnAgain").onclick=()=>{hideCard();resetGame();};
  document.getElementById("btnMenu").onclick=()=>{showStart();};
}
function togglePause(){
  if(gameOver||!running)return;
  paused=!paused;
  document.getElementById("bPause").textContent=paused?"RESUME (P)":"PAUSE (P)";
  if(paused){
    showCard(`<h1>PAUSED</h1><h2>Operation suspended</h2>
      <p>Press <kbd>P</kbd> or the button below to resume.</p>
      <button class="big" id="btnResume">RESUME</button>`);
    document.getElementById("btnResume").onclick=()=>{togglePause();};
  }else hideCard();
}
function toggleHelp(){
  const h=document.getElementById("help");
  h.classList.toggle("show");
}
document.getElementById("help").innerHTML=`
  <b>FRONTIER WAR — quick reference</b><br><br>
  <b>Goal:</b> destroy every enemy Command Nexus.<br>
  <b>Economy:</b> Haulers mine crystal → Nexus. Supply Relays give +10 supply each. Deposits run dry — expand.<br>
  <b>Build:</b> select a Hauler, pick a structure, click a valid spot. A nearby idle Hauler walks over and builds it.<br>
  <b>Produce:</b> select a Foundry / Aerie / Nexus and queue units. Right-click the ground with a building selected to set its rally point.<br>
  <b>Fight:</b> <span class="k">A</span> attack-move, <span class="k">S</span> stop, <span class="k">H</span> hold. Right-click an enemy to focus it.<br>
  <b>Counters:</b> Kinetic beats light armour · Explosive beats heavy armour · Bastions cannot shoot air.<br>
  <b>Fog:</b> unexplored ground is black, explored-but-unwatched is dim, enemy units vanish when unobserved. Last-known enemy buildings stay as silhouettes.<br>
  <b>Camera:</b> arrows / screen edge · <span class="k">Q</span><span class="k">E</span> rotate · wheel zoom · minimap click.<br>
  <b>Groups:</b> <span class="k">Ctrl</span>+<span class="k">1-9</span> assign, <span class="k">1-9</span> recall.`;
document.getElementById("bHelp").onclick=toggleHelp;
document.getElementById("bPause").onclick=togglePause;
document.getElementById("bMute").onclick=toggleMute;

/* ============================================================
   INIT / RESET
   ============================================================ */
let terrain=null;
function resize(){
  W=window.innerWidth;H=window.innerHeight;
  const dpr=Math.min(window.devicePixelRatio||1,1.5);
  canvas.width=Math.floor(W*dpr);canvas.height=Math.floor(H*dpr);
  canvas.style.width=W+"px";canvas.style.height=H+"px";
  fxc.width=W;fxc.height=H;fxc.style.width=W+"px";fxc.style.height=H+"px";
  gl.viewport(0,0,canvas.width,canvas.height);
}
window.addEventListener("resize",resize);
document.addEventListener("mousemove",e=>{overHUD=isHUD(e.target);});

function resetGame(){
  hideCard();
  units=[];buildings=[];projectiles=[];effects=[];
  nextId=1;gameTime=0;gameOver=0;paused=false;running=true;
  selected=[];controlGroups={};placing=null;pendingCmd=null;
  knownEnemyBld.clear();aiKnownBld.clear();aiKnownUnits.clear();
  teamState[TEAM_P]={crystal:900,supply:0,supplyCap:0,upgrades:{},ai:false};
  teamState[TEAM_A]={crystal:900,supply:0,supplyCap:0,upgrades:{},ai:true};
  ai.think=1.5;ai.attackT=75;ai.waveSize=6;ai.wave=0;ai.expandT=120;ai.scoutT=8;
  fogVis.fill(0);fogExp.fill(0);aiExp.fill(0);aiVis.fill(0);
  aiMemoryTimer=0;visTimer=0;
  srand(20240613+((Math.random()*1000)|0));
  genMap();
  buildRockInstances();
  buildMinimapBase();
  if(!terrain)terrain=buildTerrainMesh();
  rebuildPathGrid();
  /* starting forces */
  const ph=mkBuilding("hq",TEAM_P,START_P.x,START_P.z,true);
  const ah=mkBuilding("hq",TEAM_A,START_A.x,START_A.z,true);
  rebuildPathGrid();
  for(let i=0;i<5;i++){
    const a=i/5*TAU;
    const u=mkUnit("hauler",TEAM_P,START_P.x+Math.cos(a)*13,START_P.z+Math.sin(a)*13,a);
    const n=nearestRes(u.x,u.z,80);
    if(n)orderGather(u,n);
  }
  for(let i=0;i<5;i++){
    const a=i/5*TAU;
    const u=mkUnit("hauler",TEAM_A,START_A.x+Math.cos(a)*13,START_A.z+Math.sin(a)*13,a);
    const n=nearestRes(u.x,u.z,80);
    if(n)orderGather(u,n);
  }
  cam.x=START_P.x;cam.z=START_P.z;cam.yaw=0.75;cam.pitch=0.82;cam.dist=78;
  updateVision();rebuildFogTex();updateFogCanvas();
  refreshSelection();updateTopBar();
  toast("Establish your base — scout to find the enemy","good");
  document.getElementById("bPause").textContent="PAUSE (P)";
}

/* ============================================================
   MAIN LOOP
   ============================================================ */
let lastT=0;
function loop(ts){
  requestAnimationFrame(loop);
  const dt=Math.min(0.05,(ts-lastT)/1000||0.016);
  lastT=ts;
  sfxFrame=0;
  if(running&&!paused&&!gameOver){
    stepSim(dt);
    updateCamera(dt);
  }
  if(fogDirty){rebuildFogTex();updateFogCanvas();}
  render();
  drawOverlay();
  drawMinimap();
  updateTopBar();
}
resize();
resetGame();
showStart();
requestAnimationFrame(loop);
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4.1-flash","provider":"openrouter","persona":"full","sessionId":"8eca9336-2a59-4aae-a9e2-2b0fc6b9f656","tokensIn":6304465,"tokensOut":30045,"tokensTotal":6334510,"cost":0.11774588400000002,"turns":59,"toolCalls":58,"failedToolCalls":0,"timestamp":"2026-10-01T15:01:28.526Z"} -->