← RTS results

RTS

GLM 5.3 Flash · full

Initial view of RTS

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

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

Generation prompt · exact text unavailable

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

Inspect original source 68,482 bytes · SHA-256 0fce2aef12db
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>FRONTIER WAR</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
:root{--teal:#39d7c8;--rust:#e0603a;--bg:#0a0e16;--panel:rgba(10,16,26,.88);--edge:#2a3a52;}
*{box-sizing:border-box;margin:0;padding:0;user-select:none;-webkit-user-select:none}
html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-family:"Segoe UI",Verdana,sans-serif;color:#cfe3ee}
canvas#view{position:absolute;inset:0;display:block;cursor:crosshair}
#hud{position:absolute;inset:0;pointer-events:none}
.hudbox{position:absolute;background:var(--panel);border:1px solid var(--edge);border-radius:6px;pointer-events:auto;padding:6px 10px;font-size:13px}
#topbar{top:8px;left:8px;right:8px;display:flex;gap:18px;align-items:center;padding:6px 12px}
#topbar .res{font-weight:600;color:#7fe8ff}
#topbar .sup{color:#ffd98a}
#topbar .sup.blocked{color:#ff7a6a}
#topbar .spacer{flex:1}
#topbar button,.btn{background:#16233a;border:1px solid var(--edge);color:#cfe3ee;border-radius:4px;padding:4px 10px;cursor:pointer;font-size:12px}
#topbar button:hover{background:#22345a}
#minimapWrap{right:8px;bottom:8px;padding:5px}
#minimap{display:block;border:1px solid var(--edge);cursor:pointer}
#selpanel{left:8px;bottom:8px;width:430px;min-height:120px;padding:8px}
#selpanel .title{color:#7fe8ff;font-size:13px;font-weight:600;margin-bottom:4px}
#cmdgrid{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px}
.cmdbtn{width:64px;height:52px;background:#131f33;border:1px solid #33496b;border-radius:5px;color:#d7ecf7;font-size:10.5px;cursor:pointer;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;line-height:1.25;padding:2px}
.cmdbtn:hover{background:#1e3050}
.cmdbtn.off{opacity:.42;cursor:not-allowed}
.cmdbtn .hk{color:#8fa8c8;font-size:9px}
.cmdbtn .cost{color:#ffd98a;font-size:9px}
#unitrow{display:flex;flex-wrap:wrap;gap:3px;margin-bottom:4px}
.uchip{background:#131f33;border:1px solid #33496b;border-radius:4px;padding:2px 6px;font-size:10.5px;cursor:pointer}
.uchip.sel{border-color:var(--teal);color:var(--teal)}
#queuebar{display:flex;gap:4px;margin-top:5px}
.qitem{width:44px;height:44px;background:#131f33;border:1px solid #33496b;border-radius:4px;font-size:9px;position:relative;cursor:pointer;overflow:hidden;display:flex;align-items:center;justify-content:center;text-align:center}
.qitem .prog{position:absolute;left:0;bottom:0;width:100%;background:rgba(57,215,200,.35)}
#infobar{left:50%;transform:translateX(-50%);bottom:8px;max-width:560px;font-size:12px;color:#a9c2d6;text-align:center;padding:4px 12px}
#alertbar{left:50%;transform:translateX(-50%);top:52px;font-size:13px;color:#ffb37a;padding:4px 14px;display:none}
#help{left:50%;top:50%;transform:translate(-50%,-50%);width:640px;max-height:80vh;overflow:auto;padding:16px 20px;font-size:13px;line-height:1.5;display:none}
#help h2{color:var(--teal);margin:8px 0 4px;font-size:15px}
#help b{color:#ffd98a}
.screen{position:absolute;inset:0;background:radial-gradient(ellipse at 50% 40%,#12203a 0%,#05070d 75%);display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:50;pointer-events:auto}
.screen h1{font-size:52px;letter-spacing:8px;color:var(--teal);text-shadow:0 0 24px rgba(57,215,200,.5);margin-bottom:6px}
.screen h1 .r{color:var(--rust);text-shadow:0 0 24px rgba(224,96,58,.5)}
.screen .sub{color:#8fa8c8;margin-bottom:22px;letter-spacing:2px}
.screen .box{max-width:640px;background:var(--panel);border:1px solid var(--edge);border-radius:8px;padding:16px 22px;font-size:13.5px;line-height:1.55;margin-bottom:22px}
.screen .box b{color:#ffd98a}
.screen .big{font-size:20px;padding:10px 34px;background:#16233a;border:1px solid var(--edge);color:#dff4ff;border-radius:6px;cursor:pointer;margin:4px}
.screen .big:hover{background:#24406b}
#endScreen h1{font-size:44px}
.hidden{display:none!important}
#tooltip{position:absolute;pointer-events:none;background:#0d1524;border:1px solid #3a5578;border-radius:4px;padding:5px 8px;font-size:11.5px;max-width:240px;z-index:60;display:none;line-height:1.4}
#tooltip .warn{color:#ff9a7a}
#tooltip .ok{color:#9fe8b0}
</style>
</head>
<body>
<canvas id="view"></canvas>
<div id="hud">
  <div id="topbar" class="hudbox">
    <span class="res">◆ <span id="resAmt">0</span></span>
    <span class="sup">SUP <span id="supAmt">0/0</span></span>
    <span id="armyCount" style="color:#9fe8b0">ARMY 0</span>
    <span class="spacer"></span>
    <button id="btnPause">Pause (P)</button>
    <button id="btnHelp">Help (F1)</button>
    <button id="btnMute">Sound: On (M)</button>
  </div>
  <div id="alertbar" class="hudbox"></div>
  <div id="minimapWrap" class="hudbox"><canvas id="minimap" width="216" height="216"></canvas></div>
  <div id="selpanel" class="hudbox">
    <div class="title" id="selTitle">—</div>
    <div id="unitrow"></div>
    <div id="cmdgrid"></div>
    <div id="queuebar"></div>
  </div>
  <div id="infobar" class="hudbox">Select workers and press <b>B</b> to build. Right-click to move / attack / gather. <b>F1</b> for help.</div>
  <div id="help" class="hudbox">
    <h2>FRONTIER WAR — Field Manual</h2>
    <b>Goal:</b> destroy every enemy Headquarters. Lose all of yours and you are defeated. Extra HQs can be built as expansions.<br>
    <h2>Camera & Controls</h2>
    <b>Edge scroll / WASD / Arrows</b> pan · <b>Q / E</b> rotate · <b>Wheel</b> zoom · click <b>minimap</b> to jump (right-click minimap also moves camera).<br>
    <b>Left-click / drag</b> select (Shift adds) · <b>double-click</b> selects all of that type on screen · <b>Ctrl+1..9</b> set control group, <b>1..9</b> recall.<br>
    <b>Right-click</b>: contextual move / gather / attack. <b>A</b> attack-move · <b>S</b> stop · <b>H</b> hold position · <b>B</b> build menu (workers) · <b>Esc</b> cancel.<br>
    <h2>Economy</h2>
    Drones harvest <b>Aurite</b> crystals and return them to a Headquarters. Deposits <b>deplete</b> — expand to new fields with another HQ nearby to shorten trips.<br>
    <b>Supply</b> is capacity, not a cost: build <b>Flux Pylons</b> (+10 each) to grow your population toward the hundreds. If supply is blocked (pylons lost), production pauses until capacity recovers.<br>
    <h2>Forces & Counters</h2>
    <b>Raptor</b> — fast melee brawler. <b>Lance</b> — ranged energy, also your anti-air answer. <b>Bastion</b> — heavy siege walker, slow but devastating. <b>Wyvern</b> — gunship, ignores terrain.<br>
    Kinetic shreds <b>light</b> armor; explosives crack <b>heavy</b> armor; energy burns structures. Mix arms — a pure force has hard counters.<br>
    <h2>Intel</h2>
    The map starts dark. Terrain you have seen stays remembered, but enemies vanish from view when out of sight — last-seen buildings show as ghost silhouettes. Scout with drones; watch the minimap.<br><br>
    <button class="btn" onclick="toggleHelp()">Close (F1)</button>
  </div>
  <div id="tooltip"></div>
</div>
<div id="titleScreen" class="screen">
  <h1>FRONTIER <span class="r">WAR</span></h1>
  <div class="sub">HELION CONCORD vs VOREK DOMINION · AURITE VALLEY SKIRMISH</div>
  <div class="box">
    You command a Concord expedition on a resource-rich frontier world. A Dominion force holds the far side of the valley.<br><br>
    ◆ Harvest <b>Aurite</b> with Drones · build <b>Flux Pylons</b> for supply · raise <b>Barracks, Factory, Starport</b> · expand with new <b>Headquarters</b> · research at the <b>Lab</b>.<br>
    ◆ The valley starts <b>unexplored</b> — scout before you strike. The enemy is doing the same.<br>
    ◆ <b>Victory:</b> destroy all enemy Headquarters. <b>Defeat:</b> lose all of yours.<br>
    ◆ Expect early raids to grow into large combined-arms battles. Counters matter: Kinetic → light armor, Explosive → heavy armor, Energy → structures. Lances and Turrets shoot air.<br><br>
    Controls: edge-scroll/WASD pan · Q/E rotate · wheel zoom · left-drag select · right-click order · A attack-move · B build · F1 help.
  </div>
  <button class="big" id="btnStart">LAUNCH EXPEDITION</button>
</div>
<div id="endScreen" class="screen hidden"><h1 id="endTitle"></h1><div class="sub" id="endSub"></div><button class="big" id="btnRestart">RESTART MATCH</button></div>
<script>
"use strict";
/* ============================== CORE MATH / CAMERA ============================== */
const TAU=Math.PI*2, MAP=120, FOGN=120, GN=60, CELL=MAP/GN;
let W=innerWidth,H=innerHeight;
const cam={tx:MAP/2,tz:MAP/2,yaw:Math.PI*0.25,pitch:0.98,dist:1.05, minD:0.45,maxD:2.6};
const clamp=(v,a,b)=>v<a?a:v>b?b:v, lerp=(a,b,t)=>a+(b-a)*t, 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 proj(x,y,z){ // world -> screen (orthographic with pitch)
  const s=H*0.026*cam.dist, dx=x-cam.tx, dz=z-cam.tz;
  const cy=Math.cos(cam.yaw), sy=Math.sin(cam.yaw);
  const vx=dx*cy-dz*sy, vz=dx*sy+dz*cy;
  return {x:W/2+vx*s, y:H*0.52+(vz*Math.cos(cam.pitch)-y*Math.sin(cam.pitch))*s, d:vz, s};
}
function unproj(sx,sy){ // screen -> ground plane
  const s=H*0.026*cam.dist, cy=Math.cos(cam.yaw), sy2=Math.sin(cam.yaw);
  const vx=(sx-W/2)/s, vz=(sy-H*0.52)/(Math.cos(cam.pitch)*s);
  return {x:cam.tx+vx*cy+vz*sy2, z:cam.tz-vx*sy2+vz*cy};
}
/* ============================== RENDERER ============================== */
const view=document.getElementById('view'), ctx=view.getContext('2d');
function resize(){W=innerWidth;H=innerHeight;view.width=W;view.height=H;}
addEventListener('resize',resize);resize();
const LIGHT={x:-0.45,y:0.8,z:0.4};
function shade(nx,ny,nz,base){ // base [r,g,b]
  const d=Math.max(0,nx*LIGHT.x+ny*LIGHT.y+nz*LIGHT.z);
  const f=0.45+0.62*d;
  return `rgb(${(base[0]*f)|0},${(base[1]*f)|0},${(base[2]*f)|0})`;
}
// draw an oriented box. cx,cz center, y bottom, w,d footprint, h height, col [r,g,b]
function boxPts(cx,cz,y,w,d,h){
  const hw=w/2,hd=d/2;
  return [[cx-hw,y,cz-hd],[cx+hw,y,cz-hd],[cx+hw,y,cz+hd],[cx-hw,y,cz+hd],
          [cx-hw,y+h,cz-hd],[cx+hw,y+h,cz-hd],[cx+hw,y+h,cz+hd],[cx-hw,y+h,cz+hd]].map(p=>proj(p[0],p[1],p[2]));
}
function fillPoly(pts,col){ctx.beginPath();ctx.moveTo(pts[0].x,pts[0].y);for(let i=1;i<pts.length;i++)ctx.lineTo(pts[i].x,pts[i].y);ctx.closePath();ctx.fillStyle=col;ctx.fill();}
function drawBox(dl,cx,cz,y,w,d,h,col,ang){
  // rotate footprint corners
  const c=Math.cos(ang||0),s=Math.sin(ang||0),hw=w/2,hd=d/2;
  const cor=[[-hw,-hd],[hw,-hd],[hw,hd],[-hw,hd]].map(p=>[cx+p[0]*c-p[1]*s,cz+p[0]*s+p[1]*c]);
  const bot=cor.map(p=>proj(p[0],y,p[1])), top=cor.map(p=>proj(p[0],y+h,p[1]));
  const faces=[];
  for(let i=0;i<4;i++){const j=(i+1)%4;
    const nx=(cor[j][0]-cor[i][0]),nz=(cor[j][1]-cor[i][1]);const len=Math.hypot(nx,nz);
    // outward normal (corners are CCW when viewed from above with x right z down -> flip)
    const ox=-nz/len,oz=nx/len;
    faces.push({pts:[bot[i],bot[j],top[j],top[i]],n:[ox,0,oz],depth:(bot[i].d+bot[j].d+top[i].d+top[j].d)/4});
  }
  faces.push({pts:[top[0],top[1],top[2],top[3]],n:[0,1,0],depth:(top[0].d+top[2].d)/2});
  for(const f of faces){
    // face visible if normal points toward viewer: view dir ~ (sinYaw?, ...) use screen-space winding test
    const a=f.pts;
    const cross=(a[1].x-a[0].x)*(a[2].y-a[0].y)-(a[1].y-a[0].y)*(a[2].x-a[0].x);
    if(cross>=0) continue; // backface (screen winding)
    dl.push({d:f.depth,f:()=>fillPoly(a,shade(f.n[0],f.n[1],f.n[2],col))});
  }
  dl.push({d:top[0].d,f:()=>{ctx.strokeStyle='rgba(0,0,0,.35)';ctx.beginPath();ctx.moveTo(top[0].x,top[0].y);for(let i=1;i<4;i++)ctx.lineTo(top[i].x,top[i].y);ctx.closePath();ctx.stroke();}});
}
function drawCyl(dl,cx,cz,y,r,h,col,n=10){
  const pts=[],top=[];
  for(let i=0;i<n;i++){const a=i/n*TAU;pts.push(proj(cx+Math.cos(a)*r,y,cz+Math.sin(a)*r));top.push(proj(cx+Math.cos(a)*r,y+h,cz+Math.sin(a)*r));}
  for(let i=0;i<n;i++){const j=(i+1)%n;
    const a=i/n*TAU;const nx=Math.cos(a),nz=Math.sin(a);
    const a2=(i+0.5)/n*TAU;
    const p=[pts[i],pts[j],top[j],top[i]];
    const cross=(p[1].x-p[0].x)*(p[2].y-p[0].y)-(p[1].y-p[0].y)*(p[2].x-p[0].x);
    if(cross>=0)continue;
    const depth=(p[0].d+p[1].d)/2;
    dl.push({d:depth,f:()=>fillPoly(p,shade(Math.cos(a2),0,Math.sin(a2),col))});
  }
  dl.push({d:top[0].d-0.01,f:()=>{ctx.beginPath();for(let i=0;i<n;i++){const p=top[i];i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y);}ctx.closePath();ctx.fillStyle=shade(0,1,0,col);ctx.fill();}});
}
function drawGroundQuad(dl,cx,cz,w,d,col,alpha){
  const p=[proj(cx-w/2,0,cz-d/2),proj(cx+w/2,0,cz-d/2),proj(cx+w/2,0,cz+d/2),proj(cx-w/2,0,cz+d/2)];
  dl.push({d:(p[0].d+p[2].d)/2+0.02,f:()=>{ctx.globalAlpha=alpha==null?1:alpha;fillPoly(p,col);ctx.globalAlpha=1;}});
}
/* ============================== TYPES ============================== */
const SIDES={P:0,E:1};
const SIDE_COL={0:[64,214,196],1:[214,92,58]};
const SIDE_COL_D={0:[52,150,140],1:[150,70,48]};
const UNIT={
 drone:  {name:'Drone',   cost:50, sup:1, time:6,  hp:45,  spd:5.6, r:0.55, armor:'light', dmg:4, rng:1.2, cd:1.0, dtype:'kinetic', g:1,a:0, kind:'melee', from:'hq'},
 raptor: {name:'Raptor',  cost:80, sup:2, time:10, hp:120, spd:6.2, r:0.6,  armor:'light', dmg:13,rng:1.4, cd:0.9, dtype:'kinetic', g:1,a:0, kind:'melee', from:'barracks'},
 lance:  {name:'Lance',   cost:100,sup:2, time:12, hp:70,  spd:5.0, r:0.6,  armor:'light', dmg:11,rng:8,   cd:1.4, dtype:'energy',  g:1,a:1, kind:'proj',  from:'barracks'},
 bastion:{name:'Bastion', cost:220,sup:4, time:20, hp:360, spd:3.1, r:0.95, armor:'heavy', dmg:30,rng:7,   cd:2.0, dtype:'explosive',g:1,a:0, kind:'proj', from:'factory'},
 wyvern: {name:'Wyvern',  cost:160,sup:3, time:15, hp:130, spd:7.6, r:0.7,  armor:'light', dmg:15,rng:6,   cd:1.2, dtype:'kinetic', g:1,a:1, kind:'proj',  from:'starport', fly:1},
};
const BUILD={
 hq:      {name:'Headquarters', cost:400,sup:10,time:45,hp:1400,w:6,d:6,h:3.4, trains:['drone'],drop:1},
 supply:  {name:'Flux Pylon',   cost:80, sup:10,time:14,hp:320, w:2.4,d:2.4,h:3.6},
 barracks:{name:'Barracks',     cost:150,sup:0, time:26,hp:700, w:4.6,d:4.6,h:2.8, trains:['raptor','lance']},
 factory: {name:'Factory',      cost:250,sup:0, time:34,hp:900, w:5.4,d:5.4,h:3.2, trains:['bastion'], req:'barracks'},
 starport:{name:'Starport',     cost:300,sup:0, time:38,hp:750, w:5,d:5,h:3.6, trains:['wyvern'], req:'factory'},
 lab:     {name:'Research Lab', cost:200,sup:0, time:30,hp:600, w:4.4,d:4.4,h:2.6, req:'barracks'},
 turret:  {name:'Aegis Turret', cost:120,sup:0, time:18,hp:340, w:2.2,d:2.2,h:3.8, dmg:18,rng:9.5,cd:1.3,dtype:'explosive',g:1,a:1},
};
const DMOD={kinetic:{light:1.25,heavy:0.7},explosive:{light:0.7,heavy:1.3},energy:{light:0.95,heavy:1.05}};
function dmgOf(dtype,armor){return (DMOD[dtype]||{}).hasOwnProperty(armor)?DMOD[dtype][armor]:1;}
const TECH=[
 {id:'wep',name:'Weapon Amplifiers',cost:150,time:30,desc:'+25% damage, all forces'},
 {id:'arm',name:'Ablative Plating',cost:150,time:30,desc:'+25% hit points, all forces'},
 {id:'eng',name:'Vector Thrusters',cost:100,time:24,desc:'+20% speed, all forces'},
];
/* ============================== WORLD ============================== */
let deposits=[], rocks=[], nextId=1;
let units=[], buildings=[], projectiles=[], effects=[];
let fog=[]; // 0 unexplored 1 explored 2 visible  (player)
let eFog=[]; // enemy fog
let blockGrid=[]; // static block from rocks
let dynBlock=[];  // buildings
const P={crystal:400, supUsed:0, supCap:0, supReserved:0, tech:{}, side:0};
const E={crystal:400, supUsed:0, supCap:0, supReserved:0, tech:{}, side:1};
const sideState=s=>s===0?P:E;
function resFor(s){return s===0?P:E;}
function mkFog(){const f=[];for(let i=0;i<FOGN*FOGN;i++)f.push(0);return f;}
function fogIdx(x,z){return clamp(Math.round(z),0,FOGN-1)*FOGN+clamp(Math.round(x),0,FOGN-1);}
function reveal(f,x,z,r,val){ // mark val in radius r around x,z
  const x0=Math.max(0,Math.floor(x-r)),x1=Math.min(FOGN-1,Math.ceil(x+r));
  const z0=Math.max(0,Math.floor(z-r)),z1=Math.min(FOGN-1,Math.ceil(z+r));
  const r2=r*r;
  for(let zz=z0;zz<=z1;zz++)for(let xx=x0;xx<=x1;xx++){
    const dx=xx-x,dz=zz-z; if(dx*dx+dz*dz<=r2) f[zz*FOGN+xx]=Math.max(f[zz*FOGN+xx],val);
  }
}
function fogAt(f,x,z){return f[fogIdx(x,z)];}
function genWorld(){
  deposits=[];rocks=[];units=[];buildings=[];projectiles=[];effects=[];nextId=1;
  fog=mkFog();eFog=mkFog();
  blockGrid=[];dynBlock=[];
  for(let i=0;i<GN*GN;i++){blockGrid.push(0);dynBlock.push(0);}
  // deposits: near each base + contested fields
  const depSpots=[[24,96],[34,86],[96,24],[86,34],[60,60],[38,42],[82,78],[24,40],[96,80],[60,18],[60,102],[18,72],[102,48]];
  for(const [x,z] of depSpots) deposits.push({id:nextId++,x,z,amt:2600+((nextId*7919)%1400),known:0,workers:0});
  // rock mesas: lanes / chokepoints
  const rockDefs=[
    {x:60,z:44,w:34,d:7},{x:60,z:76,w:34,d:7},
    {x:34,z:60,w:7,d:26},{x:86,z:60,w:7,d:26},
    {x:44,z:24,w:16,d:5},{x:76,z:96,w:16,d:5},
    {x:16,z:52,w:5,d:14},{x:104,z:68,w:5,d:14},
  ];
  for(const r of rockDefs){rocks.push({x:r.x,z:r.z,w:r.w,d:r.d,h:2.6+((r.x*7)%3)});
    const x0=Math.max(0,Math.floor((r.x-r.w/2)/CELL)),x1=Math.min(GN-1,Math.floor((r.x+r.w/2)/CELL));
    const z0=Math.max(0,Math.floor((r.z-r.d/2)/CELL)),z1=Math.min(GN-1,Math.floor((r.z+r.d/2)/CELL));
    for(let zz=z0;zz<=z1;zz++)for(let xx=x0;xx<=x1;xx++)blockGrid[zz*GN+xx]=1;
  }
}
function cellBlocked(x,z){
  const gx=Math.floor(x/CELL),gz=Math.floor(z/CELL);
  if(gx<0||gz<0||gx>=GN||gz>=GN)return true;
  return blockGrid[gz*GN+gx]===1||dynBlock[gz*GN+gx]===1;
}
function rebuildDyn(){
  for(let i=0;i<GN*GN;i++)dynBlock[i]=0;
  for(const b of buildings){
    const x0=Math.max(0,Math.floor((b.x-b.w/2-0.3)/CELL)),x1=Math.min(GN-1,Math.floor((b.x+b.w/2+0.3)/CELL));
    const z0=Math.max(0,Math.floor((b.z-b.d/2-0.3)/CELL)),z1=Math.min(GN-1,Math.floor((b.z+b.d/2+0.3)/CELL));
    for(let zz=z0;zz<=z1;zz++)for(let xx=x0;xx<=x1;xx++)dynBlock[zz*GN+xx]=1;
  }
}
/* ============================== PATHFINDING (A*) ============================== */
const AS_OPEN=[],AS_G=[],AS_F=[],AS_P=[],AS_ST=[];
for(let i=0;i<GN*GN;i++){AS_G.push(0);AS_F.push(0);AS_P.push(-1);AS_ST.push(0);}
let asStamp=0;
const N8=[[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
function findPath(sx,sz,tx,tz){
  let sgx=clamp(Math.floor(sx/CELL),0,GN-1),sgz=clamp(Math.floor(sz/CELL),0,GN-1);
  let tgx=clamp(Math.floor(tx/CELL),0,GN-1),tgz=clamp(Math.floor(tz/CELL),0,GN-1);
  if(cellBlocked(tx,tz)){ // nudge target to nearest open cell
    let best=null,bd=1e9;
    for(let r=1;r<6&&!best;r++)for(let dz=-r;dz<=r;dz++)for(let dx=-r;dx<=r;dx++){
      const gx=tgx+dx,gz=tgz+dz;if(gx<0||gz<0||gx>=GN||gz>=GN)continue;
      if(!blockGrid[gz*GN+gx]&&!dynBlock[gz*GN+gx]){const d=dx*dx+dz*dz;if(d<bd){bd=d;best=[gx,gz];}}
    }
    if(!best)return null; tgx=best[0];tgz=best[1];
  }
  const start=sgz*GN+sgx, goal=tgz*GN+tgx;
  if(start===goal)return [{x:tx,z:tz}];
  asStamp++;
  const open=[start];AS_G[start]=0;AS_F[start]=0;AS_P[start]=-1;AS_ST[start]=asStamp;
  let iter=0;
  while(open.length&&iter++<1600){
    // pick lowest F (linear scan; grid small)
    let bi=0,bf=AS_F[open[0]];
    for(let i=1;i<open.length;i++)if(AS_F[open[i]]<bf){bf=AS_F[open[i]];bi=i;}
    const cur=open.splice(bi,1)[0];
    if(cur===goal){ // reconstruct
      const path=[];let c=cur;
      while(c!==-1){const gx=c%GN,gz=(c-gx)/GN;path.push({x:(gx+0.5)*CELL,z:(gz+0.5)*CELL});c=AS_P[c];}
      path.reverse();path.push({x:tx,z:tz});
      // smooth
      const sm=[path[0]];let i0=0;
      for(let i=2;i<path.length;i++){
        if(!lineClear(sm[sm.length-1].x,sm[sm.length-1].z,path[i].x,path[i].z)){sm.push(path[i-1]);}
      }
      sm.push(path[path.length-1]);
      return sm;
    }
    const cx=cur%GN,cz=(cur-cx)/GN;
    for(const [dx,dz] of N8){
      const nx=cx+dx,nz=cz+dz;
      if(nx<0||nz<0||nx>=GN||nz>=GN)continue;
      const ni=nz*GN+nx;
      if(blockGrid[ni]||dynBlock[ni])continue;
      if(dx&&dz&&(blockGrid[cz*GN+nx]||dynBlock[cz*GN+nx]||blockGrid[nz*GN+cx]||dynBlock[nz*GN+cx]))continue;
      const ng=AS_G[cur]+(dx&&dz?1.41:1);
      if(AS_ST[ni]!==asStamp||ng<AS_G[ni]){
        AS_ST[ni]=asStamp;AS_G[ni]=ng;
        AS_F[ni]=ng+Math.hypot(nx-tgx,nz-tgz)*1.05;AS_P[ni]=cur;
        if(!open.includes(ni))open.push(ni);
      }
    }
  }
  return null;
}
function lineClear(ax,az,bx,bz){
  const d=dist(ax,az,bx,bz),steps=Math.ceil(d/CELL);
  for(let i=1;i<steps;i++){const t=i/steps;if(cellBlocked(lerp(ax,bx,t),lerp(az,bz,t)))return false;}
  return true;
}
/* ============================== ENTITIES ============================== */
function mkUnit(side,type,x,z){
  const t=UNIT[type],st=sideState(side);
  const u={id:nextId++,kind:'unit',side,type,x,z,y:0,ang:0,hp:t.hp,maxhp:t.hp,
    spd:t.spd,r:t.r,rng:t.rng,cd:0,order:null,state:'idle',path:null,pi:0,
    carry:0,gatherT:0,dep:null,target:null,hold:false,atkMove:false,
    dmgMul:1,spdMul:1,vis:t.fly?9:7,repT:0,fly:!!t.fly};
  applyTech(u);units.push(u);return u;
}
function applyTech(u){
  const st=sideState(u.side);
  u.dmgMul=st.tech.wep?1.25:1;u.spdMul=st.tech.eng?1.2:1;
  const nm=u.maxhp*(st.tech.arm?1.25:1);
  if(nm>u.maxhp){u.hp+=nm-u.maxhp;u.maxhp=nm;}
}
function mkBuilding(side,type,x,z,done){
  const t=BUILD[type];
  const b={id:nextId++,kind:'bld',side,type,x,z,w:t.w,d:t.d,h:t.h,hp:done?t.hp:t.hp*0.1,maxhp:t.hp,
    done:!!done,prog:done?1:0,queue:[],prodT:0,rally:null,cd:0,ghost:false,seen:0};
  buildings.push(b);rebuildDyn();return b;
}
function canPlace(side,type,x,z){
  const t=BUILD[type],hw=t.w/2+0.4,hd=t.d/2+0.4;
  if(x-hw<1||x+hw>MAP-1||z-hd<1||z+hd>MAP-1)return false;
  // overlap buildings
  for(const b of buildings){
    if(Math.abs(b.x-x)<(b.w+t.w)/2+0.5&&Math.abs(b.z-z)<(b.d+t.d)/2+0.5)return false;
  }
  // overlap deposits
  for(const dp of deposits)if(dist2(dp.x,dp.z,x,z)<Math.pow(t.w/2+3.2,2))return false;
  // rocks
  const x0=Math.floor((x-hw)/CELL),x1=Math.floor((x+hw)/CELL),z0=Math.floor((z-hd)/CELL),z1=Math.floor((z+hd)/CELL);
  for(let zz=z0;zz<=z1;zz++)for(let xx=x0;xx<=x1;xx++){
    if(xx<0||zz<0||xx>=GN||zz>=GN)return false;
    if(blockGrid[zz*GN+xx])return false;
    if(dynBlock[zz*GN+xx])return false;
  }
  return true;
}
function supplyCap(side){let c=0;for(const b of buildings)if(b.side===side&&b.done&&(b.type==='hq'||b.type==='supply'))c+=BUILD[b.type].sup;return c;}
function supplyUsed(side){let u=0;for(const un of units)if(un.side===side)u+=UNIT[un.type].sup;return u;}
function supReserved(side){let r=0;for(const b of buildings)if(b.side===side)for(const q of b.queue)r+=UNIT[q.type]?UNIT[q.type].sup:0;return r;}
function supFree(side){return supplyCap(side)-supplyUsed(side)-supReserved(side);}
function hqCount(side){let n=0;for(const b of buildings)if(b.side===side&&b.type==='hq')n++;return n;}
function nearDrop(side,x,z){let best=null,bd=1e9;
  for(const b of buildings)if(b.side===side&&b.done&&BUILD[b.type].drop){const d=dist2(b.x,b.z,x,z);if(d<bd){bd=d;best=b;}}
  return best;
}
function nearestDep(side,x,z,maxD){
  let best=null,bd=(maxD||1e9)*(maxD||1e9);
  for(const dp of deposits){
    if(dp.amt<=0)continue;
    if(side!==null&&fogAt(side===0?fog:eFog,dp.x,dp.z)===0)continue;
    const d=dist2(dp.x,dp.z,x,z);if(d<bd){bd=d;best=dp;}
  }
  return best;
}
/* ============================== SPATIAL HASH ============================== */
const HASHC=4;let hash=new Map();
function rebuildHash(){
  hash.clear();
  for(const u of units){
    const k=(Math.floor(u.x/HASHC)<<10)|Math.floor(u.z/HASHC);
    let a=hash.get(k);if(!a){a=[];hash.set(k,a);}a.push(u);
  }
}
function nearUnits(x,z,out){
  out.length=0;
  const gx=Math.floor(x/HASHC),gz=Math.floor(z/HASHC);
  for(let dz=-1;dz<=1;dz++)for(let dx=-1;dx<=1;dx++){
    const a=hash.get(((gx+dx)<<10)|(gz+dz));if(a)for(const u of a)out.push(u);
  }
  return out;
}
/* ============================== SIMULATION ============================== */
let gameTime=0, paused=false, over=false, winner=-1;
const tmpN=[];
function issueOrder(u,o){u.order=o;u.state='idle';u.path=null;u.pi=0;u.target=null;u.hold=false;}
function orderMove(u,x,z,atkMove){issueOrder(u,{k:atkMove?'amove':'move',x,z});}
function orderAttack(u,tgt){issueOrder(u,{k:'attack',tgt});}
function orderGather(u,dp){issueOrder(u,{k:'gather',dep:dp});u.dep=dp;}
function stopUnit(u){issueOrder(u,{k:'stop'});}
function holdUnit(u){issueOrder(u,{k:'hold'});u.hold=true;}
function setPath(u,x,z){
  const p=findPath(u.x,u.z,x,z);
  if(p){u.path=p;u.pi=1;}else{u.path=[{x,z}];u.pi=1;}
}
function moveStep(u,dt){
  if(!u.path||u.pi>=u.path.length)return true;
  const wp=u.path[u.pi],d=dist(u.x,u.z,wp.x,wp.z);
  const sp=u.spd*u.spdMul;
  if(d<0.35){u.pi++;return u.pi>=u.path.length;}
  u.ang=Math.atan2(wp.x-u.x,wp.z-u.z);
  const step=Math.min(sp*dt,d);
  u.x+=(wp.x-u.x)/d*step;u.z+=(wp.z-u.z)/d*step;
  return false;
}
function acquireTarget(u,range){
  // only enemies visible to u.side
  const f=u.side===0?fog:eFog;
  let best=null,bd=range*range;
  nearUnits(u.x,u.z,tmpN);
  for(const e of tmpN){
    if(e.side===u.side||e.hp<=0)continue;
    const t=UNIT[e.type];
    if(t.fly&&!UNIT[u.type].a)continue;
    const d=dist2(u.x,u.z,e.x,e.z);
    if(d<bd&&fogAt(f,e.x,e.z)===2){bd=d;best=e;}
  }
  if(best)return best;
  // buildings
  for(const b of buildings){
    if(b.side===u.side||b.hp<=0)continue;
    if(fogAt(f,b.x,b.z)!==2)continue;
    const d=dist2(u.x,u.z,b.x,b.z);
    if(d<bd){bd=d;best=b;}
  }
  return best;
}
function inRange(u,t){
  const r=(t.kind==='bld'?Math.max(t.w,t.d)/2+0.6:t.r)+u.rng;
  return dist2(u.x,u.z,t.x,t.z)<=r*r;
}
function fireAt(u,t,dt){
  const T=UNIT[u.type];
  u.cd-=dt;
  const rr=(t.kind==='bld'?Math.max(t.w,t.d)/2+0.6:t.r)+u.rng;
  if(u.cd>0)return 'wait';
  if(T.kind==='melee'){
    if(dist2(u.x,u.z,t.x,t.z)>(rr+0.4)*(rr+0.4))return 'far';
    u.cd=T.cd;dealDamage(t,T.dmg*u.dmgMul,u.side,T.dtype);
    fx(u.x,u.z,0.3,'#ffd98a');
    return 'hit';
  }
  u.cd=T.cd;u.ang=Math.atan2(t.x-u.x,t.z-u.z);
  projectiles.push({x:u.x,z:u.z,y:u.fly?2.2:1.0,tgt:t,side:u.side,spd:26,
    dmg:u.dmgMul*T.dmg,dtype:T.dtype,col:u.side===0?'#7ff5e8':'#ffab84'});
  return 'hit';
}
function dealDamage(t,amt,fromSide,dtype){
  if(!t||t.hp<=0)return;
  const armor=t.kind==='bld'?'heavy':UNIT[t.type].armor;
  amt*=dmgOf(dtype,armor);
  t.hp-=amt;
  if(t.kind==='bld'&&t.side===0&&fromSide===1)baseAlert(t);
  if(t.hp<=0){
    t.hp=0;
    fx(t.x,t.z,t.kind==='bld'?2.2:0.9,t.kind==='bld'?'#ff9a5a':'#ffcf7a');
    if(t.kind==='unit'){
      const i=units.indexOf(t);if(i>=0)units.splice(i,1);
      if(t.dep)t.dep.workers=Math.max(0,t.dep.workers-1);
      sel.delete(t);grpSel.forEach(s=>s.delete(t));
    }else{
      const i=buildings.indexOf(t);if(i>=0)buildings.splice(i,1);
      rebuildDyn();
      for(const q of t.queue)if(q.side!==undefined){} // refund handled below
      if(t.side===0){for(const q of t.queue)P.crystal+=q.cost;}
      else{for(const q of t.queue)E.crystal+=q.cost;}
      for(const u of units)if(u.order&&u.order.tgt===t){u.order={k:'stop'};u.state='idle';}
      if(t.side===0)alertMsg((BUILD[t.type].name)+' destroyed!');
      checkEnd();
    }
  }
}
function fx(x,z,r,col){effects.push({x,z,y:0.1,r0:0.2,r:r*2.2,t:0,dur:0.5,col});}
function baseAlert(b){alertMsg('Our '+BUILD[b.type].name+' is under attack!');sfx('alarm');}
let alertT=0;
function alertMsg(m){if(gameTime-alertT<4)return;alertT=gameTime;const a=document.getElementById('alertbar');a.textContent=m;a.style.display='block';setTimeout(()=>{a.style.display='none'},3500);}
function checkEnd(){
  if(over)return;
  if(hqCount(1)===0){over=true;winner=0;}
  else if(hqCount(0)===0){over=true;winner=1;}
  if(over)endGame(winner===0);
}
/* ---------- worker harvest ---------- */
function updWorker(u,dt){
  const o=u.order;
  if(!o||o.k==='stop'){u.state='idle';return;}
  if(o.k==='gather'){
    let dp=o.dep;
    if(!dp||dp.amt<=0){ // find another known deposit
      const nd=nearestDep(u.side,u.x,u.z,60);
      if(nd){o.dep=nd;dp=nd;u.path=null;}
      else{u.state='idle';return;}
    }
    if(u.carry>=8){ // return
      const dr=nearDrop(u.side,u.x,u.z);
      if(!dr){u.state='idle';return;}
      if(dist2(u.x,u.z,dr.x,dr.z)<Math.pow(dr.w/2+1.6,2)){
        const st=sideState(u.side);st.crystal+=u.carry;u.carry=0;
        if(u.side===0)sfx('drop');
        u.path=null;
      }else{
        if(!u.path||u.pi>=u.path.length)setPath(u,dr.x,dr.z);
        moveStep(u,dt);
      }
      return;
    }
    if(dp.amt<=0){o.dep=null;return;}
    const rr=2.6;
    if(dist2(u.x,u.z,dp.x,dp.z)<rr*rr){
      u.gatherT+=dt;u.ang=Math.atan2(dp.x-u.x,dp.z-u.z);
      if(u.gatherT>=1.6){
        u.gatherT=0;
        const take=Math.min(8-u.carry,dp.amt);u.carry+=take;dp.amt-=take;
      }
    }else{
      if(!u.path||u.pi>=u.path.length)setPath(u,dp.x+1.5,dp.z+1.5);
      moveStep(u,dt);
    }
    return;
  }
  if(o.k==='build'){
    const b=o.bld;
    if(!b||b.hp<=0||b.done){u.order={k:'stop'};u.state='idle';return;}
    const rr=Math.max(b.w,b.d)/2+1.4;
    if(dist2(u.x,u.z,b.x,b.z)<rr*rr){
      b.prog+=dt/BUILD[b.type].time;
      b.hp=Math.min(b.maxhp,b.maxhp*(0.1+0.9*b.prog));
      if(b.prog>=1){b.done=true;b.hp=b.maxhp;rebuildDyn();
        if(b.side===0){alertMsg(BUILD[b.type].name+' complete');sfx('done');}
        u.order={k:'stop'};u.state='idle';
        if(b.type==='hq'||b.type==='supply'){}
      }
    }else{
      if(!u.path||u.pi>=u.path.length)setPath(u,b.x,b.z+Math.max(b.w,b.d)/2+1);
      moveStep(u,dt);
    }
    return;
  }
  // combat-capable orders handled by combat sim below
  if(o.k==='move'||o.k==='amove'){
    if(!u.path||u.pi>=u.path.length){u.order={k:'stop'};u.state='idle';}
    else moveStep(u,dt);
  }
}
/* ---------- combat unit update ---------- */
const nbuf=[];
function updFighter(u,dt){
  const o=u.order,T=UNIT[u.type];
  // auto acquire when idle/hold/amove or attack target dead
  let tgt=null;
  if(o){
    if(o.k==='attack'){tgt=o.tgt;if(!tgt||tgt.hp<=0){u.order={k:'stop'};o=null;}}
    else if(o.k==='amove'){tgt=acquireTarget(u,T.rng+u.vis*0.5);}
    else if(o.k==='hold'){tgt=acquireTarget(u,T.rng+1);}
    else if(o.k==='move'){tgt=acquireTarget(u,T.rng+1.5);}
    else if(o.k==='gather'||o.k==='build'){updWorker(u,dt);return;}
    else if(o.k==='stop'){tgt=acquireTarget(u,T.rng+1);}
  } else tgt=acquireTarget(u,T.rng+1);
  if(tgt){
    if(inRange(u,tgt)){
      u.path=null;
      fireAt(u,tgt,dt);
    }else{
      if(o&&(o.k==='hold')){return;}
      if(!u.path||u.pi>=u.path.length||u.retarget!==tgt.id){
        setPath(u,tgt.x,tgt.z);u.retarget=tgt.id;
      }
      moveStep(u,dt);
    }
    return;
  }
  if(o&&o.k==='amove'){
    if(!u.path){setPath(u,o.x,o.z);}
    else if(u.pi>=u.path.length){u.order={k:'stop'};}
    else moveStep(u,dt);
    return;
  }
  if(o&&(o.k==='move'||o.k==='attack')){
    if(!u.path){setPath(u,o.x!==undefined?o.x:(o.tgt?o.tgt.x:u.x),o.z!==undefined?o.z:(o.tgt?o.tgt.z:u.z));}
    else if(u.pi>=u.path.length){if(o.k==='move')u.order={k:'stop'};}
    else moveStep(u,dt);
    return;
  }
  u.state='idle';
}
/* ---------- building update ---------- */
function updBuilding(b,dt){
  const st=sideState(b.side);
  if(!b.done)return;
  if(b.type==='turret'&&b.cd>0)b.cd-=dt;
  if(b.type==='turret'){
    // auto attack
    const f=b.side===0?fog:eFog;
    let tgt=null,bd=BUILD.turret.rng*BUILD.turret.rng;
    nearUnits(b.x,b.z,nbuf);
    for(const e of nbuf){
      if(e.side===b.side||e.hp<=0)continue;
      const d=dist2(b.x,b.z,e.x,e.z);
      if(d<bd&&fogAt(f,e.x,e.z)===2){bd=d;tgt=e;}
    }
    if(tgt&&b.cd<=0){
      b.cd=BUILD.turret.cd;
      projectiles.push({x:b.x,z:b.z,y:2.6,tgt:tgt,side:b.side,spd:30,dmg:BUILD.turret.dmg*(st.tech.wep?1.25:1),dtype:'explosive',col:b.side===0?'#7ff5e8':'#ffab84'});
    }
  }
  // production
  if(b.queue.length){
    const item=b.queue[0];
    const isTech=item.type==='tech';
    const T=isTech?TECH.find(t=>t.id===item.tech):UNIT[item.type];
    const tTime=isTech?T.time:T.time;
    b.blocked=supFree(b.side)<0&&!isTech;
    if(b.blocked){if(b.side===0&&Math.random()<dt*0.5)alertMsg('Supply blocked — build Flux Pylons (production paused)');return;}
    b.prodT+=dt;
    if(b.prodT>=tTime){
      b.prodT=0;b.queue.shift();
      if(isTech){
        sideState(b.side).tech[item.tech]=true;
        for(const u of units)if(u.side===b.side)applyTech(u);
        if(b.side===0)alertMsg(TECH.find(t=>t.id===item.tech).name+' researched');sfx('done');
      }else{
      const ang=Math.random()*TAU,rr=Math.max(b.w,b.d)/2+1.6;
      const u=mkUnit(b.side,item.type,b.x+Math.cos(ang)*rr,b.z+Math.sin(ang)*rr);
      if(b.rally){
        if(UNIT[item.type].from==='hq'&&b.rally.dep)orderGather(u,b.rally.dep);
        else if(b.rally.am)orderMove(u,b.rally.x,b.rally.z,true);
        else orderMove(u,b.rally.x,b.rally.z);
      }
      if(b.side===0)sfx('done');
      }
    }
  }
}
/* ---------- projectiles ---------- */
function updProjectiles(dt){
  for(let i=projectiles.length-1;i>=0;i--){
    const p=projectiles[i],t=p.tgt;
    if(!t||t.hp<=0){projectiles.splice(i,1);continue;}
    const ty=t.kind==='bld'?1.2:(t.fly?2.2:0.8);
    const dx=t.x-p.x,dz=t.z-p.z,dy=ty-p.y;
    const d=Math.hypot(dx,dz,dy);
    const step=p.spd*dt;
    if(d<=step||d<0.3){
      dealDamage(t,p.dmg,p.side,p.dtype);
      fx(t.x,t.z,0.35,p.col);
      projectiles.splice(i,1);
    }else{
      p.x+=dx/d*step;p.z+=dz/d*step;p.y+=dy/d*step;
    }
  }
}
/* ---------- fog update ---------- */
let fogTimer=0;
function updFog(dt){
  fogTimer-=dt;if(fogTimer>0)return;fogTimer=0.2;
  for(let i=0;i<FOGN*FOGN;i++){if(fog[i]===2)fog[i]=1;if(eFog[i]===2)eFog[i]=1;}
  for(const u of units){reveal(u.side===0?fog:eFog,u.x,u.z,u.vis,2);}
  for(const b of buildings){if(b.done||b.side===0)reveal(b.side===0?fog:eFog,b.x,b.z,Math.max(b.w,b.d)/2+6,2);else reveal(b.side===0?fog:eFog,b.x,b.z,Math.max(b.w,b.d)/2+6,2);}
  // remember deposits seen
  for(const dp of deposits){
    if(fog[fogIdx(dp.x,dp.z)]>0)dp.known=1;
    if(eFog[fogIdx(dp.x,dp.z)]>0)dp.eKnown=1;
  }
  // building last-seen memory for player
  for(const b of buildings){
    if(b.side===1&&fogAt(fog,b.x,b.z)>0)b.seen=1;
    if(b.side===0&&fogAt(eFog,b.x,b.z)>0)eSeen.set(b.id,{x:b.x,z:b.z,type:b.type});
  }
  drawFogCanvas();
}
const eSeen=new Map(); // enemy knowledge of player buildings (id -> last pos)
/* ---------- main sim step ---------- */
function simStep(dt){
  gameTime+=dt;
  rebuildHash();
  P.supUsed=supplyUsed(0);P.supCap=supplyCap(0);
  E.supUsed=supplyUsed(1);E.supCap=supplyCap(1);
  for(const u of units){
    u.cd=Math.max(0,u.cd-dt);
    if(u.type==='drone')updWorker(u,dt);else updFighter(u,dt);
  }
  for(const b of buildings)updBuilding(b,dt);
  updProjectiles(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);}
  updFog(dt);
  if(!over)aiTick(dt);
}
/* ============================== AI ============================== */
const ai={t:0,mode:'boom',wave:5,attackT:35,scoutT:8,defRally:null,expT:60};
function aiTick(dt){
  ai.t-=dt;ai.scoutT-=dt;ai.attackT-=dt;
  if(ai.t>0)return;ai.t=1.6;
  const st=E;
  const myB=buildings.filter(b=>b.side===1);
  const myU=units.filter(u=>u.side===1);
  const workers=myU.filter(u=>u.type==='drone');
  const army=myU.filter(u=>u.type!=='drone');
  const hqs=myB.filter(b=>b.type==='hq'&&b.done);
  if(!hqs.length)return;
  const home=hqs[0];
  // assign idle workers to nearest known deposit
  for(const w of workers){
    if(w.order&&(w.order.k==='gather'||w.order.k==='build'||w.order.k==='move'))continue;
    const dp=nearestDep(1,w.x,w.z,80);
    if(dp)orderGather(w,dp);
  }
  const buildingCount=t=>myB.filter(b=>b.type===t).length;
  const constructing=t=>myB.some(b=>b.type===t&&!b.done);
  // ensure every unfinished AI building has a builder
  for(const b of myB){
    if(b.done)continue;
    if(units.some(u=>u.side===1&&u.type==='drone'&&u.order&&u.order.bld===b))continue;
    let best=null,bd=1e9;
    for(const w of workers){
      if(w.order&&w.order.k==='build')continue;
      const d=dist2(w.x,w.z,b.x,b.z);if(d<bd){bd=d;best=w;}
    }
    if(best)issueOrder(best,{k:'build',bld:b});
  }
  // build worker
  if(workers.length<16&&hqs.length){
    const hq=hqs.find(h=>h.queue.length<2);
    if(hq&&hq.queue.length<2&&st.crystal>=UNIT.drone.cost&&supFree(1)>=1)tryTrain(hq,'drone');
  }
  // supply
  if(st.supCap-st.supUsed-supReserved(1)<5&&st.supCap<220&&!constructing('supply')&&st.crystal>=BUILD.supply.cost){
    aiPlace('supply',home.x,home.z);
  }
  // production chain
  if(!buildingCount('barracks')&&!constructing('barracks')&&st.crystal>=BUILD.barracks.cost)aiPlace('barracks',home.x,home.z);
  if(buildingCount('barracks')>=1&&buildingCount('barracks')<2&&!constructing('barracks')&&st.crystal>=BUILD.barracks.cost+150&&gameTime>120)aiPlace('barracks',home.x,home.z);
  if(buildingCount('barracks')&&!constructing('factory')&&buildingCount('factory')<1&&st.crystal>=BUILD.factory.cost&&gameTime>150)aiPlace('factory',home.x,home.z);
  if(buildingCount('factory')&&!constructing('starport')&&buildingCount('starport')<1&&st.crystal>=BUILD.starport.cost&&gameTime>260)aiPlace('starport',home.x,home.z);
  if(buildingCount('barracks')&&!buildingCount('lab')&&!constructing('lab')&&st.crystal>=BUILD.lab.cost+100&&gameTime>200)aiPlace('lab',home.x,home.z);
  if(buildingCount('lab')&&st.crystal>=260&&!st.tech.wep){const lab=myB.find(b=>b.type==='lab'&&b.done);if(lab&&lab.queue.length===0)startResearch(lab,'wep');}
  else if(buildingCount('lab')&&st.crystal>=260&&!st.tech.arm){const lab=myB.find(b=>b.type==='lab'&&b.done);if(lab&&lab.queue.length===0)startResearch(lab,'arm');}
  // turret defense
  if(buildingCount('turret')<Math.min(3,1+Math.floor(gameTime/180))&&!constructing('turret')&&st.crystal>=BUILD.turret.cost)aiPlace('turret',home.x,home.z);
  // expansion
  if(gameTime>ai.expT){
    const local=deposits.filter(d=>d.amt>300&&d.eKnown&&dist2(d.x,d.z,home.x,home.z)<45*45);
    const totalLocal=local.reduce((s,d)=>s+d.amt,0);
    if((totalLocal<900||hqs.length<2)&&st.crystal>=BUILD.hq.cost&&!constructing('hq')){
      const target=deposits.filter(d=>d.amt>800).sort((a,b)=>dist2(a.x,a.z,home.x,home.z)-dist2(b.x,b.z,home.x,home.z))[0];
      if(target&&fogAt(eFog,target.x,target.z)>0)aiPlace('hq',target.x,target.z);
      ai.expT=gameTime+70;
    } else ai.expT=gameTime+30;
  }
  // train army
  const comp={barracks:['raptor','lance'],factory:['bastion'],starport:['wyvern']};
  for(const b of myB){
    if(!b.done||b.type==='hq'||b.type==='lab'||!BUILD[b.type].trains)continue;
    if(b.queue.length>=2)continue;
    const opts=BUILD[b.type].trains.filter(t=>st.crystal>=UNIT[t].cost&&supFree(1)>=UNIT[t].sup);
    if(!opts.length)continue;
    let pick;
    if(b.type==='barracks')pick=army.filter(a=>a.type==='raptor').length<=army.filter(a=>a.type==='lance').length?'raptor':'lance';
    else pick=opts[0];
    if(opts.includes(pick))tryTrain(b,pick);
  }
  // scouting: send a worker toward the nearest unexplored area
  if(ai.scoutT<=0){
    ai.scoutT=18;
    const scout=workers.find(w=>w.order&&w.order.k==='gather'&&w.carry===0);
    if(scout){
      // pick unexplored point farthest from home
      let best=null,bd=-1;
      for(let tries=0;tries<24;tries++){
        const x=14+Math.random()*(MAP-28),z=14+Math.random()*(MAP-28);
        if(fogAt(eFog,x,z)>0)continue;
        const d=dist2(x,z,home.x,home.z);
        if(d>bd){bd=d;best={x,z};}
      }
      if(!best)best={x:clamp(120-home.x,10,110),z:clamp(120-home.z,10,110)};
      issueOrder(scout,{k:'move',x:best.x,z:best.z});setPath(scout,best.x,best.z);
    }
  }
  // defense: player units visible near AI buildings
  let threat=null;
  for(const b of myB){
    nearUnits(b.x,b.z,nbuf);
    for(const e of nbuf)if(e.side===0&&dist2(e.x,e.z,b.x,b.z)<25*25&&fogAt(eFog,e.x,e.z)===2)threat=e;
  }
  // attack logic
  if(threat){
    for(const u of army)if(u.order&&(u.order.k==='stop'||u.order.k==='hold'||!u.order.k))orderMove(u,threat.x,threat.z,true);
  } else if(ai.attackT<=0&&army.length>=ai.wave){
    ai.wave+=4;ai.attackT=45;
    // target: nearest known player building to home
    let tgt=null,bd=1e18;
    for(const b of buildings)if(b.side===0&&fogAt(eFog,b.x,b.z)>0){const d=dist2(b.x,b.z,home.x,home.z);if(d<bd){bd=d;tgt=b;}}
    const goal=tgt?{x:tgt.x,z:tgt.z}:{x:MAP/2+(Math.random()*40-20),z:MAP/2+(Math.random()*40-20)};
    for(const u of army)orderMove(u,goal.x+(Math.random()*8-4),goal.z+(Math.random()*8-4),true);
    ai.rally=goal;
  } else if(ai.rally&&army.length<ai.wave){
    // regroup near own base between waves
    for(const u of army)if(dist2(u.x,u.z,home.x,home.z)>40*40&&(!u.order||u.order.k==='stop'))orderMove(u,home.x+Math.random()*10-5,home.z+Math.random()*10-5,true);
  }
}
function aiPlace(type,hx,hz){
  const t=BUILD[type];
  if(E.crystal<t.cost)return false;
  if(t.req&&!buildings.some(b=>b.side===1&&b.type===t.req&&b.done))return false;
  for(let att=0;att<40;att++){
    const a=Math.random()*TAU,r=6+Math.random()*14;
    const x=clamp(hx+Math.cos(a)*r,4,MAP-4),z=clamp(hz+Math.sin(a)*r,4,MAP-4);
    if(canPlace(1,type,x,z)){
      E.crystal-=t.cost;
      const b=mkBuilding(1,type,x,z,false);
      // nearest worker builds
      let best=null,bd=1e9;
      for(const u of units)if(u.side===1&&u.type==='drone'){const d=dist2(u.x,u.z,x,z);if(d<bd){bd=d;best=u;}}
      if(best)issueOrder(best,{k:'build',bld:b});
      return true;
    }
  }
  return false;
}
function tryTrain(b,type){
  const st=sideState(b.side),T=UNIT[type];
  if(st.crystal<T.cost)return false;
  if(supFree(b.side)<T.sup)return false;
  st.crystal-=T.cost;st.supReserved+=0; // computed dynamically
  b.queue.push({type,cost:T.cost});
  return true;
}
function startResearch(lab,techId){
  const st=sideState(lab.side),t=TECH.find(x=>x.id===techId);
  if(st.crystal<t.cost||st.tech[techId]||lab.queue.length)return false;
  st.crystal-=t.cost;lab.queue.push({type:'tech',tech:techId,cost:t.cost});
  return true;
}
/* ============================== AUDIO ============================== */
let AC=null,muted=false;
function sfx(kind){
  if(muted)return;
  try{
    if(!AC)AC=new (window.AudioContext||window.webkitAudioContext)();
    const t=AC.currentTime,o=AC.createOscillator(),g=AC.createGain();
    o.connect(g);g.connect(AC.destination);
    let f=440,d=0.08,v=0.05,wave='square';
    if(kind==='sel'){f=620;d=0.05;v=0.03;}
    if(kind==='order'){f=520;d=0.06;v=0.035;}
    if(kind==='build'){f=300;d=0.1;v=0.04;wave='sawtooth';}
    if(kind==='done'){f=880;d=0.14;v=0.05;wave='triangle';}
    if(kind==='drop'){f=740;d=0.05;v=0.025;wave='triangle';}
    if(kind==='alarm'){f=220;d=0.25;v=0.06;wave='sawtooth';}
    if(kind==='err'){f=160;d=0.12;v=0.05;wave='sawtooth';}
    o.type=wave;o.frequency.setValueAtTime(f,t);o.frequency.exponentialRampToValueAtTime(Math.max(60,f*0.6),t+d);
    g.gain.setValueAtTime(v,t);g.gain.exponentialRampToValueAtTime(0.001,t+d);
    o.start(t);o.stop(t+d+0.02);
  }catch(e){}
}
/* ============================== GAME STATE / SETUP ============================== */
let mode='title'; // title | play | end
const sel=new Set(), grpSel=new Map();
let buildMode=null; // building type being placed
let dragSel=null;
let mouse={x:0,y:0,inside:false};
function startGame(){
  genWorld();
  P.crystal=400;E.crystal=400;P.tech={};E.tech={};
  sel.clear();grpSel.clear();buildMode=null;gameTime=0;over=false;winner=-1;paused=false;
  eSeen.clear();
  ai.t=1;ai.wave=5;ai.attackT=40;ai.scoutT=6;ai.expT=90;ai.rally=null;
  const phq=mkBuilding(0,'hq',22,98,true);
  mkBuilding(1,'hq',98,22,true);
  for(let i=0;i<5;i++){
    mkUnit(0,'drone',22+Math.cos(i*1.3)*5,98+Math.sin(i*1.3)*5);
    mkUnit(1,'drone',98+Math.cos(i*1.3)*5,22+Math.sin(i*1.3)*5);
  }
  reveal(fog,22,98,14,2);
  for(const u of units){const dp=nearestDep(u.side,u.x,u.z,40);if(dp)orderGather(u,dp);}
  reveal(fog,22,98,14,2);
  cam.tx=22;cam.tz=94;cam.yaw=Math.PI*0.25;cam.dist=1.0;
  mode='play';
  document.getElementById('titleScreen').classList.add('hidden');
  document.getElementById('endScreen').classList.add('hidden');
  updateUI(true);
  drawFogCanvas();
}
function endGame(win){
  mode='end';
  document.getElementById('endTitle').textContent=win?'VICTORY':'DEFEAT';
  document.getElementById('endTitle').style.color=win?'var(--teal)':'var(--rust)';
  document.getElementById('endSub').textContent=win?'The Dominion has been driven from the valley. Aurite flows to the Concord.':'The Concord expedition has been annihilated. The Dominion claims the frontier.';
  document.getElementById('endScreen').classList.remove('hidden');
  sfx(win?'done':'alarm');
}
/* ============================== INPUT ============================== */
const mm=document.getElementById('minimap'),mctx=mm.getContext('2d');
const MMS=216/MAP;
function mmToWorld(e){
  const r=mm.getBoundingClientRect();
  return {x:(e.clientX-r.left)/MMS,z:(e.clientY-r.top)/MMS};
}
mm.addEventListener('mousedown',e=>{
  e.preventDefault();e.stopPropagation();
  const w=mmToWorld(e);cam.tx=w.x;cam.tz=w.z;
  if(e.button===2&&sel.size&&unitsSelected()){issueWorldOrder(w.x,w.z,null);}
});
view.addEventListener('contextmenu',e=>e.preventDefault());
mm.addEventListener('contextmenu',e=>e.preventDefault());
view.addEventListener('mousedown',e=>{
  if(mode!=='play')return;
  const w=unproj(e.clientX,e.clientY);
  if(e.button===0){
    if(buildMode){tryPlaceBuild(w.x,w.z);return;}
    dragSel={x0:e.clientX,y0:e.clientY,x1:e.clientX,y1:e.clientY,add:e.shiftKey};
  }else if(e.button===2){
    if(buildMode){buildMode=null;return;}
    if(bldSelected()&&sel.size===1){ // rally
      const b=[...sel][0];
      const dp=deposits.find(d=>d.amt>0&&dist2(d.x,d.z,w.x,w.z)<9);
      b.rally=dp?{x:dp.x,z:dp.z,dep:dp}:{x:w.x,z:w.z};
      sfx('order');return;
    }
    issueWorldOrder(w.x,w.z,{sx:e.clientX,sy:e.clientY});
  }
});
function unitsSelected(){for(const s of sel)if(s.kind==='unit')return true;return false;}
function bldSelected(){for(const s of sel)if(s.kind==='bld')return true;return false;}
addEventListener('mousemove',e=>{
  mouse.x=e.clientX;mouse.y=e.clientY;
  mouse.inside=e.clientX>=0&&e.clientY>=0&&e.clientX<W&&e.clientY<H;
  if(dragSel){dragSel.x1=e.clientX;dragSel.y1=e.clientY;}
});
addEventListener('mouseup',e=>{
  if(e.button===0&&dragSel){
    const d=dragSel;dragSel=null;
    if(mode!=='play')return;
    const w=Math.abs(d.x1-d.x0),h=Math.abs(d.y1-d.y0);
    if(!d.add)sel.clear();
    if(w<6&&h<6){ // click select
      const pw=unproj(e.clientX,e.clientY);
      let best=null,bd=1e9;
      const consider=ent=>{
        if(ent.side!==0)return;
        if(ent.kind==='unit'&&ent.side===1)return;
        const p=proj(ent.x,ent.kind==='bld'?0:0.8,ent.z);
        const dd=Math.abs(p.x-e.clientX)+Math.abs(p.y-e.clientY)*1.4;
        if(dd<bd){bd=dd;best=ent;}
      };
      for(const u of units)if(u.side===0)consider(u);
      for(const b of buildings)if(b.side===0)consider(b);
      if(best&&bd<40){
        if(best.kind==='bld'){sel.clear();sel.add(best);}
        else sel.add(best);
        sfx('sel');
      } else sel.clear();
    }else{ // box select
      let n=0;
      for(const u of units){
        if(u.side!==0)continue;
        const p=proj(u.x,0.8,u.z);
        if(p.x>=Math.min(d.x0,d.x1)&&p.x<=Math.max(d.x0,d.x1)&&p.y>=Math.min(d.y0,d.y1)&&p.y<=Math.max(d.y0,d.y1)){sel.add(u);n++;}
      }
      if(n)sfx('sel');
    }
    updateUI(true);
  }
});
addEventListener('wheel',e=>{
  if(mode!=='play')return;
  cam.dist=clamp(cam.dist*(e.deltaY>0?1.12:0.89),cam.minD,cam.maxD);
},{passive:true});
addEventListener('keydown',e=>{
  if(mode==='title'&&e.key==='Enter'){startGame();return;}
  if(e.key==='F1'){e.preventDefault();toggleHelp();return;}
  if(mode!=='play')return;
  const k=e.key.toLowerCase();
  if(k==='p'){togglePause();return;}
  if(k==='m'){toggleMute();return;}
  if(k==='escape'){buildMode=null;sel.clear();updateUI(true);return;}
  if(k==='h'){cmdSel('hold');return;}
  if(k==='s'){cmdSel('stop');return;}
  if(k==='a'){cmdSel('amove');return;}
  if(k==='b'){if(unitsSelected()&&[...sel].some(u=>u.type==='drone'))showBuildMenu();return;}
  if(/^[1-9]$/.test(k)){
    if(e.ctrlKey){grpSel.set(+k,[...sel].filter(s=>s.kind==='unit'));e.preventDefault();alertMsg('Control group '+k+' set');}
    else{const g=grpSel.get(+k);if(g){sel.clear();for(const u of g)if(u.hp>0)sel.add(u);updateUI(true);}}
    return;
  }
  camKeys[k]=true;
});
addEventListener('keyup',e=>{camKeys[e.key.toLowerCase()]=false;});
const camKeys={};
function togglePause(){paused=!paused;document.getElementById('btnPause').textContent=paused?'Resume (P)':'Pause (P)';}
function toggleMute(){muted=!muted;document.getElementById('btnMute').textContent='Sound: '+(muted?'Off':'On')+' (M)';}
function toggleHelp(){const h=document.getElementById('help');h.style.display=h.style.display==='block'?'none':'block';}
document.getElementById('btnPause').onclick=togglePause;
document.getElementById('btnHelp').onclick=toggleHelp;
document.getElementById('btnMute').onclick=toggleMute;
document.getElementById('btnStart').onclick=startGame;
document.getElementById('btnRestart').onclick=startGame;
function cmdSel(kind,wx,wz){
  const us=[...sel].filter(s=>s.kind==='unit');
  if(!us.length)return;
  if(kind==='amove'){
    if(wx===undefined){alertMsg('Attack-move: press A then left-click a destination');pendingCmd='amove';return;}
    for(const u of us)orderMove(u,wx,wz,true);
  }
  if(kind==='stop')for(const u of us)stopUnit(u);
  if(kind==='hold')for(const u of us)holdUnit(u);
  if(kind==='move'&&wx!==undefined)for(const u of us)orderMove(u,wx,wz,false);
  sfx('order');
}
let pendingCmd=null;
function issueWorldOrder(wx,wz,scr){
  const us=[...sel].filter(s=>s.kind==='unit');
  if(!us.length)return;
  if(pendingCmd==='amove'){pendingCmd=null;cmdSel('amove',wx,wz);return;}
  // contextual: enemy visible under click?
  let tgt=null,bd=1e9;
  for(const u of units){if(u.side===1&&fogAt(fog,u.x,u.z)===2){const d=dist2(u.x,u.z,wx,wz);if(d<9&&d<bd){bd=d;tgt=u;}}}
  if(!tgt)for(const b of buildings){if(b.side===1&&fogAt(fog,b.x,b.z)===2){const d=dist2(b.x,b.z,wx,wz);if(d<Math.pow(b.w/2+2,2)&&d<bd){bd=d;tgt=b;}}}
  const dp=deposits.find(d=>d.amt>0&&dist2(d.x,d.z,wx,wz)<9);
  if(tgt){for(const u of us)orderAttack(u,tgt);alertMsg('Attacking '+((tgt.kind==='bld')?BUILD[tgt.type].name:UNIT[tgt.type].name));}
  else if(dp&&us.some(u=>u.type==='drone')){
    for(const u of us){if(u.type==='drone')orderGather(u,dp);else orderMove(u,wx,wz,false);}
  }else{
    // spread destinations
    const n=us.length,c=Math.ceil(Math.sqrt(n));
    us.forEach((u,i)=>{
      const ox=(i%c-(c-1)/2)*1.6,oz=(Math.floor(i/c)-(c-1)/2)*1.6;
      orderMove(u,clamp(wx+ox,1,MAP-1),clamp(wz+oz,1,MAP-1),false);
    });
  }
  sfx('order');
}
function tryPlaceBuild(x,z){
  const t=BUILD[buildMode];
  const workers=[...sel].filter(s=>s.kind==='unit'&&s.type==='drone');
  if(!canPlace(0,buildMode,x,z)){alertMsg('Cannot build there (blocked or overlapping)');sfx('err');return;}
  if(P.crystal<t.cost){alertMsg('Not enough Aurite');sfx('err');return;}
  if(t.req&&!buildings.some(b=>b.side===0&&b.type===t.req&&b.done)){alertMsg('Requires '+BUILD[t.req].name);sfx('err');return;}
  P.crystal-=t.cost;
  const b=mkBuilding(0,buildMode,x,z,false);
  let best=null,bd=1e9;
  for(const w of workers){const d=dist2(w.x,w.z,x,z);if(d<bd){bd=d;best=w;}}
  if(best)issueOrder(best,{k:'build',bld:b});
  sfx('build');
  if(!buildModeShift)buildMode=null;
}
let buildModeShift=false;
function showBuildMenu(){uiMode='build';updateUI(true);}
let uiMode='cmd';
/* ============================== UI ============================== */
const $=id=>document.getElementById(id);
function fmt(n){return Math.floor(n);}
function updateUI(force){
  if(mode!=='play')return;
  $('resAmt').textContent=fmt(P.crystal);
  const free=supFree(0);
  const supEl=$('supAmt');
  supEl.textContent=P.supUsed+supReserved(0)+'/'+P.supCap;
  supEl.parentElement.classList.toggle('blocked',free<0);
  $('armyCount').textContent='ARMY '+[...units].filter(u=>u.side===0&&u.type!=='drone').length;
  if(!force&&Math.random()>0.15)return;
  // selection panel
  const s=[...sel].filter(e=>e.hp>0);
  const title=$('selTitle'),row=$('unitrow'),grid=$('cmdgrid'),qb=$('queuebar');
  row.innerHTML='';grid.innerHTML='';qb.innerHTML='';
  if(!s.length){title.textContent='—';return;}
  if(s.length===1&&s[0].kind==='bld'){
    const b=s[0],T=BUILD[b.type];
    title.textContent=T.name+(b.done?'':' — under construction '+Math.floor(b.prog*100)+'%');
    const hp=document.createElement('div');
    hp.innerHTML=`<span style="color:#9fe8b0">HP ${Math.ceil(b.hp)}/${b.maxhp}</span>`+(b.rally?` <span style="color:#8fa8c8">· rally set</span>`:'');
    row.appendChild(hp);
    if(b.done&&T.trains){
      for(const ut of T.trains){
        const U=UNIT[ut];
        const btn=document.createElement('button');btn.className='cmdbtn';
        const canAfford=P.crystal>=U.cost,canSup=supFree(0)>=U.sup,canReq=!U.req||true;
        const ok=canAfford&&canSup&&b.queue.length<5;
        if(!ok)btn.classList.add('off');
        btn.innerHTML=`${U.name}<span class="cost">◆${U.cost} · ${U.sup} sup</span><span class="hk">${Math.floor(U.time)}s</span>`;
        btn.onclick=()=>{if(tryTrain(b,ut)){sfx('build');updateUI(true);}else{sfx('err');alertMsg(!canAfford?'Not enough Aurite':!canSup?'Not enough supply — build Flux Pylons':'Queue full');}};
        btn.title=`${U.name} — ${U.sup} supply. ${canSup?'':'Not enough supply. '}${canAfford?'':'Not enough Aurite.'}`;
        grid.appendChild(btn);
      }
      const rb=document.createElement('button');rb.className='cmdbtn';rb.innerHTML='Set Rally<span class="hk">right-click ground</span>';
      grid.appendChild(rb);
    }
    if(b.type==='lab'&&b.done){
      for(const t of TECH){
        const btn=document.createElement('button');btn.className='cmdbtn';
        const has=P.tech[t.id];
        if(has)btn.classList.add('off');
        btn.innerHTML=`${t.name}<span class="cost">◆${t.cost}</span><span class="hk">${has?'RESEARCHED':Math.floor(t.time)+'s'}</span>`;
        btn.title=t.desc;
        btn.onclick=()=>{if(has)return;if(startResearch(b,t.id)){sfx('build');updateUI(true);}else{sfx('err');alertMsg('Cannot research (cost or busy)');}};
        grid.appendChild(btn);
      }
    }
    // queue
    if(b.queue.length){
      b.queue.forEach((q,i)=>{
        const d=document.createElement('div');d.className='qitem';
        const nm=q.type==='tech'?TECH.find(t=>t.id===q.tech).name:UNIT[q.type].name;
        d.innerHTML=`<span style="z-index:1">${nm}</span>${i===0?`<div class="prog" style="height:${q.type==='tech'?(b.prodT/TECH.find(t=>t.id===q.tech).time*100):(b.prodT/UNIT[q.type].time*100)}%"></div>`:''}`;
        d.title='Click to cancel (full refund)';
        d.onclick=()=>{
          b.queue.splice(i,1);P.crystal+=q.cost;
          if(i===0)b.prodT=0;
          updateUI(true);sfx('order');
        };
        qb.appendChild(d);
      });
    }
    return;
  }
  // units
  const byType={};
  for(const u of s){if(u.kind!=='unit')continue;(byType[u.type]=byType[u.type]||[]).push(u);}
  const keys=Object.keys(byType);
  title.textContent=keys.map(k=>byType[k].length+'× '+UNIT[k].name).join(', ')||'—';
  for(const k of keys){
    const chip=document.createElement('div');chip.className='uchip';
    const g=byType[k],avg=g.reduce((a,u)=>a+u.hp/u.maxhp,0)/g.length;
    chip.innerHTML=`${g.length}× ${UNIT[k].name} <span style="color:${avg>0.6?'#9fe8b0':'#ff9a7a'}">${Math.round(avg*100)}%</span>`;
    chip.onclick=()=>{sel.clear();g.forEach(u=>sel.add(u));updateUI(true);};
    chip.title=UNIT[k].name+`: ${UNIT[k].dmg} dmg (${UNIT[k].dtype}) · range ${UNIT[k].rng} · armor ${UNIT[k].armor} · ${UNIT[k].g?'ground':''}${UNIT[k].a?' +air':''}`;
    row.appendChild(chip);
  }
  const mk=(label,hk,fn,off,tip)=>{
    const b=document.createElement('button');b.className='cmdbtn'+(off?' off':'');
    b.innerHTML=`${label}<span class="hk">${hk}</span>`;if(tip)b.title=tip;
    b.onclick=()=>{if(!off)fn();};grid.appendChild(b);
  };
  mk('Attack-Move','A',()=>{pendingCmd='amove';alertMsg('Attack-move: left-click destination');},false,'Engages enemies encountered on the way');
  mk('Stop','S',()=>cmdSel('stop'));
  mk('Hold','H',()=>cmdSel('hold'),false,'Hold position: attack in range, never pursue');
  if(keys.includes('drone'))mk('Build…','B',()=>{uiMode='build';updateUI(true);});
  if(uiMode==='build'&&keys.includes('drone')){
    for(const bt of Object.keys(BUILD)){
      const T=BUILD[bt];
      const reqOk=!T.req||buildings.some(b=>b.side===0&&b.type===T.req&&b.done);
      const costOk=P.crystal>=T.cost;
      const btn=document.createElement('button');btn.className='cmdbtn'+((!reqOk||!costOk)?' off':'');
      btn.innerHTML=`${T.name}<span class="cost">◆${T.cost}</span><span class="hk">${T.sup?'+${T.sup} sup'.replace('${T.sup}',T.sup):Math.floor(T.time)+'s'}</span>`;
      let why='';
      if(!reqOk)why='Requires '+BUILD[T.req].name;
      else if(!costOk)why='Not enough Aurite';
      btn.title=(T.trains?'Trains: '+T.trains.map(t=>UNIT[t].name).join(', ')+'. ':T.sup?'Provides +'+T.sup+' supply. ':'')+(T.dmg?'Defensive turret, hits ground & air. ':'')+(why?`<span class='warn'>${why}</span>`:'');
      btn.onclick=()=>{
        if(!reqOk||!costOk){sfx('err');alertMsg(why);return;}
        buildMode=bt;uiMode='cmd';updateUI(true);alertMsg('Left-click to place '+T.name+' · Esc to cancel');
      };
      grid.appendChild(btn);
    }
    const c=document.createElement('button');c.className='cmdbtn';c.innerHTML='Cancel<span class="hk">Esc</span>';
    c.onclick=()=>{uiMode='cmd';updateUI(true);};grid.appendChild(c);
  }
}
/* ============================== RENDER ============================== */
const fogCv=document.createElement('canvas');fogCv.width=FOGN;fogCv.height=FOGN;
const fogCtx=fogCv.getContext('2d');
const fogImg=fogCtx.createImageData(FOGN,FOGN);
function drawFogCanvas(){
  const d=fogImg.data;
  for(let i=0;i<FOGN*FOGN;i++){
    const v=fog[i],o=i*4;
    d[o]=5;d[o+1]=8;d[o+2]=14;
    d[o+3]=v===0?240:v===1?90:0;
  }
  fogCtx.putImageData(fogImg,0,0);
}
function drawShadow(x,z,r){
  const p=proj(x,0,z);
  ctx.fillStyle='rgba(0,0,0,0.3)';
  ctx.beginPath();ctx.ellipse(p.x,p.y,r*p.s,r*p.s*Math.cos(cam.pitch),0,0,TAU);ctx.fill();
}
function selRing(x,z,r,col){
  const p=proj(x,0,z);
  ctx.strokeStyle=col;ctx.lineWidth=1.5;
  ctx.beginPath();ctx.ellipse(p.x,p.y,r*p.s,r*p.s*Math.cos(cam.pitch),0,0,TAU);ctx.stroke();
}
function hpBar(x,y,frac,w){
  ctx.fillStyle='rgba(0,0,0,0.6)';ctx.fillRect(x-w/2,y,w,4);
  ctx.fillStyle=frac>0.6?'#6fe89a':frac>0.3?'#ffc95a':'#ff6a5a';
  ctx.fillRect(x-w/2+0.5,y+0.5,(w-1)*clamp(frac,0,1),3);
}
const COL_TEAL=[64,214,196],COL_RUST=[214,92,58];
function render(){
  ctx.fillStyle='#0a0e16';ctx.fillRect(0,0,W,H);
  const dl=[];
  // ground
  const g=[proj(0,0,0),proj(MAP,0,0),proj(MAP,0,MAP),proj(0,0,MAP)];
  fillPoly(g,'#243830');
  // grid
  ctx.strokeStyle='rgba(255,255,255,0.03)';ctx.lineWidth=1;
  for(let i=0;i<=MAP;i+=10){
    const a=proj(i,0,0),b=proj(i,0,MAP),c=proj(0,0,i),d=proj(MAP,0,i);
    ctx.beginPath();ctx.moveTo(a.x,a.y);ctx.lineTo(b.x,b.y);ctx.moveTo(c.x,c.y);ctx.lineTo(d.x,d.y);ctx.stroke();
  }
  // rocks
  for(const r of rocks){
    drawGroundQuad(dl,r.x,r.z,r.w+1.5,r.d+1.5,'#243028');
    drawBox(dl,r.x,r.z,0,r.w,r.d,r.h,[70,84,74],0);
  }
  // deposits
  for(const dp of deposits){
    if(dp.amt<=0){drawGroundQuad(dl,dp.x,dp.z,5,5,'#2a2620');continue;}
    const known=fog[fogIdx(dp.x,dp.z)]>0;
    drawGroundQuad(dl,dp.x,dp.z,5.5,5.5,known?'#20404a':'#1a2430');
    if(known){
      const n=3+Math.min(4,Math.floor(dp.amt/900));
      for(let i=0;i<n;i++){
        const a=i*2.4,r=0.9+((i*37)%10)/8;
        drawBox(dl,dp.x+Math.cos(a)*r,dp.z+Math.sin(a)*r,0,1.1,1.1,1.4+((i*53)%10)/6,[110,230,235],a);
      }
    }
  }
  // buildings
  for(const b of buildings){
    const vis=b.side===0||fogAt(fog,b.x,b.z)>0;
    if(!vis)continue;
    const ghost=!b.done;
    const col=b.side===0?COL_TEAL:COL_RUST;
    const base=b.side===0?SIDE_COL_D[0]:SIDE_COL_D[1];
    drawGroundQuad(dl,b.x,b.z,b.w+1.6,b.d+1.6,b.side===0?'rgba(64,214,196,0.10)':'rgba(214,92,58,0.10)');
    const hh=ghost?b.h*b.prog:b.h;
    drawBox(dl,b.x,b.z,0,b.w,b.d,Math.max(0.3,hh),ghost?[base[0]*0.6,base[1]*0.6,base[2]*0.6]:base,0);
    if(b.side===0&&b.done){
      // accent lights
      drawBox(dl,b.x,b.z,b.h,b.w*0.5,b.d*0.5,0.25,col,0);
    }
    if(b.type==='turret'&&b.done){
      drawCyl(dl,b.x,b.z,b.h,0.9,1.6,col,8);
    }
    if(b.type==='hq'&&b.done){
      drawCyl(dl,b.x,b.z,b.h,1.4,2.2,col,8);
    }
    if(b.type==='supply'&&b.done){
      drawCyl(dl,b.x,b.z,b.h*0.4,0.5,b.h*0.8,col,6);
    }
    if(b.type==='starport'&&b.done){
      drawCyl(dl,b.x,b.z,b.h,1.2,0.4,col,8);
    }
    // last-known ghost for enemy buildings out of vision
    if(b.side===1&&fogAt(fog,b.x,b.z)===0&&b.seen){
      ctx.globalAlpha=0.35;
      drawBox(dl,b.x,b.z,0,b.w,b.d,b.h,[80,90,110],0);
      ctx.globalAlpha=1;
    }
    if(sel.has(b))selRing(b.x,b.z,Math.max(b.w,b.d)/2+0.8,'#7ff5e8');
    if(b.side===0&&(sel.has(b)||b.hp<b.maxhp||b.blocked)){
      const p=proj(b.x,b.h+1.2,b.z);
      hpBar(p.x,p.y,b.hp/b.maxhp,Math.max(40,b.w*10));
      if(b.blocked){ctx.fillStyle='#ff9a7a';ctx.font='9px sans-serif';ctx.fillText('SUPPLY BLOCKED',p.x-30,p.y-6);}
    }
    if(b.side===1&&fogAt(fog,b.x,b.z)===2&&b.hp<b.maxhp){
      const p=proj(b.x,b.h+1.2,b.z);hpBar(p.x,p.y,b.hp/b.maxhp,36);
    }
    if(b.side===0&&!b.done){
      const p=proj(b.x,b.h+1.4,b.z);
      ctx.fillStyle='#cfe3ee';ctx.font='10px sans-serif';ctx.textAlign='center';
      ctx.fillText(Math.floor(b.prog*100)+'%',p.x,p.y);ctx.textAlign='left';
    }
  }
  // units
  for(const u of units){
    const vis=u.side===0||fogAt(fog,u.x,u.z)===2;
    if(!vis)continue;
    const col=u.side===0?COL_TEAL:COL_RUST;
    const T=UNIT[u.type];
    const bob=u.fly?1.9+Math.sin(gameTime*3+u.id)*0.25:0;
    if(u.fly)drawShadow(u.x,u.z,u.r*1.1);
    if(sel.has(u))selRing(u.x,u.z,u.r+0.35,'#7ff5e8');
    if(u.type==='drone'){
      drawBox(dl,u.x,u.z,bob,0.9,0.9,0.7,col,u.ang);
      if(u.carry>0)drawBox(dl,u.x,u.z,bob+0.7,0.5,0.5,0.5,[140,240,240],0);
    }else if(u.type==='raptor'){
      drawBox(dl,u.x,u.z,bob,1.2,0.8,0.6,col,u.ang);
      drawBox(dl,u.x+Math.sin(u.ang)*0.7,u.z+Math.cos(u.ang)*0.7,bob+0.3,0.7,0.5,0.5,col,u.ang);
    }else if(u.type==='lance'){
      drawBox(dl,u.x,u.z,bob,0.8,0.8,1.0,col,u.ang);
      drawCyl(dl,u.x,u.z,bob+1.0,0.22,0.8,col,6);
    }else if(u.type==='bastion'){
      drawBox(dl,u.x,u.z,bob,2.0,1.6,1.1,col,u.ang);
      drawBox(dl,u.x,u.z,bob+1.1,1.3,1.3,0.8,col,u.ang);
      drawCyl(dl,u.x,u.z,bob+1.9,0.28,1.0,[200,210,220],6);
    }else if(u.type==='wyvern'){
      drawBox(dl,u.x,u.z,bob,1.4,0.9,0.55,col,u.ang);
      const wing=Math.sin(gameTime*10+u.id)*0.5;
      drawBox(dl,u.x,u.z,bob+0.3,3.0,0.25,0.12,col,0);
      drawBox(dl,u.x,u.z,bob+0.3+wing*0.1,0.25,2.6,0.12,col,0);
    }
    if(u.side===0&&(sel.has(u)||u.hp<u.maxhp)){
      const p=proj(u.x,(u.fly?bob+1.6:1.4),u.z);
      hpBar(p.x,p.y,u.hp/u.maxhp,Math.max(22,u.r*26));
    }
    if(u.side===1&&u.hp<u.maxhp){
      const p=proj(u.x,(u.fly?bob+1.6:1.4),u.z);
      hpBar(p.x,p.y,u.hp/u.maxhp,20);
    }
  }
  // projectiles
  for(const p of projectiles){
    const pp=proj(p.x,p.y,p.z);
    dl.push({d:pp.d,f:()=>{ctx.fillStyle=p.col;ctx.beginPath();ctx.arc(pp.x,pp.y,2.5,0,TAU);ctx.fill();}});
  }
  // effects
  for(const e of effects){
    const f=e.t/e.dur,r=lerp(e.r0,e.r,f);
    const p=proj(e.x,e.y,e.z);
    dl.push({d:p.d+0.05,f:()=>{
      ctx.strokeStyle=e.col;ctx.globalAlpha=1-f;ctx.lineWidth=2.5;
      ctx.beginPath();ctx.ellipse(p.x,p.y,r*p.s,r*p.s*Math.cos(cam.pitch),0,0,TAU);ctx.stroke();
      ctx.globalAlpha=1;
    }});
  }
  // sort & draw
  dl.sort((a,b)=>a.d-b.d);
  for(const it of dl)it.f();
  // fog overlay (affine map of fog canvas)
  const s=H*0.026*cam.dist,cy=Math.cos(cam.yaw),sy=Math.sin(cam.yaw),cp=Math.cos(cam.pitch);
  const o=proj(0,0,0);
  ctx.save();
  ctx.setTransform(cy*s,sy*cp*s,-sy*s,cy*cp*s,o.x,o.y);
  ctx.imageSmoothingEnabled=true;
  ctx.drawImage(fogCv,0,0,MAP,MAP);
  ctx.setTransform(1,0,0,1,0,0);
  ctx.restore();
  // placement ghost
  if(buildMode&&mouse.inside){
    const w=unproj(mouse.x,mouse.y);
    const ok=canPlace(0,buildMode,w.x,w.z)&&P.crystal>=BUILD[buildMode].cost;
    const T=BUILD[buildMode];
    drawGroundQuad2(w.x,w.z,T.w,T.d,ok?'rgba(111,232,154,0.3)':'rgba(255,106,90,0.35)');
    drawBox(dl2,w.x,w.z,0,T.w,T.d,T.h,ok?[90,200,130]:[220,90,70],0);
    flushList(dl2);
  }
  // drag rect
  if(dragSel){
    ctx.strokeStyle='#7ff5e8';ctx.fillStyle='rgba(127,245,232,0.08)';ctx.lineWidth=1;
    ctx.fillRect(Math.min(dragSel.x0,dragSel.x1),Math.min(dragSel.y0,dragSel.y1),Math.abs(dragSel.x1-dragSel.x0),Math.abs(dragSel.y1-dragSel.y0));
    ctx.strokeRect(Math.min(dragSel.x0,dragSel.x1),Math.min(dragSel.y0,dragSel.y1),Math.abs(dragSel.x1-dragSel.x0),Math.abs(dragSel.y1-dragSel.y0));
  }
  drawMinimap();
}
let dl2=[];
function drawGroundQuad2(x,z,w,d,col){
  const p=[proj(x-w/2,0,z-d/2),proj(x+w/2,0,z-d/2),proj(x+w/2,0,z+d/2),proj(x-w/2,0,z+d/2)];
  fillPoly(p,col);
}
function flushList(l){l.sort((a,b)=>a.d-b.d);for(const it of l)it.f();l.length=0;}
function drawMinimap(){
  const S=216/MAP;
  mctx.fillStyle='#0c1210';mctx.fillRect(0,0,216,216);
  // fog
  for(let z=0;z<FOGN;z++)for(let x=0;x<FOGN;x++){
    const v=fog[z*FOGN+x];
    if(v===0){mctx.fillStyle='#05070c';mctx.fillRect(x*S,z*S,S+0.5,S+0.5);}
    else if(v===1){mctx.fillStyle='rgba(30,44,60,0.55)';mctx.fillRect(x*S,z*S,S+0.5,S+0.5);}
  }
  // deposits known
  for(const dp of deposits){
    if(dp.amt<=0||fog[fogIdx(dp.x,dp.z)]===0)continue;
    mctx.fillStyle='#5adfe0';mctx.fillRect(dp.x*S-2,dp.z*S-2,4,4);
  }
  // buildings
  for(const b of buildings){
    if(b.side===0){
      mctx.fillStyle=b.done?'#39d7c8':'#1f7d74';
      mctx.fillRect(b.x*S-b.w*S/2,b.z*S-b.d*S/2,Math.max(3,b.w*S),Math.max(3,b.d*S));
    }else if(fogAt(fog,b.x,b.z)===2){
      mctx.fillStyle='#e0603a';mctx.fillRect(b.x*S-3,b.z*S-3,6,6);
    }else if(b.seen){
      mctx.fillStyle='rgba(224,96,58,0.35)';mctx.fillRect(b.x*S-3,b.z*S-3,6,6);
    }
  }
  // units
  for(const u of units){
    if(u.side===0){
      mctx.fillStyle=u.type==='drone'?'#8fe8dd':'#39d7c8';
      mctx.fillRect(u.x*S-1.5,u.z*S-1.5,3,3);
    }else if(fogAt(fog,u.x,u.z)===2){
      mctx.fillStyle='#ff8a64';mctx.fillRect(u.x*S-1.5,u.z*S-1.5,3,3);
    }
  }
  // camera footprint
  const c=[proj(0,0,0),proj(W,0,0),proj(W,H,0),proj(0,H,0)].map(p=>unproj(p.x,p.y));
  mctx.strokeStyle='rgba(255,255,255,0.7)';mctx.beginPath();
  c.forEach((p,i)=>i?mctx.lineTo(p.x*S,p.z*S):mctx.moveTo(p.x*S,p.z*S));
  mctx.closePath();mctx.stroke();
}
/* ============================== LOOP ============================== */
let last=performance.now(),acc=0,uiT=0;
function frame(now){
  const dt=Math.min(0.1,(now-last)/1000);last=now;
  if(mode==='play'&&!paused&&!over){
    // camera keys + edge scroll
    const pan=60*dt/Math.max(0.5,cam.dist);
    let dx=0,dz=0;
    if(camKeys['arrowup'])dz-=1;
    if(camKeys['arrowdown'])dz+=1;
    if(camKeys['arrowleft'])dx-=1;
    if(camKeys['arrowright'])dx+=1;
    const edge=18;
    if(mouse.inside&&!dragSel){
      if(mouse.x<edge)dx-=1;if(mouse.x>W-edge)dx+=1;
      if(mouse.y<edge)dz-=1;if(mouse.y>H-edge)dz+=1;
    }
    if(dx||dz){
      const cy=Math.cos(cam.yaw),sy=Math.sin(cam.yaw);
      cam.tx=clamp(cam.tx+(dx*cy+dz*sy)*pan,0,MAP);
      cam.tz=clamp(cam.tz+(-dx*sy+dz*cy)*pan,0,MAP);
    }
    acc+=dt;
    let steps=0;
    while(acc>=0.05&&steps<6){simStep(0.05);acc-=0.05;steps++;}
    uiT-=dt;
    if(uiT<=0){uiT=0.2;updateUI(false);}
  }
  render();
  requestAnimationFrame(frame);
}
genWorld();drawFogCanvas();
requestAnimationFrame(frame);
</script>
</body>
</html>
<!-- agent-meta {"model":"z-ai/glm-5.3-flash","provider":"openrouter","persona":"full","sessionId":"dd0c36e2-e131-4cd4-a5e4-5846416141a8","tokensIn":1625706,"tokensOut":10088,"tokensTotal":1635794,"cost":0.06676101999999998,"turns":30,"toolCalls":30,"failedToolCalls":0,"timestamp":"2026-10-01T14:15:23.900Z"} -->