← RTS results

RTS

Qwen 3.8 · 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 93,416 bytes · SHA-256 e34efe702092
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FRONTIER WAR — Kharos-9</title>
<style>
  * { margin:0; padding:0; box-sizing:border-box; user-select:none; -webkit-user-select:none; }
  html,body { width:100%; height:100%; overflow:hidden; background:#04070c; font-family:"Segoe UI",system-ui,sans-serif; color:#cfe9ff; }
  #wrap { position:fixed; inset:0; }
  canvas { position:absolute; inset:0; width:100%; height:100%; display:block; }
  #ovc { pointer-events:none; }
  #hud { position:absolute; inset:0; pointer-events:none; font-size:13px; }
  .panel { background:rgba(8,16,26,0.86); border:1px solid rgba(90,200,255,0.28); box-shadow:0 0 18px rgba(0,40,80,0.35) inset; clip-path:polygon(10px 0,100% 0,100% calc(100% - 10px),calc(100% - 10px) 100%,0 100%,0 10px); }
  #topbar { position:absolute; top:8px; left:8px; right:8px; display:flex; align-items:center; gap:10px; height:46px; padding:0 12px; pointer-events:auto; }
  .stat { display:flex; align-items:baseline; gap:6px; padding:4px 10px; background:rgba(6,14,24,0.7); border:1px solid rgba(90,200,255,0.18); }
  .stat .ic { color:#59d8ff; font-size:15px; }
  .stat b { font-family:Consolas,monospace; font-size:17px; color:#eaffff; min-width:52px; }
  .stat i { font-style:normal; font-size:10px; letter-spacing:2px; color:#6fa8c8; }
  #alertbox { flex:1; display:flex; flex-direction:column; justify-content:center; gap:3px; overflow:hidden; min-height:0; }
  .alert { font-size:12px; padding:2px 8px; background:rgba(20,10,4,0.8); border-left:3px solid #ffb347; color:#ffd9a0; animation:alfade 4s forwards; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  .alert.bad { border-left-color:#ff5566; color:#ffb0b8; background:rgba(30,6,10,0.85); }
  .alert.good { border-left-color:#59d8ff; color:#c8ecff; }
  @keyframes alfade { 0%{opacity:0; transform:translateX(-8px);} 6%{opacity:1; transform:none;} 80%{opacity:1;} 100%{opacity:0;} }
  #topbtns { display:flex; gap:6px; }
  button { pointer-events:auto; cursor:pointer; background:rgba(12,26,40,0.9); color:#bfe6ff; border:1px solid rgba(90,200,255,0.35); padding:6px 10px; font-size:13px; letter-spacing:1px; clip-path:polygon(6px 0,100% 0,100% calc(100% - 6px),calc(100% - 6px) 100%,0 100%,0 6px); }
  button:hover { background:rgba(30,60,90,0.95); color:#fff; }
  button:disabled { opacity:0.4; cursor:default; }
  #bottombar { position:absolute; left:8px; right:8px; bottom:8px; display:flex; align-items:stretch; gap:8px; height:112px; pointer-events:none; }
  #selinfo { flex:1; padding:8px 12px; pointer-events:auto; overflow:hidden; }
  #selinfo .t { font-size:14px; letter-spacing:1px; color:#eaffff; margin-bottom:4px; }
  #selinfo .row { display:flex; align-items:center; gap:8px; margin-top:3px; font-size:12px; color:#9fd0ea; }
  .hpbar { flex:1; height:8px; background:rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.15); position:relative; }
  .hpbar i { position:absolute; left:0; top:0; bottom:0; background:linear-gradient(90deg,#37e08a,#7dffb0); }
  .hpbar i.mid { background:linear-gradient(90deg,#ffb347,#ffd9a0); }
  .hpbar i.low { background:linear-gradient(90deg,#ff5566,#ff9aa4); }
  #actions { display:flex; flex-direction:column; gap:4px; justify-content:center; pointer-events:auto; width:150px; }
  #actions button { width:100%; text-align:left; padding:5px 8px; font-size:12px; }
  #actions button small { float:right; color:#6fa8c8; font-size:10px; }
  #mmwrap { position:relative; pointer-events:auto; align-self:center; width:106px; height:106px; }
  #mmc { width:100px; height:100px; border:1px solid rgba(90,200,255,0.4); background:#000; cursor:crosshair; }
  #mmlabel { position:absolute; top:2px; left:4px; font-size:9px; letter-spacing:2px; color:rgba(160,220,255,0.6); pointer-events:none; }
  #sidepanel { position:absolute; left:8px; bottom:128px; width:300px; max-height:46vh; overflow-y:auto; padding:10px; pointer-events:auto; display:none; }
  #sidepanel h3 { font-size:11px; letter-spacing:2px; color:#59d8ff; margin-bottom:8px; }
  .bbtn { display:flex; align-items:center; gap:8px; width:100%; margin-bottom:5px; padding:6px 8px; text-align:left; }
  .bbtn .nm { flex:1; font-size:12px; color:#dff2ff; }
  .bbtn .nm small { display:block; color:#7fa8c0; font-size:10px; }
  .bbtn .cost { font-family:Consolas,monospace; font-size:12px; color:#ffd27a; }
  .bbtn .sup { font-family:Consolas,monospace; font-size:11px; color:#8fd8ff; }
  .qrow { display:flex; align-items:center; gap:6px; margin-bottom:4px; font-size:12px; }
  .qrow .bar { flex:1; height:10px; background:rgba(255,255,255,0.07); border:1px solid rgba(255,255,255,0.14); position:relative; }
  .qrow .bar i { position:absolute; inset:0 auto 0 0; background:linear-gradient(90deg,#2f88c8,#59d8ff); }
  .qrow .pct { font-family:Consolas,monospace; width:34px; text-align:right; color:#9fd0ea; }
  .qrow button { padding:1px 6px; font-size:11px; }
  #screen { position:absolute; inset:0; display:none; align-items:center; justify-content:center; background:rgba(3,6,10,0.82); pointer-events:auto; z-index:10; }
  #screen .box { width:min(720px,92vw); max-height:92vh; overflow-y:auto; padding:30px 36px; }
  #screen h1 { font-size:34px; letter-spacing:8px; color:#eaffff; text-shadow:0 0 24px rgba(89,216,255,0.6); margin-bottom:2px; }
  #screen h1 span { color:#59d8ff; }
  #screen .sub { letter-spacing:4px; font-size:12px; color:#7fa8c0; margin-bottom:16px; }
  #screen p { font-size:13px; line-height:1.55; color:#b8d8ee; margin-bottom:10px; }
  #screen .cols { display:flex; gap:18px; flex-wrap:wrap; }
  #screen .col { flex:1; min-width:240px; }
  #screen h4 { font-size:11px; letter-spacing:2px; color:#59d8ff; margin:10px 0 6px; }
  #screen ul { list-style:none; font-size:12px; line-height:1.7; color:#a8c8de; }
  #screen ul b { color:#e8f6ff; font-weight:600; }
  #screen .big { font-size:16px; padding:12px 26px; margin-top:16px; letter-spacing:3px; }
  #screen .win { color:#7dffb0; text-shadow:0 0 20px rgba(80,255,160,0.5); }
  #screen .lose { color:#ff8896; text-shadow:0 0 20px rgba(255,80,100,0.5); }
  #screen .stats { font-family:Consolas,monospace; font-size:13px; color:#9fd0ea; line-height:1.8; margin-top:10px; }
  .k { display:inline-block; background:rgba(90,200,255,0.14); border:1px solid rgba(90,200,255,0.3); padding:0 5px; font-family:Consolas,monospace; font-size:11px; color:#dff2ff; border-radius:2px; }
  ::-webkit-scrollbar { width:8px; } ::-webkit-scrollbar-thumb { background:rgba(90,200,255,0.3); } ::-webkit-scrollbar-track { background:rgba(0,0,0,0.3); }
</style>
</head>
<body>
<div id="wrap">
  <canvas id="glc"></canvas>
  <canvas id="ovc"></canvas>
  <div id="hud">
    <div id="topbar" class="panel">
      <div class="stat"><span class="ic">◆</span><b id="fluxv">0</b><i>FLUX</i></div>
      <div class="stat"><span class="ic">▲</span><b id="supv">0/0</b><i>SUPPLY</i></div>
      <div class="stat"><span class="ic">◷</span><b id="timev">0:00</b><i>TIME</i></div>
      <div id="alertbox"></div>
      <div id="topbtns">
        <button id="btnpause" title="Pause (Space)">❚❚</button>
        <button id="btnmute" title="Mute (M)">♪</button>
        <button id="btnhelp" title="Help (F1)">?</button>
      </div>
    </div>
    <div id="bottombar">
      <div id="selinfo" class="panel"></div>
      <div id="actions"></div>
      <div id="mmwrap"><canvas id="mmc" width="100" height="100"></canvas><div id="mmlabel">TACTICAL MAP</div></div>
    </div>
    <div id="sidepanel" class="panel"></div>
  </div>
  <div id="screen"></div>
</div>
<script>
'use strict';
/* ============================== HELPERS ============================== */
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 dist=(ax,az,bx,bz)=>Math.sqrt(dist2(ax,az,bx,bz));
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296;}}
let R=mulberry32(1);
const rr=(a,b)=>a+R()*(b-a);
function angLerp(a,b,t){let d=(b-a)%(Math.PI*2);if(d>Math.PI)d-=Math.PI*2;if(d<-Math.PI)d+=Math.PI*2;return a+d*t;}

/* mat4 (column major) */
function m4Persp(fov,asp,n,f){const t=1/Math.tan(fov/2);return[t/asp,0,0,0, 0,t,0,0, 0,0,(f+n)/(n-f),-1, 0,0,2*f*n/(n-f),0];}
function m4LookAt(ex,ey,ez,tx,ty,tz){
  let zx=ex-tx,zy=ey-ty,zz=ez-tz;let l=1/Math.hypot(zx,zy,zz);zx*=l;zy*=l;zz*=l;
  let xx=1*zz-0*zy, xy=0*zx-0*zz, xz=0*zy-1*zx; // up(0,1,0) x z
  l=1/(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;
  return[xx,yx,zx,0, xy,yy,zy,0, xz,yz,zz,0, -(xx*ex+xy*ey+xz*ez), -(yx*ex+yy*ey+yz*ez), -(zx*ex+zy*ey+zz*ez), 1];
}
function m4Mul(a,b){const o=new Array(16);for(let c=0;c<4;c++)for(let r=0;r<4;r++){o[c*4+r]=a[r]*b[c*4]+a[4+r]*b[c*4+1]+a[8+r]*b[c*4+2]+a[12+r]*b[c*4+3];}return o;}
function m4Xform(m,x,y,z){return[m[0]*x+m[4]*y+m[8]*z+m[12], m[1]*x+m[5]*y+m[9]*z+m[13], m[2]*x+m[6]*y+m[10]*z+m[14], m[3]*x+m[7]*y+m[11]*z+m[15]];}

/* ============================== AUDIO ============================== */
let AC=null, master=null, muted=false, lastShot=0, lastBoom=0;
function audioInit(){ if(AC)return; try{ AC=new (window.AudioContext||window.webkitAudioContext)(); master=AC.createGain(); master.gain.value=0.5; master.connect(AC.destination);}catch(e){} }
function beep(f,d,type,vol,slide){ if(!AC||muted)return; const t=AC.currentTime; const o=AC.createOscillator(),g=AC.createGain(); o.type=type||'sine'; o.frequency.setValueAtTime(f,t); if(slide)o.frequency.exponentialRampToValueAtTime(Math.max(30,f+slide),t+d); g.gain.setValueAtTime(vol||0.1,t); g.gain.exponentialRampToValueAtTime(0.001,t+d); o.connect(g);g.connect(master); o.start(t);o.stop(t+d+0.02);}
function noiseBurst(d,vol,freq){ if(!AC||muted)return; const t=AC.currentTime; const n=AC.sampleRate*d|0; const buf=AC.createBuffer(1,n,AC.sampleRate); const ch=buf.getChannelData(0); for(let i=0;i<n;i++)ch[i]=(Math.random()*2-1)*(1-i/n); const s=AC.createBufferSource(); s.buffer=buf; const f=AC.createBiquadFilter(); f.type='lowpass'; f.frequency.value=freq||1000; const g=AC.createGain(); g.gain.value=vol||0.15; s.connect(f);f.connect(g);g.connect(master); s.start(t); }
const SFX={
  select:()=>beep(880,0.05,'sine',0.06),
  move:()=>beep(520,0.06,'triangle',0.08),
  error:()=>beep(150,0.16,'sawtooth',0.1,-60),
  build:()=>noiseBurst(0.14,0.08,700),
  complete:()=>{beep(660,0.07,'sine',0.1);setTimeout(()=>beep(990,0.12,'sine',0.1),70);},
  unit:()=>beep(740,0.06,'triangle',0.08),
  harvest:()=>beep(1250,0.04,'sine',0.04),
  shot:(d)=>{const n=performance.now(); if(n-lastShot>60){lastShot=n;noiseBurst(0.05,0.05,2200);}},
  boom:(d)=>{const n=performance.now(); if(n-lastBoom>90){lastBoom=n;noiseBurst(0.35,0.16,320);beep(80,0.3,'sine',0.12,-40);}},
  alert:()=>{beep(440,0.12,'square',0.09);setTimeout(()=>beep(560,0.16,'square',0.09),130);},
  research:()=>{beep(980,0.08,'sine',0.08);setTimeout(()=>beep(1320,0.14,'sine',0.08),90);},
  click:()=>beep(660,0.03,'sine',0.05),
};
function toggleMute(){ muted=!muted; if(master)master.gain.value=muted?0:0.5; document.getElementById('btnmute').textContent=muted?'✕':'♪'; return muted; }

/* ============================== CONSTANTS ============================== */
const MAP=256, FOGY=0.06, NRES=64, NCELL=MAP/NRES;
const SIDE_P=0, SIDE_E=1;
const PAL={
  0:{body:[0.30,0.85,0.98], dark:[0.10,0.42,0.55], glow:[0.45,1.0,1.0], accent:[0.7,1.0,1.0]},
  1:{body:[0.95,0.34,0.45], dark:[0.48,0.13,0.22], glow:[1.0,0.5,0.62], accent:[1.0,0.72,0.72]},
};
PAL.p=PAL[0]; PAL.e=PAL[1];
const DMG={ kinetic:{light:1.1,heavy:0.5,aero:1.0}, energy:{light:1.0,heavy:1.2,aero:1.0} };
const UT={
  worker:{name:'Harvester Drone',cost:50,supply:1,hp:45,speed:6.0,vision:8,aggro:0,armor:'light',fly:false,r:0.7,buildT:8,desc:'Gathers flux. Build structures.'},
  blade:{name:'Razor Skirmisher',cost:45,supply:1,hp:75,speed:7.2,vision:9,aggro:9,armor:'light',fly:false,r:0.8,buildT:9,dmg:9,range:1.8,rate:1.3,dmgType:'kinetic',targets:'ground',desc:'Cheap melee. Fast, fragile, swarms.'},
  lancer:{name:'Pulse Lancer',cost:85,supply:1,hp:60,speed:5.8,vision:10,aggro:10,armor:'light',fly:false,r:0.8,buildT:13,dmg:11,range:10,rate:1.0,dmgType:'energy',targets:'both',desc:'Ranged, anti-air. Beats heavies.'},
  goliath:{name:'Goliath Juggernaut',cost:220,supply:3,hp:420,speed:3.4,vision:10,aggro:9,armor:'heavy',fly:false,r:1.5,buildT:26,dmg:32,range:2.6,rate:0.7,dmgType:'kinetic',targets:'ground',desc:'Heavy armor. Resists kinetic. Needs research.'},
  striker:{name:'Striker Wraith',cost:130,supply:2,hp:95,speed:11,vision:12,aggro:12,armor:'aero',fly:true,r:1.0,buildT:16,dmg:13,range:8,rate:1.0,dmgType:'energy',targets:'both',desc:'Flying. Crosses terrain. Anti-air too.'},
};
const BT={
  hq:{name:'Command Spire',cost:300,hp:650,supply:10,r:4.2,buildT:35,returns:true,produces:['worker'],desc:'Returns flux, +10 supply, trains Drones.'},
  beacon:{name:'Flux Beacon',cost:100,hp:130,supply:25,r:2.2,buildT:12,returns:true,desc:'+25 supply. Flux drop-off near deposits.'},
  forge:{name:'Forge Bay',cost:160,hp:320,supply:0,r:3.0,buildT:20,produces:['blade','lancer','goliath'],desc:'Trains Skirmishers, Lancers, Juggernauts.'},
  hangar:{name:'Wraith Hangar',cost:200,hp:320,supply:0,r:3.0,buildT:25,produces:['striker'],desc:'Trains Striker Wraiths (air).'},
  lab:{name:'Research Spire',cost:180,hp:220,supply:0,r:2.8,buildT:22,research:true,desc:'Researches upgrades.'},
  turret:{name:'Pulse Turret',cost:120,hp:260,supply:0,r:1.8,buildT:12,dmg:16,range:14,rate:0.8,dmgType:'kinetic',targets:'both',desc:'Defense. Anti-air + anti-ground.'},
};
const UP={
  harvest:{name:'Harvester Boost',cost:100,time:20,desc:'+50% flux per trip'},
  overdrive:{name:'Overdrive Cores',cost:150,time:30,desc:'+30% unit damage'},
  plating:{name:'Plasma Plating',cost:150,time:30,desc:'-20% damage taken'},
  ion:{name:'Ion Thrusters',cost:150,time:30,desc:'+25% unit speed'},
  airsup:{name:'Air Superiority',cost:150,time:30,desc:'+50% Striker damage'},
  heavyarmor:{name:'Heavy Plating',cost:200,time:40,desc:'Unlocks Goliath Juggernaut'},
};
const FLY_H=4.5;
</script>
<script>
/* ============================== WORLD ============================== */
let heightData=null, heightPx=null;
let obstacles=[], resources=[];
let fogP=null, fogE=null, fogPx=null;
let exploredP=0, exploredE=0;
const visP=new Uint8Array(NRES*NRES), visE=new Uint8Array(NRES*NRES);
const lastSeenP=new Float32Array(NRES*NRES), lastSeenE=new Float32Array(NRES*NRES);

function genWorld(){
  R=mulberry32(1337+Math.floor(Math.random()*9999));
  heightPx=new Uint8Array(MAP*MAP*4);
  const blobs=[]; for(let i=0;i<26;i++)blobs.push({x:R()*MAP,y:R()*MAP,r:rr(14,46),h:rr(0.6,1.6),f:R()<0.5?-1:1});
  const ridgeX=MAP/2+rr(-20,20);
  for(let y=0;y<MAP;y++)for(let x=0;x<MAP;x++){
    let h=0;
    for(const b of blobs){const dx=x-b.x,dy=y-b.y;const d2=dx*dx+dy*dy; if(d2<b.r*b.r){const d=Math.sqrt(d2); h+=b.h*b.f*(1-d/b.r);}}
    h+=0.9*Math.exp(-((x-ridgeX)*(x-ridgeX))/(2*22*22));
    h=clamp(h,0,2.2);
    const i=(y*MAP+x)*4;
    const v=h/2.2;
    heightPx[i]=  72+ v*90 + (R()-0.5)*8;
    heightPx[i+1]= 92+ v*110+(R()-0.5)*8;
    heightPx[i+2]= 108+ v*100+(R()-0.5)*8;
    heightPx[i+3]=250; // <255 so progFog's "c.a>0.99 discard" doesn't cull terrain
  }
  heightData=new Float32Array(MAP*MAP);
  for(let i=0;i<MAP*MAP;i++)heightData[i]=(heightPx[i*4+1]/255)*2.2;
  const pStart={x:36,z:MAP/2}, eStart={x:MAP-36,z:MAP/2};
  obstacles=[];
  const clearOf=(x,z)=>{ if(x<18||z<18||x>MAP-18||z>MAP-18)return false;
    if(dist(x,z,pStart.x,pStart.z)<30||dist(x,z,eStart.x,eStart.z)<30)return false; return true; };
  for(let i=0;i<30;i++){
    const x=rr(20,MAP-20),z=rr(20,MAP-20);
    if(!clearOf(x,z))continue;
    let ok=true; for(const o of obstacles) if(dist(x,z,o.x,o.z)<o.r+7){ok=false;break;}
    if(!ok)continue;
    obstacles.push({x,z,r:rr(2.4,4.6),type:'rock',h:rr(2,4.5)});
  }
  for(const wx of [MAP*0.35, MAP*0.65]){
    const gapZ=MAP/2+rr(-40,40), gapLen=16;
    for(let z=14;z<MAP-14;z+=3){
      if(Math.abs(z-gapZ)<gapLen)continue;
      if(dist(wx,z,pStart.x,pStart.z)<24||dist(wx,z,eStart.x,eStart.z)<24)continue;
      obstacles.push({x:wx+rr(-2,2),z,r:rr(1.6,2.2),type:'wall',h:rr(3,4.5)});
    }
  }
  resources=[];
  const tryCluster=(x,z,n)=>{
    for(let i=0;i<n;i++){ const a=R()*Math.PI*2, d=rr(3,15);
      const nx=clamp(x+Math.cos(a)*d,10,MAP-10), nz=clamp(z+Math.sin(a)*d,10,MAP-10);
      const amt=rr(140,220);
      resources.push({x:nx,z:nz,amt,max:amt,visible:false});
    }};
  tryCluster(24,MAP/2,6);
  tryCluster(MAP-24,MAP/2,6);
  tryCluster(MAP/2,MAP/2,8);
  tryCluster(MAP/2,22,6); tryCluster(MAP/2,MAP-22,6);
  tryCluster(MAP*0.3,MAP*0.25,5); tryCluster(MAP*0.7,MAP*0.75,5);
  tryCluster(MAP*0.72,MAP*0.22,5); tryCluster(MAP*0.28,MAP*0.78,5);
  fogP=new Uint8Array(NRES*NRES); fogE=new Uint8Array(NRES*NRES);
  exploredP=0; exploredE=0;
  visP.fill(0); visE.fill(0); lastSeenP.fill(0); lastSeenE.fill(0);
  fogPx=new Uint8Array(NRES*NRES*4);
  return {pStart,eStart};
}
function hAt(x,z){ x=clamp(Math.floor(x),0,MAP-1); z=clamp(Math.floor(z),0,MAP-1); return heightData[z*MAP+x]; }
function blockedAt(x,z,r){
  if(x<r||z<r||x>MAP-r||z>MAP-r)return true;
  for(const o of obstacles){ const rr2=o.r+r*0.8; if(dist2(x,z,o.x,o.z)<rr2*rr2)return true; }
  return false;
}
function placeableBuilding(x,z,r,side){
  if(x<r+2||z<r+2||x>MAP-r-2||z>MAP-r-2)return false;
  for(const o of obstacles){ const rr2=o.r+r*0.9; if(dist2(x,z,o.x,o.z)<rr2*rr2)return false; }
  for(const b of buildings){ if(b.side!==side||b.hp<=0)continue; const rr2=b.r+r*0.95; if(dist2(x,z,b.x,b.z)<rr2*rr2)return false; }
  return true;
}
function reveal(side,x,z,rad){
  const fog=side===SIDE_P?fogP:fogE, vis=side===SIDE_P?visP:visE, seen=side===SIDE_P?lastSeenP:lastSeenE;
  let ex=side===SIDE_P?exploredP:exploredE;
  const c0x=clamp(Math.floor((x-rad)/NCELL),0,NRES-1), c1x=clamp(Math.floor((x+rad)/NCELL),0,NRES-1);
  const c0z=clamp(Math.floor((z-rad)/NCELL),0,NRES-1), c1z=clamp(Math.floor((z+rad)/NCELL),0,NRES-1);
  const r2=rad*rad;
  for(let cz=c0z;cz<=c1z;cz++)for(let cx=c0x;cx<=c1x;cx++){
    const px=(cx+0.5)*NCELL, pz=(cz+0.5)*NCELL;
    if(dist2(px,pz,x,z)<=r2){ const i=cz*NRES+cx; if(!vis[i]){vis[i]=1; if(!fog[i]){fog[i]=1;ex++;}} seen[i]=gameTime; }
  }
  if(side===SIDE_P)exploredP=ex;else exploredE=ex;
}
function fogDraw(){
  const d=fogPx;
  for(let i=0;i<NRES*NRES;i++){
    const o=i*4;
    if(fogP[i]){ d[o]=0; d[o+1]=0; d[o+2]=0; d[o+3]=visP[i]?0:150; }
    else { d[o]=6; d[o+1]=9; d[o+2]=14; d[o+3]=255; }
  }
}

/* ============================== GEOMETRY ============================== */
function box(w,h,d){
  const x=w/2,y=h/2,z=d/2;
  const P=[[ -x,-y,-z, 0,0,1], [ x,-y,-z, 1,0,1], [ x,y,-z, 1,1,1], [-x,y,-z, 0,1,1],
           [ x,-y, z, 0,0,1], [ x,y, z, 1,0,1], [-x,y, z, 1,1,1], [-x,-y, z, 0,1,1],
           [ x,-y, z, 0,0,1], [ x, y, z, 1,0,1], [ x,y,-z, 1,1,1], [ x,-y,-z, 0,1,1],
           [-x,-y, z, 0,0,1], [-x, y, z, 1,0,1], [-x,y,-z, 1,1,1], [-x,-y,-z, 0,1,1],
           [ x, y, z, 0,0,1], [-x, y, z, 1,0,1], [-x,y,-z, 1,1,1], [ x,y,-z, 0,1,1],
           [ x,-y,-z, 0,0,1], [-x,-y,-z, 1,0,1], [-x,y,-z, 1,1,1], [ x,y,-z, 0,1,1]];
  const F=[[0,1,2,3],[4,5,6,7],[8,9,10,11],[12,13,14,15],[16,17,18,19],[20,21,22,23]];
  const pos=[],idx=[];
  F.forEach((f,fi)=>{ const b=pos.length/6;
    for(const vi of f){const p=P[vi];pos.push(p[0],p[1],p[2],p[3],p[4],p[5]);}
    idx.push(b,b+1,b+2,b,b+2,b+3);
  });
  return{pos:new Float32Array(pos),uv:null,idx:new Uint16Array(idx)};
}
function prism(r1,r2,h,seg){
  const pos=[],idx=[];
  for(let i=0;i<seg;i++){const a=i/seg*Math.PI*2;pos.push(Math.cos(a)*r1,0,Math.sin(a)*r1,0.5,0.5,1);}
  for(let i=0;i<seg;i++){const a=i/seg*Math.PI*2;pos.push(Math.cos(a)*r2,h,Math.sin(a)*r2,0.5,0.5,1);}
  for(let i=0;i<seg;i++){const j=(i+1)%seg;idx.push(i,j,seg+j, i,seg+j,seg+i);}
  return{pos:new Float32Array(pos),uv:null,idx:new Uint16Array(idx)};
}
function shiftY(g,dy,dz){
  const p=new Float32Array(g.pos);
  for(let i=1;i<p.length;i+=6){p[i]+=dy;p[i+2]+=dz||0;}
  return{pos:p,uv:g.uv,idx:g.idx};
}
function mergeGeos(list){
  const list2=list.filter(Boolean);
  let n=0; for(const g of list2)n+=g.pos.length/6;
  const pos=new Float32Array(n*6), uv=new Float32Array(n*2); let idx=null; let off=0;
  for(const g of list2){
    pos.set(g.pos,off*6); if(g.uv)uv.set(g.uv,off*2);
    const li=new Uint16Array(g.idx.length);
    for(let i=0;i<g.idx.length;i++)li[i]=g.idx[i]+off;
    idx=idx?concatU16(idx,li):li; off+=g.pos.length/6;
  }
  return{pos,uv,idx};
}
function concatU16(a,b){const o=new Uint16Array(a.length+b.length);o.set(a);o.set(b,a.length);return o;}
function diamond(r,seg){
  const pos=[],uv=[],idx=[];
  pos.push(0,0,0,0.5,0.5,1); uv.push(0.5,0.5);
  for(let i=0;i<seg;i++){const a=i/seg*Math.PI*2;pos.push(Math.cos(a)*r,0,Math.sin(a)*r,0.5+Math.cos(a)*0.5,0.5+Math.sin(a)*0.5,1);uv.push(0.5,0.5);}
  for(let i=0;i<seg;i++){const j=(i+1)%seg;idx.push(0,i+1,j+1);}
  return{pos:new Float32Array(pos),uv:new Float32Array(uv),idx:new Uint16Array(idx)};
}
const GEO={};
function buildGeos(){
  const dia=diamond(1,8);
  GEO.worker=mergeGeos([
    {pos:new Float32Array([0,0.15,0.7,0,1,0, 0,0.15,-0.7,0,1,0, -0.55,0.15,0,0,1,0, 0.55,0.15,0,0,1,0]),uv:new Float32Array(8),idx:new Uint16Array([0,1,2,2,1,3])},
    prism(0.55,0.42,0.5,6),
    shiftY(prism(0.3,0.12,0.5,4),0.5,0.2)
  ]);
  GEO.blade=mergeGeos([
    {pos:new Float32Array([0,0.2,0.8,0,1,0, 0,0.2,-0.8,0,1,0, -0.6,0.2,0,0,1,0, 0.6,0.2,0,0,1,0]),uv:new Float32Array(8),idx:new Uint16Array([0,1,2,2,1,3])},
    box(0.7,0.5,1.1),
    shiftY(box(0.3,0.45,0.75),0.48,0.1)
  ]);
  GEO.lancer=mergeGeos([
    {pos:new Float32Array([0,0.18,0.9,0,1,0, 0,0.18,-0.9,0,1,0, -0.65,0.18,0,0,1,0, 0.65,0.18,0,0,1,0]),uv:new Float32Array(8),idx:new Uint16Array([0,1,2,2,1,3])},
    box(0.6,0.42,1.0),
    shiftY(prism(0.4,0.28,0.45,5),0.42,0),
    shiftY(box(0.28,0.2,0.95),0.55,0.35)
  ]);
  GEO.goliath=mergeGeos([
    {pos:new Float32Array([0,0.4,1.4,0,1,0, 0,0.4,-1.4,0,1,0, -1.2,0.4,0,0,1,0, 1.2,0.4,0,0,1,0]),uv:new Float32Array(8),idx:new Uint16Array([0,1,2,2,1,3])},
    box(1.9,1.1,2.4),
    shiftY(box(1.4,0.7,1.2),1.0,0),
    shiftY(box(0.35,0.7,1.6),1.35,0.9)
  ]);
  GEO.striker=mergeGeos([
    {pos:new Float32Array([0,0.15,1.3,0,1,0, 0,0.15,-0.9,0,1,0, -0.35,0.15,0.3,0,1,0, 0.35,0.15,0.3,0,1,0]),uv:new Float32Array(8),idx:new Uint16Array([0,1,2,2,1,3])},
    {pos:new Float32Array([-0.9,0.1,0.2,0,1,0, 0.9,0.1,0.2,0,1,0, 0.4,0.1,-1.0,0,1,0, -0.4,0.1,-1.0,0,1,0]),uv:new Float32Array(8),idx:new Uint16Array([0,1,2,2,1,3])},
    box(0.5,0.35,1.6)
  ]);
  GEO.hq=mergeGeos([
    prism(4.0,3.2,1.2,8),
    shiftY(prism(2.2,0.9,3.4,6),1.2,0),
    shiftY(prism(0.7,0.35,2.6,4),4.6,0)
  ]);
  GEO.beacon=mergeGeos([
    prism(2.0,1.4,0.8,6),
    shiftY(prism(0.5,0.15,3.2,5),0.8,0)
  ]);
  GEO.forge=mergeGeos([
    prism(2.9,2.4,0.7,8),
    shiftY(box(2.6,1.6,2.6),0.7,0),
    shiftY(box(1.0,0.9,0.6),1.9,1.4)
  ]);
  GEO.hangar=mergeGeos([
    prism(2.9,2.4,0.6,8),
    shiftY(box(2.8,1.2,2.6),0.6,-0.3),
    shiftY(box(2.8,1.1,0.5),1.2,1.15)
  ]);
  GEO.lab=mergeGeos([
    prism(2.6,2.2,0.6,8),
    shiftY(prism(1.6,0.5,2.2,6),0.6,0),
    shiftY(prism(0.5,0.5,0.9,4),2.8,0)
  ]);
  GEO.turret=mergeGeos([
    prism(1.7,1.3,0.9,6),
    shiftY(box(1.1,0.8,1.1),0.9,0),
    shiftY(box(0.35,0.35,1.6),1.55,0.6)
  ]);
  GEO.rock=box(1,1,1);
  GEO.wall=box(1,1,1);
  GEO.__tp=prism(1,0.4,1,8);
  GEO.dia=dia;
}
/* ============================== GL SETUP ============================== */
const glc=document.getElementById('glc'), ovc=document.getElementById('ovc');
let gl=null, octx=null;
let progMain=null, progGlow=null, progLine=null, progFog=null;
let capInst=4096, instanceCount=0, instBuf=null, instDirty=true;
const ISTRIDE=28;
function sh(type,src){const s=gl.createShader(type);gl.shaderSource(s,src);gl.compileShader(s);if(!gl.getShaderParameter(s,gl.COMPILE_STATUS))throw new Error('shader: '+gl.getShaderInfoLog(s));return s;}
function mkProg(vs,fs,unis,binds){
  const p=gl.createProgram();gl.attachShader(p,sh(gl.VERTEX_SHADER,vs));gl.attachShader(p,sh(gl.FRAGMENT_SHADER,fs));
  const B=Object.assign({aPos:0,aUV:1},binds||{});
  for(const name in B)gl.bindAttribLocation(p,B[name],name);
  gl.linkProgram(p);if(!gl.getProgramParameter(p,gl.LINK_STATUS))throw new Error('link: '+gl.getProgramInfoLog(p));
  const o={p}; for(const u of unis)o[u]=gl.getUniformLocation(p,u); return o;
}
function buf(data,target){const b=gl.createBuffer();gl.bindBuffer(target||gl.ARRAY_BUFFER,b);gl.bufferData(target||gl.ARRAY_BUFFER,data,gl.STATIC_DRAW);return b;}
function makeGeo(g){
  return{vbo:buf(g.pos),ibo:buf(g.idx,gl.ELEMENT_ARRAY_BUFFER),uvb:g.uv?buf(g.uv):null,nIdx:g.idx.length,stride:g.pos.length/6,hasUV:!!(g.uv&&g.uv.length)};
}
function initGL(){
  gl=glc.getContext('webgl',{antialias:true,alpha:false})||glc.getContext('experimental-webgl');
  const instExt=gl.getExtension('ANGLE_instanced_arrays');
  if(!instExt)throw new Error('WebGL instancing unsupported');
  gl.vertexAttribDivisor=function(i,d){instExt.vertexAttribDivisorANGLE(i,d);};
  gl.drawElementsInstanced=function(mode,count,type,offset,count2){instExt.drawElementsInstancedANGLE(mode,count,type,offset,count2);};
  octx=ovc.getContext('2d');
  buildGeos();
  const VS_MAIN=`
  attribute vec3 aPos; attribute vec2 aUV;
  uniform mat4 uMVP; uniform mat4 uM; uniform vec3 uTint;
  varying vec3 vC; varying vec3 vW; varying float vY;
  void main(){
    vec4 w=uM*vec4(aPos,1.0);
    vW=w.xyz; vY=aPos.y; vC=uTint;
    gl_Position=uMVP*w;
  }`;
  const FS_MAIN=`
  precision mediump float;
  varying vec3 vC; varying vec3 vW; varying float vY;
  uniform vec3 uCam; uniform vec3 uLight;
  void main(){
    vec3 col=vC;
    vec3 n=normalize(vec3(-0.35,1.0,-0.2));
    float d=max(dot(n,uLight),0.0);
    float sh=0.42+0.62*d;
    sh*=1.0-smoothstep(0.0,0.5,vY)*0.15;
    col*=sh;
    float fd=distance(vW,uCam);
    float f=smoothstep(140.0,320.0,fd);
    col=mix(col,vec3(0.10,0.16,0.22),f);
    gl_FragColor=vec4(col,1.0);
  }`;
  const VS_GLOW=`
  attribute vec3 aPos; attribute vec2 aUV;
  uniform mat4 uMVP; uniform mat4 uM; uniform vec3 uTint;
  varying vec3 vC; varying vec2 vUV;
  void main(){
    vUV=aUV; vC=uTint;
    gl_Position=uMVP*(uM*vec4(aPos,1.0));
  }`;
  const FS_GLOW=`
  precision mediump float;
  varying vec3 vC; varying vec2 vUV; uniform float uTime; uniform float uAlpha;
  void main(){
    float d=length(vUV-0.5)*2.0;
    float a=smoothstep(1.0,0.15,d);
    float pulse=0.75+0.25*sin(uTime*3.0+vC.x*20.0);
    gl_FragColor=vec4(vC*pulse, a*0.9*uAlpha);
  }`;
  const VS_LINE=`
  attribute vec3 aPos; attribute vec2 aUV; attribute vec3 aColor;
  uniform mat4 uMVP; varying vec3 vC; varying vec2 vUV;
  void main(){ vC=aColor; vUV=aUV; gl_Position=uMVP*vec4(aPos,1.0); }`;
  const FS_LINE=`
  precision mediump float; varying vec3 vC; varying vec2 vUV;
  void main(){ float a=smoothstep(1.0,0.0,vUV.y); gl_FragColor=vec4(vC,a*0.85); }`;
  progMain=mkProg(VS_MAIN,FS_MAIN,['uMVP','uM','uTint','uCam','uLight']);
  progMain.p.__t='MAIN';
  progGlow=mkProg(VS_GLOW,FS_GLOW,['uMVP','uM','uTint','uTime','uAlpha']);
  progGlow.p.__t='GLOW';
  progLine=mkProg(VS_LINE,FS_LINE,['uMVP']);
  progLine.p.__t='LINE';
  progFog={p:gl.createProgram()};
  {
    const vs=`attribute vec3 aPos; attribute vec2 aUV; attribute vec3 aTint; uniform mat4 uMVP; varying vec2 vUV; varying vec3 vT; void main(){vUV=aUV; vT=aTint; gl_Position=uMVP*vec4(aPos,1.0);}`;
    const fs=`precision mediump float; varying vec2 vUV; varying vec3 vT; uniform sampler2D uTex; void main(){vec4 c=texture2D(uTex,vUV); c.rgb*=vT; if(c.a>0.99) discard; gl_FragColor=vec4(c.rgb, c.a*0.92);}`;
    const P=gl.createProgram();gl.attachShader(P,sh(gl.VERTEX_SHADER,vs));gl.attachShader(P,sh(gl.FRAGMENT_SHADER,fs));
    gl.bindAttribLocation(P,0,'aPos');gl.bindAttribLocation(P,1,'aUV');gl.bindAttribLocation(P,2,'aTint');gl.linkProgram(P);
    progFog.p=P; P.__t='FOG'; progFog.uMVP=gl.getUniformLocation(P,'uMVP'); progFog.uTex=gl.getUniformLocation(P,'uTex');
  }
  const fogTex=gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D,fogTex);
  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);
  progFog.tex=fogTex;
  progFog.geo=makeGeo({pos:new Float32Array([0,FOGY,0, 0,1,1, MAP,FOGY,0, 1,1,1, MAP,FOGY,MAP, 1,0,1, 0,FOGY,MAP, 0,0,1]),idx:new Uint16Array([0,1,2,0,2,3])});
  instBuf=gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER,instBuf);
  gl.bufferData(gl.ARRAY_BUFFER,new Float32Array(capInst*ISTRIDE),gl.DYNAMIC_DRAW);
  for(const k in GEO)GEO[k]=makeGeo(GEO[k]);
  // terrain quad
  progFog.terrain=makeGeo({pos:new Float32Array([-4,0,-4, 0,1,1, MAP+4,0,-4, 1,1,1, MAP+4,0,MAP+4, 1,0,1, -4,0,MAP+4, 0,0,1]),idx:new Uint16Array([0,1,2,0,2,3])});
  // terrain tint buffer (vec3 per vertex, all white)
  progFog.terrainTint=buf(new Float32Array([1,1,1, 1,1,1, 1,1,1, 1,1,1]));
  // terrain texture
  const ttex=gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D,ttex);
  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);
  gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,MAP,MAP,0,gl.RGBA,gl.UNSIGNED_BYTE,heightPx);
  progFog.terrainTex=ttex;
}
</script>
<script>
/* ============================== GAME STATE ============================== */
let gameTime=0, paused=false, gameState='title'; // title|play|win|lose
let units=[], buildings=[], effects=[];
let cam={x:36,y:12,z:MAP/2+30, yaw:0, pitch:0.72, zoom:26};
let sel=[], selBuilding=null, cg=[[],[],[],[],[],[],[],[],[],[]];
let placing=null; // {btype, valid, x, z}
let alertMsgs=[];
const stats={k:0,d:0,b:0,g:0,t:0};
let ai=null;
const grid=new Map(); // "x,z" -> entity (units + buildings)
function gridKey(x,z){return (x|0)+','+(z|0);}
function gridAdd(e){e.gk=gridKey(e.x,e.z); grid.set(e.gk,e);}
function gridMove(e){ if(e.gk===gridKey(e.x,e.z))return; const old=e.gk; if(grid.get(old)===e)grid.delete(old); e.gk=gridKey(e.x,e.z); grid.set(e.gk,e); }
function gridRemove(e){ if(e.gk&&grid.get(e.gk)===e)grid.delete(e.gk); }
function gridNear(x,z,r){
  const out=[];
  const r0=Math.ceil(r);
  for(let dx=-r0;dx<=r0;dx++)for(let dz=-r0;dz<=r0;dz++){
    const e=grid.get(gridKey(Math.floor(x+dx),Math.floor(z+dz)));
    if(e&&e.hp>0)out.push(e);
  }
  return out;
}
const sideState={
  0:{flux:150, supply:0, reserve:0, up:{}, upQueue:null, upProg:0},
  1:{flux:150, supply:0, reserve:0, up:{}, upQueue:null, upProg:0},
};
function supplyCap(side){ let c=0; for(const b of buildings) if(b.side===side&&b.hp>0&&BT[b.type].supply)c+=BT[b.type].supply; return c; }
function addAlert(side,text,cls){
  if(side!==SIDE_P)return;
  alertMsgs.push({text,cls:cls||'',t:gameTime});
  if(alertMsgs.length>5)alertMsgs.shift();
  if(cls==='bad')SFX.alert();
}
/* ---------- entity creation ---------- */
function spawnUnit(type,side,x,z){
  const d=UT[type];
  const u={kind:'unit',type,side,x,z,hp:d.hp,maxHp:d.hp,angle:R()*6.28,
    state:'idle', target:null, order:null, carry:0, carryRes:null,
    atkCd:0, moveT:0, wanderT:0, buildTarget:null, rally:null,
    vis:gameTime, lastSeen:-99, dead:false,
    fly:!!d.fly, r:d.r, speed:d.speed, vision:d.vision, aggro:d.aggro,
    dmg:d.dmg||0, range:d.range||0, rate:d.rate||1, dmgType:d.dmgType||'kinetic', targets:d.targets||'ground',
    armor:d.armor, selected:false, id:Math.random()};
  units.push(u); gridAdd(u);
  return u;
}
function spawnBuilding(type,side,x,z,underConstruction){
  const d=BT[type];
  const b={kind:'building',type,side,x,z,hp:underConstruction?0:d.hp,maxHp:d.hp,angle:0,
    queue:[], qProg:0, research:null, rProg:0, rally:null,
    buildT:underConstruction?d.buildT:0, dead:false, selected:false, id:Math.random(),
    r:d.r, dmg:d.dmg||0, range:d.range||0, rate:d.rate||1, dmgType:d.dmgType||'kinetic', targets:d.targets||'both', atkCd:0};
  buildings.push(b); gridAdd(b);
  return b;
}
function startGame(){
  gameTime=0; paused=false; stats.k=0;stats.d=0;stats.b=0;stats.g=0;stats.t=0;
  units=[];buildings=[];effects=[];sel=[];selBuilding=null;cg=[[],[],[],[],[],[],[],[],[],[]];
  alertMsgs=[]; placing=null;
  sideState[0]={flux:150,supply:0,reserve:0,up:{},upQueue:null,upProg:0};
  sideState[1]={flux:150,supply:0,reserve:0,up:{},upQueue:null,upProg:0};
  grid.clear();
  const s=genWorld();
  // (re)upload terrain texture now that the world exists
  gl.bindTexture(gl.TEXTURE_2D,progFog.terrainTex);
  gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,MAP,MAP,0,gl.RGBA,gl.UNSIGNED_BYTE,heightPx);
  const hp=spawnBuilding('hq',SIDE_P,s.pStart.x,s.pStart.z);
  const he=spawnBuilding('hq',SIDE_E,s.eStart.x,s.eStart.z);
  for(let i=0;i<3;i++){
    const u=spawnUnit('worker',SIDE_P,s.pStart.x+rr(-4,4),s.pStart.z+rr(-6,2));
    const w=spawnUnit('worker',SIDE_E,s.eStart.x+rr(-4,4),s.eStart.z+rr(-6,2));
  }
  // initial workers ordered to gather nearest
  for(const u of units) if(u.side===SIDE_P||true){
    const res=nearestResource(u.x,u.z);
    if(res)orderGather(u,res);
  }
  hp.rally={x:s.pStart.x+8,z:s.pStart.z+6}; he.rally={x:s.eStart.x-8,z:s.eStart.z+6};
  reveal(SIDE_P,s.pStart.x,s.pStart.z,16);
  reveal(SIDE_E,s.eStart.x,s.eStart.z,16);
  ai=newAI(s);
  // anchor on the player base; eye placed behind it (away from enemy), facing toward enemy
  cam.x=s.pStart.x; cam.z=s.pStart.z; cam.y=12;
  cam.yaw=Math.atan2(-(s.eStart.x-s.pStart.x), -(s.eStart.z-s.pStart.z));
  cam.zoom=42; cam.pitch=0.92;
  gameState='play';
  hideScreen();
  addAlert(SIDE_P,'Expedition deployed. Secure the frontier.','good');
}
function newAI(s){
  return {
    nextThink:0, scoutT:5, attackT:90, lastAttackT:0, lastExpandT:0,
    knownPlayer:0, raidTarget:null, focus:null,
    workersNeeded:3, plan:{},
    stats:{units:0, lost:0}
  };
}
function nearestResource(x,z){
  let best=null,bd=1e9;
  for(const r of resources){ if(r.amt<=0)continue; const d=dist2(x,z,r.x,r.z); if(d<bd){bd=d;best=r;} }
  return best;
}
function nearestDepot(u){
  let best=null,bd=1e9;
  for(const b of buildings){ if(b.side!==u.side||b.hp<=0)continue; if(!BT[b.type].returns)continue; const d=dist2(u.x,u.z,b.x,b.z); if(d<bd){bd=d;best=b;} }
  return best;
}
function nearestEnemy(u,rad){
  const vis=u.side===SIDE_P?visP:visE;
  let best=null,bd=1e9;
  const near=gridNear(u.x,u.z,rad);
  for(const e of near){
    if(e.hp<=0||e.side===u.side)continue;
    if(e.kind==='unit'&&!e.fly&&e.state==='idle'&&e.hp<=0)continue;
    const d=dist2(u.x,u.z,e.x,e.z);
    if(d<bd){bd=d;best=e;}
  }
  return best;
}
function visibleTo(u,e){
  if(e.side===u.side)return true;
  const vis=u.side===SIDE_P?visP:visE;
  const cx=clamp(Math.floor(e.x/NCELL),0,NRES-1), cz=clamp(Math.floor(e.z/NCELL),0,NRES-1);
  return vis[cz*NRES+cx]===1;
}
/* ---------- orders ---------- */
function orderMove(u,x,z){ u.state='move'; u.order={t:'move',x,z}; u.target=null; u.buildTarget=null; }
function orderGather(u,res){ u.state='gather'; u.order={t:'gather',res}; u.target=null; }
function orderReturn(u){ u.state='return'; u.order={t:'return'}; }
function orderAttack(u,t){ u.state='attack'; u.order={t:'attack',target:t}; u.target=t; }
function orderAttackMove(u,x,z){ u.state='attackmove'; u.order={t:'attackmove',x,z}; u.target=null; }
function orderStop(u){ u.state='idle'; u.order=null; u.target=null; u.buildTarget=null; }
function orderHold(u){ u.state='hold'; u.order=null; u.target=null; }
function orderBuild(u,b){ u.state='build'; u.order={t:'build',b}; u.buildTarget=b; u.target=null; }
function issueOrder(kind,x,z,shift){
  if(gameState!=='play')return;
  let list=[];
  if(shift&&sel.length)list=sel.slice();
  else if(sel.length)list=sel.slice();
  if(!list.length&&selBuilding){ if(kind==='select')return; list=[]; }
  if(!list.length&&kind!=='select'){ SFX.error(); return; }
  // contextual target
  let target=null;
  if(kind==='attack'||kind==='move'||kind==='attackmove'||kind==='gather'||kind==='stop'||kind==='hold'){
    if(kind!=='stop'&&kind!=='hold'){
      // find entity at point
      const near=gridNear(x,z,2.2);
      for(const e of near){ if(e.hp<=0)continue; if(e.side!==SIDE_P){ target=e; break; } }
    }
  }
  for(const u of list){
    if(u.kind!=='unit'||u.side!==SIDE_P||u.dead)continue;
    if(kind==='select')continue;
    if(kind==='stop'){ orderStop(u); }
    else if(kind==='hold'){ orderHold(u); }
    else if(kind==='attack'){ if(target)orderAttack(u,target); else {orderAttackMove(u,x,z);} }
    else if(kind==='attackmove'){ orderAttackMove(u,x,z); }
    else if(kind==='gather'){
      let res=null;
      for(const r of resources){ if(r.amt>0&&dist2(x,z,r.x,r.z)<6*6){res=r;break;} }
      if(res)orderGather(u,res); else orderMove(u,x,z);
    }
    else if(kind==='move'){
      if(target){ orderAttack(u,target); }
      else orderMove(u,x,z);
      if(u.type==='worker'&&target&&target.kind==='building'&&BT[target.type].returns&&target.side===SIDE_P)orderReturn(u);
    }
  }
  if(kind!=='select'&&list.length)SFX.move();
  drawOrderMarker(x,z,kind);
}
let orderMarker=null;
function drawOrderMarker(x,z,kind){ orderMarker={x,z,kind,t:gameTime}; }
/* ---------- damage ---------- */
function damage(e,amt,src,side){
  if(e.hp<=0)return;
  let mul=1;
  if(sideState[src].up.plating)mul*=0.8;
  e.hp-=amt*mul;
  if(e.kind==='unit'&&e.side===SIDE_P&&e.hp>0&&src===SIDE_E)SFX.shot(1);
  if(e.hp<=0){
    e.hp=0; e.dead=true;
    gridRemove(e);
    effects.push({type:'boom',x:e.x,z:e.z,y:e.kind==='building'?1:0.5,age:0,life:0.7,side:src,r:e.r});
    SFX.boom(1);
    if(e.side===SIDE_P){stats.d++; if(e.kind==='building')addAlert(SIDE_P,(e.type==='hq'?'COMMAND SPIRE':'Structure')+' destroyed!','bad');}
    else {stats.k++;}
    if(e.kind==='building'&&e.type==='hq'){
      if(e.side===SIDE_E){ gameState='win'; showEnd(true); }
      else { gameState='lose'; showEnd(false); }
    }
  } else {
    effects.push({type:'hit',x:e.x,z:e.z,y:0.8,age:0,life:0.25,side:src});
  }
}
</script>
<script>
/* ============================== SIMULATION ============================== */
function updateUnits(dt){
  for(let i=units.length-1;i>=0;i--){
    const u=units[i];
    if(u.dead){ units.splice(i,1); continue; }
    u.atkCd=Math.max(0,u.atkCd-dt);
    // auto aggro
    if(u.aggro>0&&(u.state==='idle'||u.state==='move'||u.state==='attackmove')){
      const e=nearestEnemy(u,u.aggro);
      if(e&&visibleTo(u,e)&&canTarget(u,e))orderAttack(u,e);
    }
    switch(u.state){
      case 'idle':
        u.moveT+=dt;
        break;
      case 'move': {
        const o=u.order; if(!o){u.state='idle';break;}
        const d=dist(u.x,u.z,o.x,o.z);
        if(d<0.8){ u.state='idle'; u.order=null; }
        else moveToward(u,o.x,o.z,dt);
        break;
      }
      case 'attackmove': {
        const o=u.order;
        const e=nearestEnemy(u,Math.max(u.range+2,u.aggro));
        if(e&&visibleTo(u,e)&&canTarget(u,e)){ orderAttack(u,e); u.amOrigin={x:o.x,z:o.z}; }
        else {
          const d=dist(u.x,u.z,o.x,o.z);
          if(d<0.8){ u.state='idle'; u.order=null; }
          else moveToward(u,o.x,o.z,dt);
        }
        break;
      }
      case 'attack': {
        const t=u.target;
        if(!t||t.hp<=0||!visibleTo(u,t)){
          if(u.amOrigin){ const o=u.order; if(o){u.state='attackmove';u.order={t:'attackmove',x:o.x,z:o.z};} else u.state='idle'; }
          else u.state='idle';
          break;
        }
        const d=dist(u.x,u.z,t.x,t.z);
        const need=u.range+ (u.r+t.r)*0.5;
        if(d>need) moveToward(u,t.x,t.z,dt);
        u.angle=angLerp(u.angle,Math.atan2(t.x-u.x,t.z-u.z),dt*10);
        if(d<=need&&u.atkCd<=0&&u.dmg>0){
          u.atkCd=1/u.rate;
          fireWeapon(u,t);
        }
        break;
      }
      case 'hold':
        u.angle+=dt*0.5;
        break;
      case 'gather': {
        const o=u.order; const res=o&&o.res;
        if(!res||res.amt<=0){
          // depleted: return to depot if carrying, else idle
          if(u.carry>0)orderReturn(u); else { u.state='idle'; u.order=null; }
          break;
        }
        const d=dist(u.x,u.z,res.x,res.z);
        if(d>1.2) moveToward(u,res.x,res.z,dt);
        else {
          // mining
          u.moveT+=dt;
          if(u.carry<5){
            const amt=Math.min(5-u.carry, res.amt, dt*9);
            res.amt-=amt; u.carry+=amt;
            if(res.amt<=0){ orderReturn(u); }
          }
          if(u.carry>=4.99){ u.carry=5; orderReturn(u); }
        }
        break;
      }
      case 'return': {
        const dep=nearestDepot(u);
        if(!dep){ u.state='idle'; break; }
        const d=dist(u.x,u.z,dep.x,dep.z);
        if(d>dep.r*0.6) moveToward(u,dep.x,dep.z,dt);
        else {
          const gain=u.carry;
          sideState[u.side].flux+=gain;
          u.carry=0;
          stats.g+=gain;
          if(gain>0.5)SFX.harvest();
          const res=nearestResource(u.x,u.z);
          if(res)orderGather(u,res);
          else { u.state='idle'; u.order=null; }
        }
        break;
      }
      case 'build': {
        const b=u.buildTarget;
        if(!b||b.hp>=BT[b.type].hp){ u.state='idle'; u.buildTarget=null; break; }
        const d=dist(u.x,u.z,b.x,b.z);
        if(d>b.r+1.5) moveToward(u,b.x+ (b.r+1.2)*Math.cos(u.id*7),b.z+(b.r+1.2)*Math.sin(u.id*7),dt);
        else {
          u.angle=Math.atan2(b.x-u.x,b.z-u.z);
          b.hp=Math.min(BT[b.type].hp, b.hp+dt*BT[b.type].hp/ (BT[b.type].buildT*0.55));
          if(b.hp>=BT[b.type].hp-0.5){ b.hp=BT[b.type].hp; u.state='idle'; u.buildTarget=null; SFX.complete(); }
        }
        break;
      }
    }
    // separation
    if(u.state!=='hold'){
      const near=gridNear(u.x,u.z,u.r+1.2);
      let px=0,pz=0;
      for(const e of near){
        if(e===u||e.hp<=0)continue;
        const d=dist(u.x,u.z,e.x,e.z);
        const min=u.r+e.r*0.6+0.35;
        if(d<min&&d>0.001){
          const f=(min-d)/min;
          px+=(u.x-e.x)/d*f; pz+=(u.z-e.z)/d*f;
        }
      }
      if(px||pz){
        const l=Math.hypot(px,pz)||1;
        u.x+=px/l*dt*4; u.z+=pz/l*dt*4;
      }
    }
    // bounds
    u.x=clamp(u.x,1,MAP-1); u.z=clamp(u.z,1,MAP-1);
    gridMove(u);
    // visibility
    if(gameTime-u.lastSeen>0.2){
      u.lastSeen=gameTime;
      reveal(u.side,u.x,u.z,u.vision);
    }
  }
}
function canTarget(u,e){
  if(u.targets==='ground'&&e.fly&&e.kind==='unit')return false;
  if(u.targets==='ground'&&e.kind==='building')return true;
  if(e.kind==='unit'&&u.targets==='ground'&&!e.fly)return true;
  if(e.kind==='unit'&&u.targets==='both')return true;
  return e.kind==='building';
}
function fireWeapon(u,t){
  let dmg=u.dmg;
  if(sideState[u.side].up.overdrive)dmg*=1.3;
  if(u.type==='striker'&&sideState[u.side].up.airsup)dmg*=1.5;
  const mod=DMG[u.dmgType][t.armor||'light'];
  dmg*=mod;
  damage(t,dmg,u.side,u.side);
  effects.push({type:'shot',x:u.x,z:u.z,y:u.fly?FLY_H:0.8,tx:t.x,tz:t.z,ty:t.fly?FLY_H:0.8,age:0,life:0.12,side:u.side,melee:u.range<3});
  if(u.side===SIDE_P||t.side===SIDE_P)SFX.shot(1);
}
function moveToward(u,tx,tz,dt){
  const d=dist(u.x,u.z,tx,tz);
  if(d<0.05)return;
  let ang=Math.atan2(tx-u.x,tz-u.z);
  // obstacle avoidance: raycast a bit ahead
  const look=2.5;
  const ax=u.x+Math.sin(ang)*look, az=u.z+Math.cos(ang)*look;
  if(!u.fly&&blockedAt(ax,az,u.r)){
    const a1=ang+0.9, a2=ang-0.9;
    const b1=blockedAt(u.x+Math.sin(a1)*look,u.z+Math.cos(a1)*look,u.r);
    const b2=blockedAt(u.x+Math.sin(a2)*look,u.z+Math.cos(a2)*look,u.r);
    if(b1&&!b2)ang=a2; else if(b2&&!b1)ang=a1;
  }
  u.angle=angLerp(u.angle,ang,dt*8);
  const sp=u.speed*(sideState[u.side].up.ion?1.25:1);
  u.x+=Math.sin(u.angle)*sp*dt;
  u.z+=Math.cos(u.angle)*sp*dt;
  if(!u.fly&&blockedAt(u.x,u.z,u.r*0.8)){
    // push out
    u.x-=Math.sin(u.angle)*sp*dt*2; u.z-=Math.cos(u.angle)*sp*dt*2;
  }
}
/* ---------- buildings ---------- */
function updateBuildings(dt){
  for(const b of buildings){
    if(b.dead)continue;
    if(b.hp<=0){ b.dead=true; gridRemove(b);
      effects.push({type:'boom',x:b.x,z:b.z,y:1,age:0,life:0.9,side:1-b.side,r:b.r});
      SFX.boom(1);
      continue;
    }
    const st=sideState[b.side];
    // production
    const d=BT[b.type];
    if(d.produces&&b.hp>0){
      if(b.queue.length===0){ b.qProg=0; }
      else {
        const it=b.queue[0];
        if(it){
          if(st.flux>=0&&st.reserve>=UT[it.type].supply){
            b.qProg+=dt;
            if(b.qProg>=UT[it.type].buildT){
              b.qProg=0; st.reserve-=UT[it.type].supply;
              const ang=R()*6.28;
              const rx=b.x+Math.sin(ang)*(b.r+2.2), rz=b.z+Math.cos(ang)*(b.r+2.2);
              const u=spawnUnit(it.type,b.side, clamp(rx,2,MAP-2), clamp(rz,2,MAP-2));
              SFX.unit();
              if(b.rally)orderMove(u,b.rally.x,b.rally.z);
              else orderHold(u);
              b.queue.shift();
              if(b.side===SIDE_P&&it.type!=='worker')addAlert(SIDE_P,UT[it.type].name+' ready','good');
            }
          } else {
            // supply shortage or (flux already deducted at queue) — pause with feedback
            if(st.reserve<UT[it.type].supply&&b.side===SIDE_P){ b.supplyWarn=gameTime; }
          }
        }
      }
    }
    // research
    if(d.research&&b.hp>0){
      if(st.upQueue){
        const up=UP[st.upQueue];
        st.upProg+=dt;
        if(st.upProg>=up.time){
          st.up[st.upQueue]=true; st.upQueue=null; st.upProg=0;
          SFX.research();
          if(b.side===SIDE_P)addAlert(SIDE_P,up.name+' researched','good');
        }
      }
    }
    // turret / building attack
    if(b.dmg>0&&b.hp>0){
      b.atkCd=Math.max(0,b.atkCd-dt);
      if(b.atkCd<=0){
        const e=nearestEnemy(b,b.range+2);
        if(e&&visibleTo(b,e)&&canTarget(b,e)&&dist(b.x,b.z,e.x,e.z)<=b.range){
          b.atkCd=1/b.rate;
          let dmg=b.dmg;
          if(sideState[b.side].up.overdrive)dmg*=1.3;
          dmg*=DMG[b.dmgType][e.armor||'light'];
          damage(e,dmg,b.side,b.side);
          effects.push({type:'shot',x:b.x,z:b.z,y:2.2,tx:e.x,tz:e.z,ty:e.fly?FLY_H:0.8,age:0,life:0.12,side:b.side,melee:false});
          if(e.side===SIDE_P)SFX.shot(1);
        }
      }
    }
    if(gameTime-b.lastSeen>0.2){
      b.lastSeen=gameTime;
      reveal(b.side,b.x,b.z,d.r+8);
    }
  }
  buildings=buildings.filter(b=>!b.dead);
}
/* ---------- supply bookkeeping ---------- */
function refreshSupply(side){
  const st=sideState[side];
  let used=0;
  for(const u of units) if(u.side===side&&!u.dead)used+=UT[u.type].supply;
  st.supply=used;
}
function tryReserve(side,n){
  const st=sideState[side];
  refreshSupply(side);
  if(st.supply+st.reserve+n>supplyCap(side))return false;
  st.reserve+=n; return true;
}
function releaseReserve(side,n){
  const st=sideState[side];
  st.reserve=Math.max(0,st.reserve-n);
}
/* ---------- effects ---------- */
function updateEffects(dt){
  for(let i=effects.length-1;i>=0;i--){
    const e=effects[i];
    e.age+=dt;
    if(e.age>e.life)effects.splice(i,1);
  }
}
/* ---------- win/lose ---------- */
function checkVictory(){
  let p=0,e=0;
  for(const b of buildings){ if(b.type==='hq'&&b.hp>0){ if(b.side===SIDE_P)p++; else e++; } }
  if(e===0&&gameState==='play'){ gameState='win'; showEnd(true); }
  if(p===0&&gameState==='play'){ gameState='lose'; showEnd(false); }
}
</script>
<script>
/* ============================== AI ============================== */
function aiUpdate(dt){
  if(gameState!=='play'||paused)return;
  if(gameTime<ai.nextThink)return;
  ai.nextThink=gameTime+0.8;
  const st=sideState[SIDE_E];
  refreshSupply(SIDE_E);
  const myB=buildings.filter(b=>b.side===SIDE_E&&b.hp>0);
  const myU=units.filter(u=>u.side===SIDE_E&&!u.dead);
  const workers=myU.filter(u=>u.type==='worker');
  const combat=myU.filter(u=>u.type!=='worker');
  ai.stats.units=combat.length;
  // ---- workers: mine until needed, then expand ----
  const hqs=myB.filter(b=>b.type==='hq');
  const beacons=myB.filter(b=>b.type==='beacon');
  const needed=Math.min(2+hqs.length*2,16) - workers.length;
  if(needed>0){
    const hq=hqs[0];
    if(hq&&hq.queue.length<4&&hq.hp>0){
      if(st.flux>=UT.worker.cost&&tryReserve(SIDE_E,UT.worker.supply)){
        st.flux-=UT.worker.cost; hq.queue.push({type:'worker'});
      }
    }
  }
  // assign idle workers to nearest resource
  for(const w of workers){
    if(w.state==='idle'){
      const res=nearestResource(w.x,w.z);
      if(res)orderGather(w,res);
    }
  }
  // ---- economy: beacons near mines, then production ----
  const mines=resources.filter(r=>r.amt>60&&wNear(r,workers));
  if(beacons.length<hqs.length+1&&mines.length){
    const m=mines[Math.floor(R()*mines.length)];
    const hq=hqs[0];
    if(hq&&hq.queue.length<5&&st.flux>=BT.beacon.cost){
      const x=clamp(m.x+rr(-6,6),6,MAP-6), z=clamp(m.z+rr(-6,6),6,MAP-6);
      if(placeableBuilding(x,z,BT.beacon.r,SIDE_E)){
        st.flux-=BT.beacon.cost;
        const b=spawnBuilding('beacon',SIDE_E,x,z,true);
        const w=workers[0]||null;
        if(w)orderBuild(w,b);
      }
    }
  }
  // expansion
  if(st.flux>500&&workers.length>4&&hqs.length<4&&gameTime-ai.lastExpandT>60){
    // find a resource cluster far from home, visible or explored
    let best=null,bd=-1;
    for(const r of resources){
      if(r.amt<80)continue;
      const dHome=dist(r.x,r.z,hqs[0].x,hqs[0].z);
      if(dHome<40)continue;
      const cx=clamp(Math.floor(r.x/NCELL),0,NRES-1), cz=clamp(Math.floor(r.z/NCELL),0,NRES-1);
      if(!fogE[cz*NRES+cx])continue; // unexplored
      if(dHome>bd){bd=dHome;best=r;}
    }
    if(best){
      const x=clamp(best.x+rr(-8,8),6,MAP-6), z=clamp(best.z+rr(-8,8),6,MAP-6);
      if(placeableBuilding(x,z,BT.hq.r,SIDE_E)&&st.flux>=BT.hq.cost){
        st.flux-=BT.hq.cost;
        const b=spawnBuilding('hq',SIDE_E,x,z,true);
        b.rally={x:x+6,z:z+6};
        ai.lastExpandT=gameTime;
        // send 2 workers
        const ws=workers.filter(w=>w.state==='gather'||w.state==='idle').slice(0,2);
        for(const w of ws)orderBuild(w,b);
        addAlert(SIDE_E,'Enemy expansion detected!','bad');
      }
    }
  }
  // production buildings
  const forges=myB.filter(b=>b.type==='forge');
  const hangars=myB.filter(b=>b.type==='hangar');
  const labs=myB.filter(b=>b.type==='lab');
  if(forges.length<hqs.length&&st.flux>=BT.forge.cost){
    const hq=hqs[0];
    if(hq&&hq.queue.length<6){
      st.flux-=BT.forge.cost;
      const b=spawnBuilding('forge',SIDE_E,hq.x+rr(-9,9),hq.z+rr(-9,9),true);
      const w=workers.find(w=>w.state!=='build');
      if(w)orderBuild(w,b);
    }
  }
  if(hangars.length<hqs.length&&st.flux>=BT.hangar.cost&&gameTime>120){
    const hq=hqs[0];
    if(hq&&hq.queue.length<6){
      st.flux-=BT.hangar.cost;
      const b=spawnBuilding('hangar',SIDE_E,hq.x+rr(-9,9),hq.z+rr(-9,9),true);
      const w=workers.find(w=>w.state!=='build');
      if(w)orderBuild(w,b);
    }
  }
  if(labs.length<hqs.length&&st.flux>=BT.lab.cost&&gameTime>90){
    const hq=hqs[0];
    if(hq&&hq.queue.length<6){
      st.flux-=BT.lab.cost;
      const b=spawnBuilding('lab',SIDE_E,hq.x+rr(-9,9),hq.z+rr(-9,9),true);
      const w=workers.find(w=>w.state!=='build');
      if(w)orderBuild(w,b);
    }
  }
  // defenses
  const turrets=myB.filter(b=>b.type==='turret');
  if(turrets.length<hqs.length*2&&st.flux>=BT.turret.cost){
    const hq=hqs[0];
    if(hq&&hq.queue.length<8){
      st.flux-=BT.turret.cost;
      const b=spawnBuilding('turret',SIDE_E,hq.x+rr(-11,11),hq.z+rr(-11,11),true);
      const w=workers.find(w=>w.state!=='build');
      if(w)orderBuild(w,b);
    }
  }
  // research
  if(labs.length&&st.flux>=150&&!st.upQueue){
    const order=['heavyarmor','overdrive','harvest','plating','ion','airsup'];
    for(const k of order) if(!st.up[k]){ st.upQueue=k; st.upProg=0; break; }
  }
  // ---- army training (ramps with economy) ----
  const tier=combat.length;
  const want={blade:0,lancer:0,goliath:0,striker:0};
  if(tier<6){ want.blade=3; want.lancer=1; }
  else if(tier<14){ want.blade=4; want.lancer=3; want.striker=2; }
  else if(tier<26){ want.blade=4; want.lancer=4; want.striker=3; if(st.up.heavyarmor)want.goliath=2; }
  else { want.blade=5; want.lancer=5; want.striker=4; if(st.up.heavyarmor)want.goliath=4; }
  const counts={blade:0,lancer:0,goliath:0,striker:0};
  for(const u of combat)counts[u.type]++;
  for(const f of forges){
    for(let i=0;i<f.queue.length;i++){
      const t=f.queue[i].type;
      if(counts[t]<(want[t]||0)&&st.reserve<supplyCap(SIDE_E)-st.supply-4){
        const c=UT[t].cost;
        if(t==='goliath'&&!st.up.heavyarmor)continue;
        if(st.flux>=c&&tryReserve(SIDE_E,UT[t].supply)){ st.flux-=c; f.queue.push({type:t}); counts[t]++; }
      }
    }
  }
  for(const h of hangars){
    for(let i=0;i<h.queue.length;i++){
      if(counts.striker<(want.striker||0)&&st.flux>=UT.striker.cost&&tryReserve(SIDE_E,UT.striker.supply)){
        st.flux-=UT.striker.cost; h.queue.push({type:'striker'}); counts.striker++;
      }
    }
  }
  // ---- scouting ----
  if(gameTime>ai.scoutT&&combat.length>2){
    ai.scoutT=gameTime+25;
    const scout=combat.find(u=>u.type==='striker')||combat[0];
    if(scout){
      // scout toward player's known direction or map center
      let tx=MAP/2,tz=MAP/2;
      if(ai.knownPlayer>0){ tx=ai.knownPlayer.x; tz=ai.knownPlayer.z; }
      orderAttackMove(scout,clamp(tx,6,MAP-6),clamp(tz,6,MAP-6));
    }
  }
  // ---- attack planning ----
  const playerHqKnown=ai.knownPlayer>0;
  const ready=combat.length;
  const minAtk=Math.min(4+Math.floor(gameTime/40),30);
  if(ready>=minAtk&&gameTime-ai.lastAttackT>80&&gameTime>100){
    // pick target: player HQ if known, else nearest visible player building
    let tx, tz;
    if(playerHqKnown){ tx=ai.knownPlayer.x; tz=ai.knownPlayer.z; }
    else {
      let best=null,bd=1e9;
      for(const b of buildings){
        if(b.side!==SIDE_P||b.hp<=0)continue;
        const cx=clamp(Math.floor(b.x/NCELL),0,NRES-1), cz=clamp(Math.floor(b.z/NCELL),0,NRES-1);
        if(!visE[cz*NRES+cx])continue;
        const d=dist(b.x,b.z,hqs[0].x,hqs[0].z);
        if(d<bd){bd=d;best=b;}
      }
      if(!best){ // no visible target: send raid to center
        tx=MAP/2; tz=MAP/2;
      } else { tx=best.x; tz=best.z; }
    }
    // send combat units (keep 25% home)
    const keep=Math.max(2,Math.floor(combat.length*0.25));
    const senders=combat.slice(keep);
    for(const u of senders){
      orderAttackMove(u,tx,tz);
      u.amOrigin=null;
    }
    ai.lastAttackT=gameTime;
    ai.lastAttackSize=senders.length;
    addAlert(SIDE_P,'Enemy force detected on the move!','bad');
  }
  // ---- defense: if enemy units visible near my buildings, rally ----
  const visPnear=units.filter(u=>u.side===SIDE_P&&!u.dead&&(u.fly?true:true));
  let threat=null;
  for(const u of visPnear){
    const cx=clamp(Math.floor(u.x/NCELL),0,NRES-1), cz=clamp(Math.floor(u.z/NCELL),0,NRES-1);
    if(!visE[cz*NRES+cx])continue;
    for(const b of myB){
      if(dist(u.x,u.z,b.x,b.z)<18){ threat={x:u.x,z:u.z}; break; }
    }
    if(threat)break;
  }
  if(threat){
    const def=combat.filter(u=>u.state!=='attackmove'&&dist(u.x,u.z,hqs[0].x,hqs[0].z)<25);
    for(const u of def.slice(0,6)) orderAttackMove(u,threat.x,threat.z);
  }
}
function wNear(r,workers){
  for(const w of workers){ if(dist(r.x,r.z,w.x,w.z)<20)return true; }
  // also count explored beacon proximity
  for(const b of buildings){ if(b.side===SIDE_E&&BT[b.type].returns&&dist(r.x,r.z,b.x,b.z)<14)return true; }
  return false;
}
</script>
<script>
/* ============================== RENDERING ============================== */
let proj=null, view=null, vp=null;
function camUpdate(dt){
  // edge scroll (only once the mouse has actually moved on screen)
  let ex=0,ez=0;
  const m=14;
  if(!mouse.moved)return;
  if(mouse.x<m)ex=-1; else if(mouse.x>ovc.width-m)ex=1;
  if(mouse.y<m)ez=-1; else if(mouse.y>ovc.height-m)ez=1;
  if(ex||ez){
    const s=cam.zoom*0.7*dt;
    cam.x+= (Math.cos(cam.yaw)*ex + Math.sin(cam.yaw)*ez)*s;
    cam.z+= (-Math.sin(cam.yaw)*ex + Math.cos(cam.yaw)*ez)*s;
  }
  cam.x=clamp(cam.x,4,MAP-4); cam.z=clamp(cam.z,4,MAP-4);
  cam.y=cam.zoom*Math.sin(cam.pitch)+4;
}
function camMatrices(){
  const asp=glc.width/glc.height;
  proj=m4Persp(0.9,asp,1,420);
  const ex=cam.x+Math.sin(cam.yaw)*cam.zoom*Math.cos(cam.pitch);
  const ez=cam.z+Math.cos(cam.yaw)*cam.zoom*Math.cos(cam.pitch);
  const ey=cam.zoom*Math.sin(cam.pitch)+4;
  view=m4LookAt(ex,ey,ez,cam.x,0,cam.z);
  vp=m4Mul(proj,view);
  return {ex,ey,ez};
}
const instData=new Float32Array(capInst*ISTRIDE);
function matRotY(x,y,z,ang,sx,sy,sz){
  const c=Math.cos(ang),s=Math.sin(ang);
  return[c*sx,0,-s*sz, x,  0,sy,0, y,  s*sz,0,c*sx, z,  0,0,0, 1];
}
function render(){
  if(!heightData)return; // world not generated yet (title screen)
  const t=gameTime;
  const eye=camMatrices();
  gl.viewport(0,0,glc.width,glc.height);
  gl.clearColor(0,0,0,1);
  gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);
  gl.enable(gl.DEPTH_TEST);
  gl.disable(gl.CULL_FACE);
  // ISOLATED TEST: fresh prism buffer, scale 100
  gl.useProgram(progMain.p);
  gl.uniformMatrix4fv(progMain.uMVP,false,vp);
  gl.uniform3f(progMain.uCam,eye.ex,eye.ey,eye.ez);
  gl.uniform3f(progMain.uLight,-0.35,1.0,-0.2);
  if(!window.__fp){
    const p=(function(){const pos=[],idx=[];for(let i=0;i<8;i++){const a=i/8*Math.PI*2;pos.push(Math.cos(a),0,Math.sin(a),0.5,0.5,1);}for(let i=0;i<8;i++){const a=i/8*Math.PI*2;pos.push(Math.cos(a)*0.4,1,Math.sin(a)*0.4,0.5,0.5,1);}for(let i=0;i<8;i++){const j=(i+1)%8;idx.push(i,j,8+j, i,8+j,8+i);}return{pos:new Float32Array(pos),idx:new Uint16Array(idx)};})();
    const v=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,v);gl.bufferData(gl.ARRAY_BUFFER,p.pos,gl.STATIC_DRAW);
    const ib=gl.createBuffer();gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,ib);gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,p.idx,gl.STATIC_DRAW);
    window.__fp={vbo:v,ibo:ib,nIdx:p.idx.length};
  }
  _m4=matRotY(36,5,128,0,100,100,100);
  gl.uniformMatrix4fv(progMain.uM,false,_m4); gl.uniform3f(progMain.uTint,1,0,1);
  bindGeo(window.__fp);
  gl.drawElements(gl.TRIANGLES,window.__fp.nIdx,gl.UNSIGNED_SHORT,0);
  return;
  // ---- terrain ----
  {
    gl.useProgram(progFog.p);
    gl.activeTexture(gl.TEXTURE0);
    gl.uniformMatrix4fv(progFog.uMVP,false,vp);
    gl.uniform1i(progFog.uTex,0);
    gl.bindTexture(gl.TEXTURE_2D,progFog.terrainTex);
    bindGeo(progFog.terrain);
    gl.bindBuffer(gl.ARRAY_BUFFER,progFog.terrainTint);
    gl.vertexAttribPointer(2,3,gl.FLOAT,false,0,0);
    gl.enableVertexAttribArray(2);
    gl.drawElements(gl.TRIANGLES,progFog.terrain.nIdx,gl.UNSIGNED_SHORT,0);
    gl.disableVertexAttribArray(2);
    gl.vertexAttribDivisor(2,0);
    // fog overlay (no depth write so it never occludes entities)
    gl.enable(gl.BLEND);
    gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA);
    gl.depthMask(false);
    gl.bindTexture(gl.TEXTURE_2D,progFog.tex);
    gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,NRES,NRES,0,gl.RGBA,gl.UNSIGNED_BYTE,fogPx);
    bindGeo(progFog.geo);
    gl.drawElements(gl.TRIANGLES,progFog.geo.nIdx,gl.UNSIGNED_SHORT,0);
    gl.depthMask(true);
    gl.disable(gl.BLEND);
  }
  // ---- units & buildings (per-entity draw calls) ----
  gl.enable(gl.DEPTH_TEST);
  gl.useProgram(progMain.p);
  gl.uniformMatrix4fv(progMain.uMVP,false,vp);
  gl.uniform3f(progMain.uCam,eye.ex,eye.ey,eye.ez);
  gl.uniform3f(progMain.uLight,-0.35,1.0,-0.2);
  drawEntitiesMain(t);
  drawObstacles();
  // ---- glow pass (resource deposits) ----
  gl.enable(gl.BLEND);
  gl.blendFunc(gl.SRC_ALPHA,gl.ONE);
  gl.depthMask(false);
  gl.useProgram(progGlow.p);
  gl.uniformMatrix4fv(progGlow.uMVP,false,vp);
  gl.uniform1f(progGlow.uTime,t);
  gl.uniform1f(progGlow.uAlpha,1);
  drawGlowRes(t);
  // selection rings
  gl.uniform1f(progGlow.uAlpha,0.9);
  drawGlowRings();
  gl.depthMask(true);
  gl.disable(gl.BLEND);
  // ---- lines (shots) ----
  if(effects.length){
    gl.useProgram(progLine.p);
    gl.uniformMatrix4fv(progLine.uMVP,false,vp);
    gl.enable(gl.BLEND);
    gl.blendFunc(gl.SRC_ALPHA,gl.ONE);
    gl.depthMask(false);
    let n=0;
    const lineData=[];
    for(const e of effects){
      if(e.type!=='shot')continue;
      const c=PAL[e.side].glow;
      if(e.melee){
        lineData.push(e.x,0.6,e.z, 0,1, c[0],c[1],c[2]);
        lineData.push(e.x+ (e.tx-e.x)*0.8, 0.6, e.z+(e.tz-e.z)*0.8, 0,0, c[0],c[1],c[2]);
      } else {
        lineData.push(e.x,e.y,e.z, 0,1, c[0],c[1],c[2]);
        lineData.push(e.tx,e.ty,e.tz, 0,0, c[0],c[1],c[2]);
      }
    }
    if(lineData.length){
      const b=gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER,b);
      gl.bufferData(gl.ARRAY_BUFFER,new Float32Array(lineData),gl.DYNAMIC_DRAW);
      gl.vertexAttribPointer(0,3,gl.FLOAT,false,32,0);
      gl.vertexAttribPointer(1,2,gl.FLOAT,false,32,12);
      gl.vertexAttribPointer(2,3,gl.FLOAT,false,32,20);
      gl.enableVertexAttribArray(0);gl.enableVertexAttribArray(1);gl.enableVertexAttribArray(2);
      gl.vertexAttribDivisor(0,0);gl.vertexAttribDivisor(1,0);gl.vertexAttribDivisor(2,0);
      gl.drawArrays(gl.LINES,0,lineData.length/8);
      gl.deleteBuffer(b);
    }
    gl.depthMask(true);
    gl.disable(gl.BLEND);
  }
  // ---- overlay canvas ----
  drawOverlay(eye);
  drawMinimap();
}
function bindGeo(g){
  gl.bindBuffer(gl.ARRAY_BUFFER,g.vbo);
  gl.vertexAttribPointer(0,3,gl.FLOAT,false,24,0);
  gl.vertexAttribPointer(1,2,gl.FLOAT,false,24,12);
  gl.enableVertexAttribArray(0);gl.enableVertexAttribArray(1);
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,g.ibo);
}
let _m4=new Float32Array(16);
function drawEntitiesMain(t){
  // units
  for(const u of units){
    if(u.dead)continue;
    const cx=clamp(Math.floor(u.x/NCELL),0,NRES-1), cz=clamp(Math.floor(u.z/NCELL),0,NRES-1);
    if(u.side===SIDE_P){ if(!visP[cz*NRES+cx]&&gameTime-u.vis>0.5)continue; }
    else { if(!visP[cz*NRES+cx])continue; }
    const pal=PAL[u.side];
    const y=u.fly?FLY_H+Math.sin(t*2+u.id*9)*0.15:hAt(u.x,u.z);
    const c=u.fly?pal.accent:pal.body;
    _m4=matRotY(u.x,y,u.z,u.angle,1,1,1);
    gl.uniformMatrix4fv(progMain.uM,false,_m4);
    gl.uniform3f(progMain.uTint,c[0],c[1],c[2]);
    bindGeo(GEO[u.type]);
    gl.drawElements(gl.TRIANGLES,GEO[u.type].nIdx,gl.UNSIGNED_SHORT,0);
  }
  // buildings
  for(const b of buildings){
    if(b.hp<=0)continue;
    const cx=clamp(Math.floor(b.x/NCELL),0,NRES-1), cz=clamp(Math.floor(b.z/NCELL),0,NRES-1);
    if(b.side===SIDE_P){ if(!visP[cz*NRES+cx]&&gameTime-b.lastSeen>0.5)continue; }
    else { if(!visP[cz*NRES+cx])continue; }
    const pal=PAL[b.side];
    const y=hAt(b.x,b.z);
    const underC=b.hp<BT[b.type].hp*0.999;
    const c=underC?[0.5,0.5,0.52]:pal.body;
    _m4=matRotY(b.x,y,b.z,0,1,1,1);
    gl.uniformMatrix4fv(progMain.uM,false,_m4);
    gl.uniform3f(progMain.uTint,c[0],c[1],c[2]);
    bindGeo(GEO[b.type]);
    gl.drawElements(gl.TRIANGLES,GEO[b.type].nIdx,gl.UNSIGNED_SHORT,0);
  }
}
function drawObstacles(){
  const g=GEO.rock;
  for(const o of obstacles){
    const cx=clamp(Math.floor(o.x/NCELL),0,NRES-1), cz=clamp(Math.floor(o.z/NCELL),0,NRES-1);
    if(!fogP[cz*NRES+cx])continue;
    const y=hAt(o.x,o.z);
    const s=o.r*(o.type==='rock'?1:0.9);
    _m4=matRotY(o.x,y,o.z,o.x*0.7+o.z,s,o.h/s,s);
    gl.uniformMatrix4fv(progMain.uM,false,_m4);
    gl.uniform3f(progMain.uTint,0.42,0.45,0.5);
    bindGeo(g);
    gl.drawElements(gl.TRIANGLES,g.nIdx,gl.UNSIGNED_SHORT,0);
  }
}
function drawGlowRes(t){
  const g=GEO.dia;
  for(const r of resources){
    if(r.amt<=0)continue;
    const cx=clamp(Math.floor(r.x/NCELL),0,NRES-1), cz=clamp(Math.floor(r.z/NCELL),0,NRES-1);
    const known=fogP[cz*NRES+cx];
    if(!known)continue;
    const seen=visP[cz*NRES+cx];
    if(!seen&&gameTime-lastSeenP[cz*NRES+cx]>3)continue;
    const s=1.4+(r.amt/r.max)*1.2;
    _m4=matRotY(r.x,hAt(r.x,r.z)+0.15,r.z,0,s,s*0.4,s);
    gl.uniformMatrix4fv(progGlow.uM,false,_m4);
    gl.uniform3f(progGlow.uTint,0.4,1.0,0.75);
    bindGeo(g);
    gl.drawElements(gl.TRIANGLES,g.nIdx,gl.UNSIGNED_SHORT,0);
  }
}
function drawGlowRings(){
  const g=GEO.dia;
  const pushRing=(x,z,y,s,c)=>{
    _m4=matRotY(x,y,z,0,s,s*0.25,s);
    gl.uniformMatrix4fv(progGlow.uM,false,_m4);
    gl.uniform3f(progGlow.uTint,c[0],c[1],c[2]);
    bindGeo(g);
    gl.drawElements(gl.TRIANGLES,g.nIdx,gl.UNSIGNED_SHORT,0);
  };
  for(const u of units){
    if(u.side!==SIDE_P||u.dead||!u.selected)continue;
    pushRing(u.x,u.z,u.fly?FLY_H+0.2:hAt(u.x,u.z)+0.12,u.r+0.5,[0.3,1.0,0.5]);
  }
  if(selBuilding&&selBuilding.hp>0){
    pushRing(selBuilding.x,selBuilding.z,hAt(selBuilding.x,selBuilding.z)+0.12,selBuilding.r+0.8,[0.3,1.0,0.5]);
  }
  if(placing){
    pushRing(placing.x,placing.z,hAt(placing.x,placing.z)+0.12,BT[placing.btype].r+0.5,placing.valid?[0.3,1.0,0.5]:[1.0,0.3,0.35]);
  }
}
function initGL_instReset(){ return instBuf; }
/* ---------- overlay (2D) ---------- */
function drawOverlay(eye){
  const w=ovc.width,h=ovc.height;
  octx.clearRect(0,0,w,h);
  const projP=(x,y,z)=>{
    const v=m4Xform(vp,x,y,z);
    if(v[3]<=0.01)return null;
    const sx=(v[0]/v[3]*0.5+0.5)*w, sy=(-v[1]/v[3]*0.5+0.5)*h;
    return {x:sx,y:sy};
  };
  // health bars
  octx.font='10px Consolas,monospace';
  for(const u of units){
    if(u.dead)continue;
    if(u.side===SIDE_P){
      if(!u.selected&&u.hp>=u.maxHp)continue;
      if(u.hp>=u.maxHp&&!u.selected)continue;
      const cx=clamp(Math.floor(u.x/NCELL),0,NRES-1), cz=clamp(Math.floor(u.z/NCELL),0,NRES-1);
      if(!visP[cz*NRES+cx])continue;
    } else {
      const cx=clamp(Math.floor(u.x/NCELL),0,NRES-1), cz=clamp(Math.floor(u.z/NCELL),0,NRES-1);
      if(!visP[cz*NRES+cx])continue;
    }
    if(u.hp>=u.maxHp)continue;
    const y=u.fly?FLY_H+1.4:hAt(u.x,u.z)+1.6;
    const p=projP(u.x,y,u.z);
    if(!p)continue;
    const bw=Math.max(14,26*cam.zoom/26);
    const f=u.hp/u.maxHp;
    octx.fillStyle='rgba(0,0,0,0.55)';
    octx.fillRect(p.x-bw/2,p.y,bw,3);
    octx.fillStyle=u.side===SIDE_P?'#37e08a':'#ff5566';
    octx.fillRect(p.x-bw/2,p.y,bw*f,3);
  }
  for(const b of buildings){
    if(b.hp<=0||b.hp>=b.maxHp)continue;
    const p=projP(b.x,hAt(b.x,b.z)+b.r*0.8,b.z);
    if(!p)continue;
    const bw=34;
    octx.fillStyle='rgba(0,0,0,0.55)';
    octx.fillRect(p.x-bw/2,p.y,bw,3);
    octx.fillStyle=b.side===SIDE_P?'#37e08a':'#ff5566';
    octx.fillRect(p.x-bw/2,p.y,bw*(b.hp/b.maxHp),3);
  }
  // order marker
  if(orderMarker&&gameTime-orderMarker.t<1.2){
    const p=projP(orderMarker.x,hAt(orderMarker.x,orderMarker.z)+0.2,orderMarker.z);
    if(p){
      const a=1-(gameTime-orderMarker.t)/1.2;
      octx.strokeStyle=`rgba(120,255,160,${a})`;
      octx.lineWidth=2;
      const r=6+(gameTime-orderMarker.t)*14;
      octx.beginPath();octx.arc(p.x,p.y,r,0,6.28);octx.stroke();
      if(orderMarker.kind==='attack'||orderMarker.kind==='attackmove'){
        octx.beginPath();octx.moveTo(p.x-5,p.y-5);octx.lineTo(p.x+5,p.y+5);octx.moveTo(p.x+5,p.y-5);octx.lineTo(p.x-5,p.y+5);octx.stroke();
      }
    }
  }
  // placement ghost
  if(placing){
    const d=BT[placing.btype];
    const p=projP(placing.x,hAt(placing.x,placing.z)+0.3,placing.z);
    if(p){
      octx.strokeStyle=placing.valid?'rgba(80,255,140,0.8)':'rgba(255,80,90,0.8)';
      octx.lineWidth=2;
      octx.beginPath();
      for(let i=0;i<=16;i++){
        const a=i/16*Math.PI*2;
        const q=projP(placing.x+Math.sin(a)*d.r,placing.y||hAt(placing.x,placing.z)+0.3,placing.z+Math.cos(a)*d.r);
        if(i===0)octx.moveTo(q.x,q.y);else octx.lineTo(q.x,q.y);
      }
      octx.stroke();
      octx.fillStyle=placing.valid?'rgba(80,255,140,0.12)':'rgba(255,80,90,0.12)';
      octx.fill();
    }
  }
  // selection box
  if(dragBox){
    octx.strokeStyle='rgba(90,220,255,0.9)';
    octx.lineWidth=1;
    octx.strokeRect(Math.min(dragBox.x0,dragBox.x1),Math.min(dragBox.y0,dragBox.y1),Math.abs(dragBox.x1-dragBox.x0),Math.abs(dragBox.y1-dragBox.y0));
    octx.fillStyle='rgba(90,220,255,0.08)';
    octx.fillRect(Math.min(dragBox.x0,dragBox.x1),Math.min(dragBox.y0,dragBox.y1),Math.abs(dragBox.x1-dragBox.x0),Math.abs(dragBox.y1-dragBox.y0));
  }
}
let dragBox=null;
/* ---------- minimap ---------- */
const mmc=document.getElementById('mmc'), mmctx=mmc.getContext('2d');
const mmImg=mmctx.createImageData(100,100);
function drawMinimap(){
  if(!fogP)return;
  const S=100, sc=S/MAP;
  // terrain + fog
  for(let py=0;py<S;py+=2)for(let px=0;px<S;px+=2){
    const wx=px/sc, wz=py/sc;
    const cx=clamp(Math.floor(wx/NCELL),0,NRES-1), cz=clamp(Math.floor(wz/NCELL),0,NRES-1);
    const i=(py*S+px)*4;
    if(!fogP[cz*NRES+cx]){
      mmImg.data[i]=5;mmImg.data[i+1]=8;mmImg.data[i+2]=13;
      for(let dy=0;dy<2;dy++)for(let dx=0;dx<2;dx++){const j=((py+dy)*S+(px+dx))*4; mmImg.data[j]=5;mmImg.data[j+1]=8;mmImg.data[j+2]=13;}
    } else {
      const h=hAt(wx,wz)/2.2;
      const r=visP[cz*NRES+cx]?1:0.45;
      mmImg.data[i]=(30+h*110)*r;mmImg.data[i+1]=(40+h*130)*r;mmImg.data[i+2]=(52+h*115)*r;
      for(let dy=0;dy<2;dy++)for(let dx=0;dx<2;dx++){const j=((py+dy)*S+(px+dx))*4; mmImg.data[j]=mmImg.data[i];mmImg.data[j+1]=mmImg.data[i+1];mmImg.data[j+2]=mmImg.data[i+2];}
    }
  }
  mmctx.putImageData(mmImg,0,0);
  // resources (explored)
  mmctx.fillStyle='#5fe8a8';
  for(const r of resources){
    if(r.amt<=0)continue;
    const cx=clamp(Math.floor(r.x/NCELL),0,NRES-1), cz=clamp(Math.floor(r.z/NCELL),0,NRES-1);
    if(!fogP[cz*NRES+cx])continue;
    mmctx.globalAlpha=visP[cz*NRES+cx]?0.9:0.35;
    mmctx.fillRect(r.x*sc-1.5,r.z*sc-1.5,3,3);
  }
  mmctx.globalAlpha=1;
  // buildings
  for(const b of buildings){
    if(b.hp<=0)continue;
    const cx=clamp(Math.floor(b.x/NCELL),0,NRES-1), cz=clamp(Math.floor(b.z/NCELL),0,NRES-1);
    if(b.side===SIDE_P){ if(!visP[cz*NRES+cx]&&gameTime-b.lastSeen>2)continue; }
    else { if(!visP[cz*NRES+cx])continue; }
    mmctx.fillStyle=b.side===SIDE_P?'#59d8ff':'#ff5566';
    const s=b.type==='hq'?5:3;
    mmctx.fillRect(b.x*sc-s/2,b.z*sc-s/2,s,s);
  }
  // units
  for(const u of units){
    if(u.dead)continue;
    const cx=clamp(Math.floor(u.x/NCELL),0,NRES-1), cz=clamp(Math.floor(u.z/NCELL),0,NRES-1);
    if(u.side===SIDE_P){ if(!visP[cz*NRES+cx])continue; }
    else { if(!visP[cz*NRES+cx])continue; }
    mmctx.fillStyle=u.side===SIDE_P?'#bfefff':'#ff8896';
    mmctx.fillRect(u.x*sc-1,u.z*sc-1,2,2);
  }
  // camera footprint
  const cz=cam.zoom*0.5;
  mmctx.strokeStyle='rgba(255,255,255,0.75)';
  mmctx.lineWidth=1;
  mmctx.strokeRect((cam.x-cz)*sc,(cam.z-cz)*sc,cz*2*sc,cz*2*sc);
}
</script>
<script>
/* ============================== INPUT & UI ============================== */
const mouse={x:-1000,y:-1000,down:false,rdown:false,moved:false};
let mmDrag=null, keys={};
function resize(){
  const dpr=Math.min(window.devicePixelRatio||1,1.5);
  glc.width=innerWidth*dpr; glc.height=innerHeight*dpr;
  ovc.width=innerWidth*dpr; ovc.height=innerHeight*dpr;
  glc.style.width=ovc.style.width=innerWidth+'px';
  glc.style.height=ovc.style.height=innerHeight+'px';
}
addEventListener('resize',resize);
function m4Inv(m){
  const a00=m[0],a01=m[4],a02=m[8],a03=m[12],
        a10=m[1],a11=m[5],a12=m[9],a13=m[13],
        a20=m[2],a21=m[6],a22=m[10],a23=m[14],
        a30=m[3],a31=m[7],a32=m[11],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 det=b00*b11-b01*b10+b02*b09+b03*b08-b04*b07+b05*b06;
  if(!det)return null;
  det=1/det;
  return[
   (a11*b11-a12*b10+a13*b09)*det, (a02*b10-a01*b11-a03*b09)*det,
   (a31*b05-a32*b04+a33*b03)*det, (a22*b04-a21*b05-a23*b03)*det,
   (a12*b08-a10*b11-a13*b07)*det, (a00*b11-a02*b08+a03*b07)*det,
   (a32*b02-a30*b05-a33*b01)*det, (a20*b05-a22*b02+a23*b01)*det,
   (a10*b10-a11*b08+a13*b06)*det, (a01*b08-a00*b10-a03*b06)*det,
   (a30*b04-a31*b02+a33*b00)*det, (a21*b02-a20*b04-a23*b00)*det,
   (a11*b07-a10*b09-a12*b06)*det, (a00*b09-a01*b07+a02*b06)*det,
   (a31*b01-a30*b03-a32*b00)*det, (a20*b03-a21*b01+a22*b00)*det
  ];
}
function screenToWorld(sx,sy){
  const w=glc.width,h=glc.height;
  const nx=sx/w*2-1, ny=1-sy/h*2;
  const inv=m4Inv(vp);
  if(!inv)return null;
  const p=m4Xform(inv,nx,ny,-1,1);
  const q=m4Xform(inv,nx,ny,1,1);
  if(p[3]===0||q[3]===0)return null;
  const ox=p[0]/p[3],oy=p[1]/p[3],oz=p[2]/p[3];
  const dx=q[0]/q[3]-ox, dy=q[1]/q[3]-oy, dz=q[2]/q[3]-oz;
  if(Math.abs(dy)<1e-6)return null;
  const t=-oy/dy;
  if(t<0)return null;
  return {x:ox+dx*t, z:oz+dz*t};
}
function project(sx,sy){}
function worldToScreen(x,y,z){
  const v=m4Xform(vp,x,y,z);
  if(v[3]<=0.01)return null;
  return {x:(v[0]/v[3]*0.5+0.5)*glc.width, y:(-v[1]/v[3]*0.5+0.5)*glc.height};
}
function pickAt(sx,sy){
  let best=null,bd=1e9;
  const th=14;
  for(const u of units){
    if(u.side!==SIDE_P||u.dead)continue;
    const y=u.fly?FLY_H+0.5:hAt(u.x,u.z)+0.5;
    const p=worldToScreen(u.x,y,u.z);
    if(!p)continue;
    const d=dist2(p.x,p.y,sx,sy);
    if(d<th*th&&d<bd){bd=d;best=u;}
  }
  for(const b of buildings){
    if(b.side!==SIDE_P||b.hp<=0)continue;
    const p=worldToScreen(b.x,hAt(b.x,b.z)+b.r*0.5,b.z);
    if(!p)continue;
    const d=dist2(p.x,p.y,sx,sy);
    const thb=th+b.r*4;
    if(d<thb*thb&&d<bd){bd=d;best=b;}
  }
  return best;
}
function doSelect(e,shift,ctrl){
  if(gameState!=='play')return;
  const e2=e.target;
  if(e2===mmc||mmc.contains(e2))return;
  const sx=e.clientX*dprScale(), sy=e.clientY*dprScale();
  if(ctrl){
    // select same type
    const p=pickAt(sx,sy);
    if(p){
      const list=p.kind==='unit'?units.filter(u=>u.side===SIDE_P&&!u.dead&&u.type===p.type):buildings.filter(b=>b.side===SIDE_P&&b.type===p.type&&b.hp>0);
      sel=list; selBuilding=list.find(b=>b.kind==='building')||null;
      for(const u of units)u.selected=false; for(const b of buildings)b.selected=false;
      for(const x of sel){x.selected=true; if(x.kind==='building')selBuilding=x;}
      SFX.select(); refreshPanels();
    }
    return;
  }
  const p=pickAt(sx,sy);
  if(p){
    if(p.kind==='unit'){
      if(shift){ p.selected=!p.selected; if(!sel.includes(p))sel.push(p); else sel=sel.filter(x=>x!==p); }
      else { sel=[p]; selBuilding=null; }
    } else {
      if(shift){ p.selected=!p.selected; selBuilding=p; if(!sel.includes(p))sel.push(p); }
      else { sel=[p]; selBuilding=p; }
    }
    for(const u of units)if(u.side===SIDE_P)u.selected=sel.includes(u);
    SFX.select();
    refreshPanels();
  } else if(!shift){
    sel=[]; selBuilding=null;
    for(const u of units)u.selected=false;
    refreshPanels();
  }
}
function dprScale(){ return glc.width/innerWidth; }
function issueFromScreen(sx,sy,kind,shift){
  const wpt=screenToWorld(sx,sy);
  if(!wpt)return;
  issueOrder(kind,wpt.x,wpt.z,shift);
}
function placeFromScreen(sx,sy){
  const wpt=screenToWorld(sx,sy);
  if(!wpt||!placing)return;
  placing.x=wpt.x; placing.z=wpt.z;
  const d=BT[placing.btype];
  placing.valid=placeableBuilding(wpt.x,wpt.z,d.r,SIDE_P)&&sideState[0].flux>=d.cost;
}
/* ---- mouse events ---- */
ovc.addEventListener('mousedown',e=>{
  if(gameState!=='play')return;
  const d=dprScale();
  mouse.x=e.clientX*d; mouse.y=e.clientY*d; mouse.moved=true;
  if(e.button===0){
    if(mmc.contains(e.target)){
      mmDrag={sx:e.clientX,sy:e.clientY,moved:false};
      return;
    }
    if(placing){
      if(placing.valid){
        const cost=BT[placing.btype].cost;
        sideState[0].flux-=cost;
        const b=spawnBuilding(placing.btype,SIDE_P,placing.x,placing.z,true);
        // rally default
        b.rally={x:placing.x+(placing.btype==='hq'?8:5),z:placing.z+4};
        let w=sel.find(u=>u.kind==='unit'&&u.type==='worker');
        if(w){ orderBuild(w,b); }
        SFX.build();
        placing=null;
      } else SFX.error();
      return;
    }
    mouse.down=true;
    dragBox={x0:mouse.x,y0:mouse.y,x1:mouse.x,y1:mouse.y,shift:e.shiftKey};
  }
  else if(e.button===2){
    if(mmc.contains(e.target)){
      const r=mmc.getBoundingClientRect();
      const wx=(e.clientX-r.left)/r.width*MAP, wz=(e.clientY-r.top)/r.height*MAP;
      issueOrder('move', clamp(wx,2,MAP-2), clamp(wz,2,MAP-2), e.shiftKey);
      return;
    }
    if(placing){ placing=null; return; }
    issueFromScreen(mouse.x,mouse.y,'move',e.shiftKey);
  }
});
ovc.addEventListener('mousemove',e=>{
  const d=dprScale();
  mouse.x=e.clientX*d; mouse.y=e.clientY*d; mouse.moved=true;
  if(placing)placeFromScreen(mouse.x,mouse.y);
  if(dragBox&&mouse.down){ dragBox.x1=mouse.x; dragBox.y1=mouse.y; }
  if(mmDrag){
    const dx=e.clientX-mmDrag.sx, dy=e.clientY-mmDrag.sy;
    if(Math.abs(dx)+Math.abs(dy)>3)mmDrag.moved=true;
    if(mmDrag.moved){
      const r=mmc.getBoundingClientRect();
      cam.x=clamp(mmDrag.sx+(e.clientX-mmDrag.sx)*MAP/r.width,4,MAP-4);
      cam.z=clamp(mmDrag.sy+(e.clientY-mmDrag.sy)*MAP/r.height,4,MAP-4);
    }
  }
});
addEventListener('mouseup',e=>{
  if(e.button===0){
    if(dragBox){
      const w=glc.width,h=glc.height;
      const x0=Math.min(dragBox.x0,dragBox.x1), x1=Math.max(dragBox.x0,dragBox.x1);
      const y0=Math.min(dragBox.y0,dragBox.y1), y1=Math.max(dragBox.y0,dragBox.y1);
      if(x1-x0>6||y1-y0>6){
        const list=[];
        for(const u of units){
          if(u.side!==SIDE_P||u.dead)continue;
          const p=worldToScreen(u.x,u.fly?FLY_H+0.5:hAt(u.x,u.z)+0.5,u.z);
          if(p&&p.x>=x0&&p.x<=x1&&p.y>=y0&&p.y<=y1)list.push(u);
        }
        if(dragBox.shift)for(const u of list){u.selected=true; if(!sel.includes(u))sel.push(u);}
        else { sel=list; selBuilding=null; }
        for(const u of units)if(u.side===SIDE_P)u.selected=sel.includes(u);
        SFX.select(); refreshPanels();
      } else {
        if(!placing)doSelect(e,dragBox.shift,false);
      }
      dragBox=null;
    }
    if(mmDrag&&!mmDrag.moved){
      const r=mmc.getBoundingClientRect();
      cam.x=clamp((e.clientX-r.left)/r.width*MAP,4,MAP-4);
      cam.z=clamp((e.clientY-r.top)/r.height*MAP,4,MAP-4);
    }
    mmDrag=null;
  }
  mouse.down=false;
});
ovc.addEventListener('contextmenu',e=>e.preventDefault());
mmc.addEventListener('contextmenu',e=>e.preventDefault());
mmc.addEventListener('mousedown',e=>{
  if(gameState!=='play')return;
  if(e.button===0){ mmDrag={sx:e.clientX,sy:e.clientY,moved:false}; }
  else if(e.button===2){
    const r=mmc.getBoundingClientRect();
    const wx=(e.clientX-r.left)/r.width*MAP, wz=(e.clientY-r.top)/r.height*MAP;
    issueOrder('move',clamp(wx,2,MAP-2),clamp(wz,2,MAP-2),e.shiftKey);
  }
});
addEventListener('mousemove',e=>{
  if(mmDrag){
    const dx=e.clientX-mmDrag.sx, dy=e.clientY-mmDrag.sy;
    if(Math.abs(dx)+Math.abs(dy)>3)mmDrag.moved=true;
    if(mmDrag.moved){
      const r=mmc.getBoundingClientRect();
      cam.x=clamp(mmDrag.sx+(e.clientX-mmDrag.sx)*MAP/r.width,4,MAP-4);
      cam.z=clamp(mmDrag.sy+(e.clientY-mmDrag.sy)*MAP/r.height,4,MAP-4);
    }
  }
});
ovc.addEventListener('wheel',e=>{
  e.preventDefault();
  cam.zoom=clamp(cam.zoom*(e.deltaY>0?1.12:0.89),8,80);
},{passive:false});
/* ---- keyboard ---- */
addEventListener('keydown',e=>{
  if(e.repeat)return;
  keys[e.key.toLowerCase()]=true;
  if(e.key===' '){ e.preventDefault(); if(gameState==='play')togglePause(); else if(gameState==='title')startGame(); return; }
  if(e.key==='Escape'){ if(placing){placing=null;} else if(gameState==='play')togglePause(); return; }
  if(e.key==='F1'){ e.preventDefault(); showHelp(); return; }
  if(e.key==='m'||e.key==='M'){ toggleMute(); return; }
  if(gameState!=='play')return;
  const k=e.key.toLowerCase();
  if(k>='1'&&k<='0'){
    const i=k==='0'?9:parseInt(k)-1;
    if(e.altKey){ cg[i]=sel.slice(); SFX.select(); }
    else { sel=cg[i].filter(x=>!x.dead&&x.hp>0); selBuilding=sel.find(b=>b.kind==='building')||null;
      for(const u of units)u.selected=sel.includes(u);
      SFX.select(); refreshPanels(); }
  }
  if(k==='a'){ for(const u of sel)if(u.kind==='unit')orderAttackMove(u,cam.x,cam.z); SFX.move(); }
  if(k==='s'){ for(const u of sel)if(u.kind==='unit')orderStop(u); SFX.click(); }
  if(k==='h'){ for(const u of sel)if(u.kind==='unit')orderHold(u); SFX.click(); }
  if(e.ctrlKey&&k==='a'){ e.preventDefault(); for(const u of sel)if(u.kind==='unit')orderAttackMove(u,cam.x,cam.z); }
});
addEventListener('keyup',e=>{keys[e.key.toLowerCase()]=false;});
/* ============================== UI PANELS ============================== */
const elSel=document.getElementById('selinfo'), elAct=document.getElementById('actions'), elSide=document.getElementById('sidepanel');
function refreshPanels(){
  // top stats
  const st=sideState[0];
  refreshSupply(0);
  document.getElementById('fluxv').textContent=Math.floor(st.flux);
  document.getElementById('supv').textContent=st.supply+'/'+supplyCap(0);
  const m=Math.floor(gameTime/60), s=Math.floor(gameTime%60);
  document.getElementById('timev').textContent=m+':'+(s<10?'0':'')+s;
  // alerts
  const ab=document.getElementById('alertbox');
  ab.innerHTML=alertMsgs.map(a=>`<div class="alert ${a.cls}">${a.text}</div>`).join('');
  // selection info
  if(sel.length===1&&sel[0].kind==='unit'){
    const u=sel[0], d=UT[u.type];
    elSel.innerHTML=`<div class="t">${d.name} <small style="color:#7fa8c0">${d.desc}</small></div>
      <div class="row">HP <div class="hpbar"><i class="${u.hp/u.maxHp>0.5?'':(u.hp/u.maxHp>0.25?'mid':'low')}" style="width:${u.hp/u.maxHp*100}%"></i></div> ${Math.ceil(u.hp)}/${u.maxHp}</div>
      <div class="row">Supply ${d.supply} · Vision ${d.vision} · ${d.fly?'<span style="color:#8fd8ff">AIR</span>':'Ground'} · Armor ${d.armor}</div>`;
  } else if(sel.length===1&&sel[0].kind==='building'){
    const b=sel[0], d=BT[b.type];
    elSel.innerHTML=`<div class="t">${d.name} <small style="color:#7fa8c0">${d.desc}</small></div>
      <div class="row">HP <div class="hpbar"><i style="width:${b.hp/b.maxHp*100}%"></i></div> ${Math.ceil(b.hp)}/${b.maxHp}</div>`;
  } else if(sel.length>1){
    const counts={};
    let hps=0,hpmax=0;
    for(const x of sel){ if(x.kind==='unit'){counts[x.type]=(counts[x.type]||0)+1; hps+=x.hp;hpmax+=x.maxHp;} }
    let html=`<div class="t">${sel.length} selected</div><div class="row">HP <div class="hpbar"><i style="width:${hps/hpmax*100}%"></i></div> ${Math.floor(hps/hpmax*100)}%</div>`;
    for(const k in counts)html+=`<div class="row" style="font-size:11px">${UT[k].name} × ${counts[k]}</div>`;
    elSel.innerHTML=html;
  } else {
    elSel.innerHTML=`<div class="t" style="color:#7fa8c0">No selection — drag to select, right-click to order</div>`;
  }
  // actions
  let acts=[];
  if(sel.length===1&&sel[0].kind==='unit'&&sel[0].type==='worker'){
    acts.push({label:'Build…',fn:()=>showBuildMenu()});
    acts.push({label:'Return to Stockpile',fn:()=>{orderReturn(sel[0]);SFX.click();}});
  }
  if(sel.some(x=>x.kind==='unit')){
    acts.push({label:'Attack Move <small>A</small>',fn:()=>{for(const u of sel)if(u.kind==='unit')orderAttackMove(u,cam.x,cam.z);SFX.move();}});
    acts.push({label:'Stop <small>S</small>',fn:()=>{for(const u of sel)if(u.kind==='unit')orderStop(u);SFX.click();}});
    acts.push({label:'Hold Position <small>H</small>',fn:()=>{for(const u of sel)if(u.kind==='unit')orderHold(u);SFX.click();}});
  }
  if(selBuilding&&selBuilding.side===SIDE_P){
    const b=selBuilding, d=BT[b.type];
    if(d.produces){
      for(const t of d.produces){
        const u=UT[t];
        const locked=t==='goliath'&&!sideState[0].up.heavyarmor;
        const can=sideState[0].flux>=u.cost&&!locked;
        acts.push({label:`Train ${u.name} <small>${u.cost}◆ ${u.supply}▲${locked?' 🔒':''}</small>`,
          fn:()=>{
            if(locked){addAlert(SIDE_P,'Requires Heavy Plating research','bad');SFX.error();return;}
            if(sideState[0].flux<u.cost){addAlert(SIDE_P,'Not enough flux','bad');SFX.error();return;}
            if(!tryReserve(0,u.supply)){addAlert(SIDE_P,'Supply capacity exceeded','bad');SFX.error();return;}
            sideState[0].flux-=u.cost;
            b.queue.push({type:t});
            SFX.click(); refreshPanels();
          }, disabled:!can});
      }
    }
    if(d.research){
      const order=['harvest','overdrive','plating','ion','airsup','heavyarmor'];
      const nextUp=order.find(k=>!sideState[0].up[k]);
      if(nextUp){
        const u=UP[nextUp];
        const can=sideState[0].flux>=u.cost&&!sideState[0].upQueue;
        acts.push({label:`Research ${u.name} <small>${u.cost}◆</small>`,
          fn:()=>{
            if(sideState[0].upQueue){addAlert(SIDE_P,'Research already in progress','bad');return;}
            if(sideState[0].flux<u.cost){addAlert(SIDE_P,'Not enough flux','bad');SFX.error();return;}
            sideState[0].flux-=u.cost; sideState[0].upQueue=nextUp; sideState[0].upProg=0;
            SFX.click(); refreshPanels();
          },disabled:!can});
      }
      if(sideState[0].upQueue){
        const u=UP[sideState[0].upQueue];
        acts.push({label:`${u.name}… ${Math.floor(sideState[0].upProg/u.time*100)}%`,fn:()=>{},disabled:true});
      }
    }
    // queue display
    if(b.queue.length){
      acts.push({label:`Queue: ${b.queue.length} — cancel last (full refund)`,fn:()=>{
        const it=b.queue[b.queue.length-1];
        if(it){ sideState[0].flux+=UT[it.type].cost; releaseReserve(0,UT[it.type].supply); b.queue.pop(); SFX.click(); refreshPanels(); }
      }});
    }
  }
  elAct.innerHTML='';
  for(const a of acts){
    const b=document.createElement('button');
    b.innerHTML=a.label;
    if(a.disabled)b.disabled=true;
    else b.onclick=a.fn;
    elAct.appendChild(b);
  }
  // side panel (build menu / production)
  if(elSide.dataset.open==='build')renderBuildMenu();
}
function showBuildMenu(){
  elSide.dataset.open='build';
  renderBuildMenu();
}
function renderBuildMenu(){
  let html='<h3>CONSTRUCT (select a worker first)</h3>';
  for(const k in BT){
    const d=BT[k];
    const can=sideState[0].flux>=d.cost;
    html+=`<button class="bbtn" data-b="${k}" ${can?'':'disabled'}><span class="nm">${d.name}<small>${d.desc}</small></span><span class="cost">${d.cost}◆</span></button>`;
  }
  elSide.innerHTML=html;
  elSide.style.display='block';
  elSide.querySelectorAll('[data-b]').forEach(btn=>{
    btn.onclick=()=>{
      const k=btn.dataset.b;
      const worker=sel.find(u=>u.kind==='unit'&&u.type==='worker');
      if(!worker){ addAlert(SIDE_P,'Select a worker to construct','bad'); return; }
      if(sideState[0].flux<BT[k].cost){ addAlert(SIDE_P,'Not enough flux','bad'); SFX.error(); return; }
      placing={btype:k,valid:false,x:worker.x,z:worker.z};
      elSide.dataset.open=''; elSide.style.display='none';
    };
  });
}
/* ============================== SCREENS ============================== */
const screen=document.getElementById('screen');
function showScreen(html){ screen.innerHTML=`<div class="box panel">${html}</div>`; screen.style.display='flex'; }
function hideScreen(){ screen.style.display='none'; }
function showTitle(){
  gameState='title';
  showScreen(`
  <h1>FRONTIER <span>WAR</span></h1>
  <div class="sub">KHAROS-9 — EXPLORATION CAMPAIGN · SKIRMISH</div>
  <p>You command the <b style="color:#59d8ff">AZURE EXPEDITION</b> against the rival <b style="color:#ff8896">CRIMSON SYNDICATE</b> on an uncharted frontier world. The map is unknown — scout it, build an economy, expand, and grow a combined-arms force.</p>
  <div class="cols">
    <div class="col">
      <h4>OBJECTIVE</h4>
      <ul><li>Destroy <b>all enemy Command Spires</b> to win.</li><li>Lose your last Spire and the match is over.</li><li>Build extra Spires as expansions — they are winnable targets too.</li></ul>
      <h4>ECONOMY</h4>
      <ul><li><b>Flux ◆</b> is harvested from glowing deposits by Harvester Drones.</li><li>Beacons add <b>supply ▲</b> and are flux drop-offs — place them near distant deposits.</li><li>Every unit consumes supply capacity. Shortages pause production.</li></ul>
    </div>
    <div class="col">
      <h4>CONTROLS</h4>
      <ul>
        <li><span class="k">L-drag</span> select · <span class="k">Shift</span> add · <span class="k">Ctrl</span> same type</li>
        <li><span class="k">R-click</span> move / attack / gather · <span class="k">A</span> attack-move <span class="k">S</span> stop <span class="k">H</span> hold</li>
        <li><span class="k">Alt+1-0</span> control groups · <span class="k">1-0</span> select</li>
        <li><span class="k">WASD</span> / screen edges pan · <span class="k">Wheel</span> zoom · <span class="k">Q</span>/<span class="k">E</span> rotate</li>
        <li><span class="k">Space</span> pause · <span class="k">M</span> mute · <span class="k">F1</span> help</li>
        <li>Minimap: click = camera, right-click = order</li>
      </ul>
      <h4>COUNTERS</h4>
      <ul>
        <li><b>Lancer</b> (energy, anti-air) beats heavies & aircraft.</li>
        <li><b>Juggernaut</b> (heavy armor) shrugs off kinetic — needs research.</li>
        <li><b>Wraith</b> flies over walls; turrets shoot down air.</li>
        <li>Kinetic weapons halved vs heavy armor; energy boosted.</li>
      </ul>
    </div>
  </div>
  <button class="big" id="startbtn">BEGIN EXPEDITION</button>`);
  document.getElementById('startbtn').onclick=()=>{ audioInit(); startGame(); };
}
function showHelp(){
  const was=gameState;
  showScreen(`
  <h1 style="font-size:22px">FIELD MANUAL</h1>
  <div class="cols">
    <div class="col">
      <h4>COMMAND</h4>
      <ul><li>Left-drag to select units; right-click for contextual orders.</li><li>Attack-move engages enemies encountered en route.</li><li>Workers: select → Build… → click a valid spot. A worker constructs it.</li></ul>
      <h4>ECONOMY</h4>
      <ul><li>Drones carry 5 flux per trip. Beacons = supply + drop-off.</li><li>Cancel production = full refund. Supply is capacity, not a cost.</li></ul>
    </div>
    <div class="col">
      <h4>WARFARE</h4>
      <ul><li>Scout: the enemy is invisible until seen. Fog hides their base.</li><li>Defend expansions with turrets; raid enemy workers to starve them.</li><li>Escalate: more Spires → more supply → bigger armies.</li></ul>
    </div>
  </div>
  <button class="big" id="closehelp">RESUME</button>`);
  document.getElementById('closehelp').onclick=()=>{ hideScreen(); if(was==='title')showTitle(); };
}
function showEnd(won){
  const m=Math.floor(gameTime/60), s=Math.floor(gameTime%60);
  showScreen(`
  <h1 class="${won?'win':'lose'}" style="font-size:30px">${won?'FRONTIER SECURED':'EXPEDITION LOST'}</h1>
  <div class="sub">${won?'The Crimson Syndicate is destroyed.':'Your last Command Spire has fallen.'}</div>
  <div class="stats">
    Match time: ${m}:${s<10?'0':''}${s}<br>
    Enemy units destroyed: ${stats.k}<br>
    Own units lost: ${stats.d}<br>
    Flux gathered: ${Math.floor(stats.g)}
  </div>
  <button class="big" id="restart">RESTART MATCH</button>`);
  document.getElementById('restart').onclick=()=>startGame();
}
function togglePause(){
  paused=!paused;
  document.getElementById('btnpause').textContent=paused?'▶':'❚❚';
  if(paused){
    showScreen(`<h1 style="font-size:26px">PAUSED</h1><p>The frontier holds its breath.</p>
    <button class="big" id="resume">RESUME (Space)</button>`);
    document.getElementById('resume').onclick=togglePause;
  } else hideScreen();
}
/* ============================== MAIN LOOP ============================== */
let lastT=0, fogT=0;
function loop(t){
  requestAnimationFrame(loop);
  const dt=Math.min(0.05,(t-lastT)/1000||0.016);
  lastT=t;
  if(gameState==='play'&&!paused){
    gameTime+=dt;
    // WASD pan
    const sp=cam.zoom*0.8*dt;
    if(keys['w']){cam.x+=Math.cos(cam.yaw)*sp; cam.z-=Math.sin(cam.yaw)*sp;}
    if(keys['s']&&!sel.length){cam.x-=Math.cos(cam.yaw)*sp; cam.z+=Math.sin(cam.yaw)*sp;}
    if(keys['a']){cam.x-=Math.sin(cam.yaw)*sp; cam.z-=Math.cos(cam.yaw)*sp;}
    if(keys['d']){cam.x+=Math.sin(cam.yaw)*sp; cam.z+=Math.cos(cam.yaw)*sp;}
    if(keys['q'])cam.yaw+=1.6*dt;
    if(keys['e'])cam.yaw-=1.6*dt;
    camUpdate(dt);
    updateUnits(dt);
    updateBuildings(dt);
    aiUpdate(dt);
    updateEffects(dt);
    refreshSupply(0); refreshSupply(1);
    checkVictory();
    fogT+=dt;
    if(fogT>0.2){ fogT=0; fogDraw(); }
  }
  render();
  // periodic HUD refresh
  hudT+=dt;
  if(hudT>0.25){ hudT=0; refreshPanels(); }
}
let hudT=0;
/* boot */
resize();
initGL();
showTitle();
requestAnimationFrame(loop);
</script>
</body>
</html>
<!-- agent-meta {"model":"Qwen3.8","provider":"brigid","persona":"full","sessionId":"deed72cb-6464-40d3-a1ee-cd48caf93e89","tokensIn":5461984,"tokensOut":32615,"tokensTotal":5494599,"cost":0,"turns":48,"toolCalls":51,"failedToolCalls":0,"timestamp":"2026-09-30T15:54:06.747Z"} -->