← RTS results

RTS

Swift Qwen3.8 27B GGUF · 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 91,648 bytes · SHA-256 a5a0960d0372
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>FRONTIER WAR — Skirmish on Kestrel-9</title>
<style>
:root{
  --p:#5fd4ff; --p2:#1b6f8f; --e:#ff7a4d; --e2:#8f3a1b;
  --bg:#05090f; --panel:rgba(10,18,28,.88); --line:#1e3a4d; --txt:#cfe8f5; --dim:#7fa3b8;
  --ok:#7dffa8; --warn:#ffd166; --bad:#ff5d5d;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);color:var(--txt);
  font:14px/1.35 "Segoe UI",system-ui,sans-serif;user-select:none;-webkit-user-select:none}
#gl{position:fixed;inset:0;width:100%;height:100%;display:block;cursor:crosshair}
#boxdiv{position:fixed;z-index:8;display:none;border:1px solid #7fdcff;background:rgba(95,212,255,.12);pointer-events:none}
.hud{position:fixed;z-index:5;pointer-events:none}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:6px;
  box-shadow:0 2px 14px rgba(0,0,0,.55),inset 0 0 22px rgba(95,212,255,.05);pointer-events:auto}
/* ---------- top bar ---------- */
#topbar{top:8px;left:8px;right:8px;display:flex;justify-content:space-between;align-items:flex-start}
#resbox{display:flex;gap:10px;align-items:center;padding:6px 12px;font-weight:600}
#resbox .res{display:flex;align-items:center;gap:6px;font-size:15px}
#resbox .ic{width:14px;height:14px;border-radius:3px;background:linear-gradient(135deg,#9ff3ff,#2fa8d8);box-shadow:0 0 8px #5fd4ff88}
#resbox .sup{color:var(--dim);font-size:13px}
#resbox .sup b{color:var(--txt)}
#resbox .sup.low b{color:var(--bad)}
#topmid{display:flex;gap:6px;align-items:center}
#clock{padding:6px 12px;font-size:15px;font-variant-numeric:tabular-nums;letter-spacing:1px}
.tbtn{pointer-events:auto;background:var(--panel);border:1px solid var(--line);color:var(--txt);
  border-radius:6px;padding:6px 10px;font-size:13px;cursor:pointer}
