RTS
Mimo v2.6 Pro · 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 124,733 bytes · SHA-256 d66d071bbb03
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>FRONTIER WAR — 3D Real-Time Strategy</title>
<style>
*{box-sizing:border-box}
html,body{margin:0;padding:0;height:100%;overflow:hidden;background:#04070d;color:#cfe6ff;
font-family:"Segoe UI",Roboto,system-ui,-apple-system,sans-serif;-webkit-user-select:none;user-select:none}
canvas{display:block}
#view{position:absolute;inset:0;width:100vw;height:100vh;cursor:crosshair}
#hud{position:absolute;inset:0;pointer-events:none;display:flex;flex-direction:column;justify-content:space-between;z-index:5}
.spacer{flex:1}
/* ---------- TOP BAR ---------- */
#topbar{display:flex;align-items:center;gap:10px;padding:8px 12px;
background:linear-gradient(180deg,rgba(6,14,26,.95),rgba(6,14,26,.55) 70%,transparent);
border-bottom:1px solid rgba(60,210,255,.16)}
.stat{display:flex;flex-direction:column;min-width:86px;padding:3px 12px;border-left:2px solid rgba(53,224,255,.4)}
.stat .lbl{font-size:9px;letter-spacing:.18em;color:#6fa9c8}
.stat .val{font-size:19px;font-weight:600;letter-spacing:.04em;line-height:1.15}
#minerals{color:#7ef2ff}
#supply.warn{color:#ffb347}
#supply.bad{color:#ff5d7a}
.btn{pointer-events:auto;background:rgba(18,38,60,.85);color:#bfe6ff;border:1px solid rgba(60,210,255,.3);
padding:7px 13px;border-radius:7px;font-size:11px;letter-spacing:.12em;cursor:pointer;transition:.15s}
.btn:hover{background:rgba(30,90,130,.95);border-color:#35e0ff;color:#fff;box-shadow:0 0 14px rgba(53,224,255,.35)}
.btn.big{font-size:17px;padding:15px 42px;letter-spacing:.22em;background:rgba(24,80,116,.9);border-color:#35e0ff}
/* ---------- LOG / TOASTS ---------- */
#log{position:absolute;right:14px;top:62px;width:320px;display:flex;flex-direction:column;gap:6px;z-index:6}
.toast{background:rgba(8,20,34,.9);border-left:3px solid #35e0ff;padding:7px 11px;font-size:12px;
border-radius:0 6px 6px 0;opacity:.96;box-shadow:0 3px 14px rgba(0,0,0,.4);animation:slide .25s ease}
.toast.warn{border-color:#ffb347}
.toast.bad{border-color:#ff5d7a}
.toast.good{border-color:#4dff9e}
@keyframes slide{from{transform:translateX(24px);opacity:0}to{transform:none;opacity:.96}}
/* ---------- BOTTOM HUD ---------- */
#bottombar{display:flex;align-items:flex-end;gap:10px;padding:0 12px 12px}
.panel{background:linear-gradient(180deg,rgba(11,24,40,.93),rgba(8,18,32,.96));border:1px solid rgba(60,210,255,.22);
border-radius:11px;pointer-events:auto;box-shadow:0 -4px 26px rgba(0,0,0,.5)}
#selpanel{width:296px;min-height:168px;padding:10px 12px}
#cmdpanel{flex:1;min-height:168px;padding:10px 12px;max-width:640px}
#minipanel{width:222px;padding:8px}
.ptitle{font-size:10px;letter-spacing:.2em;color:#6fa9c8;margin-bottom:7px;display:flex;justify-content:space-between}
#selTitle{font-size:14px;font-weight:600;letter-spacing:.05em}
#selBody{font-size:11.5px;line-height:1.65;color:#a9c9e2}
#selBody b{color:#e8f6ff;font-weight:600}
.bar{height:6px;border-radius:3px;background:rgba(255,255,255,.12);overflow:hidden;margin:3px 0 7px}
.bar i{display:block;height:100%;background:linear-gradient(90deg,#25d6a8,#7ef2ff)}
.bar.hp i{background:linear-gradient(90deg,#2bd66e,#a6ff7a)}
.bar.mp i{background:linear-gradient(90deg,#ffb347,#ff7a3c)}
.iconrow{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px}
.picon{width:38px;height:38px;border-radius:7px;border:1px solid rgba(60,210,255,.25);background:rgba(24,52,80,.7);
display:flex;align-items:center;justify-content:center;font-size:17px;position:relative}
.picon span{position:absolute;right:2px;bottom:0;font-size:9px;color:#bfe6ff}
#cmdGrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(96px,1fr));gap:7px}
.cmd{position:relative;min-height:56px;padding:5px 6px 4px;border:1px solid rgba(60,210,255,.24);border-radius:8px;
background:rgba(21,46,72,.72);color:#d6ecff;cursor:pointer;font-size:10.5px;text-align:left;transition:.12s;
display:flex;flex-direction:column;justify-content:space-between}
.cmd:hover{background:rgba(38,96,138,.95);border-color:#35e0ff;transform:translateY(-1px)}
.cmd .nm{font-weight:600;letter-spacing:.03em;line-height:1.15}
.cmd .sub{font-size:9px;color:#8fc4de;letter-spacing:.04em}
.cmd .hk{position:absolute;top:3px;right:5px;font-size:9px;color:#5fd4ff;opacity:.85}
.cmd.disabled{opacity:.42;cursor:not-allowed;filter:grayscale(.55)}
.cmd.disabled:hover{transform:none;border-color:rgba(60,210,255,.24);background:rgba(21,46,72,.72)}
.cmd.active{border-color:#4dff9e;box-shadow:0 0 12px rgba(77,255,158,.35)}
#queue{display:flex;gap:6px;margin-top:9px;min-height:34px;flex-wrap:wrap}
.qitem{width:46px;height:46px;border-radius:7px;border:1px solid rgba(60,210,255,.3);background:rgba(20,44,70,.8);
position:relative;overflow:hidden;cursor:pointer;font-size:9px;display:flex;align-items:center;justify-content:center;text-align:center;padding:2px}
.qitem i{position:absolute;left:0;bottom:0;width:100%;background:rgba(53,224,255,.32)}
.qitem span{position:relative;z-index:2}
.qhead{font-size:9px;letter-spacing:.18em;color:#6fa9c8;margin-top:8px}
#minimap{width:206px;height:206px;border-radius:7px;border:1px solid rgba(60,210,255,.3);cursor:crosshair;background:#04101c}
.mmlabel{font-size:9px;letter-spacing:.16em;color:#6fa9c8;display:flex;justify-content:space-between;margin-top:5px}
/* ---------- OVERLAYS ---------- */
.overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
background:radial-gradient(ellipse at 50% 35%,rgba(12,30,52,.9),rgba(3,7,14,.97));z-index:30;pointer-events:auto}
.overlay.hidden{display:none}
.card{width:min(1000px,92vw);max-height:92vh;overflow:auto;padding:30px 38px;border-radius:16px;
background:linear-gradient(160deg,rgba(14,32,54,.96),rgba(8,18,32,.98));border:1px solid rgba(60,210,255,.28);
box-shadow:0 24px 90px rgba(0,0,0,.7)}
h1{margin:0;font-size:clamp(38px,7vw,82px);letter-spacing:.14em;font-weight:800;line-height:.95;
background:linear-gradient(180deg,#eafcff,#37d9ff 55%,#1b7fb8);-webkit-background-clip:text;background-clip:text;color:transparent}
h1 em{font-style:normal;background:linear-gradient(180deg,#ffd9b0,#ff7a3c 60%,#b23c14);-webkit-background-clip:text;background-clip:text;color:transparent}
.tag{margin:10px 0 22px;font-size:13px;letter-spacing:.28em;color:#79b6d6}
.cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:20px;margin:18px 0 26px}
.col h3{margin:0 0 9px;font-size:11px;letter-spacing:.22em;color:#35e0ff}
.col ul{margin:0;padding-left:17px;font-size:12.5px;line-height:1.85;color:#a9c9e2}
.col li{margin-bottom:3px}
.col b{color:#eaf6ff}
.kbd{display:inline-block;padding:1px 7px;border-radius:5px;border:1px solid rgba(60,210,255,.35);
background:rgba(20,48,76,.8);font-size:11px;color:#bfe6ff;margin:0 2px}
.rules{border:1px solid rgba(255,122,60,.35);background:rgba(60,26,14,.42);border-radius:11px;padding:14px 18px;margin:6px 0 22px}
.rules h3{color:#ff9d5c;margin:0 0 8px;font-size:11px;letter-spacing:.22em}
.rules p{margin:5px 0;font-size:13px;color:#ffd9c2;line-height:1.6}
.endtitle{font-size:clamp(34px,6vw,64px);letter-spacing:.22em;margin:0 0 6px;font-weight:800}
.won{color:#4dff9e;text-shadow:0 0 34px rgba(77,255,158,.5)}
.lost{color:#ff5d7a;text-shadow:0 0 34px rgba(255,93,122,.5)}
.statgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin:22px 0}
.sbox{background:rgba(16,38,62,.7);border:1px solid rgba(60,210,255,.2);border-radius:10px;padding:12px 14px}
.sbox .k{font-size:9px;letter-spacing:.18em;color:#6fa9c8}
.sbox .v{font-size:24px;font-weight:600;color:#eaf6ff}
.hidden{display:none!important}
#help{z-index:40}
#help .card{width:min(920px,92vw)}
table{width:100%;border-collapse:collapse;font-size:12px;margin:8px 0 16px}
th,td{border:1px solid rgba(60,210,255,.18);padding:6px 9px;text-align:left}
th{background:rgba(20,48,76,.75);color:#7fd8ff;font-size:10px;letter-spacing:.14em}
td{color:#b6d4ea}
.tagrow{display:flex;gap:6px;flex-wrap:wrap;margin:6px 0 12px}
.tagpill{font-size:10px;letter-spacing:.1em;padding:4px 10px;border-radius:20px;border:1px solid rgba(60,210,255,.3);background:rgba(20,48,76,.7)}
</style>
</head>
<body>
<canvas id="view"></canvas>
<div id="hud">
<div id="topbar">
<div class="stat"><span class="lbl">AETHERIUM</span><span class="val" id="minerals">0</span></div>
<div class="stat"><span class="lbl">SUPPLY</span><span class="val" id="supply">0 / 0</span></div>
<div class="stat"><span class="lbl">EXPEDITION FORCES</span><span class="val" id="armysize">0</span></div>
<div class="stat"><span class="lbl">MISSION TIME</span><span class="val" id="clock">0:00</span></div>
<div class="spacer"></div>
<button class="btn" id="btnHelp">HELP</button>
<button class="btn" id="btnMute">SOUND: ON</button>
<button class="btn" id="btnPause">PAUSE</button>
</div>
<div id="log"></div>
<div id="bottombar">
<div class="panel" id="selpanel">
<div class="ptitle"><span>SELECTION</span><span id="selCount"></span></div>
<div id="selTitle">No units selected</div>
<div id="selBody">Drag a box over your forces, or click a unit. Right-click to issue contextual orders.</div>
</div>
<div class="panel" id="cmdpanel">
<div class="ptitle"><span>COMMAND</span><span id="cmdHint">Left-click selects · Right-click orders</span></div>
<div id="cmdGrid"></div>
<div class="qhead">PRODUCTION QUEUE</div>
<div id="queue"></div>
</div>
<div class="panel" id="minipanel">
<canvas id="minimap" width="206" height="206"></canvas>
<div class="mmlabel"><span>TACTICAL MAP</span><span id="mmLegend">FOG: ACTIVE</span></div>
</div>
</div>
</div>
<div class="overlay" id="title">
<div class="card">
<h1>FRONTIER <em>WAR</em></h1>
<div class="tag">VANGUARD EXPEDITION · 3D REAL-TIME STRATEGY · KHAR DOMINION RIVAL</div>
<div class="rules">
<h3>VICTORY CONDITIONS</h3>
<p><b>WIN</b> by destroying every enemy <b>Headquarters</b> on the frontier. <b>LOSE</b> if all of your own Headquarters are destroyed.</p>
<p>Headquarters can be rebuilt as expansions — a rival with a hidden second HQ is never out of the fight. Scout, expand, and end them.</p>
</div>
<div class="cols">
<div class="col">
<h3>ECONOMY</h3>
<ul>
<li><b>Prospectors</b> harvest <b>Aetherium</b> from crystal deposits and return it to a Headquarters or Refinery.</li>
<li>Deposits are finite — expand toward fresh fields and build drop-offs nearby.</li>
<li><b>Supply</b> is reusable capacity: build Supply Relays to field bigger armies.</li>
<li>Queued units reserve supply; a smashed relay <b>pauses</b> production until rebuilt.</li>
</ul>
</div>
<div class="col">
<h3>COMMAND</h3>
<ul>
<li><span class="kbd">Left-drag</span> select · <span class="kbd">Shift</span> add · <span class="kbd">Ctrl+1-9</span> groups</li>
<li><span class="kbd">Right-click</span> move / attack / gather · <span class="kbd">Shift</span> queues orders</li>
<li><span class="kbd">A</span> attack-move · <span class="kbd">S</span> stop · <span class="kbd">H</span> hold position</li>
<li><span class="kbd">WASD</span>/<span class="kbd">arrows</span> pan · <span class="kbd">Q</span>/<span class="kbd">E</span> rotate · <span class="kbd">wheel</span> zoom</li>
<li>Minimap: left-click to jump, right-click to order.</li>
</ul>
</div>
<div class="col">
<h3>WAR MACHINE</h3>
<ul>
<li><b>Ravager</b> — fast melee skimmer, shreds light units.</li>
<li><b>Marksman</b> — long-ranged railgun, hits ground and air.</li>
<li><b>Juggernaut</b> — heavy siege armour, crushes armour and structures.</li>
<li><b>Talon</b> — interceptor aircraft, flies over all terrain.</li>
<li><b>Widow</b> — dedicated anti-air flak platform.</li>
<li>Fog of war hides the enemy base — scouting wins wars.</li>
</ul>
</div>
</div>
<button class="btn big" id="btnStart">DEPLOY EXPEDITION</button>
</div>
</div>
<div class="overlay hidden" id="pause">
<div class="card" style="text-align:center">
<h2 class="endtitle" style="letter-spacing:.3em">PAUSED</h2>
<p style="color:#8fc4de;letter-spacing:.12em">THE FRONTIER HOLDS ITS BREATH</p>
<div style="display:flex;gap:14px;justify-content:center;margin-top:26px">
<button class="btn big" id="btnResume">RESUME</button>
<button class="btn big" id="btnRestart2">RESTART MATCH</button>
<button class="btn big" id="btnHelp2">HELP</button>
</div>
</div>
</div>
<div class="overlay hidden" id="end">
<div class="card" style="text-align:center">
<h2 class="endtitle" id="endTitle">VICTORY</h2>
<p id="endSub" style="letter-spacing:.16em;color:#a9c9e2"></p>
<div class="statgrid" id="endStats"></div>
<button class="btn big" id="btnRestart">FIGHT AGAIN</button>
</div>
</div>
<div class="overlay hidden" id="help">
<div class="card">
<h2 style="margin:0 0 4px;letter-spacing:.22em;font-size:26px;color:#8fe9ff">FIELD MANUAL</h2>
<div class="tagrow">
<div class="tagpill">VICTORY: DESTROY ALL ENEMY HQs</div>
<div class="tagpill">DEFEAT: LOSE ALL YOUR HQs</div>
<div class="tagpill">FOG: UNEXPLORED / SEEN / LIVE</div>
<div class="tagpill">SUPPLY: RELAYS & HQs</div>
</div>
<div class="cols">
<div class="col">
<h3>CONTROLS</h3>
<ul>
<li><b>Left-click</b> select · <b>left-drag</b> box select · <b>Shift</b> adds to selection.</li>
<li><b>Double-click</b> a unit to select all of that type on screen.</li>
<li><b>Right-click</b>: move, attack, gather, return — context sensitive. <b>Shift</b> queues orders.</li>
<li><b>A</b> attack-move · <b>S</b> stop · <b>H</b> hold position · <b>G</b> gather-mode.</li>
<li><b>Ctrl + 0-9</b> assign control group · <b>0-9</b> recall (twice to centre camera).</li>
<li><b>WASD / arrows</b> pan · <b>Q / E</b> rotate · <b>mouse wheel</b> zoom · <b>edge scroll</b>.</li>
<li><b>Esc</b> pause · <b>F1</b> this manual.</li>
</ul>
</div>
<div class="col">
<h3>ECONOMY & SUPPLY</h3>
<ul>
<li>Workers mine crystals and carry them home. Place <b>Refineries</b> beside distant fields to shorten trips.</li>
<li>Every deposit is finite. Watch the crystal size shrink as it depletes.</li>
<li><b>Supply</b> is capacity, never spent: HQ +20, Supply Relay +16. Queue as many as you can support.</li>
<li>Queueing reserves supply; cancelling refunds the full cost instantly.</li>
<li>If supply drops below demand, production <b>pauses</b> (shown in the queue) until relays are rebuilt.</li>
<li>Build near friendly structures — the frontier is claimed by proximity.</li>
</ul>
</div>
<div class="col">
<h3>COUNTERS & COMBAT</h3>
<ul>
<li><b>Light</b> armour (Ravager, Marksman, workers) fears energy blades and massed fire.</li>
<li><b>Heavy</b> armour (Juggernaut) shrugs off kinetic fire but melts to explosive shells.</li>
<li><b>Air</b> units take extra damage from energy weapons; Widow flak and Marksmen shoot up.</li>
<li>Juggernauts cannot shoot air. Ravagers cannot reach air. Bring combined arms.</li>
<li><b>Attack-move</b> fights while travelling; <b>hold</b> holds ground and fires without chasing.</li>
</ul>
</div>
</div>
<table>
<tr><th>UNIT</th><th>ROLE</th><th>DAMAGE</th><th>ARMOUR</th><th>HITS</th><th>COST</th><th>SUPPLY</th></tr>
<tr><td>Prospector</td><td>Worker / builder</td><td>Kinetic</td><td>Light</td><td>Ground</td><td>50</td><td>1</td></tr>
<tr><td>Ravager</td><td>Fast melee skirmisher</td><td>Energy</td><td>Light</td><td>Ground</td><td>90</td><td>2</td></tr>
<tr><td>Marksman</td><td>Long-range railgun</td><td>Kinetic</td><td>Light</td><td>Ground + Air</td><td>120</td><td>2</td></tr>
<tr><td>Widow</td><td>Anti-air flak</td><td>Energy</td><td>Light</td><td>Air</td><td>140</td><td>2</td></tr>
<tr><td>Juggernaut</td><td>Heavy siege armour</td><td>Explosive</td><td>Heavy</td><td>Ground</td><td>260</td><td>5</td></tr>
<tr><td>Talon</td><td>Interceptor aircraft</td><td>Energy</td><td>Air</td><td>Ground + Air</td><td>220</td><td>4</td></tr>
</table>
<div style="text-align:center"><button class="btn big" id="btnCloseHelp">RETURN TO BATTLE</button></div>
</div>
</div>
<script>
"use strict";
/* ================================================================
FRONTIER WAR — self-contained 3D RTS engine (canvas 2.5D renderer)
================================================================ */
const TAU = Math.PI * 2;
const clamp = (v,a,b)=> v<a?a:(v>b?b:v);
const lerp = (a,b,t)=> a+(b-a)*t;
const 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));
/* deterministic-ish rng for world generation */
let _seed = 20240617;
function rnd(){ _seed = (_seed*1664525 + 1013904223) & 0x7fffffff; return _seed/0x7fffffff; }
function rr(a,b){ return a + (b-a)*rnd(); }
/* ---------------- world constants ---------------- */
const WORLD = 240;
const FOGN = 128;
const CELL = WORLD / FOGN;
const HCELL = 8, HDIM = Math.ceil(WORLD/HCELL);
const HUMAN = 0, ENEMY = 1;
const FOV = 0.86, PITCH = 0.88; // radians
const MIN_ZOOM = 48, MAX_ZOOM = 250;
/* ---------------- canvases ---------------- */
const view = document.getElementById('view');
const ctx = view.getContext('2d', {alpha:false});
const mm = document.getElementById('minimap');
const mmx = mm.getContext('2d');
const fogCanvas = document.createElement('canvas'); fogCanvas.width = FOGN; fogCanvas.height = FOGN;
const fogCtx = fogCanvas.getContext('2d');
let CW=0, CH=0, DPR=1, FOCAL=1, HALF_W=0, HALF_H=0;
function resize(){
DPR = Math.min(window.devicePixelRatio || 1, 1.6);
CW = view.clientWidth; CH = view.clientHeight;
view.width = Math.max(2,Math.floor(CW*DPR)); view.height = Math.max(2,Math.floor(CH*DPR));
ctx.setTransform(DPR,0,0,DPR,0,0);
HALF_W = CW/2; HALF_H = CH/2;
FOCAL = (CH/2)/Math.tan(FOV/2);
}
window.addEventListener('resize', resize);
/* ---------------- camera ---------------- */
const cam = { x: 60, z: 178, yaw: 0, dist: 128,
posX:0, posY:0, posZ:0, fx:0,fy:0,fz:0, rx:0,ry:0,rz:0, ux:0,uy:0,uz:0 };
function updateCamera(){
cam.dist = clamp(cam.dist, MIN_ZOOM, MAX_ZOOM);
cam.x = clamp(cam.x, 14, WORLD-14);
cam.z = clamp(cam.z, 14, WORLD-14);
const sy = Math.sin(cam.yaw), cy = Math.cos(cam.yaw), sp = Math.sin(PITCH), cp = Math.cos(PITCH);
cam.posX = cam.x - sy*cam.dist*cp;
cam.posY = cam.dist*sp;
cam.posZ = cam.z - cy*cam.dist*cp;
cam.fx = sy*cp; cam.fy = -sp; cam.fz = cy*cp; // forward
cam.rx = cy; cam.ry = 0; cam.rz = -sy; // right
cam.ux = sp*sy; cam.uy = cp; cam.uz = sp*cy; // up
}
/* project world point -> screen; returns depth or -1 when behind camera */
const _p = {x:0,y:0,z:0};
function project(x,y,z,out){
const dx=x-cam.posX, dy=y-cam.posY, dz=z-cam.posZ;
const X = dx*cam.rx + dy*cam.ry + dz*cam.rz;
const Y = dx*cam.ux + dy*cam.uy + dz*cam.uz;
const Z = dx*cam.fx + dy*cam.fy + dz*cam.fz;
if (Z < 1) return false;
out.x = HALF_W + FOCAL*X/Z;
out.y = HALF_H - FOCAL*Y/Z;
out.z = Z;
return true;
}
function screenToGround(sx, sy, out){
const ndx = (sx - HALF_W)/FOCAL, ndy = -(sy - HALF_H)/FOCAL;
const dx = cam.fx + cam.rx*ndx + cam.ux*ndy;
const dy = cam.fy + cam.ry*ndx + cam.uy*ndy;
const dz = cam.fz + cam.rz*ndx + cam.uz*ndy;
const t = (0.0 - cam.posY)/dy;
out.x = clamp(cam.posX + dx*t, -60, WORLD+60);
out.z = clamp(cam.posZ + dz*t, -60, WORLD+60);
return out;
}
/* ---------------- entity stores ---------------- */
let nextId = 1;
const units = [], buildings = [], nodes = [], rocks = [], projs = [], fxList = [], beacons = [];
const ents = { all: [] };
/* ---------------- game state ---------------- */
const S = {
running:false, paused:false, over:false, t:0,
min:[0,0], supUsed:[0,0], supRes:[0,0], supCap:[0,0],
upg:[{weapons:0,armor:0,thrusters:0,harvest:0},{weapons:0,armor:0,thrusters:0,harvest:0}],
kills:[0,0], lost:[0,0], gathered:[0,0],
ai:{ nextBuild:0, nextArmy:0, nextScout:0, attackAt:0, waveSize:4, threat:0 },
stats:{ fps:60 }
};
/* ---------------- fog of war ---------------- */
const fog = [
{ vis:new Uint8Array(FOGN*FOGN), exp:new Uint8Array(FOGN*FOGN) },
{ vis:new Uint8Array(FOGN*FOGN), exp:new Uint8Array(FOGN*FOGN) }
];
function fogIdx(x,z){
const cx = x/CELL|0, cz = z/CELL|0;
return (cx<0?0:cx>=FOGN?FOGN-1:cx) + FOGN*(cz<0?0:cz>=FOGN?FOGN-1:cz);
}
function visTo(p,x,z){ return fog[p].vis[fogIdx(x,z)]>0; }
function seenTo(p,x,z){ return fog[p].exp[fogIdx(x,z)]>0; }
function stampVision(f,x,z,rad){
const x0 = Math.max(0, Math.floor((x-rad)/CELL)), x1 = Math.min(FOGN-1, Math.floor((x+rad)/CELL));
const z0 = Math.max(0, Math.floor((z-rad)/CELL)), z1 = Math.min(FOGN-1, Math.floor((z+rad)/CELL));
const c2 = (rad/CELL)*(rad/CELL);
const v = f.vis;
for (let cz=z0; cz<=z1; cz++){
const dz = cz + 0.5 - z/CELL;
for (let cx=x0; cx<=x1; cx++){
const dx = cx + 0.5 - x/CELL;
if (dx*dx + dz*dz <= c2) v[cx + FOGN*cz] = 1;
}
}
}
function stampExplored(f,x,z,rad){
const x0 = Math.max(0, Math.floor((x-rad)/CELL)), x1 = Math.min(FOGN-1, Math.floor((x+rad)/CELL));
const z0 = Math.max(0, Math.floor((z-rad)/CELL)), z1 = Math.min(FOGN-1, Math.floor((z+rad)/CELL));
const c2 = (rad/CELL)*(rad/CELL);
const ex = f.exp;
for (let cz=z0; cz<=z1; cz++){
const dz = cz + 0.5 - z/CELL;
for (let cx=x0; cx<=x1; cx++){
const dx = cx + 0.5 - x/CELL;
if (dx*dx + dz*dz <= c2) ex[cx + FOGN*cz] = 1;
}
}
}
let visionTimer = 0;
function computeVision(){
for (let p=0;p<2;p++){
const f = fog[p]; f.vis.fill(0);
for (let i=0;i<units.length;i++){ const u=units[i]; if (u.player===p) stampVision(f,u.x,u.z,u.sight); }
for (let i=0;i<buildings.length;i++){ const b=buildings[i]; if (b.player===p) stampVision(f,b.x,b.z,b.sight); }
const v=f.vis, ex=f.exp;
for (let i=0;i<v.length;i++) if (v[i]) ex[i]=1;
}
}
/* ---------------- spatial hash (units) ---------------- */
const hash = new Array(HDIM*HDIM);
for (let i=0;i<hash.length;i++) hash[i] = [];
function hashRebuild(){
for (let i=0;i<hash.length;i++) if (hash[i].length) hash[i].length = 0;
for (let i=0;i<units.length;i++){
const u = units[i];
const cx = clamp(u.x/HCELL|0,0,HDIM-1), cz = clamp(u.z/HCELL|0,0,HDIM-1);
hash[cx + HDIM*cz].push(u);
}
}
function hashQuery(x,z,r,cb){
const x0 = clamp((x-r)/HCELL|0,0,HDIM-1), x1 = clamp((x+r)/HCELL|0,0,HDIM-1);
const z0 = clamp((z-r)/HCELL|0,0,HDIM-1), z1 = clamp((z+r)/HCELL|0,0,HDIM-1);
for (let cz=z0; cz<=z1; cz++){
const row = cz*HDIM;
for (let cx=x0; cx<=x1; cx++){
const bucket = hash[row+cx];
for (let i=0;i<bucket.length;i++) if (cb(bucket[i])===false) return;
}
}
}
/* static hash for buildings & rocks (obstacles) */
const oHash = new Array(HDIM*HDIM);
for (let i=0;i<oHash.length;i++) oHash[i] = [];
let oHashDirty = true;
function oHashRebuild(){
for (let i=0;i<oHash.length;i++) if (oHash[i].length) oHash[i].length = 0;
const push=(o,cx,cz)=>{ oHash[cx + HDIM*cz].push(o); };
for (let i=0;i<buildings.length;i++){
const b = buildings[i];
const r = b.def.rad;
const x0=clamp((b.x-r)/HCELL|0,0,HDIM-1), x1=clamp((b.x+r)/HCELL|0,0,HDIM-1);
const z0=clamp((b.z-r)/HCELL|0,0,HDIM-1), z1=clamp((b.z+r)/HCELL|0,0,HDIM-1);
for (let cz=z0;cz<=z1;cz++) for (let cx=x0;cx<=x1;cx++) push(b,cx,cz);
}
for (let i=0;i<rocks.length;i++){
const o = rocks[i];
const cx = clamp(o.x/HCELL|0,0,HDIM-1), cz = clamp(o.z/HCELL|0,0,HDIM-1);
push(o,cx,cz);
}
oHashDirty = false;
}
function oHashQuery(x,z,r,cb){
const x0 = clamp((x-r)/HCELL|0,0,HDIM-1), x1 = clamp((x+r)/HCELL|0,0,HDIM-1);
const z0 = clamp((z-r)/HCELL|0,0,HDIM-1), z1 = clamp((z+r)/HCELL|0,0,HDIM-1);
for (let cz=z0; cz<=z1; cz++){
const row = cz*HDIM;
for (let cx=x0; cx<=x1;cx++){
const bucket = oHash[row+cx];
for (let i=0;i<bucket.length;i++) if (cb(bucket[i])===false) return;
}
}
}
/* ---------------- world generation ---------------- */
function addRock(x,z,r,h,sides,tint){
const vcount = sides|0;
const base = [], top = [];
const a0 = rnd()*TAU;
for (let i=0;i<vcount;i++){
const a = a0 + i/vcount*TAU;
const rad = r*(0.72+rnd()*0.5);
base.push([x+Math.cos(a)*rad, 0, z+Math.sin(a)*rad]);
top.push([x+Math.cos(a)*rad*0.55, h*(0.72+rnd()*0.42), z+Math.sin(a)*rad*0.55]);
}
const shade = tint||0;
const faces = [];
const c1 = `rgb(${38+shade},${46+shade},${64+shade})`;
const c2 = `rgb(${28+shade},${34+shade},${50+shade})`;
const cTop = `rgb(${62+shade},${76+shade},${98+shade})`;
for (let i=0;i<vcount;i++){
const j = (i+1)%vcount;
faces.push({p:[base[i],base[j],top[j],top[i]], c: i%2 ? c2 : c1});
}
faces.push({p:top, c:cTop});
rocks.push({x, z, r:r*1.02, h, faces, kind:'rock'});
}
function rockChain(x1,z1,x2,z2,n,minR,maxR,gapAt){
for (let i=0;i<n;i++){
if (gapAt && gapAt.includes(i)) continue;
const t = n===1?0.5:i/(n-1);
const x = lerp(x1,x2,t) + rr(-3,3), z = lerp(z1,z2,t) + rr(-3,3);
addRock(x,z, rr(minR,maxR), rr(5,13), 5+Math.floor(rnd()*3), Math.floor(rr(-6,10)));
}
}
function addNode(x,z,amount){
const crystals = [];
const n = 4 + Math.floor(rnd()*3);
for (let i=0;i<n;i++){
const a = rnd()*TAU, d = rr(0,4.6);
crystals.push({dx:Math.cos(a)*d, dz:Math.sin(a)*d, h:rr(5,12), w:rr(1.4,2.8), rot:rnd()*TAU});
}
nodes.push({ id:nextId++, x, z, amount, max:amount, r:7.5, crystals, seed:rnd()*TAU });
}
function genWorld(){
rocks.length = 0; nodes.length = 0;
/* resource deposits */
addNode(34,196,1600); addNode(74,166,1600);
addNode(206,44,1600); addNode(166,74,1600);
addNode(120,120,2600);
addNode(56,58,2100); addNode(184,182,2100);
addNode(36,120,1900); addNode(204,120,1900);
addNode(120,38,1900); addNode(120,202,1900);
addNode(88,96,1500); addNode(152,144,1500);
/* ridges forming lanes and chokepoints */
rockChain(30,140,74,104,7,5,11,[3]);
rockChain(166,136,210,100,7,5,11,[3]);
rockChain(96,36,144,26,6,5,11,[2,3]);
rockChain(96,214,144,204,6,5,11,[2,3]);
rockChain(148,72,176,44,5,4,9,[2]);
rockChain(64,196,92,168,5,4,9,[2]);
/* central ring around the main field (gaps at N/S/E/W) */
const ring = [];
for (let i=0;i<18;i++){ const a=i/18*TAU; if (i%4===0) continue; ring.push(i); }
for (const i of ring){
const a = i/18*TAU + rr(-0.06,0.06);
addRock(120+Math.cos(a)*rr(30,35), 120+Math.sin(a)*rr(30,35), rr(5,10), rr(6,14), 5+Math.floor(rnd()*3), Math.floor(rr(-6,10)));
}
/* scattered clusters */
for (let i=0;i<40;i++){
const x = rr(18,WORLD-18), z = rr(18,WORLD-18);
if (dist(x,z,56,184)<34 || dist(x,z,184,56)<34) continue;
let ok = true;
for (const nd of nodes) if (dist2(x,z,nd.x,nd.z) < 26*26) { ok=false; break; }
if (!ok) continue;
const n = 1+Math.floor(rnd()*3);
for (let k=0;k<n;k++) addRock(x+rr(-9,9), z+rr(-9,9), rr(3.5,8), rr(4,11), 5+Math.floor(rnd()*3), Math.floor(rr(-6,10)));
}
oHashDirty = true;
}
/* ================================================================
GAME DATA — factions, units, buildings, upgrades, damage model
================================================================ */
const DMG = {
kinetic: { light:1.00, heavy:0.55, air:0.85, structure:0.62 },
explosive:{ light:0.80, heavy:1.30, air:0.50, structure:1.15 },
energy: { light:1.10, heavy:0.70, air:1.25, structure:0.75 }
};
const DMGNAME = { kinetic:'Kinetic', explosive:'Explosive', energy:'Energy' };
const UNITS = {
worker: { name:'Prospector', role:'Worker · builder', cost:50, time:8, supply:1, hp:65, speed:11, sight:13,
dmg:4, dmgType:'kinetic', range:2.6, cd:1.0, targets:'ground', armor:'light', rad:1.35, h:3.6, hotkey:'W' },
ravager: { name:'Ravager', role:'Fast melee skimmer', cost:90, time:10, supply:2, hp:135, speed:15.5, sight:15,
dmg:15, dmgType:'energy', range:3.2, cd:0.75, targets:'ground', armor:'light', rad:1.55, h:3.6, hotkey:'V' },
marksman: { name:'Marksman', role:'Railgun · hits air', cost:120, time:12, supply:2, hp:100, speed:11, sight:19,
dmg:17, dmgType:'kinetic', range:14, cd:1.55, targets:'both', armor:'light', rad:1.55, h:5, hotkey:'M' },
widow: { name:'Widow', role:'Anti-air flak', cost:140, time:13, supply:2, hp:115, speed:11.5, sight:21,
dmg:19, dmgType:'energy', range:16, cd:1.0, targets:'air', armor:'light', rad:1.6, h:4.6, hotkey:'D' },
juggernaut:{ name:'Juggernaut', role:'Heavy siege armour', cost:260, time:22, supply:5, hp:440, speed:7.2, sight:17,
dmg:42, dmgType:'explosive', range:11.5, cd:2.5, targets:'ground', armor:'heavy', rad:2.7, h:5.4, hotkey:'J' },
talon: { name:'Talon', role:'Interceptor aircraft', cost:220, time:18, supply:4, hp:185, speed:22, sight:21,
dmg:18, dmgType:'energy', range:10, cd:1.15, targets:'both', armor:'air', rad:2.1, h:4, hotkey:'L', fly:true }
};
const BUILDS = {
hq: { name:'Headquarters', role:'Drop-off · trains Prospectors · +20 supply', cost:500, time:40, hp:2200,
w:16, d:16, sight:26, supply:20, drop:true, trains:['worker'], hotkey:'Y' },
supply: { name:'Supply Relay', role:'+16 supply capacity', cost:100, time:12, hp:650,
w:9, d:9, sight:13, supply:16, hotkey:'Z' },
foundry: { name:'War Foundry', role:'Trains ground forces', cost:180, time:20, hp:1300,
w:13, d:13, sight:15, trains:['ravager','marksman','widow','juggernaut'], hotkey:'F' },
starport: { name:'Aerodrome', role:'Trains aircraft', cost:240, time:24, hp:1150,
w:15, d:15, sight:15, trains:['talon'], hotkey:'P' },
lab: { name:'Tech Nexus', role:'Research upgrades', cost:220, time:20, hp:950,
w:11, d:11, sight:15, research:true, hotkey:'N' },
turret: { name:'Sentinel Turret', role:'Static defence · ground + air', cost:120, time:12, hp:850,
w:6, d:6, sight:21, dmg:23, dmgType:'kinetic', range:21, cd:1.15, targets:'both', hotkey:'T' },
refinery: { name:'Refinery', role:'Resource drop-off', cost:140, time:14, hp:950,
w:11, d:11, sight:13, drop:true, hotkey:'R' }
};
const UPGRADES = {
weapons: { name:'Pulse Amplifiers', cost:150, time:28, max:2, desc:'+12% unit damage per level', hotkey:'C' },
armor: { name:'Reactive Plating', cost:150, time:28, max:2, desc:'+12% unit hull per level', hotkey:'I' },
thrusters: { name:'Vector Thrusters', cost:200, time:34, max:1, desc:'+12% movement speed', hotkey:'U' },
harvest: { name:'Flux Extraction', cost:200, time:34, max:1, desc:'+30% harvest rate & carry', hotkey:'O' }
};
const THEME = [
{ main:[32,124,150], dark:[16,66,84], trim:[46,168,201], glow:[126,242,255], accent:'#35e0ff' },
{ main:[168,76,44], dark:[92,38,22], trim:[201,110,60], glow:[255,178,122], accent:'#ff7a3c' }
];
/* ------------- procedural models (local space: +z forward, +y up) ------------- */
function shadeSet(c){
const out = [];
for (let i=0;i<8;i++){
const k = 0.42 + i*(0.62/7);
out.push(`rgb(${Math.min(255,c[0]*k|0)},${Math.min(255,c[1]*k|0)},${Math.min(255,c[2]*k|0)})`);
}
return out;
}
function box(x,y,z,w,h,d,c){ return {x,y,z,w,h,d,sh:shadeSet(c)}; }
const BOX_FACES = [
{ i:[4,5,6,7], n:[0,1,0] },
{ i:[3,2,6,7], n:[0,0,1] },
{ i:[0,1,5,4], n:[0,0,-1] },
{ i:[1,2,6,5], n:[1,0,0] },
{ i:[0,3,7,4], n:[-1,0,0] }
];
const MODELS = {
worker(t){ return [
box(0,1.1,0, 2.7,1.15,3.5, t.main),
box(0,2.25,-0.35, 1.55,1.15,1.9, t.trim),
box(1.75,0.85,-0.1, 0.75,0.95,2.5, t.dark),
box(-1.75,0.85,-0.1, 0.75,0.95,2.5, t.dark),
box(0,1.45,1.95, 1.25,0.75,0.55, t.glow)
]; },
ravager(t){ return [
box(0,1.15,0, 2.9,1.25,4.2, t.main),
box(0,2.35,-0.6, 1.5,1.05,1.7, t.trim),
box(1.9,0.75,1.2, 0.45,2.1,2.6, t.glow),
box(-1.9,0.75,1.2, 0.45,2.1,2.6, t.glow),
box(0,1.5,2.35, 1.15,0.65,0.5, t.glow),
box(0,0.55,-2.0, 2.1,0.75,0.9, t.dark)
]; },
marksman(t){ return [
box(0,1.25,0, 2.5,1.5,3.3, t.main),
box(0,2.7,-0.5, 1.55,1.2,1.7, t.trim),
box(0,2.95,1.85, 0.55,0.55,4.9, t.dark),
box(0,3.0,4.4, 0.85,0.85,0.85, t.glow),
box(1.45,0.15,0, 0.85,1.35,2.5, t.dark),
box(-1.45,0.15,0, 0.85,1.35,2.5, t.dark),
box(0,1.75,-1.9, 1.3,0.8,0.7, t.glow)
]; },
widow(t){ return [
box(0,1.0,0, 3.1,1.15,3.7, t.main),
box(0,2.15,-0.3, 1.85,1.15,2.1, t.trim),
box(1.15,2.55,0.9, 0.45,0.45,3.2, t.dark),
box(-1.15,2.55,0.9, 0.45,0.45,3.2, t.dark),
box(0,3.3,-1.15, 1.75,0.35,1.15, t.glow),
box(0,1.35,1.95, 1.35,0.65,0.5, t.glow)
]; },
juggernaut(t){ return [
box(0,1.15,0, 4.6,1.9,6.2, t.main),
box(0,3.05,-0.35, 3.3,1.75,3.4, t.trim),
box(1.15,3.55,2.15, 0.85,0.85,4.7, t.dark),
box(-1.15,3.55,2.15, 0.85,0.85,4.7, t.dark),
box(1.15,3.55,4.6, 1.15,1.15,0.9, t.glow),
box(-1.15,3.55,4.6, 1.15,1.15,0.9, t.glow),
box(2.65,0.35,0, 1.05,1.85,6.6, t.dark),
box(-2.65,0.35,0, 1.05,1.85,6.6, t.dark),
box(0,4.8,-1.5, 1.6,0.85,1.3, t.glow)
]; },
talon(t){ return [
box(0,0,0, 1.7,1.35,6.2, t.main),
box(0,0.85,1.9, 1.15,0.85,1.9, t.trim),
box(0,-0.12,-0.2, 7.4,0.42,2.3, t.dark),
box(0,0.05,-2.85, 3.4,0.4,1.35, t.dark),
box(0,0.35,-2.2, 0.42,1.75,1.15, t.trim),
box(0,-0.35,2.25, 1.0,0.55,1.15, t.glow)
]; },
/* ---- buildings ---- */
hq(t){ return [
box(0,0,0, 16,1.3,16, t.dark),
box(0,1.3,0, 10.5,7,10.5, t.main),
box(0,8.3,0, 7.5,2.2,7.5, t.trim),
box(0,10.5,0, 4.2,2.1,4.2, t.glow),
box(6.2,1.3,6.2, 2.2,10,2.2, t.trim),
box(6.2,11.3,6.2, 1.15,3.2,1.15, t.glow),
box(-6.2,1.3,-6.2, 2.2,7,2.2, t.trim),
box(0,1.3,7.4, 6.2,3.4,1.6, t.trim)
]; },
supply(t){ return [
box(0,0,0, 9,1.1,9, t.dark),
box(0,1.1,0, 4.4,11,4.4, t.main),
box(0,6.2,0, 6.6,1.5,6.6, t.trim),
box(0,9.2,0, 6.6,1.2,6.6, t.trim),
box(0,12.1,0, 2.4,2.6,2.4, t.glow)
]; },
foundry(t){ return [
box(0,0,0, 13,1.2,13, t.dark),
box(0,1.2,-1, 10.5,6.2,9, t.main),
box(0,7.4,-1, 8,1.5,7, t.trim),
box(-4.2,1.2,-5.2, 2.4,9.5,2.4, t.trim),
box(-4.2,10.7,-5.2, 1.6,1.8,1.6, t.glow),
box(0,1.2,6.2, 6.6,3.1,2.2, t.trim),
box(0,3.2,6.6, 3.2,1.2,1.4, t.glow)
]; },
starport(t){ return [
box(0,0,0, 15,1.1,15, t.dark),
box(0,1.1,0, 11,0.75,11, t.trim),
box(0,1.85,0, 6,0.45,6, t.glow),
box(-5.4,1.1,-5.4, 3.2,9.5,3.2, t.main),
box(-5.4,10.6,-5.4, 2,2.1,2, t.glow),
box(5.4,1.1,-5.4, 2.2,6.2,2.2, t.main),
box(5.4,7.3,-5.4, 1.5,1.6,1.5, t.glow),
box(0,1.1,6.4, 7.2,2.2,1.6, t.trim)
]; },
lab(t){ return [
box(0,0,0, 11,1.2,11, t.dark),
box(0,1.2,0, 7.2,5.6,7.2, t.main),
box(0,6.8,0, 9,1.15,9, t.trim),
box(0,7.95,0, 5.4,2.4,5.4, t.trim),
box(0,10.35,0, 2.1,3.6,2.1, t.glow),
box(3.9,1.2,3.9, 1.5,6.8,1.5, t.trim),
box(-3.9,1.2,3.9, 1.5,6.8,1.5, t.trim)
]; },
turret(t){ return [
box(0,0,0, 6,1.3,6, t.dark),
box(0,1.3,0, 2.9,3.6,2.9, t.main),
box(0,4.9,0, 4.1,1.5,4.1, t.trim),
box(0,5.5,1.5, 1.15,1.15,4.4, t.dark),
box(0,5.5,-1.2, 1.9,1.5,1.9, t.glow)
]; },
refinery(t){ return [
box(0,0,0, 11,1.15,11, t.dark),
box(-2.9,1.15,-1.4, 4.2,5.4,5.2, t.main),
box(2.9,1.15,-1.4, 4.2,4.2,5.2, t.main),
box(0,1.15,3.9, 9,3.4,2.6, t.trim),
box(-2.9,6.55,-1.4, 2.6,1.5,3.2, t.glow),
box(0,4.55,3.9, 3.2,1.2,1.6, t.glow)
]; }
};
const MODEL_H = { worker:3.6, ravager:3.6, marksman:5.2, widow:4.6, juggernaut:5.6, talon:3,
hq:14, supply:14.8, foundry:12, starport:12, lab:14, turret:7.2, refinery:8 };
const modelCache = {};
function getModel(key, player){
const id = key + player;
if (!modelCache[id]) modelCache[id] = MODELS[key](THEME[player]);
return modelCache[id];
}
/* ================================================================
ENTITIES
================================================================ */
function unitHpMul(p){ return 1 + 0.12*S.upg[p].armor; }
function unitDmgMul(p){ return 1 + 0.12*S.upg[p].weapons; }
function unitSpeedMul(p){ return 1 + 0.12*S.upg[p].thrusters; }
function carryCap(p){ return Math.round(10 * (S.upg[p].harvest ? 1.3 : 1)); }
function harvestMul(p){ return S.upg[p].harvest ? 1.3 : 1; }
function spawnUnit(key, player, x, z, yaw){
const d = UNITS[key];
const u = {
id:nextId++, kind:'unit', key, def:d, player,
x:x, z:z, y:(d.fly?15:0), yaw:(yaw!==undefined?yaw:rnd()*TAU),
vx:0, vz:0, hp:0, maxHp:0, speed:d.speed, sight:d.sight, rad:d.rad,
cd:0, flash:0, hitT:0, anim:rnd()*TAU,
orders:[], order:null, target:null, tx:0, tz:0,
carry:0, node:null, mineT:0, buildRef:null,
hold:false, dead:false, attackMove:false
};
u.maxHp = Math.round(d.hp*unitHpMul(player));
u.hp = u.maxHp;
units.push(u);
recomputeSupply();
return u;
}
function spawnBuilding(key, player, x, z, complete){
const d = BUILDS[key];
const b = {
id:nextId++, kind:'building', key, def:d, player,
x:x, z:z, y:0, yaw:0, rad:Math.max(d.w,d.d)*0.52,
hp: complete ? d.hp : Math.round(d.hp*0.1), maxHp:d.hp,
progress: complete ? 1 : 0, built: !!complete,
queue:[], rally:null, research:null, researchT:0,
cd:0, flash:0, hitT:0, dead:false, sight:d.sight,
anim:rnd()*TAU, builders:0, spawnT:0
};
buildings.push(b);
oHashDirty = true;
recomputeSupply();
return b;
}
function recomputeSupply(){
for (let p=0;p<2;p++){
let cap=0, used=0, res=0;
for (let i=0;i<buildings.length;i++){
const b = buildings[i];
if (b.player!==p) continue;
if (b.built && b.def.supply) cap += b.def.supply;
for (let q=0;q<b.queue.length;q++) res += UNITS[b.queue[q].key].supply;
}
for (let i=0;i<units.length;i++) if (units[i].player===p) used += units[i].def.supply;
S.supCap[p]=cap; S.supUsed[p]=used; S.supRes[p]=res;
}
}
function supplyFree(p){ return S.supCap[p] - S.supUsed[p] - S.supRes[p]; }
function supplyBlocked(p){ return S.supUsed[p] + S.supRes[p] > S.supCap[p]; }
/* knowledge of enemy buildings (last-known silhouettes) */
const knowledge = [new Map(), new Map()];
function updateKnowledge(){
for (let p=0;p<2;p++){
const kn = knowledge[p];
for (let i=0;i<buildings.length;i++){
const b = buildings[i];
if (b.player===p || b.dead) continue;
if (visTo(p,b.x,b.z)) kn.set(b.id, {x:b.x, z:b.z, key:b.key, player:b.player, ref:b});
}
for (const [id,g] of kn){
if (g.ref && !g.ref.dead) continue;
if (visTo(p, g.x, g.z)) kn.delete(id);
}
}
}
/* ================================================================
RENDERER
================================================================ */
const drawList = [];
const V8 = [];
for (let i=0;i<8;i++) V8.push({x:0,y:0,z:0});
const _c = {x:0,y:0,z:0};
const LIGHT = (function(){ const v=[0.36,0.87,-0.34]; const m=Math.hypot(v[0],v[1],v[2]); return [v[0]/m,v[1]/m,v[2]/m]; })();
function camDepth(x,y,z){ return (x-cam.posX)*cam.fx + (y-cam.posY)*cam.fy + (z-cam.posZ)*cam.fz; }
/* --- starfield backdrop --- */
const starCanvas = document.createElement('canvas');
starCanvas.width = 420; starCanvas.height = 180;
(function(){
const sc = starCanvas.getContext('2d');
sc.fillStyle = '#000'; sc.fillRect(0,0,420,180);
for (let i=0;i<220;i++){
const x = rnd()*420, y = rnd()*180, r = rnd()*1.5+0.3;
sc.fillStyle = `rgba(${180+rnd()*75|0},${210+rnd()*45|0},255,${0.25+rnd()*0.7})`;
sc.beginPath(); sc.arc(x,y,r,0,TAU); sc.fill();
}
})();
function clipNear(poly, zn){
const out = [];
for (let i=0;i<poly.length;i++){
const a = poly[i], b = poly[(i+1)%poly.length];
const ain = a.z >= zn, bin = b.z >= zn;
if (ain) out.push(a);
if (ain !== bin){
const t = (zn - a.z)/(b.z - a.z);
out.push({x:a.x+(b.x-a.x)*t, y:a.y+(b.y-a.y)*t, z:zn});
}
}
return out;
}
function drawSky(){
const g = ctx.createLinearGradient(0,0,0,CH);
g.addColorStop(0,'#060a17');
g.addColorStop(0.30,'#0d1c34');
g.addColorStop(0.62,'#28405e');
g.addColorStop(1,'#4b6480');
ctx.fillStyle = g; ctx.fillRect(0,0,CW,CH);
ctx.save();
ctx.globalAlpha = 0.75;
ctx.drawImage(starCanvas, 0, 0, CW, Math.max(1,CH*0.42));
ctx.restore();
}
function camSpace(x,y,z,out){
const dx=x-cam.posX, dy=y-cam.posY, dz=z-cam.posZ;
out.x = dx*cam.rx + dy*cam.ry + dz*cam.rz;
out.y = dx*cam.ux + dy*cam.uy + dz*cam.uz;
out.z = dx*cam.fx + dy*cam.fy + dz*cam.fz;
return out;
}
function drawGroundPlane(){
const size = 1500;
const cs = [];
const corners = [[cam.posX-size, cam.posZ-size],[cam.posX+size, cam.posZ-size],
[cam.posX+size, cam.posZ+size],[cam.posX-size, cam.posZ+size]];
for (const c of corners) cs.push(camSpace(c[0],0,c[1],{x:0,y:0,z:0}));
const poly = clipNear(cs, 1.5);
if (poly.length < 3) return;
const g = ctx.createLinearGradient(0,0,0,CH);
g.addColorStop(0,'#2b4360');
g.addColorStop(0.35,'#243b56');
g.addColorStop(1,'#16273d');
ctx.fillStyle = g;
ctx.beginPath();
for (let i=0;i<poly.length;i++){
const p = poly[i];
const sx = HALF_W + FOCAL*p.x/p.z, sy = HALF_H - FOCAL*p.y/p.z;
if (i) ctx.lineTo(sx,sy); else ctx.moveTo(sx,sy);
}
ctx.closePath(); ctx.fill();
}
function drawGrid(){
const a = 1 - clamp((cam.dist-140)/110, 0, 1);
if (a <= 0.02) return;
const tl = {x:0,z:0}, br = {x:0,z:0};
screenToGround(0, CH, tl); screenToGround(CW, 0, br);
const x0 = Math.min(tl.x, br.x) - 60, x1 = Math.max(tl.x, br.x) + 60;
const z0 = Math.min(tl.z, br.z) - 60, z1 = Math.max(tl.z, br.z) + 60;
const step = 10;
ctx.lineWidth = 1;
for (let x=Math.floor(x0/step)*step; x<=x1; x+=step){
const major = (x % 40 === 0);
ctx.strokeStyle = major ? `rgba(90,215,255,${0.16*a})` : `rgba(90,215,255,${0.075*a})`;
ctx.beginPath();
let started = false;
for (let z=z0; z<=z1; z+=step){
if (project(x,0,z,_c)){ started ? ctx.lineTo(_c.x,_c.y) : ctx.moveTo(_c.x,_c.y); started=true; }
else started = false;
}
ctx.stroke();
}
for (let z=Math.floor(z0/step)*step; z<=z1; z+=step){
const major = (z % 40 === 0);
ctx.strokeStyle = major ? `rgba(90,215,255,${0.16*a})` : `rgba(90,215,255,${0.075*a})`;
ctx.beginPath();
let started = false;
for (let x=x0; x<=x1; x+=step){
if (project(x,0,z,_c)){ started ? ctx.lineTo(_c.x,_c.y) : ctx.moveTo(_c.x,_c.y); started=true; }
else started = false;
}
ctx.stroke();
}
}
function drawGroundDetail(){
/* resource deposit glows */
for (let i=0;i<nodes.length;i++){
const n = nodes[i];
if (!seenTo(0,n.x,n.z)) continue;
if (n.amount <= 0) continue;
const t = 0.35 + 0.65*(n.amount/n.max);
if (!project(n.x, 0.1, n.z, _c)) continue;
const edge = {x:0,y:0,z:0};
project(n.x + n.r*t, 0.1, n.z, edge);
const r = Math.abs(edge.x - _c.x) + 6;
if (_c.x < -r || _c.x > CW+r || _c.y < -r || _c.y > CH+r) continue;
const g = ctx.createRadialGradient(_c.x,_c.y,0,_c.x,_c.y,r);
g.addColorStop(0,`rgba(64,226,255,${0.30*t})`);
g.addColorStop(1,'rgba(64,226,255,0)');
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(_c.x,_c.y,r,0,TAU); ctx.fill();
}
}
/* --- model drawing --- */
function drawBoxes(boxes, x, y, z, yaw, alpha, ghost, maxBoxes){
const cy = Math.cos(yaw), sy = Math.sin(yaw);
if (alpha !== undefined) ctx.globalAlpha = alpha;
const nb = maxBoxes ? Math.min(boxes.length, maxBoxes) : boxes.length;
for (let bi=0; bi<nb; bi++){
const b = boxes[bi];
const hx = b.w/2, hz = b.d/2;
/* corners: index order matches BOX_FACES */
const lx = [-hx,hx,hx,-hx,-hx,hx,hx,-hx];
const ly = [b.y,b.y,b.y,b.y,b.y+b.h,b.y+b.h,b.y+b.h,b.y+b.h];
const lz = [-hz,-hz,hz,hz,-hz,-hz,hz,hz];
let ok = true;
for (let i=0;i<8;i++){
const px = x + lx[i]*cy + lz[i]*sy;
const pz = z - lx[i]*sy + lz[i]*cy;
const py = y + ly[i];
const dx = px-cam.posX, dy = py-cam.posY, dz = pz-cam.posZ;
const vx = dx*cam.rx + dy*cam.ry + dz*cam.rz;
const vy = dx*cam.ux + dy*cam.uy + dz*cam.uz;
const vz = dx*cam.fx + dy*cam.fy + dz*cam.fz;
if (vz < 1.5){ ok = false; break; }
V8[i].x = HALF_W + FOCAL*vx/vz;
V8[i].y = HALF_H - FOCAL*vy/vz;
V8[i].z = vz;
}
if (!ok) continue;
for (let f=0; f<5; f++){
const face = BOX_FACES[f], n = face.n;
const nx = n[0]*cy + n[2]*sy, ny = n[1], nz = -n[0]*sy + n[2]*cy;
/* backface cull using face center in world space */
const cxi = face.i;
const cxw = x + ((lx[cxi[0]]+lx[cxi[2]])/2)*cy + ((lz[cxi[0]]+lz[cxi[2]])/2)*sy;
const czw = z - ((lx[cxi[0]]+lx[cxi[2]])/2)*sy + ((lz[cxi[0]]+lz[cxi[2]])/2)*cy;
const cyw = y + ((ly[cxi[0]]+ly[cxi[2]])/2);
const dot = nx*(cxw-cam.posX) + ny*(cyw-cam.posY) + nz*(czw-cam.posZ);
if (dot >= 0) continue;
const shade = 0.5 + 0.5*Math.max(0, nx*LIGHT[0] + ny*LIGHT[1] + nz*LIGHT[2]);
const idx = clamp(Math.round(shade*7),0,7);
ctx.fillStyle = ghost ? 'rgba(122,158,196,0.16)' : b.sh[idx];
ctx.beginPath();
const i0 = cxi[0], i1 = cxi[1], i2 = cxi[2], i3 = cxi[3];
ctx.moveTo(V8[i0].x,V8[i0].y);
ctx.lineTo(V8[i1].x,V8[i1].y);
ctx.lineTo(V8[i2].x,V8[i2].y);
ctx.lineTo(V8[i3].x,V8[i3].y);
ctx.closePath();
ctx.fill();
}
}
if (alpha !== undefined) ctx.globalAlpha = 1;
}
function groundRing(x, z, r, color, width, y){
ctx.beginPath();
let started = false, any = false;
for (let i=0;i<=14;i++){
const a = i/14*TAU;
if (project(x + Math.cos(a)*r, y||0.08, z + Math.sin(a)*r, _c)){
started ? ctx.lineTo(_c.x,_c.y) : ctx.moveTo(_c.x,_c.y);
started = true; any = true;
} else started = false;
}
if (!any) return;
ctx.closePath();
ctx.strokeStyle = color; ctx.lineWidth = width||2; ctx.stroke();
}
function groundDisc(x, z, r, color, y){
ctx.beginPath();
let started = false, any = false;
for (let i=0;i<=12;i++){
const a = i/12*TAU;
if (project(x + Math.cos(a)*r, y||0.05, z + Math.sin(a)*r, _c)){
started ? ctx.lineTo(_c.x,_c.y) : ctx.moveTo(_c.x,_c.y);
started = true; any = true;
} else started = false;
}
if (!any) return;
ctx.closePath();
ctx.fillStyle = color; ctx.fill();
}
/* --- main object pass --- */
function entityVisibleToHuman(e){
if (e.player === HUMAN) return true;
if (e.kind === 'unit') return visTo(0, e.x, e.z);
return visTo(0, e.x, e.z);
}
function drawObjects(){
drawList.length = 0;
for (let i=0;i<rocks.length;i++){
const r = rocks[i];
if (!seenTo(0,r.x,r.z)) continue;
const d = camDepth(r.x, r.h*0.5, r.z);
if (d < 2) continue;
drawList.push({d, k:0, e:r});
}
for (let i=0;i<nodes.length;i++){
const n = nodes[i];
if (n.amount<=0 || !seenTo(0,n.x,n.z)) continue;
const d = camDepth(n.x, 5, n.z);
if (d < 2) continue;
drawList.push({d, k:1, e:n});
}
for (let i=0;i<buildings.length;i++){
const b = buildings[i];
const vis = b.player===HUMAN || visTo(0,b.x,b.z);
if (!vis) continue;
const d = camDepth(b.x, 4, b.z);
if (d < 2) continue;
drawList.push({d, k:2, e:b, ghost:false});
}
/* last-known enemy building silhouettes */
for (const [id,g] of knowledge[0]){
if (g.ref && !g.ref.dead && visTo(0,g.x,g.z)) continue;
const d = camDepth(g.x, 4, g.z);
if (d < 2) continue;
drawList.push({d, k:2, e:g, ghost:true});
}
for (let i=0;i<units.length;i++){
const u = units[i];
if (u.player!==HUMAN && !visTo(0,u.x,u.z)) continue;
const d = camDepth(u.x, u.y+1.5, u.z);
if (d < 2) continue;
drawList.push({d, k:3, e:u});
}
for (let i=0;i<projs.length;i++){
const p = projs[i];
const d = camDepth(p.x,p.y,p.z);
if (d < 2) continue;
drawList.push({d, k:4, e:p});
}
drawList.sort((a,b)=>b.d-a.d);
for (let i=0;i<drawList.length;i++){
const it = drawList[i];
switch(it.k){
case 0: drawRock(it.e); break;
case 1: drawNode(it.e); break;
case 2: drawBuilding(it.e, it.ghost); break;
case 3: drawUnit(it.e); break;
case 4: drawProjectile(it.e); break;
}
}
}
function drawRock(r){
for (let f=0; f<r.faces.length; f++){
const face = r.faces[f], p = face.p;
let dz = 0;
for (let i=0;i<p.length;i++) dz += camDepth(p[i][0],p[i][1],p[i][2]);
dz /= p.length;
if (dz < 2) continue;
let ok = true;
ctx.beginPath();
for (let i=0;i<p.length;i++){
if (!project(p[i][0],p[i][1],p[i][2],_c)){ ok=false; break; }
i ? ctx.lineTo(_c.x,_c.y) : ctx.moveTo(_c.x,_c.y);
}
if (!ok) continue;
ctx.closePath();
ctx.fillStyle = face.c;
ctx.fill();
ctx.strokeStyle = 'rgba(10,16,28,0.35)'; ctx.lineWidth = 1; ctx.stroke();
}
}
const _oneBox = [null];
function drawNode(n){
const t = 0.32 + 0.68*Math.max(0, n.amount/n.max);
for (let i=0;i<n.crystals.length;i++){
const c = n.crystals[i];
if (!c.bx) c.bx = box(0,0,0,1,1,1,[120,236,255]);
c.bx.w = c.bx.d = c.w*(0.55+0.45*t);
c.bx.h = c.h*t;
_oneBox[0] = c.bx;
drawBoxes(_oneBox, n.x + c.dx, 0, n.z + c.dz, c.rot, 1);
}
}
function drawBuilding(b, ghost){
const model = getModel(b.key, b.player === HUMAN || ghost ? (ghost ? b.player : b.player) : b.player);
const prog = ghost ? 1 : (b.built ? 1 : b.progress);
if (!ghost && !b.built){
drawDisc(b.x, b.z, b.rad*1.15, 'rgba(53,224,255,0.10)');
drawBoxes(model, b.x, 0, b.z, 0, 0.22 + 0.5*prog, false);
if (project(b.x, MODEL_H[b.key]*prog + 2, b.z, _c)){
const w = 74, h = 7;
ctx.fillStyle = 'rgba(6,16,28,0.85)';
ctx.fillRect(_c.x-w/2, _c.y-h/2, w, h);
ctx.fillStyle = '#35e0ff';
ctx.fillRect(_c.x-w/2+1, _c.y-h/2+1, (w-2)*prog, h-2);
ctx.fillStyle = '#cfeaff'; ctx.font = '10px sans-serif'; ctx.textAlign='center';
ctx.fillText('BUILDING ' + Math.round(prog*100) + '%', _c.x, _c.y - 9);
}
return;
}
drawBoxes(model, b.x, 0, b.z, 0, ghost ? 1 : 1, ghost);
if (ghost) return;
/* hit flash */
if (b.flash > 0 && project(b.x, MODEL_H[b.key]*0.5, b.z, _c)){
ctx.globalAlpha = Math.min(0.5, b.flash*1.6);
ctx.fillStyle = '#ffd0a0';
ctx.beginPath(); ctx.arc(_c.x,_c.y, MODEL_H[b.key]*0.9, 0, TAU); ctx.fill();
ctx.globalAlpha = 1;
}
if (b.player === HUMAN && (b.selected || b.hp < b.maxHp)) drawHealthBar(b, MODEL_H[b.key]+1.6);
if (b.player === HUMAN && b.selected) groundRing(b.x, b.z, b.rad*1.28, 'rgba(90,240,255,0.85)', 2);
if (b.rally && b.player === HUMAN){
groundRing(b.rally.x, b.rally.z, 2.4, 'rgba(120,255,170,0.8)', 2);
if (project(b.rally.x, 0.2, b.rally.z, _c) && project(b.x, MODEL_H[b.key]*0.6, b.z, _p2)){
ctx.strokeStyle = 'rgba(120,255,170,0.45)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(_p2.x,_p2.y); ctx.lineTo(_c.x,_c.y); ctx.stroke();
}
}
}
const _p2 = {x:0,y:0,z:0};
function drawDisc(x,z,r,color){ groundDisc(x,z,r,color); }
function drawUnit(u){
const lod = FOCAL*(MODEL_H[u.key]||3)/Math.max(6,camDepth(u.x,u.y,u.z));
/* shadow */
if (u.def.fly) groundDisc(u.x, u.z, u.rad*1.25, 'rgba(6,12,22,0.30)');
else groundDisc(u.x, u.z, u.rad*1.15, 'rgba(6,12,22,0.34)');
const model = getModel(u.key, u.player);
drawBoxes(model, u.x, u.y, u.z, u.yaw, 1, false, lod < 5.2 ? 1 : (lod < 9 ? 3 : 0));
if (u.flash > 0 && project(u.x, u.y+1.4, u.z, _c)){
ctx.globalAlpha = Math.min(0.55, u.flash*1.8);
ctx.fillStyle = u.player===HUMAN ? '#bfefff' : '#ffc9a0';
ctx.beginPath(); ctx.arc(_c.x,_c.y, Math.max(4,lod*0.8), 0, TAU); ctx.fill();
ctx.globalAlpha = 1;
}
if (u.player === HUMAN && u.selected) groundRing(u.x, u.z, u.rad*1.55, 'rgba(90,240,255,0.9)', 2);
if (u.player !== HUMAN && u.targeted) groundRing(u.x, u.z, u.rad*1.55, 'rgba(255,110,110,0.8)', 2);
if (u.player === HUMAN && (u.selected || u.hp < u.maxHp)) drawHealthBar(u, (MODEL_H[u.key]||3) + 1.4);
if (u.carry > 0 && u.player === HUMAN && project(u.x, u.y+MODEL_H[u.key]+0.6, u.z, _c)){
ctx.fillStyle = '#7ef2ff';
ctx.fillRect(_c.x-3, _c.y-3, 6, 6);
}
}
function drawHealthBar(e, yOff){
if (!project(e.x, (e.y||0) + yOff, e.z, _c)) return;
const w = clamp(FOCAL*2.6/Math.max(20,_c.z), 16, 46), h = 4;
const frac = clamp(e.hp/e.maxHp, 0, 1);
ctx.fillStyle = 'rgba(4,10,18,0.82)';
ctx.fillRect(_c.x-w/2-1, _c.y-1, w+2, h+2);
ctx.fillStyle = e.player===HUMAN ? (frac>0.35?'#43e08a':'#ffb347') : (frac>0.35?'#ff6b52':'#ff9d5c');
ctx.fillRect(_c.x-w/2, _c.y, w*frac, h);
}
function drawProjectile(p){
const a = project(p.px,p.py,p.pz,_c), b = project(p.x,p.y,p.z,_p2);
if (!b) return;
if (a){
ctx.strokeStyle = p.color; ctx.lineWidth = p.size; ctx.globalAlpha = 0.75;
ctx.beginPath(); ctx.moveTo(_c.x,_c.y); ctx.lineTo(_p2.x,_p2.y); ctx.stroke();
ctx.globalAlpha = 1;
}
ctx.fillStyle = p.color;
ctx.beginPath(); ctx.arc(_p2.x,_p2.y, p.size*1.15, 0, TAU); ctx.fill();
}
function drawEffects(){
for (let i=0;i<fxList.length;i++){
const f = fxList[i];
const k = f.t/f.dur;
if (f.k === 'boom'){
if (project(f.x, f.y+0.4, f.z, _c)){
const r = f.r*(0.35+k*1.1);
const edge = {x:0,y:0,z:0};
project(f.x + r, f.y+0.4, f.z, edge);
const rr2 = Math.abs(edge.x-_c.x) + 3;
const g = ctx.createRadialGradient(_c.x,_c.y,0,_c.x,_c.y,rr2);
g.addColorStop(0, `rgba(${f.c[0]},${f.c[1]},${f.c[2]},${0.75*(1-k)})`);
g.addColorStop(1, `rgba(${f.c[0]},${f.c[1]},${f.c[2]},0)`);
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(_c.x,_c.y,rr2,0,TAU); ctx.fill();
}
groundRing(f.x, f.z, f.r*(0.4+k*1.6), `rgba(${f.c[0]},${f.c[1]},${f.c[2]},${0.6*(1-k)})`, 2.4, 0.12);
} else if (f.k === 'spark'){
if (project(f.x, f.y, f.z, _c)){
ctx.globalAlpha = 1-k;
ctx.fillStyle = f.color;
ctx.fillRect(_c.x-f.s/2, _c.y-f.s/2, f.s, f.s);
ctx.globalAlpha = 1;
}
} else if (f.k === 'text'){
if (project(f.x, f.y, f.z, _c)){
ctx.globalAlpha = Math.min(1, (1-k)*1.6);
ctx.font = 'bold 13px sans-serif'; ctx.textAlign = 'center';
ctx.fillStyle = f.color;
ctx.fillText(f.str, _c.x, _c.y - k*30);
ctx.globalAlpha = 1;
}
} else if (f.k === 'ring'){
groundRing(f.x, f.z, f.r*(0.3+k), `rgba(${f.c[0]},${f.c[1]},${f.c[2]},${0.8*(1-k)})`, 2, 0.1);
}
}
}
/* --- fog of war overlay (batched into two fills) --- */
/* append a ground-level polygon (world coords) to the current path, near-clipped */
function groundPolyPath(q){
const cs = [];
for (let i=0;i<q.length;i++) cs.push(camSpace(q[i][0], 0.15, q[i][1], {x:0,y:0,z:0}));
const poly = clipNear(cs, 1.5);
if (poly.length < 3) return;
for (let i=0;i<poly.length;i++){
const p = poly[i];
const sx = HALF_W + FOCAL*p.x/p.z, sy = HALF_H - FOCAL*p.y/p.z;
if (i) ctx.lineTo(sx,sy); else ctx.moveTo(sx,sy);
}
ctx.closePath();
}
function drawFog(){
/* ground beyond the map border is always unexplored: fill the ring around the map */
{
const size = 1500;
const outer = [[cam.posX-size, cam.posZ-size],[cam.posX+size, cam.posZ-size],
[cam.posX+size, cam.posZ+size],[cam.posX-size, cam.posZ+size]];
const inner = [[0,0],[WORLD,0],[WORLD,WORLD],[0,WORLD]];
ctx.fillStyle = 'rgba(3,7,14,0.945)';
ctx.beginPath();
groundPolyPath(outer);
groundPolyPath(inner);
ctx.fill('evenodd');
}
const step = (FOCAL*CELL/cam.dist) < 7 ? 2 : 1;
const paths = [[],[]];
for (let cz=0; cz<FOGN; cz+=step){
for (let cx=0; cx<FOGN; cx+=step){
let a = 0;
for (let bz=0; bz<step; bz++){
for (let bx=0; bx<step; bx++){
const i = (cx+bx) + FOGN*(cz+bz);
const v = fog[0].vis[i], e = fog[0].exp[i];
const av = v ? 0 : (e ? 1 : 2);
if (av > a) a = av;
}
}
if (a === 0) continue;
const x0 = cx*CELL, z0 = cz*CELL, x1 = (cx+step)*CELL + 0.02, z1 = (cz+step)*CELL + 0.02;
const q = [[x0,z0],[x1,z0],[x1,z1],[x0,z1]];
let pts = [];
let ok = true;
for (let i=0;i<4;i++){
if (!project(q[i][0], 0.15, q[i][1], _c)){ ok = false; break; }
pts.push(_c.x, _c.y);
}
if (!ok){
/* quad crosses the near plane: clip it rather than dropping the fog */
const cs = [];
for (let i=0;i<4;i++) cs.push(camSpace(q[i][0], 0.15, q[i][1], {x:0,y:0,z:0}));
const poly = clipNear(cs, 1.5);
if (poly.length < 3) continue;
pts = [];
for (let i=0;i<poly.length;i++){
const p = poly[i];
pts.push(HALF_W + FOCAL*p.x/p.z, HALF_H - FOCAL*p.y/p.z);
}
}
/* consistent winding for nonzero fill */
let area = 0;
for (let i=0,n=pts.length/2;i<n;i++){
const j = (i+1)%n;
area += pts[i*2]*pts[j*2+1] - pts[j*2]*pts[i*2+1];
}
if (area < 0){
const rev = [];
for (let i=pts.length/2-1;i>=0;i--) rev.push(pts[i*2], pts[i*2+1]);
pts = rev;
}
paths[a-1].push(pts);
}
}
const cols = ['rgba(4,9,18,0.46)','rgba(3,7,14,0.945)'];
for (let b=0;b<2;b++){
const list = paths[b];
if (!list.length) continue;
ctx.fillStyle = cols[b];
ctx.beginPath();
for (let i=0;i<list.length;i++){
const p = list[i];
ctx.moveTo(p[0],p[1]);
for (let j=1,n=p.length/2;j<n;j++) ctx.lineTo(p[j*2],p[j*2+1]);
ctx.closePath();
}
ctx.fill();
}
}
/* --- in-world overlay: drag select, ghosts, order markers --- */
function drawWorldOverlay(){
/* placement ghost */
if (placing && mouse.world){
const bDef = BUILDS[placing];
const ok = canPlace(placing, mouse.world.x, mouse.world.z);
const x = mouse.world.x, z = mouse.world.z;
drawBoxes(getModel(placing, HUMAN), x, 0, z, 0, 0.42, false);
groundRing(x, z, bDef.w*0.62, ok ? 'rgba(80,255,150,0.9)' : 'rgba(255,90,90,0.95)', 2.4);
if (project(x, MODEL_H[placing]+1, z, _c)){
ctx.font = 'bold 13px sans-serif'; ctx.textAlign='center';
ctx.fillStyle = ok ? '#8dffb8' : '#ff8a8a';
ctx.fillText(ok ? 'Click to build · right-click to cancel' : invalidReason, _c.x, _c.y);
}
if (mouse.down && dragSel.active){
ctx.strokeStyle = 'rgba(120,240,255,0.75)'; ctx.lineWidth = 1.6;
ctx.setLineDash([7,6]);
ctx.strokeRect(Math.min(dragSel.x0,dragSel.x1), Math.min(dragSel.y0,dragSel.y1),
Math.abs(dragSel.x1-dragSel.x0), Math.abs(dragSel.y1-dragSel.y0));
ctx.setLineDash([]);
}
} else if (dragSel.active){
const x0 = Math.min(dragSel.x0,dragSel.x1), y0 = Math.min(dragSel.y0,dragSel.y1);
const w = Math.abs(dragSel.x1-dragSel.x0), h = Math.abs(dragSel.y1-dragSel.y0);
ctx.fillStyle = 'rgba(60,210,255,0.10)';
ctx.fillRect(x0,y0,w,h);
ctx.strokeStyle = 'rgba(120,240,255,0.85)'; ctx.lineWidth = 1.4;
ctx.strokeRect(x0,y0,w,h);
}
/* order markers */
for (let i=0;i<beacons.length;i++){
const bc = beacons[i];
const k = bc.t/bc.dur;
groundRing(bc.x, bc.z, 2 + k*7, `rgba(${bc.c[0]},${bc.c[1]},${bc.c[2]},${1-k})`, 2.2);
}
}
/* --- minimap --- */
const fogImg = fogCtx.createImageData(FOGN, FOGN);
function drawMinimap(){
const MM = 206;
mmx.clearRect(0,0,MM,MM);
mmx.fillStyle = '#0d2033';
mmx.fillRect(0,0,MM,MM);
/* terrain hints */
const sc = MM/WORLD;
for (let i=0;i<rocks.length;i++){
const r = rocks[i];
if (!seenTo(0,r.x,r.z)) continue;
mmx.fillStyle = '#22344a';
mmx.fillRect(r.x*sc-1.5, r.z*sc-1.5, 3, 3);
}
for (let i=0;i<nodes.length;i++){
const n = nodes[i];
if (n.amount<=0 || !seenTo(0,n.x,n.z)) continue;
mmx.fillStyle = '#3fe4ff';
mmx.fillRect(n.x*sc-2.5, n.z*sc-2.5, 5, 5);
}
/* fog */
const d = fogImg.data;
for (let i=0;i<FOGN*FOGN;i++){
const v = fog[0].vis[i], e = fog[0].exp[i];
const o = i*4;
d[o] = 3; d[o+1] = 7; d[o+2] = 15;
d[o+3] = v ? 0 : (e ? 128 : 252);
}
fogCtx.putImageData(fogImg, 0, 0);
mmx.imageSmoothingEnabled = false;
mmx.drawImage(fogCanvas, 0, 0, MM, MM);
/* buildings & units */
for (let i=0;i<buildings.length;i++){
const b = buildings[i];
const vis = b.player===HUMAN || visTo(0,b.x,b.z);
if (!vis) continue;
mmx.fillStyle = b.player===HUMAN ? '#59f0ff' : '#ff7c4a';
const s = b.key==='hq' ? 7 : 5;
mmx.fillRect(b.x*sc-s/2, b.z*sc-s/2, s, s);
}
for (const [id,g] of knowledge[0]){
if (g.ref && !g.ref.dead && visTo(0,g.x,g.z)) continue;
mmx.fillStyle = 'rgba(255,124,74,0.42)';
mmx.fillRect(g.x*sc-2.5, g.z*sc-2.5, 5, 5);
}
for (let i=0;i<units.length;i++){
const u = units[i];
if (u.player!==HUMAN && !visTo(0,u.x,u.z)) continue;
mmx.fillStyle = u.player===HUMAN ? '#7ef2ff' : '#ff6b52';
mmx.fillRect(u.x*sc-1.4, u.z*sc-1.4, 2.8, 2.8);
}
/* camera footprint */
const corners = [[0,0],[CW,0],[CW,CH],[0,CH]];
mmx.save();
mmx.beginPath(); mmx.rect(0,0,MM,MM); mmx.clip();
mmx.beginPath();
for (let i=0;i<4;i++){
const g = {x:0,z:0};
screenToGround(corners[i][0], corners[i][1], g);
const px = g.x*sc, py = g.z*sc;
i ? mmx.lineTo(px,py) : mmx.moveTo(px,py);
}
mmx.closePath();
mmx.strokeStyle = 'rgba(190,240,255,0.9)'; mmx.lineWidth = 1.6; mmx.stroke();
mmx.restore();
}
/* ================================================================
SIMULATION
================================================================ */
function nextOrder(u){ u.orders.shift(); u.order = u.orders[0] || null; }
function clearOrders(u){ u.orders.length = 0; u.order = null; u.target = null; u.attackMove = false; }
function nearestDropoff(p, x, z){
let best = null, bestD = 1e18;
for (let i=0;i<buildings.length;i++){
const b = buildings[i];
if (b.player!==p || !b.built || !b.def.drop) continue;
const d = dist2(x,z,b.x,b.z);
if (d < bestD){ bestD = d; best = b; }
}
return best;
}
function nearestNode(x, z, ignore){
let best = null, bestD = 1e18;
for (let i=0;i<nodes.length;i++){
const n = nodes[i];
if (n.amount <= 0 || n === ignore) continue;
const d = dist2(x,z,n.x,n.z);
if (d < bestD){ bestD = d; best = n; }
}
return best;
}
/* -------- steering -------- */
function steerTo(u, dt, tx, tz, arriveR){
const dx = tx-u.x, dz = tz-u.z;
const d = Math.sqrt(dx*dx+dz*dz);
if (d <= arriveR) return true;
let dirx = dx/d, dirz = dz/d;
const speed = u.def.speed * unitSpeedMul(u.player);
if (!u.def.fly){
/* obstacle avoidance */
let ax = 0, az = 0;
const look = 5 + u.rad*2;
oHashQuery(u.x, u.z, 16, (o)=>{
const ox = u.x-o.x, oz = u.z-o.z;
const od = Math.sqrt(ox*ox+oz*oz) || 0.001;
const clear = o.r + u.rad;
if (od > clear + look) return;
const ahead = (-ox)*dirx + (-oz)*dirz;
if (ahead < -1) return; // behind us
const prox = clamp((clear + look - od)/(clear + look), 0, 1);
const nx = ox/od, nz = oz/od;
ax += nx*prox*1.35; az += nz*prox*1.35;
/* tangential slide */
const tx2 = -nz, tz2 = nx;
const side = (dirx*tx2 + dirz*tz2) >= 0 ? 1 : -1;
ax += tx2*side*prox*0.95; az += tz2*side*prox*0.95;
});
dirx += ax; dirz += az;
const m = Math.sqrt(dirx*dirx+dirz*dirz) || 1;
dirx /= m; dirz /= m;
/* unit separation */
let sx = 0, sz = 0;
hashQuery(u.x, u.z, u.rad*2.6, (o)=>{
if (o === u || o.def.fly) return;
const dx2 = u.x-o.x, dz2 = u.z-o.z;
const dd = Math.sqrt(dx2*dx2+dz2*dz2) || 0.001;
const min = u.rad + o.rad;
if (dd < min*1.25){
const k = (min*1.25 - dd)/(min*1.25);
sx += dx2/dd*k; sz += dz2/dd*k;
}
});
dirx += sx*0.85; dirz += sz*0.85;
const m2 = Math.sqrt(dirx*dirx+dirz*dirz) || 1;
dirx /= m2; dirz /= m2;
}
const desiredVx = dirx*speed, desiredVz = dirz*speed;
const accel = u.def.fly ? 6.5 : 5.5;
u.vx += (desiredVx-u.vx)*Math.min(1, dt*accel);
u.vz += (desiredVz-u.vz)*Math.min(1, dt*accel);
u.x += u.vx*dt; u.z += u.vz*dt;
if (!u.def.fly){
oHashQuery(u.x, u.z, u.rad+3, (o)=>{
const dx3 = u.x-o.x, dz3 = u.z-o.z;
const dd = Math.sqrt(dx3*dx3+dz3*dz3) || 0.001;
const min = o.r + u.rad;
if (dd < min){ u.x += dx3/dd*(min-dd); u.z += dz3/dd*(min-dd); }
});
hashQuery(u.x, u.z, u.rad*1.15, (o)=>{
if (o === u || o.def.fly) return;
const dx4 = u.x-o.x, dz4 = u.z-o.z;
const dd = Math.sqrt(dx4*dx4+dz4*dz4) || 0.001;
const min = (u.rad + o.rad)*0.62;
if (dd < min){ u.x += dx4/dd*(min-dd)*0.5; u.z += dz4/dd*(min-dd)*0.5; }
});
u.x = clamp(u.x, 3, WORLD-3); u.z = clamp(u.z, 3, WORLD-3);
} else {
u.x = clamp(u.x, 2, WORLD-2); u.z = clamp(u.z, 2, WORLD-2);
}
const sp = Math.sqrt(u.vx*u.vx+u.vz*u.vz);
if (sp > 0.4) u.yaw = Math.atan2(u.vx, u.vz);
return d <= arriveR + sp*dt*1.2;
}
function stopMoving(u, dt){
u.vx *= Math.max(0, 1-dt*7); u.vz *= Math.max(0, 1-dt*7);
}
/* -------- targeting & damage -------- */
function canHit(shooter, t){
const tt = shooter.def.targets;
const isAir = t.kind==='unit' && !!t.def.fly;
return tt==='both' || (tt==='air' && isAir) || (tt==='ground' && !isAir);
}
function acquireTarget(u, radius){
const sight = radius || u.sight;
let best = null, bestD = 1e18;
const consider = (o)=>{
if (o.dead || o.player===u.player) return;
if (!canHit(u,o)) return;
if (!visTo(u.player,o.x,o.z)) return;
const d = dist2(u.x,u.z,o.x,o.z);
if (d < bestD){ bestD = d; best = o; }
};
hashQuery(u.x, u.z, sight, consider);
for (let i=0;i<buildings.length;i++){
const b = buildings[i];
if (b.dead || b.player===u.player) continue;
if (!canHit(u,b)) continue;
if (!visTo(u.player,b.x,b.z)) continue;
const d = dist2(u.x,u.z,b.x,b.z);
if (d < bestD && d < sight*sight){ bestD = d; best = b; }
}
return best;
}
function acquireForBuilding(b){
const sight = b.def.range + 4;
let best = null, bestD = 1e18;
hashQuery(b.x, b.z, sight, (o)=>{
if (o.dead || o.player===b.player) return;
if (!canHit(b,o)) return;
if (!visTo(b.player,o.x,o.z)) return;
const d = dist2(b.x,b.z,o.x,o.z);
if (d < bestD){ bestD = d; best = o; }
});
return best;
}
function applyDamage(t, amount, dmgType, srcPlayer, src){
if (!t || t.dead) return;
const armor = t.def.armor || 'structure';
const mult = (DMG[dmgType] && DMG[dmgType][armor]) || 1;
t.hp -= amount*mult;
t.flash = 1; t.hitT = 6;
if (src && t.kind === 'unit' && !t.order && !t.hold && UNITS[t.key].dmg){
t.orders.unshift({type:'attack', target:src, leash:{x:t.x, z:t.z}});
t.order = t.orders[0];
}
if (t.player === HUMAN && t.kind === 'building') alertBase(t);
if (t.player === ENEMY && srcPlayer === HUMAN){ S.ai.threatPos = {x:t.x, z:t.z}; S.ai.threatT = S.t; }
if (t.hp <= 0) destroyEntity(t, srcPlayer);
}
function destroyEntity(e, byPlayer){
if (e.dead) return;
e.dead = true;
e.hp = 0;
const isUnit = e.kind === 'unit';
if (!isUnit){
if (visTo(0,e.x,e.z)) knowledge[0].delete(e.id);
if (visTo(1,e.x,e.z)) knowledge[1].delete(e.id);
oHashDirty = true;
}
S.lost[e.player]++;
if (byPlayer !== undefined && byPlayer !== e.player) S.kills[byPlayer]++;
boom(e.x, isUnit ? e.y+1.5 : 5, e.z, isUnit ? 6 : 13, isUnit ? [255,170,90] : [255,120,60]);
sparkBurst(e.x, isUnit ? e.y+1.4 : 5, e.z, isUnit ? 7 : 15, isUnit ? '#ffc38a' : '#ff9a5c');
Snd.play(isUnit ? 'boom' : 'bigboom');
if (isUnit && e.player === HUMAN && e.key === 'worker' && rnd() < 0.35) toast('A Prospector has been lost.', 'warn');
recomputeSupply();
}
function sweepDead(){
let w = 0;
for (let i=0;i<units.length;i++){ const u = units[i]; if (!u.dead) units[w++] = u; }
units.length = w;
w = 0;
for (let i=0;i<buildings.length;i++){ const b = buildings[i]; if (!b.dead) buildings[w++] = b; }
buildings.length = w;
w = 0;
for (let i=0;i<projs.length;i++){ const p = projs[i]; if (!p.dead) projs[w++] = p; }
projs.length = w;
}
/* -------- effects & projectiles -------- */
function boom(x,y,z,r,c){ fxList.push({k:'boom', x,y,z, t:0, dur:0.55, r, c}); }
function ring(x,z,r,c){ fxList.push({k:'ring', x, y:0, z, t:0, dur:0.6, r, c}); }
function sparkBurst(x,y,z,n,color){
for (let i=0;i<n;i++){
const a = rnd()*TAU, sp = 4+rnd()*11;
fxList.push({k:'spark', x,y,z, vx:Math.cos(a)*sp, vy:2+rnd()*9, vz:Math.sin(a)*sp, t:0, dur:0.45+rnd()*0.5, s:2+rnd()*3, color});
}
}
function floatText(x,y,z,str,color){ fxList.push({k:'text', x,y,z, t:0, dur:1.1, str, color}); }
function spawnProjectile(x,y,z,target,dmg,dmgType,player,color,size,speed){
projs.push({x,y,z, px:x, py:y, pz:z, target, dmg, dmgType, player, color, size, speed, dead:false});
}
function updateProjectiles(dt){
for (let i=0;i<projs.length;i++){
const p = projs[i];
const t = p.target;
let tx = p.x, ty = p.y, tz = p.z;
if (t && !t.dead){ tx = t.x; ty = (t.y||0) + (t.kind==='unit' ? (MODEL_H[t.key]||3)*0.5 : 4); tz = t.z; }
const dx = tx-p.x, dy = ty-p.y, dz = tz-p.z;
const d = Math.sqrt(dx*dx+dy*dy+dz*dz);
const step = p.speed*dt;
p.px = p.x; p.py = p.y; p.pz = p.z;
if (d <= step + 0.6){
p.dead = true;
if (t && !t.dead) applyDamage(t, p.dmg, p.dmgType, p.player, null);
sparkBurst(tx, ty, tz, 3, p.color);
continue;
}
p.x += dx/d*step; p.y += dy/d*step; p.z += dz/d*step;
if (p.y < 0.4) p.y = 0.4;
}
}
function updateEffects(dt){
for (let i=fxList.length-1;i>=0;i--){
const f = fxList[i];
f.t += dt;
if (f.k === 'spark'){
f.x += f.vx*dt; f.y += f.vy*dt; f.z += f.vz*dt;
f.vy -= 22*dt;
if (f.y < 0.2){ f.y = 0.2; f.vy *= -0.35; f.vx *= 0.6; f.vz *= 0.6; }
}
if (f.t >= f.dur) fxList.splice(i,1);
}
for (let i=beacons.length-1;i>=0;i--){
beacons[i].t += dt;
if (beacons[i].t >= beacons[i].dur) beacons.splice(i,1);
}
}
/* -------- unit behaviour -------- */
function fireUnit(u, t){
u.cd = u.def.cd;
u.flash = 0.4;
const dmg = u.def.dmg * unitDmgMul(u.player);
const ty = (t.kind==='unit' ? (t.y||0) + (MODEL_H[t.key]||3)*0.5 : 4);
if (u.def.range < 5){
applyDamage(t, dmg, u.def.dmgType, u.player, u);
sparkBurst(t.x, ty, t.z, 2, '#fff2c0');
Snd.play('hit');
} else {
const uy = (u.y||0) + (MODEL_H[u.key]||3)*0.55;
spawnProjectile(u.x, uy, u.z, t, dmg, u.def.dmgType, u.player,
u.def.dmgType==='energy' ? '#8ef4ff' : (u.def.dmgType==='explosive' ? '#ffb45c' : '#fff0b0'),
u.def.dmgType==='explosive' ? 3.4 : 2.3, 62);
Snd.play('shot');
}
}
function updateUnit(u, dt){
u.flash = Math.max(0, u.flash - dt*2.6);
u.hitT = Math.max(0, u.hitT - dt);
if (u.cd > 0) u.cd -= dt;
const o = u.order;
if (!o){
if (u.def.dmg && !u.hold){
const t = acquireTarget(u, u.sight*0.9);
if (t){ u.orders.push({type:'attack', target:t, leash:{x:u.x, z:u.z}}); u.order = u.orders[0]; }
}
stopMoving(u, dt);
return;
}
switch(o.type){
case 'move':
if (steerTo(u, dt, o.x, o.z, 1.8 + u.rad*0.6)) nextOrder(u);
break;
case 'attackMove': {
const t = acquireTarget(u, u.sight);
if (t){
u.orders.unshift({type:'attack', target:t, resume:o});
u.order = u.orders[0];
break;
}
if (steerTo(u, dt, o.x, o.z, 2 + u.rad*0.6)) nextOrder(u);
break;
}
case 'attack': {
const t = o.target;
if (!t || t.dead){
if (o.resume){ u.orders[0] = o.resume; u.order = o.resume; }
else nextOrder(u);
break;
}
if (o.leash && dist2(u.x,u.z,o.leash.x,o.leash.z) > 26*26){
if (o.resume){ u.orders[0] = o.resume; u.order = o.resume; }
else nextOrder(u);
break;
}
const d = dist(u.x,u.z,t.x,t.z) - (t.rad||1);
const range = u.def.range + (t.kind==='building' ? t.rad*0.35 : 0);
if (d > range*0.92){
if (u.hold){
if (o.resume){ u.orders[0] = o.resume; u.order = o.resume; }
else nextOrder(u);
break;
}
steerTo(u, dt, t.x, t.z, Math.max(1.2, range*0.82));
} else {
stopMoving(u, dt);
u.yaw = Math.atan2(t.x-u.x, t.z-u.z);
if (u.cd <= 0) fireUnit(u, t);
}
break;
}
case 'hold':
stopMoving(u, dt);
if (u.def.dmg && u.cd <= 0){
const t = acquireTarget(u, u.def.range + 1.5);
if (t){ u.yaw = Math.atan2(t.x-u.x, t.z-u.z); fireUnit(u, t); }
}
break;
case 'gather': {
if (u.carry >= carryCap(u.player)){
u.orders.unshift({type:'return'});
u.order = u.orders[0];
break;
}
const n = u.node;
if (!n || n.amount <= 0){
const nn = nearestNode(u.x, u.z);
if (!nn){ nextOrder(u); break; }
u.node = nn;
}
const nd = u.node;
const d = dist(u.x,u.z,nd.x,nd.z);
if (d > nd.r*0.82){
steerTo(u, dt, nd.x, nd.z, nd.r*0.7);
} else {
stopMoving(u, dt);
u.yaw = Math.atan2(nd.x-u.x, nd.z-u.z);
u.mineT += dt*harvestMul(u.player);
if (u.mineT >= 1.05){
u.mineT = 0;
const take = Math.min(nd.amount, carryCap(u.player) - u.carry);
nd.amount -= take;
u.carry += take;
sparkBurst(nd.x + (rnd()-0.5)*4, 3.5, nd.z + (rnd()-0.5)*4, 2, '#7ef2ff');
Snd.play('mine');
}
}
break;
}
case 'return': {
const drop = nearestDropoff(u.player, u.x, u.z);
if (!drop){ stopMoving(u, dt); break; }
const d = dist(u.x,u.z,drop.x,drop.z);
if (d > drop.rad + 2.2){
steerTo(u, dt, drop.x, drop.z, drop.rad + 1.4);
} else {
stopMoving(u, dt);
if (u.carry > 0){
S.min[u.player] += u.carry;
S.gathered[u.player] += u.carry;
if (u.player === HUMAN){
floatText(drop.x, MODEL_H[drop.key]+1, drop.z, '+' + u.carry, '#7ef2ff');
if (rnd() < 0.25) Snd.play('cash');
}
u.carry = 0;
}
nextOrder(u);
}
break;
}
case 'build': {
const b = o.ref;
if (!b || b.dead || b.built){ nextOrder(u); break; }
const d = dist(u.x,u.z,b.x,b.z);
if (d > b.rad + 2.4){
steerTo(u, dt, b.x, b.z, b.rad + 1.2);
} else {
stopMoving(u, dt);
u.yaw = Math.atan2(b.x-u.x, b.z-u.z);
const speed = Math.min(3, 1 + b.builders*0.55);
b.progress += dt / b.def.time * speed;
b.hp = Math.max(b.hp, b.maxHp*(0.1 + 0.9*b.progress));
if (rnd() < dt*7) sparkBurst(b.x + (rnd()-0.5)*b.rad, 2+rnd()*4, b.z + (rnd()-0.5)*b.rad, 1, '#ffd08a');
if (b.progress >= 1){
b.progress = 1; b.built = true; b.hp = b.maxHp;
oHashDirty = true;
recomputeSupply();
if (b.player === HUMAN){ toast(b.def.name + ' complete.', 'good'); Snd.play('complete'); }
else Snd.play('complete');
nextOrder(u);
if (!u.order && u.key === 'worker'){
const nn2 = nearestNode(u.x, u.z);
if (nn2){ u.node = nn2; u.orders.push({type:'gather', node:nn2}); u.order = u.orders[0]; }
}
}
}
break;
}
default:
stopMoving(u, dt);
break;
}
}
/* -------- buildings -------- */
function completeUnit(b, key){
const ang = b.yaw + Math.PI + rr(-0.7,0.7);
const ex = clamp(b.x + Math.sin(ang)*(b.rad+3.2), 3, WORLD-3);
const ez = clamp(b.z + Math.cos(ang)*(b.rad+3.2), 3, WORLD-3);
const u = spawnUnit(key, b.player, ex, ez);
if (b.player === HUMAN){ toast(UNITS[key].name + ' ready.', 'good'); Snd.play('ready'); }
else Snd.play('ready');
if (b.rally){
if (b.rally.node && b.rally.node.amount > 0 && key === 'worker'){
u.node = b.rally.node; u.orders.push({type:'gather', node:b.rally.node});
} else {
u.orders.push({type:'move', x:b.rally.x, z:b.rally.z});
}
u.order = u.orders[0];
}
return u;
}
function updateBuilding(b, dt){
b.flash = Math.max(0, b.flash - dt*2.4);
if (!b.built) return;
/* static defence */
if (b.def.dmg){
if (b.cd > 0) b.cd -= dt;
let t = b.target;
if (t && (t.dead || dist2(b.x,b.z,t.x,t.z) > (b.def.range+4)*(b.def.range+4) || !visTo(b.player,t.x,t.z))) t = null;
if (!t){ t = acquireForBuilding(b); b.target = t; }
if (t && b.cd <= 0){
b.cd = b.def.cd;
const dmg = b.def.dmg * unitDmgMul(b.player);
spawnProjectile(b.x, MODEL_H[b.key]*0.78, b.z, t, dmg, b.def.dmgType, b.player, '#ffe08a', 2.6, 78);
Snd.play('shot');
}
}
/* research (not gated by supply) */
if (b.research){
{
b.research.t += dt;
if (b.research.t >= b.research.time){
const key = b.research.key;
S.upg[b.player][key]++;
if (key === 'armor'){
for (const u of units) if (u.player === b.player){ u.maxHp = Math.round(u.def.hp*unitHpMul(b.player)); u.hp = Math.min(u.maxHp, u.hp*1.12); }
}
if (b.player === HUMAN){ toast(UPGRADES[key].name + ' research complete.', 'good'); Snd.play('complete'); }
b.research = null;
}
}
}
/* production */
if (b.queue.length){
if (supplyBlocked(b.player)) return;
const q = b.queue[0];
q.t += dt;
if (q.t >= q.time){
b.queue.shift();
recomputeSupply();
completeUnit(b, q.key);
}
}
}
/* -------- per-tick world update -------- */
function updateWorld(dt){
S.t += dt;
visionTimer -= dt;
if (visionTimer <= 0){ visionTimer = 0.2; computeVision(); updateKnowledge(); }
hashRebuild();
if (oHashDirty) oHashRebuild();
for (let i=0;i<units.length;i++) updateUnit(units[i], dt);
for (let i=0;i<buildings.length;i++) updateBuilding(buildings[i], dt);
updateProjectiles(dt);
updateEffects(dt);
sweepDead();
}
/* -------- base under attack alert -------- */
let baseAlertT = 0;
function alertBase(b){
if (S.t < baseAlertT) return;
baseAlertT = S.t + 10;
toast('Our base is under attack!', 'bad');
Snd.play('alert');
}
/* ================================================================
AUDIO (procedural, WebAudio)
================================================================ */
const Snd = {
ctx:null, master:null, muted:false, last:{}, noise:null,
init(){
if (this.ctx) return;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
this.ctx = new AC();
this.master = this.ctx.createGain();
this.master.gain.value = 0.42;
this.master.connect(this.ctx.destination);
const len = this.ctx.sampleRate*1.2;
const buf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i=0;i<len;i++) d[i] = Math.random()*2-1;
this.noise = buf;
},
env(dur, vol, type, freq, freq2, delay){
const c = this.ctx; if (!c) return;
const t0 = c.currentTime + (delay||0);
const o = c.createOscillator(), g = c.createGain();
o.type = type||'sine';
o.frequency.setValueAtTime(freq, t0);
if (freq2) o.frequency.exponentialRampToValueAtTime(Math.max(20,freq2), t0+dur);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(vol, t0+0.008);
g.gain.exponentialRampToValueAtTime(0.0001, t0+dur);
o.connect(g); g.connect(this.master);
o.start(t0); o.stop(t0+dur+0.05);
},
burst(dur, vol, freq, q, delay, freq2){
const c = this.ctx; if (!c) return;
const t0 = c.currentTime + (delay||0);
const src = c.createBufferSource(); src.buffer = this.noise;
const f = c.createBiquadFilter(); f.type = 'bandpass';
f.frequency.setValueAtTime(freq, t0);
if (freq2) f.frequency.exponentialRampToValueAtTime(Math.max(60,freq2), t0+dur);
f.Q.value = q||1.2;
const g = c.createGain();
g.gain.setValueAtTime(vol, t0);
g.gain.exponentialRampToValueAtTime(0.0001, t0+dur);
src.connect(f); f.connect(g); g.connect(this.master);
src.start(t0); src.stop(t0+dur+0.05);
},
play(name){
if (this.muted) return;
this.init();
if (!this.ctx) return;
if (this.ctx.state === 'suspended') this.ctx.resume();
const now = performance.now();
if (this.last[name] && now - this.last[name] < 45) return;
this.last[name] = now;
switch(name){
case 'click': this.env(0.06,0.16,'square',520,380); break;
case 'select': this.env(0.07,0.14,'triangle',680,880); break;
case 'move': this.env(0.09,0.15,'triangle',330,260); this.env(0.07,0.1,'sine',660,520,0.02); break;
case 'attack': this.env(0.12,0.18,'sawtooth',260,120); this.burst(0.1,0.12,900,1); break;
case 'shot': this.burst(0.07,0.12,1800,2.4); break;
case 'hit': this.burst(0.06,0.10,520,1.1); break;
case 'mine': this.env(0.05,0.07,'triangle',880,1180); break;
case 'cash': this.env(0.08,0.11,'triangle',740,980); this.env(0.09,0.10,'triangle',980,1320,0.07); break;
case 'boom': this.burst(0.32,0.22,420,0.8,null,120); this.env(0.30,0.16,'sine',150,52); break;
case 'bigboom': this.burst(0.62,0.30,320,0.7,null,90); this.env(0.55,0.22,'sine',120,42); this.burst(0.4,0.12,1400,1,0.05,300); break;
case 'complete': this.env(0.11,0.15,'triangle',520,760); this.env(0.16,0.15,'triangle',780,1040,0.11); break;
case 'ready': this.env(0.09,0.13,'square',600,820); break;
case 'build': this.burst(0.16,0.10,620,1.4); this.env(0.12,0.09,'square',220,170); break;
case 'error': this.env(0.16,0.16,'square',180,110); break;
case 'alert': this.env(0.16,0.20,'square',420,300); this.env(0.16,0.20,'square',420,300,0.2); break;
case 'victory': [392,494,587,784].forEach((f,i)=>this.env(0.5,0.16,'triangle',f,f,i*0.16)); break;
case 'defeat': [330,262,208,165].forEach((f,i)=>this.env(0.6,0.16,'sawtooth',f,f*0.98,i*0.22)); break;
}
}
};
/* ================================================================
INPUT / SELECTION / COMMANDS
================================================================ */
const mouse = { x:-999, y:-999, world:{x:0,z:0}, down:false, over:false, mid:false };
const dragSel = { active:false, x0:0, y0:0, x1:0, y1:0, moved:false };
const keys = {};
const sel = [];
let placing = null, invalidReason = '', amMode = false, gatherMode = false;
const groups = [[],[],[],[],[],[],[],[],[],[]];
let groupAnchor = [0,0,0,0,0,0,0,0,0,0];
let lastClick = { t:0, id:0 };
function toast(msg, kind){
const log = document.getElementById('log');
const el = document.createElement('div');
el.className = 'toast' + (kind ? ' ' + kind : '');
el.textContent = msg;
log.appendChild(el);
while (log.children.length > 6) log.removeChild(log.firstChild);
setTimeout(()=>{ if (el.parentNode) el.parentNode.removeChild(el); }, 5200);
}
function clearSelection(){
for (const e of sel) e.selected = false;
sel.length = 0;
selDirty = true;
}
function setSelection(list){
clearSelection();
for (const e of list){
if (e.dead) continue;
if (e.selected) continue;
e.selected = true; sel.push(e);
}
selDirty = true;
}
function toggleSelect(e){
if (e.selected){ e.selected = false; const i = sel.indexOf(e); if (i>=0) sel.splice(i,1); }
else { e.selected = true; sel.push(e); }
selDirty = true;
}
function pickEntity(sx, sy){
let best = null, bestD = 1e9;
const tryPick = (e, isBuilding)=>{
if (e.dead) return;
if (e.player !== HUMAN && !visTo(0,e.x,e.z)) return;
if (!project(e.x, (e.y||0) + (MODEL_H[e.key]||3)*0.5, e.z, _c)) return;
const r = clamp(FOCAL*(isBuilding ? e.rad*1.15 : e.rad*1.7)/Math.max(12,_c.z), 9, 90);
const d = Math.hypot(sx-_c.x, sy-_c.y);
if (d < r && _c.z < bestD){ bestD = _c.z; best = e; }
};
for (let i=0;i<units.length;i++) tryPick(units[i], false);
for (let i=0;i<buildings.length;i++) tryPick(buildings[i], true);
return best;
}
function pickNode(sx, sy){
let best = null, bestD = 1e9;
for (let i=0;i<nodes.length;i++){
const n = nodes[i];
if (n.amount <= 0) continue;
if (!seenTo(0,n.x,n.z)) continue;
if (!project(n.x, 3, n.z, _c)) continue;
const r = clamp(FOCAL*n.r/Math.max(12,_c.z), 12, 120);
const d = Math.hypot(sx-_c.x, sy-_c.y);
if (d < r && _c.z < bestD){ bestD = _c.z; best = n; }
}
return best;
}
function selectRect(x0,y0,x1,y1, additive){
const ax = Math.min(x0,x1), bx = Math.max(x0,x1), ay = Math.min(y0,y1), by = Math.max(y0,y1);
const found = [];
for (let i=0;i<units.length;i++){
const u = units[i];
if (u.player !== HUMAN || u.dead) continue;
if (!project(u.x, u.y+1, u.z, _c)) continue;
if (_c.x>=ax && _c.x<=bx && _c.y>=ay && _c.y<=by) found.push(u);
}
let list = found;
if (!found.length){
for (let i=0;i<buildings.length;i++){
const b = buildings[i];
if (b.player !== HUMAN || b.dead) continue;
if (!project(b.x, 3, b.z, _c)) continue;
if (_c.x>=ax && _c.x<=bx && _c.y>=ay && _c.y<=by){ found.push(b); break; }
}
list = found;
} else {
/* prefer a single unit type in big selections */
const counts = {};
for (const u of found) counts[u.key] = (counts[u.key]||0)+1;
let top = null, topN = 0;
for (const k in counts) if (counts[k] > topN){ topN = counts[k]; top = k; }
if (topN >= 3) list = found.filter(u=>u.key === top);
}
if (additive){
for (const e of list) if (!e.selected){ e.selected = true; sel.push(e); }
selDirty = true;
} else setSelection(list);
}
function selectSameTypeOnScreen(){
const one = sel[0];
if (!one || one.kind !== 'unit') return;
const list = [];
for (let i=0;i<units.length;i++){
const u = units[i];
if (u.player !== HUMAN || u.key !== one.key || u.dead) continue;
if (!project(u.x, u.y+1, u.z, _c)) continue;
if (_c.x>=0 && _c.x<=CW && _c.y>=0 && _c.y<=CH) list.push(u);
}
setSelection(list);
}
/* ---------- commands ---------- */
function orderUnits(list, makeOrder, queued, quiet){
let any = false;
for (const e of list){
if (e.kind !== 'unit' || e.player !== HUMAN) continue;
if (e.dead) continue;
if (!queued) clearOrders(e);
e.orders.push(makeOrder(e));
if (!e.order) e.order = e.orders[0];
any = true;
}
return any;
}
function cmdMove(x, z, queued){
const list = sel.slice();
const n = list.filter(e=>e.kind==='unit').length;
const spacing = n > 1 ? clamp(2.2*Math.sqrt(n), 3, 16) : 0;
let i = 0;
orderUnits(list, (e)=>{
let ox = 0, oz = 0;
if (spacing){
const a = (i++)/Math.max(1,n)*TAU*1.6;
const rr2 = spacing*Math.sqrt((i)/Math.max(1,n));
ox = Math.cos(a)*rr2; oz = Math.sin(a)*rr2;
}
return {type:'move', x:clamp(x+ox,2,WORLD-2), z:clamp(z+oz,2,WORLD-2)};
}, queued);
if (n){ beacons.push({x, z, t:0, dur:0.85, c:[110,235,255]}); Snd.play('move'); }
}
function cmdAttackMove(x, z, queued){
orderUnits(sel.slice(), ()=>({type:'attackMove', x, z}), queued);
if (sel.length){ beacons.push({x, z, t:0, dur:0.95, c:[255,150,80]}); Snd.play('attack'); }
}
function cmdAttackTarget(target, queued){
orderUnits(sel.slice(), ()=>({type:'attack', target}), queued);
if (sel.length){ beacons.push({x:target.x, z:target.z, t:0, dur:0.95, c:[255,110,90]}); Snd.play('attack'); }
}
function cmdGather(node, queued){
orderUnits(sel.slice(), (e)=>{ e.node = node; return {type:'gather', node}; }, queued);
beacons.push({x:node.x, z:node.z, t:0, dur:0.9, c:[110,255,190]});
Snd.play('move');
}
function cmdReturn(queued){
orderUnits(sel.slice(), ()=>({type:'return'}), queued);
Snd.play('move');
}
function cmdStop(){
for (const e of sel){ if (e.kind !== 'unit') continue; clearOrders(e); e.hold = false; e.vx = 0; e.vz = 0; }
Snd.play('click');
}
function cmdHold(){
for (const e of sel){ if (e.kind !== 'unit') continue; clearOrders(e); e.hold = true; e.orders.push({type:'hold'}); e.order = e.orders[0]; }
Snd.play('click');
}
function setRally(x, z, node){
for (const e of sel){
if (e.kind !== 'building' || e.player !== HUMAN) continue;
e.rally = {x, z, node:node||null};
beacons.push({x, z, t:0, dur:0.9, c:[110,255,170]});
Snd.play('click');
}
}
/* ---------- building placement ---------- */
function canPlace(key, x, z, player){
player = player === undefined ? HUMAN : player;
const d = BUILDS[key];
const hw = d.w/2 + 1.6, hd = d.d/2 + 1.6;
const mr = Math.max(hw,hd);
if (x < hw+2 || z < hd+2 || x > WORLD-hw-2 || z > WORLD-hd-2){ invalidReason = 'Outside the frontier'; return false; }
for (let i=0;i<buildings.length;i++){
const b = buildings[i];
if (b.dead) continue;
if (dist2(x,z,b.x,b.z) < (b.rad + mr*0.82)*(b.rad + mr*0.82)){ invalidReason = 'Overlaps a structure'; return false; }
}
for (let i=0;i<rocks.length;i++){
const r = rocks[i];
if (dist2(x,z,r.x,r.z) < (r.r + mr*0.62)*(r.r + mr*0.62)){ invalidReason = 'Blocked by terrain'; return false; }
}
for (let i=0;i<nodes.length;i++){
const n = nodes[i];
if (n.amount<=0) continue;
if (dist2(x,z,n.x,n.z) < (n.r + mr*0.6)*(n.r + mr*0.6)){ invalidReason = 'Cannot cover a deposit'; return false; }
}
let near = false;
for (let i=0;i<buildings.length;i++){
const b = buildings[i];
if (b.dead || b.player !== player) continue;
if (dist2(x,z,b.x,b.z) < 48*48){ near = true; break; }
}
if (!near){ invalidReason = 'Must build within 48 units of a friendly structure'; return false; }
if (S.min[player] < d.cost){ invalidReason = 'Not enough Aetherium'; return false; }
invalidReason = '';
return true;
}
function placeBuilding(key, x, z){
if (!canPlace(key, x, z)){ Snd.play('error'); toast(invalidReason, 'bad'); return false; }
const d = BUILDS[key];
S.min[HUMAN] -= d.cost;
const b = spawnBuilding(key, HUMAN, x, z, false);
const workers = sel.filter(e=>e.kind==='unit' && e.key==='worker').slice(0,3);
if (!workers.length){
for (const u of units) if (u.player===HUMAN && u.key==='worker' && !u.order){ workers.push(u); if (workers.length>=3) break; }
}
for (const w of workers){ clearOrders(w); w.orders.push({type:'build', ref:b}); w.order = w.orders[0]; }
Snd.play('build');
toast('Constructing ' + d.name + '.', 'good');
placing = null;
return true;
}
function enqueueUnit(b, key){
const d = UNITS[key];
if (!b.built) return false;
if (b.queue.length >= 9){ Snd.play('error'); toast('Production queue is full.', 'warn'); return false; }
if (S.min[HUMAN] < d.cost){ Snd.play('error'); toast('Not enough Aetherium for ' + d.name + '.', 'warn'); return false; }
recomputeSupply();
if (supplyFree(HUMAN) < d.supply){ Snd.play('error'); toast('Not enough supply — build a Supply Relay.', 'warn'); return false; }
S.min[HUMAN] -= d.cost;
b.queue.push({key, t:0, time:d.time});
recomputeSupply();
Snd.play('click');
return true;
}
function startResearch(b, key){
const d = UPGRADES[key];
if (!b.built || b.research) return false;
if (S.upg[HUMAN][key] >= d.max){ Snd.play('error'); toast(d.name + ' is already at maximum level.', 'warn'); return false; }
if (S.min[HUMAN] < d.cost){ Snd.play('error'); toast('Not enough Aetherium for ' + d.name + '.', 'warn'); return false; }
S.min[HUMAN] -= d.cost;
b.research = {key, t:0, time:d.time};
Snd.play('click');
return true;
}
function cancelQueueItem(b, idx){
const q = b.queue[idx];
if (!q) return;
S.min[HUMAN] += UNITS[q.key].cost;
b.queue.splice(idx,1);
recomputeSupply();
Snd.play('click');
toast('Production cancelled — full refund.', 'warn');
}
/* ================================================================
HUD
================================================================ */
let selDirty = true;
let hudTimer = 0;
const el = {
minerals: document.getElementById('minerals'),
supply: document.getElementById('supply'),
army: document.getElementById('armysize'),
clock: document.getElementById('clock'),
selTitle: document.getElementById('selTitle'),
selBody: document.getElementById('selBody'),
selCount: document.getElementById('selCount'),
cmdGrid: document.getElementById('cmdGrid'),
queue: document.getElementById('queue'),
cmdHint: document.getElementById('cmdHint')
};
function fmtTime(t){
const m = Math.floor(t/60), s = Math.floor(t%60);
return m + ':' + (s<10?'0':'') + s;
}
function unitIcon(key){
return { worker:'◆', ravager:'⚔', marksman:'✦', widow:'✧', juggernaut:'▰', talon:'▲' }[key] || '■';
}
function buildIcon(key){
return { hq:'⌂', supply:'⚡', foundry:'▣', starport:'✈', lab:'⚗', turret:'♜', refinery:'⛁' }[key] || '▣';
}
function renderSelectionPanel(){
const list = sel.filter(e=>!e.dead);
if (!list.length){
el.selTitle.textContent = 'No units selected';
el.selBody.textContent = 'Drag a box over your forces, or click a unit. Right-click to issue contextual orders.';
el.selCount.textContent = '';
return;
}
el.selCount.textContent = list.length > 1 ? list.length + ' SELECTED' : '';
if (list.length === 1){
const e = list[0];
const d = e.def;
el.selTitle.textContent = d.name + (e.kind==='building' && !e.built ? ' (under construction)' : '');
let html = '<div class="bar hp"><i style="width:' + clamp(e.hp/e.maxHp*100,0,100) + '%"></i></div>';
html += '<b>Hull</b> ' + Math.ceil(e.hp) + ' / ' + e.maxHp + '<br>';
html += '<b>Role</b> ' + (d.role || '') + '<br>';
if (e.kind === 'unit'){
html += '<b>Damage</b> ' + d.dmg + ' ' + DMGNAME[d.dmgType] + ' · <b>Range</b> ' + d.range + '<br>';
html += '<b>Speed</b> ' + d.speed + ' · <b>Sight</b> ' + d.sight + ' · <b>Armour</b> ' + d.armor + '<br>';
html += '<b>Supply</b> ' + d.supply + ' · <b>Targets</b> ' + (d.targets==='both'?'ground + air':d.targets);
if (e.key === 'worker') html += '<br><b>Cargo</b> ' + e.carry + ' Aetherium';
} else {
html += '<b>Structure</b> · <b>Sight</b> ' + d.sight + '<br>';
if (d.supply) html += '<b>Supply provided</b> ' + d.supply + '<br>';
if (d.drop) html += 'Accepts Aetherium deliveries<br>';
if (d.dmg) html += '<b>Defence</b> ' + d.dmg + ' ' + DMGNAME[d.dmgType] + ' · range ' + d.range + '<br>';
if (e.rally) html += '<b>Rally</b> set';
if (e.research) html += '<b>Researching</b> ' + UPGRADES[e.research.key].name + ' — ' + Math.round(e.research.t/e.research.time*100) + '%';
}
el.selBody.innerHTML = html;
return;
}
const counts = {};
let hp = 0, maxHp = 0;
for (const e of list){ counts[e.key] = (counts[e.key]||0)+1; hp += e.hp; maxHp += e.maxHp; }
el.selTitle.textContent = list.length + ' units selected';
let html = '<div class="bar hp"><i style="width:' + clamp(hp/maxHp*100,0,100) + '%"></i></div>';
html += '<b>Combined hull</b> ' + Math.ceil(hp) + ' / ' + maxHp + '<br>';
let supply = 0;
for (const e of list) supply += e.def.supply || 0;
html += '<b>Supply</b> ' + supply + '<div class="iconrow">';
for (const k in counts){
const def = UNITS[k] || BUILDS[k];
html += '<div class="picon">' + (UNITS[k] ? unitIcon(k) : buildIcon(k)) + '<span>' + counts[k] + '</span></div>';
}
html += '</div>';
el.selBody.innerHTML = html;
}
function cmdButton(label, sub, hk, act, disabled, active){
return '<button class="cmd' + (disabled?' disabled':'') + (active?' active':'') + '" data-act="' + act + '">' +
(hk ? '<span class="hk">' + hk + '</span>' : '') +
'<span class="nm">' + label + '</span><span class="sub">' + (sub||'') + '</span></button>';
}
function renderCommands(){
const list = sel.filter(e=>!e.dead);
let html = '';
const workers = list.filter(e=>e.kind==='unit' && e.key==='worker');
const prod = list.filter(e=>e.kind==='building' && e.def.trains && e.built);
const labs = list.filter(e=>e.kind==='building' && e.def.research && e.built);
const single = list.length===1 ? list[0] : null;
const hasUnits = list.some(e=>e.kind==='unit');
if (workers.length){
for (const k in BUILDS){
const d = BUILDS[k];
const poor = S.min[HUMAN] < d.cost;
html += cmdButton(buildIcon(k) + ' ' + d.name, d.cost + ' · ' + d.time + 's', d.hotkey, 'build:'+k, poor);
}
}
if (prod.length){
const p = prod[0];
for (const k of p.def.trains){
const d = UNITS[k];
const poor = S.min[HUMAN] < d.cost || supplyFree(HUMAN) < d.supply;
html += cmdButton(unitIcon(k) + ' ' + d.name, d.cost + ' · ' + d.supply + ' supply · ' + d.time + 's', d.hotkey, 'train:'+k, poor);
}
}
if (labs.length){
const p = labs[0];
for (const k in UPGRADES){
const d = UPGRADES[k];
const lvl = S.upg[HUMAN][k];
const maxed = lvl >= d.max;
const poor = S.min[HUMAN] < d.cost;
html += cmdButton('▲ ' + d.name, maxed ? 'MAX LEVEL' : ('Lv ' + lvl + ' → ' + (lvl+1) + ' · ' + d.cost), d.hotkey,
'research:'+k, maxed || poor || !!p.research);
}
}
if (hasUnits){
html += cmdButton('◎ Attack-Move', 'Move and engage', 'A', 'cmd:am', false, amMode);
html += cmdButton('■ Stop', 'Hold fire position', 'S', 'cmd:stop');
html += cmdButton('⛨ Hold Position', 'Fire without chasing', 'H', 'cmd:hold', false, list.some(e=>e.hold));
html += cmdButton('◈ Return Cargo', 'Deliver resources', '', 'cmd:return');
}
if (single && single.kind === 'building' && single.player === HUMAN){
html += cmdButton('⚑ Rally Point', 'Right-click on the map', '', 'cmd:rally');
}
el.cmdGrid.innerHTML = html;
}
function renderQueue(){
const list = sel.filter(e=>e.kind==='building' && e.def.trains);
let html = '';
for (const b of list){
if (b.research){
html += '<div class="qitem"><i style="height:' + (b.research.t/b.research.time*100) + '%"></i><span>' +
UPGRADES[b.research.key].name.slice(0,8) + '</span></div>';
}
for (let i=0;i<b.queue.length;i++){
const q = b.queue[i];
const pct = q.t/q.time*100;
html += '<div class="qitem" data-cancel="' + i + '" title="Click to cancel (full refund)"><i style="height:' + pct + '%"></i><span>' +
UNITS[q.key].name.slice(0,8) + '</span></div>';
}
if (supplyBlocked(b.player)){
html += '<div class="qitem" style="border-color:#ff5d7a"><span>SUPPLY BLOCKED</span></div>';
}
}
if (!html) html = '<div style="font-size:11px;color:#6f93ad">Select a War Foundry, Aerodrome, Headquarters or Tech Nexus to produce.</div>';
el.queue.innerHTML = html;
}
function updateHUD(){
el.minerals.textContent = Math.floor(S.min[HUMAN]);
const used = S.supUsed[HUMAN], res = S.supRes[HUMAN], cap = S.supCap[HUMAN];
el.supply.textContent = (used + res) + (res ? ' (+' + res + ')' : '') + ' / ' + cap;
el.supply.className = 'val' + (supplyBlocked(HUMAN) ? ' bad' : (supplyFree(HUMAN) < 4 ? ' warn' : ''));
let army = 0;
for (let i=0;i<units.length;i++) if (units[i].player===HUMAN && UNITS[units[i].key].dmg) army++;
el.army.textContent = army;
el.clock.textContent = fmtTime(S.t);
if (selDirty){ renderSelectionPanel(); renderCommands(); selDirty = false; }
renderQueue();
if (amMode) el.cmdHint.textContent = 'ATTACK-MOVE: click a destination';
else if (placing) el.cmdHint.textContent = 'PLACING: ' + BUILDS[placing].name + ' — click to build, right-click to cancel';
else el.cmdHint.textContent = 'Left-click selects · Right-click orders';
}
/* ================================================================
EVENT WIRING
================================================================ */
function screenPos(e){
const r = view.getBoundingClientRect();
return { x:e.clientX - r.left, y:e.clientY - r.top };
}
view.addEventListener('mousedown', (e)=>{
if (S.over) return;
Snd.init();
const p = screenPos(e);
mouse.x = p.x; mouse.y = p.y;
if (e.button === 1){ mouse.mid = true; mouse.midX = p.x; mouse.midY = p.y; e.preventDefault(); return; }
if (e.button === 2) return;
mouse.down = true;
dragSel.x0 = p.x; dragSel.y0 = p.y; dragSel.x1 = p.x; dragSel.y1 = p.y;
dragSel.moved = false; dragSel.active = false;
});
view.addEventListener('mouseenter', ()=>{ mouse.over = true; });
view.addEventListener('mouseleave', ()=>{ mouse.over = false; mouse.down = false; mouse.mid = false; });
view.addEventListener('mousemove', (e)=>{
const p = screenPos(e);
mouse.over = true;
if (mouse.mid){
const dx = p.x - mouse.midX, dy = p.y - mouse.midY;
cam.yaw -= dx*0.006;
cam.x -= Math.sin(cam.yaw)*dy*0.45;
cam.z -= Math.cos(cam.yaw)*dy*0.45;
mouse.midX = p.x; mouse.midY = p.y;
return;
}
mouse.x = p.x; mouse.y = p.y;
if (mouse.down){
dragSel.x1 = p.x; dragSel.y1 = p.y;
if (Math.abs(dragSel.x1-dragSel.x0) + Math.abs(dragSel.y1-dragSel.y0) > 7) dragSel.moved = true;
dragSel.active = dragSel.moved;
}
});
view.addEventListener('mouseup', (e)=>{
if (e.button === 1){ mouse.mid = false; return; }
if (e.button !== 0) return;
mouse.down = false;
dragSel.active = false;
const p = screenPos(e);
mouse.x = p.x; mouse.y = p.y;
screenToGround(p.x, p.y, mouse.world);
if (placing){
if (!placeBuilding(placing, mouse.world.x, mouse.world.z)) placing = placing;
return;
}
if (amMode){
const target = pickEntity(p.x, p.y);
if (target && target.player !== HUMAN) cmdAttackTarget(target, e.shiftKey);
else cmdAttackMove(mouse.world.x, mouse.world.z, e.shiftKey);
amMode = false; selDirty = true;
return;
}
if (gatherMode){
const node = pickNode(p.x, p.y);
if (node) cmdGather(node, e.shiftKey);
gatherMode = false; selDirty = true;
return;
}
if (dragSel.moved){
selectRect(dragSel.x0, dragSel.y0, dragSel.x1, dragSel.y1, e.shiftKey);
Snd.play('select');
return;
}
const picked = pickEntity(p.x, p.y);
if (picked && picked.player === HUMAN){
const now = performance.now();
if (now - lastClick.t < 320 && lastClick.id === picked.id && picked.kind === 'unit'){
selectSameTypeOnScreen();
} else {
if (e.shiftKey) toggleSelect(picked);
else setSelection([picked]);
}
lastClick.t = now; lastClick.id = picked.id;
Snd.play('select');
} else if (!e.shiftKey){
clearSelection();
}
});
view.addEventListener('contextmenu', (e)=>{
e.preventDefault();
if (S.over || S.paused) return;
const p = screenPos(e);
screenToGround(p.x, p.y, mouse.world);
if (placing){ placing = null; selDirty = true; return; }
if (amMode || gatherMode){ amMode = false; gatherMode = false; selDirty = true; return; }
const picked = pickEntity(p.x, p.y);
const node = pickNode(p.x, p.y);
const queued = e.shiftKey;
/* rally for buildings when only buildings selected */
const onlyBuildings = sel.length && sel.every(x=>x.kind==='building');
const onlyWorkers = sel.length && sel.some(x=>x.kind==='unit' && x.key==='worker') && sel.every(x=>x.kind==='unit' && x.key==='worker');
if (onlyBuildings && sel.some(x=>x.def.trains)){
setRally(mouse.world.x, mouse.world.z, node);
return;
}
if (picked && picked.player !== HUMAN){
cmdAttackTarget(picked, queued);
return;
}
if (picked && picked.player === HUMAN && picked.kind === 'building' && picked.def.drop && sel.some(x=>x.key==='worker')){
cmdReturn(queued);
return;
}
if (node && sel.some(x=>x.key==='worker')){
cmdGather(node, queued);
return;
}
cmdMove(mouse.world.x, mouse.world.z, queued);
});
view.addEventListener('wheel', (e)=>{
e.preventDefault();
const f = Math.exp(e.deltaY*0.0011);
cam.dist = clamp(cam.dist*f, MIN_ZOOM, MAX_ZOOM);
}, {passive:false});
window.addEventListener('keydown', (e)=>{
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return;
keys[e.key.toLowerCase()] = true;
const k = e.key.toLowerCase();
if (k === 'escape'){
if (!document.getElementById('help').classList.contains('hidden')){ closeHelp(); return; }
if (placing || amMode || gatherMode){ placing = null; amMode = false; gatherMode = false; selDirty = true; return; }
togglePause();
return;
}
if (k === 'f1'){ e.preventDefault(); toggleHelp(); return; }
if (!S.running || S.paused || S.over) return;
if (k === 'a'){ amMode = true; gatherMode = false; selDirty = true; Snd.play('click'); return; }
if (k === 'g'){ gatherMode = true; amMode = false; selDirty = true; Snd.play('click'); return; }
if (k === 's' || k === 'x'){ cmdStop(); selDirty = true; return; }
if (k === 'h'){ cmdHold(); selDirty = true; return; }
if (k >= '0' && k <= '9'){
const idx = +k;
if (e.ctrlKey || e.metaKey){
e.preventDefault();
groups[idx] = sel.filter(x=>x.kind==='unit').map(x=>x.id);
groupAnchor[idx] = groups[idx].length ? 1 : 0;
toast('Control group ' + idx + ' set (' + groups[idx].length + ' units).', 'good');
Snd.play('click');
} else {
const list = groups[idx].map(id=>units.find(u=>u.id===id)).filter(u=>u && !u.dead);
if (list.length){
setSelection(list);
Snd.play('select');
if (groupAnchor[idx] === 2){
let cx=0, cz=0;
for (const u of list){ cx += u.x; cz += u.z; }
cam.x = cx/list.length; cam.z = cz/list.length;
groupAnchor[idx] = 1;
} else groupAnchor[idx] = 2;
}
}
return;
}
/* command card hotkeys */
const list = sel.filter(e2=>!e2.dead);
const workers = list.some(e2=>e2.kind==='unit' && e2.key==='worker');
const prod = list.find(e2=>e2.kind==='building' && e2.def.trains && e2.built);
const lab = list.find(e2=>e2.kind==='building' && e2.def.research && e2.built);
if (workers){
for (const key in BUILDS){
if (BUILDS[key].hotkey.toLowerCase() === k){ placing = key; amMode = false; selDirty = true; Snd.play('click'); return; }
}
}
if (prod){
for (const key of prod.def.trains){
if (UNITS[key].hotkey.toLowerCase() === k){ enqueueUnit(prod, key); renderCommands(); return; }
}
}
if (lab){
for (const key in UPGRADES){
if (UPGRADES[key].hotkey.toLowerCase() === k){ startResearch(lab, key); renderCommands(); return; }
}
}
});
window.addEventListener('keyup', (e)=>{ keys[e.key.toLowerCase()] = false; });
el.cmdGrid.addEventListener('click', (e)=>{
const btn = e.target.closest('.cmd');
if (!btn || btn.classList.contains('disabled')) return;
const act = btn.dataset.act;
Snd.init();
if (act.startsWith('build:')){ placing = act.slice(6); amMode = false; selDirty = true; Snd.play('click'); }
else if (act.startsWith('train:')){
const prod = sel.find(x=>x.kind==='building' && x.def.trains && x.built);
if (prod){ enqueueUnit(prod, act.slice(6)); renderCommands(); }
}
else if (act.startsWith('research:')){
const lab = sel.find(x=>x.kind==='building' && x.def.research && x.built);
if (lab){ startResearch(lab, act.slice(9)); renderCommands(); }
}
else if (act === 'cmd:am'){ amMode = true; placing = null; selDirty = true; Snd.play('click'); }
else if (act === 'cmd:stop'){ cmdStop(); selDirty = true; }
else if (act === 'cmd:hold'){ cmdHold(); selDirty = true; }
else if (act === 'cmd:return'){ cmdReturn(false); Snd.play('move'); }
else if (act === 'cmd:rally'){ toast('Right-click on the map to set the rally point.', 'good'); }
});
el.queue.addEventListener('click', (e)=>{
const item = e.target.closest('.qitem');
if (!item || !item.dataset.cancel) return;
const b = sel.find(x=>x.kind==='building' && x.def.trains);
if (b) cancelQueueItem(b, +item.dataset.cancel);
});
/* minimap */
function mmToGround(e){
const r = mm.getBoundingClientRect();
const x = (e.clientX - r.left)/r.width*WORLD;
const z = (e.clientY - r.top)/r.height*WORLD;
return {x:clamp(x,0,WORLD), z:clamp(z,0,WORLD)};
}
mm.addEventListener('mousedown', (e)=>{
if (S.over) return;
Snd.init();
const g = mmToGround(e);
if (e.button === 2){
e.preventDefault();
if (sel.some(x=>x.kind==='unit')) cmdAttackMove(g.x, g.z, e.shiftKey);
return;
}
cam.x = g.x; cam.z = g.z;
});
mm.addEventListener('contextmenu', (e)=>e.preventDefault());
mm.addEventListener('mousemove', (e)=>{
if (!(e.buttons & 1)) return;
const g = mmToGround(e);
cam.x = g.x; cam.z = g.z;
});
/* ================================================================
AI OPPONENT — same rules, own fog of war
================================================================ */
function aiBuildings(key){
const out = [];
for (const b of buildings) if (b.player===ENEMY && !b.dead && (!key || b.key===key)) out.push(b);
return out;
}
function aiCount(key){
let n = 0;
for (const b of buildings) if (b.player===ENEMY && !b.dead && b.key===key) n++;
return n;
}
function aiPending(key){
for (const b of buildings) if (b.player===ENEMY && !b.dead && b.key===key && !b.built) return true;
return false;
}
function aiUnitCount(key){
let n = 0;
for (const u of units) if (u.player===ENEMY && !u.dead && u.key===key) n++;
return n;
}
function aiPlace(key, nearX, nearZ, minR, maxR){
for (let i=0;i<28;i++){
const a = rnd()*TAU, r = rr(minR,maxR);
const x = clamp(nearX + Math.cos(a)*r, 8, WORLD-8), z = clamp(nearZ + Math.sin(a)*r, 8, WORLD-8);
if (canPlace(key, x, z, ENEMY)) return {x, z};
}
return null;
}
function aiNearestWorker(x, z, requireIdle){
let best = null, bestD = 1e18;
for (const u of units){
if (u.player!==ENEMY || u.dead || u.key!=='worker') continue;
if (requireIdle && u.order) continue;
const d = dist2(u.x,u.z,x,z);
if (d < bestD){ bestD = d; best = u; }
}
return best;
}
function aiStartBuild(key, x, z){
if (S.min[ENEMY] < BUILDS[key].cost) return false;
const w = aiNearestWorker(x, z, true) || aiNearestWorker(x, z, false);
if (!w) return false;
S.min[ENEMY] -= BUILDS[key].cost;
const b = spawnBuilding(key, ENEMY, x, z, false);
clearOrders(w); w.orders.push({type:'build', ref:b}); w.order = w.orders[0];
return true;
}
function aiTryBuild(key, nearX, nearZ, minR, maxR){
if (S.min[ENEMY] < BUILDS[key].cost + 40) return false;
const spot = aiPlace(key, nearX, nearZ, minR, maxR);
return spot ? aiStartBuild(key, spot.x, spot.z) : false;
}
function aiBaseCenter(){
const hqs = aiBuildings('hq');
if (hqs.length){ let x=0,z=0; for (const b of hqs){ x+=b.x; z+=b.z; } return {x:x/hqs.length, z:z/hqs.length}; }
const bs = aiBuildings();
if (bs.length){ return {x:bs[0].x, z:bs[0].z}; }
return {x:184, z:56};
}
function aiTrainAt(buildingKey, unitKey){
const list = aiBuildings(buildingKey);
for (const b of list){
if (!b.built || b.queue.length >= 3) continue;
const d = UNITS[unitKey];
if (S.min[ENEMY] < d.cost) return false;
recomputeSupply();
if (supplyFree(ENEMY) < d.supply) return false;
S.min[ENEMY] -= d.cost;
b.queue.push({key:unitKey, t:0, time:d.time});
recomputeSupply();
return true;
}
return false;
}
function aiAssignWorkers(){
for (const u of units){
if (u.player!==ENEMY || u.dead || u.key!=='worker') continue;
if (u.order) continue;
const drop = nearestDropoff(ENEMY, u.x, u.z);
let node = null, bestD = 1e18;
for (const n of nodes){
if (n.amount<=0) continue;
const d = dist2(n.x,n.z, drop?drop.x:u.x, drop?drop.z:u.z);
if (d < bestD){ bestD = d; node = n; }
}
if (node){ u.node = node; u.orders.push({type:'gather', node}); u.order = u.orders[0]; }
}
}
function aiTrainArmy(){
const t = S.t;
const cap = 1 + Math.floor(t/210);
for (let i=0;i<cap;i++){
const r = rnd();
let key;
if (aiUnitCount('marksman') + aiUnitCount('ravager') < 3) key = r < 0.5 ? 'ravager' : 'marksman';
else if (t < 150) key = r < 0.62 ? 'ravager' : 'marksman';
else if (t < 330) key = r < 0.36 ? 'ravager' : (r < 0.74 ? 'marksman' : 'widow');
else key = r < 0.26 ? 'ravager' : (r < 0.56 ? 'marksman' : (r < 0.74 ? 'widow' : 'juggernaut'));
aiTrainAt('foundry', key);
if (t > 260 && rnd() < 0.34) aiTrainAt('starport', 'talon');
if (aiUnitCount('worker') < 15 && S.min[ENEMY] > 160) aiTrainAt('hq', 'worker');
}
}
function aiResearch(){
for (const b of aiBuildings('lab')){
if (!b.built || b.research) continue;
const order = ['weapons','armor','thrusters','harvest','weapons','armor'];
for (const k of order){
if (S.upg[ENEMY][k] >= UPGRADES[k].max) continue;
if (S.min[ENEMY] < UPGRADES[k].cost + 120) break;
S.min[ENEMY] -= UPGRADES[k].cost;
b.research = {key:k, t:0, time:UPGRADES[k].time};
break;
}
}
}
function aiArmy(){
const p = ENEMY;
const idle = [];
for (const u of units){
if (u.player!==p || u.dead) continue;
if (!UNITS[u.key].dmg) continue;
if (!u.order) idle.push(u);
}
/* defend threatened assets */
if (S.ai.threatT && S.t - S.ai.threatT < 14 && idle.length >= 2 && S.ai.threatPos){
for (const u of idle){
u.orders.push({type:'attackMove', x:S.ai.threatPos.x + rr(-6,6), z:S.ai.threatPos.z + rr(-6,6)});
u.order = u.orders[0];
}
return;
}
if (idle.length >= S.ai.waveSize && S.t >= S.ai.attackAt){
let tx = null, tz = null, bestD = 1e18;
const base = aiBaseCenter();
for (const [id,g] of knowledge[p]){
const d = dist2(base.x, base.z, g.x, g.z);
if (d < bestD){ bestD = d; tx = g.x; tz = g.z; }
}
if (tx === null){
/* nothing known: probe the map — after a while, check the rival's suspected landing zone */
if (S.t > 150){ tx = 56 + rr(-24,24); tz = 184 + rr(-24,24); }
else {
const cand = nodes.filter(n=>n.amount>0 && seenTo(1,n.x,n.z));
if (cand.length){
const n = cand[Math.floor(rnd()*cand.length)];
tx = n.x + rr(-16,16); tz = n.z + rr(-16,16);
} else { tx = WORLD*0.35 + rr(-25,25); tz = WORLD*0.35 + rr(-25,25); }
}
}
for (const u of idle){
u.orders.push({type:'attackMove', x:clamp(tx+rr(-9,9),4,WORLD-4), z:clamp(tz+rr(-9,9),4,WORLD-4)});
u.order = u.orders[0];
}
S.ai.attackAt = S.t + 40 + rnd()*25;
S.ai.waveSize = Math.min(34, S.ai.waveSize + 2 + Math.floor(S.t/260));
}
}
function aiScout(){
if (S.t < S.ai.nextScout) return;
S.ai.nextScout = S.t + 26 + rnd()*22;
const cand = nodes.filter(n=>n.amount>0 && !seenTo(1,n.x,n.z));
let tx, tz;
if (knowledge[ENEMY].size === 0 && S.t > 100){
/* the rival's landing zone is suspected — send eyes to confirm */
tx = 56 + rr(-32,32); tz = 184 + rr(-32,32);
} else if (cand.length){ const n = cand[Math.floor(rnd()*cand.length)]; tx = n.x; tz = n.z; }
else { tx = rr(20,WORLD-20); tz = rr(20,WORLD-20); }
const scout = aiNearestWorker(1e9, 1e9, true);
const fighter = units.find(u=>u.player===ENEMY && !u.dead && UNITS[u.key].dmg && !u.order);
const s = fighter || scout;
if (s){
clearOrders(s);
s.orders.push({type:'move', x:tx, z:tz});
s.order = s.orders[0];
}
}
function aiTick(){
aiAssignWorkers();
const base = aiBaseCenter();
const min = S.min[ENEMY];
const workers = aiUnitCount('worker');
if (supplyFree(ENEMY) < 8 && min > 130 && !aiPending('supply')){
aiTryBuild('supply', base.x, base.z, 12, 26);
}
if (aiCount('foundry') === 0 && min > 220 && !aiPending('foundry')){
aiTryBuild('foundry', base.x, base.z, 14, 26);
}
if (workers < 15 && min > 170){
aiTrainAt('hq','worker');
aiTrainAt('hq','worker');
}
if (S.t > 220 && aiCount('starport') === 0 && min > 520 && !aiPending('starport')){
aiTryBuild('starport', base.x, base.z, 16, 30);
}
if (S.t > 180 && aiCount('lab') === 0 && min > 360 && !aiPending('lab')){
aiTryBuild('lab', base.x, base.z, 15, 28);
}
if (workers > 8 && aiCount('refinery') < 2 && min > 400 && !aiPending('refinery')){
const n = nodes.filter(n2=>n2.amount>600 && seenTo(1,n2.x,n2.z) &&
dist2(n2.x,n2.z,base.x,base.z) > 22*22)[0];
if (n) aiTryBuild('refinery', n.x, n.z, 12, 19);
}
if (min > 620 && aiCount('turret') < 2 + Math.floor(S.t/170) && !aiPending('turret')){
/* face the player's side of the map */
const ang = Math.atan2(WORLD*0.3 - base.x, WORLD*0.3 - base.z);
const tx = base.x + Math.sin(ang)*rr(18,30), tz = base.z + Math.cos(ang)*rr(18,30);
if (!aiTryBuild('turret', tx, tz, 2, 12)) aiTryBuild('turret', base.x, base.z, 16, 30);
}
if (min > 1150 && aiCount('hq') < 3 && S.t > 300 && !aiPending('hq')){
const n = nodes.filter(n2=>n2.amount>800 && seenTo(1,n2.x,n2.z) &&
dist2(n2.x,n2.z,base.x,base.z) > 46*46)[0];
if (n) aiTryBuild('hq', n.x, n.z, 20, 30);
}
if (min > 460) aiResearch();
aiTrainArmy();
aiArmy();
aiScout();
}
/* ================================================================
GAME FLOW
================================================================ */
function resetGame(){
units.length = 0; buildings.length = 0; projs.length = 0; fxList.length = 0; beacons.length = 0;
knowledge[0].clear(); knowledge[1].clear();
fog[0].vis.fill(0); fog[0].exp.fill(0); fog[1].vis.fill(0); fog[1].exp.fill(0);
nextId = 1;
S.t = 0;
S.min = [800, 800];
S.kills = [0,0]; S.lost = [0,0]; S.gathered = [0,0];
S.upg = [{weapons:0,armor:0,thrusters:0,harvest:0},{weapons:0,armor:0,thrusters:0,harvest:0}];
S.ai = { nextBuild:0, nextArmy:0, nextScout:55, attackAt:200, waveSize:4, threatT:0, threatPos:null };
S.over = false; S.paused = false;
baseAlertT = 0;
_seed = 20240617;
genWorld();
spawnBuilding('hq', HUMAN, 56, 184, true);
for (let i=0;i<4;i++) spawnUnit('worker', HUMAN, 52 + (i%2)*7, 172 + Math.floor(i/2)*8);
spawnBuilding('hq', ENEMY, 184, 56, true);
for (let i=0;i<4;i++) spawnUnit('worker', ENEMY, 188 - (i%2)*7, 68 - Math.floor(i/2)*8);
cam.x = 58; cam.z = 176; cam.dist = 130; cam.yaw = Math.atan2(120-56, 120-184);
clearSelection();
recomputeSupply();
computeVision(); updateKnowledge();
/* each side has surveyed its starting deposits */
for (const n of nodes){
if (dist(n.x,n.z,56,184) < 34) stampExplored(fog[0], n.x, n.z, 9);
if (dist(n.x,n.z,184,56) < 34) stampExplored(fog[1], n.x, n.z, 9);
}
for (const g of groups) g.length = 0;
selDirty = true;
}
function startGame(){
document.getElementById('title').classList.add('hidden');
document.getElementById('end').classList.add('hidden');
document.getElementById('pause').classList.add('hidden');
resetGame();
S.running = true; S.paused = false;
Snd.init(); Snd.play('complete');
toast('Frontier landing complete. Build, scout, and destroy the enemy Headquarters.', 'good');
setTimeout(()=>toast('Tip: Prospectors gather Aetherium automatically when ordered onto a deposit.', 'good'), 6000);
}
function togglePause(){
if (S.over || !S.running) return;
S.paused = !S.paused;
document.getElementById('pause').classList.toggle('hidden', !S.paused);
Snd.play('click');
}
function toggleHelp(){
const h = document.getElementById('help');
const show = h.classList.contains('hidden');
h.classList.toggle('hidden', !show);
Snd.play('click');
}
function closeHelp(){ document.getElementById('help').classList.add('hidden'); }
function endGame(win){
if (S.over) return;
S.over = true; S.running = false;
const box = document.getElementById('end');
document.getElementById('endTitle').textContent = win ? 'VICTORY' : 'DEFEAT';
document.getElementById('endTitle').className = 'endtitle ' + (win ? 'won' : 'lost');
document.getElementById('endSub').textContent = win
? 'THE KHAR DOMINION HAS BEEN DRIVEN FROM THE FRONTIER'
: 'THE EXPEDITION HAS LOST ITS HEADQUARTERS';
let army = 0;
for (const u of units) if (u.player===HUMAN && UNITS[u.key].dmg) army++;
document.getElementById('endStats').innerHTML = [
['MISSION TIME', fmtTime(S.t)],
['AETHERIUM MINED', Math.floor(S.gathered[HUMAN])],
['ENEMIES DESTROYED', S.kills[HUMAN]],
['UNITS LOST', S.lost[HUMAN]],
['FORCES SURVIVING', army],
['SUPPLY CAPACITY', S.supCap[HUMAN]]
].map(s=>'<div class="sbox"><div class="k">'+s[0]+'</div><div class="v">'+s[1]+'</div></div>').join('');
box.classList.remove('hidden');
Snd.play(win ? 'victory' : 'defeat');
}
/* buttons */
document.getElementById('btnStart').addEventListener('click', startGame);
document.getElementById('btnRestart').addEventListener('click', ()=>{ startGame(); });
document.getElementById('btnRestart2').addEventListener('click', ()=>{ startGame(); });
document.getElementById('btnResume').addEventListener('click', togglePause);
document.getElementById('btnPause').addEventListener('click', togglePause);
document.getElementById('btnHelp').addEventListener('click', toggleHelp);
document.getElementById('btnHelp2').addEventListener('click', toggleHelp);
document.getElementById('btnCloseHelp').addEventListener('click', closeHelp);
document.getElementById('btnMute').addEventListener('click', (e)=>{
Snd.init();
Snd.muted = !Snd.muted;
e.target.textContent = Snd.muted ? 'SOUND: OFF' : 'SOUND: ON';
if (!Snd.muted) Snd.play('click');
});
/* ================================================================
CAMERA INPUT + MAIN LOOP
================================================================ */
function updateCameraInput(dt){
const speed = cam.dist*0.85*dt;
let mv = 0, mh = 0;
if (keys['arrowup']) mv += 1;
if (keys['arrowdown']) mv -= 1;
if (keys['arrowleft']) mh -= 1;
if (keys['arrowright']) mh += 1;
if (mouse.over && S.running && !S.paused && !S.over){
if (mouse.x < 10) mh -= 1;
if (mouse.x > CW-10) mh += 1;
if (mouse.y < 10) mv += 1;
if (mouse.y > CH-10) mv -= 1;
}
if (mv || mh){
const fw = Math.sin(cam.yaw), fz = Math.cos(cam.yaw);
const rx = Math.cos(cam.yaw), rz = -Math.sin(cam.yaw);
cam.x += (fw*mv + rx*mh)*speed;
cam.z += (fz*mv + rz*mh)*speed;
}
if (keys['q']) cam.yaw -= 1.5*dt;
if (keys['e']) cam.yaw += 1.5*dt;
}
function checkVictory(){
let hqH = 0, hqE = 0;
for (const b of buildings){
if (b.dead || b.key !== 'hq') continue;
if (b.player === HUMAN) hqH++; else hqE++;
}
if (hqE === 0) endGame(true);
else if (hqH === 0) endGame(false);
}
function render(){
updateCamera();
screenToGround(mouse.x, mouse.y, mouse.world);
drawSky();
drawGroundPlane();
drawGrid();
drawGroundDetail();
drawObjects();
drawEffects();
drawFog();
drawWorldOverlay();
drawMinimap();
}
let lastTime = performance.now();
let victoryTimer = 0, aiTimer = 0, sweepTimer = 0;
function frame(now){
requestAnimationFrame(frame);
const dt = Math.min(0.06, Math.max(0.001, (now - lastTime)/1000));
lastTime = now;
if (S.running && !S.paused && !S.over){
updateCameraInput(dt);
updateWorld(dt);
aiTimer -= dt;
if (aiTimer <= 0){ aiTimer = 1.0; aiTick(); }
victoryTimer -= dt;
if (victoryTimer <= 0){ victoryTimer = 1.0; checkVictory(); }
hudTimer -= dt;
if (hudTimer <= 0){ hudTimer = 0.25; updateHUD(); }
if (units.length + buildings.length > 0 && (S.t % 30) < dt) toast('War grows: ' + units.length + ' units active on the frontier.', 'good');
} else {
hudTimer -= dt;
if (hudTimer <= 0){ hudTimer = 0.35; updateHUD(); }
}
render();
}
/* ================================================================
BOOT
================================================================ */
resize();
resetGame();
updateHUD();
requestAnimationFrame(frame);
window.FW = { S, units, buildings, nodes, cam, sel, spawnUnit, spawnBuilding, resetGame, startGame,
updateWorld, aiTick, knowledge, fog, UNITS, BUILDS, cmdMove, cmdAttackMove, endGame, aiTimer:0,
applyDamage, canPlace, placeBuilding, enqueueUnit, startResearch, project, screenToGround, checkVictory };
</script>
</body>
</html>
<!-- agent-meta {"model":"xiaomi/mimo-v2.6-pro","provider":"openrouter","persona":"full","sessionId":"1d10e891-81bb-48bd-884a-58336012d827","tokensIn":737694,"tokensOut":7669,"tokensTotal":745363,"cost":0.030222608400000007,"turns":17,"toolCalls":18,"failedToolCalls":0,"timestamp":"2026-10-01T16:02:35.968Z"} -->