← RTS results

RTS

Mimo v2.6 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 148,037 bytes · SHA-256 18eae10696c4
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>FRONTIER WAR — Cinderfall Expanse</title>
<style>
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:#04060a;overflow:hidden;color:#cfe6f2;
 font-family:system-ui,"Segoe UI",Roboto,sans-serif;user-select:none;-webkit-user-select:none}
canvas{position:fixed;left:0;top:0}
#gl{z-index:1;cursor:default}
#hud{z-index:2;pointer-events:none}
#ui{position:fixed;inset:0;z-index:3;pointer-events:none}
#ui>*{pointer-events:auto}
.mono{font-family:ui-monospace,Menlo,Consolas,monospace}
.hidden{display:none!important}
button{font-family:inherit;color:inherit;cursor:pointer}

/* ---------- top bar ---------- */
#topbar{position:absolute;top:0;left:0;right:0;height:44px;display:flex;align-items:center;gap:12px;
 padding:0 10px;background:linear-gradient(180deg,rgba(5,11,17,.96),rgba(5,11,17,.55));
 border-bottom:1px solid #16344a;pointer-events:auto}
.pill{display:flex;align-items:center;gap:7px;padding:4px 10px;border:1px solid #1c3f56;
 background:linear-gradient(180deg,rgba(10,22,32,.9),rgba(6,12,18,.9));font-size:13px;
 letter-spacing:.4px;clip-path:polygon(0 6px,6px 0,100% 0,100% calc(100% - 6px),calc(100% - 6px) 100%,0 100%)}
.pill b{font-size:15px;font-weight:700;color:#eaf7ff}
.dot{width:9px;height:9px;border-radius:50%;background:#43e0ff;box-shadow:0 0 8px #43e0ff}
.dot.sup{background:#8ef0c0;box-shadow:0 0 8px #8ef0c0}
#supWrap{position:relative;overflow:hidden}
#supFill{position:absolute;left:0;top:0;bottom:0;background:rgba(140,255,200,.16);z-index:0;
 transition:width .2s linear}
#supWrap.low #supFill{background:rgba(255,90,90,.28)}
#supWrap.low b{color:#ff8a8a}
#supWrap>*{position:relative;z-index:1}
#income{opacity:.75;font-size:12px}
#clock{font-size:15px;color:#ffd782;letter-spacing:1px}
#alert{flex:1;text-align:center;font-size:13px;color:#ff9d6b;letter-spacing:.6px;
 text-shadow:0 0 12px rgba(255,120,60,.6);white-space:nowrap;overflow:hidden}
.tbtn{width:30px;height:30px;border:1px solid #1c3f56;background:rgba(10,22,32,.85);
 display:flex;align-items:center;justify-content:center;border-radius:3px;font-size:14px;padding:0}
.tbtn:hover{border-color:#43e0ff;background:rgba(20,50,70,.9)}
.tbtn.on{border-color:#8ef0c0;color:#8ef0c0}
.tbtn svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.8}

/* ---------- bottom bar ---------- */
#bottom{position:absolute;left:0;right:0;bottom:0;height:244px;display:flex;gap:9px;padding:9px;
 align-items:stretch;pointer-events:none}
#bottom>*{pointer-events:auto}
.panel{background:linear-gradient(180deg,rgba(9,18,28,.93),rgba(5,10,16,.95));
 border:1px solid #1d4056;box-shadow:0 0 0 1px #0008,0 10px 34px #000b,inset 0 1px rgba(255,255,255,.05);
 clip-path:polygon(0 12px,12px 0,calc(100% - 12px) 0,100% 12px,100% 100%,0 100%)}
.ptitle{font-size:10px;letter-spacing:2px;color:#5f93b0;text-transform:uppercase;
 padding:5px 8px 3px;border-bottom:1px solid #16344a}

/* minimap */
#minipanel{width:216px;display:flex;flex-direction:column}
#miniWrap{position:relative;flex:none;width:200px;height:200px;margin:7px auto;border:1px solid #1d4056;background:#000;overflow:hidden}
#mini{position:absolute;left:0;top:0;width:100%;height:100%;cursor:crosshair}
#minihint{position:absolute;left:0;right:0;bottom:0;font-size:9px;letter-spacing:1px;
 text-align:center;color:#4d7d99;background:rgba(0,0,0,.5);padding:2px}

/* selection */
#selframe{flex:1;min-width:0;display:flex;flex-direction:column}
#selhead{display:flex;gap:10px;align-items:center;padding:7px 9px;border-bottom:1px solid #16344a;
 min-height:52px}
#portrait{width:44px;height:44px;border:1px solid #2a5f7c;background:#07131c;flex:none;
 display:flex;align-items:center;justify-content:center}
#portrait svg{width:30px;height:30px}
#selname{font-size:14px;font-weight:700;color:#eaf7ff;letter-spacing:.4px}
#selsub{font-size:11px;color:#79a9c4;margin-top:2px}
#selstats{display:flex;gap:12px;font-size:11px;color:#9dc6db;flex-wrap:wrap}
#hpbar{height:7px;background:#0b1b25;border:1px solid #1d4056;margin-top:5px;width:100%}
#hpfill{height:100%;background:linear-gradient(90deg,#3ee08a,#9dffcf);width:100%}
#sellist{flex:1;overflow-y:auto;padding:6px;display:flex;flex-wrap:wrap;gap:5px;align-content:flex-start}
.sitem{width:52px;height:52px;border:1px solid #24506a;background:rgba(10,24,34,.8);
 position:relative;display:flex;align-items:center;justify-content:center;cursor:pointer}