.tbtn:hover{border-color:var(--p);color:#fff}
/* ---------- bottom bar ---------- */
#bottombar{left:8px;right:8px;bottom:8px;display:flex;gap:8px;align-items:flex-end}
#selpanel{width:340px;min-height:118px;padding:8px 10px;display:flex;flex-direction:column;gap:6px}
#selhead{display:flex;justify-content:space-between;align-items:center;gap:8px}
#selname{font-weight:700;font-size:14px;letter-spacing:.5px}
#selinfo{color:var(--dim);font-size:12px}
#hpwrap{height:8px;border-radius:4px;background:#0a141d;border:1px solid var(--line);overflow:hidden}
#hpbar{height:100%;width:100%;background:linear-gradient(90deg,#37e08a,#7dffa8);transition:width .15s}
#hpbar.mid{background:linear-gradient(90deg,#e0b237,#ffd166)}
#hpbar.low{background:linear-gradient(90deg,#e03737,#ff5d5d)}
#selactions{display:flex;flex-wrap:wrap;gap:5px}
.abtn{pointer-events:auto;display:flex;align-items:center;gap:7px;background:#0c1824;border:1px solid var(--line);
  border-radius:5px;padding:5px 8px;cursor:pointer;color:var(--txt);font-size:12px;min-width:86px}
.abtn:hover{border-color:var(--p)}
.abtn:disabled{opacity:.42;cursor:default}
.abtn .cost{color:var(--warn);font-weight:700}
.abtn .hk{color:var(--dim);font-size:10px;border:1px solid #2a4a5e;border-radius:3px;padding:0 4px}
.abtn .nm{font-weight:600}
.abtn .why{color:var(--dim);font-size:10px}
#selstate{color:var(--dim);font-size:12px;padding:2px 0}
#selstate .warn{color:var(--bad);font-weight:700}
#selstate .ok{color:var(--ok)}
/* ---------- build menu ---------- */
#buildmenu{position:fixed;z-index:9;display:none;padding:6px;gap:5px;flex-wrap:wrap;width:236px}
#buildmenu.show{display:flex}
/* ---------- minimap ---------- */
#mmwrap{width:212px;padding:6px;display:flex;flex-direction:column;gap:4px}
#mm{width:200px;height:200px;display:block;border:1px solid var(--line);border-radius:4px;cursor:crosshair;image-rendering:pixelated}
#mmhint{color:var(--dim);font-size:10px;text-align:center}
/* ---------- toast / help ---------- */
#toasts{left:50%;top:64px;transform:translateX(-50%);display:flex;flex-direction:column;gap:6px;align-items:center}
.toast{padding:6px 16px;border-radius:6px;font-size:13px;font-weight:600;animation:tfade 4s forwards;
  background:var(--panel);border:1px solid var(--line)}
.toast.bad{border-color:var(--bad);color:var(--bad)}
.toast.warn{border-color:var(--warn);color:var(--warn)}
.toast.info{border-color:var(--p);color:var(--p)}
@keyframes tfade{0%{opacity:0;transform:translateY(-8px)}8%{opacity:1;transform:none}80%{opacity:1}100%{opacity:0}}
#helpbtn{position:fixed;z-index:8;bottom:140px;left:10px}
#help{position:fixed;z-index:20;left:50%;top:50%;transform:translate(-50%,-50%);width:min(720px,92vw);max-height:86vh;
  overflow:auto;padding:18px 22px;display:none}
#help.show{display:block}
#help h2{color:var(--p);font-size:18px;margin-bottom:8px;letter-spacing:1px}
#help h3{color:var(--p);font-size:14px;margin:12px 0 4px}
#help p,#help li{font-size:13px;color:var(--txt)}
#help ul{padding-left:18px}
#help .row{display:flex;justify-content:space-between;gap:10px;padding:2px 0;border-bottom:1px dashed #16303f}
#help .k{color:var(--dim);font-size:11px;background:#0c1824;border:1px solid var(--line);border-radius:3px;padding:0 5px}
#help .close{margin-top:14px;width:100%}
/* ---------- screens ---------- */
.screen{position:fixed;inset:0;z-index:30;display:flex;align-items:center;justify-content:center;
  background:radial-gradient(ellipse at 50% 30%,#0a1c2c 0%,#04070c 70%)}
.card{width:min(680px,94vw);max-height:92vh;overflow:auto;text-align:center;padding:26px 34px;
  background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 0 60px rgba(95,212,255,.15)}
.card h1{font-size:44px;letter-spacing:8px;color:var(--p);text-shadow:0 0 24px rgba(95,212,255,.6);margin-bottom:2px}
.card .sub{color:var(--e);letter-spacing:3px;font-size:13px;margin-bottom:16px}
.card h2{font-size:30px;letter-spacing:4px;margin-bottom:10px}
.card h2.win{color:var(--ok);text-shadow:0 0 24px rgba(125,255,168,.5)}
.card h2.lose{color:var(--bad);text-shadow:0 0 24px rgba(255,93,93,.5)}
.card p{font-size:14px;margin:6px 0;color:var(--txt)}
.card .grid{display:grid;grid-template-columns:1fr 1fr;gap:4px 22px;text-align:left;margin:14px 0;font-size:13px}
.card .grid .k{color:var(--p);font-weight:600}
.bigbtn{pointer-events:auto;margin-top:18px;background:linear-gradient(180deg,#123246,#0b1d2a);border:1px solid var(--p);
  color:#dff4ff;font-size:18px;letter-spacing:3px;padding:12px 44px;border-radius:8px;cursor:pointer}
.bigbtn:hover{background:linear-gradient(180deg,#1a4a66,#102838);box-shadow:0 0 24px rgba(95,212,255,.35)}
#stats{margin:10px auto 0;font-size:13px;color:var(--dim);max-width:420px}
#stats b{color:var(--txt)}
#pauseveil{position:fixed;inset:0;z-index:15;display:none;align-items:center;justify-content:center;background:rgba(3,6,10,.55)}
#pauseveil.show{display:flex}
#pauseveil .card{width:340px}
.hidden{display:none!important}
#loaderr{position:fixed;inset:0;z-index:99;background:#04070c;color:#ff8a8a;display:none;align-items:center;justify-content:center;padding:30px;font-size:15px}
</style>
</head>
<body>
<canvas id="gl"></canvas>

<div class="hud" id="topbar">
  <div class="panel" id="resbox">
    <div class="res"><span class="ic"></span><span id="resval">0</span></div>
    <div class="res sup" id="supval"><b>0</b>&nbsp;/&nbsp;<b>0</b>&nbsp;SUPPLY</div>
    <div class="res sup" id="incval" style="display:none"><span style="color:var(--ok)">▲</span><b>0</b></div>
  </div>
  <div id="topmid">
    <div class="panel" id="clock">00:00</div>
    <button class="tbtn" id="btnPause" title="Pause (Space)">❚❚ Pause</button>
    <button class="tbtn" id="btnMute" title="Mute">🔊</button>
    <button class="tbtn" id="btnHelp" title="Help (H)">? Help</button>
  </div>
</div>

<div class="hud" id="toasts"></div>
<div id="boxdiv"></div>

<div class="hud" id="bottombar">
  <div class="panel" id="selpanel">
    <div id="selhead"><span id="selname">No selection</span><span id="selinfo"></span></div>
    <div id="hpwrap" class="hidden"><div id="hpbar"></div></div>
    <div id="selstate"></div>
    <div id="selactions"></div>
  </div>
  <div class="panel" id="mmwrap">
    <canvas id="mm" width="200" height="200"></canvas>
    <div id="mmhint">Minimap — click / right-click to move camera · H for help</div>
  </div>
</div>

<div class="panel" id="buildmenu"></div>
<button class="tbtn" id="helpbtn">?</button>

<div class="panel" id="help">
  <h2>FIELD MANUAL — KESTREL-9</h2>
  <p><b>Objective:</b> destroy <b>every</b> enemy Nexus. You lose if all of your own are destroyed.
     Build extra Nexuses at distant Crystal fields to expand — each one raises your supply cap and can store Crystal.</p>
  <h3>Controls</h3>
  <div class="row"><span>Left click / drag box — select · <span class="k">Shift</span> add</span><span></span></div>
  <div class="row"><span>Right click — contextual order (move / attack / harvest)</span><span></span></div>
  <div class="row"><span><span class="k">A</span> attack-move · <span class="k">S</span> stop · <span class="k">H</span> hold position</span><span></span></div>
  <div class="row"><span><span class="k">1–9</span> control groups · <span class="k">Shift</span>+key set · <span class="k">Q</span> select by type</span><span></span></div>
  <div class="row"><span><span class="k">WASD</span>/<span class="k">arrows</span> pan · <span class="k">Q/E</span> rotate · <span class="k">wheel</span> zoom · <span class="k">F</span> follow army</span><span></span></div>
  <div class="row"><span><span class="k">Space</span> pause · <span class="k">Esc</span> deselect / close</span><span></span></div>
  <h3>Economy &amp; Supply</h3>
  <ul>
    <li>Workers harvest <b>Crystal</b> from fields and return it to your Nexus (or any of your buildings).</li>
    <li>Supply is a <b>capacity</b>, not a cost: every unit occupies supply while alive; Nexus +15, Foundry +10, each upgrade +10. Queued units reserve supply; cancelling a queue refunds the full cost and frees the reservation.</li>
    <li>Expansions near new fields let workers drop off close by — faster income, bigger supply.</li>
  </ul>
  <h3>Unit counters</h3>
  <div class="row"><span><span class="k">Mauler</span> melee, light armor — cheap shock, weak to ranged</span><span></span></div>
  <div class="row"><span><span class="k">Pulse Lance</span> ranged, light armor — melts light armor, weak to heavy</span><span></span></div>
  <div class="row"><span><span class="k">Bulwark</span> heavy armor, slow — soaks laser fire, weak to pulse</span><span></span></div>
  <div class="row"><span><span class="k">Specter</span> flying — crosses cliffs; only Pulse Lance &amp; Sentry Turrets hit air</span><span></span></div>
  <div class="row"><span><span class="k">Reclaimer</span> — harvests Crystal (1 supply)</span><span></span></div>
  <h3>Upgrades</h3>
  <ul><li><b>Ion Edge</b> +30% melee damage · <b>Coil Focus</b> +30% laser damage · <b>Composite Plate</b> +25% max HP · <b>Thruster Kit</b> +15% speed · <b>Orbital Uplink</b> — a Specter strike wipes the largest enemy cluster (once per 75s). Research at the Nexus.</li></ul>
  <button class="bigbtn close" id="helpClose" style="font-size:14px;padding:9px">CLOSE</button>
</div>

<div class="screen" id="title">
  <div class="card">
    <h1>FRONTIER WAR</h1>
    <div class="sub">SKIRMISH ON KESTREL-9 · TWO EXPEDITIONS · ONE WORLD</div>
    <p>Your expedition has landed on a crystal-rich frontier world. A rival force has landed too.
       Explore the dark, build an economy, expand, and raise an army — then <b>destroy every enemy Nexus</b>.</p>
    <div class="grid">
      <span><span class="k">Left click / drag</span> — select</span>
      <span><span class="k">Right click</span> — move / attack / harvest</span>
      <span><span class="k">A / S / H</span> — attack-move / stop / hold</span>
      <span><span class="k">1–9</span> — control groups</span>
      <span><span class="k">WASD + wheel</span> — camera · <span class="k">Q/E</span> rotate</span>
      <span><span class="k">Minimap</span> — fog of war, camera jump</span>
    </div>
    <p style="color:var(--dim);font-size:12px">Victory: destroy all enemy Nexuses · Defeat: all of yours destroyed ·
       Supply is capacity, not cost · Unexplored terrain hides the enemy — scout the map.</p>
    <button class="bigbtn" id="btnStart">DEPLOY</button>
  </div>
</div>

<div class="screen hidden" id="endscreen">
  <div class="card">
    <h2 id="endtitle">VICTORY</h2>
    <p id="endsub"></p>
    <div id="stats"></div>
    <button class="bigbtn" id="btnRestart">REDEPLOY</button>
  </div>
</div>

<div id="pauseveil"><div class="card"><h2 style="color:var(--p)">PAUSED</h2><p>Press <b>Space</b> to resume</p></div></div>
<div id="loaderr"></div>

<script>
'use strict';
/* ============================== math / utils ============================== */
const TAU=Math.PI*2;
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const lerp=(a,b,t)=>a+(b-a)*t;
const rand=(a=1,b)=>b===undefined?Math.random()*a:a+Math.random()*(b-a);
const randi=(a,b)=>Math.floor(rand(a,b+1));
const dist2=(x1,z1,x2,z2)=>{const dx=x2-x1,dz=z2-z1;return dx*dx+dz*dz};
const dist=(x1,z1,x2,z2)=>Math.sqrt(dist2(x1,z1,x2,z2));
const angTo=(x1,z1,x2,z2)=>Math.atan2(x2-x1,z2-z1);
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}}
function hash2(x,z,s){let h=(x*374761393+z*668265263+s*1442695041)|0;h=(h^(h>>>13))*1274126177|0;return((h^(h>>>16))>>>0)/4294967296}
function vnoise(x,z,s){const xi=Math.floor(x),zi=Math.floor(z),xf=x-xi,zf=z-zi;
  const u=xf*xf*(3-2*xf),v=zf*zf*(3-2*zf);
  const a=hash2(xi,zi,s),b=hash2(xi+1,zi,s),c=hash2(xi,zi+1,s),d=hash2(xi+1,zi+1,s);
  return a+(b-a)*u+(c-a)*v+(a-b-c+d)*u*v}
function fbm(x,z,s,o=4){let f=0,amp=.5,tot=0;for(let i=0;i<o;i++){f+=vnoise(x,z,s+i*101)*amp;tot+=amp;amp*=.5}return f/tot}
const fmtT=s=>{s=Math.max(0,Math.floor(s));return (s/60|0)+':'+String(s%60).padStart(2,'0')}

/* ============================== audio (procedural) ============================== */
const Audio2={ctx:null,muted:false,master:null,
  init(){if(this.ctx)return;try{this.ctx=new (window.AudioContext||window.webkitAudioContext)();
    this.master=this.ctx.createGain();this.master.gain.value=.5;this.master.connect(this.ctx.destination)}catch(e){}},
  beep(f=440,d=.08,type='sine',g=.2,slide=0){if(!this.ctx||this.muted)return;
    const t=this.ctx.currentTime,o=this.ctx.createOscillator(),gn=this.ctx.createGain();
    o.type=type;o.frequency.setValueAtTime(f,t);if(slide)o.frequency.exponentialRampToValueAtTime(Math.max(30,f+slide),t+d);
    gn.gain.setValueAtTime(g,t);gn.gain.exponentialRampToValueAtTime(.001,t+d);
    o.connect(gn);gn.connect(this.master);o.start(t);o.stop(t+d+.02)},
  noise(d=.15,g=.2,fq=800){if(!this.ctx||this.muted)return;
    const t=this.ctx.currentTime,n=Math.floor(this.ctx.sampleRate*d),buf=this.ctx.createBuffer(1,n,this.ctx.sampleRate),ch=buf.getChannelData(0);
    for(let i=0;i<n;i++)ch[i]=(Math.random()*2-1)*(1-i/n);
    const s=this.ctx.createBufferSource();s.buffer=buf;const f=this.ctx.createBiquadFilter();f.type='lowpass';f.frequency.value=fq;
    const gn=this.ctx.createGain();gn.gain.value=g;s.connect(f);f.connect(gn);gn.connect(this.master);s.start(t)},
  click(){this.beep(880,.04,'square',.08)},
  err(){this.beep(160,.12,'sawtooth',.15,-60)},
  build(){this.noise(.25,.18,500);this.beep(220,.2,'triangle',.12,120)},
  done(){this.beep(660,.09,'sine',.16);setTimeout(()=>this.beep(990,.12,'sine',.14),80)},
  shoot(r){if(r==='laser'){this.beep(1400+rand(-200,200),.07,'sawtooth',.05,-900)}
    else if(r==='kinetic'){this.noise(.06,.12,2400);this.beep(180,.06,'square',.08,-80)}
    else this.noise(.12,.14,900)},
  boom(s=1){this.noise(.5,.3*s,300);this.beep(60,.4,'sine',.25*s,-30)},
  alarm(){this.beep(520,.14,'square',.1);setTimeout(()=>this.beep(392,.18,'square',.1),150)},
  tick(){this.beep(1200,.03,'sine',.06)}
};

/* ============================== data ============================== */
const MAPW=192,MAPD=128,CELL=4;
const RES_CAP=3000;
const DMG_MOD={kinetic:{light:1,heavy:.55,fly:1},laser:{light:1.4,heavy:.7,fly:1.2}};
const RES_PER_SEC=.5;
const UPG={
  ion:{name:'Ion Edge',cost:300,time:45,desc:'+30% melee damage'},
  coil:{name:'Coil Focus',cost:300,time:45,desc:'+30% laser damage'},
  plate:{name:'Composite Plate',cost:400,time:60,desc:'+25% unit & building HP'},
  thr:{name:'Thruster Kit',cost:350,time:60,desc:'+15% movement speed'},
  orbit:{name:'Orbital Uplink',cost:600,time:90,desc:'Specter strike: wipe largest enemy cluster'}
};
const UNITS={
  worker:{name:'Reclaimer',type:'worker',cost:60,sup:1,hp:45,spd:5.2,dmg:3,rate:1.0,range:1.6,dmgType:'kinetic',armor:'light',
    prod:'nexus',time:14,gt:1,at:0,desc:'Harvests Crystal. 1 supply.'},
  mauler:{name:'Mauler',type:'melee',cost:55,sup:1,hp:70,spd:6.6,dmg:11,rate:.85,range:2.0,dmgType:'kinetic',armor:'light',
    prod:'foundry',time:9,gt:1,at:1,desc:'Cheap melee shock troop.'},
  lance:{name:'Pulse Lance',type:'ranged',cost:90,sup:1,hp:55,spd:5.6,dmg:13,rate:1.1,range:10.5,dmgType:'laser',armor:'light',
    prod:'foundry',time:13,gt:1,at:1,desc:'Ranged laser. Strong vs light armor, hits air.'},
  bulwark:{name:'Bulwark',type:'heavy',cost:170,sup:2,hp:230,spd:4.4,dmg:22,rate:1.5,range:2.4,dmgType:'kinetic',armor:'heavy',
    prod:'foundry',time:22,gt:1,at:1,desc:'Heavy armor tank. Soaks laser fire.'},
  specter:{name:'Specter',type:'air',cost:130,sup:2,hp:90,spd:7.4,dmg:15,rate:1.0,range:8.5,dmgType:'laser',armor:'fly',
    prod:'hangar',time:16,gt:2,at:1,desc:'Flying striker. Only lances & turrets hit air.'}
};
const BLD={
  nexus:{name:'Nexus',w:6,d:6,h:5.2,cost:350,time:35,sup:15,hp:1100,armor:'heavy',
    roles:['store','train','research'],train:{worker:1},desc:'HQ. Stores Crystal, trains Reclaimers, researches. Win target.'},
  foundry:{name:'Foundry',w:4,d:4,h:3.2,cost:150,time:25,sup:10,hp:550,armor:'heavy',
    roles:['train'],train:{mauler:1,lance:1,bulwark:1},desc:'Ground unit production. +10 supply.'},
  hangar:{name:'Hangar',w:4,d:4,h:3.6,cost:220,time:30,sup:10,hp:450,armor:'heavy',
    roles:['train'],train:{specter:1},desc:'Air unit production. +10 supply.'},
  turret:{name:'Sentry Turret',w:2.4,d:2.4,h:3.4,cost:110,time:18,sup:0,hp:320,armor:'heavy',
    roles:['defense'],desc:'Auto-defense. Hits ground & air.'}
};
const BUILD_ORDER=['nexus','foundry','hangar','turret'];

/* ============================== globals ============================== */
let gl,prog,uni={},VBOs={};
let game=null;
const cam={x:MAPW*CELL*.18,z:MAPD*CELL*.82,yaw:0,pitch:1.05,zoom:60,tgt:{x:0,z:0,yaw:0,pitch:1.05,zoom:60},follow:false};
let mouse={x:0,y:0,wx:0,wz:0,onGL:false,down:false,button:0,sx:0,sy:0,dragging:false};
let selection=[];
const ctrlGroups={};
const keys={};
let paused=false,gameOver=false,started=false;
let boxSel=null; // {x0,y0,x1,y1}
let camVel={x:0,z:0};

/* ============================== WebGL setup ============================== */
const VS=`attribute vec3 aPos;attribute vec4 aCol;
uniform mat4 uMVP;uniform mat4 uModel;uniform float uTime;
varying vec4 vCol;varying vec3 vW;varying vec3 vN;
void main(){vec4 w=uModel*vec4(aPos,1.0);vW=w.xyz;
vec4 c=aCol;
if(c.a>0.5){ // dynamic flag: selection / placement
  float p=0.5+0.5*sin(uTime*6.0+w.x*0.5+w.z*0.5);
  c.rgb=mix(c.rgb,vec3(1.0),0.35+0.45*p);
}
vCol=c;vN=normalize(mat3(uModel)*vec3(0.0,1.0,0.0));
gl_Position=uMVP*w;}`;
const FS=`precision mediump float;varying vec4 vCol;varying vec3 vW;varying vec3 vN;
uniform vec3 uLightDir;uniform vec3 uCamPos;
void main(){
 float d=distance(vW,uCamPos);
 float fog=clamp((d-160.0)/420.0,0.0,1.0);
 vec3 n=normalize(vN);
 float diff=0.55+0.45*max(dot(n,normalize(uLightDir)),0.0);
 vec3 col=vCol.rgb*diff;
 col=mix(col,vec3(0.05,0.08,0.12),fog);
 gl_FragColor=vec4(col,vCol.a);}`;
function mkShader(t,src){const s=gl.createShader(t);gl.shaderSource(s,src);gl.compileShader(s);
  if(!gl.getShaderParameter(s,gl.COMPILE_STATUS))throw new Error(gl.getShaderInfoLog(s));return s}
function glInit(){
  const cv=document.getElementById('gl');
  gl=cv.getContext('webgl',{antialias:true});
  if(!gl){document.getElementById('loaderr').style.display='flex';document.getElementById('loaderr').textContent='WebGL unavailable — this game needs it.';return false}
  prog=gl.createProgram();
  gl.attachShader(prog,mkShader(gl.VERTEX_SHADER,VS));
  gl.attachShader(prog,mkShader(gl.FRAGMENT_SHADER,FS));
  gl.linkProgram(prog);
  if(!gl.getProgramParameter(prog,gl.LINK_STATUS))throw new Error(gl.getProgramInfoLog(prog));
  gl.useProgram(prog);
  for(const n of ['uMVP','uModel','uTime','uLightDir','uCamPos'])uni[n]=gl.getUniformLocation(prog,n);
  const aPos=gl.getAttribLocation(prog,'aPos'),aCol=gl.getAttribLocation(prog,'aCol');
  A_POS=aPos;A_COL=aCol;
  gl.enableVertexAttribArray(aPos);gl.vertexAttribPointer(aPos,3,gl.FLOAT,false,28,0);
  gl.enableVertexAttribArray(aCol);gl.vertexAttribPointer(aCol,4,gl.FLOAT,false,28,12);
  gl.enable(gl.DEPTH_TEST);gl.disable(gl.CULL_FACE);
  gl.clearColor(0.02,0.045,0.075,1);
  resizeGL();
  window.addEventListener('resize',resizeGL);
  return true;
}
function resizeGL(){const cv=document.getElementById('gl');
  cv.width=innerWidth*devicePixelRatio;cv.height=innerHeight*devicePixelRatio;
  cv.style.width=innerWidth+'px';cv.style.height=innerHeight+'px';
  gl.viewport(0,0,cv.width,cv.height)}
const GEOM={};
function buf(name,verts){const v=new Float32Array(verts);
  GEOM[name]=v;
  VBOs[name]=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,VBOs[name]);gl.bufferData(gl.ARRAY_BUFFER,v,gl.STATIC_DRAW);
  VBOs[name+'N']=v.length/7}
let A_POS=-1,A_COL=-1;
function draw(name,model,time){
  gl.uniformMatrix4fv(uni.uModel,false,model);
  gl.uniform1f(uni.uTime,time);
  gl.bindBuffer(gl.ARRAY_BUFFER,VBOs[name]);
  gl.vertexAttribPointer(A_POS,3,gl.FLOAT,false,28,0);
  gl.vertexAttribPointer(A_COL,4,gl.FLOAT,false,28,12);
  gl.drawArrays(gl.TRIANGLES,0,VBOs[name+'N']);
}
function setCol(name,color){
  const v=new Float32Array(VBOs[name+'N']*7);
  const src=gl.getParameter(gl.ARRAY_BUFFER_BINDING);
  gl.bindBuffer(gl.ARRAY_BUFFER,VBOs[name]);
  gl.getBufferSubData(gl.ARRAY_BUFFER,0,v);
  for(let i=0;i<v.length;i+=7){v[i]=color[0];v[i+1]=color[1];v[i+2]=color[2];v[i+3]=1}
  gl.bindBuffer(gl.ARRAY_BUFFER,VBOs[name]);gl.bufferSubData(gl.ARRAY_BUFFER,0,v);
}
/* geometry builders */
function boxGeom(w,h,d,cx=0,cy=0,cz=0){ // axis-aligned box, 12 tris, per-face shading
  const x0=cx-w/2,x1=cx+w/2,y0=cy-h/2,y1=cy+h/2,z0=cz-d/2,z1=cz+d/2;
  const F=[ // [n, 4 corners]
    [[0,1,0],[[x0,y1,z1],[x1,y1,z1],[x1,y1,z0],[x0,y1,z0]]],
    [[0,-1,0],[[x0,y0,z0],[x1,y0,z0],[x1,y0,z1],[x0,y0,z1]]],
    [[1,0,0],[[x1,y0,z1],[x1,y0,z0],[x1,y1,z0],[x1,y1,z1]]],
    [[-1,0,0],[[x0,y0,z0],[x0,y0,z1],[x0,y1,z1],[x0,y1,z0]]],
    [[0,0,1],[[x0,y0,z1],[x1,y0,z1],[x1,y1,z1],[x0,y1,z1]]],
    [[0,0,-1],[[x1,y0,z0],[x0,y0,z0],[x0,y1,z0],[x1,y1,z0]]]
  ];
  const v=[];
  for(const[n,c]of F){const sh=.72+.28*Math.abs(n[1]*.6+n[0]*.2+n[2]*.2);
    const col=[sh,sh,sh];
    const idx=[0,1,2,0,2,3];
    for(const i of idx)v.push(c[i][0],c[i][1],c[i][2],col[0],col[1],col[2],1);
  }
  return v;
}
function prismGeom(r,h,n=6,cx=0,cy=0,cz=0){ // hexagonal-ish prism
  const v=[];
  for(let i=0;i<n;i++){const a0=TAU*i/n,a1=TAU*(i+1)/n;
    const p0=[cx+Math.cos(a0)*r,cy,cz+Math.sin(a0)*r],p1=[cx+Math.cos(a1)*r,cy,cz+Math.sin(a1)*r];
    const p2=[cx+Math.cos(a1)*r,cy+h,cz+Math.sin(a1)*r],p3=[cx+Math.cos(a0)*r,cy+h,cz+Math.sin(a0)*r];
    const sh=.8;v.push(p0[0],p0[1],p0[2],sh,sh,sh,1,p1[0],p1[1],p1[2],sh,sh,sh,1,p2[0],p2[1],p2[2],sh,sh,sh,1,
      p0[0],p0[1],p0[2],sh,sh,sh,1,p2[0],p2[1],p2[2],sh,sh,sh,1,p3[0],p3[1],p3[2],sh,sh,sh,1);
    // top fan
    const c=[cx,cy+h,cz];const sh2=1;
    v.push(c[0],c[1],c[2],sh2,sh2,sh2,1,p0[0],p0[1],p0[2],sh2,sh2,sh2,1,p1[0],p1[1],p1[2],sh2,sh2,sh2,1);
  }
  return v;
}
function cylGeom(r,h,n=8,cx=0,cy=0,cz=0){
  const v=[];
  for(let i=0;i<n;i++){const a0=TAU*i/n,a1=TAU*(i+1)/n;
    const p0=[cx+Math.cos(a0)*r,cy,cz+Math.sin(a0)*r],p1=[cx+Math.cos(a1)*r,cy,cz+Math.sin(a1)*r];
    const p2=[cx+Math.cos(a1)*r,cy+h,cz+Math.sin(a1)*r],p3=[cx+Math.cos(a0)*r,cy+h,cz+Math.sin(a0)*r];
    const sh=.8;v.push(p0[0],p0[1],p0[2],sh,sh,sh,1,p1[0],p1[1],p1[2],sh,sh,sh,1,p2[0],p2[1],p2[2],sh,sh,sh,1,
      p0[0],p0[1],p0[2],sh,sh,sh,1,p2[0],p2[1],p2[2],sh,sh,sh,1,p3[0],p3[1],p3[2],sh,sh,sh,1);
  }
  return v;
}
function sphereGeom(r,n=6,cx=0,cy=0,cz=0){ // faceted sphere
  const v=[];const lat=4,lon=6;
  const pts=[];
  for(let i=0;i<=lat;i++){const th=Math.PI*i/lat;
    for(let j=0;j<lon;j++){const ph=TAU*j/lon;
      pts.push([cx+r*Math.sin(th)*Math.cos(ph),cy+r*Math.cos(th),cz+r*Math.sin(th)*Math.sin(ph)])}}
  for(let i=0;i<lat;i++)for(let j=0;j<lon;j++){
    const a=pts[i*lon+j],b=pts[i*lon+(j+1)%lon],c=pts[(i+1)*lon+(j+1)%lon],d=pts[(i+1)*lon+j];
    const sh=.85;v.push(a[0],a[1],a[2],sh,sh,sh,1,b[0],b[1],b[2],sh,sh,sh,1,c[0],c[1],c[2],sh,sh,sh,1,
      a[0],a[1],a[2],sh,sh,sh,1,c[0],c[1],c[2],sh,sh,sh,1,d[0],d[1],d[2],sh,sh,sh,1);
  }
  return v;
}
function buildMeshes(){
  buf('u_worker',boxGeom(1.6,.9,1.0,0,.45,0).concat(prismGeom(.55,.7,5,0,.9,.15)));
  buf('u_mauler',boxGeom(2.0,1.1,1.5,0,.55,0).concat(boxGeom(.5,.9,.5,-1.3,.45,.45)).concat(boxGeom(.5,.9,.5,-1.3,.45,-.45)));
  buf('u_lance',boxGeom(1.7,.8,1.2,0,.4,0).concat(boxGeom(.35,1.0,1.6,0,.8,1.4)));
  buf('u_bulwark',boxGeom(2.8,1.4,2.0,0,.7,0).concat(boxGeom(1.0,1.0,.6,1.4,.9,0)).concat(boxGeom(.7,.9,.7,-1.5,.45,1.2)).concat(boxGeom(.7,.9,.7,-1.5,.45,-1.2)));
  buf('u_specter',boxGeom(2.4,.7,1.0,0,.35,0).concat(boxGeom(.9,.5,.35,1.0,.35,.6)).concat(boxGeom(.9,.5,.35,1.0,.35,-.6)).concat(boxGeom(.5,.9,.5,-1.1,.35,0)));
  buf('b_nexus',prismGeom(4.4,3.0,6).concat(prismGeom(3.2,1.6,6,0,3.0,0)).concat(cylGeom(1.1,3.4,8,0,4.6,0)).concat(sphereGeom(1.3,6,0,8.6,0)));
  buf('b_foundry',boxGeom(3.6,2.6,3.6).concat(boxGeom(1.2,1.6,1.2,1.2,2.6,1.2)).concat(boxGeom(2.8,.8,2.8,0,3.2,0)));
  buf('b_hangar',boxGeom(3.6,2.2,3.6).concat(boxGeom(3.6,.6,3.6,0,2.5,0)).concat(boxGeom(1.8,1.2,2.2,1.9,1.1,0)));
  buf('b_turret',cylGeom(1.0,1.2,8).concat(sphereGeom(.8,6,0,1.6,0)).concat(boxGeom(.3,1.6,.3,0,2.4,0)));
  buf('fx_ring',[0,0,0,0,0,0,1]);
  const ring=[];for(let i=0;i<24;i++){const a0=TAU*i/24,a1=TAU*(i+1)/24;
    ring.push(Math.cos(a0)*1,0,Math.sin(a0)*1,1,1,1,1,Math.cos(a1)*1,0,Math.sin(a1)*1,1,1,1,1,
      Math.cos(a0)*1,.05,Math.sin(a0)*1,1,1,1,1,Math.cos(a1)*1,0,Math.sin(a1)*1,1,1,1,1,Math.cos(a1)*1,.05,Math.sin(a1)*1,1,1,1,1)}
  buf('fx_ring',ring);
  const beam=[];beam.push(0,0,0,1,1,1,1,0,1,0,1,1,1,1,0,0,0,1,1,1,1,0,1.2,0,1,1,1,1,0,0,0,1,1,1,1,0,1.2,0,1,1,1,1);
  buf('fx_beam',beam);
  // selection rings
  const sel=[];for(let i=0;i<32;i++){const a0=TAU*i/32,a1=TAU*(i+1)/32;
    sel.push(Math.cos(a0)*1,0,Math.sin(a0)*1,1,1,1,1,Math.cos(a1)*1,0,Math.sin(a1)*1,1,1,1,1,
      Math.cos(a0)*1,.06,Math.sin(a0)*1,1,1,1,1,Math.cos(a1)*1,0,Math.sin(a1)*1,1,1,1,1,Math.cos(a1)*1,.06,Math.sin(a1)*1,1,1,1,1)}
  buf('sel_ring',sel);
}

/* ============================== terrain & map ============================== */
let terrain=null; // {h:Float32Array, solid:Uint8Array, vbo, vboN}
let fields=[];
function genMap(seed){
  const n=MAPW*MAPD;
  const h=new Float32Array(n),solid=new Uint8Array(n);
  const R=mulberry32(seed);
  const p1={x:MAPW*CELL*.16,z:MAPD*CELL*.80},p2={x:MAPW*CELL*.84,z:MAPD*CELL*.20};
  const fieldsList=[];
  // fields: 2 near each base, 1 mid per side, 3 contested
  const spots=[
    {x:p1.x+70,z:p1.z-30},{x:p1.x-40,z:p1.z-80},{x:p2.x-70,z:p2.z+30},{x:p2.x+40,z:p2.z+80},
    {x:MAPW*CELL*.38,z:MAPD*CELL*.62},{x:MAPW*CELL*.62,z:MAPD*CELL*.38},
    {x:MAPW*CELL*.5,z:MAPD*CELL*.5+40},{x:MAPW*CELL*.42,z:MAPD*CELL*.18},{x:MAPW*CELL*.58,z:MAPD*CELL*.82}
  ];
  for(const s of spots){
    const fx=clamp(s.x+rand(-30,30),60,MAPW*CELL-60),fz=clamp(s.z+rand(-30,30),60,MAPD*CELL-60);
    fieldsList.push({x:fx,z:fz,amt:2600+randi(0,9)*250,max:0,depleted:false,buf:null});
  }
  for(let z=0;z<MAPD;z++)for(let x=0;x<MAPW;x++){
    const wx=x*CELL,wz=z*CELL,i=z*MAPW+x;
    const d1=dist(wx,wz,p1.x,p1.z),d2=dist(wx,wz,p2.x,p2.z);
    const dBase=Math.min(d1,d2);
    let hh=fbm(wx*.004,wz*.004,seed,4)*26+fbm(wx*.013,wz*.013,seed+7,3)*8;
    const flat=clamp((dBase-60)/120,0,1);
    hh*=flat;
    // ridges
    const ridge=fbm(wx*.007+90,wz*.007,seed+31,3);
    if(ridge>.62&&flat>.3)hh+=(ridge-.62)*90;
    hh=clamp(hh,0,46);
    // flatten bases
    if(d1<42)hh*=clamp(d1/42,0,1)*.5;
    if(d2<42)hh*=clamp(d2/42,0,1)*.5;
    h[i]=hh;
  }
  // carve field bowls
  for(const f of fieldsList){
    const fx=f.x/CELL,fz=f.z/CELL;
    for(let z=Math.max(0,fz-4|0);z<Math.min(MAPD,fz+5);z++)for(let x=Math.max(0,fx-4|0);x<Math.min(MAPW,fx+5);x++){
      const d=dist(x*CELL,z*CELL,f.x,f.z);
      h[z*MAPW+x]=Math.min(h[z*MAPW+x],lerp(2,10,clamp(d/18,0,1)));
    }
    f.max=f.amt;
  }
  // solid: cliffs + rocks
  const rockR=R;
  for(let z=1;z<MAPD-1;z++)for(let x=1;x<MAPW-1;x++){
    const i=z*MAPW+x;
    if(h[i]>30&&Math.max(h[i-1],h[i+1],h[i-MAPW],h[i+MAPW])<h[i]-9)solid[i]=1;
  }
  // scatter boulders (avoid bases & fields)
  for(let k=0;k<90;k++){
    const x=2+Math.floor(R()*(MAPW-4)),z=2+Math.floor(R()*(MAPD-4)),i=z*MAPW+x;
    const wx=x*CELL,wz=z*CELL;
    if(solid[i])continue;
    if(dist(wx,wz,p1.x,p1.z)<90||dist(wx,wz,p2.x,p2.z)<90)continue;
    let nearField=false;for(const f of fieldsList)if(dist(wx,wz,f.x,f.z)<40)nearField=true;
    if(nearField)continue;
    solid[i]=2;
  }
  // clear base pads
  for(const p of [p1,p2])for(let z=Math.floor(p.z/CELL)-6;z<=Math.floor(p.z/CELL)+6;z++)
    for(let x=Math.floor(p.x/CELL)-6;x<=Math.floor(p.x/CELL)+6;x++)
      if(x>0&&z>0&&x<MAPW-1&&z<MAPD-1)solid[z*MAPW+x]=0;
  // pathfinding grid: walkable if not solid
  const grid=new Uint8Array(n);
  for(let i=0;i<n;i++)grid[i]=solid[i]?0:1;
  terrain={h,solid,grid,p1,p2};
  fields=fieldsList;
  buildTerrainMesh();
  buildFieldMeshes();
}
function buildTerrainMesh(){
  const v=[];
  for(let z=0;z<MAPD-1;z++)for(let x=0;x<MAPW-1;x++){
    const x0=x*CELL,x1=(x+1)*CELL,z0=z*CELL,z1=(z+1)*CELL;
    const i00=z*MAPW+x,i10=z*MAPW+x+1,i01=(z+1)*MAPW+x,i11=(z+1)*MAPW+x+1;
    const h00=terrain.h[i00],h10=terrain.h[i10],h01=terrain.h[i01],h11=terrain.h[i11];
    const cols=[colOf(h00),colOf(h10),colOf(h01),colOf(h11)];
    const P=[[x0,h00,z0,cols[0]],[x1,h10,z0,cols[1]],[x1,h11,z1,cols[2]],[x0,h01,z1,cols[3]]];
    const T=(a,b,c)=>{v.push(a[0],a[1],a[2],a[3][0],a[3][1],a[3][2],1,b[0],b[1],b[2],b[3][0],b[3][1],b[3][2],1,c[0],c[1],c[2],c[3][0],c[3][1],c[3][2],1)};
    T(P[0],P[1],P[2]);T(P[0],P[2],P[3]);
  }
  buf('terrain',v);
  // rocks
  const rv=[];
  for(let z=0;z<MAPD;z++)for(let x=0;x<MAPW;x++){
    if(terrain.solid[z*MAPW+x]===2){
      const wx=x*CELL,wz=z*CELL;
      rv.push(...boxGeom(3.4,3.0,3.4,wx,1.5+terrain.h[z*MAPW+x]*.4,wz));
    }
  }
  buf('rocks',rv);
}
function colOf(h){
  let r,g,b;
  if(h<3){r=.10;g=.22;b=.16}
  else if(h<12){r=.13;g=.24;b=.15}
  else if(h<24){r=.22;g=.22;b=.13}
  else if(h<34){r=.30;g=.26;b=.18}
  else{r=.42;g=.40;b=.38}
  // subtle variation baked per-vertex via hash
  return [r,g,b];
}
function buildFieldMeshes(){
  let vi=0;
  for(const f of fields){
    const g=[];
    for(let i=0;i<7;i++){
      const a=TAU*i/7,rr=i===0?2.6:1.6+hash2(i,vi,5)*1.4;
      const px=f.x+Math.cos(a)*rr,pz=f.z+Math.sin(a)*rr;
      const ph=2+hash2(i,vi,9)*2.2;
      g.push(...prismGeom(.5,ph,5,px,0,pz));
    }
    const arr=new Float32Array(g);
    f.key='field'+vi;
    GEOM[f.key]=arr;
    f.buf=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,f.buf);
    gl.bufferData(gl.ARRAY_BUFFER,arr,gl.STATIC_DRAW);f.n=g.length/7;
    vi++;
  }
}
function terrainH(x,z){
  const gx=clamp(x/CELL,0,MAPW-1.001),gz=clamp(z/CELL,0,MAPD-1.001);
  const x0=gx|0,z0=gz|0,xf=gx-x0,zf=gz-z0;
  const i=z0*MAPW+x0;
  return lerp(lerp(terrain.h[i],terrain.h[i+1],xf),lerp(terrain.h[i+MAPW],terrain.h[i+MAPW+1],xf),zf);
}
function isSolidCell(x,z){if(x<0||z<0||x>=MAPW||z>=MAPD)return true;return terrain.solid[z*MAPW+x]!==0}
function isSolid(x,z){return isSolidCell(Math.floor(x/CELL),Math.floor(z/CELL))}

/* ============================== pathfinding (A* on grid) ============================== */
const astar={
  open:[],g:new Float32Array(MAPW*MAPD),f:new Float32Array(MAPW*MAPD),came:new Int32Array(MAPW*MAPD),seen:new Uint8Array(MAPW*MAPD),
  find(sx,sz,tx,tz,limit=14000){
    const grid=terrain.grid;
    const s=sz*MAPW+sx,t=tz*MAPW+tx;
    if(sx<0||sz<0||sx>=MAPW||sz>=MAPD||tx<0||tz<0||tx>=MAPW||tz>=MAPD)return null;
    if(!grid[t]){ // find nearest walkable
      let best=-1,bd=1e9;
      for(let dz=-3;dz<=3;dz++)for(let dx=-3;dx<=3;dx++){
        const nx=tx+dx,nz=tz+dz;if(nx<0||nz<0||nx>=MAPW||nz>=MAPD)continue;
        if(grid[nz*MAPW+nx]){const d=dx*dx+dz*dz;if(d<bd){bd=d;best=nz*MAPW+nx}}
      }
      if(best<0)return null;t=best;
    }
    const g=this.g,f=this.f,came=this.came,seen=this.seen,open=this.open;
    open.length=0;
    const hFn=(i)=>Math.abs((i%MAPW)-tx)+Math.abs(((i/MAPW)|0)-tz);
    g.fill(0);seen.fill(0);
    g[s]=0;f[s]=hFn(s);seen[s]=1;open.push(s);
    let steps=0;
    while(open.length&&steps++<limit){
      let bi=0;for(let i=1;i<open.length;i++)if(f[open[i]]<f[open[bi]])bi=i;
      const cur=open[bi];open[bi]=open[open.length-1];open.pop();
      if(cur===t){
        const pts=[];let c=cur;
        while(c!==s){pts.push([c%MAPW,((c/MAPW)|0)]);c=came[c];if(c<0)break}
        pts.push([sx,sz]);pts.reverse();
        return pts;
      }
      const cx=cur%MAPW,cz=(cur/MAPW)|0;
      for(let dz=-1;dz<=1;dz++)for(let dx=-1;dx<=1;dx++){
        if(!dx&&!dz)continue;
        const nx=cx+dx,nz=cz+dz;
        if(nx<0||nz<0||nx>=MAPW||nz>=MAPD)continue;
        const ni=nz*MAPW+nx;
        if(!grid[ni])continue;
        if(dx&&dz&&(grid[cz*MAPW+nx]===0||grid[nz*MAPW+cx]===0))continue;
        const cost=(dx&&dz)?1.414:1;
        const ng=g[cur]+cost;
        if(ng<g[ni]){g[ni]=ng;f[ni]=ng+hFn(ni);came[ni]=cur;if(!seen[ni]){seen[ni]=1;open.push(ni)}}
      }
    }
    return null;
  }
};
function smoothPath(pts){ // waypoint reduction
  if(pts.length<=2)return pts;
  const out=[pts[0]];
  let a=0;
  while(a<pts.length-1){
    let b=pts.length-1;
    while(b>a+1){
      const p0=pts[a],p1=pts[b];
      const dx=p1[0]-p0[0],dz=p1[1]-p0[1],L=Math.sqrt(dx*dx+dz*dz)||1;
      let ok=true;
      for(let s=1;s<10;s++){
        const t=s/10;
        if(isSolid(p0[0]*CELL+dx*t-CELL/2,p0[1]*CELL+dz*t-CELL/2)||isSolid(p0[0]*CELL+dx*t+CELL/2,p0[1]*CELL+dz*t+CELL/2)){ok=false;break}
      }
      if(ok)break;b--;
    }
    out.push(pts[b]);a=b;
  }
  return out;
}
function findPath(x,z,tx,tz){
  const sx=Math.floor(x/CELL),sz=Math.floor(z/CELL);
  if(isSolidCell(sx,sz)){ // nudge
    let found=false;
    for(let r=1;r<=3&&!found;r++)for(let dz=-r;dz<=r;dz++)for(let dx=-r;dx<=r;dx++){
      const nx=sx+dx,nz=sz+dz;
      if(nx>=0&&nz>=0&&nx<MAPW&&nz<MAPD&&!isSolidCell(nx,nz)){sx=nx;sz=nz;found=true;break}
    }
    if(!found)return null;
  }
  let pts=astar.find(sx,sz,Math.floor(tx/CELL),Math.floor(tz/CELL));
  if(!pts)return null;
  pts=smoothPath(pts);
  const wpts=pts.map(p=>[p[0]*CELL+CELL/2,p[1]*CELL+CELL/2]);
  wpts.push([tx,tz]);
  return wpts;
}

/* ============================== entities ============================== */
let nextId=1;
const units=[],buildings=[],effects=[];
const FOG_R=52;
function mkUnit(kind,owner,x,z,opts={}){
  const d=UNITS[kind];
  const u={id:nextId++,kind,owner,x,z,y:0,heading:rand(TAU),defHeading:0,
    hp:d.hp*(opts.hpMul||1),maxHp:d.hp*(opts.hpMul||1),
    spd:d.spd,dmg:d.dmg,rate:d.rate,range:d.range,gt:d.gt,at:d.at,armor:d.armor,dmgType:d.dmgType,sup:d.sup,
    state:'idle',path:null,wp:0,order:null,atkCd:0,atkTarget:null,
    gatherField:null,gatherPhase:0,cargo:0,
    sel:false,dead:false,
    bob:rand(TAU),aggroT:0,
    radius:kind==='bulwark'?2.2:1.6,
    alt:0,altTgt:0};
  if(kind==='specter'){u.alt=14;u.altTgt=14}
  units.push(u);
  return u;
}
function mkBuilding(kind,owner,x,z,opts={}){
  const d=BLD[kind];
  const b={id:nextId++,kind,owner,x,z,w:d.w,d:d.d,hp:d.hp*(opts.hpMul||1),maxHp:d.hp*(opts.hpMul||1),
    armor:'heavy',built:false,buildT:d.time,buildTotal:d.time,
    queue:[],research:null,rally:null,sel:false,dead:false,
    atkCd:0,atkTarget:null,
    vision:kind==='nexus'?60:kind==='turret'?48:40,
    orbitCd:0};
  if(opts.instant){b.built=true;b.buildT=0}
  if(opts.rally)b.rally=[opts.rally[0],opts.rally[1]];
  buildings.push(b);
  return b;
}
function addEffect(type,x,z,y=0,dur=1,extra={}){
  if(effects.length>260)effects.shift();
  effects.push({type,x,z,y,t:0,dur,extra});
}
function killUnit(u){
  u.dead=true;
  addEffect('boom',u.x,u.z,terrainH(u.x,u.z),.7,{s:u.kind==='bulwark'?1.6:1});
  if(Math.random()<.5)Audio2.boom(.5);
  if(game)game.stats.unitsLost[u.owner]++;
  for(let i=units.length-1;i>=0;i--)if(units[i]===u)units.splice(i,1);
  const si=selection.indexOf(u);if(si>=0)selection.splice(si,1);
  for(const k in ctrlGroups)ctrlGroups[k]=ctrlGroups[k].filter(v=>v!==u);
}
function killBuilding(b){
  b.dead=true;
  addEffect('boom',b.x,b.z,terrainH(b.x,b.z),1.2,{s:2.2});
  Audio2.boom(1);
  if(b.owner===0)Audio2.alarm();
  if(game)game.stats.destroyed[b.owner===0?'bldPlayer':'bldEnemy']++;
  for(let i=buildings.length-1;i>=0;i--)if(buildings[i]===b)buildings.splice(i,1);
  const si=selection.indexOf(b);if(si>=0)selection.splice(si,1);
  for(const k in ctrlGroups)ctrlGroups[k]=ctrlGroups[k].filter(v=>v!==b);
  if(game)game.stats.destroyed[b.owner===0?'bldEnemy':'bldPlayer']=(game.stats.destroyed[b.owner===0?'bldEnemy':'bldPlayer']||0)+1;
}

/* ============================== fog / vision ============================== */
function fogInit(){
  game.fog=new Uint8Array(MAPW*MAPD); // 0 unexplored 1 explored 2 visible
}
let visionMap=new Map(); // owner -> Set of cells
function fogUpdate(){
  const f=game.fog;
  const vis0=new Set(),vis1=new Set();
  const consider=(x,z,s)=>{
    if(x<0||z<0||x>=MAPW||z>=MAPD)return;
    const i=z*MAPW+x;
    s.add(i);
  };
  const reveal=(cx,cz,r,s)=>{
    const r2=r*r;
    const x0=Math.max(0,Math.floor((cx-r)/CELL)),x1=Math.min(MAPW-1,Math.ceil((cx+r)/CELL));
    const z0=Math.max(0,Math.floor((cz-r)/CELL)),z1=Math.min(MAPD-1,Math.ceil((cz+r)/CELL));
    for(let z=z0;z<=z1;z++)for(let x=x0;x<=x1;x++)
      if(dist2(x*CELL+CELL/2,z*CELL+CELL/2,cx,cz)<r2)consider(x,z,s);
  };
  for(const u of units){
    const s=u.owner===0?vis0:vis1;
    const r=u.kind==='specter'?FOG_R+8:FOG_R;
    reveal(u.x,u.z,r,s);
  }
  for(const b of buildings){
    const s=b.owner===0?vis0:vis1;
    reveal(b.x,b.z,b.vision,s);
  }
  for(const i of vis0){f[i]=f[i]===0?1:2}
  for(const i of vis1){if(f[i]===0)f[i]=1}
  // clear stale visible
  for(let i=0;i<f.length;i++)if(f[i]===2&&!vis0.has(i))f[i]=1;
  visionMap.set(0,vis0);visionMap.set(1,vis1);
}
function cellState(x,z){if(x<0||z<0||x>=MAPW||z>=MAPD)return 0;return game.fog[z*MAPW+x]}
function canSee(x,z){return cellState(Math.floor(x/CELL),Math.floor(z/CELL))===2}

/* ============================== economy ============================== */
function resOf(o){return game.res[o]}
function supplyCap(o){
  let c=0;
  for(const b of buildings)if(b.owner===o&&b.built&&BLD[b.kind].sup)c+=BLD[b.kind].sup;
  c+=10*(game.upgrades[o].length); // each upgrade +10
  return c;
}
function supplyUsed(o){
  let u=0;
  for(const un of units)if(un.owner===o)u+=UNITS[un.kind].sup;
  for(const b of buildings)if(b.owner===o)for(const q of b.queue)u+=UNITS[q.kind].sup;
  return u;
}
function supplyReserve(o){
  let u=0;
  for(const b of buildings)if(b.owner===o)for(const q of b.queue)u+=UNITS[q.kind].sup;
  return u;
}
function canAfford(o,cost){return resOf(o)>=cost}
function spend(o,cost){if(resOf(o)>=cost){resOf(o)-=cost;return true}return false}
function trainUnit(b,kind){
  const d=UNITS[kind];
  const o=b.owner;
  if(!canAfford(o,d.cost))return 'insufficient resources';
  if(supplyUsed(o)+d.sup>supplyCap(o))return 'insufficient supply';
  spend(o,d.cost);
  b.queue.push({kind,t:d.time,total:d.time});
  return null;
}
function cancelQueue(b,idx){
  const q=b.queue[idx];if(!q)return;
  resOf(b.owner)+=(b.owner===0?1:1)*UNITS[q.kind].cost; // full refund
  b.queue.splice(idx,1);
  Audio2.click();
}
function research(b,key){
  const o=b.owner;
  if(game.upgrades[o].includes(key))return 'already researched';
  if(b.research)return 'already researching';
  if(!canAfford(o,UPG[key].cost))return 'insufficient resources';
  spend(o,UPG[key].cost);
  b.research={key,t:UPG[key].time,total:UPG[key].time};
  return null;
}
function startBuild(kind,owner,x,z){
  const d=BLD[kind];
  if(!canAfford(owner,d.cost))return 'insufficient resources';
  if(!validBuildSpot(kind,x,z,owner))return 'invalid location';
  spend(owner,d.cost);
  const b=mkBuilding(kind,owner,x,z);
  addEffect('ring',x,z,terrainH(x,z),.6,{s:d.w*.8,c:[0.4,0.9,1]});
  Audio2.build();
  return b;
}
function validBuildSpot(kind,x,z,owner,ignore=null){
  const d=BLD[kind];
  const hw=d.w/2-0.5,hd=d.d/2-0.5;
  if(x<hw+2||z<hd+2||x>MAPW*CELL-hw-2||z>MAPD*CELL-hd-2)return false;
  const h0=terrainH(x,z);
  // flatness
  for(const[dx,dz]of[[-hw,-hd],[hw,-hd],[-hw,hd],[hw,hd],[0,0]])
    if(Math.abs(terrainH(x+dx,z+dz)-h0)>2.5)return false;
  // solid inside
  for(let sx=Math.floor((x-hw)/CELL);sx<=Math.floor((x+hw)/CELL);sx++)
    for(let sz=Math.floor((z-hd)/CELL);sz<=Math.floor((z+hd)/CELL);sz++)
      if(isSolidCell(sx,sz))return false;
  // overlap buildings
  for(const b of buildings)if(b!==ignore){
    if(Math.abs(b.x-x)<(d.w+b.w)/2-1&&Math.abs(b.z-z)<(d.d+b.d)/2-1)return false;
  }
  return true;
}
function nearestStore(o,x,z,maxD=1e9){
  let best=null,bd=maxD;
  for(const b of buildings)if(b.owner===o&&b.built){
    const d=dist2(x,z,b.x,b.z);
    if(d<bd){bd=d;best=b}
  }
  return best;
}
function nearestField(o,x,z,maxD=1e9){
  let best=null,bd=maxD;
  for(const f of fields)if(!f.depleted){
    const d=dist2(x,z,f.x,f.z);
    if(d<bd){bd=d;best=f}
  }
  return best;
}
/* ============================== combat ============================== */
function upgMul(o,kind){
  const U=game.upgrades[o];
  if(kind==='melee')return U.includes('ion')?1.3:1;
  if(kind==='laser')return U.includes('coil')?1.3:1;
  return 1;
}
function canTarget(attacker,def){ // attacker unit/building, def unit/building
  if(def.owner===attacker.owner)return false;
  if(def.dead)return false;
  const isUnitDef=(def.maxHp!==undefined&&def.kind&&UNITS[def.kind]!==undefined);
  const defAir=isUnitDef?def.kind==='specter':false;
  if(defAir){ // air: only laser weapons hit
    return attacker.dmgType==='laser';
  }
  return true;
}
function applyDamage(att,def,dmg){
  const mod=DMG_MOD[att.dmgType]||(DMG_MOD[att.armor]&&{light:1,heavy:1,fly:1});
  let m=1;
  if(att.dmgType==='kinetic')m=def.armor==='heavy'?.55:def.armor==='fly'?1:1;
  else if(att.dmgType==='laser')m=def.armor==='light'?1.4:def.armor==='heavy'?.7:1.2;
  const real=dmg*m;
  def.hp-=real;
  addEffect('hit',def.x,def.z,terrainH(def.x,def.z)+1,.18,{c:att.dmgType==='laser'?[0.4,0.9,1.6]:[2.2,1.4,0.4]});
  if(def.hp<=0){
    if(UNITS[def.kind]!==undefined)killUnit(def);else killBuilding(def);
    if(att.owner===0)game.stats.kills[def.kind]=(game.stats.kills[def.kind]||0)+1;
  }
  return real;
}
function fireWeapon(att,def,dt){
  att.atkCd=att.rate;
  addEffect('beam',att.x,att.z,(att.alt||0)+1.2,.12,{tx:def.x,tz:def.z,ty:terrainH(def.x,def.z)+1.5,
    c:att.dmgType==='laser'?[0.35,0.85,1.8]:[1.8,1.2,0.3]});
  if(att.dmgType==='laser')Audio2.shoot('laser');else Audio2.shoot('kinetic');
  applyDamage(att,def,att.dmg*upgMul(att.owner,att.kind||'laser'));
}
function acquireTarget(u,fromX,fromZ,maxRange){
  let best=null,bd=1e18;
  const R=maxRange||u.range+2;
  for(const e of units)if(e!==u&&canTarget(u,e)){
    if(!canSee(e.x,e.z))continue;
    const d=dist2(u.x,u.z,e.x,e.z);
    if(d<R*R&&d<bd){bd=d;best=e}
  }
  for(const e of buildings)if(canTarget(u,e)&&canSee(e.x,e.z)){
    const d=dist2(u.x,u.z,e.x,e.z);
    if(d<R*R&&d<bd){bd=d;best=e}
  }
  return best;
}
function findTarget(u){
  if(u.atkTarget&&!u.atkTarget.dead&&canTarget(u,u.atkTarget))return u.atkTarget;
  return null;
}

/* ============================== orders ============================== */
function orderMove(list,x,z){
  for(const u of list)if(UNITS[u.kind]!==undefined){
    u.order=null;u.gatherField=null;u.cargo=0;
    u.state='move';
    const p=findPath(u.x,u.z,x,z);
    u.path=p;u.wp=0;
    if(!p){u.state='idle'}
    addEffect('ring',x,z,terrainH(x,z),.5,{s:1.2,c:[0.4,0.9,1]});
  }
  Audio2.click();
}
function orderAttack(list,tgt){
  for(const u of list)if(UNITS[u.kind]!==undefined){
    u.order=null;u.gatherField=null;u.cargo=0;
    u.atkTarget=tgt;
    u.state='attack';
    const p=findPath(u.x,u.z,tgt.x,tgt.z);
    u.path=p;u.wp=0;
  }
  Audio2.click();
}
function orderAttackMove(list,x,z){
  for(const u of list)if(UNITS[u.kind]!==undefined){
    u.order={type:'attackmove',x,z};
    u.gatherField=null;u.cargo=0;
    u.state='move';
    const p=findPath(u.x,u.z,x,z);
    u.path=p;u.wp=0;
  }
  addEffect('ring',x,z,terrainH(x,z),.6,{s:1.5,c:[1.6,0.5,0.3]});
  Audio2.click();
}
function orderStop(list){
  for(const u of list)if(UNITS[u.kind]!==undefined){
    u.order=null;u.path=null;u.wp=0;u.state='idle';u.atkTarget=null;
  }
  Audio2.click();
}
function orderHold(list){
  for(const u of list)if(UNITS[u.kind]!==undefined){
    u.order={type:'hold'};u.path=null;u.wp=0;u.state='idle';
  }
  Audio2.click();
}
function orderGather(list,field){
  for(const u of list)if(UNITS[u.kind]!=='worker')continue;
  for(const u of list)if(UNITS[u.kind]==='worker'){
    u.order=null;u.atkTarget=null;
    u.gatherField=field;u.cargo=0;u.gatherPhase=0;
    u.state='move';
    const p=findPath(u.x,u.z,field.x,field.z);
    u.path=p;u.wp=0;
  }
  Audio2.click();
}
function orderReturn(list){
  for(const u of list)if(UNITS[u.kind]==='worker'){
    u.order=null;u.atkTarget=null;u.gatherField=null;
    u.state='move';
    const st=nearestStore(u.owner,u.x,u.z);
    if(st){const p=findPath(u.x,u.z,st.x,st.z);u.path=p;u.wp=0}
    else u.state='idle';
  }
  Audio2.click();
}
function selectList(list,add){
  if(!add)selection=[];
  for(const e of list)if(!selection.includes(e))selection.push(e);
  for(const e of selection)e.sel=true;
  hudDirty=true;
}
function clearSel(){for(const e of selection)e.sel=false;selection=[];hudDirty=true}

/* ============================== unit simulation ============================== */
function updateUnit(u,dt){
  if(u.dead)return;
  u.atkCd-=dt;
  const isAir=u.kind==='specter';
  u.bob+=dt*4;
  const altGoal=isAir?11:0;
  u.altTgt=altGoal;
  u.alt+=(u.altTgt-u.alt)*Math.min(1,dt*2.5);
  const holding=u.order&&u.order.type==='hold';
  const amove=u.order&&u.order.type==='attackmove';
  // acquire target (respect sight, range, targetability)
  if(!holding&&u.atkCd<=0){
    if(u.atkTarget&&u.atkTarget.dead)u.atkTarget=null;
    if(!u.atkTarget){
      const t=amove?acquireTarget(u,u.x,u.z,u.range+3):acquireTarget(u,u.x,u.z,Math.max(u.range+1,6));
      if(t){
        u.atkTarget=t;
        if(u.order)u.order=null;
        u.state='attack';
        const p=findPath(u.x,u.z,t.x,t.z);
        u.path=p;u.wp=0;
      }
    }
  }
  if(u.atkTarget){
    const t=u.atkTarget;
    if(t.dead){u.atkTarget=null;u.state='idle'}
    else{
      u.state='attack';
      const d=dist(u.x,u.z,t.x,t.z);
      if(d<=u.range+(t.radius||1.5)){
        if(u.atkCd<=0)fireWeapon(u,t,dt);
      }else followPath(u,dt,t.x,t.z);
    }
  }
  else if(u.state==='move'||u.state==='attack'){
    followPath(u,dt);
    if(amove){
      if(u.path===null||u.wp>=u.path.length){
        const p=findPath(u.x,u.z,u.order.x,u.order.z);
        u.path=p;u.wp=0;
        if(!p)u.order=null;
      }
    }
    if(!u.atkTarget&&u.state==='move'&&(u.path===null||u.wp>=u.path.length)){
      u.state='idle';
      if(u.order&&u.order.type==='attackmove')u.order=null;
    }
  }
  // worker gathering
  if(UNITS[u.kind]==='worker'&&!u.atkTarget)workerSim(u,dt);
}
function followPath(u,dt,ox,oz){
  if(!u.path||u.wp>=u.path.length)return;
  let tx=ox!==undefined?ox:u.path[u.wp][0],tz=oz!==undefined?oz:u.path[u.wp][1];
  const d=dist(u.x,u.z,tx,tz);
  const spd=u.spd*(game.upgrades[u.owner].includes('thr')?1.15:1);
  if(d<0.9){
    u.wp++;
    if(u.wp>=u.path.length){u.path=null;return}
    tx=u.path[u.wp][0];tz=u.path[u.wp][1];
  }
  const a=angTo(u.x,u.z,tx,tz);
  const step=Math.min(d,spd*dt);
  u.x+=Math.sin(a)*step;u.z+=Math.cos(a)*step;
  u.heading=a;
}
function separate(u,dt){
  // push away from nearby allies (cheap grid-free: only check small subset via hash)
  const cellX=Math.floor(u.x/8),cellZ=Math.floor(u.z/8);
  for(const v of units){
    if(v===u||v.dead)continue;
    const dx=v.x-u.x,dz=v.z-u.z;
    if(Math.abs(dx)>3.4||Math.abs(dz)>3.4)continue;
    const d=Math.sqrt(dx*dx+dz*dz);
    const minD=(u.radius+v.radius)*0.8;
    if(d<minD&&d>0.001){
      const push=(minD-d)/d*0.5;
      u.x-=dx*push;u.z-=dz*push;
    }
  }
}
function workerSim(u,dt){
  const f=u.gatherField;
  if(f){
    if(f.depleted){u.gatherField=null;return}
    const d=dist(u.x,u.z,f.x,f.z);
    if(u.cargo===0){
      if(d>6){
        // move to field
        if(!u.path||u.wp>=u.path.length){
          const p=findPath(u.x,u.z,f.x+rand(-3,3),f.z+rand(-3,3));
          u.path=p;u.wp=0;
        }
        followPath(u,dt);
      }else{
        u.path=null;
        u.gatherPhase+=dt;
        if(u.gatherPhase>0.8){
          u.gatherPhase=0;
          if(f.amt>0){
            f.amt=Math.max(0,f.amt-1);
            u.cargo+=8;
            addEffect('hit',f.x+rand(-2,2),f.z+rand(-2,2),terrainH(f.x,f.z)+1,.15,{c:[0.5,1.2,1.6]});
            if(f.amt<=0)f.depleted=true;
          }
        }
      }
    }else{
      // return
      const st=nearestStore(u.owner,u.x,u.z,2000);
      if(!st){u.cargo=0;return}
      const ds=dist(u.x,u.z,st.x,st.z);
      if(ds<st.w){
        resOf(u.owner)=Math.min(RES_CAP,resOf(u.owner)+u.cargo);
        game.inc[u.owner]+=u.cargo;
        u.cargo=0;
        addEffect('ring',st.x,st.z,terrainH(st.x,st.z)+.5,.4,{s:2,c:[0.5,1,0.6]});
      }else{
        if(!u.path||u.wp>=u.path.length){
          const p=findPath(u.x,u.z,st.x+rand(-2,2),st.z+rand(-2,2));
          u.path=p;u.wp=0;
        }
        followPath(u,dt);
      }
    }
  }
}

/* ============================== building simulation ============================== */
function updateBuilding(b,dt){
  if(b.dead)return;
  if(!b.built){
    b.buildT-=dt;
    if(b.buildT<=0){
      b.built=true;
      addEffect('ring',b.x,b.z,terrainH(b.x,b.z)+.5,.8,{s:b.w,c:[0.5,1,0.7]});
      if(b.owner===0)Audio2.done();
      // spawn rally
      if(b.rally){
        // nothing here; rally used at production
      }
    }
    return;
  }
  // research
  if(b.research){
    b.research.t-=dt;
    if(b.research.t<=0){
      game.upgrades[b.owner].push(b.research.key);
      addEffect('ring',b.x,b.z,terrainH(b.x,b.z)+2,1,{s:b.w*1.4,c:[1.2,0.9,0.4]});
      if(b.owner===0){Audio2.done();toast(UPG[b.research.key].name+' researched','info')}
      b.research=null;
    }
  }
  // production
  if(b.queue.length&&b.built){
    const q=b.queue[0];
    q.t-=dt;
    if(q.t<=0){
      b.queue.shift();
      spawnFromBuilding(b,q.kind);
    }
  }
  // turret combat
  if(b.kind==='turret'){
    b.atkCd-=dt;
    if(b.atkTarget&&b.atkTarget.dead)b.atkTarget=null;
    if(!b.atkTarget&&b.atkCd<=0){
      const t=acquireTarget({owner:b.owner,dmgType:'laser',range:30,x:b.x,z:b.z,kind:'turret'},b.x,b.z,30);
      if(t)b.atkTarget=t;
    }
    if(b.atkTarget){
      const d=dist(b.x,b.z,b.atkTarget.x,b.atkTarget.z);
      if(d<=30){
        if(b.atkCd<=0){
          b.atkCd=1.0;
          addEffect('beam',b.x,b.z,terrainH(b.x,b.z)+3,.12,{tx:b.atkTarget.x,tz:b.atkTarget.z,ty:terrainH(b.atkTarget.x,b.atkTarget.z)+1.5,c:[0.4,0.9,1.8]});
          Audio2.shoot('laser');
          const dmg=14*(game.upgrades[b.owner].includes('coil')?1.3:1);
          applyDamage({dmgType:'laser',owner:b.owner,kind:'turret'},b.atkTarget,dmg);
        }
      }else b.atkTarget=null;
    }
  }
}
function spawnFromBuilding(b,kind){
  const d=UNITS[kind];
  const o=b.owner;
  let sx=b.x,sz=b.z;
  // find free spot near building
  for(let i=0;i<12;i++){
    const a=rand(TAU),r=b.w*.9+1.5;
    sx=b.x+Math.sin(a)*r;sz=b.z+Math.cos(a)*r;
    if(!isSolid(sx,sz))break;
  }
  const hpMul=game.upgrades[o].includes('plate')?1.25:1;
  const u=mkUnit(kind,o,sx,sz,{hpMul});
  u.hp=u.maxHp;
  // rally
  if(b.rally){
    u.state='move';
    const p=findPath(sx,sz,b.rally[0],b.rally[1]);
    u.path=p;u.wp=0;
  }
  addEffect('ring',sx,sz,terrainH(sx,sz),.5,{s:1.5,c:o===0?[0.4,0.9,1]:[1.4,0.5,0.3]});
  if(o===0)Audio2.done();
  return u;
}

/* ============================== effects ============================== */
function updateEffects(dt){
  for(let i=effects.length-1;i>=0;i--){
    const e=effects[i];
    e.t+=dt;
    if(e.t>=e.dur)effects.splice(i,1);
  }
}

/* ============================== game state ============================== */
function newGame(seed){
  units.length=0;buildings.length=0;effects.length=0;
  for(const k in ctrlGroups)delete ctrlGroups[k];
  selection.length=0;
  nextId=1;
  const s=seed!==undefined?seed:(Math.random()*1e9|0);
  genMap(s);
  game={
    seed:s,time:0,
    res:[250,250],
    upgrades:[[],[]],
    fog:null,
    stats:{kills:{},destroyed:{},unitsLost:[0,0],unitsBuilt:[0,0]},
    inc:[0,0],
    ai:{scoutT:8,expandT:60,attackT:90,defT:0,phase:0},
    over:false,win:null
  };
  fogInit();
  buildMinimapBase();
  const p1=terrain.p1,p2=terrain.p2;
  const n0=mkBuilding('nexus',0,p1.x,p1.z,{instant:true,rally:[p1.x+30,p1.z-30]});
  const n1=mkBuilding('nexus',1,p2.x,p2.z,{instant:true,rally:[p2.x-30,p2.z+30]});
  n0.rally=[p1.x+30,p1.z-30];n1.rally=[p2.x-30,p2.z+30];
  for(let i=0;i<4;i++){
    mkUnit('worker',0,p1.x+rand(-8,8),p1.z+rand(-8,8));
    mkUnit('worker',1,p2.x+rand(-8,8),p2.z+rand(-8,8));
  }
  // initial vision
  fogUpdate();
  cam.x=p1.x;cam.z=p1.z;cam.tgt.x=p1.x;cam.tgt.z=p1.z;
  cam.yaw=0;cam.tgt.yaw=0;cam.pitch=1.05;cam.tgt.pitch=1.05;cam.zoom=60;cam.tgt.zoom=60;
  cam.follow=false;
  hudDirty=true;
  toast('Deploy complete. Build your economy, scout the frontier.','info');
}
function checkWin(){
  if(game.over)return;
  const n0=buildings.some(b=>b.owner===0&&b.kind==='nexus');
  const n1=buildings.some(b=>b.owner===1&&b.kind==='nexus');
  if(!n1){game.over=true;game.win=0}
  if(!n0){game.over=true;game.win=1}
  if(game.over)endScreen(game.win===0);
}
function endScreen(win){
  gameOver=true;
  const et=document.getElementById('endtitle');
  et.textContent=win?'VICTORY':'DEFEAT';
  et.className=win?'win':'lose';
  document.getElementById('endsub').textContent=win?
    'The rival expedition has been annihilated. Kestrel-9 is yours.':
    'Your last Nexus has fallen. The frontier claims another expedition.';
  const st=game.stats;
  const kills=Object.values(st.kills).reduce((a,b)=>a+b,0);
  document.getElementById('stats').innerHTML=
    `Time: <b>${fmtT(game.time)}</b> · Units destroyed: <b>${kills}</b> · Buildings destroyed: <b>${(st.destroyed.bldEnemy||0)+(st.destroyed.bldPlayer||0)}</b><br>`+
    `Units lost: <b>${st.unitsLost[0]}</b> · Built: <b>${st.unitsBuilt[0]}</b> · Final supply: <b>${supplyUsed(0)}/${supplyCap(0)}</b>`;
  document.getElementById('endscreen').classList.remove('hidden');
  Audio2[win?'done':'alarm']();
}
function toast(msg,cls='info'){
  const t=document.createElement('div');
  t.className='toast '+cls;
  t.textContent=msg;
  document.getElementById('toasts').appendChild(t);
  setTimeout(()=>t.remove(),4200);
}

/* ============================== matrices ============================== */
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(e,c,u){
  let zx=e[0]-c[0],zy=e[1]-c[1],zz=e[2]-c[2];let l=Math.hypot(zx,zy,zz);zx/=l;zy/=l;zz/=l;
  let xx=u[1]*zz-u[2]*zy,xy=u[2]*zx-u[0]*zz,xz=u[0]*zy-u[1]*zx;l=Math.hypot(xx,xy,xz);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*e[0]+xy*e[1]+xz*e[2]),-(yx*e[0]+yy*e[1]+yz*e[2]),-(zx*e[0]+zy*e[1]+zz*e[2]),1]}
function m4Mul(a,b){const o=new Array(16);
  for(let i=0;i<4;i++)for(let j=0;j<4;j++){let s=0;for(let k=0;k<4;k++)s+=a[k*4+i]*b[j*4+k];o[j*4+i]=s}
  return o}
function m4Inv(m){
  const o=new Array(16);
  o[0]=m[5]*m[10]*m[15]-m[5]*m[11]*m[14]-m[9]*m[6]*m[15]+m[9]*m[7]*m[14]+m[13]*m[6]*m[11]-m[13]*m[7]*m[10];
  o[4]=-m[4]*m[10]*m[15]+m[4]*m[11]*m[14]+m[8]*m[6]*m[15]-m[8]*m[7]*m[14]-m[12]*m[6]*m[11]+m[12]*m[7]*m[10];
  o[8]=m[4]*m[9]*m[15]-m[4]*m[11]*m[13]-m[8]*m[5]*m[15]+m[8]*m[7]*m[13]+m[12]*m[5]*m[11]-m[12]*m[7]*m[9];
  o[12]=-m[4]*m[9]*m[14]+m[4]*m[10]*m[13]+m[8]*m[5]*m[14]-m[8]*m[6]*m[13]-m[12]*m[5]*m[10]+m[12]*m[6]*m[9];
  o[1]=-m[1]*m[10]*m[15]+m[1]*m[11]*m[14]+m[9]*m[2]*m[15]-m[9]*m[3]*m[14]-m[13]*m[2]*m[11]+m[13]*m[3]*m[10];
  o[5]=m[0]*m[10]*m[15]-m[0]*m[11]*m[14]-m[8]*m[2]*m[15]+m[8]*m[3]*m[14]+m[12]*m[2]*m[11]-m[12]*m[3]*m[10];
  o[9]=-m[0]*m[9]*m[15]+m[0]*m[11]*m[13]+m[8]*m[1]*m[15]-m[8]*m[3]*m[13]-m[12]*m[1]*m[11]+m[12]*m[3]*m[9];
  o[13]=m[0]*m[9]*m[14]-m[0]*m[10]*m[13]-m[8]*m[1]*m[14]+m[8]*m[2]*m[13]+m[12]*m[1]*m[10]-m[12]*m[2]*m[9];
  o[2]=m[1]*m[6]*m[15]-m[1]*m[7]*m[14]-m[5]*m[2]*m[15]+m[5]*m[3]*m[14]+m[13]*m[2]*m[7]-m[13]*m[3]*m[6];
  o[6]=-m[0]*m[6]*m[15]+m[0]*m[7]*m[14]+m[4]*m[2]*m[15]-m[4]*m[3]*m[14]-m[12]*m[2]*m[7]+m[12]*m[3]*m[6];
  o[10]=m[0]*m[5]*m[15]-m[0]*m[7]*m[13]-m[4]*m[1]*m[15]+m[4]*m[3]*m[13]+m[12]*m[1]*m[7]-m[12]*m[3]*m[5];
  o[14]=-m[0]*m[5]*m[14]+m[0]*m[6]*m[13]+m[4]*m[1]*m[14]-m[4]*m[2]*m[13]-m[12]*m[1]*m[6]+m[12]*m[2]*m[5];
  o[3]=-m[1]*m[6]*m[11]+m[1]*m[7]*m[10]+m[5]*m[2]*m[11]-m[5]*m[3]*m[10]-m[9]*m[2]*m[7]+m[9]*m[3]*m[6];
  o[7]=m[0]*m[6]*m[11]-m[0]*m[7]*m[10]-m[4]*m[2]*m[11]+m[4]*m[3]*m[10]+m[8]*m[2]*m[7]-m[8]*m[3]*m[6];
  o[11]=-m[0]*m[5]*m[11]+m[0]*m[7]*m[9]+m[4]*m[1]*m[11]-m[4]*m[3]*m[9]-m[8]*m[1]*m[7]+m[8]*m[3]*m[5];
  o[15]=m[0]*m[5]*m[10]-m[0]*m[6]*m[9]-m[4]*m[1]*m[10]+m[4]*m[2]*m[9]+m[8]*m[1]*m[6]-m[8]*m[2]*m[5];
  let det=m[0]*o[0]+m[1]*o[4]+m[2]*o[8]+m[3]*o[12];
  if(!det)return null;det=1/det;
  for(let i=0;i<16;i++)o[i]*=det;
  return o;
}
function m4T(x,y,z){return [1,0,0,0,0,1,0,0,0,0,1,0,x,y,z,1]}
function m4RY(a){const c=Math.cos(a),s=Math.sin(a);return [c,0,-s,0, 0,1,0,0, s,0,c,0, 0,0,0,1]}
function m4S(x,y,z){return [x,0,0,0,0,y,0,0,0,0,z,0,0,0,0,1]}
let PV=null,IPV=null;
function project(x,y,z){
  if(!PV)return null;
  const m=PV;
  const w=m[3]*x+m[7]*y+m[11]*z+m[15];
  const cx=(m[0]*x+m[4]*y+m[8]*z+m[12])/w;
  const cy=(m[1]*x+m[5]*y+m[9]*z+m[13])/w;
  if(w<=0)return null;
  return [(cx*0.5+0.5)*innerWidth,(1-(cy*0.5+0.5))*innerHeight];
}
function unproject(sx,sy){
  if(!IPV)return null;
  const nx=(sx/innerWidth)*2-1,ny=1-(sy/innerHeight)*2;
  const m=IPV;
  const w=m[3]*nx+m[7]*ny+m[11]*(-1)+m[15];
  if(Math.abs(w)<1e-6)return null;
  return [(m[0]*nx+m[4]*ny+m[8]*(-1)+m[12])/w,(m[1]*nx+m[5]*ny+m[9]*(-1)+m[13])/w,(m[2]*nx+m[6]*ny+m[10]*(-1)+m[14])/w];
}
function screenToWorld(sx,sy){
  const o=unproject(sx,sy);if(!o)return null;
  const o2=unproject(sx+1,sy);
  if(!o2)return null;
  let dx=o2[0]-o[0],dy=o2[1]-o[1],dz=o2[2]-o[2];
  const l=Math.hypot(dx,dy,dz);dx/=l;dy/=l;dz/=l;
  for(let t=0;t<900;t+=2.5){
    const px=o[0]+dx*t,py=o[1]+dy*t,pz=o[2]+dz*t;
    if(py<=terrainH(px,pz))return [px,pz];
    if(px<-50||pz<-50||px>MAPW*CELL+50||pz>MAPD*CELL+50)return null;
  }
  return null;
}

/* ============================== AI ============================== */
function aiUpdate(dt){
  const A=game.ai,t=game.time;
  A.phase=t<150?0:t<300?1:t<450?2:3;
  A.econT=(A.econT||0)-dt;
  A.scoutT-=dt;
  A.attackCd=(A.attackCd||0)-dt;
  if(A.econT<=0){
    A.econT=2;
    aiEconomy();
  }
  if(A.scoutT<=0){
    A.scoutT=40+rand(0,20);
    aiScout();
  }
  aiAttack(dt);
}
function aiEconomy(){
  const o=1;
  const myB=buildings.filter(b=>b.owner===o);
  const nexus=myB.find(b=>b.kind==='nexus');
  if(!nexus)return;
  const workers=units.filter(u=>u.owner===o&&u.kind==='worker');
  const combat=units.filter(u=>u.owner===o&&u.kind!=='worker');
  // 1. assign idle workers to fields
  for(const w of workers){
    if(w.gatherField&&!w.gatherField.depleted)continue;
    let best=null,bd=1e18;
    for(const f of fields){
      if(f.depleted)continue;
      const load=units.filter(u=>u.owner===o&&u.kind==='worker'&&u.gatherField===f).length;
      if(load>=4)continue;
      const d=dist2(w.x,w.z,f.x,f.z);
      if(d<bd){bd=d;best=f}
    }
    if(best)orderGather([w],best);
  }
  // 2. train workers
  const cap=supplyCap(o),used=supplyUsed(o);
  if(workers.length<7&&resOf(o)>=60&&used+1<=cap&&nexus.queue.length===0)trainUnit(nexus,'worker');
  // 3. buildings
  const foundries=myB.filter(b=>b.kind==='foundry').length;
  const hangars=myB.filter(b=>b.kind==='hangar').length;
  const nexuses=myB.filter(b=>b.kind==='nexus').length;
  if(foundries===0&&resOf(o)>=150)aiBuild('foundry',nexus);
  if(foundries<2&&resOf(o)>=320&&t>140)aiBuild('foundry',nexus);
  if(hangars===0&&resOf(o)>=240&&t>200)aiBuild('hangar',nexus);
  if(nexuses<2&&resOf(o)>=420&&t>240)aiExpand();
  if(cap-used<12&&resOf(o)>=150&&foundries<3)aiBuild('foundry',nexus);
  // 4. research
  if(!nexus.research&&nexus.built){
    const want=['plate','ion','coil','thr','orbit'];
    for(const k of want)if(!game.upgrades[o].includes(k)&&resOf(o)>=UPG[k].cost){research(nexus,k);break}
  }
  // 5. production
  const mix=A.phase===0?['mauler','mauler','lance']:
    A.phase===1?['mauler','lance','lance','bulwark']:
    A.phase===2?['lance','bulwark','bulwark','specter']:
    ['lance','bulwark','specter','specter'];
  let budget=Math.min(2,A.phase+1);
  for(const b of myB){
    if(b.kind==='foundry'&&b.built&&budget>0){
      while(b.queue.length<3){
        const k=mix[randi(0,mix.length-1)];
        if(UNITS[k].gt===2)break;
        const err=trainUnit(b,k);
        if(err)break;
        budget--;
        if(budget<=0)break;
      }
    }
    if(b.kind==='hangar'&&b.built&&budget>0){
      while(b.queue.length<2){
        const err=trainUnit(b,'specter');
        if(err)break;
        budget--;
      }
    }
  }
  // 6. defend
  let threat=null;
  for(const u of units)if(u.owner===0&&u.kind!=='worker'){
    for(const b of myB)if(dist(u.x,u.z,b.x,b.z)<90){threat=u;break}
    if(threat)break;
  }
  if(threat){
    for(const u of combat){
      if(dist(u.x,u.z,threat.x,threat.z)<220)orderAttack([u],threat);
    }
  }
}
function aiBuild(kind,anchor){
  for(let i=0;i<14;i++){
    const a=rand(TAU),r=10+rand(0,10);
    const x=anchor.x+Math.sin(a)*r,z=anchor.z+Math.cos(a)*r;
    if(validBuildSpot(kind,x,z,1)){startBuild(kind,1,x,z);return true}
  }
  return false;
}
function aiExpand(){
  // pick a field far from player units
  let best=null,bs=-1;
  for(const f of fields){
    if(f.depleted)continue;
    let minP=1e18;
    for(const u of units)if(u.owner===0)minP=Math.min(minP,dist2(u.x,u.z,f.x,f.z));
    for(const b of buildings)if(b.owner===0)minP=Math.min(minP,dist2(b.x,b.z,f.x,f.z));
    const score=Math.sqrt(minP)-dist(f.x,f.z,terrain.p2.x,terrain.p2.z)*0.3;
    if(score>bs){bs=score;best=f}
  }
  if(!best)return;
  for(let i=0;i<10;i++){
    const a=rand(TAU),r=rand(6,10);
    const x=best.x+Math.sin(a)*r,z=best.z+Math.cos(a)*r;
    if(validBuildSpot('nexus',x,z,1)){startBuild('nexus',1,x,z);return}
  }
}
function aiScout(){
  const workers=units.filter(u=>u.owner===1&&u.kind==='worker'&&u.cargo===0);
  if(!workers.length)return;
  const w=workers[randi(0,workers.length-1)];
  // patrol contested fields & midpoints & player base vicinity
  const pts=fields.map(f=>[f.x,f.z]);
  pts.push([MAPW*CELL*.5,MAPD*CELL*.5]);
  pts.push([terrain.p1.x+rand(-40,40),terrain.p1.z+rand(-40,40)]);
  const p=pts[randi(0,pts.length-1)];
  orderMove([w],p[0],p[1]);
}
function aiAttack(dt){
  const A=game.ai;
  const combat=units.filter(u=>u.owner===1&&u.kind!=='worker');
  const threshold=[10,16,24,34][A.phase];
  const cap=supplyCap(1);
  const playerNexus=buildings.find(b=>b.owner===0&&b.kind==='nexus');
  if(!playerNexus)return;
  if(A.gathering){
    // check arrival
    let arrived=combat.filter(u=>dist(u.x,u.z,A.stage[0],A.stage[1])<70).length;
    if(arrived>=combat.length*0.7&&combat.length>0){
      A.gathering=false;
      orderAttack(combat,playerNexus);
      A.attackCd=80;
    }
    return;
  }
  if(combat.length>=threshold&&A.attackCd<=0&&combat.length<cap-4){
    A.gathering=true;
    A.stage=[playerNexus.x+rand(-60,60),playerNexus.z+rand(-60,60)];
    for(const u of combat)orderAttackMove([u],A.stage[0],A.stage[1]);
  }
}

/* ============================== rendering ============================== */
const COL_P=[0.34,0.8,1.0],COL_E=[1.0,0.45,0.28],COL_G=[0.45,0.48,0.52];
function tintCopy(srcName,color,outName){
  const n=VBOs[srcName+'N'];
  const v=new Float32Array(n*7);
  v.set(GEOM[srcName]);
  for(let i=0;i<n;i++){
    v[i*7]=Math.min(3,v[i*7]*color[0]);v[i*7+1]=Math.min(3,v[i*7+1]*color[1]);v[i*7+2]=Math.min(3,v[i*7+2]*color[2]);
  }
  GEOM[outName]=v;
  VBOs[outName]=gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER,VBOs[outName]);
  gl.bufferData(gl.ARRAY_BUFFER,v,gl.STATIC_DRAW);
  VBOs[outName+'N']=n;
}
function buildTints(){
  for(const k of ['worker','mauler','lance','bulwark','specter']){
    tintCopy('u_'+k,COL_P,'u_'+k+'_p');
    tintCopy('u_'+k,COL_E,'u_'+k+'_e');
  }
  for(const k of ['nexus','foundry','hangar','turret']){
    tintCopy('b_'+k,COL_P,'b_'+k+'_p');
    tintCopy('b_'+k,COL_E,'b_'+k+'_e');
    tintCopy('b_'+k,COL_G,'b_'+k+'_g');
  }
  tintCopy('fx_ring',COL_P,'fx_ring_c');
  tintCopy('fx_ring',[1,0.6,0.3],'fx_ring_o');
  tintCopy('fx_ring',[0.5,0.5,0.5],'fx_ring_g');
  tintCopy('fx_ring',[0.4,1,0.55],'fx_ring_ok');
  tintCopy('fx_ring',[1,0.35,0.3],'fx_ring_bad');
  tintCopy('fx_ring',[1,1,1],'fx_ring_w');
  tintCopy('fx_beam',COL_P,'fx_beam_c');
  tintCopy('fx_beam',[1.4,0.9,0.3],'fx_beam_o');
  tintCopy('sel_ring',[0.9,1,1],'sel_ring_w');
  tintCopy('b_nexus',[0.6,1,0.8],'b_nexus_prev');
  tintCopy('b_foundry',[0.6,1,0.8],'b_foundry_prev');
  tintCopy('b_hangar',[0.6,1,0.8],'b_hangar_prev');
  tintCopy('b_turret',[0.6,1,0.8],'b_turret_prev');
}
let fieldColT=0;
function render(t){
  if(!gl||!game)return;
  const W=innerWidth,H=innerHeight;
  const asp=W/H;
  const cp=Math.cos(cam.pitch),sp=Math.sin(cam.pitch);
  const eyeY=terrainH(cam.x,cam.z)+4;
  const eye=[cam.x+Math.sin(cam.yaw)*cp*cam.zoom,eyeY+sp*cam.zoom,cam.z+Math.cos(cam.yaw)*cp*cam.zoom];
  const V=m4LookAt(eye,[cam.x,eyeY,cam.z],[0,1,0]);
  const P=m4Persp(0.9,asp,2,1400);
  PV=m4Mul(P,V);IPV=m4Inv(PV);
  gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);
  gl.uniformMatrix4fv(uni.uMVP,false,PV);
  gl.uniform3f(uni.uLightDir,0.4,0.8,0.35);
  gl.uniform3fv(uni.uCamPos,eye);
  gl.uniform1f(uni.uTime,t);
  const M0=m4T(0,0,0);
  draw('terrain',M0,t);
  draw('rocks',M0,t);
  // fields
  fieldColT-=1/60;
  for(const f of fields){
    const st=cellState(Math.floor(f.x/CELL),Math.floor(f.z/CELL));
    if(st===0)continue;
    let c;
    if(f.depleted)c=[0.25,0.3,0.3];
    else if(st===2)c=[0.5,1.4,1.7];
    else c=[0.25,0.6,0.7];
    if(fieldColT<=0){
      const v=new Float32Array(f.n*7);
      v.set(GEOM[f.key]);
      for(let i=0;i<f.n;i++){v[i*7]=Math.min(3,v[i*7]*c[0]);v[i*7+1]=Math.min(3,v[i*7+1]*c[1]);v[i*7+2]=Math.min(3,v[i*7+2]*c[2])}
      gl.bindBuffer(gl.ARRAY_BUFFER,f.buf);
      gl.bufferSubData(gl.ARRAY_BUFFER,0,v);
    }
    gl.uniformMatrix4fv(uni.uModel,false,M0);
    gl.bindBuffer(gl.ARRAY_BUFFER,f.buf);
    gl.vertexAttribPointer(A_POS,3,gl.FLOAT,false,28,0);
    gl.vertexAttribPointer(A_COL,4,gl.FLOAT,false,28,12);
    gl.drawArrays(gl.TRIANGLES,0,f.n);
  }
  if(fieldColT<=0)fieldColT=0.4;
  // buildings
  for(const b of buildings){
    const st=cellState(Math.floor(b.x/CELL),Math.floor(b.z/CELL));
    if(b.owner===1&&st===0)continue;
    let name;
    if(b.owner===0)name='b_'+b.kind+'_p';
    else name=st===2?'b_'+b.kind+'_e':'b_'+b.kind+'_g';
    const prog=b.built?1:0.35+0.65*(1-b.buildT/b.buildTotal);
    const M=m4Mul(m4T(b.x,terrainH(b.x,b.z),b.z),m4S(1,prog,1));
    draw(name,M,t);
    if(b.sel)draw('sel_ring_w',m4Mul(m4T(b.x,terrainH(b.x,b.z)+0.1,b.z),m4S(b.w*0.8,1,b.w*0.8)),t);
  }
  // units
  for(const u of units){
    if(u.dead)continue;
    if(u.owner===1&&!canSee(u.x,u.z))continue;
    const name='u_'+u.kind+'_'+(u.owner===0?'p':'e');
    const M=m4Mul(m4T(u.x,terrainH(u.x,u.z)+u.alt+Math.sin(u.bob)*0.12,u.z),m4RY(u.heading));
    draw(name,M,t);
    if(u.sel)draw('sel_ring_w',m4Mul(m4T(u.x,terrainH(u.x,u.z)+u.alt+0.1,u.z),m4S(u.radius*1.4,1,u.radius*1.4)),t);
  }
  // effects
  for(const e of effects){
    const k=e.t/e.dur;
    if(e.type==='beam'){
      const dx=e.extra.tx-e.x,dz=e.extra.tz-e.z,d=Math.hypot(dx,dz)||1;
      const M=m4Mul(m4T((e.x+e.extra.tx)/2,(e.y+e.extra.ty)/2,(e.z+e.extra.tz)/2),
        m4Mul(m4RY(Math.atan2(dz,dx)),m4S(d,1,1)));
      draw(e.extra.c[1]>1?'fx_beam_c':'fx_beam_o',M,t);
    }else if(e.type==='ring'){
      const s=e.extra.s*(0.4+k*1.2);
      draw('fx_ring_'+(e.extra.c? (e.extra.c[1]>1.2?'ok':e.extra.c[2]>1.2?'c':e.extra.c[0]>1.2?'o':'g'):'w'),
        m4Mul(m4T(e.x,e.y,e.z),m4S(s,1,s)),t);
    }else if(e.type==='hit'){
      draw('fx_beam_'+(e.extra.c[2]>1?'c':'o'),m4Mul(m4T(e.x,e.y,e.z),m4S(0.5,0.5,0.5)),t);
    }else if(e.type==='boom'){
      draw('fx_ring_g',m4Mul(m4T(e.x,e.y,e.z),m4S(1+k*8*e.extra.s,1,1+k*8*e.extra.s)),t);
      draw('fx_ring_o',m4Mul(m4T(e.x,e.y+0.2,e.z),m4S(0.5+k*5*e.extra.s,1,0.5+k*5*e.extra.s)),t);
    }
  }
  // placement preview
  if(buildPreview){
    const ok=buildPreview.valid;
    const M=m4Mul(m4T(buildPreview.x,terrainH(buildPreview.x,buildPreview.z),buildPreview.z),
      m4S(1,0.9,1));
    draw('b_'+buildPreview.kind+'_prev',M,t);
    draw(ok?'fx_ring_ok':'fx_ring_bad',
      m4Mul(m4T(buildPreview.x,terrainH(buildPreview.x,buildPreview.z)+0.1,buildPreview.z),
        m4S(BLD[buildPreview.kind].w*0.75,1,BLD[buildPreview.kind].w*0.75)),t);
  }
}
let buildPreview=null;

