RTS
Swift Qwen3.8 27B GGUF · 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 91,648 bytes · SHA-256 a5a0960d0372
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>FRONTIER WAR — Skirmish on Kestrel-9</title>
<style>
:root{
--p:#5fd4ff; --p2:#1b6f8f; --e:#ff7a4d; --e2:#8f3a1b;
--bg:#05090f; --panel:rgba(10,18,28,.88); --line:#1e3a4d; --txt:#cfe8f5; --dim:#7fa3b8;
--ok:#7dffa8; --warn:#ffd166; --bad:#ff5d5d;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);color:var(--txt);
font:14px/1.35 "Segoe UI",system-ui,sans-serif;user-select:none;-webkit-user-select:none}
#gl{position:fixed;inset:0;width:100%;height:100%;display:block;cursor:crosshair}
#boxdiv{position:fixed;z-index:8;display:none;border:1px solid #7fdcff;background:rgba(95,212,255,.12);pointer-events:none}
.hud{position:fixed;z-index:5;pointer-events:none}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:6px;
box-shadow:0 2px 14px rgba(0,0,0,.55),inset 0 0 22px rgba(95,212,255,.05);pointer-events:auto}
/* ---------- top bar ---------- */
#topbar{top:8px;left:8px;right:8px;display:flex;justify-content:space-between;align-items:flex-start}
#resbox{display:flex;gap:10px;align-items:center;padding:6px 12px;font-weight:600}
#resbox .res{display:flex;align-items:center;gap:6px;font-size:15px}
#resbox .ic{width:14px;height:14px;border-radius:3px;background:linear-gradient(135deg,#9ff3ff,#2fa8d8);box-shadow:0 0 8px #5fd4ff88}
#resbox .sup{color:var(--dim);font-size:13px}
#resbox .sup b{color:var(--txt)}
#resbox .sup.low b{color:var(--bad)}
#topmid{display:flex;gap:6px;align-items:center}
#clock{padding:6px 12px;font-size:15px;font-variant-numeric:tabular-nums;letter-spacing:1px}
.tbtn{pointer-events:auto;background:var(--panel);border:1px solid var(--line);color:var(--txt);
border-radius:6px;padding:6px 10px;font-size:13px;cursor:pointer}
.tbtn:hover{border-color:var(--p);color:#fff}
/* ---------- bottom bar ---------- */
#bottombar{left:8px;right:8px;bottom:8px;display:flex;gap:8px;align-items:flex-end}
#selpanel{width:340px;min-height:118px;padding:8px 10px;display:flex;flex-direction:column;gap:6px}
#selhead{display:flex;justify-content:space-between;align-items:center;gap:8px}
#selname{font-weight:700;font-size:14px;letter-spacing:.5px}
#selinfo{color:var(--dim);font-size:12px}
#hpwrap{height:8px;border-radius:4px;background:#0a141d;border:1px solid var(--line);overflow:hidden}
#hpbar{height:100%;width:100%;background:linear-gradient(90deg,#37e08a,#7dffa8);transition:width .15s}
#hpbar.mid{background:linear-gradient(90deg,#e0b237,#ffd166)}
#hpbar.low{background:linear-gradient(90deg,#e03737,#ff5d5d)}
#selactions{display:flex;flex-wrap:wrap;gap:5px}
.abtn{pointer-events:auto;display:flex;align-items:center;gap:7px;background:#0c1824;border:1px solid var(--line);
border-radius:5px;padding:5px 8px;cursor:pointer;color:var(--txt);font-size:12px;min-width:86px}
.abtn:hover{border-color:var(--p)}
.abtn:disabled{opacity:.42;cursor:default}
.abtn .cost{color:var(--warn);font-weight:700}
.abtn .hk{color:var(--dim);font-size:10px;border:1px solid #2a4a5e;border-radius:3px;padding:0 4px}
.abtn .nm{font-weight:600}
.abtn .why{color:var(--dim);font-size:10px}
#selstate{color:var(--dim);font-size:12px;padding:2px 0}
#selstate .warn{color:var(--bad);font-weight:700}
#selstate .ok{color:var(--ok)}
/* ---------- build menu ---------- */
#buildmenu{position:fixed;z-index:9;display:none;padding:6px;gap:5px;flex-wrap:wrap;width:236px}
#buildmenu.show{display:flex}
/* ---------- minimap ---------- */
#mmwrap{width:212px;padding:6px;display:flex;flex-direction:column;gap:4px}
#mm{width:200px;height:200px;display:block;border:1px solid var(--line);border-radius:4px;cursor:crosshair;image-rendering:pixelated}
#mmhint{color:var(--dim);font-size:10px;text-align:center}
/* ---------- toast / help ---------- */
#toasts{left:50%;top:64px;transform:translateX(-50%);display:flex;flex-direction:column;gap:6px;align-items:center}
.toast{padding:6px 16px;border-radius:6px;font-size:13px;font-weight:600;animation:tfade 4s forwards;
background:var(--panel);border:1px solid var(--line)}
.toast.bad{border-color:var(--bad);color:var(--bad)}
.toast.warn{border-color:var(--warn);color:var(--warn)}
.toast.info{border-color:var(--p);color:var(--p)}
@keyframes tfade{0%{opacity:0;transform:translateY(-8px)}8%{opacity:1;transform:none}80%{opacity:1}100%{opacity:0}}
#helpbtn{position:fixed;z-index:8;bottom:140px;left:10px}
#help{position:fixed;z-index:20;left:50%;top:50%;transform:translate(-50%,-50%);width:min(720px,92vw);max-height:86vh;
overflow:auto;padding:18px 22px;display:none}
#help.show{display:block}
#help h2{color:var(--p);font-size:18px;margin-bottom:8px;letter-spacing:1px}
#help h3{color:var(--p);font-size:14px;margin:12px 0 4px}
#help p,#help li{font-size:13px;color:var(--txt)}
#help ul{padding-left:18px}
#help .row{display:flex;justify-content:space-between;gap:10px;padding:2px 0;border-bottom:1px dashed #16303f}
#help .k{color:var(--dim);font-size:11px;background:#0c1824;border:1px solid var(--line);border-radius:3px;padding:0 5px}
#help .close{margin-top:14px;width:100%}
/* ---------- screens ---------- */
.screen{position:fixed;inset:0;z-index:30;display:flex;align-items:center;justify-content:center;
background:radial-gradient(ellipse at 50% 30%,#0a1c2c 0%,#04070c 70%)}
.card{width:min(680px,94vw);max-height:92vh;overflow:auto;text-align:center;padding:26px 34px;
background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 0 60px rgba(95,212,255,.15)}
.card h1{font-size:44px;letter-spacing:8px;color:var(--p);text-shadow:0 0 24px rgba(95,212,255,.6);margin-bottom:2px}
.card .sub{color:var(--e);letter-spacing:3px;font-size:13px;margin-bottom:16px}
.card h2{font-size:30px;letter-spacing:4px;margin-bottom:10px}
.card h2.win{color:var(--ok);text-shadow:0 0 24px rgba(125,255,168,.5)}
.card h2.lose{color:var(--bad);text-shadow:0 0 24px rgba(255,93,93,.5)}
.card p{font-size:14px;margin:6px 0;color:var(--txt)}
.card .grid{display:grid;grid-template-columns:1fr 1fr;gap:4px 22px;text-align:left;margin:14px 0;font-size:13px}
.card .grid .k{color:var(--p);font-weight:600}
.bigbtn{pointer-events:auto;margin-top:18px;background:linear-gradient(180deg,#123246,#0b1d2a);border:1px solid var(--p);
color:#dff4ff;font-size:18px;letter-spacing:3px;padding:12px 44px;border-radius:8px;cursor:pointer}
.bigbtn:hover{background:linear-gradient(180deg,#1a4a66,#102838);box-shadow:0 0 24px rgba(95,212,255,.35)}
#stats{margin:10px auto 0;font-size:13px;color:var(--dim);max-width:420px}
#stats b{color:var(--txt)}
#pauseveil{position:fixed;inset:0;z-index:15;display:none;align-items:center;justify-content:center;background:rgba(3,6,10,.55)}
#pauseveil.show{display:flex}
#pauseveil .card{width:340px}
.hidden{display:none!important}
#loaderr{position:fixed;inset:0;z-index:99;background:#04070c;color:#ff8a8a;display:none;align-items:center;justify-content:center;padding:30px;font-size:15px}
</style>
</head>
<body>
<canvas id="gl"></canvas>
<div class="hud" id="topbar">
<div class="panel" id="resbox">
<div class="res"><span class="ic"></span><span id="resval">0</span></div>
<div class="res sup" id="supval"><b>0</b> / <b>0</b> SUPPLY</div>
<div class="res sup" id="incval" style="display:none"><span style="color:var(--ok)">▲</span><b>0</b></div>
</div>
<div id="topmid">
<div class="panel" id="clock">00:00</div>
<button class="tbtn" id="btnPause" title="Pause (Space)">❚❚ Pause</button>
<button class="tbtn" id="btnMute" title="Mute">🔊</button>
<button class="tbtn" id="btnHelp" title="Help (H)">? Help</button>
</div>
</div>
<div class="hud" id="toasts"></div>
<div id="boxdiv"></div>
<div class="hud" id="bottombar">
<div class="panel" id="selpanel">
<div id="selhead"><span id="selname">No selection</span><span id="selinfo"></span></div>
<div id="hpwrap" class="hidden"><div id="hpbar"></div></div>
<div id="selstate"></div>
<div id="selactions"></div>
</div>
<div class="panel" id="mmwrap">
<canvas id="mm" width="200" height="200"></canvas>
<div id="mmhint">Minimap — click / right-click to move camera · H for help</div>
</div>
</div>
<div class="panel" id="buildmenu"></div>
<button class="tbtn" id="helpbtn">?</button>
<div class="panel" id="help">
<h2>FIELD MANUAL — KESTREL-9</h2>
<p><b>Objective:</b> destroy <b>every</b> enemy Nexus. You lose if all of your own are destroyed.
Build extra Nexuses at distant Crystal fields to expand — each one raises your supply cap and can store Crystal.</p>
<h3>Controls</h3>
<div class="row"><span>Left click / drag box — select · <span class="k">Shift</span> add</span><span></span></div>
<div class="row"><span>Right click — contextual order (move / attack / harvest)</span><span></span></div>
<div class="row"><span><span class="k">A</span> attack-move · <span class="k">S</span> stop · <span class="k">H</span> hold position</span><span></span></div>
<div class="row"><span><span class="k">1–9</span> control groups · <span class="k">Shift</span>+key set · <span class="k">Q</span> select by type</span><span></span></div>
<div class="row"><span><span class="k">WASD</span>/<span class="k">arrows</span> pan · <span class="k">Q/E</span> rotate · <span class="k">wheel</span> zoom · <span class="k">F</span> follow army</span><span></span></div>
<div class="row"><span><span class="k">Space</span> pause · <span class="k">Esc</span> deselect / close</span><span></span></div>
<h3>Economy & Supply</h3>
<ul>
<li>Workers harvest <b>Crystal</b> from fields and return it to your Nexus (or any of your buildings).</li>
<li>Supply is a <b>capacity</b>, not a cost: every unit occupies supply while alive; Nexus +15, Foundry +10, each upgrade +10. Queued units reserve supply; cancelling a queue refunds the full cost and frees the reservation.</li>
<li>Expansions near new fields let workers drop off close by — faster income, bigger supply.</li>
</ul>
<h3>Unit counters</h3>
<div class="row"><span><span class="k">Mauler</span> melee, light armor — cheap shock, weak to ranged</span><span></span></div>
<div class="row"><span><span class="k">Pulse Lance</span> ranged, light armor — melts light armor, weak to heavy</span><span></span></div>
<div class="row"><span><span class="k">Bulwark</span> heavy armor, slow — soaks laser fire, weak to pulse</span><span></span></div>
<div class="row"><span><span class="k">Specter</span> flying — crosses cliffs; only Pulse Lance & Sentry Turrets hit air</span><span></span></div>
<div class="row"><span><span class="k">Reclaimer</span> — harvests Crystal (1 supply)</span><span></span></div>
<h3>Upgrades</h3>
<ul><li><b>Ion Edge</b> +30% melee damage · <b>Coil Focus</b> +30% laser damage · <b>Composite Plate</b> +25% max HP · <b>Thruster Kit</b> +15% speed · <b>Orbital Uplink</b> — a Specter strike wipes the largest enemy cluster (once per 75s). Research at the Nexus.</li></ul>
<button class="bigbtn close" id="helpClose" style="font-size:14px;padding:9px">CLOSE</button>
</div>
<div class="screen" id="title">
<div class="card">
<h1>FRONTIER WAR</h1>
<div class="sub">SKIRMISH ON KESTREL-9 · TWO EXPEDITIONS · ONE WORLD</div>
<p>Your expedition has landed on a crystal-rich frontier world. A rival force has landed too.
Explore the dark, build an economy, expand, and raise an army — then <b>destroy every enemy Nexus</b>.</p>
<div class="grid">
<span><span class="k">Left click / drag</span> — select</span>
<span><span class="k">Right click</span> — move / attack / harvest</span>
<span><span class="k">A / S / H</span> — attack-move / stop / hold</span>
<span><span class="k">1–9</span> — control groups</span>
<span><span class="k">WASD + wheel</span> — camera · <span class="k">Q/E</span> rotate</span>
<span><span class="k">Minimap</span> — fog of war, camera jump</span>
</div>
<p style="color:var(--dim);font-size:12px">Victory: destroy all enemy Nexuses · Defeat: all of yours destroyed ·
Supply is capacity, not cost · Unexplored terrain hides the enemy — scout the map.</p>
<button class="bigbtn" id="btnStart">DEPLOY</button>
</div>
</div>
<div class="screen hidden" id="endscreen">
<div class="card">
<h2 id="endtitle">VICTORY</h2>
<p id="endsub"></p>
<div id="stats"></div>
<button class="bigbtn" id="btnRestart">REDEPLOY</button>
</div>
</div>
<div id="pauseveil"><div class="card"><h2 style="color:var(--p)">PAUSED</h2><p>Press <b>Space</b> to resume</p></div></div>
<div id="loaderr"></div>
<script>
'use strict';
/* ============================== math / utils ============================== */
const TAU=Math.PI*2;
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const lerp=(a,b,t)=>a+(b-a)*t;
const rand=(a=1,b)=>b===undefined?Math.random()*a:a+Math.random()*(b-a);
const randi=(a,b)=>Math.floor(rand(a,b+1));
const dist2=(x1,z1,x2,z2)=>{const dx=x2-x1,dz=z2-z1;return dx*dx+dz*dz};
const dist=(x1,z1,x2,z2)=>Math.sqrt(dist2(x1,z1,x2,z2));
const angTo=(x1,z1,x2,z2)=>Math.atan2(x2-x1,z2-z1);
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296}}
function hash2(x,z,s){let h=(x*374761393+z*668265263+s*1442695041)|0;h=(h^(h>>>13))*1274126177|0;return((h^(h>>>16))>>>0)/4294967296}
function vnoise(x,z,s){const xi=Math.floor(x),zi=Math.floor(z),xf=x-xi,zf=z-zi;
const u=xf*xf*(3-2*xf),v=zf*zf*(3-2*zf);
const a=hash2(xi,zi,s),b=hash2(xi+1,zi,s),c=hash2(xi,zi+1,s),d=hash2(xi+1,zi+1,s);
return a+(b-a)*u+(c-a)*v+(a-b-c+d)*u*v}
function fbm(x,z,s,o=4){let f=0,amp=.5,tot=0;for(let i=0;i<o;i++){f+=vnoise(x,z,s+i*101)*amp;tot+=amp;amp*=.5}return f/tot}
const fmtT=s=>{s=Math.max(0,Math.floor(s));return (s/60|0)+':'+String(s%60).padStart(2,'0')}
/* ============================== audio (procedural) ============================== */
const Audio2={ctx:null,muted:false,master:null,
init(){if(this.ctx)return;try{this.ctx=new (window.AudioContext||window.webkitAudioContext)();
this.master=this.ctx.createGain();this.master.gain.value=.5;this.master.connect(this.ctx.destination)}catch(e){}},
beep(f=440,d=.08,type='sine',g=.2,slide=0){if(!this.ctx||this.muted)return;
const t=this.ctx.currentTime,o=this.ctx.createOscillator(),gn=this.ctx.createGain();
o.type=type;o.frequency.setValueAtTime(f,t);if(slide)o.frequency.exponentialRampToValueAtTime(Math.max(30,f+slide),t+d);
gn.gain.setValueAtTime(g,t);gn.gain.exponentialRampToValueAtTime(.001,t+d);
o.connect(gn);gn.connect(this.master);o.start(t);o.stop(t+d+.02)},
noise(d=.15,g=.2,fq=800){if(!this.ctx||this.muted)return;
const t=this.ctx.currentTime,n=Math.floor(this.ctx.sampleRate*d),buf=this.ctx.createBuffer(1,n,this.ctx.sampleRate),ch=buf.getChannelData(0);
for(let i=0;i<n;i++)ch[i]=(Math.random()*2-1)*(1-i/n);
const s=this.ctx.createBufferSource();s.buffer=buf;const f=this.ctx.createBiquadFilter();f.type='lowpass';f.frequency.value=fq;
const gn=this.ctx.createGain();gn.gain.value=g;s.connect(f);f.connect(gn);gn.connect(this.master);s.start(t)},
click(){this.beep(880,.04,'square',.08)},
err(){this.beep(160,.12,'sawtooth',.15,-60)},
build(){this.noise(.25,.18,500);this.beep(220,.2,'triangle',.12,120)},
done(){this.beep(660,.09,'sine',.16);setTimeout(()=>this.beep(990,.12,'sine',.14),80)},
shoot(r){if(r==='laser'){this.beep(1400+rand(-200,200),.07,'sawtooth',.05,-900)}
else if(r==='kinetic'){this.noise(.06,.12,2400);this.beep(180,.06,'square',.08,-80)}
else this.noise(.12,.14,900)},
boom(s=1){this.noise(.5,.3*s,300);this.beep(60,.4,'sine',.25*s,-30)},
alarm(){this.beep(520,.14,'square',.1);setTimeout(()=>this.beep(392,.18,'square',.1),150)},
tick(){this.beep(1200,.03,'sine',.06)}
};
/* ============================== data ============================== */
const MAPW=192,MAPD=128,CELL=4;
const RES_CAP=3000;
const DMG_MOD={kinetic:{light:1,heavy:.55,fly:1},laser:{light:1.4,heavy:.7,fly:1.2}};
const RES_PER_SEC=.5;
const UPG={
ion:{name:'Ion Edge',cost:300,time:45,desc:'+30% melee damage'},
coil:{name:'Coil Focus',cost:300,time:45,desc:'+30% laser damage'},
plate:{name:'Composite Plate',cost:400,time:60,desc:'+25% unit & building HP'},
thr:{name:'Thruster Kit',cost:350,time:60,desc:'+15% movement speed'},
orbit:{name:'Orbital Uplink',cost:600,time:90,desc:'Specter strike: wipe largest enemy cluster'}
};
const UNITS={
worker:{name:'Reclaimer',type:'worker',cost:60,sup:1,hp:45,spd:5.2,dmg:3,rate:1.0,range:1.6,dmgType:'kinetic',armor:'light',
prod:'nexus',time:14,gt:1,at:0,desc:'Harvests Crystal. 1 supply.'},
mauler:{name:'Mauler',type:'melee',cost:55,sup:1,hp:70,spd:6.6,dmg:11,rate:.85,range:2.0,dmgType:'kinetic',armor:'light',
prod:'foundry',time:9,gt:1,at:1,desc:'Cheap melee shock troop.'},
lance:{name:'Pulse Lance',type:'ranged',cost:90,sup:1,hp:55,spd:5.6,dmg:13,rate:1.1,range:10.5,dmgType:'laser',armor:'light',
prod:'foundry',time:13,gt:1,at:1,desc:'Ranged laser. Strong vs light armor, hits air.'},
bulwark:{name:'Bulwark',type:'heavy',cost:170,sup:2,hp:230,spd:4.4,dmg:22,rate:1.5,range:2.4,dmgType:'kinetic',armor:'heavy',
prod:'foundry',time:22,gt:1,at:1,desc:'Heavy armor tank. Soaks laser fire.'},
specter:{name:'Specter',type:'air',cost:130,sup:2,hp:90,spd:7.4,dmg:15,rate:1.0,range:8.5,dmgType:'laser',armor:'fly',
prod:'hangar',time:16,gt:2,at:1,desc:'Flying striker. Only lances & turrets hit air.'}
};
const BLD={
nexus:{name:'Nexus',w:6,d:6,h:5.2,cost:350,time:35,sup:15,hp:1100,armor:'heavy',
roles:['store','train','research'],train:{worker:1},desc:'HQ. Stores Crystal, trains Reclaimers, researches. Win target.'},
foundry:{name:'Foundry',w:4,d:4,h:3.2,cost:150,time:25,sup:10,hp:550,armor:'heavy',
roles:['train'],train:{mauler:1,lance:1,bulwark:1},desc:'Ground unit production. +10 supply.'},
hangar:{name:'Hangar',w:4,d:4,h:3.6,cost:220,time:30,sup:10,hp:450,armor:'heavy',
roles:['train'],train:{specter:1},desc:'Air unit production. +10 supply.'},
turret:{name:'Sentry Turret',w:2.4,d:2.4,h:3.4,cost:110,time:18,sup:0,hp:320,armor:'heavy',
roles:['defense'],desc:'Auto-defense. Hits ground & air.'}
};
const BUILD_ORDER=['nexus','foundry','hangar','turret'];
/* ============================== globals ============================== */
let gl,prog,uni={},VBOs={};
let game=null;
const cam={x:MAPW*CELL*.18,z:MAPD*CELL*.82,yaw:0,pitch:1.05,zoom:60,tgt:{x:0,z:0,yaw:0,pitch:1.05,zoom:60},follow:false};
let mouse={x:0,y:0,wx:0,wz:0,onGL:false,down:false,button:0,sx:0,sy:0,dragging:false};
let selection=[];
const ctrlGroups={};
const keys={};
let paused=false,gameOver=false,started=false;
let boxSel=null; // {x0,y0,x1,y1}
let camVel={x:0,z:0};
/* ============================== WebGL setup ============================== */
const VS=`attribute vec3 aPos;attribute vec4 aCol;
uniform mat4 uMVP;uniform mat4 uModel;uniform float uTime;
varying vec4 vCol;varying vec3 vW;varying vec3 vN;
void main(){vec4 w=uModel*vec4(aPos,1.0);vW=w.xyz;
vec4 c=aCol;
if(c.a>0.5){ // dynamic flag: selection / placement
float p=0.5+0.5*sin(uTime*6.0+w.x*0.5+w.z*0.5);
c.rgb=mix(c.rgb,vec3(1.0),0.35+0.45*p);
}
vCol=c;vN=normalize(mat3(uModel)*vec3(0.0,1.0,0.0));
gl_Position=uMVP*w;}`;
const FS=`precision mediump float;varying vec4 vCol;varying vec3 vW;varying vec3 vN;
uniform vec3 uLightDir;uniform vec3 uCamPos;
void main(){
float d=distance(vW,uCamPos);
float fog=clamp((d-160.0)/420.0,0.0,1.0);
vec3 n=normalize(vN);
float diff=0.55+0.45*max(dot(n,normalize(uLightDir)),0.0);
vec3 col=vCol.rgb*diff;
col=mix(col,vec3(0.05,0.08,0.12),fog);
gl_FragColor=vec4(col,vCol.a);}`;
function mkShader(t,src){const s=gl.createShader(t);gl.shaderSource(s,src);gl.compileShader(s);
if(!gl.getShaderParameter(s,gl.COMPILE_STATUS))throw new Error(gl.getShaderInfoLog(s));return s}
function glInit(){
const cv=document.getElementById('gl');
gl=cv.getContext('webgl',{antialias:true});
if(!gl){document.getElementById('loaderr').style.display='flex';document.getElementById('loaderr').textContent='WebGL unavailable — this game needs it.';return false}
prog=gl.createProgram();
gl.attachShader(prog,mkShader(gl.VERTEX_SHADER,VS));
gl.attachShader(prog,mkShader(gl.FRAGMENT_SHADER,FS));
gl.linkProgram(prog);
if(!gl.getProgramParameter(prog,gl.LINK_STATUS))throw new Error(gl.getProgramInfoLog(prog));
gl.useProgram(prog);
for(const n of ['uMVP','uModel','uTime','uLightDir','uCamPos'])uni[n]=gl.getUniformLocation(prog,n);
const aPos=gl.getAttribLocation(prog,'aPos'),aCol=gl.getAttribLocation(prog,'aCol');
A_POS=aPos;A_COL=aCol;
gl.enableVertexAttribArray(aPos);gl.vertexAttribPointer(aPos,3,gl.FLOAT,false,28,0);
gl.enableVertexAttribArray(aCol);gl.vertexAttribPointer(aCol,4,gl.FLOAT,false,28,12);
gl.enable(gl.DEPTH_TEST);gl.disable(gl.CULL_FACE);
gl.clearColor(0.02,0.045,0.075,1);
resizeGL();
window.addEventListener('resize',resizeGL);
return true;
}
function resizeGL(){const cv=document.getElementById('gl');
cv.width=innerWidth*devicePixelRatio;cv.height=innerHeight*devicePixelRatio;
cv.style.width=innerWidth+'px';cv.style.height=innerHeight+'px';
gl.viewport(0,0,cv.width,cv.height)}
const GEOM={};
function buf(name,verts){const v=new Float32Array(verts);
GEOM[name]=v;
VBOs[name]=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,VBOs[name]);gl.bufferData(gl.ARRAY_BUFFER,v,gl.STATIC_DRAW);
VBOs[name+'N']=v.length/7}
let A_POS=-1,A_COL=-1;
function draw(name,model,time){
gl.uniformMatrix4fv(uni.uModel,false,model);
gl.uniform1f(uni.uTime,time);
gl.bindBuffer(gl.ARRAY_BUFFER,VBOs[name]);
gl.vertexAttribPointer(A_POS,3,gl.FLOAT,false,28,0);
gl.vertexAttribPointer(A_COL,4,gl.FLOAT,false,28,12);
gl.drawArrays(gl.TRIANGLES,0,VBOs[name+'N']);
}
function setCol(name,color){
const v=new Float32Array(VBOs[name+'N']*7);
const src=gl.getParameter(gl.ARRAY_BUFFER_BINDING);
gl.bindBuffer(gl.ARRAY_BUFFER,VBOs[name]);
gl.getBufferSubData(gl.ARRAY_BUFFER,0,v);
for(let i=0;i<v.length;i+=7){v[i]=color[0];v[i+1]=color[1];v[i+2]=color[2];v[i+3]=1}
gl.bindBuffer(gl.ARRAY_BUFFER,VBOs[name]);gl.bufferSubData(gl.ARRAY_BUFFER,0,v);
}
/* geometry builders */
function boxGeom(w,h,d,cx=0,cy=0,cz=0){ // axis-aligned box, 12 tris, per-face shading
const x0=cx-w/2,x1=cx+w/2,y0=cy-h/2,y1=cy+h/2,z0=cz-d/2,z1=cz+d/2;
const F=[ // [n, 4 corners]
[[0,1,0],[[x0,y1,z1],[x1,y1,z1],[x1,y1,z0],[x0,y1,z0]]],
[[0,-1,0],[[x0,y0,z0],[x1,y0,z0],[x1,y0,z1],[x0,y0,z1]]],
[[1,0,0],[[x1,y0,z1],[x1,y0,z0],[x1,y1,z0],[x1,y1,z1]]],
[[-1,0,0],[[x0,y0,z0],[x0,y0,z1],[x0,y1,z1],[x0,y1,z0]]],
[[0,0,1],[[x0,y0,z1],[x1,y0,z1],[x1,y1,z1],[x0,y1,z1]]],
[[0,0,-1],[[x1,y0,z0],[x0,y0,z0],[x0,y1,z0],[x1,y1,z0]]]
];
const v=[];
for(const[n,c]of F){const sh=.72+.28*Math.abs(n[1]*.6+n[0]*.2+n[2]*.2);
const col=[sh,sh,sh];
const idx=[0,1,2,0,2,3];
for(const i of idx)v.push(c[i][0],c[i][1],c[i][2],col[0],col[1],col[2],1);
}
return v;
}
function prismGeom(r,h,n=6,cx=0,cy=0,cz=0){ // hexagonal-ish prism
const v=[];
for(let i=0;i<n;i++){const a0=TAU*i/n,a1=TAU*(i+1)/n;
const p0=[cx+Math.cos(a0)*r,cy,cz+Math.sin(a0)*r],p1=[cx+Math.cos(a1)*r,cy,cz+Math.sin(a1)*r];
const p2=[cx+Math.cos(a1)*r,cy+h,cz+Math.sin(a1)*r],p3=[cx+Math.cos(a0)*r,cy+h,cz+Math.sin(a0)*r];
const sh=.8;v.push(p0[0],p0[1],p0[2],sh,sh,sh,1,p1[0],p1[1],p1[2],sh,sh,sh,1,p2[0],p2[1],p2[2],sh,sh,sh,1,
p0[0],p0[1],p0[2],sh,sh,sh,1,p2[0],p2[1],p2[2],sh,sh,sh,1,p3[0],p3[1],p3[2],sh,sh,sh,1);
// top fan
const c=[cx,cy+h,cz];const sh2=1;
v.push(c[0],c[1],c[2],sh2,sh2,sh2,1,p0[0],p0[1],p0[2],sh2,sh2,sh2,1,p1[0],p1[1],p1[2],sh2,sh2,sh2,1);
}
return v;
}
function cylGeom(r,h,n=8,cx=0,cy=0,cz=0){
const v=[];
for(let i=0;i<n;i++){const a0=TAU*i/n,a1=TAU*(i+1)/n;
const p0=[cx+Math.cos(a0)*r,cy,cz+Math.sin(a0)*r],p1=[cx+Math.cos(a1)*r,cy,cz+Math.sin(a1)*r];
const p2=[cx+Math.cos(a1)*r,cy+h,cz+Math.sin(a1)*r],p3=[cx+Math.cos(a0)*r,cy+h,cz+Math.sin(a0)*r];
const sh=.8;v.push(p0[0],p0[1],p0[2],sh,sh,sh,1,p1[0],p1[1],p1[2],sh,sh,sh,1,p2[0],p2[1],p2[2],sh,sh,sh,1,
p0[0],p0[1],p0[2],sh,sh,sh,1,p2[0],p2[1],p2[2],sh,sh,sh,1,p3[0],p3[1],p3[2],sh,sh,sh,1);
}
return v;
}
function sphereGeom(r,n=6,cx=0,cy=0,cz=0){ // faceted sphere
const v=[];const lat=4,lon=6;
const pts=[];
for(let i=0;i<=lat;i++){const th=Math.PI*i/lat;
for(let j=0;j<lon;j++){const ph=TAU*j/lon;
pts.push([cx+r*Math.sin(th)*Math.cos(ph),cy+r*Math.cos(th),cz+r*Math.sin(th)*Math.sin(ph)])}}
for(let i=0;i<lat;i++)for(let j=0;j<lon;j++){
const a=pts[i*lon+j],b=pts[i*lon+(j+1)%lon],c=pts[(i+1)*lon+(j+1)%lon],d=pts[(i+1)*lon+j];
const sh=.85;v.push(a[0],a[1],a[2],sh,sh,sh,1,b[0],b[1],b[2],sh,sh,sh,1,c[0],c[1],c[2],sh,sh,sh,1,
a[0],a[1],a[2],sh,sh,sh,1,c[0],c[1],c[2],sh,sh,sh,1,d[0],d[1],d[2],sh,sh,sh,1);
}
return v;
}
function buildMeshes(){
buf('u_worker',boxGeom(1.6,.9,1.0,0,.45,0).concat(prismGeom(.55,.7,5,0,.9,.15)));
buf('u_mauler',boxGeom(2.0,1.1,1.5,0,.55,0).concat(boxGeom(.5,.9,.5,-1.3,.45,.45)).concat(boxGeom(.5,.9,.5,-1.3,.45,-.45)));
buf('u_lance',boxGeom(1.7,.8,1.2,0,.4,0).concat(boxGeom(.35,1.0,1.6,0,.8,1.4)));
buf('u_bulwark',boxGeom(2.8,1.4,2.0,0,.7,0).concat(boxGeom(1.0,1.0,.6,1.4,.9,0)).concat(boxGeom(.7,.9,.7,-1.5,.45,1.2)).concat(boxGeom(.7,.9,.7,-1.5,.45,-1.2)));
buf('u_specter',boxGeom(2.4,.7,1.0,0,.35,0).concat(boxGeom(.9,.5,.35,1.0,.35,.6)).concat(boxGeom(.9,.5,.35,1.0,.35,-.6)).concat(boxGeom(.5,.9,.5,-1.1,.35,0)));
buf('b_nexus',prismGeom(4.4,3.0,6).concat(prismGeom(3.2,1.6,6,0,3.0,0)).concat(cylGeom(1.1,3.4,8,0,4.6,0)).concat(sphereGeom(1.3,6,0,8.6,0)));
buf('b_foundry',boxGeom(3.6,2.6,3.6).concat(boxGeom(1.2,1.6,1.2,1.2,2.6,1.2)).concat(boxGeom(2.8,.8,2.8,0,3.2,0)));
buf('b_hangar',boxGeom(3.6,2.2,3.6).concat(boxGeom(3.6,.6,3.6,0,2.5,0)).concat(boxGeom(1.8,1.2,2.2,1.9,1.1,0)));
buf('b_turret',cylGeom(1.0,1.2,8).concat(sphereGeom(.8,6,0,1.6,0)).concat(boxGeom(.3,1.6,.3,0,2.4,0)));
buf('fx_ring',[0,0,0,0,0,0,1]);
const ring=[];for(let i=0;i<24;i++){const a0=TAU*i/24,a1=TAU*(i+1)/24;
ring.push(Math.cos(a0)*1,0,Math.sin(a0)*1,1,1,1,1,Math.cos(a1)*1,0,Math.sin(a1)*1,1,1,1,1,
Math.cos(a0)*1,.05,Math.sin(a0)*1,1,1,1,1,Math.cos(a1)*1,0,Math.sin(a1)*1,1,1,1,1,Math.cos(a1)*1,.05,Math.sin(a1)*1,1,1,1,1)}
buf('fx_ring',ring);
const beam=[];beam.push(0,0,0,1,1,1,1,0,1,0,1,1,1,1,0,0,0,1,1,1,1,0,1.2,0,1,1,1,1,0,0,0,1,1,1,1,0,1.2,0,1,1,1,1);
buf('fx_beam',beam);
// selection rings
const sel=[];for(let i=0;i<32;i++){const a0=TAU*i/32,a1=TAU*(i+1)/32;
sel.push(Math.cos(a0)*1,0,Math.sin(a0)*1,1,1,1,1,Math.cos(a1)*1,0,Math.sin(a1)*1,1,1,1,1,
Math.cos(a0)*1,.06,Math.sin(a0)*1,1,1,1,1,Math.cos(a1)*1,0,Math.sin(a1)*1,1,1,1,1,Math.cos(a1)*1,.06,Math.sin(a1)*1,1,1,1,1)}
buf('sel_ring',sel);
}
/* ============================== terrain & map ============================== */
let terrain=null; // {h:Float32Array, solid:Uint8Array, vbo, vboN}
let fields=[];
function genMap(seed){
const n=MAPW*MAPD;
const h=new Float32Array(n),solid=new Uint8Array(n);
const R=mulberry32(seed);
const p1={x:MAPW*CELL*.16,z:MAPD*CELL*.80},p2={x:MAPW*CELL*.84,z:MAPD*CELL*.20};
const fieldsList=[];
// fields: 2 near each base, 1 mid per side, 3 contested
const spots=[
{x:p1.x+70,z:p1.z-30},{x:p1.x-40,z:p1.z-80},{x:p2.x-70,z:p2.z+30},{x:p2.x+40,z:p2.z+80},
{x:MAPW*CELL*.38,z:MAPD*CELL*.62},{x:MAPW*CELL*.62,z:MAPD*CELL*.38},
{x:MAPW*CELL*.5,z:MAPD*CELL*.5+40},{x:MAPW*CELL*.42,z:MAPD*CELL*.18},{x:MAPW*CELL*.58,z:MAPD*CELL*.82}
];
for(const s of spots){
const fx=clamp(s.x+rand(-30,30),60,MAPW*CELL-60),fz=clamp(s.z+rand(-30,30),60,MAPD*CELL-60);
fieldsList.push({x:fx,z:fz,amt:2600+randi(0,9)*250,max:0,depleted:false,buf:null});
}
for(let z=0;z<MAPD;z++)for(let x=0;x<MAPW;x++){
const wx=x*CELL,wz=z*CELL,i=z*MAPW+x;
const d1=dist(wx,wz,p1.x,p1.z),d2=dist(wx,wz,p2.x,p2.z);
const dBase=Math.min(d1,d2);
let hh=fbm(wx*.004,wz*.004,seed,4)*26+fbm(wx*.013,wz*.013,seed+7,3)*8;
const flat=clamp((dBase-60)/120,0,1);
hh*=flat;
// ridges
const ridge=fbm(wx*.007+90,wz*.007,seed+31,3);
if(ridge>.62&&flat>.3)hh+=(ridge-.62)*90;
hh=clamp(hh,0,46);
// flatten bases
if(d1<42)hh*=clamp(d1/42,0,1)*.5;
if(d2<42)hh*=clamp(d2/42,0,1)*.5;
h[i]=hh;
}
// carve field bowls
for(const f of fieldsList){
const fx=f.x/CELL,fz=f.z/CELL;
for(let z=Math.max(0,fz-4|0);z<Math.min(MAPD,fz+5);z++)for(let x=Math.max(0,fx-4|0);x<Math.min(MAPW,fx+5);x++){
const d=dist(x*CELL,z*CELL,f.x,f.z);
h[z*MAPW+x]=Math.min(h[z*MAPW+x],lerp(2,10,clamp(d/18,0,1)));
}
f.max=f.amt;
}
// solid: cliffs + rocks
const rockR=R;
for(let z=1;z<MAPD-1;z++)for(let x=1;x<MAPW-1;x++){
const i=z*MAPW+x;
if(h[i]>30&&Math.max(h[i-1],h[i+1],h[i-MAPW],h[i+MAPW])<h[i]-9)solid[i]=1;
}
// scatter boulders (avoid bases & fields)
for(let k=0;k<90;k++){
const x=2+Math.floor(R()*(MAPW-4)),z=2+Math.floor(R()*(MAPD-4)),i=z*MAPW+x;
const wx=x*CELL,wz=z*CELL;
if(solid[i])continue;
if(dist(wx,wz,p1.x,p1.z)<90||dist(wx,wz,p2.x,p2.z)<90)continue;
let nearField=false;for(const f of fieldsList)if(dist(wx,wz,f.x,f.z)<40)nearField=true;
if(nearField)continue;
solid[i]=2;
}
// clear base pads
for(const p of [p1,p2])for(let z=Math.floor(p.z/CELL)-6;z<=Math.floor(p.z/CELL)+6;z++)
for(let x=Math.floor(p.x/CELL)-6;x<=Math.floor(p.x/CELL)+6;x++)
if(x>0&&z>0&&x<MAPW-1&&z<MAPD-1)solid[z*MAPW+x]=0;
// pathfinding grid: walkable if not solid
const grid=new Uint8Array(n);
for(let i=0;i<n;i++)grid[i]=solid[i]?0:1;
terrain={h,solid,grid,p1,p2};
fields=fieldsList;
buildTerrainMesh();
buildFieldMeshes();
}
function buildTerrainMesh(){
const v=[];
for(let z=0;z<MAPD-1;z++)for(let x=0;x<MAPW-1;x++){
const x0=x*CELL,x1=(x+1)*CELL,z0=z*CELL,z1=(z+1)*CELL;
const i00=z*MAPW+x,i10=z*MAPW+x+1,i01=(z+1)*MAPW+x,i11=(z+1)*MAPW+x+1;
const h00=terrain.h[i00],h10=terrain.h[i10],h01=terrain.h[i01],h11=terrain.h[i11];
const cols=[colOf(h00),colOf(h10),colOf(h01),colOf(h11)];
const P=[[x0,h00,z0,cols[0]],[x1,h10,z0,cols[1]],[x1,h11,z1,cols[2]],[x0,h01,z1,cols[3]]];
const T=(a,b,c)=>{v.push(a[0],a[1],a[2],a[3][0],a[3][1],a[3][2],1,b[0],b[1],b[2],b[3][0],b[3][1],b[3][2],1,c[0],c[1],c[2],c[3][0],c[3][1],c[3][2],1)};
T(P[0],P[1],P[2]);T(P[0],P[2],P[3]);
}
buf('terrain',v);
// rocks
const rv=[];
for(let z=0;z<MAPD;z++)for(let x=0;x<MAPW;x++){
if(terrain.solid[z*MAPW+x]===2){
const wx=x*CELL,wz=z*CELL;
rv.push(...boxGeom(3.4,3.0,3.4,wx,1.5+terrain.h[z*MAPW+x]*.4,wz));
}
}
buf('rocks',rv);
}
function colOf(h){
let r,g,b;
if(h<3){r=.10;g=.22;b=.16}
else if(h<12){r=.13;g=.24;b=.15}
else if(h<24){r=.22;g=.22;b=.13}
else if(h<34){r=.30;g=.26;b=.18}
else{r=.42;g=.40;b=.38}
// subtle variation baked per-vertex via hash
return [r,g,b];
}
function buildFieldMeshes(){
let vi=0;
for(const f of fields){
const g=[];
for(let i=0;i<7;i++){
const a=TAU*i/7,rr=i===0?2.6:1.6+hash2(i,vi,5)*1.4;
const px=f.x+Math.cos(a)*rr,pz=f.z+Math.sin(a)*rr;
const ph=2+hash2(i,vi,9)*2.2;
g.push(...prismGeom(.5,ph,5,px,0,pz));
}
const arr=new Float32Array(g);
f.key='field'+vi;
GEOM[f.key]=arr;
f.buf=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,f.buf);
gl.bufferData(gl.ARRAY_BUFFER,arr,gl.STATIC_DRAW);f.n=g.length/7;
vi++;
}
}
function terrainH(x,z){
const gx=clamp(x/CELL,0,MAPW-1.001),gz=clamp(z/CELL,0,MAPD-1.001);
const x0=gx|0,z0=gz|0,xf=gx-x0,zf=gz-z0;
const i=z0*MAPW+x0;
return lerp(lerp(terrain.h[i],terrain.h[i+1],xf),lerp(terrain.h[i+MAPW],terrain.h[i+MAPW+1],xf),zf);
}
function isSolidCell(x,z){if(x<0||z<0||x>=MAPW||z>=MAPD)return true;return terrain.solid[z*MAPW+x]!==0}
function isSolid(x,z){return isSolidCell(Math.floor(x/CELL),Math.floor(z/CELL))}
/* ============================== pathfinding (A* on grid) ============================== */
const astar={
open:[],g:new Float32Array(MAPW*MAPD),f:new Float32Array(MAPW*MAPD),came:new Int32Array(MAPW*MAPD),seen:new Uint8Array(MAPW*MAPD),
find(sx,sz,tx,tz,limit=14000){
const grid=terrain.grid;
const s=sz*MAPW+sx,t=tz*MAPW+tx;
if(sx<0||sz<0||sx>=MAPW||sz>=MAPD||tx<0||tz<0||tx>=MAPW||tz>=MAPD)return null;
if(!grid[t]){ // find nearest walkable
let best=-1,bd=1e9;
for(let dz=-3;dz<=3;dz++)for(let dx=-3;dx<=3;dx++){
const nx=tx+dx,nz=tz+dz;if(nx<0||nz<0||nx>=MAPW||nz>=MAPD)continue;
if(grid[nz*MAPW+nx]){const d=dx*dx+dz*dz;if(d<bd){bd=d;best=nz*MAPW+nx}}
}
if(best<0)return null;t=best;
}
const g=this.g,f=this.f,came=this.came,seen=this.seen,open=this.open;
open.length=0;
const hFn=(i)=>Math.abs((i%MAPW)-tx)+Math.abs(((i/MAPW)|0)-tz);
g.fill(0);seen.fill(0);
g[s]=0;f[s]=hFn(s);seen[s]=1;open.push(s);
let steps=0;
while(open.length&&steps++<limit){
let bi=0;for(let i=1;i<open.length;i++)if(f[open[i]]<f[open[bi]])bi=i;
const cur=open[bi];open[bi]=open[open.length-1];open.pop();
if(cur===t){
const pts=[];let c=cur;
while(c!==s){pts.push([c%MAPW,((c/MAPW)|0)]);c=came[c];if(c<0)break}
pts.push([sx,sz]);pts.reverse();
return pts;
}
const cx=cur%MAPW,cz=(cur/MAPW)|0;
for(let dz=-1;dz<=1;dz++)for(let dx=-1;dx<=1;dx++){
if(!dx&&!dz)continue;
const nx=cx+dx,nz=cz+dz;
if(nx<0||nz<0||nx>=MAPW||nz>=MAPD)continue;
const ni=nz*MAPW+nx;
if(!grid[ni])continue;
if(dx&&dz&&(grid[cz*MAPW+nx]===0||grid[nz*MAPW+cx]===0))continue;
const cost=(dx&&dz)?1.414:1;
const ng=g[cur]+cost;
if(ng<g[ni]){g[ni]=ng;f[ni]=ng+hFn(ni);came[ni]=cur;if(!seen[ni]){seen[ni]=1;open.push(ni)}}
}
}
return null;
}
};
function smoothPath(pts){ // waypoint reduction
if(pts.length<=2)return pts;
const out=[pts[0]];
let a=0;
while(a<pts.length-1){
let b=pts.length-1;
while(b>a+1){
const p0=pts[a],p1=pts[b];
const dx=p1[0]-p0[0],dz=p1[1]-p0[1],L=Math.sqrt(dx*dx+dz*dz)||1;
let ok=true;
for(let s=1;s<10;s++){
const t=s/10;
if(isSolid(p0[0]*CELL+dx*t-CELL/2,p0[1]*CELL+dz*t-CELL/2)||isSolid(p0[0]*CELL+dx*t+CELL/2,p0[1]*CELL+dz*t+CELL/2)){ok=false;break}
}
if(ok)break;b--;
}
out.push(pts[b]);a=b;
}
return out;
}
function findPath(x,z,tx,tz){
const sx=Math.floor(x/CELL),sz=Math.floor(z/CELL);
if(isSolidCell(sx,sz)){ // nudge
let found=false;
for(let r=1;r<=3&&!found;r++)for(let dz=-r;dz<=r;dz++)for(let dx=-r;dx<=r;dx++){
const nx=sx+dx,nz=sz+dz;
if(nx>=0&&nz>=0&&nx<MAPW&&nz<MAPD&&!isSolidCell(nx,nz)){sx=nx;sz=nz;found=true;break}
}
if(!found)return null;
}
let pts=astar.find(sx,sz,Math.floor(tx/CELL),Math.floor(tz/CELL));
if(!pts)return null;
pts=smoothPath(pts);
const wpts=pts.map(p=>[p[0]*CELL+CELL/2,p[1]*CELL+CELL/2]);
wpts.push([tx,tz]);
return wpts;
}
/* ============================== entities ============================== */
let nextId=1;
const units=[],buildings=[],effects=[];
const FOG_R=52;
function mkUnit(kind,owner,x,z,opts={}){
const d=UNITS[kind];
const u={id:nextId++,kind,owner,x,z,y:0,heading:rand(TAU),defHeading:0,
hp:d.hp*(opts.hpMul||1),maxHp:d.hp*(opts.hpMul||1),
spd:d.spd,dmg:d.dmg,rate:d.rate,range:d.range,gt:d.gt,at:d.at,armor:d.armor,dmgType:d.dmgType,sup:d.sup,
state:'idle',path:null,wp:0,order:null,atkCd:0,atkTarget:null,
gatherField:null,gatherPhase:0,cargo:0,
sel:false,dead:false,
bob:rand(TAU),aggroT:0,
radius:kind==='bulwark'?2.2:1.6,
alt:0,altTgt:0};
if(kind==='specter'){u.alt=14;u.altTgt=14}
units.push(u);
return u;
}
function mkBuilding(kind,owner,x,z,opts={}){
const d=BLD[kind];
const b={id:nextId++,kind,owner,x,z,w:d.w,d:d.d,hp:d.hp*(opts.hpMul||1),maxHp:d.hp*(opts.hpMul||1),
armor:'heavy',built:false,buildT:d.time,buildTotal:d.time,
queue:[],research:null,rally:null,sel:false,dead:false,
atkCd:0,atkTarget:null,
vision:kind==='nexus'?60:kind==='turret'?48:40,
orbitCd:0};
if(opts.instant){b.built=true;b.buildT=0}
if(opts.rally)b.rally=[opts.rally[0],opts.rally[1]];
buildings.push(b);
return b;
}
function addEffect(type,x,z,y=0,dur=1,extra={}){
if(effects.length>260)effects.shift();
effects.push({type,x,z,y,t:0,dur,extra});
}
function killUnit(u){
u.dead=true;
addEffect('boom',u.x,u.z,terrainH(u.x,u.z),.7,{s:u.kind==='bulwark'?1.6:1});
if(Math.random()<.5)Audio2.boom(.5);
if(game)game.stats.unitsLost[u.owner]++;
for(let i=units.length-1;i>=0;i--)if(units[i]===u)units.splice(i,1);
const si=selection.indexOf(u);if(si>=0)selection.splice(si,1);
for(const k in ctrlGroups)ctrlGroups[k]=ctrlGroups[k].filter(v=>v!==u);
}
function killBuilding(b){
b.dead=true;
addEffect('boom',b.x,b.z,terrainH(b.x,b.z),1.2,{s:2.2});
Audio2.boom(1);
if(b.owner===0)Audio2.alarm();
if(game)game.stats.destroyed[b.owner===0?'bldPlayer':'bldEnemy']++;
for(let i=buildings.length-1;i>=0;i--)if(buildings[i]===b)buildings.splice(i,1);
const si=selection.indexOf(b);if(si>=0)selection.splice(si,1);
for(const k in ctrlGroups)ctrlGroups[k]=ctrlGroups[k].filter(v=>v!==b);
if(game)game.stats.destroyed[b.owner===0?'bldEnemy':'bldPlayer']=(game.stats.destroyed[b.owner===0?'bldEnemy':'bldPlayer']||0)+1;
}
/* ============================== fog / vision ============================== */
function fogInit(){
game.fog=new Uint8Array(MAPW*MAPD); // 0 unexplored 1 explored 2 visible
}
let visionMap=new Map(); // owner -> Set of cells
function fogUpdate(){
const f=game.fog;
const vis0=new Set(),vis1=new Set();
const consider=(x,z,s)=>{
if(x<0||z<0||x>=MAPW||z>=MAPD)return;
const i=z*MAPW+x;
s.add(i);
};
const reveal=(cx,cz,r,s)=>{
const r2=r*r;
const x0=Math.max(0,Math.floor((cx-r)/CELL)),x1=Math.min(MAPW-1,Math.ceil((cx+r)/CELL));
const z0=Math.max(0,Math.floor((cz-r)/CELL)),z1=Math.min(MAPD-1,Math.ceil((cz+r)/CELL));
for(let z=z0;z<=z1;z++)for(let x=x0;x<=x1;x++)
if(dist2(x*CELL+CELL/2,z*CELL+CELL/2,cx,cz)<r2)consider(x,z,s);
};
for(const u of units){
const s=u.owner===0?vis0:vis1;
const r=u.kind==='specter'?FOG_R+8:FOG_R;
reveal(u.x,u.z,r,s);
}
for(const b of buildings){
const s=b.owner===0?vis0:vis1;
reveal(b.x,b.z,b.vision,s);
}
for(const i of vis0){f[i]=f[i]===0?1:2}
for(const i of vis1){if(f[i]===0)f[i]=1}
// clear stale visible
for(let i=0;i<f.length;i++)if(f[i]===2&&!vis0.has(i))f[i]=1;
visionMap.set(0,vis0);visionMap.set(1,vis1);
}
function cellState(x,z){if(x<0||z<0||x>=MAPW||z>=MAPD)return 0;return game.fog[z*MAPW+x]}
function canSee(x,z){return cellState(Math.floor(x/CELL),Math.floor(z/CELL))===2}
/* ============================== economy ============================== */
function resOf(o){return game.res[o]}
function supplyCap(o){
let c=0;
for(const b of buildings)if(b.owner===o&&b.built&&BLD[b.kind].sup)c+=BLD[b.kind].sup;
c+=10*(game.upgrades[o].length); // each upgrade +10
return c;
}
function supplyUsed(o){
let u=0;
for(const un of units)if(un.owner===o)u+=UNITS[un.kind].sup;
for(const b of buildings)if(b.owner===o)for(const q of b.queue)u+=UNITS[q.kind].sup;
return u;
}
function supplyReserve(o){
let u=0;
for(const b of buildings)if(b.owner===o)for(const q of b.queue)u+=UNITS[q.kind].sup;
return u;
}
function canAfford(o,cost){return resOf(o)>=cost}
function spend(o,cost){if(resOf(o)>=cost){resOf(o)-=cost;return true}return false}
function trainUnit(b,kind){
const d=UNITS[kind];
const o=b.owner;
if(!canAfford(o,d.cost))return 'insufficient resources';
if(supplyUsed(o)+d.sup>supplyCap(o))return 'insufficient supply';
spend(o,d.cost);
b.queue.push({kind,t:d.time,total:d.time});
return null;
}
function cancelQueue(b,idx){
const q=b.queue[idx];if(!q)return;
resOf(b.owner)+=(b.owner===0?1:1)*UNITS[q.kind].cost; // full refund
b.queue.splice(idx,1);
Audio2.click();
}
function research(b,key){
const o=b.owner;
if(game.upgrades[o].includes(key))return 'already researched';
if(b.research)return 'already researching';
if(!canAfford(o,UPG[key].cost))return 'insufficient resources';
spend(o,UPG[key].cost);
b.research={key,t:UPG[key].time,total:UPG[key].time};
return null;
}
function startBuild(kind,owner,x,z){
const d=BLD[kind];
if(!canAfford(owner,d.cost))return 'insufficient resources';
if(!validBuildSpot(kind,x,z,owner))return 'invalid location';
spend(owner,d.cost);
const b=mkBuilding(kind,owner,x,z);
addEffect('ring',x,z,terrainH(x,z),.6,{s:d.w*.8,c:[0.4,0.9,1]});
Audio2.build();
return b;
}
function validBuildSpot(kind,x,z,owner,ignore=null){
const d=BLD[kind];
const hw=d.w/2-0.5,hd=d.d/2-0.5;
if(x<hw+2||z<hd+2||x>MAPW*CELL-hw-2||z>MAPD*CELL-hd-2)return false;
const h0=terrainH(x,z);
// flatness
for(const[dx,dz]of[[-hw,-hd],[hw,-hd],[-hw,hd],[hw,hd],[0,0]])
if(Math.abs(terrainH(x+dx,z+dz)-h0)>2.5)return false;
// solid inside
for(let sx=Math.floor((x-hw)/CELL);sx<=Math.floor((x+hw)/CELL);sx++)
for(let sz=Math.floor((z-hd)/CELL);sz<=Math.floor((z+hd)/CELL);sz++)
if(isSolidCell(sx,sz))return false;
// overlap buildings
for(const b of buildings)if(b!==ignore){
if(Math.abs(b.x-x)<(d.w+b.w)/2-1&&Math.abs(b.z-z)<(d.d+b.d)/2-1)return false;
}
return true;
}
function nearestStore(o,x,z,maxD=1e9){
let best=null,bd=maxD;
for(const b of buildings)if(b.owner===o&&b.built){
const d=dist2(x,z,b.x,b.z);
if(d<bd){bd=d;best=b}
}
return best;
}
function nearestField(o,x,z,maxD=1e9){
let best=null,bd=maxD;
for(const f of fields)if(!f.depleted){
const d=dist2(x,z,f.x,f.z);
if(d<bd){bd=d;best=f}
}
return best;
}
/* ============================== combat ============================== */
function upgMul(o,kind){
const U=game.upgrades[o];
if(kind==='melee')return U.includes('ion')?1.3:1;
if(kind==='laser')return U.includes('coil')?1.3:1;
return 1;
}
function canTarget(attacker,def){ // attacker unit/building, def unit/building
if(def.owner===attacker.owner)return false;
if(def.dead)return false;
const isUnitDef=(def.maxHp!==undefined&&def.kind&&UNITS[def.kind]!==undefined);
const defAir=isUnitDef?def.kind==='specter':false;
if(defAir){ // air: only laser weapons hit
return attacker.dmgType==='laser';
}
return true;
}
function applyDamage(att,def,dmg){
const mod=DMG_MOD[att.dmgType]||(DMG_MOD[att.armor]&&{light:1,heavy:1,fly:1});
let m=1;
if(att.dmgType==='kinetic')m=def.armor==='heavy'?.55:def.armor==='fly'?1:1;
else if(att.dmgType==='laser')m=def.armor==='light'?1.4:def.armor==='heavy'?.7:1.2;
const real=dmg*m;
def.hp-=real;
addEffect('hit',def.x,def.z,terrainH(def.x,def.z)+1,.18,{c:att.dmgType==='laser'?[0.4,0.9,1.6]:[2.2,1.4,0.4]});
if(def.hp<=0){
if(UNITS[def.kind]!==undefined)killUnit(def);else killBuilding(def);
if(att.owner===0)game.stats.kills[def.kind]=(game.stats.kills[def.kind]||0)+1;
}
return real;
}
function fireWeapon(att,def,dt){
att.atkCd=att.rate;
addEffect('beam',att.x,att.z,(att.alt||0)+1.2,.12,{tx:def.x,tz:def.z,ty:terrainH(def.x,def.z)+1.5,
c:att.dmgType==='laser'?[0.35,0.85,1.8]:[1.8,1.2,0.3]});
if(att.dmgType==='laser')Audio2.shoot('laser');else Audio2.shoot('kinetic');
applyDamage(att,def,att.dmg*upgMul(att.owner,att.kind||'laser'));
}
function acquireTarget(u,fromX,fromZ,maxRange){
let best=null,bd=1e18;
const R=maxRange||u.range+2;
for(const e of units)if(e!==u&&canTarget(u,e)){
if(!canSee(e.x,e.z))continue;
const d=dist2(u.x,u.z,e.x,e.z);
if(d<R*R&&d<bd){bd=d;best=e}
}
for(const e of buildings)if(canTarget(u,e)&&canSee(e.x,e.z)){
const d=dist2(u.x,u.z,e.x,e.z);
if(d<R*R&&d<bd){bd=d;best=e}
}
return best;
}
function findTarget(u){
if(u.atkTarget&&!u.atkTarget.dead&&canTarget(u,u.atkTarget))return u.atkTarget;
return null;
}
/* ============================== orders ============================== */
function orderMove(list,x,z){
for(const u of list)if(UNITS[u.kind]!==undefined){
u.order=null;u.gatherField=null;u.cargo=0;
u.state='move';
const p=findPath(u.x,u.z,x,z);
u.path=p;u.wp=0;
if(!p){u.state='idle'}
addEffect('ring',x,z,terrainH(x,z),.5,{s:1.2,c:[0.4,0.9,1]});
}
Audio2.click();
}
function orderAttack(list,tgt){
for(const u of list)if(UNITS[u.kind]!==undefined){
u.order=null;u.gatherField=null;u.cargo=0;
u.atkTarget=tgt;
u.state='attack';
const p=findPath(u.x,u.z,tgt.x,tgt.z);
u.path=p;u.wp=0;
}
Audio2.click();
}
function orderAttackMove(list,x,z){
for(const u of list)if(UNITS[u.kind]!==undefined){
u.order={type:'attackmove',x,z};
u.gatherField=null;u.cargo=0;
u.state='move';
const p=findPath(u.x,u.z,x,z);
u.path=p;u.wp=0;
}
addEffect('ring',x,z,terrainH(x,z),.6,{s:1.5,c:[1.6,0.5,0.3]});
Audio2.click();
}
function orderStop(list){
for(const u of list)if(UNITS[u.kind]!==undefined){
u.order=null;u.path=null;u.wp=0;u.state='idle';u.atkTarget=null;
}
Audio2.click();
}
function orderHold(list){
for(const u of list)if(UNITS[u.kind]!==undefined){
u.order={type:'hold'};u.path=null;u.wp=0;u.state='idle';
}
Audio2.click();
}
function orderGather(list,field){
for(const u of list)if(UNITS[u.kind]!=='worker')continue;
for(const u of list)if(UNITS[u.kind]==='worker'){
u.order=null;u.atkTarget=null;
u.gatherField=field;u.cargo=0;u.gatherPhase=0;
u.state='move';
const p=findPath(u.x,u.z,field.x,field.z);
u.path=p;u.wp=0;
}
Audio2.click();
}
function orderReturn(list){
for(const u of list)if(UNITS[u.kind]==='worker'){
u.order=null;u.atkTarget=null;u.gatherField=null;
u.state='move';
const st=nearestStore(u.owner,u.x,u.z);
if(st){const p=findPath(u.x,u.z,st.x,st.z);u.path=p;u.wp=0}
else u.state='idle';
}
Audio2.click();
}
function selectList(list,add){
if(!add)selection=[];
for(const e of list)if(!selection.includes(e))selection.push(e);
for(const e of selection)e.sel=true;
hudDirty=true;
}
function clearSel(){for(const e of selection)e.sel=false;selection=[];hudDirty=true}
/* ============================== unit simulation ============================== */
function updateUnit(u,dt){
if(u.dead)return;
u.atkCd-=dt;
const isAir=u.kind==='specter';
u.bob+=dt*4;
const altGoal=isAir?11:0;
u.altTgt=altGoal;
u.alt+=(u.altTgt-u.alt)*Math.min(1,dt*2.5);
const holding=u.order&&u.order.type==='hold';
const amove=u.order&&u.order.type==='attackmove';
// acquire target (respect sight, range, targetability)
if(!holding&&u.atkCd<=0){
if(u.atkTarget&&u.atkTarget.dead)u.atkTarget=null;
if(!u.atkTarget){
const t=amove?acquireTarget(u,u.x,u.z,u.range+3):acquireTarget(u,u.x,u.z,Math.max(u.range+1,6));
if(t){
u.atkTarget=t;
if(u.order)u.order=null;
u.state='attack';
const p=findPath(u.x,u.z,t.x,t.z);
u.path=p;u.wp=0;
}
}
}
if(u.atkTarget){
const t=u.atkTarget;
if(t.dead){u.atkTarget=null;u.state='idle'}
else{
u.state='attack';
const d=dist(u.x,u.z,t.x,t.z);
if(d<=u.range+(t.radius||1.5)){
if(u.atkCd<=0)fireWeapon(u,t,dt);
}else followPath(u,dt,t.x,t.z);
}
}
else if(u.state==='move'||u.state==='attack'){
followPath(u,dt);
if(amove){
if(u.path===null||u.wp>=u.path.length){
const p=findPath(u.x,u.z,u.order.x,u.order.z);
u.path=p;u.wp=0;
if(!p)u.order=null;
}
}
if(!u.atkTarget&&u.state==='move'&&(u.path===null||u.wp>=u.path.length)){
u.state='idle';
if(u.order&&u.order.type==='attackmove')u.order=null;
}
}
// worker gathering
if(UNITS[u.kind]==='worker'&&!u.atkTarget)workerSim(u,dt);
}
function followPath(u,dt,ox,oz){
if(!u.path||u.wp>=u.path.length)return;
let tx=ox!==undefined?ox:u.path[u.wp][0],tz=oz!==undefined?oz:u.path[u.wp][1];
const d=dist(u.x,u.z,tx,tz);
const spd=u.spd*(game.upgrades[u.owner].includes('thr')?1.15:1);
if(d<0.9){
u.wp++;
if(u.wp>=u.path.length){u.path=null;return}
tx=u.path[u.wp][0];tz=u.path[u.wp][1];
}
const a=angTo(u.x,u.z,tx,tz);
const step=Math.min(d,spd*dt);
u.x+=Math.sin(a)*step;u.z+=Math.cos(a)*step;
u.heading=a;
}
function separate(u,dt){
// push away from nearby allies (cheap grid-free: only check small subset via hash)
const cellX=Math.floor(u.x/8),cellZ=Math.floor(u.z/8);
for(const v of units){
if(v===u||v.dead)continue;
const dx=v.x-u.x,dz=v.z-u.z;
if(Math.abs(dx)>3.4||Math.abs(dz)>3.4)continue;
const d=Math.sqrt(dx*dx+dz*dz);
const minD=(u.radius+v.radius)*0.8;
if(d<minD&&d>0.001){
const push=(minD-d)/d*0.5;
u.x-=dx*push;u.z-=dz*push;
}
}
}
function workerSim(u,dt){
const f=u.gatherField;
if(f){
if(f.depleted){u.gatherField=null;return}
const d=dist(u.x,u.z,f.x,f.z);
if(u.cargo===0){
if(d>6){
// move to field
if(!u.path||u.wp>=u.path.length){
const p=findPath(u.x,u.z,f.x+rand(-3,3),f.z+rand(-3,3));
u.path=p;u.wp=0;
}
followPath(u,dt);
}else{
u.path=null;
u.gatherPhase+=dt;
if(u.gatherPhase>0.8){
u.gatherPhase=0;
if(f.amt>0){
f.amt=Math.max(0,f.amt-1);
u.cargo+=8;
addEffect('hit',f.x+rand(-2,2),f.z+rand(-2,2),terrainH(f.x,f.z)+1,.15,{c:[0.5,1.2,1.6]});
if(f.amt<=0)f.depleted=true;
}
}
}
}else{
// return
const st=nearestStore(u.owner,u.x,u.z,2000);
if(!st){u.cargo=0;return}
const ds=dist(u.x,u.z,st.x,st.z);
if(ds<st.w){
resOf(u.owner)=Math.min(RES_CAP,resOf(u.owner)+u.cargo);
game.inc[u.owner]+=u.cargo;
u.cargo=0;
addEffect('ring',st.x,st.z,terrainH(st.x,st.z)+.5,.4,{s:2,c:[0.5,1,0.6]});
}else{
if(!u.path||u.wp>=u.path.length){
const p=findPath(u.x,u.z,st.x+rand(-2,2),st.z+rand(-2,2));
u.path=p;u.wp=0;
}
followPath(u,dt);
}
}
}
}
/* ============================== building simulation ============================== */
function updateBuilding(b,dt){
if(b.dead)return;
if(!b.built){
b.buildT-=dt;
if(b.buildT<=0){
b.built=true;
addEffect('ring',b.x,b.z,terrainH(b.x,b.z)+.5,.8,{s:b.w,c:[0.5,1,0.7]});
if(b.owner===0)Audio2.done();
// spawn rally
if(b.rally){
// nothing here; rally used at production
}
}
return;
}
// research
if(b.research){
b.research.t-=dt;
if(b.research.t<=0){
game.upgrades[b.owner].push(b.research.key);
addEffect('ring',b.x,b.z,terrainH(b.x,b.z)+2,1,{s:b.w*1.4,c:[1.2,0.9,0.4]});
if(b.owner===0){Audio2.done();toast(UPG[b.research.key].name+' researched','info')}
b.research=null;
}
}
// production
if(b.queue.length&&b.built){
const q=b.queue[0];
q.t-=dt;
if(q.t<=0){
b.queue.shift();
spawnFromBuilding(b,q.kind);
}
}
// turret combat
if(b.kind==='turret'){
b.atkCd-=dt;
if(b.atkTarget&&b.atkTarget.dead)b.atkTarget=null;
if(!b.atkTarget&&b.atkCd<=0){
const t=acquireTarget({owner:b.owner,dmgType:'laser',range:30,x:b.x,z:b.z,kind:'turret'},b.x,b.z,30);
if(t)b.atkTarget=t;
}
if(b.atkTarget){
const d=dist(b.x,b.z,b.atkTarget.x,b.atkTarget.z);
if(d<=30){
if(b.atkCd<=0){
b.atkCd=1.0;
addEffect('beam',b.x,b.z,terrainH(b.x,b.z)+3,.12,{tx:b.atkTarget.x,tz:b.atkTarget.z,ty:terrainH(b.atkTarget.x,b.atkTarget.z)+1.5,c:[0.4,0.9,1.8]});
Audio2.shoot('laser');
const dmg=14*(game.upgrades[b.owner].includes('coil')?1.3:1);
applyDamage({dmgType:'laser',owner:b.owner,kind:'turret'},b.atkTarget,dmg);
}
}else b.atkTarget=null;
}
}
}
function spawnFromBuilding(b,kind){
const d=UNITS[kind];
const o=b.owner;
let sx=b.x,sz=b.z;
// find free spot near building
for(let i=0;i<12;i++){
const a=rand(TAU),r=b.w*.9+1.5;
sx=b.x+Math.sin(a)*r;sz=b.z+Math.cos(a)*r;
if(!isSolid(sx,sz))break;
}
const hpMul=game.upgrades[o].includes('plate')?1.25:1;
const u=mkUnit(kind,o,sx,sz,{hpMul});
u.hp=u.maxHp;
// rally
if(b.rally){
u.state='move';
const p=findPath(sx,sz,b.rally[0],b.rally[1]);
u.path=p;u.wp=0;
}
addEffect('ring',sx,sz,terrainH(sx,sz),.5,{s:1.5,c:o===0?[0.4,0.9,1]:[1.4,0.5,0.3]});
if(o===0)Audio2.done();
return u;
}
/* ============================== effects ============================== */
function updateEffects(dt){
for(let i=effects.length-1;i>=0;i--){
const e=effects[i];
e.t+=dt;
if(e.t>=e.dur)effects.splice(i,1);
}
}
/* ============================== game state ============================== */
function newGame(seed){
units.length=0;buildings.length=0;effects.length=0;
for(const k in ctrlGroups)delete ctrlGroups[k];
selection.length=0;
nextId=1;
const s=seed!==undefined?seed:(Math.random()*1e9|0);
genMap(s);
game={
seed:s,time:0,
res:[250,250],
upgrades:[[],[]],
fog:null,
stats:{kills:{},destroyed:{},unitsLost:[0,0],unitsBuilt:[0,0]},
inc:[0,0],
ai:{scoutT:8,expandT:60,attackT:90,defT:0,phase:0},
over:false,win:null
};
fogInit();
buildMinimapBase();
const p1=terrain.p1,p2=terrain.p2;
const n0=mkBuilding('nexus',0,p1.x,p1.z,{instant:true,rally:[p1.x+30,p1.z-30]});
const n1=mkBuilding('nexus',1,p2.x,p2.z,{instant:true,rally:[p2.x-30,p2.z+30]});
n0.rally=[p1.x+30,p1.z-30];n1.rally=[p2.x-30,p2.z+30];
for(let i=0;i<4;i++){
mkUnit('worker',0,p1.x+rand(-8,8),p1.z+rand(-8,8));
mkUnit('worker',1,p2.x+rand(-8,8),p2.z+rand(-8,8));
}
// initial vision
fogUpdate();
cam.x=p1.x;cam.z=p1.z;cam.tgt.x=p1.x;cam.tgt.z=p1.z;
cam.yaw=0;cam.tgt.yaw=0;cam.pitch=1.05;cam.tgt.pitch=1.05;cam.zoom=60;cam.tgt.zoom=60;
cam.follow=false;
hudDirty=true;
toast('Deploy complete. Build your economy, scout the frontier.','info');
}
function checkWin(){
if(game.over)return;
const n0=buildings.some(b=>b.owner===0&&b.kind==='nexus');
const n1=buildings.some(b=>b.owner===1&&b.kind==='nexus');
if(!n1){game.over=true;game.win=0}
if(!n0){game.over=true;game.win=1}
if(game.over)endScreen(game.win===0);
}
function endScreen(win){
gameOver=true;
const et=document.getElementById('endtitle');
et.textContent=win?'VICTORY':'DEFEAT';
et.className=win?'win':'lose';
document.getElementById('endsub').textContent=win?
'The rival expedition has been annihilated. Kestrel-9 is yours.':
'Your last Nexus has fallen. The frontier claims another expedition.';
const st=game.stats;
const kills=Object.values(st.kills).reduce((a,b)=>a+b,0);
document.getElementById('stats').innerHTML=
`Time: <b>${fmtT(game.time)}</b> · Units destroyed: <b>${kills}</b> · Buildings destroyed: <b>${(st.destroyed.bldEnemy||0)+(st.destroyed.bldPlayer||0)}</b><br>`+
`Units lost: <b>${st.unitsLost[0]}</b> · Built: <b>${st.unitsBuilt[0]}</b> · Final supply: <b>${supplyUsed(0)}/${supplyCap(0)}</b>`;
document.getElementById('endscreen').classList.remove('hidden');
Audio2[win?'done':'alarm']();
}
function toast(msg,cls='info'){
const t=document.createElement('div');
t.className='toast '+cls;
t.textContent=msg;
document.getElementById('toasts').appendChild(t);
setTimeout(()=>t.remove(),4200);
}
/* ============================== matrices ============================== */
function m4Persp(fov,asp,n,f){const t=1/Math.tan(fov/2);
return [t/asp,0,0,0, 0,t,0,0, 0,0,(f+n)/(n-f),-1, 0,0,2*f*n/(n-f),0]}
function m4LookAt(e,c,u){
let zx=e[0]-c[0],zy=e[1]-c[1],zz=e[2]-c[2];let l=Math.hypot(zx,zy,zz);zx/=l;zy/=l;zz/=l;
let xx=u[1]*zz-u[2]*zy,xy=u[2]*zx-u[0]*zz,xz=u[0]*zy-u[1]*zx;l=Math.hypot(xx,xy,xz);xx/=l;xy/=l;xz/=l;
const yx=zy*xz-zz*xy,yy=zz*xx-zx*xz,yz=zx*xy-zy*xx;
return [xx,yx,zx,0, xy,yy,zy,0, xz,yz,zz,0,
-(xx*e[0]+xy*e[1]+xz*e[2]),-(yx*e[0]+yy*e[1]+yz*e[2]),-(zx*e[0]+zy*e[1]+zz*e[2]),1]}
function m4Mul(a,b){const o=new Array(16);
for(let i=0;i<4;i++)for(let j=0;j<4;j++){let s=0;for(let k=0;k<4;k++)s+=a[k*4+i]*b[j*4+k];o[j*4+i]=s}
return o}
function m4Inv(m){
const o=new Array(16);
o[0]=m[5]*m[10]*m[15]-m[5]*m[11]*m[14]-m[9]*m[6]*m[15]+m[9]*m[7]*m[14]+m[13]*m[6]*m[11]-m[13]*m[7]*m[10];
o[4]=-m[4]*m[10]*m[15]+m[4]*m[11]*m[14]+m[8]*m[6]*m[15]-m[8]*m[7]*m[14]-m[12]*m[6]*m[11]+m[12]*m[7]*m[10];
o[8]=m[4]*m[9]*m[15]-m[4]*m[11]*m[13]-m[8]*m[5]*m[15]+m[8]*m[7]*m[13]+m[12]*m[5]*m[11]-m[12]*m[7]*m[9];
o[12]=-m[4]*m[9]*m[14]+m[4]*m[10]*m[13]+m[8]*m[5]*m[14]-m[8]*m[6]*m[13]-m[12]*m[5]*m[10]+m[12]*m[6]*m[9];
o[1]=-m[1]*m[10]*m[15]+m[1]*m[11]*m[14]+m[9]*m[2]*m[15]-m[9]*m[3]*m[14]-m[13]*m[2]*m[11]+m[13]*m[3]*m[10];
o[5]=m[0]*m[10]*m[15]-m[0]*m[11]*m[14]-m[8]*m[2]*m[15]+m[8]*m[3]*m[14]+m[12]*m[2]*m[11]-m[12]*m[3]*m[10];
o[9]=-m[0]*m[9]*m[15]+m[0]*m[11]*m[13]+m[8]*m[1]*m[15]-m[8]*m[3]*m[13]-m[12]*m[1]*m[11]+m[12]*m[3]*m[9];
o[13]=m[0]*m[9]*m[14]-m[0]*m[10]*m[13]-m[8]*m[1]*m[14]+m[8]*m[2]*m[13]+m[12]*m[1]*m[10]-m[12]*m[2]*m[9];
o[2]=m[1]*m[6]*m[15]-m[1]*m[7]*m[14]-m[5]*m[2]*m[15]+m[5]*m[3]*m[14]+m[13]*m[2]*m[7]-m[13]*m[3]*m[6];
o[6]=-m[0]*m[6]*m[15]+m[0]*m[7]*m[14]+m[4]*m[2]*m[15]-m[4]*m[3]*m[14]-m[12]*m[2]*m[7]+m[12]*m[3]*m[6];
o[10]=m[0]*m[5]*m[15]-m[0]*m[7]*m[13]-m[4]*m[1]*m[15]+m[4]*m[3]*m[13]+m[12]*m[1]*m[7]-m[12]*m[3]*m[5];
o[14]=-m[0]*m[5]*m[14]+m[0]*m[6]*m[13]+m[4]*m[1]*m[14]-m[4]*m[2]*m[13]-m[12]*m[1]*m[6]+m[12]*m[2]*m[5];
o[3]=-m[1]*m[6]*m[11]+m[1]*m[7]*m[10]+m[5]*m[2]*m[11]-m[5]*m[3]*m[10]-m[9]*m[2]*m[7]+m[9]*m[3]*m[6];
o[7]=m[0]*m[6]*m[11]-m[0]*m[7]*m[10]-m[4]*m[2]*m[11]+m[4]*m[3]*m[10]+m[8]*m[2]*m[7]-m[8]*m[3]*m[6];
o[11]=-m[0]*m[5]*m[11]+m[0]*m[7]*m[9]+m[4]*m[1]*m[11]-m[4]*m[3]*m[9]-m[8]*m[1]*m[7]+m[8]*m[3]*m[5];
o[15]=m[0]*m[5]*m[10]-m[0]*m[6]*m[9]-m[4]*m[1]*m[10]+m[4]*m[2]*m[9]+m[8]*m[1]*m[6]-m[8]*m[2]*m[5];
let det=m[0]*o[0]+m[1]*o[4]+m[2]*o[8]+m[3]*o[12];
if(!det)return null;det=1/det;
for(let i=0;i<16;i++)o[i]*=det;
return o;
}
function m4T(x,y,z){return [1,0,0,0,0,1,0,0,0,0,1,0,x,y,z,1]}
function m4RY(a){const c=Math.cos(a),s=Math.sin(a);return [c,0,-s,0, 0,1,0,0, s,0,c,0, 0,0,0,1]}
function m4S(x,y,z){return [x,0,0,0,0,y,0,0,0,0,z,0,0,0,0,1]}
let PV=null,IPV=null;
function project(x,y,z){
if(!PV)return null;
const m=PV;
const w=m[3]*x+m[7]*y+m[11]*z+m[15];
const cx=(m[0]*x+m[4]*y+m[8]*z+m[12])/w;
const cy=(m[1]*x+m[5]*y+m[9]*z+m[13])/w;
if(w<=0)return null;
return [(cx*0.5+0.5)*innerWidth,(1-(cy*0.5+0.5))*innerHeight];
}
function unproject(sx,sy){
if(!IPV)return null;
const nx=(sx/innerWidth)*2-1,ny=1-(sy/innerHeight)*2;
const m=IPV;
const w=m[3]*nx+m[7]*ny+m[11]*(-1)+m[15];
if(Math.abs(w)<1e-6)return null;
return [(m[0]*nx+m[4]*ny+m[8]*(-1)+m[12])/w,(m[1]*nx+m[5]*ny+m[9]*(-1)+m[13])/w,(m[2]*nx+m[6]*ny+m[10]*(-1)+m[14])/w];
}
function screenToWorld(sx,sy){
const o=unproject(sx,sy);if(!o)return null;
const o2=unproject(sx+1,sy);
if(!o2)return null;
let dx=o2[0]-o[0],dy=o2[1]-o[1],dz=o2[2]-o[2];
const l=Math.hypot(dx,dy,dz);dx/=l;dy/=l;dz/=l;
for(let t=0;t<900;t+=2.5){
const px=o[0]+dx*t,py=o[1]+dy*t,pz=o[2]+dz*t;
if(py<=terrainH(px,pz))return [px,pz];
if(px<-50||pz<-50||px>MAPW*CELL+50||pz>MAPD*CELL+50)return null;
}
return null;
}
/* ============================== AI ============================== */
function aiUpdate(dt){
const A=game.ai,t=game.time;
A.phase=t<150?0:t<300?1:t<450?2:3;
A.econT=(A.econT||0)-dt;
A.scoutT-=dt;
A.attackCd=(A.attackCd||0)-dt;
if(A.econT<=0){
A.econT=2;
aiEconomy();
}
if(A.scoutT<=0){
A.scoutT=40+rand(0,20);
aiScout();
}
aiAttack(dt);
}
function aiEconomy(){
const o=1;
const myB=buildings.filter(b=>b.owner===o);
const nexus=myB.find(b=>b.kind==='nexus');
if(!nexus)return;
const workers=units.filter(u=>u.owner===o&&u.kind==='worker');
const combat=units.filter(u=>u.owner===o&&u.kind!=='worker');
// 1. assign idle workers to fields
for(const w of workers){
if(w.gatherField&&!w.gatherField.depleted)continue;
let best=null,bd=1e18;
for(const f of fields){
if(f.depleted)continue;
const load=units.filter(u=>u.owner===o&&u.kind==='worker'&&u.gatherField===f).length;
if(load>=4)continue;
const d=dist2(w.x,w.z,f.x,f.z);
if(d<bd){bd=d;best=f}
}
if(best)orderGather([w],best);
}
// 2. train workers
const cap=supplyCap(o),used=supplyUsed(o);
if(workers.length<7&&resOf(o)>=60&&used+1<=cap&&nexus.queue.length===0)trainUnit(nexus,'worker');
// 3. buildings
const foundries=myB.filter(b=>b.kind==='foundry').length;
const hangars=myB.filter(b=>b.kind==='hangar').length;
const nexuses=myB.filter(b=>b.kind==='nexus').length;
if(foundries===0&&resOf(o)>=150)aiBuild('foundry',nexus);
if(foundries<2&&resOf(o)>=320&&t>140)aiBuild('foundry',nexus);
if(hangars===0&&resOf(o)>=240&&t>200)aiBuild('hangar',nexus);
if(nexuses<2&&resOf(o)>=420&&t>240)aiExpand();
if(cap-used<12&&resOf(o)>=150&&foundries<3)aiBuild('foundry',nexus);
// 4. research
if(!nexus.research&&nexus.built){
const want=['plate','ion','coil','thr','orbit'];
for(const k of want)if(!game.upgrades[o].includes(k)&&resOf(o)>=UPG[k].cost){research(nexus,k);break}
}
// 5. production
const mix=A.phase===0?['mauler','mauler','lance']:
A.phase===1?['mauler','lance','lance','bulwark']:
A.phase===2?['lance','bulwark','bulwark','specter']:
['lance','bulwark','specter','specter'];
let budget=Math.min(2,A.phase+1);
for(const b of myB){
if(b.kind==='foundry'&&b.built&&budget>0){
while(b.queue.length<3){
const k=mix[randi(0,mix.length-1)];
if(UNITS[k].gt===2)break;
const err=trainUnit(b,k);
if(err)break;
budget--;
if(budget<=0)break;
}
}
if(b.kind==='hangar'&&b.built&&budget>0){
while(b.queue.length<2){
const err=trainUnit(b,'specter');
if(err)break;
budget--;
}
}
}
// 6. defend
let threat=null;
for(const u of units)if(u.owner===0&&u.kind!=='worker'){
for(const b of myB)if(dist(u.x,u.z,b.x,b.z)<90){threat=u;break}
if(threat)break;
}
if(threat){
for(const u of combat){
if(dist(u.x,u.z,threat.x,threat.z)<220)orderAttack([u],threat);
}
}
}
function aiBuild(kind,anchor){
for(let i=0;i<14;i++){
const a=rand(TAU),r=10+rand(0,10);
const x=anchor.x+Math.sin(a)*r,z=anchor.z+Math.cos(a)*r;
if(validBuildSpot(kind,x,z,1)){startBuild(kind,1,x,z);return true}
}
return false;
}
function aiExpand(){
// pick a field far from player units
let best=null,bs=-1;
for(const f of fields){
if(f.depleted)continue;
let minP=1e18;
for(const u of units)if(u.owner===0)minP=Math.min(minP,dist2(u.x,u.z,f.x,f.z));
for(const b of buildings)if(b.owner===0)minP=Math.min(minP,dist2(b.x,b.z,f.x,f.z));
const score=Math.sqrt(minP)-dist(f.x,f.z,terrain.p2.x,terrain.p2.z)*0.3;
if(score>bs){bs=score;best=f}
}
if(!best)return;
for(let i=0;i<10;i++){
const a=rand(TAU),r=rand(6,10);
const x=best.x+Math.sin(a)*r,z=best.z+Math.cos(a)*r;
if(validBuildSpot('nexus',x,z,1)){startBuild('nexus',1,x,z);return}
}
}
function aiScout(){
const workers=units.filter(u=>u.owner===1&&u.kind==='worker'&&u.cargo===0);
if(!workers.length)return;
const w=workers[randi(0,workers.length-1)];
// patrol contested fields & midpoints & player base vicinity
const pts=fields.map(f=>[f.x,f.z]);
pts.push([MAPW*CELL*.5,MAPD*CELL*.5]);
pts.push([terrain.p1.x+rand(-40,40),terrain.p1.z+rand(-40,40)]);
const p=pts[randi(0,pts.length-1)];
orderMove([w],p[0],p[1]);
}
function aiAttack(dt){
const A=game.ai;
const combat=units.filter(u=>u.owner===1&&u.kind!=='worker');
const threshold=[10,16,24,34][A.phase];
const cap=supplyCap(1);
const playerNexus=buildings.find(b=>b.owner===0&&b.kind==='nexus');
if(!playerNexus)return;
if(A.gathering){
// check arrival
let arrived=combat.filter(u=>dist(u.x,u.z,A.stage[0],A.stage[1])<70).length;
if(arrived>=combat.length*0.7&&combat.length>0){
A.gathering=false;
orderAttack(combat,playerNexus);
A.attackCd=80;
}
return;
}
if(combat.length>=threshold&&A.attackCd<=0&&combat.length<cap-4){
A.gathering=true;
A.stage=[playerNexus.x+rand(-60,60),playerNexus.z+rand(-60,60)];
for(const u of combat)orderAttackMove([u],A.stage[0],A.stage[1]);
}
}
/* ============================== rendering ============================== */
const COL_P=[0.34,0.8,1.0],COL_E=[1.0,0.45,0.28],COL_G=[0.45,0.48,0.52];
function tintCopy(srcName,color,outName){
const n=VBOs[srcName+'N'];
const v=new Float32Array(n*7);
v.set(GEOM[srcName]);
for(let i=0;i<n;i++){
v[i*7]=Math.min(3,v[i*7]*color[0]);v[i*7+1]=Math.min(3,v[i*7+1]*color[1]);v[i*7+2]=Math.min(3,v[i*7+2]*color[2]);
}
GEOM[outName]=v;
VBOs[outName]=gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER,VBOs[outName]);
gl.bufferData(gl.ARRAY_BUFFER,v,gl.STATIC_DRAW);
VBOs[outName+'N']=n;
}
function buildTints(){
for(const k of ['worker','mauler','lance','bulwark','specter']){
tintCopy('u_'+k,COL_P,'u_'+k+'_p');
tintCopy('u_'+k,COL_E,'u_'+k+'_e');
}
for(const k of ['nexus','foundry','hangar','turret']){
tintCopy('b_'+k,COL_P,'b_'+k+'_p');
tintCopy('b_'+k,COL_E,'b_'+k+'_e');
tintCopy('b_'+k,COL_G,'b_'+k+'_g');
}
tintCopy('fx_ring',COL_P,'fx_ring_c');
tintCopy('fx_ring',[1,0.6,0.3],'fx_ring_o');
tintCopy('fx_ring',[0.5,0.5,0.5],'fx_ring_g');
tintCopy('fx_ring',[0.4,1,0.55],'fx_ring_ok');
tintCopy('fx_ring',[1,0.35,0.3],'fx_ring_bad');
tintCopy('fx_ring',[1,1,1],'fx_ring_w');
tintCopy('fx_beam',COL_P,'fx_beam_c');
tintCopy('fx_beam',[1.4,0.9,0.3],'fx_beam_o');
tintCopy('sel_ring',[0.9,1,1],'sel_ring_w');
tintCopy('b_nexus',[0.6,1,0.8],'b_nexus_prev');
tintCopy('b_foundry',[0.6,1,0.8],'b_foundry_prev');
tintCopy('b_hangar',[0.6,1,0.8],'b_hangar_prev');
tintCopy('b_turret',[0.6,1,0.8],'b_turret_prev');
}
let fieldColT=0;
function render(t){
if(!gl||!game)return;
const W=innerWidth,H=innerHeight;
const asp=W/H;
const cp=Math.cos(cam.pitch),sp=Math.sin(cam.pitch);
const eyeY=terrainH(cam.x,cam.z)+4;
const eye=[cam.x+Math.sin(cam.yaw)*cp*cam.zoom,eyeY+sp*cam.zoom,cam.z+Math.cos(cam.yaw)*cp*cam.zoom];
const V=m4LookAt(eye,[cam.x,eyeY,cam.z],[0,1,0]);
const P=m4Persp(0.9,asp,2,1400);
PV=m4Mul(P,V);IPV=m4Inv(PV);
gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);
gl.uniformMatrix4fv(uni.uMVP,false,PV);
gl.uniform3f(uni.uLightDir,0.4,0.8,0.35);
gl.uniform3fv(uni.uCamPos,eye);
gl.uniform1f(uni.uTime,t);
const M0=m4T(0,0,0);
draw('terrain',M0,t);
draw('rocks',M0,t);
// fields
fieldColT-=1/60;
for(const f of fields){
const st=cellState(Math.floor(f.x/CELL),Math.floor(f.z/CELL));
if(st===0)continue;
let c;
if(f.depleted)c=[0.25,0.3,0.3];
else if(st===2)c=[0.5,1.4,1.7];
else c=[0.25,0.6,0.7];
if(fieldColT<=0){
const v=new Float32Array(f.n*7);
v.set(GEOM[f.key]);
for(let i=0;i<f.n;i++){v[i*7]=Math.min(3,v[i*7]*c[0]);v[i*7+1]=Math.min(3,v[i*7+1]*c[1]);v[i*7+2]=Math.min(3,v[i*7+2]*c[2])}
gl.bindBuffer(gl.ARRAY_BUFFER,f.buf);
gl.bufferSubData(gl.ARRAY_BUFFER,0,v);
}
gl.uniformMatrix4fv(uni.uModel,false,M0);
gl.bindBuffer(gl.ARRAY_BUFFER,f.buf);
gl.vertexAttribPointer(A_POS,3,gl.FLOAT,false,28,0);
gl.vertexAttribPointer(A_COL,4,gl.FLOAT,false,28,12);
gl.drawArrays(gl.TRIANGLES,0,f.n);
}
if(fieldColT<=0)fieldColT=0.4;
// buildings
for(const b of buildings){
const st=cellState(Math.floor(b.x/CELL),Math.floor(b.z/CELL));
if(b.owner===1&&st===0)continue;
let name;
if(b.owner===0)name='b_'+b.kind+'_p';
else name=st===2?'b_'+b.kind+'_e':'b_'+b.kind+'_g';
const prog=b.built?1:0.35+0.65*(1-b.buildT/b.buildTotal);
const M=m4Mul(m4T(b.x,terrainH(b.x,b.z),b.z),m4S(1,prog,1));
draw(name,M,t);
if(b.sel)draw('sel_ring_w',m4Mul(m4T(b.x,terrainH(b.x,b.z)+0.1,b.z),m4S(b.w*0.8,1,b.w*0.8)),t);
}
// units
for(const u of units){
if(u.dead)continue;
if(u.owner===1&&!canSee(u.x,u.z))continue;
const name='u_'+u.kind+'_'+(u.owner===0?'p':'e');
const M=m4Mul(m4T(u.x,terrainH(u.x,u.z)+u.alt+Math.sin(u.bob)*0.12,u.z),m4RY(u.heading));
draw(name,M,t);
if(u.sel)draw('sel_ring_w',m4Mul(m4T(u.x,terrainH(u.x,u.z)+u.alt+0.1,u.z),m4S(u.radius*1.4,1,u.radius*1.4)),t);
}
// effects
for(const e of effects){
const k=e.t/e.dur;
if(e.type==='beam'){
const dx=e.extra.tx-e.x,dz=e.extra.tz-e.z,d=Math.hypot(dx,dz)||1;
const M=m4Mul(m4T((e.x+e.extra.tx)/2,(e.y+e.extra.ty)/2,(e.z+e.extra.tz)/2),
m4Mul(m4RY(Math.atan2(dz,dx)),m4S(d,1,1)));
draw(e.extra.c[1]>1?'fx_beam_c':'fx_beam_o',M,t);
}else if(e.type==='ring'){
const s=e.extra.s*(0.4+k*1.2);
draw('fx_ring_'+(e.extra.c? (e.extra.c[1]>1.2?'ok':e.extra.c[2]>1.2?'c':e.extra.c[0]>1.2?'o':'g'):'w'),
m4Mul(m4T(e.x,e.y,e.z),m4S(s,1,s)),t);
}else if(e.type==='hit'){
draw('fx_beam_'+(e.extra.c[2]>1?'c':'o'),m4Mul(m4T(e.x,e.y,e.z),m4S(0.5,0.5,0.5)),t);
}else if(e.type==='boom'){
draw('fx_ring_g',m4Mul(m4T(e.x,e.y,e.z),m4S(1+k*8*e.extra.s,1,1+k*8*e.extra.s)),t);
draw('fx_ring_o',m4Mul(m4T(e.x,e.y+0.2,e.z),m4S(0.5+k*5*e.extra.s,1,0.5+k*5*e.extra.s)),t);
}
}
// placement preview
if(buildPreview){
const ok=buildPreview.valid;
const M=m4Mul(m4T(buildPreview.x,terrainH(buildPreview.x,buildPreview.z),buildPreview.z),
m4S(1,0.9,1));
draw('b_'+buildPreview.kind+'_prev',M,t);
draw(ok?'fx_ring_ok':'fx_ring_bad',
m4Mul(m4T(buildPreview.x,terrainH(buildPreview.x,buildPreview.z)+0.1,buildPreview.z),
m4S(BLD[buildPreview.kind].w*0.75,1,BLD[buildPreview.kind].w*0.75)),t);
}
}
let buildPreview=null;
/* ============================== camera & input ============================== */
function camUpdate(dt){
const k=Math.min(1,dt*6);
cam.x+=(cam.tgt.x-cam.x)*k;cam.z+=(cam.tgt.z-cam.z)*k;
cam.yaw+=(cam.tgt.yaw-cam.yaw)*k;cam.pitch+=(cam.tgt.pitch-cam.pitch)*k;cam.zoom+=(cam.tgt.zoom-cam.zoom)*k;
const pan=(keys['arrowleft']?-1:0)+(keys['arrowright']?1:0);
const panz=(keys['arrowup']?-1:0)+(keys['arrowdown']?1:0);
if(pan||panz){
const sp=cam.zoom*1.4*dt;
cam.tgt.x+=Math.sin(cam.yaw)*pan*sp+Math.cos(cam.yaw)*panz*sp;
cam.tgt.z+=Math.cos(cam.yaw)*pan*sp-Math.sin(cam.yaw)*panz*sp;
cam.follow=false;
}
// edge scroll
const M=24;
if(mouse.onGL&&started&&!paused){
if(mouse.x<M)cam.tgt.x-=cam.zoom*1.2*dt;
if(mouse.x>innerWidth-M)cam.tgt.x+=cam.zoom*1.2*dt;
if(mouse.y<M)cam.tgt.z-=cam.zoom*1.2*dt;
if(mouse.y>innerHeight-M)cam.tgt.z+=cam.zoom*1.2*dt;
}
if(keys['q']&&!keys['e'])cam.tgt.yaw+=dt*1.8;
if(keys['e']&&!keys['q'])cam.tgt.yaw-=dt*1.8;
if(cam.follow&&selection.length){
let cx=0,cz=0;for(const e of selection){cx+=e.x;cz+=e.z}
cam.tgt.x=cx/selection.length;cam.tgt.z=cz/selection.length;
}
cam.tgt.x=clamp(cam.tgt.x,10,MAPW*CELL-10);
cam.tgt.z=clamp(cam.tgt.z,10,MAPD*CELL-10);
cam.tgt.zoom=clamp(cam.tgt.zoom,22,150);
cam.tgt.pitch=clamp(cam.tgt.pitch,0.55,1.25);
}
let hudDirty=true;
function onKey(e,down){
const k=e.key.toLowerCase();
keys[k]=down;
if(!down||!started||gameOver)return;
if(k===' '){e.preventDefault();togglePause();return}
if(k==='escape'){clearSel();buildPreview=null;hideBuildMenu();document.getElementById('help').classList.remove('show');return}
if(k==='f1'){e.preventDefault();document.getElementById('help').classList.toggle('show');return}
if(k==='f'){cam.follow=!cam.follow;return}
if(k>='1'&&k<='9'){
if(e.shiftKey&&selection.length){
ctrlGroups[k]=selection.slice();
toast('Control group '+k+' set ('+selection.length+')');
return;
}
if(selection.length){
const g=ctrlGroups[k]||[];
selectList(g,true);
return;
}
return;
}
if(selection.length){
if(k==='a'){orderAttackMove(selection,mouse.wx,mouse.wz);return}
if(k==='s'){orderStop(selection);return}
if(k==='h'){orderHold(selection);return}
if(k==='q'){
const first=selection.find(e=>UNITS[e.kind]!==undefined);
if(first){
const same=units.filter(u=>u.kind===first.kind&&u.owner===0);
selectList(same,true);
}
return;
}
}
}
function togglePause(){
if(gameOver||!started)return;
paused=!paused;
document.getElementById('pauseveil').classList.toggle('show',paused);
}
let mmBase=null;
function buildMinimapBase(){
mmBase=document.createElement('canvas');
mmBase.width=200;mmBase.height=200;
const c=mmBase.getContext('2d');
const sx=200/(MAPW*CELL),sz=200/(MAPD*CELL);
for(let z=0;z<MAPD;z++)for(let x=0;x<MAPW;x++){
const h=terrain.h[z*MAPW+x];
const sol=terrain.solid[z*MAPW+x];
let r,g,b;
if(sol){r=20;g=26;b=34}
else if(h<3){r=14;g=34;b=24}
else if(h<12){r=18;g=40;b=26}
else if(h<24){r=34;g=34;b=22}
else{r=58;g=54;b=50}
c.fillStyle=`rgb(${r},${g},${b})`;
c.fillRect(x*CELL*sx,z*CELL*sz,CELL*sx+1,CELL*sz+1);
}
}
let mmT=0;
function renderMinimap(dt){
mmT-=dt;if(mmT>0)return;mmT=0.12;
const cv=document.getElementById('mm');
const c=cv.getContext('2d');
c.drawImage(mmBase,0,0);
const sx=200/(MAPW*CELL),sz=200/(MAPD*CELL);
// fields (explored+)
for(const f of fields){
const st=cellState(Math.floor(f.x/CELL),Math.floor(f.z/CELL));
if(st===0)continue;
c.fillStyle=f.depleted?'#333':st===2?'#6fe8ff':'#2a7a8f';
c.fillRect(f.x*sx-2,f.z*sz-2,4,4);
}
// buildings
for(const b of buildings){
const st=cellState(Math.floor(b.x/CELL),Math.floor(b.z/CELL));
if(b.owner===0)c.fillStyle='#5fd4ff';
else if(st===2)c.fillStyle='#ff7a4d';
else if(st===1)c.fillStyle='#8a8f96';
else continue;
c.fillRect(b.x*sx-2.5,b.z*sz-2.5,5,5);
}
// units (visible only)
for(const u of units){
if(!canSee(u.x,u.z))continue;
c.fillStyle=u.owner===0?'#9fe8ff':'#ff9a6d';
c.fillRect(u.x*sx-1.2,u.z*sz-1.2,2.5,2.5);
}
// fog
const img=c.getImageData(0,0,200,200);
const d=img.data;
for(let z=0;z<MAPD;z++)for(let x=0;x<MAPW;x++){
const st=game.fog[z*MAPW+x];
if(st===0)continue;
const a=st===1?170:0;
if(a===0)continue;
const px0=Math.floor(x*CELL*sx),px1=Math.floor((x+1)*CELL*sx);
const pz0=Math.floor(z*CELL*sz),pz1=Math.floor((z+1)*CELL*sz);
for(let py=pz0;py<pz1;py++)for(let px=px0;px<px1;px++){
if(px>=200||py>=200)continue;
const i=(py*200+px)*4;
d[i]=d[i]*(1-a/255);d[i+1]=d[i+1]*(1-a/255);d[i+2]=d[i+2]*(1-a/255);
}
}
c.putImageData(img,0,0);
// camera rect
const w=cam.zoom*1.7*sx,h=cam.zoom*1.7*sz;
c.strokeStyle='#fff';c.lineWidth=1;
c.strokeRect(cam.x*sx-w/2,cam.z*sz-h/2,w,h);
}
function mmToWorld(sx,sy){
const r=document.getElementById('mm').getBoundingClientRect();
const x=(sx-r.left)/200*MAPW*CELL;
const z=(sy-r.top)/200*MAPD*CELL;
return [clamp(x,0,MAPW*CELL),clamp(z,0,MAPD*CELL)];
}
function contextOrder(x,z,list){
// enemy target?
let tgt=null,bd=49;
for(const e of units)if(e.owner===1&&canSee(e.x,e.z)){
const d=dist2(x,z,e.x,e.z);if(d<bd){bd=d;tgt=e}
}
if(!tgt)for(const e of buildings)if(e.owner===1&&canSee(e.x,e.z)){
const d=dist2(x,z,e.x,e.z);if(d<16){tgt=e}
}
if(tgt){orderAttack(list,tgt);return}
const workers=list.filter(u=>u.kind==='worker');
const others=list.filter(u=>u.kind!=='worker');
if(workers.length){
let f=null,bd=64;
for(const fl of fields)if(!fl.depleted){
const d=dist2(x,z,fl.x,fl.z);if(d<bd){bd=d;f=fl}
}
if(f){orderGather(workers,f)}
else{
let st=null,bd=1e18;
for(const b of buildings)if(b.owner===0){
const d=dist2(x,z,b.x,b.z);if(d<bd){bd=d;st=b}
}
if(st&&bd<100)orderReturn(workers);
else orderMove(workers,x,z);
}
}
if(others.length)orderMove(others,x,z);
}
function pickAt(sx,sy){
// units first (screen space)
let best=null,bd=14;
for(const u of units){
if(u.owner===1&&!canSee(u.x,u.z))continue;
if(u.owner!==0)continue;
const p=project(u.x,terrainH(u.x,u.z)+u.alt+1,u.z);
if(!p)continue;
const d=Math.hypot(p[0]-sx,p[1]-sy);
if(d<bd){bd=d;best=u}
}
if(best)return best;
best=null;bd=20;
for(const b of buildings){
if(b.owner!==0)continue;
const p=project(b.x,terrainH(b.x,b.z)+2,b.z);
if(!p)continue;
const d=Math.hypot(p[0]-sx,p[1]-sy);
if(d<bd){bd=d;best=b}
}
return best;
}
function setupInput(){
const cv=document.getElementById('gl');
cv.addEventListener('mousedown',e=>{
if(!started||gameOver)return;
mouse.down=true;mouse.button=e.button;
mouse.sx=e.clientX;mouse.sy=e.clientY;mouse.dragging=false;
if(e.button===0)Audio2.init();
});
window.addEventListener('mousemove',e=>{
mouse.x=e.clientX;mouse.y=e.clientY;
mouse.onGL=e.target===cv;
if(started&&game){
const w=screenToWorld(mouse.x,mouse.y);
if(w){mouse.wx=w[0];mouse.wz=w[1]}
if(buildPreview){
buildPreview.x=mouse.wx;buildPreview.z=mouse.wz;
buildPreview.valid=validBuildSpot(buildPreview.kind,mouse.wx,mouse.wz,0);
}
}
if(mouse.down&&mouse.button===0&&started){
if(Math.hypot(e.clientX-mouse.sx,e.clientY-mouse.sy)>7)mouse.dragging=true;
if(mouse.dragging)boxSel={x0:mouse.sx,y0:mouse.sy,x1:e.clientX,y1:e.clientY};
}
});
window.addEventListener('mouseup',e=>{
if(!started)return;
const wasDrag=mouse.dragging;
if(mouse.button===0&&mouse.down){
if(wasDrag&&boxSel){
const x0=Math.min(boxSel.x0,boxSel.x1),x1=Math.max(boxSel.x0,boxSel.x1);
const y0=Math.min(boxSel.y0,boxSel.y1),y1=Math.max(boxSel.y0,boxSel.y1);
const found=[];
for(const u of units){
if(u.owner!==0)continue;
const p=project(u.x,terrainH(u.x,u.z)+u.alt+1,u.z);
if(p&&p[0]>=x0&&p[0]<=x1&&p[1]>=y0&&p[1]<=y1)found.push(u);
}
selectList(found,e.shiftKey);
}else{
const hit=pickAt(e.clientX,e.clientY);
if(hit)selectList([hit],e.shiftKey);
else if(!e.shiftKey)clearSel();
else{
// worker selected & empty ground → build menu
const w=screenToWorld(e.clientX,e.clientY);
if(w&&selection.some(u=>u.kind==='worker'))openBuildMenu(e.clientX,e.clientY,w);
}
}
}
if(mouse.button===2&&mouse.down&&started&&!gameOver){
const w=screenToWorld(e.clientX,e.clientY);
if(w){
if(buildPreview){
const b=startBuild(buildPreview.kind,0,w[0],w[1]);
if(!b)Audio2.err();
buildPreview=null;hideBuildMenu();
}else if(selection.length){
contextOrder(w[0],w[1],selection);
}
}
}
mouse.down=false;mouse.dragging=false;boxSel=null;
});
cv.addEventListener('contextmenu',e=>e.preventDefault());
cv.addEventListener('wheel',e=>{
e.preventDefault();
cam.tgt.zoom*=e.deltaY>0?1.12:0.89;
},{passive:false});
window.addEventListener('keydown',e=>onKey(e,true));
window.addEventListener('keyup',e=>onKey(e,false));
// minimap
const mm=document.getElementById('mm');
mm.addEventListener('mousedown',e=>{
e.stopPropagation();
const w=mmToWorld(e.clientX,e.clientY);
if(e.button===2&&selection.length){
contextOrder(w[0],w[1],selection);
}else{
cam.tgt.x=w[0];cam.tgt.z=w[1];cam.follow=false;
}
});
mm.addEventListener('contextmenu',e=>e.preventDefault());
// buttons
document.getElementById('btnStart').onclick=()=>{
Audio2.init();
document.getElementById('title').classList.add('hidden');
newGame();
started=true;
};
document.getElementById('btnRestart').onclick=()=>{
document.getElementById('endscreen').classList.add('hidden');
gameOver=false;paused=false;
document.getElementById('pauseveil').classList.remove('show');
newGame();
};
document.getElementById('btnPause').onclick=togglePause;
document.getElementById('btnMute').onclick=function(){
Audio2.muted=!Audio2.muted;
this.textContent=Audio2.muted?'🔇':'🔊';
};
document.getElementById('btnHelp').onclick=()=>document.getElementById('help').classList.toggle('show');
document.getElementById('helpbtn').onclick=()=>document.getElementById('help').classList.toggle('show');
document.getElementById('helpClose').onclick=()=>document.getElementById('help').classList.remove('show');
}
/* ============================== HUD ============================== */
function openBuildMenu(sx,sy,w){
const bm=document.getElementById('buildmenu');
bm.innerHTML='';
for(const k of BUILD_ORDER){
const d=BLD[k];
const btn=document.createElement('button');
btn.className='abtn';
btn.innerHTML=`<span><span class="nm">${d.name}</span><br><span class="cost">${d.cost} ◆</span> <span class="why">${d.sup?'+'+d.sup+' sup':''}</span></span>`;
btn.title=d.desc;
btn.onclick=ev=>{
ev.stopPropagation();
if(!canAfford(0,d.cost)){Audio2.err();toast('Not enough Crystal','bad');return}
buildPreview={kind:k,x:w[0],z:w[1],valid:validBuildSpot(k,w[0],w[1],0)};
};
bm.appendChild(btn);
}
bm.style.left=Math.min(sx,innerWidth-250)+'px';
bm.style.top=Math.min(sy,innerHeight-120)+'px';
bm.classList.add('show');
}
function hideBuildMenu(){document.getElementById('buildmenu').classList.remove('show')}
let lastPanelKey='';
function hudUpdate(dt){
if(!game)return;
// top bar
document.getElementById('resval').textContent=Math.floor(resOf(0));
const cap=supplyCap(0),used=supplyUsed(0);
const sv=document.getElementById('supval');
sv.innerHTML=`<b>${used}</b> / <b>${cap}</b> SUPPLY`;
sv.classList.toggle('low',used>=cap-2);
const inc=document.getElementById('incval');
inc.style.display='';
inc.innerHTML=`<span style="color:var(--ok)">▲</span><b>${(game.inc[0]/5).toFixed(1)}/s</b>`;
game.incT=(game.incT||0)-dt;
if(game.incT<=0){game.incT=5;game.inc=[0,0]}
document.getElementById('clock').textContent=fmtT(game.time);
// box overlay
if(boxSel){
document.getElementById('gl').style.boxShadow='inset 0 0 0 0 transparent';
}
// selection panel
const key=selection.map(e=>e.id).join(',')+selection.length;
if(hudDirty||key!==lastPanelKey){
lastPanelKey=key;hudDirty=false;
renderPanel();
}
renderMinimap(dt);
}
function renderPanel(){
const nameEl=document.getElementById('selname');
const infoEl=document.getElementById('selinfo');
const stateEl=document.getElementById('selstate');
const actEl=document.getElementById('selactions');
const hpWrap=document.getElementById('hpwrap');
const hpBar=document.getElementById('hpbar');
actEl.innerHTML='';
hpWrap.classList.add('hidden');
if(!selection.length){
nameEl.textContent='No selection';
infoEl.textContent=selection.length?'':'Right-click: order · select a worker to build';
stateEl.innerHTML='<span style="color:var(--dim)">Select units or a building. Right-click to issue orders.</span>';
return;
}
const single=selection.length===1?selection[0]:null;
const isBld=single&&BLD[single.kind]!==undefined;
const isUnit=single&&UNITS[single.kind]!==undefined;
if(single){
nameEl.textContent=(isBld?BLD[single.kind]:UNITS[single.kind]).name;
infoEl.textContent=isBld?`Building · ${BLD[single.kind].cost}◆`:`${UNITS[single.kind].sup} supply`;
const pct=single.hp/single.maxHp*100;
hpWrap.classList.remove('hidden');
hpBar.style.width=pct+'%';
hpBar.className=pct>55?'':pct>25?'mid':'low';
hpBar.id='hpbar';
}else{
const counts={};
for(const e of selection)if(UNITS[e.kind]!==undefined)counts[e.kind]=(counts[e.kind]||0)+1;
nameEl.textContent=selection.length+' units';
infoEl.textContent=Object.entries(counts).map(([k,v])=>UNITS[k].name+' ×'+v).join(' · ');
let hp=0,n=0;for(const e of selection)if(UNITS[e.kind]!==undefined){hp+=e.hp;e.maxHp&&(n+=e.maxHp)}
if(n){
const pct=hp/n*100;
hpWrap.classList.remove('hidden');
hpBar.style.width=pct+'%';
hpBar.className=pct>55?'':pct>25?'mid':'low';
hpBar.id='hpbar';
}
}
// state text
if(single&&isUnit){
let s='';
if(single.gatherField)s=single.cargo>0?'Returning crystal':'Harvesting crystal';
else if(single.order&&single.order.type==='attackmove')s='Attack-move';
else if(single.order&&single.order.type==='hold')s='Holding position';
else if(single.atkTarget)s='Attacking';
else if(single.state==='move')s='Moving';
else s='Idle';
stateEl.innerHTML=`<span class="ok">${s}</span> · HP ${Math.ceil(single.hp)}/${Math.ceil(single.maxHp)}`;
}
else if(single&&isBld){
if(!single.built)stateEl.innerHTML=`<span class="ok">Constructing… ${Math.ceil(100-single.buildT/single.buildTotal*100)}%</span>`;
else stateEl.innerHTML=`<span class="ok">Operational</span> · HP ${Math.ceil(single.hp)}/${Math.ceil(single.maxHp)}`;
}
else stateEl.innerHTML='';
// actions
if(single&&isBld&&single.built){
const b=single;
// train buttons
if(BLD[b.kind].train){
for(const k of Object.keys(BLD[b.kind].train)){
const d=UNITS[k];
const q=b.queue.length;
const err=supplyCap(0)-supplyUsed(0)<d.sup?'supply':(resOf(0)<d.cost?'resources':null);
const btn=document.createElement('button');
btn.className='abtn';
btn.disabled=!!err;
btn.innerHTML=`<span><span class="nm">${d.name}</span> <span class="hk">${q+1}</span><br><span class="cost">${d.cost}◆</span> <span class="why">${d.sup} sup · ${d.time}s${err?' · '+err:''}</span></span>`;
btn.onclick=()=>{const e=trainUnit(b,k);if(e){Audio2.err();toast(e[0].toUpperCase()+e.slice(1),'bad')}};
actEl.appendChild(btn);
}
}
// research (nexus)
if(b.kind==='nexus'){
for(const k of Object.keys(UPG)){
if(game.upgrades[0].includes(k))continue;
const d=UPG[k];
const busy=b.research?true:false;
const btn=document.createElement('button');
btn.className='abtn';
btn.disabled=busy||resOf(0)<d.cost;
btn.innerHTML=`<span><span class="nm">${d.name}</span><br><span class="cost">${d.cost}◆</span> <span class="why">${d.time}s · ${d.desc}</span></span>`;
btn.onclick=()=>{const e=research(b,k);if(e){Audio2.err();toast(e[0].toUpperCase()+e.slice(1),'bad')}};
actEl.appendChild(btn);
}
if(b.research){
const st=document.createElement('div');
st.style.cssText='width:100%;font-size:12px;color:var(--warn)';
st.textContent=`Researching ${UPG[b.research.key].name}… ${Math.ceil(b.research.t)}s`;
actEl.appendChild(st);
}
}
// queue with cancel
if(b.queue.length){
const qd=document.createElement('div');
qd.style.cssText='width:100%;font-size:11px;color:var(--dim)';
qd.innerHTML='Queue: '+b.queue.map((q,i)=>`${UNITS[q.kind].name} ${Math.ceil(q.t)}s`).join(' · ');
actEl.appendChild(qd);
const cx=document.createElement('button');
cx.className='abtn';
cx.innerHTML='<span class="nm">Cancel</span> <span class="why">full refund</span>';
cx.onclick=()=>{cancelQueue(b,0)};
actEl.appendChild(cx);
}
// rally for production buildings
if(BLD[b.kind].train){
const rb=document.createElement('button');
rb.className='abtn';
rb.innerHTML=`<span class="nm">Rally</span> <span class="hk">R</span>`;
rb.title='Set rally point to cursor';
rb.onclick=()=>{b.rally=[mouse.wx,mouse.wz];addEffect('ring',mouse.wx,mouse.wz,terrainH(mouse.wx,mouse.wz),.6,{s:2,c:[0.5,1,0.6]});Audio2.click()};
actEl.appendChild(rb);
}
}
if(selection.some(u=>UNITS[u.kind]!=='worker'&&u.kind!=='worker')||selection.length){
if(selection.length){
const am=document.createElement('button');
am.className='abtn';
am.innerHTML='<span><span class="nm">Attack Move</span> <span class="hk">A</span><br><span class="why">at cursor</span></span>';
am.onclick=()=>orderAttackMove(selection,mouse.wx,mouse.wz);
actEl.appendChild(am);
const st=document.createElement('button');
st.className='abtn';
st.innerHTML='<span><span class="nm">Stop</span> <span class="hk">S</span></span>';
st.onclick=()=>orderStop(selection);
actEl.appendChild(st);
const hd=document.createElement('button');
hd.className='abtn';
hd.innerHTML='<span><span class="nm">Hold</span> <span class="hk">H</span></span>';
hd.onclick=()=>orderHold(selection);
actEl.appendChild(hd);
}
}
}
function drawBox(){
// simple: use canvas 2d overlay via CSS? draw on minimap? -> draw on gl? use a div
const d=document.getElementById('boxdiv');
if(boxSel){
d.style.display='block';
d.style.left=Math.min(boxSel.x0,boxSel.x1)+'px';
d.style.top=Math.min(boxSel.y0,boxSel.y1)+'px';
d.style.width=Math.abs(boxSel.x1-boxSel.x0)+'px';
d.style.height=Math.abs(boxSel.y1-boxSel.y0)+'px';
}else d.style.display='none';
}
/* ============================== main loop ============================== */
let simGrid=new Map();
function buildSimGrid(){
simGrid.clear();
for(const u of units){
const k=((u.x/8)|0)+':'+((u.z/8)|0);
let a=simGrid.get(k);
if(!a){a=[];simGrid.set(k,a)}
a.push(u);
}
}
function separateFast(u){
const cx=(u.x/8)|0,cz=(u.z/8)|0;
for(let dz=-1;dz<=1;dz++)for(let dx=-1;dx<=1;dx++){
const a=simGrid.get((cx+dx)+':'+(cz+dz));
if(!a)continue;
for(const v of a){
if(v===u||v.dead)continue;
const ddx=v.x-u.x,ddz=v.z-u.z;
const d2=ddx*ddx+ddz*ddz;
const minD=(u.radius+v.radius)*0.75;
if(d2<minD*minD&&d2>0.0001){
const d=Math.sqrt(d2);
const push=(minD-d)/d*0.4;
u.x-=ddx*push;u.z-=ddz*push;
}
}
}
}
let last=0;
function frame(ts){
requestAnimationFrame(frame);
const dt=Math.min(0.05,(ts-last)/1000||0.016);last=ts;
if(started&&game&&!paused&&!gameOver){
game.time+=dt;
buildSimGrid();
const us=units.slice();
for(const u of us)if(!u.dead){
updateUnit(u,dt);
if(!u.dead)separateFast(u);
}
for(const b of buildings)updateBuilding(b,dt);
updateEffects(dt);
fogUpdate();
aiUpdate(dt);
checkWin();
}
camUpdate(dt);
render(ts/1000);
hudUpdate(dt);
drawBox();
}
/* ============================== boot ============================== */
window.addEventListener('DOMContentLoaded',()=>{
if(!glInit())return;
buildMeshes();
buildTints();
setupInput();
requestAnimationFrame(frame);
});
</script>
</body>
</html>
<!-- agent-meta {"model":"ukisai/Swift-Qwen3.8-27B-GGUF","provider":"brigid","persona":"full","sessionId":"89b7921e-340e-4b93-abe2-b2670ae03bb1","tokensIn":2482452,"tokensOut":68860,"tokensTotal":2551312,"cost":0,"turns":26,"toolCalls":25,"failedToolCalls":3,"timestamp":"2026-09-30T01:15:50.938Z"} -->