.sitem:hover{border-color:#43e0ff}
.sitem svg{width:26px;height:26px}
.sitem .cnt{position:absolute;right:2px;bottom:1px;font-size:11px;font-weight:700;color:#fff;
 text-shadow:0 0 4px #000,0 1px 2px #000}
.sitem .shp{position:absolute;left:2px;right:2px;top:2px;height:3px;background:#0a1a22}
.sitem .shp i{display:block;height:100%;background:#3ee08a}
#queue{display:flex;gap:5px;padding:6px;border-top:1px solid #16344a;min-height:0}
.qslot{width:54px;height:54px;border:1px solid #24506a;background:#07131c;position:relative;
 display:flex;align-items:center;justify-content:center;overflow:hidden}
.qslot svg{width:26px;height:26px;opacity:.9}
.qslot .qp{position:absolute;left:0;right:0;bottom:0;background:rgba(67,224,255,.35);height:0%}
.qslot .qx{position:absolute;right:0;top:0;width:15px;height:15px;background:rgba(180,40,40,.85);
 color:#fff;font-size:11px;line-height:15px;text-align:center;cursor:pointer}
.qslot .qn{position:absolute;left:2px;bottom:1px;font-size:10px;color:#cfe6f2;text-shadow:0 0 3px #000}

/* command card */
#card{width:352px;display:flex;flex-direction:column}
#cmds{flex:1;display:grid;grid-template-columns:repeat(4,1fr);grid-auto-rows:1fr;gap:6px;padding:7px}
.cmd{position:relative;border:1px solid #24506a;background:linear-gradient(180deg,rgba(14,32,46,.9),rgba(7,15,23,.95));
 display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;padding:4px 2px 3px;
 overflow:hidden;min-height:0}
.cmd:hover:not(.dis){border-color:#43e0ff;box-shadow:0 0 12px rgba(67,224,255,.28) inset}
.cmd:active:not(.dis){transform:translateY(1px)}
.cmd svg{width:26px;height:26px;stroke:#bfe9ff;fill:none;stroke-width:1.6;stroke-linejoin:round;stroke-linecap:round}
.cmd .nm{font-size:10px;letter-spacing:.3px;color:#a8d4ea;text-align:center;line-height:1.05}
.cmd .kb{position:absolute;left:2px;top:1px;font-size:9px;color:#ffd782;letter-spacing:.5px}
.cmd .cst{position:absolute;right:3px;bottom:2px;font-size:9px;color:#8ef0c0}
.cmd .cst.sup{color:#ffd782}
.cmd.dis{opacity:.45;cursor:not-allowed}
.cmd.dis svg{stroke:#7c9bad}
.cmd .why{position:absolute;left:0;right:0;bottom:0;font-size:8.5px;color:#ff9d6b;background:rgba(30,8,4,.8);
 text-align:center;padding:1px;letter-spacing:.2px}
.cmd .cd{position:absolute;left:0;bottom:0;height:3px;background:#43e0ff;width:0}
.cmd .hot{position:absolute;right:2px;top:1px;font-size:9px;color:#5f93b0}
#cardfoot{font-size:10px;color:#6f9db8;padding:4px 8px;border-top:1px solid #16344a;letter-spacing:.4px;
 min-height:24px}

/* toasts + tooltip */
#toasts{position:absolute;left:50%;bottom:260px;transform:translateX(-50%);display:flex;
 flex-direction:column-reverse;gap:5px;align-items:center;pointer-events:none;z-index:6}
.toast{padding:6px 14px;border:1px solid #2a5f7c;background:rgba(6,14,22,.94);font-size:13px;
 letter-spacing:.4px;animation:tin .18s ease-out;box-shadow:0 6px 20px #000a;white-space:nowrap}
.toast.bad{border-color:#a33;color:#ffb0b0}
.toast.good{border-color:#2a7;color:#b6ffdd}
.toast.warn{border-color:#a83;color:#ffd9a0}
@keyframes tin{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
#tip{position:fixed;z-index:9;max-width:270px;padding:7px 10px;border:1px solid #2a5f7c;
 background:rgba(5,12,18,.97);font-size:11.5px;line-height:1.45;pointer-events:none;color:#cfe6f2;
 box-shadow:0 8px 26px #000c}
#tip b{color:#ffd782}
#tip .r{color:#ff9d6b}
#tip .g{color:#8ef0c0}
</style>
<style>
/* ---------- overlays ---------- */
.ov{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
 background:radial-gradient(ellipse at 50% 40%,rgba(8,20,32,.86),rgba(2,4,7,.96));
 backdrop-filter:blur(3px);z-index:10}
.card{width:min(940px,94vw);max-height:94vh;overflow:auto;padding:26px 30px 24px;
 background:linear-gradient(180deg,rgba(9,19,30,.97),rgba(4,9,14,.98));border:1px solid #24506a;
 box-shadow:0 0 60px rgba(0,0,0,.8),inset 0 1px rgba(255,255,255,.06);
 clip-path:polygon(0 18px,18px 0,calc(100% - 18px) 0,100% 18px,100% 100%,0 100%)}
.logo{font-size:52px;font-weight:900;letter-spacing:10px;margin:0;
 background:linear-gradient(180deg,#eaf7ff,#43e0ff 55%,#1b6f92);-webkit-background-clip:text;
 background-clip:text;color:transparent;text-shadow:0 0 40px rgba(67,224,255,.25)}
.sub{letter-spacing:5px;color:#7fb6d4;font-size:13px;margin:4px 0 18px;text-transform:uppercase}
.rule{height:1px;background:linear-gradient(90deg,transparent,#24506a,transparent);margin:16px 0}
.cols{display:grid;grid-template-columns:1fr 1fr;gap:22px}
@media(max-width:820px){.cols{grid-template-columns:1fr}}
h3{font-size:12px;letter-spacing:2.4px;color:#43e0ff;margin:0 0 8px;text-transform:uppercase}
.blk{font-size:12.5px;line-height:1.6;color:#a9cfe4}
.blk b{color:#eaf7ff}
table.keys{width:100%;border-collapse:collapse;font-size:12px}
table.keys td{padding:3px 6px;border-bottom:1px solid #12293a;color:#a9cfe4}
table.keys td:first-child{color:#ffd782;width:150px;font-family:ui-monospace,Menlo,monospace;font-size:11.5px}
.big{display:inline-block;padding:13px 40px;background:linear-gradient(180deg,#43e0ff,#1b88ad);
 color:#04121a;font-weight:800;letter-spacing:4px;font-size:16px;border:none;margin-top:16px;
 clip-path:polygon(0 10px,10px 0,100% 0,100% calc(100% - 10px),calc(100% - 10px) 100%,0 100%)}
.big:hover{filter:brightness(1.15);box-shadow:0 0 30px rgba(67,224,255,.45)}
.big.alt{background:linear-gradient(180deg,#ffb648,#c47a15);margin-left:12px}
.diff{display:flex;gap:10px;margin-top:6px}
.diff button{flex:1;padding:9px;background:rgba(10,24,34,.9);border:1px solid #24506a;
 font-size:12px;letter-spacing:1.6px;color:#9dc6db}
.diff button.sel{border-color:#43e0ff;color:#eaf7ff;background:rgba(20,54,74,.95);box-shadow:0 0 16px rgba(67,224,255,.25) inset}
.endtitle{font-size:44px;font-weight:900;letter-spacing:8px;margin:0 0 4px}
.endtitle.win{color:#7dffcf;text-shadow:0 0 34px rgba(80,255,190,.4)}
.endtitle.lose{color:#ff7a6b;text-shadow:0 0 34px rgba(255,90,70,.4)}
.stats{display:flex;gap:26px;margin-top:14px;flex-wrap:wrap}
.stat{border-left:2px solid #24506a;padding-left:10px}
.stat b{display:block;font-size:22px;color:#eaf7ff}
.stat span{font-size:10.5px;letter-spacing:1.6px;color:#6f9db8}
.helpbody{font-size:12.5px;line-height:1.6;color:#a9cfe4}
.helpbody h4{font-size:11px;letter-spacing:2px;color:#43e0ff;margin:14px 0 5px;text-transform:uppercase}
.tbl{width:100%;border-collapse:collapse;font-size:12px}
.tbl th,.tbl td{padding:4px 6px;border-bottom:1px solid #12293a;text-align:left}
.tbl th{color:#7fb6d4;font-size:10.5px;letter-spacing:1.2px;text-transform:uppercase}
.tbl td{color:#a9cfe4}
.tbl td:first-child{color:#eaf7ff}
.tag{display:inline-block;padding:1px 6px;border:1px solid #24506a;font-size:10.5px;letter-spacing:1px}
.tag.k{color:#ffd782;border-color:#7a5a1e}.tag.b{color:#ff9d6b;border-color:#7a3a1e}
.tag.p{color:#8ec9ff;border-color:#1e4a7a}
#ov-help .card,#ov-pause .card{width:min(760px,94vw)}
.center{text-align:center}
.foot{margin-top:14px;font-size:11px;color:#5f93b0;letter-spacing:.6px}
#loading{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
 background:#04060a;color:#43e0ff;letter-spacing:6px;font-size:14px;z-index:20}
</style>
</head>
<body>
<canvas id="gl"></canvas>
<canvas id="hud"></canvas>
<div id="ui">

  <div id="topbar">
    <div class="pill" id="resWrap"><span class="dot"></span><b id="res">500</b><span style="color:#6f9db8;font-size:11px">AETHERIUM</span></div>
    <div class="pill" id="supWrap"><div id="supFill"></div><span class="dot sup"></span><b id="sup">0 / 0</b><span style="color:#6f9db8;font-size:11px">SUPPLY</span></div>
    <div class="pill" id="income" title="Aetherium gathered per minute">+0/min</div>
    <div class="pill mono" id="clock">00:00</div>
    <div id="alert"></div>
    <button class="tbtn" id="btnHelp" title="Help (F1)"><svg viewBox="0 0 24 24"><path d="M9.2 9a3 3 0 1 1 4 2.8c-.8.4-1.2 1-1.2 2v.6"/><circle cx="12" cy="18" r="1" fill="currentColor" stroke="none"/></svg></button>
    <button class="tbtn" id="btnMute" title="Mute (F10)"><svg viewBox="0 0 24 24"><path d="M4 9v6h4l5 4V5L8 9H4z"/><path d="M16.5 8.5a5 5 0 0 1 0 7"/></svg></button>
    <button class="tbtn" id="btnPause" title="Pause (Esc)"><svg viewBox="0 0 24 24"><path d="M9 5v14M15 5v14"/></svg></button>
  </div>

  <div id="bottom">
    <div class="panel" id="minipanel">
      <div class="ptitle">Sector Map</div>
      <div id="miniWrap"><canvas id="mini" width="200" height="200"></canvas>
        <div id="minihint">L-CLICK GO&nbsp;&nbsp;·&nbsp;&nbsp;R-CLICK ORDER</div></div>
    </div>

    <div class="panel" id="selframe">
      <div class="ptitle">Selection</div>
      <div id="selhead">
        <div id="portrait"></div>
        <div style="flex:1;min-width:0">
          <div id="selname">No selection</div>
          <div id="selsub">Left-click a unit or drag a box to select.</div>
          <div id="selstats"></div>
          <div id="hpbar" class="hidden"><div id="hpfill"></div></div>
        </div>
      </div>
      <div id="sellist"></div>
      <div id="queue" class="hidden"></div>
    </div>

    <div class="panel" id="card">
      <div class="ptitle">Command</div>
      <div id="cmds"></div>
      <div id="cardfoot">Right-click issues orders · F1 help</div>
    </div>
  </div>

  <div id="toasts"></div>
  <div id="tip" class="hidden"></div>

  <!-- START -->
  <div class="ov" id="ov-start">
    <div class="card">
      <div class="center">
        <h1 class="logo">FRONTIER WAR</h1>
        <div class="sub">Cinderfall Expanse · Skirmish Deployment</div>
      </div>
      <div class="cols">
        <div>
          <h3>Mission</h3>
          <div class="blk">You command the <b>Aurora Expedition</b> against the <b>Rust Legion</b>.
          Both sides drop on opposite corners of an uncharted frontier world rich in
          <b>Aetherium</b>. Harvest it, build a war machine, and <b>destroy every enemy
          Headquarters</b>. Lose all of your own Headquarters and the expedition is wiped out.
          Extra Headquarters double as resource drop-offs — expansions are worth defending.</div>
          <div class="rule"></div>
          <h3>Controls</h3>
          <table class="keys">
            <tr><td>L-click / drag</td><td>Select unit or group (Shift adds)</td></tr>
            <tr><td>R-click</td><td>Move · Attack · Gather · Return cargo — contextual</td></tr>
            <tr><td>A / M</td><td>Attack-move / Move, then click ground</td></tr>
            <tr><td>S / H</td><td>Stop · Hold position</td></tr>
            <tr><td>Ctrl+1…9</td><td>Set control group · 1…9 recalls it</td></tr>
            <tr><td>WASD / arrows</td><td>Camera pan (also screen-edge)</td></tr>
            <tr><td>Q / E · wheel</td><td>Rotate camera · zoom</td></tr>
            <tr><td>Middle-drag</td><td>Pan · hold Shift to rotate</td></tr>
            <tr><td>Esc / P</td><td>Pause · F1 help · F10 mute</td></tr>
          </table>
        </div>
        <div>
          <h3>The Escalation</h3>
          <div class="blk">
          <b>1.</b> Keep Prospector workers mining — Aetherium is finite and nearby nodes run dry.<br>
          <b>2.</b> Build <b>Supply Relays</b> for population room (supply is reusable capacity,
          not a spent resource — queue as many as you can afford).<br>
          <b>3.</b> Barracks → Laboratory → Factory / Airfield unlocks heavier hardware and upgrades.<br>
          <b>4.</b> Expand to contested nodes with a second Headquarters; forward production wins wars.<br>
          <b>5.</b> Scout the black — the enemy base, routes and riches are hidden until seen.</div>
          <div class="rule"></div>
          <h3>Field Manual · Counters</h3>
          <table class="tbl">
            <tr><th>Unit</th><th>Role</th><th>Weak to</th></tr>
            <tr><td>Reaver <span class="tag k">KIN</span></td><td>Fast melee brawler</td><td>Heavy armor, air</td></tr>
            <tr><td>Lancer <span class="tag p">PUL</span></td><td>Ranged, hits air &amp; armour</td><td>Reavers that close, air</td></tr>
            <tr><td>Aegis <span class="tag b">BLS</span></td><td>Heavy tank, shreds infantry</td><td>Lancers, swarms</td></tr>
            <tr><td>Skimmer <span class="tag k">KIN</span></td><td>Flying, best anti-air</td><td>Lancers, turrets</td></tr>
          </table>
          <div class="blk" style="margin-top:8px;font-size:11.5px">
          <b>Kinetic</b> shreds light targets and aircraft · <b>Blast</b> crushes infantry and
          structures · <b>Pulse</b> defeats heavy armour. Light / Heavy / Air armour categories
          really change the fight.</div>
          <div class="diff">
            <button data-diff="0">RECRUIT</button>
            <button data-diff="1" class="sel">VETERAN</button>
            <button data-diff="2">WARLORD</button>
          </div>
          <div class="center"><button class="big" id="btnStart">DEPLOY</button></div>
        </div>
      </div>
      <div class="foot">Single-player skirmish · procedural audio · no external assets · 5–10 minute match</div>
    </div>
  </div>

  <!-- PAUSE -->
  <div class="ov hidden" id="ov-pause">
    <div class="card center">
      <h1 class="endtitle" style="color:#43e0ff">PAUSED</h1>
      <div class="blk">Simulation halted. The Legion is halted with it.</div>
      <div class="center">
        <button class="big" id="btnResume">RESUME</button>
        <button class="big alt" id="btnPauseHelp">HELP</button>
        <button class="big alt" id="btnRestart1" style="background:linear-gradient(180deg,#ff7a6b,#a8342a);color:#1a0605">RESTART</button>
      </div>
    </div>
  </div>

  <!-- END -->
  <div class="ov hidden" id="ov-end">
    <div class="card center">
      <h1 class="endtitle" id="endTitle">VICTORY</h1>
      <div class="blk" id="endText"></div>
      <div class="stats" id="endStats"></div>
      <div class="center"><button class="big" id="btnRestart2">NEW SKIRMISH</button></div>
    </div>
  </div>

  <!-- HELP -->
  <div class="ov hidden" id="ov-help">
    <div class="card">
      <div style="display:flex;justify-content:space-between;align-items:center">
        <h1 style="font-size:22px;letter-spacing:6px;margin:0;color:#eaf7ff">FIELD MANUAL</h1>
        <button class="tbtn" id="btnCloseHelp">✕</button>
      </div>
      <div class="helpbody">
        <div class="cols">
          <div>
            <h4>Objective</h4>
            Destroy every enemy <b>Headquarters</b> to win. If all of yours fall, you lose.
            Headquarters are also your resource drop-off, so expansions are both economy and victory.
            <h4>Economy</h4>
            Select Prospectors and right-click a glowing <b>Aetherium</b> node. They mine, haul and
            deposit at a Headquarters. Nodes are finite — nearby ones deplete, so expand outward.
            <h4>Supply</h4>
            Every unit reserves supply when queued. <b>Supply Relays</b> add +12 capacity each and
            can be built endlessly — supply is reusable capacity, never consumed. If a Relay dies
            while a unit is training, production <span class="r">pauses</span> until you rebuild
            capacity; existing units are never deleted. Cancelling anything refunds 100%.
            <h4>Building</h4>
            Select a worker → command card → choose a structure → click the ground. Green preview is
            valid. You may only build on explored, unobstructed ground. A worker must be free to
            start construction; several structures can be built at once by different workers.
            <h4>Production</h4>
            Each facility has its own queue and rally point (right-click the ground while the
            building is selected). Multiple barracks mean multiple units at a time.
          </div>
          <div>
            <h4>Camera</h4>
            WASD / arrows pan, Q/E rotate, wheel zooms, middle-drag pans (Shift+middle-drag rotates),
            clicking the minimap jumps the camera, right-clicking it issues a move order.
            <h4>Orders</h4>
            Left-click selects, drag a box for groups, Shift adds. Right-click is contextual:
            enemy → attack, node → gather, own drop-off → return, otherwise move.
            <b>A</b> attack-move, <b>M</b> move, <b>S</b> stop, <b>H</b> hold. Hold means "do not
            chase". Ctrl+1…9 sets groups, 1…9 selects, pressing twice centres the camera.
            <h4>Fog of War</h4>
            Black = unexplored. Dim = explored but not currently seen. Bright = in sight. Enemy
            units vanish when out of sight; enemy buildings you have seen linger as faint
            silhouettes until you look again. Moving the camera reveals nothing — only your forces do.
            <h4>Counters</h4>
            <table class="tbl">
              <tr><th>Damage</th><th>Best vs</th><th>Poor vs</th></tr>
              <tr><td><span class="tag k">Kinetic</span></td><td>Light, Air</td><td>Heavy</td></tr>
              <tr><td><span class="tag b">Blast</span></td><td>Light, structures</td><td>Air</td></tr>
              <tr><td><span class="tag p">Pulse</span></td><td>Heavy, Air</td><td>Light</td></tr>
            </table>
            <div style="margin-top:6px">Anti-air: <b>Lancer</b>, <b>Skimmer</b>, <b>Sentry Turret</b>.
            Reavers and Aegis cannot target aircraft.</div>
            <h4>Performance</h4>
            Hundreds of units are fully simulated. If the battle grows enormous, zoom out — the
            renderer batches everything into a handful of draw calls.
          </div>
        </div>
      </div>
      <div class="center"><button class="big" id="btnCloseHelp2">BACK</button></div>
    </div>
  </div>

  <div id="loading">INITIALISING THEATER…</div>
</div>
<script>
'use strict';
/* ================================================================
   FRONTIER WAR — single-file 3D RTS
   custom WebGL2 renderer, instanced batches, grid A*, fog of war
   ================================================================ */

/* ---------------- world constants ---------------- */
const MW = 320;            // world extent (0..MW on x and z)
const CELL = 4;            // coarse cell (path + fog)
const PN  = MW / CELL;     // 80  coarse grid
const FN  = MW / 2;        // 160 fine grid (2u)
const FCELL = 2;
const TICK = 1 / 30;       // simulation rate

/* ---------------- damage model ---------------- */
const DMG = {
  kinetic: { light: 1.15, heavy: 0.70, air: 1.35 },
  blast:   { light: 1.30, heavy: 1.10, air: 0.20 },
  pulse:   { light: 0.85, heavy: 1.30, air: 1.00 }
};
const DTYPE_LABEL = { kinetic:'KINETIC', blast:'BLAST', pulse:'PULSE' };

/* ---------------- unit roster ---------------- */
const UNITS = {
  worker: { key:'worker', name:'Prospector', ai:'Digger', role:'Worker',
    cost:50, supply:1, hp:55, speed:3.5, range:1.3, sight:9, dmg:5, dtype:'kinetic',
    rate:1.0, armor:'light', targets:'ground', time:12, r:0.62, build:'hq',
    blurb:'Mines Aetherium and constructs buildings. Weak in a fight.' },
  melee: { key:'melee', name:'Reaver', ai:'Mauler', role:'Close assault',
    cost:65, supply:1, hp:145, speed:4.0, range:1.5, sight:9, dmg:12, dtype:'kinetic',
    rate:1.15, armor:'light', targets:'ground', time:14, r:0.66, build:'barracks',
    blurb:'Fast melee brawler. Shreds infantry and workers, struggles against armour and cannot hit air.' },
  ranged: { key:'ranged', name:'Lancer', ai:'Needler', role:'Ranged / anti-air',
    cost:95, supply:2, hp:85, speed:3.2, range:7.5, sight:11, dmg:11, dtype:'pulse',
    rate:1.25, armor:'light', targets:'both', time:17, r:0.6, build:'barracks',
    blurb:'Fires pulse lances from range, hits ground and air, and is the best counter to heavy armour.' },
  heavy: { key:'heavy', name:'Aegis', ai:'Juggernaut', role:'Heavy armour',
    cost:170, supply:3, hp:360, speed:2.2, range:6.5, sight:10, dmg:30, dtype:'blast',
    rate:0.72, armor:'heavy', targets:'ground', time:30, r:0.95, build:'factory',
    blurb:'Slow siege tank. Blast rounds flatten infantry and structures; helpless against aircraft.' },
  air: { key:'air', name:'Skimmer', ai:'Vulture', role:'Air superiority',
    cost:145, supply:3, hp:120, speed:5.4, range:6.5, sight:12, dmg:13, dtype:'kinetic',
    rate:1.5, armor:'air', targets:'both', time:24, r:0.8, build:'airfield', fly:2.6,
    blurb:'Flying gunship that ignores terrain. Kinetic cannons excel at clearing other aircraft.' }
};
const UNIT_ORDER = ['worker','melee','ranged','heavy','air'];

/* ---------------- building roster ---------------- */
const BUILDS = {
  hq:       { key:'hq', name:'Headquarters', cost:400, time:50, hp:1700, armor:'heavy',
              size:8, sight:15, supply:12, trains:['worker'], drop:true, req:null,
              blurb:'Resource drop-off, worker training and +12 supply. Destroy every enemy HQ to win.' },
  relay:    { key:'relay', name:'Supply Relay', cost:75, time:20, hp:360, armor:'heavy',
              size:3.2, sight:8, supply:12, trains:[], req:null,
              blurb:'+12 supply. Build as many as you like — supply is reusable capacity.' },
  barracks: { key:'barracks', name:'Barracks', cost:150, time:30, hp:750, armor:'heavy',
              size:6, sight:9, trains:['melee','ranged'], req:null,
              blurb:'Trains Reavers and Lancers. Each Barracks works independently.' },
  lab:      { key:'lab', name:'Laboratory', cost:200, time:40, hp:650, armor:'heavy',
              size:5.5, sight:9, trains:[], req:'barracks',
              blurb:'Unlocks the Foundry, Skydock and all field upgrades.' },
  factory:  { key:'factory', name:'Foundry', cost:250, time:40, hp:850, armor:'heavy',
              size:6.5, sight:9, trains:['heavy'], req:'lab',
              blurb:'Assembles Aegis heavy tanks.' },
  airfield: { key:'airfield', name:'Skydock', cost:220, time:40, hp:700, armor:'heavy',
              size:6.5, sight:11, trains:['air'], req:'lab',
              blurb:'Launches Skimmer aircraft. Aircraft fly over terrain and obstacles.' },
  turret:   { key:'turret', name:'Sentry Turret', cost:120, time:25, hp:420, armor:'heavy',
              size:3.4, sight:12, trains:[], req:'barracks',
              weapon:{ range:9.5, dmg:17, dtype:'pulse', rate:1.1, targets:'both', sight:12 },
              blurb:'Automated defence. Pulse rounds strike ground and air — the static anti-air answer.' }
};
const BUILD_ORDER = ['hq','relay','barracks','lab','factory','airfield','turret'];

/* ---------------- upgrades ---------------- */
const UPGRADES = {
  alloys:  { key:'alloys',  name:'Improved Alloys',  cost:150, time:40, req:'lab',
             blurb:'+15% hit points on every unit you own.' },
  weapons: { key:'weapons', name:'Weapon Calibration', cost:185, time:45, req:'lab',
             blurb:'+20% weapon damage for all units and turrets.' },
  servos:  { key:'servos',  name:'Servo Actuators',   cost:125, time:35, req:'lab',
             blurb:'+15% movement speed for ground units.' },
  mining:  { key:'mining',  name:'Deep Extraction',   cost:140, time:40, req:'lab',
             blurb:'+35% mining speed for every Prospector.' },
  plating: { key:'plating', name:'Composite Plating', cost:165, time:45, req:'lab',
             blurb:'Ground units and structures take 15% less damage.' }
};
const UPGRADE_ORDER = ['alloys','weapons','servos','mining','plating'];

const TEAM = { P:0, E:1 };
const TEAM_COLOR = [ [0.22,0.84,1.0], [1.0,0.36,0.20] ];
const TEAM_NAME = ['Aurora Expedition','Rust Legion'];

/* ---------------- tiny utils ---------------- */
const $ = id => document.getElementById(id);
const clamp = (v,a,b) => v<a?a:(v>b?b:v);
const lerp = (a,b,t) => a+(b-a)*t;
const dist2 = (ax,az,bx,bz) => { const dx=ax-bx, dz=az-bz; return dx*dx+dz*dz; };
const dist = (ax,az,bx,bz) => Math.sqrt(dist2(ax,az,bx,bz));
function mulberry(seed){ let a=seed>>>0; return function(){ a|=0; a=a+0x6D2B79F5|0;
  let t=Math.imul(a^a>>>15,1|a); t=t+Math.imul(t^t>>>7,61|t)^t; return ((t^t>>>14)>>>0)/4294967296; }; }
let RNG = mulberry(1337);
function fmtTime(s){ s=Math.max(0,Math.floor(s)); return String((s/60)|0).padStart(2,'0')+':'+String(s%60).padStart(2,'0'); }
function fmtNum(n){ n=Math.floor(n); return n>=10000?(n/1000).toFixed(1)+'k':String(n); }

/* ---------------- icons ---------------- */
const SVG = (d,extra) => '<svg viewBox="0 0 24 24">'+(extra||'')+'<path d="'+d+'"/></svg>';
const ICONS = {
  move:   SVG('M4 12h13M12 7l5 5-5 5M19 6v12'),
  stop:   SVG('M7.5 7.5h9v9h-9z'),
  hold:   SVG('M12 3.2l7 2.8v6c0 4-3 6.9-7 8.8-4-1.9-7-4.8-7-8.8V6z'),
  attack: SVG('M12 5.5v3M12 15.5v3M5.5 12h3M15.5 12h3','<circle cx="12" cy="12" r="3.2"/>'),
  build:  SVG('M3.5 20.5h17M6 20.5V9.5l6-4 6 4v11M10 20.5v-5.5h4v5.5'),
  rally:  SVG('M6 21.5V3.5M6 4.5l11 3.8-11 3.8'),
  cancel: SVG('M6.5 6.5l11 11M17.5 6.5l-11 11'),
  worker: SVG('M15.5 3.6a4.4 4.4 0 0 0-5.6 5.4L3.8 15.7l4.5 4.5 6.7-6.1a4.4 4.4 0 0 0 5.4-5.6l-3 3-2.6-.6-.6-2.6z'),
  melee:  SVG('M18.6 2.6l2.8 2.8-9 9-2.8-2.8zM9.4 12.4L3 19.4l1.6 1.6 7-6.4'),
  ranged: SVG('M12 3.5v3.2M12 17.3v3.2M3.5 12h3.2M17.3 12h3.2','<circle cx="12" cy="12" r="4"/>'),
  heavy:  SVG('M3 15.5h18v3.2H3zM6 15.5v-4.2h9.5l3 4.2M8.5 18.7a1.5 1.5 0 1 0 .01 0M16.5 18.7a1.5 1.5 0 1 0 .01 0'),
  air:    SVG('M2.5 13.6l9.2-3 2.6-7 2.2 1-1.2 6 7.2 3v2l-7.2-1-1.6 5h-2.2l-.8-5-7.4 1z'),
  hq:     SVG('M12 2.6l8.4 5.2v8.4L12 21.4 3.6 16.2V7.8zM12 7.4v9.2M8.4 9.6v4.8M15.6 9.6v4.8'),
  relay:  SVG('M12 21V8.5M7.5 12.5L12 8l4.5 4.5M5.5 21h13M12 4.6a2.4 2.4 0 1 0 0 .01'),
  barracks: SVG('M3 20.5V10l9-6 9 6v10.5M9 20.5v-6.5h6v6.5M3 20.5h18'),
  lab:    SVG('M9.6 3v6.4L4.8 18a2 2 0 0 0 1.8 3h10.8a2 2 0 0 0 1.8-3l-4.8-8.6V3M8.6 3h6.8M7.4 15.4h9.2'),
  factory: SVG('M3 20.5V10l5 3.2V10l5 3.2V10l8 4.4v6.1zM6.5 20.5h2.5M13 20.5h2.5M3 20.5h18'),
  airfield: SVG('M12 2.6l2.2 7.4 7.3 4v2.1l-7.3-2.1-1.2 5.1 3 2.4v1.4L12 22l-4-1v-1.4l3-2.4-1.2-5.1L2.5 16v-2.1l7.3-4z'),
  turret: SVG('M12 21v-4.4M7.6 21h8.8M5.6 16.6a6.4 6.4 0 0 1 12.8 0zM12 10.2V4.4M12 4.4l6 2.2'),
  alloys: SVG('M4 8.4l8-4.2 8 4.2-8 4.2zM4 13.4l8 4.2 8-4.2M4 18l8 4.2 8-4.2'),
  weapons: SVG('M12 2.4l2.9 6.7 7.1.7-5.3 4.8 1.5 7-6.2-3.7-6.2 3.7 1.5-7L2 9.8l7.1-.7z'),
  servos: SVG('M13.4 2L4.6 13.6h5.6L9 22l9.4-12.4h-5.8z'),
  mining: SVG('M12 2.4l6.4 6.6L12 21.6 5.6 9zM5.6 9h12.8'),
  plating: SVG('M12 3.2l7 2.8v6c0 4-3 6.9-7 8.8-4-1.9-7-4.8-7-8.8V6zM12 7.6v9'),
  help:   SVG('M9.3 9a3 3 0 1 1 4.1 2.8c-.9.4-1.4 1.1-1.4 2.1v.6','<circle cx="12" cy="18" r="1.1"/>'),
  pause:  SVG('M9 5v14M15 5v14'),
  play:   SVG('M7 4.5l12 7.5-12 7.5z'),
  sound:  SVG('M4 9.2v5.6h3.6L12.6 19V5L7.6 9.2zM16.4 8.6a5 5 0 0 1 0 6.8'),
  mute:   SVG('M4 9.2v5.6h3.6L12.6 19V5L7.6 9.2zM16.5 9.5l5 5M21.5 9.5l-5 5')
};
function icon(k){ return ICONS[k] || ICONS.help; }

/* ---------------- procedural audio ---------------- */
const SND = {
  ctx:null, muted:false, master:null, last:{},
  init(){ if(this.ctx) return;
    try{ const AC = window.AudioContext||window.webkitAudioContext; this.ctx=new AC();
      this.master=this.ctx.createGain(); this.master.gain.value=0.5; this.master.connect(this.ctx.destination);
    }catch(e){ this.ctx=null; } },
  resume(){ if(this.ctx && this.ctx.state==='suspended') this.ctx.resume(); },
  limit(name,ms){ const t=performance.now(); if(this.last[name] && t-this.last[name]<ms) return false;
    this.last[name]=t; return true; },
  tone(f0,f1,dur,type,vol,delay){ if(!this.ctx||this.muted) return; const c=this.ctx,t=c.currentTime+(delay||0);
    const o=c.createOscillator(),g=c.createGain(); o.type=type||'square';
    o.frequency.setValueAtTime(f0,t); if(f1) o.frequency.exponentialRampToValueAtTime(Math.max(20,f1),t+dur);
    g.gain.setValueAtTime(0.0001,t); g.gain.exponentialRampToValueAtTime(vol||0.2,t+0.012);
    g.gain.exponentialRampToValueAtTime(0.0001,t+dur);
    o.connect(g); g.connect(this.master); o.start(t); o.stop(t+dur+0.03); },
  noise(dur,vol,filt,fc){ if(!this.ctx||this.muted) return; const c=this.ctx,t=c.currentTime;
    const n=Math.floor(c.sampleRate*dur), b=c.createBuffer(1,n,c.sampleRate), d=b.getChannelData(0);
    for(let i=0;i<n;i++) d[i]=(Math.random()*2-1)*(1-i/n);
    const s=c.createBufferSource(); s.buffer=b;
    const g=c.createGain(); g.gain.value=vol||0.2;
    let node=s; if(filt){ const bp=c.createBiquadFilter(); bp.type=filt; bp.frequency.value=fc||900; s.connect(bp); node=bp; }
    node.connect(g); g.connect(this.master); s.start(t); },
  play(name){ if(!this.ctx||this.muted) return;
    switch(name){
      case 'select': this.tone(660,880,0.06,'square',0.10); break;
      case 'cmd':    this.tone(420,620,0.08,'square',0.11); break;
      case 'ack':    this.tone(300,380,0.07,'triangle',0.10); break;
      case 'err':    if(!this.limit('err',260))return; this.tone(180,110,0.16,'sawtooth',0.14); break;
      case 'place':  this.tone(240,520,0.22,'triangle',0.14); this.noise(0.2,0.05,'lowpass',700); break;
      case 'built':  if(!this.limit('built',180))return; this.tone(392,784,0.30,'triangle',0.16);
                     this.tone(588,1176,0.28,'sine',0.09,0.06); break;
      case 'ready':  if(!this.limit('ready',200))return; this.tone(520,1040,0.14,'square',0.11); break;
      case 'research': this.tone(440,1320,0.5,'sine',0.13); break;
      case 'hit':    if(!this.limit('hit',70))return; this.noise(0.06,0.10,'highpass',1600); break;
      case 'boom':   if(!this.limit('boom',90))return; this.noise(0.45,0.30,'lowpass',420);
                     this.tone(120,40,0.4,'sine',0.16); break;
      case 'bigboom': if(!this.limit('bigboom',150))return; this.noise(0.9,0.42,'lowpass',300);
                     this.tone(90,28,0.8,'sine',0.22); this.noise(0.3,0.2,'bandpass',1400); break;
      case 'alert':  if(!this.limit('alert',4000))return; this.tone(880,660,0.18,'square',0.13);
                     this.tone(660,440,0.22,'square',0.13,0.2); break;
      case 'mined':  if(!this.limit('mined',400))return; this.tone(1100,1500,0.05,'sine',0.06); break;
      case 'win':    [523,659,784,1046].forEach((f,i)=>this.tone(f,f,0.34,'triangle',0.16,i*0.14)); break;
      case 'lose':   [392,330,262,196].forEach((f,i)=>this.tone(f,f*0.9,0.4,'sawtooth',0.14,i*0.16)); break;
      case 'ping':   if(!this.limit('ping',900))return; this.tone(1200,1600,0.07,'sine',0.10); break;
    }
  }
};
/* ================================================================
   MATRIX MATH
   ================================================================ */
function m4(){ return new Float32Array(16); }
function m4persp(out,fovy,aspect,near,far){
  const f=1/Math.tan(fovy/2), nf=1/(near-far);
  out[0]=f/aspect;out[1]=0;out[2]=0;out[3]=0;
  out[4]=0;out[5]=f;out[6]=0;out[7]=0;
  out[8]=0;out[9]=0;out[10]=(far+near)*nf;out[11]=-1;
  out[12]=0;out[13]=0;out[14]=2*far*near*nf;out[15]=0; return out;
}
function m4look(out,ex,ey,ez,cx,cy,cz){
  let zx=ex-cx,zy=ey-cy,zz=ez-cz;
  let l=Math.hypot(zx,zy,zz)||1; zx/=l;zy/=l;zz/=l;
  let xx=zz*0-0*zy, xy=0*zx-0*zz, xz=0*zy-1*zx; // up(0,1,0) cross z
  xx=1*zz-0*zy; xy=0*zx-0*zz; xz=0*zy-1*zx;
  xx=0*zz-1*zy; xy=1*zx-0*zz; xz=0*zy-0*zx;   // up x z = (uz*? ) recompute properly below
  // up(0,1,0) x z  => (1*zz - 0*zy, 0*zx - 0*zz, 0*zy - 1*zx) = (zz, 0, -zx)
  xx=zz; xy=0; xz=-zx;
  l=Math.hypot(xx,xy,xz)||1; xx/=l;xy/=l;xz/=l;
  const yx=zy*xz-zz*xy, yy=zz*xx-zx*xz, yz=zx*xy-zy*xx;
  out[0]=xx;out[1]=yx;out[2]=zx;out[3]=0;
  out[4]=xy;out[5]=yy;out[6]=zy;out[7]=0;
  out[8]=xz;out[9]=yz;out[10]=zz;out[11]=0;
  out[12]=-(xx*ex+xy*ey+xz*ez);
  out[13]=-(yx*ex+yy*ey+yz*ez);
  out[14]=-(zx*ex+zy*ey+zz*ez);
  out[15]=1; return out;
}
function m4mul(out,a,b){
  for(let c=0;c<4;c++){ const b0=b[c*4],b1=b[c*4+1],b2=b[c*4+2],b3=b[c*4+3];
    out[c*4]  =a[0]*b0+a[4]*b1+a[8]*b2+a[12]*b3;
    out[c*4+1]=a[1]*b0+a[5]*b1+a[9]*b2+a[13]*b3;
    out[c*4+2]=a[2]*b0+a[6]*b1+a[10]*b2+a[14]*b3;
    out[c*4+3]=a[3]*b0+a[7]*b1+a[11]*b2+a[15]*b3; }
  return out;
}
function m4inv(out,m){
  const a00=m[0],a01=m[1],a02=m[2],a03=m[3],a10=m[4],a11=m[5],a12=m[6],a13=m[7],
        a20=m[8],a21=m[9],a22=m[10],a23=m[11],a30=m[12],a31=m[13],a32=m[14],a33=m[15];
  const b00=a00*a11-a01*a10,b01=a00*a12-a02*a10,b02=a00*a13-a03*a10,b03=a01*a12-a02*a11,
        b04=a01*a13-a03*a11,b05=a02*a13-a03*a12,b06=a20*a31-a21*a30,b07=a20*a32-a22*a30,
        b08=a20*a33-a23*a30,b09=a21*a32-a22*a31,b10=a21*a33-a23*a31,b11=a22*a33-a23*a32;
  let det=b00*b11-b01*b10+b02*b09+b03*b08-b04*b07+b05*b06;
  if(!det) return null; det=1/det;
  out[0]=(a11*b11-a12*b10+a13*b09)*det; out[1]=(a02*b10-a01*b11-a03*b09)*det;
  out[2]=(a31*b05-a32*b04+a33*b03)*det; out[3]=(a22*b04-a21*b05-a23*b03)*det;
  out[4]=(a12*b08-a10*b11-a13*b07)*det; out[5]=(a00*b11-a02*b08+a03*b07)*det;
  out[6]=(a32*b02-a30*b05-a33*b01)*det; out[7]=(a20*b05-a22*b02+a23*b01)*det;
  out[8]=(a10*b10-a11*b08+a13*b06)*det; out[9]=(a01*b08-a00*b10-a03*b06)*det;
  out[10]=(a30*b04-a31*b02+a33*b00)*det; out[11]=(a21*b02-a20*b04-a23*b00)*det;
  out[12]=(a11*b07-a10*b09-a12*b06)*det; out[13]=(a00*b09-a01*b07+a02*b06)*det;
  out[14]=(a31*b01-a30*b03-a32*b00)*det; out[15]=(a20*b03-a21*b01+a22*b00)*det;
  return out;
}
function m4xformP(out,m,x,y,z){
  const w=m[3]*x+m[7]*y+m[11]*z+m[15]||1;
  out[0]=(m[0]*x+m[4]*y+m[8]*z+m[12])/w;
  out[1]=(m[1]*x+m[5]*y+m[9]*z+m[13])/w;
  out[2]=(m[2]*x+m[6]*y+m[10]*z+m[14])/w; return out;
}

/* ================================================================
   WEBGL RENDERER
   ================================================================ */
const glc = $('gl');
let gl = null, PROG = null, U = {};
const R = { w:0, h:0, dpr:1, vp:m4(), proj:m4(), view:m4(), inv:m4() };

const VS = `#version 300 es
precision highp float;
layout(location=0) in vec3 aPos;
layout(location=1) in vec3 aNrm;
layout(location=2) in vec3 aCol;
layout(location=3) in float aMask;
layout(location=4) in float aEmis;
layout(location=5) in vec2 aUV;
layout(location=6) in vec4 aI0;
layout(location=7) in vec4 aI1;
layout(location=8) in vec4 aI2;
uniform mat4 uVP;
out vec3 vW; out vec3 vN; out vec3 vC; out float vM; out float vE;
out vec2 vUV; out vec4 vI2; out float vA;
void main(){
  vec3 p = aPos * aI1.xyz;
  float s=sin(aI0.w), c=cos(aI0.w);
  p = vec3(p.x*c + p.z*s, p.y, -p.x*s + p.z*c);
  vec3 w = p + aI0.xyz;
  vN = vec3(aNrm.x*c + aNrm.z*s, aNrm.y, -aNrm.x*s + aNrm.z*c);
  vW = w; vC = aCol; vM = aMask; vE = aEmis; vUV = aUV; vI2 = aI2; vA = aI1.w;
  gl_Position = uVP * vec4(w,1.0);
}`;
const FS = `#version 300 es
precision highp float;
in vec3 vW; in vec3 vN; in vec3 vC; in float vM; in float vE;
in vec2 vUV; in vec4 vI2; in float vA;
uniform sampler2D uFog;
uniform sampler2D uTex;
uniform float uMode;    // 0 = lit vertex colour + team mask, 1 = flat instance colour
uniform float uTexMode; // 0 = no texture, 1 = modulate texture alpha/rgb
uniform float uFogOn;
uniform vec2 uMap;
out vec4 frag;
void main(){
  vec3 n = normalize(vN);
  if(!gl_FrontFacing) n = -n;
  vec3 base = mix(vC, mix(vC, vI2.rgb, 0.85), vM);
  base = mix(base, vI2.rgb, uMode);
  vec3 sun = normalize(vec3(-0.45, 0.86, 0.32));
  float ndl = max(dot(n, sun), 0.0);
  float rim = pow(1.0 - max(dot(n, vec3(0.0,1.0,0.0)),0.0), 2.0) * 0.10;
  vec3 col = base * (0.36 + 0.70*ndl + rim);
  col += base * vE * 1.5;
  col = mix(col, vec3(1.0,0.5,0.34), vI2.a);
  float a = vA;
  if(uTexMode > 0.5){ vec4 t = texture(uTex, vUV);
    if(uTexMode > 1.5){ col = mix(col, col*2.6 + vec3(0.03,0.07,0.10), t.a); }
    else { col *= t.rgb; a *= t.a; } }
  if(uFogOn > 0.5){
    vec2 uv = vW.xz / uMap;
    float f = texture(uFog, uv).r;
    float lit = mix(0.04, 0.40, smoothstep(0.13,0.37,f));
    lit = mix(lit, 1.0, smoothstep(0.56,0.88,f));
    col *= lit;
  }
  frag = vec4(col, a);
}`;

function compile(type,src){
  const s=gl.createShader(type); gl.shaderSource(s,src); gl.compileShader(s);
  if(!gl.getShaderParameter(s,gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
  return s;
}
function initGL(){
  gl = glc.getContext('webgl2',{antialias:true, alpha:false, powerPreference:'high-performance'});
  if(!gl){ $('loading').textContent='WEBGL2 REQUIRED — please use a modern browser'; return false; }
  const p=gl.createProgram();
  gl.attachShader(p,compile(gl.VERTEX_SHADER,VS));
  gl.attachShader(p,compile(gl.FRAGMENT_SHADER,FS));
  gl.linkProgram(p);
  if(!gl.getProgramParameter(p,gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
  PROG=p; gl.useProgram(p);
  ['uVP','uFog','uTex','uMode','uTexMode','uFogOn','uMap'].forEach(k=>U[k]=gl.getUniformLocation(p,k));
  gl.uniform1i(U.uFog,0); gl.uniform1i(U.uTex,1);
  gl.uniform2f(U.uMap, MW, MW);
  gl.enable(gl.DEPTH_TEST);
  gl.enable(gl.BLEND);
  gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
  gl.disable(gl.CULL_FACE);
  gl.clearColor(0.02,0.03,0.05,1);
  return true;
}

/* ---------- mesh construction ---------- */
function MB(){ this.v=[]; this.i=[]; this.n=0; }
MB.prototype.vert=function(x,y,z,nx,ny,nz,c,mask,emis,u,v){
  this.v.push(x,y,z,nx,ny,nz,c[0],c[1],c[2],mask,emis,u||0,v||0);
  return this.n++;
};
MB.prototype.quad=function(a,b,c,d,n,col,mask,emis,uvs){
  const i0=this.vert(a[0],a[1],a[2],n[0],n[1],n[2],col,mask,emis,uvs?uvs[0][0]:0,uvs?uvs[0][1]:0);
  this.vert(b[0],b[1],b[2],n[0],n[1],n[2],col,mask,emis,uvs?uvs[1][0]:0,uvs?uvs[1][1]:0);
  this.vert(c[0],c[1],c[2],n[0],n[1],n[2],col,mask,emis,uvs?uvs[2][0]:0,uvs?uvs[2][1]:0);
  this.vert(d[0],d[1],d[2],n[0],n[1],n[2],col,mask,emis,uvs?uvs[3][0]:0,uvs?uvs[3][1]:0);
  this.i.push(i0,i0+1,i0+2,i0,i0+2,i0+3);
};
MB.prototype.box=function(cx,cy,cz,sx,sy,sz,col,mask,emis,yaw){
  yaw=yaw||0; const c=Math.cos(yaw), s=Math.sin(yaw);
  const hx=sx/2,hy=sy/2,hz=sz/2;
  const P=(lx,ly,lz)=>[cx+lx*c+lz*s, cy+ly, cz-lx*s+lz*c];
  const ax=[c,0,-s], az=[s,0,c], ay=[0,1,0];
  const A=P(-hx,hy,-hz),B=P(hx,hy,-hz),C=P(hx,hy,hz),D=P(-hx,hy,hz);
  const E=P(-hx,-hy,-hz),F=P(hx,-hy,-hz),G=P(hx,-hy,hz),H=P(-hx,-hy,hz);
  this.quad(D,C,B,A,ay,col,mask,emis);       // top
  this.quad(E,F,G,H,[-ay[0],-ay[1],-ay[2]],col,mask,emis); // bottom
  this.quad(C,D,H,G,az,col,mask,emis);       // +z
  this.quad(F,E,A,B,[-az[0],-az[1],-az[2]],col,mask,emis); // -z
  this.quad(B,A,E,F,ax,col,mask,emis);       // +x
  this.quad(D,C,G,H,[-ax[0],-ax[1],-ax[2]],col,mask,emis); // -x
};
MB.prototype.cyl=function(cx,cy,cz,r,h,seg,col,mask,emis,r2){
  r2=(r2===undefined)?r:r2;
  for(let i=0;i<seg;i++){
    const a0=i/seg*Math.PI*2, a1=(i+1)/seg*Math.PI*2;
    const c0=Math.cos(a0),s0=Math.sin(a0),c1=Math.cos(a1),s1=Math.sin(a1);
    const p00=[cx+c0*r,cy,cz+s0*r], p01=[cx+c1*r,cy,cz+s1*r];
    const p10=[cx+c0*r2,cy+h,cz+s0*r2], p11=[cx+c1*r2,cy+h,cz+s1*r2];
    const nm=(c0+c1)/2, ns=(s0+s1)/2, nl=Math.hypot(nm,ns)||1;
    this.quad(p00,p01,p11,p10,[nm/nl,0.15,ns/nl],col,mask,emis);
    // top cap
    const ntop=[0,1,0];
    const t0=[cx,cy+h,cz], t1=[cx+c0*r2,cy+h,cz+s0*r2], t2=[cx+c1*r2,cy+h,cz+s1*r2];
    const ti=this.vert(t0[0],t0[1],t0[2],0,1,0,col,mask,emis,0.5,0.5);
    this.vert(t1[0],t1[1],t1[2],0,1,0,col,mask,emis,0,0);
    this.vert(t2[0],t2[1],t2[2],0,1,0,col,mask,emis,1,0);
    this.i.push(ti,ti+1,ti+2);
  }
};
MB.prototype.pyr=function(cx,cy,cz,sx,sz,h,col,mask,emis,yaw){
  yaw=yaw||0; const c=Math.cos(yaw), s=Math.sin(yaw);
  const hx=sx/2,hz=sz/2;
  const P=(lx,ly,lz)=>[cx+lx*c+lz*s, cy+ly, cz-lx*s+lz*c];
  const A=P(-hx,0,-hz),B=P(hx,0,-hz),C=P(hx,0,hz),D=P(-hx,0,hz),T=[cx,cy+h,cz];
  const n1=[0,0.5,-0.9], n2=[0.9,0.5,0], n3=[0,0.5,0.9], n4=[-0.9,0.5,0];
  this.quad(D,C,T,T,n3,col,mask,emis);
  this.quad(C,B,T,T,n2,col,mask,emis);
  this.quad(B,A,T,T,n1,col,mask,emis);
  this.quad(A,D,T,T,n4,col,mask,emis);
  this.quad(A,B,C,D,[0,-1,0],col,mask,emis);
};
MB.prototype.plate=function(cx,cy,cz,sx,sz,col,mask,emis,yaw){
  this.box(cx,cy,cz,sx,0.06,sz,col,mask,emis,yaw);
};
function buildMesh(mb){
  const vao=gl.createVertexArray(); gl.bindVertexArray(vao);
  const vb=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,vb);
  gl.bufferData(gl.ARRAY_BUFFER,new Float32Array(mb.v),gl.STATIC_DRAW);
  const stride=13*4;
  const attr=(loc,size,off)=>{ gl.enableVertexAttribArray(loc);
    gl.vertexAttribPointer(loc,size,gl.FLOAT,false,stride,off*4); };
  attr(0,3,0); attr(1,3,3); attr(2,3,6); attr(3,1,9); attr(4,1,10); attr(5,2,11);
  const ib=gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,ib);
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,new Uint32Array(mb.i),gl.STATIC_DRAW);
  const mesh={vao, count:mb.i.length, cap:256, gcap:256, n:0, inst:null, ibuf:ib};
  mesh.inst=new Float32Array(mesh.cap*12);
  const instBuf=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,instBuf);
  gl.bufferData(gl.ARRAY_BUFFER,mesh.inst.byteLength,gl.DYNAMIC_DRAW);
  for(let k=0;k<3;k++){
    gl.enableVertexAttribArray(6+k);
    gl.vertexAttribPointer(6+k,4,gl.FLOAT,false,48,k*16);
    gl.vertexAttribDivisor(6+k,1);
  }
  gl.bindVertexArray(null);
  mesh.ibuf=instBuf;
  MESHES[mb.name||'x']=mesh;
  return mesh;
}
const MESHES={};
function pushInst(m,x,y,z,yaw,sx,sy,sz,alpha,r,g,b,flash){
  if(m.n>=m.cap){ const nc=m.cap*2, ni=new Float32Array(nc*12); ni.set(m.inst); m.inst=ni; m.cap=nc; }
  const o=m.n*12, d=m.inst;
  d[o]=x; d[o+1]=y; d[o+2]=z; d[o+3]=yaw;
  d[o+4]=sx; d[o+5]=sy; d[o+6]=sz; d[o+7]=alpha;
  d[o+8]=r; d[o+9]=g; d[o+10]=b; d[o+11]=flash||0;
  m.n++;
}
function drawMesh(m,mode,texMode,texture){
  if(!m.n) return;
  gl.uniform1f(U.uMode,mode||0); gl.uniform1f(U.uTexMode,texMode||0);
  if(texMode){ gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, texture||tex||0); }
  gl.bindVertexArray(m.vao);
  gl.bindBuffer(gl.ARRAY_BUFFER,m.ibuf);
  if(m.n>m.gcap){ m.gcap=m.n; gl.bufferData(gl.ARRAY_BUFFER,m.n*12*4,gl.DYNAMIC_DRAW); }
  gl.bufferSubData(gl.ARRAY_BUFFER,0,m.inst.subarray(0,m.n*12));
  gl.drawElementsInstanced(gl.TRIANGLES,m.count,gl.UNSIGNED_INT,0,m.n);
  m.n=0;
}
let tex=null;
function makeRadialTex(){
  const N=64, cv=document.createElement('canvas'); cv.width=cv.height=N;
  const c=cv.getContext('2d'), g=c.createRadialGradient(N/2,N/2,0,N/2,N/2,N/2);
  g.addColorStop(0,'rgba(0,0,0,0.55)'); g.addColorStop(0.55,'rgba(0,0,0,0.34)');
  g.addColorStop(1,'rgba(0,0,0,0)');
  c.fillStyle=g; c.fillRect(0,0,N,N);
  const t=gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D,t);
  gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,gl.RGBA,gl.UNSIGNED_BYTE,cv);
  gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);
  return t;
}
/* fog texture (R8, 80x80) */
let fogTex=null, fogData=null;
function makeFogTex(){
  fogData=new Uint8Array(PN*PN);
  fogTex=gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D,fogTex);
  gl.texImage2D(gl.TEXTURE_2D,0,gl.R8,PN,PN,0,gl.RED,gl.UNSIGNED_BYTE,fogData);
  gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);
}
function uploadFog(){
  gl.bindTexture(gl.TEXTURE_2D,fogTex);
  gl.texSubImage2D(gl.TEXTURE_2D,0,0,0,PN,PN,gl.RED,gl.UNSIGNED_BYTE,fogData);
}
/* ================================================================
   PROCEDURAL MESH LIBRARY
   ================================================================ */
const COL = {
  hull:[0.30,0.34,0.38], dark:[0.13,0.15,0.18], trim:[0.52,0.57,0.62],
  glass:[0.10,0.35,0.45], white:[1,1,1], rock:[0.21,0.19,0.20], rock2:[0.27,0.24,0.23],
  crystal:[0.36,0.92,1.0], crystal2:[0.75,0.55,1.0], ground1:[0.13,0.16,0.15],
  ground2:[0.17,0.15,0.13], ground3:[0.10,0.14,0.17]
};
function M(name, fn){ const mb=new MB(); mb.name=name; fn(mb); return buildMesh(mb); }

function meshWorker(mb){
  const h=COL.hull,d=COL.dark,t=COL.trim;
  mb.box(-0.20,0.35,0,0.24,0.70,0.28,d,0,0); mb.box(0.20,0.35,0,0.24,0.70,0.28,d,0,0);
  mb.box(-0.20,0.06,0.06,0.28,0.12,0.42,t,0,0); mb.box(0.20,0.06,0.06,0.28,0.12,0.42,t,0,0);
  mb.box(0,1.02,0,0.72,0.72,0.52,h,0,0);
  mb.box(0,1.14,0.27,0.42,0.30,0.06,COL.white,1,0.55);
  mb.box(0,1.50,0.03,0.42,0.34,0.40,t,0,0);
  mb.box(0,1.50,0.25,0.32,0.13,0.05,COL.white,1,1.0);
  mb.box(0,1.05,-0.33,0.50,0.52,0.26,d,0,0);
  mb.box(0,1.18,-0.44,0.34,0.14,0.10,COL.white,1,0.8);
  mb.box(-0.44,1.02,0.04,0.16,0.54,0.18,h,0,0);
  mb.box(0.44,1.00,0.12,0.16,0.16,0.74,t,0,0,0.30);
  mb.box(0.50,1.00,0.52,0.20,0.20,0.22,COL.white,1,0.7,0.30);
}
function meshMelee(mb){
  const h=COL.hull,d=COL.dark,t=COL.trim;
  mb.box(-0.30,0.26,0.34,0.22,0.52,0.26,d,0,0); mb.box(0.30,0.26,0.34,0.22,0.52,0.26,d,0,0);
  mb.box(-0.30,0.26,-0.34,0.22,0.52,0.26,d,0,0); mb.box(0.30,0.26,-0.34,0.22,0.52,0.26,d,0,0);
  mb.box(0,0.78,0,0.86,0.52,1.16,h,0,0);
  mb.box(0,1.06,0,0.60,0.10,0.86,COL.white,1,0.6);
  mb.box(0,1.00,0.52,0.46,0.36,0.42,t,0,0);
  mb.box(0,1.02,0.75,0.32,0.11,0.05,COL.white,1,1.0);
  mb.box(0.50,1.02,-0.10,0.32,0.32,0.54,h,0,0); mb.box(-0.50,1.02,-0.10,0.32,0.32,0.54,h,0,0);
  mb.box(0.60,0.74,0.40,0.10,0.16,1.06,t,0,0.25,0.34);
  mb.box(-0.60,0.74,0.40,0.10,0.16,1.06,t,0,0.25,-0.34);
  mb.box(0.60,0.74,0.92,0.12,0.20,0.16,COL.white,1,0.9,0.34);
  mb.box(-0.60,0.74,0.92,0.12,0.20,0.16,COL.white,1,0.9,-0.34);
}
function meshRanged(mb){
  const h=COL.hull,d=COL.dark,t=COL.trim;
  mb.box(-0.17,0.35,0,0.20,0.70,0.24,d,0,0); mb.box(0.17,0.35,0,0.20,0.70,0.24,d,0,0);
  mb.box(-0.17,0.05,0.06,0.24,0.11,0.40,t,0,0); mb.box(0.17,0.05,0.06,0.24,0.11,0.40,t,0,0);
  mb.box(0,1.02,0,0.56,0.66,0.42,h,0,0);
  mb.box(0,1.10,0.22,0.34,0.26,0.06,COL.white,1,0.5);
  mb.box(-0.37,1.22,0,0.22,0.20,0.44,t,0,0); mb.box(0.37,1.22,0,0.22,0.20,0.44,t,0,0);
  mb.box(0,1.48,0.02,0.34,0.30,0.34,t,0,0);
  mb.box(0,1.52,0.20,0.24,0.10,0.05,COL.white,1,1.0);
  mb.box(0,1.68,-0.06,0.10,0.24,0.32,COL.white,1,0.75);
  mb.box(0,1.06,-0.28,0.40,0.42,0.20,h,0,0);
  mb.box(0.30,1.12,0.46,0.14,0.16,1.46,d,0,0);
  mb.box(0.30,1.12,1.20,0.11,0.11,0.24,COL.white,1,1.0);
  mb.box(-0.30,1.12,0.30,0.12,0.14,0.70,d,0,0);
}
function meshHeavy(mb){
  const h=COL.hull,d=COL.dark,t=COL.trim;
  mb.box(-0.95,0.42,0,0.42,0.84,3.00,d,0,0);
  mb.box(0.95,0.42,0,0.42,0.84,3.00,d,0,0);
  for(let i=-2;i<=2;i++){ mb.box(-0.95,0.42,i*0.55,0.46,0.88,0.24,t,0,0); mb.box(0.95,0.42,i*0.55,0.46,0.88,0.24,t,0,0); }
  mb.box(0,0.90,0,1.74,0.72,2.86,h,0,0);
  mb.box(0,1.10,1.30,1.64,0.44,0.50,t,0,0,0);
  mb.box(0,1.52,-0.15,1.26,0.62,1.50,h,0,0);
  mb.box(0,1.86,-0.15,0.84,0.08,0.92,COL.white,1,0.55);
  mb.box(0,1.56,1.24,0.26,0.26,1.74,d,0,0);
  mb.box(0,1.56,2.14,0.36,0.36,0.22,t,0,0);
  mb.box(-0.92,1.34,-0.70,0.26,0.32,0.86,t,0,0); mb.box(0.92,1.34,-0.70,0.26,0.32,0.86,t,0,0);
  mb.box(0,1.52,-1.00,0.70,0.34,0.24,d,0,0);
  mb.box(-0.60,1.20,1.42,0.20,0.16,0.14,COL.white,1,0.9);
  mb.box(0.60,1.20,1.42,0.20,0.16,0.14,COL.white,1,0.9);
}
function meshAir(mb){
  const h=COL.hull,d=COL.dark,t=COL.trim;
  mb.box(0,0,0,0.72,0.46,2.40,h,0,0);
  mb.box(0,-0.02,1.36,0.52,0.32,0.60,t,0,0);
  mb.box(0,0.26,0.72,0.42,0.26,0.74,COL.glass,1,0.45);
  mb.box(1.28,0.05,0,1.90,0.14,1.06,h,0,0); mb.box(-1.28,0.05,0,1.90,0.14,1.06,h,0,0);
  mb.box(2.16,0.0,0.24,0.32,0.32,0.94,d,0,0); mb.box(-2.16,0.0,0.24,0.32,0.32,0.94,d,0,0);
  mb.box(2.16,0.0,0.74,0.24,0.24,0.16,COL.white,1,1.0); mb.box(-2.16,0.0,0.74,0.24,0.24,0.16,COL.white,1,1.0);
  mb.box(0,0.52,-1.02,0.12,0.86,0.72,t,0,0);
  mb.box(0,0.12,-1.14,1.34,0.10,0.52,h,0,0);
  mb.box(0.42,-0.04,-1.16,0.26,0.26,0.56,d,0,0); mb.box(-0.42,-0.04,-1.16,0.26,0.26,0.56,d,0,0);
  mb.box(0.42,-0.04,-1.48,0.30,0.30,0.14,COL.white,1,1.0);
  mb.box(-0.42,-0.04,-1.48,0.30,0.30,0.14,COL.white,1,1.0);
  mb.box(0,-0.24,0.10,0.50,0.10,1.10,d,0,0);
}
function meshHQ(mb){
  const h=COL.hull,d=COL.dark,t=COL.trim;
  mb.box(0,0.5,0,8,1.0,8,d,0,0);
  mb.box(0,1.5,0,6.6,1.0,6.6,h,0,0);
  mb.box(0,2.4,0,4.8,0.9,4.8,h,0,0);
  mb.cyl(0,2.8,0,1.5,3.2,8,t,0,0);
  mb.box(0,4.4,0,1.15,2.4,1.15,COL.white,1,1.0);
  mb.cyl(0,6.9,0,0.9,0.3,8,d,0,0);
  [[3.3,3.3],[-3.3,3.3],[3.3,-3.3],[-3.3,-3.3]].forEach(p=>{
    mb.box(p[0],1.7,p[1],0.8,2.6,0.8,t,0,0);
    mb.box(p[0],3.1,p[1],0.5,0.3,0.5,COL.white,1,0.9);
  });
  mb.box(0,1.06,3.5,5.2,0.08,1.8,COL.white,1,0.55);
  mb.box(0,1.06,-3.5,5.2,0.08,1.8,COL.white,1,0.55);
  mb.box(0,1.55,3.35,2.4,1.1,0.2,d,0,0);
}
function meshRelay(mb){
  const h=COL.hull,d=COL.dark,t=COL.trim;
  mb.cyl(0,0.16,0,1.55,0.36,8,d,0,0);
  mb.cyl(0,0.4,0,0.58,3.0,6,h,0,0);
  for(let i=0;i<3;i++){ const a=i/3*Math.PI*2;
    mb.box(Math.cos(a)*1.0,1.1,Math.sin(a)*1.0,0.22,1.9,0.22,t,0,0,a); }
  mb.cyl(0,2.5,0,1.05,0.28,8,t,0,0);
  mb.cyl(0,3.4,0,0.34,1.0,6,h,0,0);
  mb.box(0,4.6,0,0.66,1.1,0.66,COL.white,1,1.0);
  mb.cyl(0,5.7,0,0.5,0.16,6,COL.white,1,1.0);
}
function meshBarracks(mb){
  const h=COL.hull,d=COL.dark,t=COL.trim;
  mb.box(0,1.5,0,6,3.0,5,h,0,0);
  mb.box(0,3.12,0,6.4,0.30,5.4,d,0,0);
  mb.box(0,3.34,0,4.2,0.16,3.2,COL.white,1,0.55);
  mb.box(0,1.2,2.56,2.2,2.4,0.20,d,0,0);
  mb.box(0,1.5,2.62,1.5,0.7,0.10,COL.white,1,1.0);
  mb.box(-2.4,1.7,2.56,0.7,1.4,0.14,d,0,0); mb.box(2.4,1.7,2.56,0.7,1.4,0.14,d,0,0);
  mb.box(0,1.5,-2.56,3.4,1.6,0.14,t,0,0);
  mb.box(-3.06,1.5,0,0.14,1.6,3.0,t,0,0); mb.box(3.06,1.5,0,0.14,1.6,3.0,t,0,0);
  mb.box(-2.6,3.7,-1.6,0.26,1.0,0.26,t,0,0); mb.box(2.6,3.7,-1.6,0.26,1.0,0.26,t,0,0);
  mb.box(-2.6,4.3,-1.6,0.5,0.2,0.5,COL.white,1,0.9);
  mb.box(2.6,4.3,-1.6,0.5,0.2,0.5,COL.white,1,0.9);
}
function meshLab(mb){
  const h=COL.hull,d=COL.dark,t=COL.trim;
  mb.cyl(0,0.24,0,2.85,0.5,6,d,0,0);
  mb.cyl(0,0.6,0,2.45,2.6,6,h,0,0);
  mb.cyl(0,2.05,0,2.55,0.26,6,COL.white,1,1.0);
  mb.cyl(0,3.2,0,1.7,1.1,6,t,0,0,0.7);
  mb.cyl(0,4.3,0,0.12,1.5,4,t,0,0);
  mb.box(0,5.9,0,0.4,0.4,0.4,COL.white,1,1.0);
  for(let i=0;i<6;i++){ const a=i/6*Math.PI*2;
    mb.box(Math.cos(a)*2.4,1.7,Math.sin(a)*2.4,0.5,1.4,0.14,d,0,0,a+Math.PI/2); }
  mb.box(0,1.2,2.5,1.4,1.6,0.2,d,0,0);
  mb.box(0,1.3,2.58,0.9,0.5,0.1,COL.white,1,1.0);
}
function meshFactory(mb){
  const h=COL.hull,d=COL.dark,t=COL.trim;
  mb.box(0,1.7,0,6.4,3.4,5.4,h,0,0);
  for(let i=-1;i<=1;i++) mb.box(i*2.0,3.6,0,1.6,0.5,5.0,t,0,0);
  mb.box(0,3.45,2.4,5.6,0.3,0.6,d,0,0);
  mb.cyl(-2.2,3.4,-1.6,0.5,2.4,6,d,0,0); mb.cyl(2.2,3.4,-1.6,0.5,2.4,6,d,0,0);
  mb.cyl(-2.2,5.6,-1.6,0.56,0.2,6,COL.white,1,1.0); mb.cyl(2.2,5.6,-1.6,0.56,0.2,6,COL.white,1,1.0);
  mb.box(0,1.5,2.76,3.4,2.8,0.2,d,0,0);
  mb.box(0,2.6,2.82,2.6,0.4,0.1,COL.white,1,1.0);
  mb.box(0,3.9,0,3.0,0.16,2.0,COL.white,1,0.55);
  mb.box(-3.26,1.7,0,0.14,2.0,3.0,t,0,0); mb.box(3.26,1.7,0,0.14,2.0,3.0,t,0,0);
}
function meshAirfield(mb){
  const h=COL.hull,d=COL.dark,t=COL.trim;
  mb.box(0,0.3,0,6.4,0.6,6.4,d,0,0);
  mb.box(0,0.64,0,0.36,0.08,5.4,COL.white,1,0.7);
  mb.box(0,0.64,0,5.4,0.08,0.36,COL.white,1,0.7);
  mb.box(0,0.64,2.4,3.0,0.08,0.30,COL.white,1,0.7);
  mb.box(0,0.64,-2.4,3.0,0.08,0.30,COL.white,1,0.7);
  mb.box(2.3,1.7,-2.2,1.3,3.4,1.3,h,0,0);
  mb.box(2.3,3.1,-2.2,1.5,0.8,1.5,COL.white,1,1.0);
  mb.cyl(2.3,3.9,-2.2,0.1,1.0,4,t,0,0);
  mb.box(-2.2,0.9,1.0,0.5,0.6,3.6,t,0,0); mb.box(-1.1,0.9,1.0,0.5,0.6,3.6,t,0,0);
  mb.box(-1.65,1.25,2.6,1.8,0.16,0.5,COL.white,1,0.8);
  mb.box(0,0.66,-3.0,5.6,0.1,0.14,COL.white,1,0.5);
}
function meshTurret(mb){
  const h=COL.hull,d=COL.dark,t=COL.trim;
  mb.cyl(0,0.28,0,1.7,0.56,8,d,0,0);
  mb.cyl(0,0.56,0,1.0,0.7,6,h,0,0);
  mb.box(0,1.5,0,1.34,0.84,1.34,h,0,0);
  mb.box(0,1.98,0,0.66,0.14,0.66,COL.white,1,1.0);
  mb.box(0,1.5,0.86,0.74,0.26,1.56,d,0,0);
  mb.box(0,1.5,1.66,0.86,0.34,0.24,t,0,0);
  mb.box(-0.5,1.5,0.3,0.2,0.5,0.7,t,0,0); mb.box(0.5,1.5,0.3,0.2,0.5,0.7,t,0,0);
}
function meshCrystal(mb){
  const c=COL.crystal, c2=COL.crystal2;
  mb.cyl(0,0,0,1.7,0.34,7,COL.rock,0,0);
  mb.pyr(0,0.2,0,1.25,1.25,2.9,c,0,0.7,0.2);
  mb.pyr(0.75,0.2,0.45,0.85,0.85,1.7,c2,0,0.6,0.7);
  mb.pyr(-0.65,0.2,-0.35,0.95,0.95,2.1,c,0,0.6,-0.5);
  mb.pyr(-0.2,0.2,0.85,0.7,0.7,1.3,c2,0,0.55,1.1);
}
function meshRock(mb,n){
  const r=COL.rock, r2=COL.rock2;
  mb.pyr(0,0,0,3.6,3.2,2.6,r,0,0,n*1.1);
  mb.box(1.0,0.5,0.7,2.0,1.2,1.9,r2,0,0,n*0.7);
  mb.box(-1.2,0.4,-0.9,1.7,1.0,1.6,r,0,0,-n*0.5);
  if(n===1) mb.pyr(1.4,0,-1.0,1.8,1.6,1.7,r2,0,0,0.9);
  if(n===2){ mb.pyr(-1.4,0,1.2,1.5,1.5,2.2,r2,0,0,0.3); mb.box(0.2,0.9,-1.4,1.4,1.4,1.4,r,0,0,0.6); }
}
function meshBolt(mb){ mb.box(0,0,0,0.30,0.30,0.90,[1,1,1],0,1.0); }
function meshSpark(mb){ mb.box(0,0,0,0.34,0.34,0.34,[1,1,1],0,1.0); }
function meshShadow(mb){
  mb.quad([-1,0.02,-1],[1,0.02,-1],[1,0.02,1],[-1,0.02,1],[0,1,0],COL.dark,0,0,
    [[0,0],[1,0],[1,1],[0,1]]);
}
function buildAllMeshes(){
  M('worker',meshWorker); M('melee',meshMelee); M('ranged',meshRanged);
  M('heavy',meshHeavy);   M('air',meshAir);
  M('hq',meshHQ); M('relay',meshRelay); M('barracks',meshBarracks);
  M('lab',meshLab); M('factory',meshFactory); M('airfield',meshAirfield);
  M('turret',meshTurret); M('crystal',meshCrystal);
  M('rock0',mb=>meshRock(mb,0)); M('rock1',mb=>meshRock(mb,1)); M('rock2',mb=>meshRock(mb,2));
  M('bolt',meshBolt); M('spark',meshSpark); M('shadow',meshShadow);
}
/* ================================================================
   MAP / TERRAIN / FOG
   ================================================================ */
let fine=null, coarse=null, groundMesh=null, rocks=[];
let spawn=[{x:56,z:56},{x:264,z:264}];
const fI=(x,z)=>z*FN+x;
const cI=(x,z)=>z*PN+x;
function fCellX(x){ return clamp(Math.floor(x/FCELL),0,FN-1); }
function cCellX(x){ return clamp(Math.floor(x/CELL),0,PN-1); }
function blockedF(x,z){ if(x<0||z<0||x>=FN||z>=FN) return 1; return fine[fI(x,z)]; }
function blockedW(x,z){ return blockedF(fCellX(x),fCellX(z)); }
function blockedC(x,z){ if(x<0||z<0||x>=PN||z>=PN) return 1; return coarse[cI(x,z)]; }
function rebuildCoarse(){
  for(let z=0;z<PN;z++) for(let x=0;x<PN;x++){
    const fx=x*2, fz=z*2;
    coarse[cI(x,z)] = (fine[fI(fx,fz)]||fine[fI(fx+1,fz)]||fine[fI(fx,fz+1)]||fine[fI(fx+1,fz+1)])?1:0;
  }
}
function markDisc(fx,fz,rCells,val){
  const r2=rCells*rCells;
  for(let z=Math.max(0,fz-rCells);z<=Math.min(FN-1,fz+rCells);z++)
    for(let x=Math.max(0,fx-rCells);x<=Math.min(FN-1,fx+rCells);x++){
      const dx=x-fx,dz=z-fz; if(dx*dx+dz*dz<=r2) fine[fI(x,z)]=val;
    }
}

function terrainNoise(x,z){
  return Math.sin(x*0.055)*Math.cos(z*0.048)
       + 0.55*Math.sin(x*0.121+1.7)*Math.cos(z*0.104-0.4)
       + 0.30*Math.sin((x+z)*0.19+2.2)
       + 0.18*Math.cos((x-z)*0.31);
}
function buildGround(){
  const mb=new MB(); mb.name='ground';
  const N=MW/4, step=MW/N;
  for(let z=0;z<=N;z++) for(let x=0;x<=N;x++){
    const wx=x*step, wz=z*step;
    const n=terrainNoise(wx,wz);
    let c;
    if(n<-0.35) c=COL.ground3; else if(n>0.55) c=COL.ground2; else c=COL.ground1;
    const t=clamp((n+1.1)/2.2,0,1);
    const col=[c[0]*(0.72+0.5*t), c[1]*(0.72+0.5*t), c[2]*(0.72+0.5*t)];
    mb.vert(wx,0,wz,0,1,0,col,0,0,wx/8,wz/8);
  }
  for(let z=0;z<N;z++) for(let x=0;x<N;x++){
    const a=z*(N+1)+x, b=a+1, c=a+N+1, d=c+1;
    mb.i.push(a,c,b, b,c,d);
  }
  return buildMesh(mb);
}
function genMap(seed){
  RNG=mulberry(seed);
  fine=new Uint8Array(FN*FN); coarse=new Uint8Array(PN*PN); rocks=[];
  // border
  for(let z=0;z<FN;z++) for(let x=0;x<FN;x++)
    if(x<3||z<3||x>=FN-3||z>=FN-3) fine[fI(x,z)]=1;
  // central ridge with three chokepoints
  const gaps=[[160,160],[104,216],[216,104]];
  const nearGap=(x,z,g)=>dist(x,z,g[0],g[1])<17;
  for(let t=0;t<=1.0001;t+=0.035){
    const x=60+t*200, z=260-t*200;
    if(gaps.some(g=>nearGap(x,z,g))) continue;
    const rr=6+RNG()*4;
    markDisc(fCellX(x+(RNG()-0.5)*8), fCellX(z+(RNG()-0.5)*8), Math.round(rr/FCELL), 1);
    rocks.push({x,z,r:rr,v:(RNG()*3)|0});
  }
  // scattered clusters
  for(let i=0;i<30;i++){
    const x=30+RNG()*(MW-60), z=30+RNG()*(MW-60);
    if(dist(x,z,spawn[0].x,spawn[0].z)<34) continue;
    if(dist(x,z,spawn[1].x,spawn[1].z)<34) continue;
    if(nearGap(x,z,gaps[0])||nearGap(x,z,gaps[1])||nearGap(x,z,gaps[2])) continue;
    const rr=5+RNG()*7;
    markDisc(fCellX(x),fCellX(z),Math.round(rr/FCELL),1);
    rocks.push({x,z,r:rr,v:(RNG()*3)|0});
  }
  // clear spawn bowls
  markDisc(fCellX(spawn[0].x),fCellX(spawn[0].z),14,0);
  markDisc(fCellX(spawn[1].x),fCellX(spawn[1].z),14,0);
  // resource nodes
  const spots=[[62,66],[74,50],[48,78],[258,254],[246,270],[272,242],
               [112,92],[92,112],[208,228],[228,208],
               [158,138],[138,158],[40,242],[240,38],
               [164,292],[156,28],[28,164],[292,156]];
  nodes=[];
  spots.forEach((s,i)=>{
    const x=s[0],z=s[1];
    markDisc(fCellX(x),fCellX(z),Math.round(3.4/FCELL),0);
    nodes.push({id:1000+i,kind:'node',x,z,amount:4000,max:4000,seed:RNG()*6.28});
  });
  // drop rocks that ended up on cleared ground (spawn bowls / node sites)
  rocks=rocks.filter(r=>blockedW(r.x,r.z));
  rebuildCoarse();
  // connectivity check between spawns, carve if needed
  if(!pathExists(spawn[0].x,spawn[0].z,spawn[1].x,spawn[1].z)){
    let x=spawn[0].x,z=spawn[0].z;
    const dx=spawn[1].x-x, dz=spawn[1].z-z, L=Math.hypot(dx,dz), steps=Math.ceil(L/2);
    for(let i=0;i<=steps;i++){
      const px=x+dx*i/steps, pz=z+dz*i/steps;
      markDisc(fCellX(px),fCellX(pz),2,0);
    }
    rebuildCoarse();
  }
  groundMesh=buildGround();
}
function pathExists(ax,az,bx,bz){
  const S=cI(cCellX(ax),cCellX(az)), T=cI(cCellX(bx),cCellX(bz));
  const seen=new Uint8Array(PN*PN); const q=[S]; seen[S]=1;
  while(q.length){ const c=q.pop(); if(c===T) return true;
    const cx=c%PN, cz=(c/PN)|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>=PN||nz>=PN) continue;
      const ni=cI(nx,nz); if(seen[ni]||coarse[ni]) continue;
      seen[ni]=1; q.push(ni);
    } }
  return false;
}

/* ---------------- A* on coarse grid ---------------- */
const heapF=new Float32Array(PN*PN*4+16), heapN=new Int32Array(PN*PN*4+16);
let heapLen=0;
const gScore=new Float32Array(PN*PN), cameFrom=new Int32Array(PN*PN);
const stamp=new Int32Array(PN*PN), closedStamp=new Int32Array(PN*PN);
let curStamp=0;
function hPush(n,f){ let i=heapLen++; heapF[i]=f; heapN[i]=n;
  while(i>0){ const p=(i-1)>>1; if(heapF[p]<=heapF[i]) break;
    const tf=heapF[p],tn=heapN[p]; heapF[p]=heapF[i]; heapN[p]=heapN[i];
    heapF[i]=tf; heapN[i]=tn; i=p; } }
function hPop(){ const top=heapN[0], tf=heapF[0]; heapLen--;
  if(heapLen>0){ heapF[0]=heapF[heapLen]; heapN[0]=heapN[heapLen];
    let i=0; for(;;){ const l=i*2+1,r=l+1; let s=i;
      if(l<heapLen&&heapF[l]<heapF[s]) s=l;
      if(r<heapLen&&heapF[r]<heapF[s]) s=r;
      if(s===i) break; const tf2=heapF[i],tn2=heapN[i];
      heapF[i]=heapF[s]; heapN[i]=heapN[s]; heapF[s]=tf2; heapN[s]=tn2; i=s; } }
  return top; }
function nearestFreeC(cx,cz){
  if(!blockedC(cx,cz)) return cI(cx,cz);
  for(let r=1;r<10;r++) for(let dz=-r;dz<=r;dz++) for(let dx=-r;dx<=r;dx++){
    if(Math.max(Math.abs(dx),Math.abs(dz))!==r) continue;
    const nx=cx+dx,nz=cz+dz; if(nx<0||nz<0||nx>=PN||nz>=PN) continue;
    if(!blockedC(nx,nz)) return cI(nx,nz);
  }
  return -1;
}
function lineClear(ax,az,bx,bz){
  const L=dist(ax,az,bx,bz), steps=Math.max(1,Math.ceil(L/1.6));
  for(let i=1;i<steps;i++){ const t=i/steps;
    if(blockedW(ax+(bx-ax)*t, az+(bz-az)*t)) return false; }
  return true;
}
const ADX=[1,-1,0,0, 1,1,-1,-1], ADZ=[0,0,1,-1, 1,-1,1,-1];
function findPath(sx,sz,tx,tz){
  const S0=cI(cCellX(sx),cCellX(sz)), T0=cI(cCellX(tx),cCellX(tz));
  const S=nearestFreeC(S0%PN,(S0/PN)|0), T=nearestFreeC(T0%PN,(T0/PN)|0);
  if(S<0||T<0) return null;
  if(S===T) return [{x:tx,z:tz}];
  curStamp++; heapLen=0;
  const tcx=T%PN, tcz=(T/PN)|0;
  const h=i=>{ const cx=i%PN, cz=(i/PN)|0, dx=Math.abs(cx-tcx), dz=Math.abs(cz-tcz);
    return (dx+dz)+(1.4142135-2)*Math.min(dx,dz); };
  stamp[S]=curStamp; gScore[S]=0; cameFrom[S]=-1; hPush(S,h(S));
  let found=-1, iter=0;
  while(heapLen && iter++<12000){
    const cur=hPop();
    if(closedStamp[cur]===curStamp) continue;
    closedStamp[cur]=curStamp;
    if(cur===T){ found=cur; break; }
    const cx=cur%PN, cz=(cur/PN)|0, gc=gScore[cur];
    for(let k=0;k<8;k++){
      const nx=cx+ADX[k], nz=cz+ADZ[k];
      if(nx<0||nz<0||nx>=PN||nz>=PN) continue;
      const ni=cI(nx,nz); if(coarse[ni]) continue;
      const diag=(k>=4);
      if(diag){ if(coarse[cI(cx,nz)]||coarse[cI(nx,cz)]) continue; }
      const ng=gc+(diag?1.4142135:1);
      if(stamp[ni]!==curStamp || ng<gScore[ni]){
        stamp[ni]=curStamp; gScore[ni]=ng; cameFrom[ni]=cur;
        hPush(ni,ng+h(ni));
      }
    }
  }
  if(found<0) return null;
  const raw=[]; let c=found;
  while(c>=0){ raw.push(c); c=cameFrom[c]; }
  raw.reverse();
  const pts=raw.map(i=>({x:(i%PN+0.5)*CELL, z:((i/PN)|0+0.5)*CELL}));
  pts[pts.length-1]={x:tx,z:tz};
  // line-of-sight smoothing
  if(pts.length>2){ const out=[pts[0]]; let i=0;
    while(i<pts.length-1){ let j=pts.length-1;
      for(;j>i+1;j--) if(lineClear(pts[i].x,pts[i].z,pts[j].x,pts[j].z)) break;
      out.push(pts[j]); i=j; }
    return out;
  }
  return pts;
}

/* ---------------- fog of war ---------------- */
function updateVision(t){
  const T=teams[t]; T.vision.fill(0);
  const mark=(x,z,r)=>{ const c0=cCellX(x-r), c1=cCellX(x+r), z0=cCellX(z-r), z1=cCellX(z+r);
    const r2=r*r;
    for(let cz=z0;cz<=z1;cz++) for(let cx=c0;cx<=c1;cx++){
      const dx=(cx+0.5)*CELL-x, dz=(cz+0.5)*CELL-z;
      if(dx*dx+dz*dz<=r2){ const i=cI(cx,cz); T.vision[i]=1; T.explored[i]=1; } } };
  for(const u of units) if(u.team===t) mark(u.x,u.z,UNITS[u.kind].sight);
  for(const b of buildings) if(b.team===t) mark(b.x,b.z,BUILDS[b.kind].sight);
}
function buildFogTexture(){
  const T=teams[0];
  for(let i=0;i<PN*PN;i++) fogData[i]=T.vision[i]?255:(T.explored[i]?128:0);
  uploadFog();
}
function visibleTo(t,x,z){
  const i=cI(cCellX(x),cCellX(z));
  return teams[t].vision[i]===1;
}
function exploredBy(t,x,z){
  const i=cI(cCellX(x),cCellX(z));
  return teams[t].explored[i]===1;
}

/* ================================================================
   ENTITIES
   ================================================================ */
let idSeq=1;
function hpMul(t){ return teams[t].upgrades.alloys?1.15:1; }
function dmgMul(t){ return teams[t].upgrades.weapons?1.20:1; }
function spdMul(t){ return teams[t].upgrades.servos?1.15:1; }
function mineMul(t){ return teams[t].upgrades.mining?1.35:1; }
function armorMul(t){ return teams[t].upgrades.plating?0.85:1; }

function bCells(x,z,size){
  const x0=clamp(Math.floor((x-size/2)/FCELL),0,FN-1), x1=clamp(Math.ceil((x+size/2)/FCELL)-1,0,FN-1);
  const z0=clamp(Math.floor((z-size/2)/FCELL),0,FN-1), z1=clamp(Math.ceil((z+size/2)/FCELL)-1,0,FN-1);
  return [x0,z0,x1,z1];
}
function markBox(x,z,size,val){
  const c=bCells(x,z,size);
  for(let zz=c[1];zz<=c[3];zz++) for(let xx=c[0];xx<=c[2];xx++) fine[fI(xx,zz)]=val;
}
function boxFree(x,z,size){
  const c=bCells(x,z,size);
  for(let zz=c[1];zz<=c[3];zz++) for(let xx=c[0];xx<=c[2];xx++)
    if(fine[fI(xx,zz)]) return false;
  return true;
}

function spawnUnit(kind,team,x,z){
  const d=UNITS[kind];
  const u={ id:idSeq++, kind, team, isUnit:true, x, z, y:d.fly?d.fly:0, fly:d.fly||0,
    yaw:team===TEAM.P?0:Math.PI,
    hp:d.hp*hpMul(team), maxHp:d.hp*hpMul(team), speed:d.speed*(d.fly?1:spdMul(team)),
    cd:0, targetId:0, order:'idle', path:null, pathI:0, gx:x, gz:z,
    atkMove:false, hold:false, flash:0, anim:Math.random()*10,
    carry:0, gstate:0, nodeId:0, stuck:0, lastProg:0, stuckT:0, pathReq:null, dead:false,
    autoGather:kind==='worker', harv:0 };
  units.push(u); byId.set(u.id,u); teams[team].supplyUsed+=d.supply; return u;
}
function spawnBuilding(kind,team,x,z,complete){
  const d=BUILDS[kind];
  const b={ id:idSeq++, kind, team, x, z, yaw:0, hp:complete?d.hp:1,
    maxHp:d.hp, prog:complete?1:0, done:!!complete, queue:[], rally:null,
    cd:0, targetId:0, flash:0, builder:0, dead:false, built:complete?d.time:0 };
  buildings.push(b); byId.set(b.id,b);
  markBox(x,z,d.size,1);
  rebuildCoarse();
  rebuildBHash();
  recalcSupply(team);
  return b;
}
function removeBuilding(b){
  b.dead=true;
  const d=BUILDS[b.kind];
  markBox(b.x,b.z,d.size,0);
  // restore cells still occupied by other buildings / rock / nodes
  for(const o of buildings) if(!o.dead) markBox(o.x,o.z,BUILDS[o.kind].size,1);
  for(const n of nodes) markDisc(fCellX(n.x),fCellX(n.z),2,0);
  for(const r of rocks) markDisc(fCellX(r.x),fCellX(r.z),Math.max(1,Math.round(r.r/FCELL)),1);
  for(let z=0;z<FN;z++) for(let x=0;x<FN;x++)
    if(x<3||z<3||x>=FN-3||z>=FN-3) fine[fI(x,z)]=1;
  rebuildCoarse();
  const idx=buildings.indexOf(b);
  if(idx>=0) buildings.splice(idx,1);
  rebuildBHash();
  recalcSupply(b.team);
  byId.delete(b.id);
}
function recalcSupply(t){
  let cap=0;
  for(const b of buildings) if(b.team===t && b.done) cap+=BUILDS[b.kind].supply||0;
  teams[t].supplyCap=cap;
}
function countHQ(t){ let n=0; for(const b of buildings) if(b.team===t&&b.kind==='hq'&&!b.dead) n++; return n; }

function damage(target,amount,dtype,srcTeam){
  if(!target||target.dead) return;
  const m=DMG[dtype]||DMG.kinetic;
  let dmg=amount*(m[target.armor]!==undefined?m[target.armor]:1)*armorMul(target.team);
  target.hp-=dmg; target.flash=Math.min(1,target.flash+0.5);
  target.underAttackT=6;
  if(target.team===TEAM.P){
    if(target.isUnit){ if(G.time-alertThrottle.unit>12){ alertThrottle.unit=G.time;
        SND.play('alert'); pushToast('Our forces are under attack','warn'); } }
    else alertPlayer(target);
  }
  if(target.hp<=0) killEntity(target,srcTeam);
}
let alertThrottle={unit:0, base:0};
function alertPlayer(b){
  const now=G.time;
  if(now-alertThrottle.base<8) return;
  alertThrottle.base=now;
  SND.play('alert');
  pushToast('BASE UNDER ATTACK — '+BUILDS[b.kind].name,'bad');
  pings.push({x:b.x,z:b.z,t:now});
}
function killEntity(e,srcTeam){
  if(e.dead) return;
  e.dead=true;
  if(e.isUnit){
    const d=UNITS[e.kind];
    spawnExplosion(e.x,e.y+0.6,e.z,e.fly?1.6:1.1,[0.9,0.6,0.3]);
    const idx=units.indexOf(e);
    if(idx>=0) units.splice(idx,1);
    byId.delete(e.id);
    teams[e.team].supplyUsed-=d.supply;
    if(srcTeam===TEAM.P&&e.team===TEAM.E) G.stats.kills++;
    if(e.team===TEAM.P) G.stats.lost++;
    SND.play('boom');
  } else {
    spawnExplosion(e.x,2.0,e.z,3.4,[1,0.6,0.3]);
    const wasHQ=e.kind==='hq';
    removeBuilding(e);
    SND.play('bigboom');
    if(wasHQ){
      const hq=countHQ(e.team);
      if(hq===0) endMatch(e.team===TEAM.P?TEAM.E:TEAM.P);
    }
  }
}
/* ================================================================
   GAME STATE
   ================================================================ */
const G = {
  state:'menu', time:0, seed:20240517, diff:1, over:false,
  sel:[], groups:{}, hoverId:0, cmdMode:null, placeKind:null,
  cam:{x:56,z:56,yaw:0.9,pitch:0.86,dist:78,tdist:78},
  stats:{mined:0,kills:0,lost:0,built:0,unitsTrained:0},
  lastAlert:-99, cine:0
};
let units=[], buildings=[], nodes=[], projs=[], fx=[], pings=[];
let byId=new Map();
let teams=[];
let hash=[], bHash=new Map(), pathQueue=[];
let hashCell=CELL;

function initTeams(){
  teams=[0,1].map(t=>({
    minerals:500, supplyUsed:0, supplyCap:0, reserved:0,
    upgrades:{}, explored:new Uint8Array(PN*PN), vision:new Uint8Array(PN*PN),
    minedAcc:0, income:0, hist:[], lastSeen:new Map(), ai:null
  }));
}
function initHash(){
  hash=new Array(PN*PN); for(let i=0;i<PN*PN;i++) hash[i]=[];
}
function rebuildHash(){
  for(let i=0;i<hash.length;i++) hash[i].length=0;
  for(const u of units){ const i=cI(cCellX(u.x),cCellX(u.z)); if(hash[i]) hash[i].push(u); }
}
function rebuildBHash(){
  bHash.clear();
  for(const b of buildings){ if(b.dead) continue;
    const k=cI(cCellX(b.x),cCellX(b.z));
    if(!bHash.has(k)) bHash.set(k,[]); bHash.get(k).push(b); }
}
function forEachNear(x,z,radCells,fn){
  const x0=cCellX(x-radCells*CELL), x1=cCellX(x+radCells*CELL);
  const z0=cCellX(z-radCells*CELL), z1=cCellX(z+radCells*CELL);
  for(let cz=z0;cz<=z1;cz++) for(let cx=x0;cx<=x1;cx++){
    const arr=hash[cI(cx,cz)]; if(!arr) continue;
    for(let i=0;i<arr.length;i++){ if(fn(arr[i])===false) return; }
  }
}
function nearBuildings(x,z,fn){
  const cx=cCellX(x), cz=cCellX(z);
  for(let dz=-1;dz<=1;dz++) for(let dx=-1;dx<=1;dx++){
    const arr=bHash.get(cI(cx+dx,cz+dz)); if(!arr) continue;
    for(let i=0;i<arr.length;i++){ if(fn(arr[i])===false) return; }
  }
}

/* ================================================================
   MOVEMENT + SEPARATION
   ================================================================ */
function requestPath(u){
  if(u.pathPending||u.dead) return;
  u.pathPending=true; pathQueue.push(u);
}
function processPaths(budget){
  while(budget-->0 && pathQueue.length){
    const u=pathQueue.shift(); u.pathPending=false;
    if(u.dead) continue;
    const p=findPath(u.x,u.z,u.gx,u.gz);
    u.path=p; u.pathI=0;
    if(!p){ u.path=[{x:u.gx,z:u.gz}]; }
  }
}
function angLerp(a,b,t){ let d=b-a;
  while(d>Math.PI) d-=Math.PI*2; while(d<-Math.PI) d+=Math.PI*2;
  return a+d*t; }
function separate(u){
  const d=UNITS[u.kind], rr0=d.r;
  let sx=0, sz=0;
  forEachNear(u.x,u.z,1,o=>{
    if(o===u||o.dead) return;
    if((o.fly?1:0)!==(u.fly?1:0)) return;
    const od=UNITS[o.kind];
    const rr=rr0+od.r;
    const dx=u.x-o.x, dz=u.z-o.z, dd=dx*dx+dz*dz;
    if(dd<rr*rr && dd>1e-5){ const dl=Math.sqrt(dd), w=(rr-dl)/rr;
      sx+=dx/dl*w*1.4; sz+=dz/dl*w*1.4; }
    else if(dd<=1e-5){ sx+=(Math.random()-0.5); sz+=(Math.random()-0.5); }
  });
  if(!u.fly){
    nearBuildings(u.x,u.z,b=>{
      if(b.dead) return;
      const h=BUILDS[b.kind].size/2+rr0;
      const dx=u.x-b.x, dz=u.z-b.z;
      if(Math.abs(dx)<h && Math.abs(dz)<h){
        const px=h-Math.abs(dx), pz=h-Math.abs(dz);
        if(px<pz) sx+=Math.sign(dx||1)*px*1.6; else sz+=Math.sign(dz||1)*pz*1.6;
      }
    });
  }
  return {x:sx,z:sz};
}
function stepMove(u,dt){
  const d=UNITS[u.kind];
  let guard=0;
  while(u.path && u.pathI<u.path.length && guard++<5){
    const wp=u.path[u.pathI];
    if(dist(u.x,u.z,wp.x,wp.z)<1.15) u.pathI++; else break;
  }
  let tx=u.gx, tz=u.gz, arrived=false;
  if(u.path && u.pathI<u.path.length){ tx=u.path[u.pathI].x; tz=u.path[u.pathI].z; }
  else { if(dist(u.x,u.z,u.gx,u.gz)<1.6) arrived=true; }
  const sep=separate(u);
  let dx=tx-u.x, dz=tz-u.z, L=Math.hypot(dx,dz);
  if(L>1e-4){ dx/=L; dz/=L; } else { dx=0; dz=0; }
  let vx=dx+sep.x, vz=dz+sep.z;
  const vl=Math.hypot(vx,vz);
  if(vl>1e-4){
    vx/=vl; vz/=vl;
    const step=d.speed*dt;
    u.x+=vx*step; u.z+=vz*step;
    u.yaw=angLerp(u.yaw,Math.atan2(vx,vz),Math.min(1,dt*7));
    u.anim+=step*1.6;
  }
  u.x=clamp(u.x,2,MW-2); u.z=clamp(u.z,2,MW-2);
  return arrived;
}
function repathIfNeeded(u,dt){
  u.stuckT=(u.stuckT||0)+dt;
  if(u.stuckT<1.0) return;
  u.stuckT=0;
  const prog=dist(u.x,u.z,u.gx,u.gz);
  if(u.lastProg===undefined||prog<u.lastProg-0.5){ u.lastProg=prog; u.stuck=0; return; }
  u.stuck=(u.stuck||0)+1;
  if(u.stuck<3) return;                 // allow time to route around obstacles
  u.stuck=0; u.lastProg=undefined;
  u.path=null; u.pathDone=false;
  u.jit=(u.jit||0)+1;
  if(u.jit%3===0){ u.gx=clamp(u.gx+(Math.random()-0.5)*6,4,MW-4);
                   u.gz=clamp(u.gz+(Math.random()-0.5)*6,4,MW-4); }
  requestPath(u);
}

/* ================================================================
   ECONOMY — harvesting
   ================================================================ */
const CARRY=20;
function nearestNode(x,z,minAmt){
  let best=null,bd=1e18;
  for(const n of nodes){ if(n.amount<(minAmt||1)) continue;
    const dd=dist2(x,z,n.x,n.z); if(dd<bd){ bd=dd; best=n; } }
  return best;
}
function nearestDrop(t,x,z){
  let best=null,bd=1e18;
  for(const b of buildings){ if(b.dead||b.team!==t||!b.done||b.kind!=='hq') continue;
    const dd=dist2(x,z,b.x,b.z); if(dd<bd){ bd=dd; best=b; } }
  return best;
}
function nodeById(id){ for(const n of nodes) if(n.id===id) return n; return null; }
function orderGather(u,node){
  if(!node) return;
  u.order='gather'; u.nodeId=node.id; u.gstate=1; u.path=null; u.pathDone=false;
  u.gx=node.x; u.gz=node.z; u.autoGather=true; u.targetId=0; u.hold=false;
  requestPath(u);
}
function gatherUpdate(u,dt){
  const d=UNITS[u.kind];
  let n=nodeById(u.nodeId);
  if(!n || n.amount<=0){
    n=nearestNode(u.x,u.z,1);
    if(!n){ u.order='idle'; u.gstate=0; return; }
    orderGather(u,n); return;
  }
  if(u.gstate===1){                       // travel to node
    if(dist(u.gx,u.gz,n.x,n.z)>1.2){ u.gx=n.x; u.gz=n.z; u.path=null; u.pathI=0; requestPath(u); }
    else if(!u.path && !u.pathPending){ requestPath(u); }
    const arrived=stepMove(u,dt);
    if(dist(u.x,u.z,n.x,n.z)<3.4){ u.gstate=2; u.harv=0; u.path=null; }
    else if(arrived && dist(u.x,u.z,n.x,n.z)>=3.4){ u.gx=n.x; u.gz=n.z; u.path=null; requestPath(u); }
    repathIfNeeded(u,dt);
  } else if(u.gstate===2){                // mining
    u.harv+=dt*CARRY/2.0*mineMul(u.team);
    u.anim+=dt*3;
    u.yaw=angLerp(u.yaw,Math.atan2(n.x-u.x,n.z-u.z),Math.min(1,dt*4));
    if(u.harv>=CARRY){
      const take=Math.min(CARRY,Math.floor(n.amount)+0.0001);
      n.amount=Math.max(0,n.amount-take);
      u.carry=Math.min(CARRY,take);
      u.gstate=3; u.path=null; u.pathDone=false;
      const drop=nearestDrop(u.team,u.x,u.z);
      if(!drop){ u.order='idle'; u.gstate=0; return; }
      u.gx=drop.x; u.gz=drop.z; requestPath(u);
      SND.play('mined');
    }
  } else if(u.gstate===3){                // return cargo
    const drop=nearestDrop(u.team,u.x,u.z);
    if(!drop){ u.order='idle'; u.gstate=0; return; }
    if(dist(u.gx,u.gz,drop.x,drop.z)>1.2){ u.gx=drop.x; u.gz=drop.z; u.path=null; u.pathI=0; requestPath(u); }
    else if(!u.path && !u.pathPending){ requestPath(u); }
    const arrived=stepMove(u,dt);
    if(dist(u.x,u.z,drop.x,drop.z)<BUILDS.hq.size/2+2.2){
      teams[u.team].minerals+=u.carry;
      teams[u.team].minedAcc+=u.carry;
      if(u.team===TEAM.P) G.stats.mined+=u.carry;
      u.carry=0; u.gstate=1; u.path=null; u.pathDone=false;
      const nn=nodeById(u.nodeId);
      if(!nn||nn.amount<=0){ const alt=nearestNode(u.x,u.z,1); if(alt) u.nodeId=alt.id; }
      const n2=nodeById(u.nodeId);
      if(n2){ u.gx=n2.x; u.gz=n2.z; requestPath(u); } else { u.order='idle'; u.gstate=0; }
    } else if(arrived){ u.gx=drop.x; u.gz=drop.z; u.path=null; requestPath(u); }
    else repathIfNeeded(u,dt);
  }
}
function autoAssignWorkers(t){
  for(const u of units){
    if(u.team!==t||u.kind!=='worker'||u.dead) continue;
    if(u.order!=='idle'||!u.autoGather) continue;
    const n=nearestNode(u.x,u.z,1);
    if(n) orderGather(u,n);
  }
}
/* ================================================================
   COMBAT
   ================================================================ */
function targetY(t){ return t.isUnit ? (t.fly?t.y:0.95) : 1.7; }
function validTarget(att,t){
  if(!t||t.dead||t.team===att.team) return false;
  const d=UNITS[att.kind];
  if(d.targets!=='both' && t.fly) return false;
  if(!visibleTo(att.team,t.x,t.z)) return false;
  return true;
}
function forEachBuildingNear(x,z,radCells,fn){
  const x0=cCellX(x-radCells*CELL), x1=cCellX(x+radCells*CELL);
  const z0=cCellX(z-radCells*CELL), z1=cCellX(z+radCells*CELL);
  for(let cz=z0;cz<=z1;cz++) for(let cx=x0;cx<=x1;cx++){
    const arr=bHash.get(cI(cx,cz)); if(!arr) continue;
    for(let i=0;i<arr.length;i++){ if(fn(arr[i])===false) return; }
  }
}
function acquire(u,dt){
  const d=UNITS[u.kind];
  const cur=u.targetId?byId.get(u.targetId):null;
  if(cur && !cur.dead && validTarget(u,cur) && dist2(u.x,u.z,cur.x,cur.z)<=d.sight*d.sight*1.3) return cur;
  u.targetId=0;
  if(u.scanT>0) return null;
  u.scanT=0.22+Math.random()*0.18;
  const R=Math.ceil(d.sight/CELL);
  let best=null,bd=1e18;
  forEachNear(u.x,u.z,R,o=>{
    if(o===u||o.dead||o.team===u.team) return;
    if(d.targets!=='both'&&o.fly) return;
    if(!visibleTo(u.team,o.x,o.z)) return;
    const dd=dist2(u.x,u.z,o.x,o.z);
    if(dd<=d.sight*d.sight && dd<bd){ bd=dd; best=o; }
  });
  if(best) return best;
  let bb=null,bd2=1e18;
  forEachBuildingNear(u.x,u.z,R,b=>{
    if(b.dead||b.team===u.team) return;
    if(d.targets!=='both'&&b.fly) return;
    if(!visibleTo(u.team,b.x,b.z)) return;
    const dd=dist2(u.x,u.z,b.x,b.z)*1.25;
    if(dd<=d.sight*d.sight && dd<bd2){ bd2=dd; bb=b; }
  });
  return bb;
}
function fireWeapon(u,t){
  const d=UNITS[u.kind];
  const dmg=d.dmg*dmgMul(u.team);
  u.yaw=Math.atan2(t.x-u.x,t.z-u.z);
  if(d.range<=2.0){
    damage(t,dmg,d.dtype,u.team);
    spawnHitFX(t.x,targetY(t),t.z,d.dtype,1);
    SND.play('hit');
  } else spawnProj(u,t,dmg,d.dtype);
}
function engage(u,t,dt,holdOnly){
  const d=UNITS[u.kind];
  const tr=t.isUnit?UNITS[t.kind].r:BUILDS[t.kind].size*0.45;
  const dd=dist(u.x,u.z,t.x,t.z);
  if(dd<=d.range+tr){
    u.yaw=angLerp(u.yaw,Math.atan2(t.x-u.x,t.z-u.z),Math.min(1,dt*9));
    if(u.cd<=0){ u.cd=1/d.rate; fireWeapon(u,t); }
    return true;
  }
  if(holdOnly) return false;
  if(!u.path||dist(u.gx,u.gz,t.x,t.z)>4.5){
    u.gx=t.x; u.gz=t.z; u.path=null; u.pathDone=false; requestPath(u);
  }
  stepMove(u,dt);
  return true;
}
function turretUpdate(b,dt){
  const w=BUILDS[b.kind].weapon;
  const fake={kind:b.kind,team:b.team,x:b.x,z:b.z,isUnit:false,fly:0,armor:'heavy',
    targets:w.targets, sight:w.sight, range:w.range, cd:b.cd, targetId:b.targetId, dead:b.dead};
  const cur=b.targetId?byId.get(b.targetId):null;
  let t=null;
  if(cur&&!cur.dead&&cur.team!==b.team&&visibleTo(b.team,cur.x,cur.z)&&
     dist2(b.x,b.z,cur.x,cur.z)<=w.sight*w.sight&&
     (w.targets==='both'||!cur.fly)) t=cur;
  if(!t){
    b.scanT=(b.scanT||0)-dt;
    if(b.scanT<=0){ b.scanT=0.25;
      const R=Math.ceil(w.sight/CELL); let best=null,bd=1e18;
      forEachNear(b.x,b.z,R,o=>{ if(o.dead||o.team===b.team) return;
        if(w.targets!=='both'&&o.fly) return;
        if(!visibleTo(b.team,o.x,o.z)) return;
        const dd=dist2(b.x,b.z,o.x,o.z);
        if(dd<=w.sight*w.sight&&dd<bd){ bd=dd; best=o; } });
      t=best;
    }
  }
  b.targetId=t?t.id:0;
  if(t){
    const dd=dist(b.x,b.z,t.x,t.z);
    if(dd<=w.range+(t.isUnit?UNITS[t.kind].r:BUILDS[t.kind].size*0.45)){
      b.yaw=Math.atan2(t.x-b.x,t.z-b.z);
      if(b.cd<=0){ b.cd=1/w.rate;
        const dmg=w.dmg*dmgMul(b.team);
        if(w.range<=2.5) damage(t,dmg,w.dtype,b.team);
        else spawnProj(b,t,dmg,w.dtype);
      }
    }
  }
}
function spawnProj(src,t,dmg,dtype){
  const col=dtype==='kinetic'?[1.0,0.86,0.42]:dtype==='blast'?[1.0,0.55,0.24]:[0.45,0.85,1.0];
  projs.push({ x:src.x, y:src.isUnit?(src.fly?src.y+0.1:1.15):1.9, z:src.z,
    tid:t.id, tx:t.x, ty:targetY(t), tz:t.z, dmg, dtype, team:src.team,
    speed:48, life:3.0, col, size:src.kind==='heavy'?1.5:1.0 });
}
function updateProjs(dt){
  for(let i=projs.length-1;i>=0;i--){
    const p=projs[i]; p.life-=dt;
    const t=byId.get(p.tid);
    if(t&&!t.dead){ p.tx=t.x; p.ty=targetY(t); p.tz=t.z; }
    const dx=p.tx-p.x, dy=p.ty-p.y, dz=p.tz-p.z;
    const L=Math.hypot(dx,dy,dz);
    const step=p.speed*dt;
    if(L<=step||p.life<=0){
      if(t&&!t.dead&&dist(p.tx,p.tz,t.x,t.z)<3.0&&p.life>0) damage(t,p.dmg,p.dtype,p.team);
      spawnHitFX(p.tx,p.ty,p.tz,p.dtype,0.7);
      SND.play('hit');
      projs.splice(i,1);
    } else { p.x+=dx/L*step; p.y+=dy/L*step; p.z+=dz/L*step; }
  }
}
/* ---- particles ---- */
function spawnHitFX(x,y,z,dtype,scale){
  const col=dtype==='kinetic'?[1,0.85,0.45]:dtype==='blast'?[1,0.5,0.2]:[0.5,0.85,1];
  const n=3+((Math.random()*3)|0);
  for(let i=0;i<n;i++) fx.push({x,y,z,
    vx:(Math.random()-0.5)*7, vy:Math.random()*5, vz:(Math.random()-0.5)*7,
    life:0.35+Math.random()*0.3, max:0.65, size:(0.25+Math.random()*0.3)*(scale||1),
    col, add:1});
}
function spawnExplosion(x,y,z,scale,col){
  const n=Math.min(40,8+Math.round(scale*6));
  for(let i=0;i<n;i++){
    const a=Math.random()*Math.PI*2, sp=(2+Math.random()*10)*scale*0.55;
    fx.push({x,y,z, vx:Math.cos(a)*sp, vy:Math.random()*7*scale*0.6, vz:Math.sin(a)*sp,
      life:0.4+Math.random()*0.7, max:1.1, size:(0.3+Math.random()*0.7)*scale,
      col:col||[1,0.6,0.3], add:1});
  }
  fx.push({x,y,z,vx:0,vy:0,vz:0,life:0.18,max:0.18,size:scale*3.4,col:[1,0.85,0.6],add:1});
}
function updateFX(dt){
  for(let i=fx.length-1;i>=0;i--){ const p=fx[i];
    p.life-=dt; if(p.life<=0){ fx.splice(i,1); continue; }
    p.x+=p.vx*dt; p.y+=p.vy*dt; p.z+=p.vz*dt;
    p.vy-=14*dt; if(p.y<0.05){ p.y=0.05; p.vy*=-0.3; p.vx*=0.6; p.vz*=0.6; }
  }
}

/* ================================================================
   PRODUCTION / RESEARCH / CONSTRUCTION
   ================================================================ */
function hasBuilding(t,kind){ for(const b of buildings) if(!b.dead&&b.team===t&&b.done&&b.kind===kind) return true; return false; }
function countBuildings(t,kind){ let n=0; for(const b of buildings) if(!b.dead&&b.team===t&&b.kind===kind) n++; return n; }
function freeSpot(x,z,r){
  for(let i=0;i<16;i++){ const a=i/16*Math.PI*2;
    const px=x+Math.cos(a)*r, pz=z+Math.sin(a)*r;
    if(px>4&&pz>4&&px<MW-4&&pz<MW-4&&!blockedW(px,pz)) return {x:px,z:pz}; }
  for(let r2=r;r2<r+8;r2+=1.5){ const a=Math.random()*6.28;
    const px=x+Math.cos(a)*r2, pz=z+Math.sin(a)*r2;
    if(!blockedW(px,pz)) return {x:px,z:pz}; }
  return {x:x+r,z:z};
}
function enqueue(b,type,kind){
  const def=type==='unit'?UNITS[kind]:UPGRADES[kind];
  const T=teams[b.team];
  const label=def.name;
  if(b.queue.length>=8) return {ok:false,why:'Queue is full'};
  if(type==='unit'){
    if(!b.done||BUILDS[b.kind].trains.indexOf(kind)<0) return {ok:false,why:'This facility cannot train that'};
    if(!hasBuilding(b.team,def.build)) return {ok:false,why:'Requires a '+BUILDS[def.build].name};
    if(T.supplyUsed+T.reserved+def.supply>T.supplyCap)
      return {ok:false,why:'Not enough supply — build a Supply Relay'};
  } else {
    if(b.kind!=='lab') return {ok:false,why:'Research requires a Laboratory'};
    if(T.upgrades[kind]) return {ok:false,why:'Already researched'};
    if(b.queue.some(q=>q.kind===kind&&q.type==='upg')) return {ok:false,why:'Already queued'};
    if(!hasBuilding(b.team,def.req)) return {ok:false,why:'Requires a Laboratory'};
  }
  if(T.minerals<def.cost) return {ok:false,why:'Not enough Aetherium ('+def.cost+' needed)'};
  T.minerals-=def.cost;
  const sup=type==='unit'?def.supply:0;
  T.reserved+=sup;
  b.queue.push({type,kind,cost:def.cost,sup,time:def.time,prog:0});
  if(b.team===TEAM.P){ SND.play('cmd'); refreshUI(); }
  return {ok:true};
}
function cancelQueue(b,idx){
  const it=b.queue[idx]; if(!it) return;
  const T=teams[b.team];
  T.minerals+=it.cost; T.reserved-=it.sup;
  b.queue.splice(idx,1);
  if(b.team===TEAM.P){ pushToast('Cancelled — '+it.cost+' Aetherium refunded','good'); SND.play('ack'); refreshUI(); }
}
function completeQueueItem(b){
  const it=b.queue.shift(); const T=teams[b.team];
  if(it.type==='unit'){
    T.reserved-=it.sup;
    const sp=freeSpot(b.x,b.z,BUILDS[b.kind].size/2+2.2);
    const u=spawnUnit(it.kind,b.team,sp.x,sp.z);
    if(b.rally){ u.order='move'; u.gx=b.rally.x; u.gz=b.rally.z; u.path=null; requestPath(u); }
    if(b.team===TEAM.P){ G.stats.unitsTrained++; SND.play('ready');
      pushToast(UNITS[it.kind].name+' ready','good'); }
  } else {
    applyUpgrade(b.team,it.kind);
    if(b.team===TEAM.P){ SND.play('research'); pushToast(UPGRADES[it.kind].name+' researched','good'); }
  }
  refreshUI();
}
function applyUpgrade(t,key){
  teams[t].upgrades[key]=1;
  if(key==='alloys') for(const u of units) if(u.team===t){ u.maxHp*=1.15; u.hp*=1.15; }
  if(key==='servos') for(const u of units) if(u.team===t&&!u.fly) u.speed=UNITS[u.kind].speed*1.15;
}
function completeBuilding(b){
  b.done=true; b.hp=b.maxHp; b.prog=1; b.builder=0;
  recalcSupply(b.team);
  if(b.team===TEAM.P){ SND.play('built'); pushToast(BUILDS[b.kind].name+' constructed','good'); refreshUI(); }
}
function updateBuilding(b,dt){
  const d=BUILDS[b.kind];
  b.flash=Math.max(0,b.flash-dt*3);
  if(!b.done) return;
  if(d.weapon) turretUpdate(b,dt);
  if(b.queue.length){
    const it=b.queue[0], T=teams[b.team];
    const supplyOk = it.sup===0 || (T.supplyUsed+T.reserved<=T.supplyCap);
    if(supplyOk){ it.prog+=dt;
      if(it.prog>=it.time) completeQueueItem(b);
    } else if(b.team===TEAM.P && G.time-G.lastAlert>6){
      G.lastAlert=G.time; SND.play('err');
      pushToast('PRODUCTION HALTED — supply is full','bad');
    }
  }
}
function canPlace(kind,x,z,team){
  const d=BUILDS[kind];
  if(x<d.size/2+3||z<d.size/2+3||x>MW-d.size/2-3||z>MW-d.size/2-3)
    return {ok:false,why:'Too close to the map edge'};
  if(!exploredBy(team,x,z)) return {ok:false,why:'Can only build on explored ground'};
  if(!boxFree(x,z,d.size)) return {ok:false,why:'Ground is obstructed'};
  for(const n of nodes) if(dist2(x,z,n.x,n.z)<Math.pow(d.size/2+3.2,2))
    return {ok:false,why:'Blocked by an Aetherium deposit'};
  for(const u of units) if(!u.fly && dist2(x,z,u.x,u.z)<Math.pow(d.size/2+0.8,2))
    return {ok:false,why:'A unit is standing there'};
  return {ok:true};
}
function placeBuilding(kind,x,z,team,worker){
  const d=BUILDS[kind], T=teams[team];
  const chk=canPlace(kind,x,z,team);
  if(!chk.ok) return chk;
  if(T.minerals<d.cost) return {ok:false,why:'Not enough Aetherium ('+d.cost+' needed)'};
  T.minerals-=d.cost;
  const b=spawnBuilding(kind,team,x,z,false);
  b.hp=Math.max(1,b.maxHp*0.06);
  if(worker){ worker.order='build'; worker.buildId=b.id; b.builder=worker.id;
    worker.path=null; worker.pathDone=false; worker.gx=x; worker.gz=z; worker.targetId=0;
    worker.hold=false; requestPath(worker); }
  if(team===TEAM.P){ SND.play('place'); G.stats.built++; refreshUI(); }
  return {ok:true,building:b};
}

/* ================================================================
   ORDERS
   ================================================================ */
function selUnits(){
  const out=[];
  for(const id of G.sel){ const e=byId.get(id); if(e&&!e.dead&&e.isUnit) out.push(e); }
  return out;
}
function selBuildings(){
  const out=[];
  for(const id of G.sel){ const e=byId.get(id); if(e&&!e.dead&&!e.isUnit) out.push(e); }
  return out;
}
function setMoveOrder(list,gx,gz,mode){
  const ground=[], air=[];
  for(const u of list){ (u.fly?air:ground).push(u); }
  const assign=(arr,sp)=>{
    const n=arr.length; if(!n) return;
    const cols=Math.max(1,Math.ceil(Math.sqrt(n)));
    const rows=Math.ceil(n/cols);
    arr.forEach((u,i)=>{
      const c=i%cols, r=(i/cols)|0;
      const ox=(c-(cols-1)/2)*sp, oz=(r-(rows-1)/2)*sp;
      u.gx=clamp(gx+ox,3,MW-3); u.gz=clamp(gz+oz,3,MW-3);
      u.path=null; u.pathDone=false; u.pathPending=false;
      u.targetId=0; u.hold=false; u.engaged=false;
      u.order=mode||'move';
      if(u.kind==='worker') u.autoGather=false;
      if(mode==='amove'){ u.goalX=u.gx; u.goalZ=u.gz; }
      requestPath(u);
    });
  };
  assign(ground,2.4); assign(air,3.4);
}
function issueOrder(wx,wz,hit,additive){
  const list=selUnits();
  if(!list.length) return;
  const workers=list.filter(u=>u.kind==='worker');
  // gather
  if(hit&&hit.kind==='node'){
    const w=workers.length?workers:list;
    for(const u of w){ if(u.kind==='worker'){ if(hit.amount>0) orderGather(u,hit); } }
    if(workers.length) orderFeedback(wx,wz,'gather');
    else setMoveOrder(list,wx,wz,'move');
    SND.play('ack'); return;
  }
  // friendly incomplete building → build/repair-assist
  if(hit&&!hit.isUnit&&hit.team===TEAM.P&&list.some(u=>u.kind==='worker')){
    if(!hit.done){
      const w=workers[0];
      if(w){ hit.builder=w.id; w.order='build'; w.buildId=hit.id; w.path=null;
        w.gx=hit.x; w.gz=hit.z; requestPath(w); SND.play('ack');
        pushToast('Worker assigned to construction','good'); }
      return;
    }
    if(BUILDS[hit.kind].drop && workers.length){
      for(const u of workers){ if(u.carry>0){ u.gstate=3; u.order='gather'; u.path=null;
        u.gx=hit.x; u.gz=hit.z; requestPath(u); } else orderGather(u,nearestNode(u.x,u.z,1)); }
      SND.play('ack'); return;
    }
  }
  // enemy → attack
  if(hit&&hit.team===TEAM.E){
    for(const u of list){ u.order='attack'; u.targetId=hit.id; u.hold=false;
      u.gx=hit.x; u.gz=hit.z; u.path=null; u.pathDone=false; requestPath(u); }
    orderFeedback(hit.x,hit.z,'attack');
    SND.play('ack'); return;
  }
  setMoveOrder(list,wx,wz,'move');
  orderFeedback(wx,wz,'move');
  SND.play('cmd');
}
function orderFeedback(x,z,kind){
  pings.push({x,z,t:G.time,kind});
}
function stopSelected(){
  for(const u of selUnits()){ u.order='idle'; u.path=null; u.pathDone=false;
    u.targetId=0; u.hold=false; u.engaged=false; u.gx=u.x; u.gz=u.z; u.autoGather=false; }
  SND.play('ack'); pushToast('Stopped','good');
}
function holdSelected(){
  for(const u of selUnits()){ u.order='hold'; u.hold=true; u.path=null; u.targetId=0;
    u.gx=u.x; u.gz=u.z; u.autoGather=false; }
  SND.play('ack'); pushToast('Holding position','good');
}
/* ================================================================
   UNIT UPDATE
   ================================================================ */
function buildUpdate(u,dt){
  const b=u.buildId?byId.get(u.buildId):null;
  if(!b||b.dead||b.done||b.builder!==u.id){ u.order='idle'; u.buildId=0; u.autoGather=true; return; }
  const reach=BUILDS[b.kind].size/2+1.9;
  if(dist(u.x,u.z,b.x,b.z)>reach){
    if(dist(u.gx,u.gz,b.x,b.z)>1.2){ u.gx=b.x; u.gz=b.z; u.path=null; u.pathI=0; requestPath(u); }
    else if(!u.path&&!u.pathPending){ requestPath(u); }
    stepMove(u,dt); repathIfNeeded(u,dt);
  } else {
    u.yaw=angLerp(u.yaw,Math.atan2(b.x-u.x,b.z-u.z),Math.min(1,dt*4));
    u.anim+=dt*5;
    b.hp=Math.min(b.maxHp, b.hp + (b.maxHp/BUILDS[b.kind].time)*dt);
    b.prog=Math.min(1, b.prog + dt/BUILDS[b.kind].time);
    if(b.prog>=1) completeBuilding(b);
  }
}
function updateUnit(u,dt){
  const d=UNITS[u.kind];
  if(u.flash>0) u.flash=Math.max(0,u.flash-dt*3);
  if(u.cd>0) u.cd-=dt;
  if(u.underAttackT>0) u.underAttackT-=dt;
  if(u.scanT>0) u.scanT-=dt;

  if(u.order==='build'){ buildUpdate(u,dt); return; }
  if(u.order==='gather'){
    if(u.kind==='worker'&&u.underAttackT>0){
      const t=acquire(u,dt);
      if(t){ u.targetId=t.id; if(engage(u,t,dt,false)) return; }
    }
    gatherUpdate(u,dt); return;
  }
  const canFight = u.kind!=='worker' || u.underAttackT>0 ||
                   u.order==='attack' || u.order==='amove' || u.order==='hold';

  if(u.order==='move'){
    if(!u.path&&!u.pathPending) requestPath(u);
    if(stepMove(u,dt)){ u.order='idle'; u.path=null; u.pathDone=true; u.engaged=false; }
    return;
  }
  if(u.order==='attack'){
    const t=u.targetId?byId.get(u.targetId):null;
    if(!t||t.dead||!validTarget(u,t)){
      u.targetId=0; u.order='idle'; u.path=null; u.engaged=false; return; }
    engage(u,t,dt,false); return;
  }
  if(u.order==='amove'){
    const t=canFight?acquire(u,dt):null;
    if(t){
      if(!u.engaged){ u.goalX=u.gx; u.goalZ=u.gz; u.engaged=true; }
      u.targetId=t.id; engage(u,t,dt,false); return;
    }
    u.targetId=0;
    if(u.engaged){ u.engaged=false; u.gx=u.goalX; u.gz=u.goalZ;
      u.path=null; u.pathDone=false; requestPath(u); }
    if(!u.path&&!u.pathPending) requestPath(u);
    if(stepMove(u,dt)){ u.order='idle'; u.path=null; u.engaged=false; }
    return;
  }
  if(u.order==='hold'){
    const t=canFight?acquire(u,dt):null;
    if(t){ u.targetId=t.id; engage(u,t,dt,true); } else u.targetId=0;
    return;
  }
  // idle
  const t=canFight?acquire(u,dt):null;
  if(t){ u.targetId=t.id; engage(u,t,dt,false); }
  else { u.targetId=0; u.engaged=false; }
}

/* ================================================================
   SIMULATION TICK
   ================================================================ */
let visionT=0, assignT=0, aiT=0;
function tick(dt){
  G.time+=dt;
  processPaths(48);
  rebuildHash();
  for(let i=units.length-1;i>=0;i--) updateUnit(units[i],dt);
  for(let i=buildings.length-1;i>=0;i--) updateBuilding(buildings[i],dt);
  updateProjs(dt); updateFX(dt);

  visionT+=dt;
  if(visionT>=0.25){
    visionT=0;
    updateVision(TEAM.P); updateVision(TEAM.E);
    buildFogTexture(); updateGhosts(TEAM.P);
    updateIncome();
  }
  assignT+=dt;
  if(assignT>=1.0){ assignT=0; autoAssignWorkers(TEAM.P); autoAssignWorkers(TEAM.E); }

  aiT+=dt;
  if(aiT>=0.5){ aiT=0; aiThink(TEAM.E); }

  for(let i=pings.length-1;i>=0;i--) if(G.time-pings[i].t>1.4) pings.splice(i,1);
}
function updateIncome(){
  const T=teams[0];
  T.hist.push(T.minedAcc); T.minedAcc=0;
  if(T.hist.length>12) T.hist.shift();
  let s=0; for(const v of T.hist) s+=v;
  T.income=s*(60/12);
}
function updateGhosts(t){
  const T=teams[t];
  for(const b of buildings){
    if(b.dead||b.team===t) continue;
    if(visibleTo(t,b.x,b.z))
      T.lastSeen.set(b.id,{kind:b.kind,x:b.x,z:b.z,hp:b.hp,maxHp:b.maxHp});
  }
  if(T.lastSeen.size){
    for(const [id,e] of T.lastSeen){
      const b=byId.get(id);
      if((!b||b.dead) && visibleTo(t,e.x,e.z)) T.lastSeen.delete(id);
      else if(b&&!b.dead && visibleTo(t,b.x,b.z)) T.lastSeen.set(id,{kind:b.kind,x:b.x,z:b.z,hp:b.hp,maxHp:b.maxHp});
    }
  }
}

/* ================================================================
   AI OPPONENT
   ================================================================ */
const DIFF=[{wave:1.7,eco:0.85,aggr:1.45,def:0.8},
            {wave:1.15,eco:1.0,aggr:1.0,def:1.0},
            {wave:0.8,eco:1.2,aggr:0.7,def:1.3}];
function initAI(t){
  teams[t].ai={ rally:{x:spawn[t].x,z:spawn[t].z}, wave:0, scoutId:0, scoutList:[],
    found:false, known:null, lastAttack:-99, buildCd:0, expandCd:40, target:null };
}
function aiFreeWorker(t){
  let best=null,bs=1e9;
  for(const u of units){
    if(u.team!==t||u.kind!=='worker'||u.order==='build') continue;
    if(u.order!=='idle'&&u.order!=='gather') continue;
    const s=(u.order==='idle'?0:1)+Math.random()*0.5;
    if(s<bs){ bs=s; best=u; }
  }
  return best;
}
function aiSpot(t,kind,cx,cz,rad){
  for(let i=0;i<26;i++){
    const a=Math.random()*6.28, r=7+Math.random()*(rad||24);
    const x=cx+Math.cos(a)*r, z=cz+Math.sin(a)*r;
    if(canPlace(kind,x,z,t).ok) return {x,z};
  }
  return null;
}
function aiBuild(t,kind,cx,cz,rad){
  const T=teams[t];
  if(T.minerals<BUILDS[kind].cost) return false;
  const w=aiFreeWorker(t); if(!w) return false;
  const s=aiSpot(t,kind,cx,cz,rad); if(!s) return false;
  return placeBuilding(kind,s.x,s.z,t,w).ok;
}
function aiThink(t){
  const T=teams[t], ai=T.ai, df=DIFF[G.diff];
  if(!ai) return;
  const hq=buildings.find(b=>!b.dead&&b.team===t&&b.kind==='hq'&&b.done);
  const base=hq?{x:hq.x,z:hq.z}:spawn[t];
  const mins=T.minerals;
  const workers=units.filter(u=>u.team===t&&u.kind==='worker').length;
  const combat=units.filter(u=>u.team===t&&u.kind!=='worker');
  const headroom=T.supplyCap-T.supplyUsed-T.reserved;

  /* --- scouting --- */
  if(!ai.scoutList.length){
    ai.scoutList=[[56,56],[264,264],[160,160],[40,242],[240,38],[164,292],[156,28],[28,164],[292,156]]
      .filter(p=>dist(p[0],p[1],spawn[t].x,spawn[t].z)>50);
  }
  const scout=ai.scoutId?byId.get(ai.scoutId):null;
  if(!scout||scout.dead||scout.order==='idle'&&dist(scout.x,scout.z,scout.gx,scout.gz)<6){
    const w=aiFreeWorker(t)||units.find(u=>u.team===t&&u.kind==='worker');
    if(w){
      const p=ai.scoutList.shift();
      if(p){ w.order='move'; w.gx=p[0]; w.gz=p[1]; w.path=null; w.pathDone=false;
        w.autoGather=false; requestPath(w); ai.scoutId=w.id; }
      else { w.autoGather=true; w.order='idle'; ai.scoutId=0; }
    }
  }
  /* --- learn enemy position from vision --- */
  for(const b of buildings){
    if(b.dead||b.team===t) continue;
    if(visibleTo(t,b.x,b.z)){ ai.known={x:b.x,z:b.z}; ai.found=true; }
  }
  if(!ai.found){
    for(const u of units){ if(u.team===t) continue;
      if(visibleTo(t,u.x,u.z)){ ai.known={x:u.x,z:u.z}; break; } }
  }

  /* --- economy: workers --- */
  const wTarget=Math.round((8+Math.min(16,G.time/45))*df.eco);
  if(workers<wTarget && mins>=UNITS.worker.cost+30 && headroom>=UNITS.worker.supply){
    const hqs=buildings.filter(b=>!b.dead&&b.team===t&&b.kind==='hq'&&b.done);
    for(const h of hqs) if(h.queue.length<2 && enqueue(h,'unit','worker').ok) break;
  }

  /* --- construction priorities --- */
  ai.buildCd-=0.5;
  if(ai.buildCd<=0){
    const nB=countBuildings(t,'barracks'), nL=countBuildings(t,'lab'),
          nF=countBuildings(t,'factory'), nA=countBuildings(t,'airfield'),
          nR=countBuildings(t,'relay'), nT=countBuildings(t,'turret'),
          nH=countBuildings(t,'hq');
    let done=false;
    if(nB<1){ done=aiBuild(t,'barracks',base.x,base.z,20); }
    else if(headroom<7 && nR<Math.ceil(T.supplyCap/12)+2){ done=aiBuild(t,'relay',base.x,base.z,26); }
    else if(nL<1 && G.time>70){ done=aiBuild(t,'lab',base.x,base.z,24); }
    else if(nT<Math.min(7,1+Math.floor(G.time/70)*df.def)){ done=aiBuild(t,'turret',base.x,base.z,30); }
    else if(nF<1 && nL>0 && G.time>140){ done=aiBuild(t,'factory',base.x,base.z,26); }
    else if(nA<1 && nL>0 && G.time>175){ done=aiBuild(t,'airfield',base.x,base.z,26); }
    else if(nH<2 && G.time>150 && mins>520){
      const cand=nodes.filter(n=>n.amount>400 && exploredBy(t,n.x,n.z) &&
        !buildings.some(b=>!b.dead&&b.team===t&&b.kind==='hq'&&dist(b.x,b.z,n.x,n.z)<34));
      if(cand.length){ cand.sort((a,b)=>dist(a.x,a.z,base.x,base.z)-dist(b.x,b.z,base.x,base.z));
        const n=cand[cand.length-1];
        done=aiBuild(t,'hq',n.x,n.z,10);
        if(!done){ const w=aiFreeWorker(t); if(w){ w.order='move'; w.gx=n.x; w.gz=n.z;
          w.path=null; w.pathDone=false; w.autoGather=false; requestPath(w); } }
      }
    }
    if(done) ai.buildCd=6;
  }

  /* --- research --- */
  const lab=buildings.find(b=>!b.dead&&b.team===t&&b.kind==='lab'&&b.done);
  if(lab && lab.queue.length===0 && mins>260){
    for(const k of ['mining','alloys','weapons','servos','plating']){
      if(!T.upgrades[k] && !lab.queue.some(q=>q.kind===k)){
        if(enqueue(lab,'upg',k).ok) break;
      }
    }
  }

  /* --- production --- */
  if(headroom>0){
    const comps=[['melee','melee','ranged'],['melee','ranged','ranged','heavy'],
                 ['melee','ranged','heavy','air','ranged']];
    const comp=comps[G.time<110?0:(G.time<220?1:2)];
    let ci=0;
    for(const b of buildings){
      if(b.dead||b.team!==t||!b.done) continue;
      const trains=BUILDS[b.kind].trains;
      if(!trains.length||b.queue.length>=2) continue;
      let pick=null;
      for(let guard=0;guard<4;guard++){
        const k=comp[(ci++)%comp.length];
        if(trains.indexOf(k)>=0 && mins>=UNITS[k].cost+40){ pick=k; break; }
      }
      if(pick){ if(enqueue(b,'unit',pick).ok){} }
    }
  }

  /* --- defense --- */
  let threat=null;
  for(const b of buildings){
    if(b.dead||b.team!==t) continue;
    if(b.underAttackT>0){ threat={x:b.x,z:b.z}; break; }
  }
  if(threat){
    let sent=0;
    for(const u of combat){
      if(u.order==='attack'||u.order==='amove') continue;
      if(dist(u.x,u.z,base.x,base.z)<44 && sent<14){
        u.order='amove'; u.gx=threat.x; u.gz=threat.z; u.goalX=threat.x; u.goalZ=threat.z;
        u.path=null; u.pathDone=false; u.engaged=false; requestPath(u); sent++;
      }
    }
  }

  /* --- attack waves --- */
  const idle=combat.filter(u=>u.order==='idle'||u.order==='hold');
  const need=Math.max(3,Math.round((3+G.time/40)*df.wave));
  if(idle.length>=need && G.time-ai.lastAttack>18){
    let tgt=null;
    if(ai.known && exploredBy(t,ai.known.x,ai.known.z)) tgt={x:ai.known.x,z:ai.known.z};
    else {
      const cands=[[56,56],[264,264],[160,160]];
      tgt={x:cands[(Math.random()*3)|0][0], z:cands[(Math.random()*3)|0][1]};
    }
    for(const u of idle){
      u.order='amove'; u.gx=tgt.x+(Math.random()-0.5)*24; u.gz=tgt.z+(Math.random()-0.5)*24;
      u.goalX=u.gx; u.goalZ=u.gz; u.path=null; u.pathDone=false; u.engaged=false;
      u.hold=false; requestPath(u);
    }
    ai.lastAttack=G.time;
    ai.rally={x:tgt.x,z:tgt.z};
  }
  /* --- keep the army moving home when idle too long far away --- */
  for(const u of combat){
    if(u.order==='idle' && dist(u.x,u.z,base.x,base.z)>90 && G.time-ai.lastAttack>10){
      u.order='move'; u.gx=base.x+(Math.random()-0.5)*30; u.gz=base.z+(Math.random()-0.5)*30;
      u.path=null; u.pathDone=false; requestPath(u);
    }
  }
}
/* ================================================================
   CAMERA / PROJECTION / PICKING
   ================================================================ */
const FOV=52*Math.PI/180;
function camEye(){
  const c=G.cam, p=c.pitch, y=c.yaw, d=c.dist;
  return [ c.x+Math.sin(y)*Math.cos(p)*d, Math.sin(p)*d, c.z+Math.cos(y)*Math.cos(p)*d ];
}
function clampCam(){
  const c=G.cam, m=18;
  c.x=clamp(c.x,m,MW-m); c.z=clamp(c.z,m,MW-m);
  c.tdist=clamp(c.tdist,26,180);
}
function screenToGround(mx,my){
  const nx=(mx/R.w)*2-1, ny=1-(my/R.h)*2;
  const a=[0,0,0], b=[0,0,0];
  m4xformP(a,R.inv,nx,ny,-1); m4xformP(b,R.inv,nx,ny,1);
  const e=camEye();
  let dx=b[0]-e[0], dy=b[1]-e[1], dz=b[2]-e[2];
  if(Math.abs(dy)<1e-6) return null;
  const t=-e[1]/dy;
  if(t<0) return null;
  return {x:e[0]+dx*t, z:e[2]+dz*t};
}
const _pv=[0,0,0];
function project(x,y,z){
  m4xformP(_pv,R.vp,x,y,z);
  if(_pv[2]>1||_pv[2]<-1) return null;
  return {x:(_pv[0]*0.5+0.5)*R.w, y:(1-(_pv[1]*0.5+0.5))*R.h, z:_pv[2]};
}
function onScreen(x,y,z){
  m4xformP(_pv,R.vp,x,y,z);
  if(_pv[2]>1||_pv[2]<-1) return false;
  return Math.abs(_pv[0])<1.4 && Math.abs(_pv[1])<1.4;
}
function pickRadiusPx(worldR,distToCam){
  return worldR*(R.h*0.5)/(Math.tan(FOV/2)*Math.max(1,distToCam));
}
function pickAt(mx,my){
  const e=camEye();
  let best=null, bestScore=1e9;
  const test=(ent,isB)=>{
    if(ent.dead) return;
    if(ent.team===TEAM.E){
      if(!visibleTo(0,ent.x,ent.z)) return;
    }
    const d=isB?BUILDS[ent.kind]:UNITS[ent.kind];
    const cy=isB?d.size*0.45:(ent.fly?ent.y+0.2:0.85);
    const p=project(ent.x,cy,ent.z); if(!p) return;
    const dc=Math.hypot(ent.x-e[0],cy-e[1],ent.z-e[2]);
    const rpx=pickRadiusPx(isB?d.size*0.55:d.r*1.5,dc);
    const dd=Math.hypot(p.x-mx,p.y-my);
    if(dd>rpx) return;
    const score=dd/rpx + (isB?0.35:0) + p.z*0.05;
    if(score<bestScore){ bestScore=score; best=ent; }
  };
  for(const u of units) test(u,false);
  for(const b of buildings) test(b,true);
  return best;
}
function selectInBox(x0,y0,x1,y1,add){
  const list=[];
  for(const u of units){
    if(u.team!==TEAM.P) continue;
    const p=project(u.x,u.fly?u.y:0.8,u.z); if(!p) continue;
    if(p.x>=x0&&p.x<=x1&&p.y>=y0&&p.y<=y1) list.push(u.id);
  }
  if(!list.length) return false;
  if(add){ for(const id of list) if(G.sel.indexOf(id)<0) G.sel.push(id); }
  else G.sel=list.slice();
  return true;
}

/* ================================================================
   TEXTURES
   ================================================================ */
function makeGridTex(){
  const N=64, cv=document.createElement('canvas'); cv.width=cv.height=N;
  const c=cv.getContext('2d');
  c.clearRect(0,0,N,N);
  c.strokeStyle='rgba(120,220,255,0.55)'; c.lineWidth=2;
  c.beginPath(); c.moveTo(1,0); c.lineTo(1,N); c.moveTo(0,1); c.lineTo(N,1); c.stroke();
  c.strokeStyle='rgba(120,220,255,0.20)'; c.lineWidth=1;
  c.beginPath(); c.moveTo(N/2,0); c.lineTo(N/2,N); c.moveTo(0,N/2); c.lineTo(N,N/2); c.stroke();
  const t=gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D,t);
  gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,gl.RGBA,gl.UNSIGNED_BYTE,cv);
  gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.REPEAT);
  gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.REPEAT);
  return t;
}

/* ================================================================
   SCENE RENDER
   ================================================================ */
function resetBatches(){ for(const k in MESHES) MESHES[k].n=0; }
function renderScene(){
  const w=glc.width, h=glc.height;
  m4persp(R.proj,FOV,w/h,1,1000);
  const e=camEye();
  m4look(R.view,e[0],e[1],e[2],G.cam.x,0,G.cam.z);
  m4mul(R.vp,R.proj,R.view);
  m4inv(R.inv,R.vp);
  gl.viewport(0,0,w,h);
  gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);
  gl.useProgram(PROG);
  gl.uniformMatrix4fv(U.uVP,false,R.vp);
  gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,fogTex);
  gl.uniform1f(U.uFogOn,G.state==='menu'?0:1);
  resetBatches();

  // ground
  pushInst(MESHES.ground,0,0,0,0,1,1,1,1,1,1,1,0);
  drawMesh(MESHES.ground,0,2,gridTex);

  // blob shadows
  gl.depthMask(false);
  for(const u of units){
    if(u.team===TEAM.E&&(G.state==='menu'||!visibleTo(0,u.x,u.z))) continue;
    const d=UNITS[u.kind];
    const s=(u.fly?1.1:1.0)*d.r*2.4;
    pushInst(MESHES.shadow,u.x,0,u.z,0,s,1,s,0.85,0,0,0,0);
  }
  for(const b of buildings){
    if(b.team===TEAM.E&&(G.state==='menu'||!visibleTo(0,b.x,b.z))) continue;
    const s=BUILDS[b.kind].size*1.25;
    pushInst(MESHES.shadow,b.x,0,b.z,0,s,1,s,0.9,0,0,0,0);
  }
  drawMesh(MESHES.shadow,0,1,shadowTex);
  gl.depthMask(true);

  // rocks
  for(const r of rocks){
    if(G.state!=='menu'&&!exploredBy(0,r.x,r.z)) continue;
    if(!onScreen(r.x,1.5,r.z)) continue;
    const s=r.r/3.4;
    pushInst(MESHES['rock'+r.v],r.x,0,r.z,r.v*1.1,s,s*1.15,s,1,1,1,1,0);
  }
  drawMesh(MESHES.rock0,0); drawMesh(MESHES.rock1,0); drawMesh(MESHES.rock2,0);
  // resource nodes
  for(const n of nodes){
    if(G.state!=='menu'&&!exploredBy(0,n.x,n.z)) continue;
    if(!onScreen(n.x,1.5,n.z)) continue;
    const f=n.amount/n.max, s=0.5+0.5*f;
    pushInst(MESHES.crystal,n.x,0,n.z,n.seed,s,s*(0.35+0.65*f),s,1,1,1,1,0);
  }
  drawMesh(MESHES.crystal,0);
  // buildings
  for(const b of buildings){
    if(b.team===TEAM.E&&(G.state==='menu'||!visibleTo(0,b.x,b.z))) continue;
    if(!onScreen(b.x,2.5,b.z)) continue;
    const tc=TEAM_COLOR[b.team];
    const s=b.done?1:(0.42+0.58*b.prog);
    pushInst(MESHES[b.kind],b.x,0,b.z,b.yaw,s,s,s,1,tc[0],tc[1],tc[2],b.flash);
  }
  // last-known enemy silhouettes
  if(G.state!=='menu') for(const [id,e2] of teams[0].lastSeen){
    const b=byId.get(id);
    if(b&&!b.dead&&visibleTo(0,b.x,b.z)) continue;
    if(!exploredBy(0,e2.x,e2.z)) continue;
    if(!onScreen(e2.x,2.5,e2.z)) continue;
    pushInst(MESHES[e2.kind],e2.x,0,e2.z,0,1,1,1,0.40,0.42,0.48,0.55,0);
  }
  for(const k of BUILD_ORDER) drawMesh(MESHES[k],0);
  // units
  for(const u of units){
    if(u.team===TEAM.E&&(G.state==='menu'||!visibleTo(0,u.x,u.z))) continue;
    if(!onScreen(u.x,u.y+1,u.z)) continue;
    const tc=TEAM_COLOR[u.team];
    const bob=u.fly?Math.sin(G.time*2+u.anim)*0.12:0;
    pushInst(MESHES[u.kind],u.x,u.y+bob,u.z,u.yaw,1,1,1,1,tc[0],tc[1],tc[2],u.flash);
  }
  for(const k of UNIT_ORDER) drawMesh(MESHES[k],0);
  // building placement ghost
  if(G.cmdMode==='place'&&G.placeKind){
    const g=ghostPos;
    if(g){
      const chk=canPlace(G.placeKind,g.x,g.z,TEAM.P);
      const d=BUILDS[G.placeKind];
      pushInst(MESHES[G.placeKind],g.x,0,g.z,0,1,1,1,0.55,
        chk.ok?0.25:1.0, chk.ok?1.0:0.25, chk.ok?0.45:0.2,0);
      gl.depthMask(false);
      drawMesh(MESHES[G.placeKind],1,0);
      gl.depthMask(true);
    }
  }

  // additive: projectiles + particles
  gl.depthMask(false);
  gl.blendFunc(gl.SRC_ALPHA,gl.ONE);
  for(const p of projs){
    if(!exploredBy(0,p.x,p.z)) continue;
    const yaw=Math.atan2(p.tx-p.x,p.tz-p.z);
    pushInst(MESHES.bolt,p.x,p.y,p.z,yaw,p.size,p.size,p.size,1,p.col[0],p.col[1],p.col[2],0);
  }
  drawMesh(MESHES.bolt,1,0);
  for(const p of fx){
    if(!exploredBy(0,p.x,p.z)) continue;
    const a=clamp(p.life/p.max,0,1);
    const s=p.size*(0.5+a*0.9);
    pushInst(MESHES.spark,p.x,p.y,p.z,0,s,s,s,a*(a*0.6+0.4),p.col[0],p.col[1],p.col[2],0);
  }
  drawMesh(MESHES.spark,1,0);
  gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA);
  gl.depthMask(true);
}
/* ================================================================
   2D OVERLAY (selection, health, orders)
   ================================================================ */
const hudC=$('gl'), hud=$('hud'), hctx=hud.getContext('2d');
function unitTopY(u){ return u.fly?u.y+1.0:(u.kind==='heavy'?2.3:(u.kind==='melee'?1.6:2.05)); }
function ringPath(x,z,r){
  hctx.beginPath(); let started=false;
  for(let i=0;i<=22;i++){
    const a=i/22*Math.PI*2;
    const p=project(x+Math.cos(a)*r,0.08,z+Math.sin(a)*r);
    if(!p) return false;
    if(!started){ hctx.moveTo(p.x,p.y); started=true; } else hctx.lineTo(p.x,p.y);
  }
  return true;
}
function drawOverlay(){
  hctx.clearRect(0,0,R.w,R.h);
  const eye=camEye();
  const selSet=new Set(G.sel);
  // selection rings
  hctx.lineWidth=2;
  for(const id of G.sel){
    const e=byId.get(id); if(!e||e.dead) continue;
    if(e.team===TEAM.E&&!visibleTo(0,e.x,e.z)) continue;
    const isB=!e.isUnit;
    const r=isB?BUILDS[e.kind].size*0.55:UNITS[e.kind].r*1.7;
    const c=e.team===TEAM.P?(isB?'#7dffcf':'#43e0ff'):'#ff6a4a';
    if(ringPath(e.x,e.z,r)){
      hctx.strokeStyle=c; hctx.globalAlpha=0.95;
      hctx.shadowColor=c; hctx.shadowBlur=8;
      hctx.stroke(); hctx.shadowBlur=0;
      hctx.globalAlpha=0.10; hctx.fillStyle=c; hctx.fill(); hctx.globalAlpha=1;
    }
  }
  // health bars
  for(const u of units){
    if(u.team===TEAM.E&&!visibleTo(0,u.x,u.z)) continue;
    const sel=selSet.has(u.id);
    if(!sel&&u.hp>=u.maxHp&&u!==hoverEnt) continue;
    if(!onScreen(u.x,u.y+1,u.z)) continue;
    const p=project(u.x,unitTopY(u),u.z); if(!p) continue;
    const dc=Math.hypot(u.x-eye[0],u.y-eye[1],u.z-eye[2]);
    const w=clamp(pickRadiusPx(UNITS[u.kind].r*2.6,dc),16,70);
    bar(p.x-w/2,p.y-9,w,5,u.hp/u.maxHp,u.team===TEAM.P);
  }
  for(const b of buildings){
    if(b.team===TEAM.E&&!visibleTo(0,b.x,b.z)) continue;
    const sel=selSet.has(b.id);
    if(!sel&&b.hp>=b.maxHp&&!b.done) { /* construction shown below */ }
    else if(!sel&&b.hp>=b.maxHp&&b.done) continue;
    const p=project(b.x,BUILDS[b.kind].size*0.9,b.z); if(!p) continue;
    const dc=Math.hypot(b.x-camEye()[0],2-camEye()[1],b.z-camEye()[2]);
    const w=clamp(pickRadiusPx(BUILDS[b.kind].size*0.8,dc),24,110);
    bar(p.x-w/2,p.y-8,w,6,b.hp/b.maxHp,b.team===TEAM.P);
    if(!b.done){
      hctx.fillStyle='rgba(255,215,130,.9)'; hctx.font='10px ui-monospace,monospace';
      hctx.textAlign='center';
      hctx.fillText(Math.floor(b.prog*100)+'%',p.x,p.y-12);
      hctx.textAlign='left';
    }
  }
  // order pings
  for(const g of pings){
    const age=(G.time-g.t)/1.4; if(age>1) continue;
    const r=(g.kind==='attack'?18:14)*(0.4+age*1.6);
    const col=g.kind==='attack'?'#ff6a4a':(g.kind==='gather'?'#ffd782':'#43e0ff');
    hctx.strokeStyle=col; hctx.globalAlpha=1-age; hctx.lineWidth=2;
    if(ringPath(g.x,g.z,r)) hctx.stroke();
    hctx.globalAlpha=1;
  }
  // drag rectangle
  if(drag.active){
    hctx.strokeStyle='#43e0ff'; hctx.lineWidth=1.5; hctx.setLineDash([5,4]);
    hctx.strokeRect(Math.min(drag.x0,drag.x1),Math.min(drag.y0,drag.y1),
      Math.abs(drag.x1-drag.x0),Math.abs(drag.y1-drag.y0));
    hctx.setLineDash([]);
    hctx.fillStyle='rgba(67,224,255,.10)';
    hctx.fillRect(Math.min(drag.x0,drag.x1),Math.min(drag.y0,drag.y1),
      Math.abs(drag.x1-drag.x0),Math.abs(drag.y1-drag.y0));
  }
}
function bar(x,y,w,h,ratio,friendly){
  ratio=clamp(ratio,0,1);
  hctx.fillStyle='rgba(2,6,10,.82)'; hctx.fillRect(x-1,y-1,w+2,h+2);
  const col=ratio>0.6?(friendly?'#3ee08a':'#ffb648'):(ratio>0.3?'#ffd782':'#ff5a5a');
  hctx.fillStyle=col; hctx.fillRect(x,y,w*ratio,h);
  hctx.strokeStyle='rgba(120,180,210,.45)'; hctx.lineWidth=1;
  hctx.strokeRect(x-0.5,y-0.5,w+1,h+1);
}

/* ================================================================
   MINIMAP
   ================================================================ */
const mini=$('mini'), mctx=mini.getContext('2d');
const miniBase=document.createElement('canvas'); miniBase.width=miniBase.height=PN;
const mctx2=miniBase.getContext('2d');
const miniImg=mctx2.createImageData(PN,PN);
const MINI=200;
function drawMini(){
  const d=miniImg.data;
  for(let i=0;i<PN*PN;i++){
    const cx=(i%PN+0.5)*CELL, cz=((i/PN|0)+0.5)*CELL;
    const f=fogData[i];
    let r,g,b;
    if(f===0){ r=3; g=5; b=9; }
    else{
      const n=terrainNoise(cx,cz);
      const t=clamp((n+1.1)/2.2,0,1);
      const k=f===255?1.0:0.52;
      r=(26+34*t)*k; g=(40+22*t)*k; b=(38+16*t)*k;
      if(n>0.55){ r+=14*k; g+=6*k; b-=4*k; }
    }
    d[i*4]=r; d[i*4+1]=g; d[i*4+2]=b; d[i*4+3]=255;
  }
  mctx2.putImageData(miniImg,0,0);
  mctx.imageSmoothingEnabled=false;
  mctx.clearRect(0,0,MINI,MINI);
  mctx.drawImage(miniBase,0,0,PN,PN,0,0,MINI,MINI);
  const S=MINI/MW;
  // known resource deposits
  for(const n of nodes){
    if(!exploredBy(0,n.x,n.z)) continue;
    const vis=visibleTo(0,n.x,n.z);
    mctx.fillStyle=vis?'rgba(150,255,255,.95)':'rgba(110,170,190,.6)';
    mctx.fillRect(n.x*S-2,n.z*S-2,4,4);
  }
  // last-known enemy structures
  for(const [id,e] of teams[0].lastSeen){
    const b=byId.get(id);
    if(b&&!b.dead&&visibleTo(0,b.x,b.z)) continue;
    if(!exploredBy(0,e.x,e.z)) continue;
    mctx.fillStyle='rgba(255,90,70,.45)';
    mctx.fillRect(e.x*S-3,e.z*S-3,6,6);
  }
  // units
  for(const u of units){
    if(u.team===TEAM.P){
      mctx.fillStyle=u.kind==='worker'?'#8ef0c0':'#43e0ff';
      const s=u.kind==='worker'?2:2.6;
      mctx.fillRect(u.x*S-s/2,u.z*S-s/2,s,s);
    } else {
      if(!visibleTo(0,u.x,u.z)) continue;
      mctx.fillStyle='#ff5a3c';
      mctx.fillRect(u.x*S-1.6,u.z*S-1.6,3.2,3.2);
    }
  }
  // buildings
  for(const b of buildings){
    if(b.team===TEAM.E&&!visibleTo(0,b.x,b.z)) continue;
    const s=Math.max(4,BUILDS[b.kind].size*S);
    mctx.fillStyle=b.team===TEAM.P?'#7dffcf':'#ff7a55';
    mctx.fillRect(b.x*S-s/2,b.z*S-s/2,s,s);
    if(!b.done){ mctx.strokeStyle='#ffd782'; mctx.lineWidth=1;
      mctx.strokeRect(b.x*S-s/2,b.z*S-s/2,s,s); }
  }
  // camera footprint (view frustum clipped to the map rectangle)
  const pts=[[0,0],[R.w,0],[R.w,R.h],[0,R.h]].map(p=>screenToGround(p[0],p[1]));
  if(pts.length===4&&pts.every(Boolean)){
    let poly=pts.map(p=>[p.x,p.z]);
    const edges=[[0,MW],[0,MW]];
    for(let ax=0;ax<2;ax++){
      const out=[];
      for(let i=0;i<poly.length;i++){
        const A=poly[i], B=poly[(i+1)%poly.length];
        const da=A[ax]-0, db=B[ax]-0;
        const inA=da>=0&&da<=edges[ax][1], inB=db>=0&&db<=edges[ax][1];
        if(inA) out.push(A);
        if((da<0)!==(db<0)){
          const t=(0-da)/(db-da); out.push([A[0]+(B[0]-A[0])*t, A[1]+(B[1]-A[1])*t]);
        } else if((da>edges[ax][1])!==(db>edges[ax][1])){
          const t=(edges[ax][1]-da)/(db-da);
          out.push([A[0]+(B[0]-A[0])*t, A[1]+(B[1]-A[1])*t]);
        }
      }
      poly=out;
      if(poly.length<3) break;
    }
    if(poly.length>=3){
      mctx.strokeStyle='rgba(255,255,255,.8)'; mctx.lineWidth=1.3;
      mctx.beginPath();
      poly.forEach((p,i)=>{ const x=clamp(p[0],0,MW)*S, y=clamp(p[1],0,MW)*S;
        i?mctx.lineTo(x,y):mctx.moveTo(x,y); });
      mctx.closePath(); mctx.stroke();
    }
  }
}
function miniToWorld(mx,mz){
  const r=mini.getBoundingClientRect();
  return { x:clamp((mx-r.left)/r.width*MW,0,MW), z:clamp((mz-r.top)/r.height*MW,0,MW) };
}
/* ================================================================
   HUD / UI
   ================================================================ */
function pushToast(msg,cls){
  const box=$('toasts');
  const d=document.createElement('div');
  d.className='toast '+(cls||''); d.textContent=msg;
  box.appendChild(d);
  while(box.children.length>4) box.firstChild.remove();
  setTimeout(()=>{ d.style.transition='opacity .45s'; d.style.opacity='0';
    setTimeout(()=>d.remove(),480); },2400);
}
function updateTopbar(){
  const T=teams[0];
  $('res').textContent=fmtNum(T.minerals);
  $('sup').textContent=T.supplyUsed+' / '+T.supplyCap;
  $('supFill').style.width=clamp(T.supplyUsed/Math.max(1,T.supplyCap)*100,0,100)+'%';
  $('supWrap').classList.toggle('low',T.supplyUsed>=T.supplyCap);
  $('income').textContent='+'+Math.round(T.income)+'/min';
  $('clock').textContent=fmtTime(G.time);
  let a='';
  if(G.cmdMode==='place') a='PLACE: green = valid · left-click to build · right-click cancels';
  else if(G.cmdMode==='attackmove') a='ATTACK-MOVE: click a destination';
  else if(G.cmdMode==='move') a='MOVE: click a destination';
  else if(G.cmdMode==='rally') a='RALLY: click a point for new units';
  else if(T.supplyUsed>=T.supplyCap) a='⚠ SUPPLY FULL — build a Supply Relay';
  else if(G.time-G.lastAlert<4) a='⚠ OUR FORCES ARE UNDER ATTACK';
  $('alert').textContent=a;
}
/* ---------- selection ---------- */
function pruneSel(){
  G.sel=G.sel.filter(id=>{ const e=byId.get(id); return e&&!e.dead; });
}
function refreshSel(){
  pruneSel();
  const name=$('selname'), sub=$('selsub'), stats=$('selstats'),
        list=$('sellist'), port=$('portrait'), hp=$('hpbar');
  if(!G.sel.length){
    name.textContent='No selection';
    sub.textContent='Left-click a unit or drag a box. Right-click issues orders.';
    stats.innerHTML=''; port.innerHTML=icon('help'); list.innerHTML='';
    hp.classList.add('hidden'); $('queue').classList.add('hidden');
    return;
  }
  const sel=G.sel.map(id=>byId.get(id));
  const e=sel[0];
  if(sel.length===1){
    const isB=!e.isUnit;
    const d=isB?BUILDS[e.kind]:UNITS[e.kind];
    port.innerHTML=icon(e.kind);
    port.style.color=e.team===TEAM.P?'#43e0ff':'#ff7a55';
    name.textContent=(e.team===TEAM.P?'':'[ENEMY] ')+d.name;
    sub.textContent=isB?(e.done?(BUILDS[e.kind].trains.length?'Production facility':'Structure')
                          :'Under construction '+Math.floor(e.prog*100)+'%')
                       :(d.role+' · '+(e.fly?'AIRBORNE':'GROUND')+' · '+DTYPE_LABEL[d.dtype]+' '+d.dmg);
    let s='';
    if(isB){
      s+='<span>HP <b>'+Math.ceil(e.hp)+'</b>/'+e.maxHp+'</span>';
      if(d.supply) s+='<span>Supply <b>+'+d.supply+'</b></span>';
      if(d.trains&&d.trains.length) s+='<span>Trains <b>'+d.trains.map(k=>UNITS[k].name).join(', ')+'</b></span>';
      if(d.weapon) s+='<span>Weapon <b>'+d.weapon.dmg+' '+DTYPE_LABEL[d.weapon.dtype]+'</b> rng '+d.weapon.range+'</span>';
      if(e.rally) s+='<span>Rally set</span>';
    } else {
      s+='<span>HP <b>'+Math.ceil(e.hp)+'</b>/'+e.maxHp+'</span>';
      s+='<span>DMG <b>'+Math.round(d.dmg*dmgMul(e.team))+'</b> '+DTYPE_LABEL[d.dtype]+'</span>';
      s+='<span>RNG <b>'+d.range+'</b></span><span>SPD <b>'+d.speed.toFixed(1)+'</b></span>';
      s+='<span>ARM <b>'+d.armor+'</b></span><span>SUP <b>'+d.supply+'</b></span>';
      s+='<span>TGT <b>'+(d.targets==='both'?'GND+AIR':'GROUND')+'</b></span>';
      if(e.carry>0) s+='<span>Cargo <b>'+e.carry+'</b></span>';
      if(e.order!=='idle') s+='<span>Order <b>'+e.order.toUpperCase()+'</b></span>';
    }
    stats.innerHTML=s;
    hp.classList.remove('hidden');
    $('hpfill').style.width=clamp(e.hp/e.maxHp*100,0,100)+'%';
    $('hpfill').style.background=e.hp/e.maxHp>0.6?'linear-gradient(90deg,#3ee08a,#9dffcf)':
      (e.hp/e.maxHp>0.3?'linear-gradient(90deg,#ffd782,#fff0c8)':'linear-gradient(90deg,#ff5a5a,#ffb0a0)');
    list.innerHTML='';
  } else {
    port.innerHTML=icon('attack'); port.style.color='#43e0ff';
    name.textContent=sel.length+' units selected';
    const sup=sel.reduce((a,u)=>a+UNITS[u.kind].supply,0);
    const hpSum=sel.reduce((a,u)=>a+u.hp,0), hpMax=sel.reduce((a,u)=>a+u.maxHp,0);
    sub.textContent='Supply '+sup+' · '+sel.filter(u=>u.kind==='worker').length+' workers';
    stats.innerHTML='<span>Group HP <b>'+Math.ceil(hpSum)+'</b>/'+hpMax+'</span>';
    hp.classList.remove('hidden');
    $('hpfill').style.width=clamp(hpSum/hpMax*100,0,100)+'%';
    $('hpfill').style.background='linear-gradient(90deg,#3ee08a,#9dffcf)';
    const groups={};
    for(const u of sel){ (groups[u.kind]=groups[u.kind]||[]).push(u); }
    list.innerHTML='';
    for(const k in groups){
      const arr=groups[k];
      const avg=arr.reduce((a,u)=>a+u.hp/u.maxHp,0)/arr.length;
      const div=document.createElement('div');
      div.className='sitem'; div.dataset.kind=k;
      div.innerHTML=icon(k)+'<div class="shp"><i style="width:'+(avg*100)+'%"></i></div>'+
        '<span class="cnt">'+arr.length+'</span>';
      div.title=UNITS[k].name;
      list.appendChild(div);
    }
    list.querySelectorAll('.sitem').forEach(el=>el.onclick=()=>{
      const k=el.dataset.kind;
      G.sel=G.sel.filter(id=>{ const u=byId.get(id); return u&&u.isUnit&&u.kind===k; });
      refreshSel(); renderCard(); SND.play('select');
    });
  }
  renderQueue();
}
/* ---------- command card ---------- */
let cmdData={};
function trainReady(kind){
  const d=UNITS[kind], T=teams[0];
  if(!hasBuilding(0,d.build)) return 'Needs '+BUILDS[d.build].name;
  if(T.minerals<d.cost) return 'Need '+d.cost+' Aetherium';
  if(T.supplyUsed+T.reserved+d.supply>T.supplyCap) return 'No supply — build Relay';
  return null;
}
function buildReady(kind){
  const d=BUILDS[kind], T=teams[0];
  if(d.req&&!hasBuilding(0,d.req)) return 'Needs '+BUILDS[d.req].name;
  if(T.minerals<d.cost) return 'Need '+d.cost+' Aetherium';
  return null;
}
function upgReady(kind){
  const d=UPGRADES[kind], T=teams[0];
  if(T.upgrades[kind]) return 'Researched';
  if(!hasBuilding(0,'lab')) return 'Needs Laboratory';
  if(T.minerals<d.cost) return 'Need '+d.cost+' Aetherium';
  return null;
}
function renderCard(){
  const box=$('cmds'); box.innerHTML=''; cmdData={};
  const sel=selUnits(), bsel=selBuildings();
  const cmds=[];
  const add=(o)=>cmds.push(o);
  if(G.cmdMode==='place'){
    add({ic:'cancel',nm:'Cancel',kb:'Esc',cmd:'cancelmode'});
    add({ic:G.placeKind,nm:BUILDS[G.placeKind].name,cmd:'noop',
      tip:'Left-click explored ground to place. Right-click or Esc cancels and refunds nothing (nothing spent yet).'});
  } else if(G.cmdMode==='rally'){
    add({ic:'cancel',nm:'Cancel',kb:'Esc',cmd:'cancelmode'});
    add({ic:'rally',nm:'Set rally',cmd:'noop'});
  } else if(sel.length){
    add({ic:'move',nm:'Move',kb:'M',cmd:'move',tip:'Move to the clicked point. Units path around obstacles.'});
    add({ic:'attack',nm:'Attack-move',kb:'A',cmd:'attack',
      tip:'Advance to the clicked point and engage anything encountered on the way.'});
    add({ic:'hold',nm:'Hold',kb:'H',cmd:'hold',
      tip:'Stand ground and fire only at targets in range. Never chase.'});
    add({ic:'stop',nm:'Stop',kb:'S',cmd:'stop',tip:'Cancel all current orders.'});
    if(sel.some(u=>u.kind==='worker')){
      for(const k of BUILD_ORDER){
        const d=BUILDS[k], why=buildReady(k);
        add({ic:k,nm:d.name.replace('Headquarters','HQ').replace('Supply ','Supply '),
          kb:BUILD_HOT[k],cost:d.cost,cmd:'build:'+k,dis:why,
          tip:'<b>'+d.name+'</b><br>'+d.blurb+'<br><span class="g">'+d.cost+' Aetherium</span> · '+d.time+'s build'+
              (d.supply?' · <b>+'+d.supply+' supply</b>':'')+(why?'<br><span class="r">'+why+'</span>':'')});
      }
    }
  } else if(bsel.length){
    const b=bsel[0], d=BUILDS[b.kind];
    if(d.trains.length){
      for(const k of d.trains){
        const u=UNITS[k], why=trainReady(k);
        add({ic:k,nm:u.name,kb:TRAIN_HOT[k],cost:u.cost,sup:u.supply,cmd:'train:'+k,dis:why,
          tip:'<b>'+u.name+'</b><br>'+u.blurb+'<br><span class="g">'+u.cost+' Aetherium</span> · <span class="g">'+
            u.supply+' supply</span> · '+u.time+'s<br>HP '+u.hp+' · DMG '+u.dmg+' '+DTYPE_LABEL[u.dtype]+
            ' · RNG '+u.range+' · ARM '+u.armor+' · hits '+u.targets+
            (why?'<br><span class="r">'+why+'</span>':'')});
      }
      add({ic:'rally',nm:'Rally',kb:'G',cmd:'rally',tip:'Set a destination where new units walk on spawn.'});
    }
    if(b.kind==='lab'){
      for(const k of UPGRADE_ORDER){
        const u=UPGRADES[k], why=upgReady(k);
        add({ic:k,nm:u.name,kb:UPG_HOT[k],cost:u.cost,cmd:'upg:'+k,dis:why,
          tip:'<b>'+u.name+'</b><br>'+u.blurb+'<br><span class="g">'+u.cost+' Aetherium</span> · '+u.time+'s'+
            (why?'<br><span class="r">'+why+'</span>':'')});
      }
    }
    if(b.queue.length) add({ic:'cancel',nm:'Cancel',kb:'X',cmd:'cancelq',
      tip:'Refunds 100% of the cancelled item\'s cost.'});
    if(!d.trains.length&&b.kind!=='lab'&&!b.queue.length)
      add({ic:'help',nm:d.name,cmd:'noop',tip:'<b>'+d.name+'</b><br>'+d.blurb});
  } else {
    add({ic:'help',nm:'Field manual',kb:'F1',cmd:'help',tip:'Controls, economy, supply and counters.'});
    add({ic:'pause',nm:'Pause',kb:'Esc',cmd:'pause'});
  }
  for(const c of cmds){
    const b=document.createElement('button');
    b.className='cmd'+(c.dis?' dis':'');
    b.dataset.cmd=c.cmd;
    b.innerHTML=(c.kb?'<span class="kb">'+c.kb+'</span>':'')+icon(c.ic)+
      '<span class="nm">'+c.nm+'</span>'+
      (c.cost?'<span class="cst">'+c.cost+'</span>':'')+
      (c.sup?'<span class="cst sup">+'+c.sup+'</span>':'')+
      (c.dis?'<span class="why">'+c.dis+'</span>':'');
    cmdData[c.cmd]=c.tip||c.dis||c.nm;
    box.appendChild(b);
  }
}
const BUILD_HOT={hq:'C',relay:'R',barracks:'B',lab:'L',factory:'F',airfield:'V',turret:'T'};
const TRAIN_HOT={worker:'W',melee:'R',ranged:'G',heavy:'E',air:'Y'};
const UPG_HOT={alloys:'A',weapons:'U',servos:'S',mining:'N',plating:'P'};
function renderQueue(){
  const q=$('queue'); const bsel=selBuildings();
  if(!bsel.length||!bsel[0].queue.length){ q.classList.add('hidden'); q.innerHTML=''; return; }
  const b=bsel[0];
  q.classList.remove('hidden'); q.innerHTML='';
  b.queue.forEach((it,i)=>{
    const d=it.type==='unit'?UNITS[it.kind]:UPGRADES[it.kind];
    const el=document.createElement('div'); el.className='qslot';
    el.innerHTML=icon(it.kind)+
      '<div class="qp" style="height:'+(i===0?clamp(it.prog/it.time*100,0,100):0)+'%"></div>'+
      (i===0?'<div class="qx" data-q="'+i+'">✕</div>':'')+
      '<div class="qn">'+(i===0?Math.ceil(it.time-it.prog)+'s':'')+'</div>';
    el.title=d.name+' — click ✕ to cancel (full refund)';
    if(i===0) el.querySelector('.qx').onclick=(ev)=>{ ev.stopPropagation(); cancelQueue(b,0); };
    q.appendChild(el);
  });
}
function refreshUI(){ refreshSel(); renderCard(); }

/* ---------- tooltips ---------- */
const tipEl=$('tip');
function showTip(html,x,y){
  tipEl.innerHTML=html; tipEl.classList.remove('hidden');
  const r=tipEl.getBoundingClientRect();
  tipEl.style.left=clamp(x+16,6,window.innerWidth-r.width-8)+'px';
  tipEl.style.top=clamp(y-r.height-12,6,window.innerHeight-r.height-8)+'px';
}
function hideTip(){ tipEl.classList.add('hidden'); }
$('cmds').addEventListener('mouseover',e=>{
  const b=e.target.closest('.cmd'); if(!b) return;
  const t=cmdData[b.dataset.cmd]; if(t) showTip(t,e.clientX,e.clientY);
});
$('cmds').addEventListener('mouseout',e=>{ if(e.target.closest('.cmd')) hideTip(); });
$('cmds').addEventListener('mouseleave',hideTip);
/* ================================================================
   INPUT
   ================================================================ */
let drag={active:false,x0:0,y0:0,x1:0,y1:0};
let ghostPos=null, hoverEnt=null, midDrag=null;
let mouse={x:0,y:0,in:false};
const keys={};
function clearMode(){ G.cmdMode=null; G.placeKind=null; }

function handleCommand(cmd){
  const bsel=selBuildings();
  if(cmd==='move'){ G.cmdMode='move'; pushToast('Move — click a destination','good'); }
  else if(cmd==='attack'){ G.cmdMode='attackmove'; pushToast('Attack-move — click a destination','warn'); }
  else if(cmd==='hold') holdSelected();
  else if(cmd==='stop') stopSelected();
  else if(cmd==='cancelmode'){ clearMode(); }
  else if(cmd==='help') toggleHelp(true);
  else if(cmd==='pause') togglePause();
  else if(cmd==='rally'){ G.cmdMode='rally'; pushToast('Rally — click a point for new units','good'); }
  else if(cmd==='cancelq'){ if(bsel[0]) cancelQueue(bsel[0],0); }
  else if(cmd.indexOf('build:')===0){
    const k=cmd.slice(6), why=buildReady(k);
    if(why){ pushToast(why,'bad'); SND.play('err'); return; }
    G.cmdMode='place'; G.placeKind=k;
  }
  else if(cmd.indexOf('train:')===0){
    const k=cmd.slice(6), b=bsel[0]; if(!b) return;
    const r=enqueue(b,'unit',k);
    if(!r.ok){ pushToast(r.why,'bad'); SND.play('err'); } else refreshUI();
  }
  else if(cmd.indexOf('upg:')===0){
    const k=cmd.slice(4), b=bsel[0]; if(!b) return;
    const r=enqueue(b,'upg',k);
    if(!r.ok){ pushToast(r.why,'bad'); SND.play('err'); } else refreshUI();
  }
  renderCard();
}
$('cmds').addEventListener('click',e=>{
  const b=e.target.closest('.cmd'); if(!b) return;
  SND.resume();
  if(b.classList.contains('dis')){
    const t=cmdData[b.dataset.cmd];
    pushToast(t&&t.indexOf('<')>=0?t.replace(/<[^>]+>/g,''):t,'bad'); SND.play('err'); return;
  }
  handleCommand(b.dataset.cmd);
});

function tryPlaceAt(mx,my,keep){
  const g=screenToGround(mx,my); if(!g) return;
  const workers=selUnits().filter(u=>u.kind==='worker');
  const r=placeBuilding(G.placeKind,g.x,g.z,TEAM.P,workers[0]);
  if(!r.ok){ pushToast(r.why,'bad'); SND.play('err'); return; }
  SND.play('place');
  if(!keep){ clearMode(); refreshUI(); } else renderCard();
}
glc.addEventListener('contextmenu',e=>e.preventDefault());
glc.addEventListener('mousedown',e=>{
  SND.resume();
  if(G.state!=='play') return;
  const mx=e.clientX, my=e.clientY;
  if(e.button===1){ midDrag={x:mx,y:my}; e.preventDefault(); return; }
  if(e.button===2){
    e.preventDefault();
    if(G.cmdMode){ clearMode(); refreshUI(); SND.play('ack'); return; }
    const g=screenToGround(mx,my); if(!g) return;
    issueOrder(g.x,g.z,pickAt(mx,my),e.shiftKey);
    return;
  }
  if(e.button!==0) return;
  if(G.cmdMode==='place'){ tryPlaceAt(mx,my,e.shiftKey); return; }
  if(G.cmdMode==='attackmove'||G.cmdMode==='move'||G.cmdMode==='rally'){
    const g=screenToGround(mx,my); if(!g) return;
    if(G.cmdMode==='rally'){
      for(const b of selBuildings()) b.rally={x:g.x,z:g.z};
      pushToast('Rally point set','good');
    } else {
      setMoveOrder(selUnits(),g.x,g.z,G.cmdMode==='attackmove'?'amove':'move');
      orderFeedback(g.x,g.z,G.cmdMode==='attackmove'?'attack':'move');
      SND.play('cmd');
    }
    if(!e.shiftKey) clearMode();
    refreshUI(); return;
  }
  drag={active:true,x0:mx,y0:my,x1:mx,y1:my};
});
window.addEventListener('mousemove',e=>{
  mouse.x=e.clientX; mouse.y=e.clientY; mouse.in=true;
  if(midDrag){
    const dx=e.clientX-midDrag.x, dy=e.clientY-midDrag.y;
    midDrag={x:e.clientX,y:e.clientY};
    if(e.shiftKey||e.ctrlKey){ G.cam.yaw-=dx*0.006; }
    else { panCamera(-dx*0.9,-dy*0.9); }
    return;
  }
  if(drag.active){ drag.x1=e.clientX; drag.y1=e.clientY; }
  if(G.state==='play') hoverEnt=pickAt(e.clientX,e.clientY);
  const gl=glc.style;
  if(G.cmdMode==='place') gl.cursor='none';
  else if(hoverEnt) gl.cursor=hoverEnt.team===TEAM.P?'pointer':'crosshair';
  else gl.cursor='default';
});
window.addEventListener('mouseup',e=>{
  if(midDrag&&e.button===1){ midDrag=null; return; }
  if(e.button!==0||!drag.active) return;
  drag.active=false;
  const dx=Math.abs(drag.x1-drag.x0), dy=Math.abs(drag.y1-drag.y0);
  if(dx>6||dy>6){
    const x0=Math.min(drag.x0,drag.x1), x1=Math.max(drag.x0,drag.x1);
    const y0=Math.min(drag.y0,drag.y1), y1=Math.max(drag.y0,drag.y1);
    if(!selectInBox(x0,y0,x1,y1,e.shiftKey)) { if(!e.shiftKey){ G.sel=[]; refreshUI(); } }
    else { refreshUI(); SND.play('select'); }
  } else {
    const hit=pickAt(e.clientX,e.clientY);
    if(hit){
      if(e.shiftKey&&hit.team===TEAM.P&&hit.isUnit){
        const i=G.sel.indexOf(hit.id);
        if(i>=0) G.sel.splice(i,1); else G.sel.push(hit.id);
      } else G.sel=[hit.id];
      refreshUI(); SND.play('select');
    } else if(!e.shiftKey){ G.sel=[]; refreshUI(); }
  }
});
glc.addEventListener('dblclick',e=>{
  const hit=pickAt(e.clientX,e.clientY);
  if(!hit||!hit.isUnit||hit.team!==TEAM.P) return;
  const list=[];
  for(const u of units){
    if(u.team!==TEAM.P||u.kind!==hit.kind) continue;
    if(!onScreen(u.x,u.y+1,u.z)) continue;
    list.push(u.id);
  }
  if(list.length){ G.sel=list; refreshUI(); SND.play('select');
    pushToast('Selected all '+hit.kind+' on screen','good'); }
});
glc.addEventListener('wheel',e=>{
  e.preventDefault();
  G.cam.tdist=clamp(G.cam.tdist*(1+Math.sign(e.deltaY)*0.09),26,180);
},{passive:false});

/* ---------- minimap ---------- */
mini.addEventListener('contextmenu',e=>e.preventDefault());
mini.addEventListener('mousedown',e=>{
  SND.resume(); if(G.state!=='play') return;
  const w=miniToWorld(e.clientX,e.clientY);
  if(e.button===0){ G.cam.x=w.x; G.cam.z=w.z; clampCam(); SND.play('ping'); }
  else if(e.button===2){
    if(G.cmdMode){ clearMode(); refreshUI(); return; }
    if(selUnits().length){ issueOrder(w.x,w.z,null,e.shiftKey); }
    else { G.cam.x=w.x; G.cam.z=w.z; clampCam(); }
  }
});

/* ---------- camera ---------- */
function panCamera(rx,rz){
  const yaw=G.cam.yaw;
  const fx=-Math.sin(yaw), fz=-Math.cos(yaw);
  const rxx=-fz, rzz=fx;
  G.cam.x+=(rxx*rx+fx*rz);
  G.cam.z+=(rzz*rx+fz*rz);
  clampCam();
}
function updateCamera(dt){
  const sp=(52+G.cam.dist*1.15)*dt;
  let px=0,pz=0;
  if(keys.ArrowUp) pz+=1;
  if(keys.ArrowDown) pz-=1;
  if(keys.ArrowLeft) px-=1;
  if(keys.ArrowRight) px+=1;
  if(keys.KeyW) pz+=1;
  if(keys.KeyS) pz-=1;
  if(keys.KeyA) px-=1;
  if(keys.KeyD) px+=1;
  if(mouse.in&&G.state==='play'&&!drag.active&&!midDrag){
    const E=8;
    if(mouse.x<E) px-=1; else if(mouse.x>R.w-E) px+=1;
    if(mouse.y<E) pz+=1; else if(mouse.y>R.h-E&&mouse.y<R.h-264) pz-=1;
  }
  if(px||pz){ const L=Math.hypot(px,pz)||1; panCamera(px/L*sp,pz/L*sp); }
  if(keys.KeyQ) G.cam.yaw+=1.7*dt;
  if(keys.KeyE) G.cam.yaw-=1.7*dt;
  G.cam.dist+=(G.cam.tdist-G.cam.dist)*Math.min(1,dt*9);
  clampCam();
  if(G.cmdMode==='place') ghostPos=screenToGround(mouse.x,mouse.y);
  else ghostPos=null;
}

/* ---------- keyboard ---------- */
function toggleHelp(on){
  const el=$('ov-help');
  const show=on!==undefined?on:el.classList.contains('hidden');
  el.classList.toggle('hidden',!show);
}
function togglePause(force){
  if(G.state==='menu'||G.state==='end') return;
  const show=force!==undefined?force:G.state!=='pause';
  if(show){ G.state='pause'; $('ov-pause').classList.remove('hidden'); }
  else { G.state='play'; $('ov-pause').classList.add('hidden'); }
}
window.addEventListener('keydown',e=>{
  if(e.target&&/INPUT|TEXTAREA/.test(e.target.tagName)) return;
  SND.resume();
  const k=e.key;
  if(k==='F1'){ e.preventDefault(); toggleHelp(); return; }
  if(k==='Escape'){
    e.preventDefault();
    if(!$('ov-help').classList.contains('hidden')){ toggleHelp(false); return; }
    if(G.cmdMode){ clearMode(); refreshUI(); return; }
    if(G.state==='play'||G.state==='pause') togglePause();
    return;
  }
  if(k==='p'||k==='P'){ togglePause(); return; }
  if(k==='F10'){ e.preventDefault(); SND.muted=!SND.muted;
    $('btnMute').classList.toggle('on',SND.muted); return; }
  if(G.state!=='play') return;
  if(e.ctrlKey&&k>='1'&&k<='9'){ e.preventDefault();
    G.groups[k]=G.sel.slice();
    pushToast('Control group '+k+' set ('+G.sel.length+')','good'); SND.play('ack'); return; }
  if(k>='1'&&k<='9'){
    const g=G.groups[k]||[];
    const alive=g.filter(id=>{const x=byId.get(id); return x&&!x.dead;});
    if(alive.length){
      const now=performance.now();
      if(lastGroup===k&&now-lastGroupT<450){
        const e0=byId.get(alive[0]);
        G.cam.x=e0.x; G.cam.z=e0.z; clampCam();
      }
      G.sel=alive.slice(); refreshUI(); SND.play('select');
      lastGroup=k; lastGroupT=now;
    } else pushToast('Control group '+k+' is empty','bad');
    return;
  }
  // command hotkeys
  const sel=selUnits(), bsel=selBuildings();
  if(sel.length){
    if(k==='a'||k==='A'){ handleCommand('attack'); return; }
    if(k==='s'||k==='S'){ handleCommand('stop'); return; }
    if(k==='h'||k==='H'){ handleCommand('hold'); return; }
    if(k==='m'||k==='M'){ handleCommand('move'); return; }
    if(sel.some(u=>u.kind==='worker')){
      for(const key in BUILD_HOT) if(BUILD_HOT[key]===k.toUpperCase()){ handleCommand('build:'+key); return; }
    }
  }
  if(bsel.length){
    const b=bsel[0];
    if(b.queue.length&&(k==='x'||k==='X')){ handleCommand('cancelq'); return; }
    if(BUILDS[b.kind].trains.length){
      for(const key in TRAIN_HOT) if(TRAIN_HOT[key]===k.toUpperCase()){ handleCommand('train:'+key); return; }
      if((k==='g'||k==='G')&&b.rally!==undefined){ handleCommand('rally'); return; }
    }
    if(b.kind==='lab'){ for(const key in UPG_HOT) if(UPG_HOT[key]===k.toUpperCase()){ handleCommand('upg:'+key); return; } }
  }
  // camera keys not consumed as hotkeys
  if(k==='q'||k==='Q') keys.KeyQ=1;
  else if(k==='e'||k==='E') keys.KeyE=1;
  else if(k==='w'||k==='W') keys.KeyW=1;
  else if(k==='d'||k==='D') keys.KeyD=1;
  else if(k==='ArrowUp'||k==='ArrowDown'||k==='ArrowLeft'||k==='ArrowRight'){ keys[k]=1; e.preventDefault(); }
  else if(k==='a'||k==='A') keys.KeyA=1;
  else if(k==='s'||k==='S') keys.KeyS=1;
});
window.addEventListener('keyup',e=>{
  const k=e.key;
  if(k==='q'||k==='Q') keys.KeyQ=0;
  else if(k==='e'||k==='E') keys.KeyE=0;
  else if(k==='w'||k==='W') keys.KeyW=0;
  else if(k==='d'||k==='D') keys.KeyD=0;
  else if(k==='a'||k==='A') keys.KeyA=0;
  else if(k==='s'||k==='S') keys.KeyS=0;
  else keys[k]=0;
});
let lastGroup=0,lastGroupT=0;
/* ================================================================
   MATCH LIFECYCLE
   ================================================================ */
function initMatch(seed){
  units=[]; buildings=[]; nodes=[]; projs=[]; fx=[]; pings=[];
  byId=new Map(); idSeq=1; pathQueue=[];
  G.time=0; G.over=false; G.sel=[]; G.groups={}; G.cine=0;
  clearMode(); G.hoverId=0; hoverEnt=null;
  G.stats={mined:0,kills:0,lost:0,built:0,unitsTrained:0};
  alertThrottle={unit:-99,base:-99}; G.lastAlert=-99;
  visionT=0; assignT=0; aiT=0;
  genMap(seed);
  initTeams(); initHash(); initAI(TEAM.E);
  spawnBuilding('hq',TEAM.P,spawn[0].x,spawn[0].z,true);
  spawnBuilding('hq',TEAM.E,spawn[1].x,spawn[1].z,true);
  for(let i=0;i<4;i++)
    spawnUnit('worker',TEAM.P,spawn[0].x+(i-1.5)*3.0,spawn[0].z+8);
  for(let i=0;i<4;i++)
    spawnUnit('worker',TEAM.E,spawn[1].x+(i-1.5)*3.0,spawn[1].z-8);
  rebuildHash(); rebuildBHash();
  updateVision(TEAM.P); updateVision(TEAM.E); buildFogTexture(); updateGhosts(TEAM.P);
  autoAssignWorkers(TEAM.P); autoAssignWorkers(TEAM.E);
  G.cam.x=spawn[0].x; G.cam.z=spawn[0].z;
  G.cam.yaw=-Math.PI*0.75; G.cam.dist=G.cam.tdist=74;
  refreshUI(); updateTopbar(); drawMini();
}
function startMatch(){
  SND.resume();
  initMatch((Math.random()*1e9)|0);
  G.state='play';
  $('ov-start').classList.add('hidden');
  $('ov-pause').classList.add('hidden');
  $('ov-end').classList.add('hidden');
  toggleHelp(false);
  pushToast('Establish your economy — scouts report enemy activity later','good');
  SND.play('ready');
}
function endMatch(winner){
  if(G.over) return;
  G.over=true; G.state='end';
  const win=winner===TEAM.P;
  const t=$('endTitle');
  t.textContent=win?'VICTORY':'DEFEAT';
  t.className='endtitle '+(win?'win':'lose');
  $('endText').innerHTML=win
    ? 'All Rust Legion Headquarters destroyed. The Cinderfall Expanse belongs to the Aurora Expedition.'
    : 'Your final Headquarters has fallen. The expedition is wiped from the frontier.';
  const s=G.stats;
  $('endStats').innerHTML=
    '<div class="stat"><b>'+fmtTime(G.time)+'</b><span>DURATION</span></div>'+
    '<div class="stat"><b>'+fmtNum(s.mined)+'</b><span>AETHERIUM MINED</span></div>'+
    '<div class="stat"><b>'+s.unitsTrained+'</b><span>UNITS TRAINED</span></div>'+
    '<div class="stat"><b>'+s.kills+'</b><span>ENEMIES DESTROYED</span></div>'+
    '<div class="stat"><b>'+s.lost+'</b><span>UNITS LOST</span></div>'+
    '<div class="stat"><b>'+s.built+'</b><span>STRUCTURES BUILT</span></div>';
  $('ov-end').classList.remove('hidden');
  SND.play(win?'win':'lose');
}
function restart(){ startMatch(); }

/* ================================================================
   UI WIRING
   ================================================================ */
document.querySelectorAll('.diff button').forEach(b=>{
  b.onclick=()=>{ document.querySelectorAll('.diff button').forEach(x=>x.classList.remove('sel'));
    b.classList.add('sel'); G.diff=+b.dataset.diff; SND.play('select'); };
});
$('btnStart').onclick=()=>startMatch();
$('btnResume').onclick=()=>togglePause(false);
$('btnPauseHelp').onclick=()=>toggleHelp(true);
$('btnRestart1').onclick=()=>restart();
$('btnRestart2').onclick=()=>restart();
$('btnCloseHelp').onclick=()=>toggleHelp(false);
$('btnCloseHelp2').onclick=()=>toggleHelp(false);
$('btnHelp').onclick=()=>toggleHelp();
$('btnPause').onclick=()=>togglePause();
$('btnMute').onclick=()=>{ SND.resume(); SND.muted=!SND.muted;
  $('btnMute').classList.toggle('on',SND.muted);
  pushToast(SND.muted?'Audio muted':'Audio on','good'); };

/* ================================================================
   RESIZE + MAIN LOOP
   ================================================================ */
function resize(){
  const dpr=Math.min(window.devicePixelRatio||1,1.6);
  const w=window.innerWidth, h=window.innerHeight;
  R.w=w; R.h=h; R.dpr=dpr;
  glc.width=Math.round(w*dpr); glc.height=Math.round(h*dpr);
  hud.width=Math.round(w*dpr); hud.height=Math.round(h*dpr);
  hctx.setTransform(dpr,0,0,dpr,0,0);
  const mr=mini.getBoundingClientRect();
  if(mr.width){ mini.width=Math.round(mr.width*dpr); mini.height=Math.round(mr.height*dpr);
    mctx.setTransform(dpr,0,0,dpr,0,0); }
}
window.addEventListener('resize',()=>{ resize(); });

let last=performance.now(), acc=0, uiT=0, miniT=0;
function frame(now){
  requestAnimationFrame(frame);
  let dt=(now-last)/1000; last=now;
  if(dt>0.25) dt=0.25;

  if(G.state==='play'){
    acc+=dt; let steps=0;
    while(acc>=TICK&&steps<5){ tick(TICK); acc-=TICK; steps++; }
    if(steps>=5) acc=0;
  } else if(G.state==='menu'){
    G.cam.x=lerp(G.cam.x,160,Math.min(1,dt*0.6));
    G.cam.z=lerp(G.cam.z,160,Math.min(1,dt*0.6));
    G.cam.yaw+=dt*0.045;
    G.cam.tdist=150;
  }
  updateCamera(dt);
  renderScene();
  drawOverlay();
  uiT+=dt;
  if(uiT>0.14){ uiT=0;
    if(G.state==='play'||G.state==='pause'){ updateTopbar(); renderQueue(); }
    if(G.state==='play'){ miniT++; if(miniT%2===0) drawMini(); }
  }
}

/* ================================================================
   BOOT
   ================================================================ */
function boot(){
  if(!initGL()) return;
  SND.init();
  tex=makeRadialTex(); shadowTex=tex;
  gridTex=makeGridTex();
  makeFogTex();
  buildAllMeshes();
  resize();
  initMatch(1337);
  G.state='menu';
  $('loading').classList.add('hidden');
  updateTopbar();
  requestAnimationFrame(frame);
}
let gridTex=null, shadowTex=null;
try{ boot(); }
catch(err){
  const l=$('loading');
  if(l){ l.style.display='flex'; l.textContent='INIT ERROR: '+err.message; }
  // eslint-disable-next-line no-console
  console.error(err);
}
</script>
</body>
</html>
<!-- agent-meta {"model":"xiaomi/mimo-v2.6-flash","provider":"openrouter","persona":"full","sessionId":"3d29b6a5-2711-4751-a1de-8b4e4a9f643d","tokensIn":588731,"tokensOut":3699,"tokensTotal":592430,"cost":0.0096729976,"turns":14,"toolCalls":13,"failedToolCalls":0,"timestamp":"2026-10-01T01:53:31.025Z"} -->