/* ============================== camera & input ============================== */
function camUpdate(dt){
  const k=Math.min(1,dt*6);
  cam.x+=(cam.tgt.x-cam.x)*k;cam.z+=(cam.tgt.z-cam.z)*k;
  cam.yaw+=(cam.tgt.yaw-cam.yaw)*k;cam.pitch+=(cam.tgt.pitch-cam.pitch)*k;cam.zoom+=(cam.tgt.zoom-cam.zoom)*k;
  const pan=(keys['arrowleft']?-1:0)+(keys['arrowright']?1:0);
  const panz=(keys['arrowup']?-1:0)+(keys['arrowdown']?1:0);
  if(pan||panz){
    const sp=cam.zoom*1.4*dt;
    cam.tgt.x+=Math.sin(cam.yaw)*pan*sp+Math.cos(cam.yaw)*panz*sp;
    cam.tgt.z+=Math.cos(cam.yaw)*pan*sp-Math.sin(cam.yaw)*panz*sp;
    cam.follow=false;
  }
  // edge scroll
  const M=24;
  if(mouse.onGL&&started&&!paused){
    if(mouse.x<M)cam.tgt.x-=cam.zoom*1.2*dt;
    if(mouse.x>innerWidth-M)cam.tgt.x+=cam.zoom*1.2*dt;
    if(mouse.y<M)cam.tgt.z-=cam.zoom*1.2*dt;
    if(mouse.y>innerHeight-M)cam.tgt.z+=cam.zoom*1.2*dt;
  }
  if(keys['q']&&!keys['e'])cam.tgt.yaw+=dt*1.8;
  if(keys['e']&&!keys['q'])cam.tgt.yaw-=dt*1.8;
  if(cam.follow&&selection.length){
    let cx=0,cz=0;for(const e of selection){cx+=e.x;cz+=e.z}
    cam.tgt.x=cx/selection.length;cam.tgt.z=cz/selection.length;
  }
  cam.tgt.x=clamp(cam.tgt.x,10,MAPW*CELL-10);
  cam.tgt.z=clamp(cam.tgt.z,10,MAPD*CELL-10);
  cam.tgt.zoom=clamp(cam.tgt.zoom,22,150);
  cam.tgt.pitch=clamp(cam.tgt.pitch,0.55,1.25);
}
let hudDirty=true;
function onKey(e,down){
  const k=e.key.toLowerCase();
  keys[k]=down;
  if(!down||!started||gameOver)return;
  if(k===' '){e.preventDefault();togglePause();return}
  if(k==='escape'){clearSel();buildPreview=null;hideBuildMenu();document.getElementById('help').classList.remove('show');return}
  if(k==='f1'){e.preventDefault();document.getElementById('help').classList.toggle('show');return}
  if(k==='f'){cam.follow=!cam.follow;return}
  if(k>='1'&&k<='9'){
    if(e.shiftKey&&selection.length){
      ctrlGroups[k]=selection.slice();
      toast('Control group '+k+' set ('+selection.length+')');
      return;
    }
    if(selection.length){
      const g=ctrlGroups[k]||[];
      selectList(g,true);
      return;
    }
    return;
  }
  if(selection.length){
    if(k==='a'){orderAttackMove(selection,mouse.wx,mouse.wz);return}
    if(k==='s'){orderStop(selection);return}
    if(k==='h'){orderHold(selection);return}
    if(k==='q'){
      const first=selection.find(e=>UNITS[e.kind]!==undefined);
      if(first){
        const same=units.filter(u=>u.kind===first.kind&&u.owner===0);
        selectList(same,true);
      }
      return;
    }
  }
}
function togglePause(){
  if(gameOver||!started)return;
  paused=!paused;
  document.getElementById('pauseveil').classList.toggle('show',paused);
}
let mmBase=null;
function buildMinimapBase(){
  mmBase=document.createElement('canvas');
  mmBase.width=200;mmBase.height=200;
  const c=mmBase.getContext('2d');
  const sx=200/(MAPW*CELL),sz=200/(MAPD*CELL);
  for(let z=0;z<MAPD;z++)for(let x=0;x<MAPW;x++){
    const h=terrain.h[z*MAPW+x];
    const sol=terrain.solid[z*MAPW+x];
    let r,g,b;
    if(sol){r=20;g=26;b=34}
    else if(h<3){r=14;g=34;b=24}
    else if(h<12){r=18;g=40;b=26}
    else if(h<24){r=34;g=34;b=22}
    else{r=58;g=54;b=50}
    c.fillStyle=`rgb(${r},${g},${b})`;
    c.fillRect(x*CELL*sx,z*CELL*sz,CELL*sx+1,CELL*sz+1);
  }
}
let mmT=0;
function renderMinimap(dt){
  mmT-=dt;if(mmT>0)return;mmT=0.12;
  const cv=document.getElementById('mm');
  const c=cv.getContext('2d');
  c.drawImage(mmBase,0,0);
  const sx=200/(MAPW*CELL),sz=200/(MAPD*CELL);
  // fields (explored+)
  for(const f of fields){
    const st=cellState(Math.floor(f.x/CELL),Math.floor(f.z/CELL));
    if(st===0)continue;
    c.fillStyle=f.depleted?'#333':st===2?'#6fe8ff':'#2a7a8f';
    c.fillRect(f.x*sx-2,f.z*sz-2,4,4);
  }
  // buildings
  for(const b of buildings){
    const st=cellState(Math.floor(b.x/CELL),Math.floor(b.z/CELL));
    if(b.owner===0)c.fillStyle='#5fd4ff';
    else if(st===2)c.fillStyle='#ff7a4d';
    else if(st===1)c.fillStyle='#8a8f96';
    else continue;
    c.fillRect(b.x*sx-2.5,b.z*sz-2.5,5,5);
  }
  // units (visible only)
  for(const u of units){
    if(!canSee(u.x,u.z))continue;
    c.fillStyle=u.owner===0?'#9fe8ff':'#ff9a6d';
    c.fillRect(u.x*sx-1.2,u.z*sz-1.2,2.5,2.5);
  }
  // fog
  const img=c.getImageData(0,0,200,200);
  const d=img.data;
  for(let z=0;z<MAPD;z++)for(let x=0;x<MAPW;x++){
    const st=game.fog[z*MAPW+x];
    if(st===0)continue;
    const a=st===1?170:0;
    if(a===0)continue;
    const px0=Math.floor(x*CELL*sx),px1=Math.floor((x+1)*CELL*sx);
    const pz0=Math.floor(z*CELL*sz),pz1=Math.floor((z+1)*CELL*sz);
    for(let py=pz0;py<pz1;py++)for(let px=px0;px<px1;px++){
      if(px>=200||py>=200)continue;
      const i=(py*200+px)*4;
      d[i]=d[i]*(1-a/255);d[i+1]=d[i+1]*(1-a/255);d[i+2]=d[i+2]*(1-a/255);
    }
  }
  c.putImageData(img,0,0);
  // camera rect
  const w=cam.zoom*1.7*sx,h=cam.zoom*1.7*sz;
  c.strokeStyle='#fff';c.lineWidth=1;
  c.strokeRect(cam.x*sx-w/2,cam.z*sz-h/2,w,h);
}
function mmToWorld(sx,sy){
  const r=document.getElementById('mm').getBoundingClientRect();
  const x=(sx-r.left)/200*MAPW*CELL;
  const z=(sy-r.top)/200*MAPD*CELL;
  return [clamp(x,0,MAPW*CELL),clamp(z,0,MAPD*CELL)];
}
function contextOrder(x,z,list){
  // enemy target?
  let tgt=null,bd=49;
  for(const e of units)if(e.owner===1&&canSee(e.x,e.z)){
    const d=dist2(x,z,e.x,e.z);if(d<bd){bd=d;tgt=e}
  }
  if(!tgt)for(const e of buildings)if(e.owner===1&&canSee(e.x,e.z)){
    const d=dist2(x,z,e.x,e.z);if(d<16){tgt=e}
  }
  if(tgt){orderAttack(list,tgt);return}
  const workers=list.filter(u=>u.kind==='worker');
  const others=list.filter(u=>u.kind!=='worker');
  if(workers.length){
    let f=null,bd=64;
    for(const fl of fields)if(!fl.depleted){
      const d=dist2(x,z,fl.x,fl.z);if(d<bd){bd=d;f=fl}
    }
    if(f){orderGather(workers,f)}
    else{
      let st=null,bd=1e18;
      for(const b of buildings)if(b.owner===0){
        const d=dist2(x,z,b.x,b.z);if(d<bd){bd=d;st=b}
      }
      if(st&&bd<100)orderReturn(workers);
      else orderMove(workers,x,z);
    }
  }
  if(others.length)orderMove(others,x,z);
}
function pickAt(sx,sy){
  // units first (screen space)
  let best=null,bd=14;
  for(const u of units){
    if(u.owner===1&&!canSee(u.x,u.z))continue;
    if(u.owner!==0)continue;
    const p=project(u.x,terrainH(u.x,u.z)+u.alt+1,u.z);
    if(!p)continue;
    const d=Math.hypot(p[0]-sx,p[1]-sy);
    if(d<bd){bd=d;best=u}
  }
  if(best)return best;
  best=null;bd=20;
  for(const b of buildings){
    if(b.owner!==0)continue;
    const p=project(b.x,terrainH(b.x,b.z)+2,b.z);
    if(!p)continue;
    const d=Math.hypot(p[0]-sx,p[1]-sy);
    if(d<bd){bd=d;best=b}
  }
  return best;
}
function setupInput(){
  const cv=document.getElementById('gl');
  cv.addEventListener('mousedown',e=>{
    if(!started||gameOver)return;
    mouse.down=true;mouse.button=e.button;
    mouse.sx=e.clientX;mouse.sy=e.clientY;mouse.dragging=false;
    if(e.button===0)Audio2.init();
  });
  window.addEventListener('mousemove',e=>{
    mouse.x=e.clientX;mouse.y=e.clientY;
    mouse.onGL=e.target===cv;
    if(started&&game){
      const w=screenToWorld(mouse.x,mouse.y);
      if(w){mouse.wx=w[0];mouse.wz=w[1]}
      if(buildPreview){
        buildPreview.x=mouse.wx;buildPreview.z=mouse.wz;
        buildPreview.valid=validBuildSpot(buildPreview.kind,mouse.wx,mouse.wz,0);
      }
    }
    if(mouse.down&&mouse.button===0&&started){
      if(Math.hypot(e.clientX-mouse.sx,e.clientY-mouse.sy)>7)mouse.dragging=true;
      if(mouse.dragging)boxSel={x0:mouse.sx,y0:mouse.sy,x1:e.clientX,y1:e.clientY};
    }
  });
  window.addEventListener('mouseup',e=>{
    if(!started)return;
    const wasDrag=mouse.dragging;
    if(mouse.button===0&&mouse.down){
      if(wasDrag&&boxSel){
        const x0=Math.min(boxSel.x0,boxSel.x1),x1=Math.max(boxSel.x0,boxSel.x1);
        const y0=Math.min(boxSel.y0,boxSel.y1),y1=Math.max(boxSel.y0,boxSel.y1);
        const found=[];
        for(const u of units){
          if(u.owner!==0)continue;
          const p=project(u.x,terrainH(u.x,u.z)+u.alt+1,u.z);
          if(p&&p[0]>=x0&&p[0]<=x1&&p[1]>=y0&&p[1]<=y1)found.push(u);
        }
        selectList(found,e.shiftKey);
      }else{
        const hit=pickAt(e.clientX,e.clientY);
        if(hit)selectList([hit],e.shiftKey);
        else if(!e.shiftKey)clearSel();
        else{
          // worker selected & empty ground → build menu
          const w=screenToWorld(e.clientX,e.clientY);
          if(w&&selection.some(u=>u.kind==='worker'))openBuildMenu(e.clientX,e.clientY,w);
        }
      }
    }
    if(mouse.button===2&&mouse.down&&started&&!gameOver){
      const w=screenToWorld(e.clientX,e.clientY);
      if(w){
        if(buildPreview){
          const b=startBuild(buildPreview.kind,0,w[0],w[1]);
          if(!b)Audio2.err();
          buildPreview=null;hideBuildMenu();
        }else if(selection.length){
          contextOrder(w[0],w[1],selection);
        }
      }
    }
    mouse.down=false;mouse.dragging=false;boxSel=null;
  });
  cv.addEventListener('contextmenu',e=>e.preventDefault());
  cv.addEventListener('wheel',e=>{
    e.preventDefault();
    cam.tgt.zoom*=e.deltaY>0?1.12:0.89;
  },{passive:false});
  window.addEventListener('keydown',e=>onKey(e,true));
  window.addEventListener('keyup',e=>onKey(e,false));
  // minimap
  const mm=document.getElementById('mm');
  mm.addEventListener('mousedown',e=>{
    e.stopPropagation();
    const w=mmToWorld(e.clientX,e.clientY);
    if(e.button===2&&selection.length){
      contextOrder(w[0],w[1],selection);
    }else{
      cam.tgt.x=w[0];cam.tgt.z=w[1];cam.follow=false;
    }
  });
  mm.addEventListener('contextmenu',e=>e.preventDefault());
  // buttons
  document.getElementById('btnStart').onclick=()=>{
    Audio2.init();
    document.getElementById('title').classList.add('hidden');
    newGame();
    started=true;
  };
  document.getElementById('btnRestart').onclick=()=>{
    document.getElementById('endscreen').classList.add('hidden');
    gameOver=false;paused=false;
    document.getElementById('pauseveil').classList.remove('show');
    newGame();
  };
  document.getElementById('btnPause').onclick=togglePause;
  document.getElementById('btnMute').onclick=function(){
    Audio2.muted=!Audio2.muted;
    this.textContent=Audio2.muted?'🔇':'🔊';
  };
  document.getElementById('btnHelp').onclick=()=>document.getElementById('help').classList.toggle('show');
  document.getElementById('helpbtn').onclick=()=>document.getElementById('help').classList.toggle('show');
  document.getElementById('helpClose').onclick=()=>document.getElementById('help').classList.remove('show');
}

