RTS
Mimo v2.6 Flash · full

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 · 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 & 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"} -->