/* ============================== HUD ============================== */
function openBuildMenu(sx,sy,w){
  const bm=document.getElementById('buildmenu');
  bm.innerHTML='';
  for(const k of BUILD_ORDER){
    const d=BLD[k];
    const btn=document.createElement('button');
    btn.className='abtn';
    btn.innerHTML=`<span><span class="nm">${d.name}</span><br><span class="cost">${d.cost} ◆</span> <span class="why">${d.sup?'+'+d.sup+' sup':''}</span></span>`;
    btn.title=d.desc;
    btn.onclick=ev=>{
      ev.stopPropagation();
      if(!canAfford(0,d.cost)){Audio2.err();toast('Not enough Crystal','bad');return}
      buildPreview={kind:k,x:w[0],z:w[1],valid:validBuildSpot(k,w[0],w[1],0)};
    };
    bm.appendChild(btn);
  }
  bm.style.left=Math.min(sx,innerWidth-250)+'px';
  bm.style.top=Math.min(sy,innerHeight-120)+'px';
  bm.classList.add('show');
}
function hideBuildMenu(){document.getElementById('buildmenu').classList.remove('show')}
let lastPanelKey='';
function hudUpdate(dt){
  if(!game)return;
  // top bar
  document.getElementById('resval').textContent=Math.floor(resOf(0));
  const cap=supplyCap(0),used=supplyUsed(0);
  const sv=document.getElementById('supval');
  sv.innerHTML=`<b>${used}</b>&nbsp;/&nbsp;<b>${cap}</b>&nbsp;SUPPLY`;
  sv.classList.toggle('low',used>=cap-2);
  const inc=document.getElementById('incval');
  inc.style.display='';
  inc.innerHTML=`<span style="color:var(--ok)">▲</span><b>${(game.inc[0]/5).toFixed(1)}/s</b>`;
  game.incT=(game.incT||0)-dt;
  if(game.incT<=0){game.incT=5;game.inc=[0,0]}
  document.getElementById('clock').textContent=fmtT(game.time);
  // box overlay
  if(boxSel){
    document.getElementById('gl').style.boxShadow='inset 0 0 0 0 transparent';
  }
  // selection panel
  const key=selection.map(e=>e.id).join(',')+selection.length;
  if(hudDirty||key!==lastPanelKey){
    lastPanelKey=key;hudDirty=false;
    renderPanel();
  }
  renderMinimap(dt);
}
function renderPanel(){
  const nameEl=document.getElementById('selname');
  const infoEl=document.getElementById('selinfo');
  const stateEl=document.getElementById('selstate');
  const actEl=document.getElementById('selactions');
  const hpWrap=document.getElementById('hpwrap');
  const hpBar=document.getElementById('hpbar');
  actEl.innerHTML='';
  hpWrap.classList.add('hidden');
  if(!selection.length){
    nameEl.textContent='No selection';
    infoEl.textContent=selection.length?'':'Right-click: order · select a worker to build';
    stateEl.innerHTML='<span style="color:var(--dim)">Select units or a building. Right-click to issue orders.</span>';
    return;
  }
  const single=selection.length===1?selection[0]:null;
  const isBld=single&&BLD[single.kind]!==undefined;
  const isUnit=single&&UNITS[single.kind]!==undefined;
  if(single){
    nameEl.textContent=(isBld?BLD[single.kind]:UNITS[single.kind]).name;
    infoEl.textContent=isBld?`Building · ${BLD[single.kind].cost}◆`:`${UNITS[single.kind].sup} supply`;
    const pct=single.hp/single.maxHp*100;
    hpWrap.classList.remove('hidden');
    hpBar.style.width=pct+'%';
    hpBar.className=pct>55?'':pct>25?'mid':'low';
    hpBar.id='hpbar';
  }else{
    const counts={};
    for(const e of selection)if(UNITS[e.kind]!==undefined)counts[e.kind]=(counts[e.kind]||0)+1;
    nameEl.textContent=selection.length+' units';
    infoEl.textContent=Object.entries(counts).map(([k,v])=>UNITS[k].name+' ×'+v).join(' · ');
    let hp=0,n=0;for(const e of selection)if(UNITS[e.kind]!==undefined){hp+=e.hp;e.maxHp&&(n+=e.maxHp)}
    if(n){
      const pct=hp/n*100;
      hpWrap.classList.remove('hidden');
      hpBar.style.width=pct+'%';
      hpBar.className=pct>55?'':pct>25?'mid':'low';
      hpBar.id='hpbar';
    }
  }
  // state text
  if(single&&isUnit){
    let s='';
    if(single.gatherField)s=single.cargo>0?'Returning crystal':'Harvesting crystal';
    else if(single.order&&single.order.type==='attackmove')s='Attack-move';
    else if(single.order&&single.order.type==='hold')s='Holding position';
    else if(single.atkTarget)s='Attacking';
    else if(single.state==='move')s='Moving';
    else s='Idle';
    stateEl.innerHTML=`<span class="ok">${s}</span> · HP ${Math.ceil(single.hp)}/${Math.ceil(single.maxHp)}`;
  }
  else if(single&&isBld){
    if(!single.built)stateEl.innerHTML=`<span class="ok">Constructing… ${Math.ceil(100-single.buildT/single.buildTotal*100)}%</span>`;
    else stateEl.innerHTML=`<span class="ok">Operational</span> · HP ${Math.ceil(single.hp)}/${Math.ceil(single.maxHp)}`;
  }
  else stateEl.innerHTML='';
  // actions
  if(single&&isBld&&single.built){
    const b=single;
    // train buttons
    if(BLD[b.kind].train){
      for(const k of Object.keys(BLD[b.kind].train)){
        const d=UNITS[k];
        const q=b.queue.length;
        const err=supplyCap(0)-supplyUsed(0)<d.sup?'supply':(resOf(0)<d.cost?'resources':null);
        const btn=document.createElement('button');
        btn.className='abtn';
        btn.disabled=!!err;
        btn.innerHTML=`<span><span class="nm">${d.name}</span> <span class="hk">${q+1}</span><br><span class="cost">${d.cost}◆</span> <span class="why">${d.sup} sup · ${d.time}s${err?' · '+err:''}</span></span>`;
        btn.onclick=()=>{const e=trainUnit(b,k);if(e){Audio2.err();toast(e[0].toUpperCase()+e.slice(1),'bad')}};
        actEl.appendChild(btn);
      }
    }
    // research (nexus)
    if(b.kind==='nexus'){
      for(const k of Object.keys(UPG)){
        if(game.upgrades[0].includes(k))continue;
        const d=UPG[k];
        const busy=b.research?true:false;
        const btn=document.createElement('button');
        btn.className='abtn';
        btn.disabled=busy||resOf(0)<d.cost;
        btn.innerHTML=`<span><span class="nm">${d.name}</span><br><span class="cost">${d.cost}◆</span> <span class="why">${d.time}s · ${d.desc}</span></span>`;
        btn.onclick=()=>{const e=research(b,k);if(e){Audio2.err();toast(e[0].toUpperCase()+e.slice(1),'bad')}};
        actEl.appendChild(btn);
      }
      if(b.research){
        const st=document.createElement('div');
        st.style.cssText='width:100%;font-size:12px;color:var(--warn)';
        st.textContent=`Researching ${UPG[b.research.key].name}… ${Math.ceil(b.research.t)}s`;
        actEl.appendChild(st);
      }
    }
    // queue with cancel
    if(b.queue.length){
      const qd=document.createElement('div');
      qd.style.cssText='width:100%;font-size:11px;color:var(--dim)';
      qd.innerHTML='Queue: '+b.queue.map((q,i)=>`${UNITS[q.kind].name} ${Math.ceil(q.t)}s`).join(' · ');
      actEl.appendChild(qd);
      const cx=document.createElement('button');
      cx.className='abtn';
      cx.innerHTML='<span class="nm">Cancel</span> <span class="why">full refund</span>';
      cx.onclick=()=>{cancelQueue(b,0)};
      actEl.appendChild(cx);
    }
    // rally for production buildings
    if(BLD[b.kind].train){
      const rb=document.createElement('button');
      rb.className='abtn';
      rb.innerHTML=`<span class="nm">Rally</span> <span class="hk">R</span>`;
      rb.title='Set rally point to cursor';
      rb.onclick=()=>{b.rally=[mouse.wx,mouse.wz];addEffect('ring',mouse.wx,mouse.wz,terrainH(mouse.wx,mouse.wz),.6,{s:2,c:[0.5,1,0.6]});Audio2.click()};
      actEl.appendChild(rb);
    }
  }
  if(selection.some(u=>UNITS[u.kind]!=='worker'&&u.kind!=='worker')||selection.length){
    if(selection.length){
      const am=document.createElement('button');
      am.className='abtn';
      am.innerHTML='<span><span class="nm">Attack Move</span> <span class="hk">A</span><br><span class="why">at cursor</span></span>';
      am.onclick=()=>orderAttackMove(selection,mouse.wx,mouse.wz);
      actEl.appendChild(am);
      const st=document.createElement('button');
      st.className='abtn';
      st.innerHTML='<span><span class="nm">Stop</span> <span class="hk">S</span></span>';
      st.onclick=()=>orderStop(selection);
      actEl.appendChild(st);
      const hd=document.createElement('button');
      hd.className='abtn';
      hd.innerHTML='<span><span class="nm">Hold</span> <span class="hk">H</span></span>';
      hd.onclick=()=>orderHold(selection);
      actEl.appendChild(hd);
    }
  }
}
function drawBox(){
  // simple: use canvas 2d overlay via CSS? draw on minimap? -> draw on gl? use a div
  const d=document.getElementById('boxdiv');
  if(boxSel){
    d.style.display='block';
    d.style.left=Math.min(boxSel.x0,boxSel.x1)+'px';
    d.style.top=Math.min(boxSel.y0,boxSel.y1)+'px';
    d.style.width=Math.abs(boxSel.x1-boxSel.x0)+'px';
    d.style.height=Math.abs(boxSel.y1-boxSel.y0)+'px';
  }else d.style.display='none';
}

/* ============================== main loop ============================== */
let simGrid=new Map();
function buildSimGrid(){
  simGrid.clear();
  for(const u of units){
    const k=((u.x/8)|0)+':'+((u.z/8)|0);
    let a=simGrid.get(k);
    if(!a){a=[];simGrid.set(k,a)}
    a.push(u);
  }
}
function separateFast(u){
  const cx=(u.x/8)|0,cz=(u.z/8)|0;
  for(let dz=-1;dz<=1;dz++)for(let dx=-1;dx<=1;dx++){
    const a=simGrid.get((cx+dx)+':'+(cz+dz));
    if(!a)continue;
    for(const v of a){
      if(v===u||v.dead)continue;
      const ddx=v.x-u.x,ddz=v.z-u.z;
      const d2=ddx*ddx+ddz*ddz;
      const minD=(u.radius+v.radius)*0.75;
      if(d2<minD*minD&&d2>0.0001){
        const d=Math.sqrt(d2);
        const push=(minD-d)/d*0.4;
        u.x-=ddx*push;u.z-=ddz*push;
      }
    }
  }
}
let last=0;
function frame(ts){
  requestAnimationFrame(frame);
  const dt=Math.min(0.05,(ts-last)/1000||0.016);last=ts;
  if(started&&game&&!paused&&!gameOver){
    game.time+=dt;
    buildSimGrid();
    const us=units.slice();
    for(const u of us)if(!u.dead){
      updateUnit(u,dt);
      if(!u.dead)separateFast(u);
    }
    for(const b of buildings)updateBuilding(b,dt);
    updateEffects(dt);
    fogUpdate();
    aiUpdate(dt);
    checkWin();
  }
  camUpdate(dt);
  render(ts/1000);
  hudUpdate(dt);
  drawBox();
}
/* ============================== boot ============================== */
window.addEventListener('DOMContentLoaded',()=>{
  if(!glInit())return;
  buildMeshes();
  buildTints();
  setupInput();
  requestAnimationFrame(frame);
});
</script>
</body>
</html>
<!-- agent-meta {"model":"ukisai/Swift-Qwen3.8-27B-GGUF","provider":"brigid","persona":"full","sessionId":"89b7921e-340e-4b93-abe2-b2670ae03bb1","tokensIn":2482452,"tokensOut":68860,"tokensTotal":2551312,"cost":0,"turns":26,"toolCalls":25,"failedToolCalls":3,"timestamp":"2026-09-30T01:15:50.938Z"} -->