RTS
Qwen 3.8 · 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 93,416 bytes · SHA-256 e34efe702092
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FRONTIER WAR — Kharos-9</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; user-select:none; -webkit-user-select:none; }
html,body { width:100%; height:100%; overflow:hidden; background:#04070c; font-family:"Segoe UI",system-ui,sans-serif; color:#cfe9ff; }
#wrap { position:fixed; inset:0; }
canvas { position:absolute; inset:0; width:100%; height:100%; display:block; }
#ovc { pointer-events:none; }
#hud { position:absolute; inset:0; pointer-events:none; font-size:13px; }
.panel { background:rgba(8,16,26,0.86); border:1px solid rgba(90,200,255,0.28); box-shadow:0 0 18px rgba(0,40,80,0.35) inset; clip-path:polygon(10px 0,100% 0,100% calc(100% - 10px),calc(100% - 10px) 100%,0 100%,0 10px); }
#topbar { position:absolute; top:8px; left:8px; right:8px; display:flex; align-items:center; gap:10px; height:46px; padding:0 12px; pointer-events:auto; }
.stat { display:flex; align-items:baseline; gap:6px; padding:4px 10px; background:rgba(6,14,24,0.7); border:1px solid rgba(90,200,255,0.18); }
.stat .ic { color:#59d8ff; font-size:15px; }
.stat b { font-family:Consolas,monospace; font-size:17px; color:#eaffff; min-width:52px; }
.stat i { font-style:normal; font-size:10px; letter-spacing:2px; color:#6fa8c8; }
#alertbox { flex:1; display:flex; flex-direction:column; justify-content:center; gap:3px; overflow:hidden; min-height:0; }
.alert { font-size:12px; padding:2px 8px; background:rgba(20,10,4,0.8); border-left:3px solid #ffb347; color:#ffd9a0; animation:alfade 4s forwards; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.alert.bad { border-left-color:#ff5566; color:#ffb0b8; background:rgba(30,6,10,0.85); }
.alert.good { border-left-color:#59d8ff; color:#c8ecff; }
@keyframes alfade { 0%{opacity:0; transform:translateX(-8px);} 6%{opacity:1; transform:none;} 80%{opacity:1;} 100%{opacity:0;} }
#topbtns { display:flex; gap:6px; }
button { pointer-events:auto; cursor:pointer; background:rgba(12,26,40,0.9); color:#bfe6ff; border:1px solid rgba(90,200,255,0.35); padding:6px 10px; font-size:13px; letter-spacing:1px; clip-path:polygon(6px 0,100% 0,100% calc(100% - 6px),calc(100% - 6px) 100%,0 100%,0 6px); }
button:hover { background:rgba(30,60,90,0.95); color:#fff; }
button:disabled { opacity:0.4; cursor:default; }
#bottombar { position:absolute; left:8px; right:8px; bottom:8px; display:flex; align-items:stretch; gap:8px; height:112px; pointer-events:none; }
#selinfo { flex:1; padding:8px 12px; pointer-events:auto; overflow:hidden; }
#selinfo .t { font-size:14px; letter-spacing:1px; color:#eaffff; margin-bottom:4px; }
#selinfo .row { display:flex; align-items:center; gap:8px; margin-top:3px; font-size:12px; color:#9fd0ea; }
.hpbar { flex:1; height:8px; background:rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.15); position:relative; }
.hpbar i { position:absolute; left:0; top:0; bottom:0; background:linear-gradient(90deg,#37e08a,#7dffb0); }
.hpbar i.mid { background:linear-gradient(90deg,#ffb347,#ffd9a0); }
.hpbar i.low { background:linear-gradient(90deg,#ff5566,#ff9aa4); }
#actions { display:flex; flex-direction:column; gap:4px; justify-content:center; pointer-events:auto; width:150px; }
#actions button { width:100%; text-align:left; padding:5px 8px; font-size:12px; }
#actions button small { float:right; color:#6fa8c8; font-size:10px; }
#mmwrap { position:relative; pointer-events:auto; align-self:center; width:106px; height:106px; }
#mmc { width:100px; height:100px; border:1px solid rgba(90,200,255,0.4); background:#000; cursor:crosshair; }
#mmlabel { position:absolute; top:2px; left:4px; font-size:9px; letter-spacing:2px; color:rgba(160,220,255,0.6); pointer-events:none; }
#sidepanel { position:absolute; left:8px; bottom:128px; width:300px; max-height:46vh; overflow-y:auto; padding:10px; pointer-events:auto; display:none; }
#sidepanel h3 { font-size:11px; letter-spacing:2px; color:#59d8ff; margin-bottom:8px; }
.bbtn { display:flex; align-items:center; gap:8px; width:100%; margin-bottom:5px; padding:6px 8px; text-align:left; }
.bbtn .nm { flex:1; font-size:12px; color:#dff2ff; }
.bbtn .nm small { display:block; color:#7fa8c0; font-size:10px; }
.bbtn .cost { font-family:Consolas,monospace; font-size:12px; color:#ffd27a; }
.bbtn .sup { font-family:Consolas,monospace; font-size:11px; color:#8fd8ff; }
.qrow { display:flex; align-items:center; gap:6px; margin-bottom:4px; font-size:12px; }
.qrow .bar { flex:1; height:10px; background:rgba(255,255,255,0.07); border:1px solid rgba(255,255,255,0.14); position:relative; }
.qrow .bar i { position:absolute; inset:0 auto 0 0; background:linear-gradient(90deg,#2f88c8,#59d8ff); }
.qrow .pct { font-family:Consolas,monospace; width:34px; text-align:right; color:#9fd0ea; }
.qrow button { padding:1px 6px; font-size:11px; }
#screen { position:absolute; inset:0; display:none; align-items:center; justify-content:center; background:rgba(3,6,10,0.82); pointer-events:auto; z-index:10; }
#screen .box { width:min(720px,92vw); max-height:92vh; overflow-y:auto; padding:30px 36px; }
#screen h1 { font-size:34px; letter-spacing:8px; color:#eaffff; text-shadow:0 0 24px rgba(89,216,255,0.6); margin-bottom:2px; }
#screen h1 span { color:#59d8ff; }
#screen .sub { letter-spacing:4px; font-size:12px; color:#7fa8c0; margin-bottom:16px; }
#screen p { font-size:13px; line-height:1.55; color:#b8d8ee; margin-bottom:10px; }
#screen .cols { display:flex; gap:18px; flex-wrap:wrap; }
#screen .col { flex:1; min-width:240px; }
#screen h4 { font-size:11px; letter-spacing:2px; color:#59d8ff; margin:10px 0 6px; }
#screen ul { list-style:none; font-size:12px; line-height:1.7; color:#a8c8de; }
#screen ul b { color:#e8f6ff; font-weight:600; }
#screen .big { font-size:16px; padding:12px 26px; margin-top:16px; letter-spacing:3px; }
#screen .win { color:#7dffb0; text-shadow:0 0 20px rgba(80,255,160,0.5); }
#screen .lose { color:#ff8896; text-shadow:0 0 20px rgba(255,80,100,0.5); }
#screen .stats { font-family:Consolas,monospace; font-size:13px; color:#9fd0ea; line-height:1.8; margin-top:10px; }
.k { display:inline-block; background:rgba(90,200,255,0.14); border:1px solid rgba(90,200,255,0.3); padding:0 5px; font-family:Consolas,monospace; font-size:11px; color:#dff2ff; border-radius:2px; }
::-webkit-scrollbar { width:8px; } ::-webkit-scrollbar-thumb { background:rgba(90,200,255,0.3); } ::-webkit-scrollbar-track { background:rgba(0,0,0,0.3); }
</style>
</head>
<body>
<div id="wrap">
<canvas id="glc"></canvas>
<canvas id="ovc"></canvas>
<div id="hud">
<div id="topbar" class="panel">
<div class="stat"><span class="ic">◆</span><b id="fluxv">0</b><i>FLUX</i></div>
<div class="stat"><span class="ic">▲</span><b id="supv">0/0</b><i>SUPPLY</i></div>
<div class="stat"><span class="ic">◷</span><b id="timev">0:00</b><i>TIME</i></div>
<div id="alertbox"></div>
<div id="topbtns">
<button id="btnpause" title="Pause (Space)">❚❚</button>
<button id="btnmute" title="Mute (M)">♪</button>
<button id="btnhelp" title="Help (F1)">?</button>
</div>
</div>
<div id="bottombar">
<div id="selinfo" class="panel"></div>
<div id="actions"></div>
<div id="mmwrap"><canvas id="mmc" width="100" height="100"></canvas><div id="mmlabel">TACTICAL MAP</div></div>
</div>
<div id="sidepanel" class="panel"></div>
</div>
<div id="screen"></div>
</div>
<script>
'use strict';
/* ============================== HELPERS ============================== */
const clamp=(v,a,b)=>v<a?a:(v>b?b:v);
const lerp=(a,b,t)=>a+(b-a)*t;
const dist2=(ax,az,bx,bz)=>{const dx=ax-bx,dz=az-bz;return dx*dx+dz*dz;};
const dist=(ax,az,bx,bz)=>Math.sqrt(dist2(ax,az,bx,bz));
function 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;}}
let R=mulberry32(1);
const rr=(a,b)=>a+R()*(b-a);
function angLerp(a,b,t){let d=(b-a)%(Math.PI*2);if(d>Math.PI)d-=Math.PI*2;if(d<-Math.PI)d+=Math.PI*2;return a+d*t;}
/* mat4 (column major) */
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(ex,ey,ez,tx,ty,tz){
let zx=ex-tx,zy=ey-ty,zz=ez-tz;let l=1/Math.hypot(zx,zy,zz);zx*=l;zy*=l;zz*=l;
let xx=1*zz-0*zy, xy=0*zx-0*zz, xz=0*zy-1*zx; // up(0,1,0) x z
l=1/(Math.hypot(xx,xy,xz)||1); xx*=l;xy*=l;xz*=l;
const yx=zy*xz-zz*xy, yy=zz*xx-zx*xz, yz=zx*xy-zy*xx;
return[xx,yx,zx,0, xy,yy,zy,0, xz,yz,zz,0, -(xx*ex+xy*ey+xz*ez), -(yx*ex+yy*ey+yz*ez), -(zx*ex+zy*ey+zz*ez), 1];
}
function m4Mul(a,b){const o=new Array(16);for(let c=0;c<4;c++)for(let r=0;r<4;r++){o[c*4+r]=a[r]*b[c*4]+a[4+r]*b[c*4+1]+a[8+r]*b[c*4+2]+a[12+r]*b[c*4+3];}return o;}
function m4Xform(m,x,y,z){return[m[0]*x+m[4]*y+m[8]*z+m[12], m[1]*x+m[5]*y+m[9]*z+m[13], m[2]*x+m[6]*y+m[10]*z+m[14], m[3]*x+m[7]*y+m[11]*z+m[15]];}
/* ============================== AUDIO ============================== */
let AC=null, master=null, muted=false, lastShot=0, lastBoom=0;
function audioInit(){ if(AC)return; try{ AC=new (window.AudioContext||window.webkitAudioContext)(); master=AC.createGain(); master.gain.value=0.5; master.connect(AC.destination);}catch(e){} }
function beep(f,d,type,vol,slide){ if(!AC||muted)return; const t=AC.currentTime; const o=AC.createOscillator(),g=AC.createGain(); o.type=type||'sine'; o.frequency.setValueAtTime(f,t); if(slide)o.frequency.exponentialRampToValueAtTime(Math.max(30,f+slide),t+d); g.gain.setValueAtTime(vol||0.1,t); g.gain.exponentialRampToValueAtTime(0.001,t+d); o.connect(g);g.connect(master); o.start(t);o.stop(t+d+0.02);}
function noiseBurst(d,vol,freq){ if(!AC||muted)return; const t=AC.currentTime; const n=AC.sampleRate*d|0; const buf=AC.createBuffer(1,n,AC.sampleRate); const ch=buf.getChannelData(0); for(let i=0;i<n;i++)ch[i]=(Math.random()*2-1)*(1-i/n); const s=AC.createBufferSource(); s.buffer=buf; const f=AC.createBiquadFilter(); f.type='lowpass'; f.frequency.value=freq||1000; const g=AC.createGain(); g.gain.value=vol||0.15; s.connect(f);f.connect(g);g.connect(master); s.start(t); }
const SFX={
select:()=>beep(880,0.05,'sine',0.06),
move:()=>beep(520,0.06,'triangle',0.08),
error:()=>beep(150,0.16,'sawtooth',0.1,-60),
build:()=>noiseBurst(0.14,0.08,700),
complete:()=>{beep(660,0.07,'sine',0.1);setTimeout(()=>beep(990,0.12,'sine',0.1),70);},
unit:()=>beep(740,0.06,'triangle',0.08),
harvest:()=>beep(1250,0.04,'sine',0.04),
shot:(d)=>{const n=performance.now(); if(n-lastShot>60){lastShot=n;noiseBurst(0.05,0.05,2200);}},
boom:(d)=>{const n=performance.now(); if(n-lastBoom>90){lastBoom=n;noiseBurst(0.35,0.16,320);beep(80,0.3,'sine',0.12,-40);}},
alert:()=>{beep(440,0.12,'square',0.09);setTimeout(()=>beep(560,0.16,'square',0.09),130);},
research:()=>{beep(980,0.08,'sine',0.08);setTimeout(()=>beep(1320,0.14,'sine',0.08),90);},
click:()=>beep(660,0.03,'sine',0.05),
};
function toggleMute(){ muted=!muted; if(master)master.gain.value=muted?0:0.5; document.getElementById('btnmute').textContent=muted?'✕':'♪'; return muted; }
/* ============================== CONSTANTS ============================== */
const MAP=256, FOGY=0.06, NRES=64, NCELL=MAP/NRES;
const SIDE_P=0, SIDE_E=1;
const PAL={
0:{body:[0.30,0.85,0.98], dark:[0.10,0.42,0.55], glow:[0.45,1.0,1.0], accent:[0.7,1.0,1.0]},
1:{body:[0.95,0.34,0.45], dark:[0.48,0.13,0.22], glow:[1.0,0.5,0.62], accent:[1.0,0.72,0.72]},
};
PAL.p=PAL[0]; PAL.e=PAL[1];
const DMG={ kinetic:{light:1.1,heavy:0.5,aero:1.0}, energy:{light:1.0,heavy:1.2,aero:1.0} };
const UT={
worker:{name:'Harvester Drone',cost:50,supply:1,hp:45,speed:6.0,vision:8,aggro:0,armor:'light',fly:false,r:0.7,buildT:8,desc:'Gathers flux. Build structures.'},
blade:{name:'Razor Skirmisher',cost:45,supply:1,hp:75,speed:7.2,vision:9,aggro:9,armor:'light',fly:false,r:0.8,buildT:9,dmg:9,range:1.8,rate:1.3,dmgType:'kinetic',targets:'ground',desc:'Cheap melee. Fast, fragile, swarms.'},
lancer:{name:'Pulse Lancer',cost:85,supply:1,hp:60,speed:5.8,vision:10,aggro:10,armor:'light',fly:false,r:0.8,buildT:13,dmg:11,range:10,rate:1.0,dmgType:'energy',targets:'both',desc:'Ranged, anti-air. Beats heavies.'},
goliath:{name:'Goliath Juggernaut',cost:220,supply:3,hp:420,speed:3.4,vision:10,aggro:9,armor:'heavy',fly:false,r:1.5,buildT:26,dmg:32,range:2.6,rate:0.7,dmgType:'kinetic',targets:'ground',desc:'Heavy armor. Resists kinetic. Needs research.'},
striker:{name:'Striker Wraith',cost:130,supply:2,hp:95,speed:11,vision:12,aggro:12,armor:'aero',fly:true,r:1.0,buildT:16,dmg:13,range:8,rate:1.0,dmgType:'energy',targets:'both',desc:'Flying. Crosses terrain. Anti-air too.'},
};
const BT={
hq:{name:'Command Spire',cost:300,hp:650,supply:10,r:4.2,buildT:35,returns:true,produces:['worker'],desc:'Returns flux, +10 supply, trains Drones.'},
beacon:{name:'Flux Beacon',cost:100,hp:130,supply:25,r:2.2,buildT:12,returns:true,desc:'+25 supply. Flux drop-off near deposits.'},
forge:{name:'Forge Bay',cost:160,hp:320,supply:0,r:3.0,buildT:20,produces:['blade','lancer','goliath'],desc:'Trains Skirmishers, Lancers, Juggernauts.'},
hangar:{name:'Wraith Hangar',cost:200,hp:320,supply:0,r:3.0,buildT:25,produces:['striker'],desc:'Trains Striker Wraiths (air).'},
lab:{name:'Research Spire',cost:180,hp:220,supply:0,r:2.8,buildT:22,research:true,desc:'Researches upgrades.'},
turret:{name:'Pulse Turret',cost:120,hp:260,supply:0,r:1.8,buildT:12,dmg:16,range:14,rate:0.8,dmgType:'kinetic',targets:'both',desc:'Defense. Anti-air + anti-ground.'},
};
const UP={
harvest:{name:'Harvester Boost',cost:100,time:20,desc:'+50% flux per trip'},
overdrive:{name:'Overdrive Cores',cost:150,time:30,desc:'+30% unit damage'},
plating:{name:'Plasma Plating',cost:150,time:30,desc:'-20% damage taken'},
ion:{name:'Ion Thrusters',cost:150,time:30,desc:'+25% unit speed'},
airsup:{name:'Air Superiority',cost:150,time:30,desc:'+50% Striker damage'},
heavyarmor:{name:'Heavy Plating',cost:200,time:40,desc:'Unlocks Goliath Juggernaut'},
};
const FLY_H=4.5;
</script>
<script>
/* ============================== WORLD ============================== */
let heightData=null, heightPx=null;
let obstacles=[], resources=[];
let fogP=null, fogE=null, fogPx=null;
let exploredP=0, exploredE=0;
const visP=new Uint8Array(NRES*NRES), visE=new Uint8Array(NRES*NRES);
const lastSeenP=new Float32Array(NRES*NRES), lastSeenE=new Float32Array(NRES*NRES);
function genWorld(){
R=mulberry32(1337+Math.floor(Math.random()*9999));
heightPx=new Uint8Array(MAP*MAP*4);
const blobs=[]; for(let i=0;i<26;i++)blobs.push({x:R()*MAP,y:R()*MAP,r:rr(14,46),h:rr(0.6,1.6),f:R()<0.5?-1:1});
const ridgeX=MAP/2+rr(-20,20);
for(let y=0;y<MAP;y++)for(let x=0;x<MAP;x++){
let h=0;
for(const b of blobs){const dx=x-b.x,dy=y-b.y;const d2=dx*dx+dy*dy; if(d2<b.r*b.r){const d=Math.sqrt(d2); h+=b.h*b.f*(1-d/b.r);}}
h+=0.9*Math.exp(-((x-ridgeX)*(x-ridgeX))/(2*22*22));
h=clamp(h,0,2.2);
const i=(y*MAP+x)*4;
const v=h/2.2;
heightPx[i]= 72+ v*90 + (R()-0.5)*8;
heightPx[i+1]= 92+ v*110+(R()-0.5)*8;
heightPx[i+2]= 108+ v*100+(R()-0.5)*8;
heightPx[i+3]=250; // <255 so progFog's "c.a>0.99 discard" doesn't cull terrain
}
heightData=new Float32Array(MAP*MAP);
for(let i=0;i<MAP*MAP;i++)heightData[i]=(heightPx[i*4+1]/255)*2.2;
const pStart={x:36,z:MAP/2}, eStart={x:MAP-36,z:MAP/2};
obstacles=[];
const clearOf=(x,z)=>{ if(x<18||z<18||x>MAP-18||z>MAP-18)return false;
if(dist(x,z,pStart.x,pStart.z)<30||dist(x,z,eStart.x,eStart.z)<30)return false; return true; };
for(let i=0;i<30;i++){
const x=rr(20,MAP-20),z=rr(20,MAP-20);
if(!clearOf(x,z))continue;
let ok=true; for(const o of obstacles) if(dist(x,z,o.x,o.z)<o.r+7){ok=false;break;}
if(!ok)continue;
obstacles.push({x,z,r:rr(2.4,4.6),type:'rock',h:rr(2,4.5)});
}
for(const wx of [MAP*0.35, MAP*0.65]){
const gapZ=MAP/2+rr(-40,40), gapLen=16;
for(let z=14;z<MAP-14;z+=3){
if(Math.abs(z-gapZ)<gapLen)continue;
if(dist(wx,z,pStart.x,pStart.z)<24||dist(wx,z,eStart.x,eStart.z)<24)continue;
obstacles.push({x:wx+rr(-2,2),z,r:rr(1.6,2.2),type:'wall',h:rr(3,4.5)});
}
}
resources=[];
const tryCluster=(x,z,n)=>{
for(let i=0;i<n;i++){ const a=R()*Math.PI*2, d=rr(3,15);
const nx=clamp(x+Math.cos(a)*d,10,MAP-10), nz=clamp(z+Math.sin(a)*d,10,MAP-10);
const amt=rr(140,220);
resources.push({x:nx,z:nz,amt,max:amt,visible:false});
}};
tryCluster(24,MAP/2,6);
tryCluster(MAP-24,MAP/2,6);
tryCluster(MAP/2,MAP/2,8);
tryCluster(MAP/2,22,6); tryCluster(MAP/2,MAP-22,6);
tryCluster(MAP*0.3,MAP*0.25,5); tryCluster(MAP*0.7,MAP*0.75,5);
tryCluster(MAP*0.72,MAP*0.22,5); tryCluster(MAP*0.28,MAP*0.78,5);
fogP=new Uint8Array(NRES*NRES); fogE=new Uint8Array(NRES*NRES);
exploredP=0; exploredE=0;
visP.fill(0); visE.fill(0); lastSeenP.fill(0); lastSeenE.fill(0);
fogPx=new Uint8Array(NRES*NRES*4);
return {pStart,eStart};
}
function hAt(x,z){ x=clamp(Math.floor(x),0,MAP-1); z=clamp(Math.floor(z),0,MAP-1); return heightData[z*MAP+x]; }
function blockedAt(x,z,r){
if(x<r||z<r||x>MAP-r||z>MAP-r)return true;
for(const o of obstacles){ const rr2=o.r+r*0.8; if(dist2(x,z,o.x,o.z)<rr2*rr2)return true; }
return false;
}
function placeableBuilding(x,z,r,side){
if(x<r+2||z<r+2||x>MAP-r-2||z>MAP-r-2)return false;
for(const o of obstacles){ const rr2=o.r+r*0.9; if(dist2(x,z,o.x,o.z)<rr2*rr2)return false; }
for(const b of buildings){ if(b.side!==side||b.hp<=0)continue; const rr2=b.r+r*0.95; if(dist2(x,z,b.x,b.z)<rr2*rr2)return false; }
return true;
}
function reveal(side,x,z,rad){
const fog=side===SIDE_P?fogP:fogE, vis=side===SIDE_P?visP:visE, seen=side===SIDE_P?lastSeenP:lastSeenE;
let ex=side===SIDE_P?exploredP:exploredE;
const c0x=clamp(Math.floor((x-rad)/NCELL),0,NRES-1), c1x=clamp(Math.floor((x+rad)/NCELL),0,NRES-1);
const c0z=clamp(Math.floor((z-rad)/NCELL),0,NRES-1), c1z=clamp(Math.floor((z+rad)/NCELL),0,NRES-1);
const r2=rad*rad;
for(let cz=c0z;cz<=c1z;cz++)for(let cx=c0x;cx<=c1x;cx++){
const px=(cx+0.5)*NCELL, pz=(cz+0.5)*NCELL;
if(dist2(px,pz,x,z)<=r2){ const i=cz*NRES+cx; if(!vis[i]){vis[i]=1; if(!fog[i]){fog[i]=1;ex++;}} seen[i]=gameTime; }
}
if(side===SIDE_P)exploredP=ex;else exploredE=ex;
}
function fogDraw(){
const d=fogPx;
for(let i=0;i<NRES*NRES;i++){
const o=i*4;
if(fogP[i]){ d[o]=0; d[o+1]=0; d[o+2]=0; d[o+3]=visP[i]?0:150; }
else { d[o]=6; d[o+1]=9; d[o+2]=14; d[o+3]=255; }
}
}
/* ============================== GEOMETRY ============================== */
function box(w,h,d){
const x=w/2,y=h/2,z=d/2;
const P=[[ -x,-y,-z, 0,0,1], [ x,-y,-z, 1,0,1], [ x,y,-z, 1,1,1], [-x,y,-z, 0,1,1],
[ x,-y, z, 0,0,1], [ x,y, z, 1,0,1], [-x,y, z, 1,1,1], [-x,-y, z, 0,1,1],
[ x,-y, z, 0,0,1], [ x, y, z, 1,0,1], [ x,y,-z, 1,1,1], [ x,-y,-z, 0,1,1],
[-x,-y, z, 0,0,1], [-x, y, z, 1,0,1], [-x,y,-z, 1,1,1], [-x,-y,-z, 0,1,1],
[ x, y, z, 0,0,1], [-x, y, z, 1,0,1], [-x,y,-z, 1,1,1], [ x,y,-z, 0,1,1],
[ x,-y,-z, 0,0,1], [-x,-y,-z, 1,0,1], [-x,y,-z, 1,1,1], [ x,y,-z, 0,1,1]];
const F=[[0,1,2,3],[4,5,6,7],[8,9,10,11],[12,13,14,15],[16,17,18,19],[20,21,22,23]];
const pos=[],idx=[];
F.forEach((f,fi)=>{ const b=pos.length/6;
for(const vi of f){const p=P[vi];pos.push(p[0],p[1],p[2],p[3],p[4],p[5]);}
idx.push(b,b+1,b+2,b,b+2,b+3);
});
return{pos:new Float32Array(pos),uv:null,idx:new Uint16Array(idx)};
}
function prism(r1,r2,h,seg){
const pos=[],idx=[];
for(let i=0;i<seg;i++){const a=i/seg*Math.PI*2;pos.push(Math.cos(a)*r1,0,Math.sin(a)*r1,0.5,0.5,1);}
for(let i=0;i<seg;i++){const a=i/seg*Math.PI*2;pos.push(Math.cos(a)*r2,h,Math.sin(a)*r2,0.5,0.5,1);}
for(let i=0;i<seg;i++){const j=(i+1)%seg;idx.push(i,j,seg+j, i,seg+j,seg+i);}
return{pos:new Float32Array(pos),uv:null,idx:new Uint16Array(idx)};
}
function shiftY(g,dy,dz){
const p=new Float32Array(g.pos);
for(let i=1;i<p.length;i+=6){p[i]+=dy;p[i+2]+=dz||0;}
return{pos:p,uv:g.uv,idx:g.idx};
}
function mergeGeos(list){
const list2=list.filter(Boolean);
let n=0; for(const g of list2)n+=g.pos.length/6;
const pos=new Float32Array(n*6), uv=new Float32Array(n*2); let idx=null; let off=0;
for(const g of list2){
pos.set(g.pos,off*6); if(g.uv)uv.set(g.uv,off*2);
const li=new Uint16Array(g.idx.length);
for(let i=0;i<g.idx.length;i++)li[i]=g.idx[i]+off;
idx=idx?concatU16(idx,li):li; off+=g.pos.length/6;
}
return{pos,uv,idx};
}
function concatU16(a,b){const o=new Uint16Array(a.length+b.length);o.set(a);o.set(b,a.length);return o;}
function diamond(r,seg){
const pos=[],uv=[],idx=[];
pos.push(0,0,0,0.5,0.5,1); uv.push(0.5,0.5);
for(let i=0;i<seg;i++){const a=i/seg*Math.PI*2;pos.push(Math.cos(a)*r,0,Math.sin(a)*r,0.5+Math.cos(a)*0.5,0.5+Math.sin(a)*0.5,1);uv.push(0.5,0.5);}
for(let i=0;i<seg;i++){const j=(i+1)%seg;idx.push(0,i+1,j+1);}
return{pos:new Float32Array(pos),uv:new Float32Array(uv),idx:new Uint16Array(idx)};
}
const GEO={};
function buildGeos(){
const dia=diamond(1,8);
GEO.worker=mergeGeos([
{pos:new Float32Array([0,0.15,0.7,0,1,0, 0,0.15,-0.7,0,1,0, -0.55,0.15,0,0,1,0, 0.55,0.15,0,0,1,0]),uv:new Float32Array(8),idx:new Uint16Array([0,1,2,2,1,3])},
prism(0.55,0.42,0.5,6),
shiftY(prism(0.3,0.12,0.5,4),0.5,0.2)
]);
GEO.blade=mergeGeos([
{pos:new Float32Array([0,0.2,0.8,0,1,0, 0,0.2,-0.8,0,1,0, -0.6,0.2,0,0,1,0, 0.6,0.2,0,0,1,0]),uv:new Float32Array(8),idx:new Uint16Array([0,1,2,2,1,3])},
box(0.7,0.5,1.1),
shiftY(box(0.3,0.45,0.75),0.48,0.1)
]);
GEO.lancer=mergeGeos([
{pos:new Float32Array([0,0.18,0.9,0,1,0, 0,0.18,-0.9,0,1,0, -0.65,0.18,0,0,1,0, 0.65,0.18,0,0,1,0]),uv:new Float32Array(8),idx:new Uint16Array([0,1,2,2,1,3])},
box(0.6,0.42,1.0),
shiftY(prism(0.4,0.28,0.45,5),0.42,0),
shiftY(box(0.28,0.2,0.95),0.55,0.35)
]);
GEO.goliath=mergeGeos([
{pos:new Float32Array([0,0.4,1.4,0,1,0, 0,0.4,-1.4,0,1,0, -1.2,0.4,0,0,1,0, 1.2,0.4,0,0,1,0]),uv:new Float32Array(8),idx:new Uint16Array([0,1,2,2,1,3])},
box(1.9,1.1,2.4),
shiftY(box(1.4,0.7,1.2),1.0,0),
shiftY(box(0.35,0.7,1.6),1.35,0.9)
]);
GEO.striker=mergeGeos([
{pos:new Float32Array([0,0.15,1.3,0,1,0, 0,0.15,-0.9,0,1,0, -0.35,0.15,0.3,0,1,0, 0.35,0.15,0.3,0,1,0]),uv:new Float32Array(8),idx:new Uint16Array([0,1,2,2,1,3])},
{pos:new Float32Array([-0.9,0.1,0.2,0,1,0, 0.9,0.1,0.2,0,1,0, 0.4,0.1,-1.0,0,1,0, -0.4,0.1,-1.0,0,1,0]),uv:new Float32Array(8),idx:new Uint16Array([0,1,2,2,1,3])},
box(0.5,0.35,1.6)
]);
GEO.hq=mergeGeos([
prism(4.0,3.2,1.2,8),
shiftY(prism(2.2,0.9,3.4,6),1.2,0),
shiftY(prism(0.7,0.35,2.6,4),4.6,0)
]);
GEO.beacon=mergeGeos([
prism(2.0,1.4,0.8,6),
shiftY(prism(0.5,0.15,3.2,5),0.8,0)
]);
GEO.forge=mergeGeos([
prism(2.9,2.4,0.7,8),
shiftY(box(2.6,1.6,2.6),0.7,0),
shiftY(box(1.0,0.9,0.6),1.9,1.4)
]);
GEO.hangar=mergeGeos([
prism(2.9,2.4,0.6,8),
shiftY(box(2.8,1.2,2.6),0.6,-0.3),
shiftY(box(2.8,1.1,0.5),1.2,1.15)
]);
GEO.lab=mergeGeos([
prism(2.6,2.2,0.6,8),
shiftY(prism(1.6,0.5,2.2,6),0.6,0),
shiftY(prism(0.5,0.5,0.9,4),2.8,0)
]);
GEO.turret=mergeGeos([
prism(1.7,1.3,0.9,6),
shiftY(box(1.1,0.8,1.1),0.9,0),
shiftY(box(0.35,0.35,1.6),1.55,0.6)
]);
GEO.rock=box(1,1,1);
GEO.wall=box(1,1,1);
GEO.__tp=prism(1,0.4,1,8);
GEO.dia=dia;
}
/* ============================== GL SETUP ============================== */
const glc=document.getElementById('glc'), ovc=document.getElementById('ovc');
let gl=null, octx=null;
let progMain=null, progGlow=null, progLine=null, progFog=null;
let capInst=4096, instanceCount=0, instBuf=null, instDirty=true;
const ISTRIDE=28;
function sh(type,src){const s=gl.createShader(type);gl.shaderSource(s,src);gl.compileShader(s);if(!gl.getShaderParameter(s,gl.COMPILE_STATUS))throw new Error('shader: '+gl.getShaderInfoLog(s));return s;}
function mkProg(vs,fs,unis,binds){
const p=gl.createProgram();gl.attachShader(p,sh(gl.VERTEX_SHADER,vs));gl.attachShader(p,sh(gl.FRAGMENT_SHADER,fs));
const B=Object.assign({aPos:0,aUV:1},binds||{});
for(const name in B)gl.bindAttribLocation(p,B[name],name);
gl.linkProgram(p);if(!gl.getProgramParameter(p,gl.LINK_STATUS))throw new Error('link: '+gl.getProgramInfoLog(p));
const o={p}; for(const u of unis)o[u]=gl.getUniformLocation(p,u); return o;
}
function buf(data,target){const b=gl.createBuffer();gl.bindBuffer(target||gl.ARRAY_BUFFER,b);gl.bufferData(target||gl.ARRAY_BUFFER,data,gl.STATIC_DRAW);return b;}
function makeGeo(g){
return{vbo:buf(g.pos),ibo:buf(g.idx,gl.ELEMENT_ARRAY_BUFFER),uvb:g.uv?buf(g.uv):null,nIdx:g.idx.length,stride:g.pos.length/6,hasUV:!!(g.uv&&g.uv.length)};
}
function initGL(){
gl=glc.getContext('webgl',{antialias:true,alpha:false})||glc.getContext('experimental-webgl');
const instExt=gl.getExtension('ANGLE_instanced_arrays');
if(!instExt)throw new Error('WebGL instancing unsupported');
gl.vertexAttribDivisor=function(i,d){instExt.vertexAttribDivisorANGLE(i,d);};
gl.drawElementsInstanced=function(mode,count,type,offset,count2){instExt.drawElementsInstancedANGLE(mode,count,type,offset,count2);};
octx=ovc.getContext('2d');
buildGeos();
const VS_MAIN=`
attribute vec3 aPos; attribute vec2 aUV;
uniform mat4 uMVP; uniform mat4 uM; uniform vec3 uTint;
varying vec3 vC; varying vec3 vW; varying float vY;
void main(){
vec4 w=uM*vec4(aPos,1.0);
vW=w.xyz; vY=aPos.y; vC=uTint;
gl_Position=uMVP*w;
}`;
const FS_MAIN=`
precision mediump float;
varying vec3 vC; varying vec3 vW; varying float vY;
uniform vec3 uCam; uniform vec3 uLight;
void main(){
vec3 col=vC;
vec3 n=normalize(vec3(-0.35,1.0,-0.2));
float d=max(dot(n,uLight),0.0);
float sh=0.42+0.62*d;
sh*=1.0-smoothstep(0.0,0.5,vY)*0.15;
col*=sh;
float fd=distance(vW,uCam);
float f=smoothstep(140.0,320.0,fd);
col=mix(col,vec3(0.10,0.16,0.22),f);
gl_FragColor=vec4(col,1.0);
}`;
const VS_GLOW=`
attribute vec3 aPos; attribute vec2 aUV;
uniform mat4 uMVP; uniform mat4 uM; uniform vec3 uTint;
varying vec3 vC; varying vec2 vUV;
void main(){
vUV=aUV; vC=uTint;
gl_Position=uMVP*(uM*vec4(aPos,1.0));
}`;
const FS_GLOW=`
precision mediump float;
varying vec3 vC; varying vec2 vUV; uniform float uTime; uniform float uAlpha;
void main(){
float d=length(vUV-0.5)*2.0;
float a=smoothstep(1.0,0.15,d);
float pulse=0.75+0.25*sin(uTime*3.0+vC.x*20.0);
gl_FragColor=vec4(vC*pulse, a*0.9*uAlpha);
}`;
const VS_LINE=`
attribute vec3 aPos; attribute vec2 aUV; attribute vec3 aColor;
uniform mat4 uMVP; varying vec3 vC; varying vec2 vUV;
void main(){ vC=aColor; vUV=aUV; gl_Position=uMVP*vec4(aPos,1.0); }`;
const FS_LINE=`
precision mediump float; varying vec3 vC; varying vec2 vUV;
void main(){ float a=smoothstep(1.0,0.0,vUV.y); gl_FragColor=vec4(vC,a*0.85); }`;
progMain=mkProg(VS_MAIN,FS_MAIN,['uMVP','uM','uTint','uCam','uLight']);
progMain.p.__t='MAIN';
progGlow=mkProg(VS_GLOW,FS_GLOW,['uMVP','uM','uTint','uTime','uAlpha']);
progGlow.p.__t='GLOW';
progLine=mkProg(VS_LINE,FS_LINE,['uMVP']);
progLine.p.__t='LINE';
progFog={p:gl.createProgram()};
{
const vs=`attribute vec3 aPos; attribute vec2 aUV; attribute vec3 aTint; uniform mat4 uMVP; varying vec2 vUV; varying vec3 vT; void main(){vUV=aUV; vT=aTint; gl_Position=uMVP*vec4(aPos,1.0);}`;
const fs=`precision mediump float; varying vec2 vUV; varying vec3 vT; uniform sampler2D uTex; void main(){vec4 c=texture2D(uTex,vUV); c.rgb*=vT; if(c.a>0.99) discard; gl_FragColor=vec4(c.rgb, c.a*0.92);}`;
const P=gl.createProgram();gl.attachShader(P,sh(gl.VERTEX_SHADER,vs));gl.attachShader(P,sh(gl.FRAGMENT_SHADER,fs));
gl.bindAttribLocation(P,0,'aPos');gl.bindAttribLocation(P,1,'aUV');gl.bindAttribLocation(P,2,'aTint');gl.linkProgram(P);
progFog.p=P; P.__t='FOG'; progFog.uMVP=gl.getUniformLocation(P,'uMVP'); progFog.uTex=gl.getUniformLocation(P,'uTex');
}
const fogTex=gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D,fogTex);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);
progFog.tex=fogTex;
progFog.geo=makeGeo({pos:new Float32Array([0,FOGY,0, 0,1,1, MAP,FOGY,0, 1,1,1, MAP,FOGY,MAP, 1,0,1, 0,FOGY,MAP, 0,0,1]),idx:new Uint16Array([0,1,2,0,2,3])});
instBuf=gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER,instBuf);
gl.bufferData(gl.ARRAY_BUFFER,new Float32Array(capInst*ISTRIDE),gl.DYNAMIC_DRAW);
for(const k in GEO)GEO[k]=makeGeo(GEO[k]);
// terrain quad
progFog.terrain=makeGeo({pos:new Float32Array([-4,0,-4, 0,1,1, MAP+4,0,-4, 1,1,1, MAP+4,0,MAP+4, 1,0,1, -4,0,MAP+4, 0,0,1]),idx:new Uint16Array([0,1,2,0,2,3])});
// terrain tint buffer (vec3 per vertex, all white)
progFog.terrainTint=buf(new Float32Array([1,1,1, 1,1,1, 1,1,1, 1,1,1]));
// terrain texture
const ttex=gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D,ttex);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);
gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,MAP,MAP,0,gl.RGBA,gl.UNSIGNED_BYTE,heightPx);
progFog.terrainTex=ttex;
}
</script>
<script>
/* ============================== GAME STATE ============================== */
let gameTime=0, paused=false, gameState='title'; // title|play|win|lose
let units=[], buildings=[], effects=[];
let cam={x:36,y:12,z:MAP/2+30, yaw:0, pitch:0.72, zoom:26};
let sel=[], selBuilding=null, cg=[[],[],[],[],[],[],[],[],[],[]];
let placing=null; // {btype, valid, x, z}
let alertMsgs=[];
const stats={k:0,d:0,b:0,g:0,t:0};
let ai=null;
const grid=new Map(); // "x,z" -> entity (units + buildings)
function gridKey(x,z){return (x|0)+','+(z|0);}
function gridAdd(e){e.gk=gridKey(e.x,e.z); grid.set(e.gk,e);}
function gridMove(e){ if(e.gk===gridKey(e.x,e.z))return; const old=e.gk; if(grid.get(old)===e)grid.delete(old); e.gk=gridKey(e.x,e.z); grid.set(e.gk,e); }
function gridRemove(e){ if(e.gk&&grid.get(e.gk)===e)grid.delete(e.gk); }
function gridNear(x,z,r){
const out=[];
const r0=Math.ceil(r);
for(let dx=-r0;dx<=r0;dx++)for(let dz=-r0;dz<=r0;dz++){
const e=grid.get(gridKey(Math.floor(x+dx),Math.floor(z+dz)));
if(e&&e.hp>0)out.push(e);
}
return out;
}
const sideState={
0:{flux:150, supply:0, reserve:0, up:{}, upQueue:null, upProg:0},
1:{flux:150, supply:0, reserve:0, up:{}, upQueue:null, upProg:0},
};
function supplyCap(side){ let c=0; for(const b of buildings) if(b.side===side&&b.hp>0&&BT[b.type].supply)c+=BT[b.type].supply; return c; }
function addAlert(side,text,cls){
if(side!==SIDE_P)return;
alertMsgs.push({text,cls:cls||'',t:gameTime});
if(alertMsgs.length>5)alertMsgs.shift();
if(cls==='bad')SFX.alert();
}
/* ---------- entity creation ---------- */
function spawnUnit(type,side,x,z){
const d=UT[type];
const u={kind:'unit',type,side,x,z,hp:d.hp,maxHp:d.hp,angle:R()*6.28,
state:'idle', target:null, order:null, carry:0, carryRes:null,
atkCd:0, moveT:0, wanderT:0, buildTarget:null, rally:null,
vis:gameTime, lastSeen:-99, dead:false,
fly:!!d.fly, r:d.r, speed:d.speed, vision:d.vision, aggro:d.aggro,
dmg:d.dmg||0, range:d.range||0, rate:d.rate||1, dmgType:d.dmgType||'kinetic', targets:d.targets||'ground',
armor:d.armor, selected:false, id:Math.random()};
units.push(u); gridAdd(u);
return u;
}
function spawnBuilding(type,side,x,z,underConstruction){
const d=BT[type];
const b={kind:'building',type,side,x,z,hp:underConstruction?0:d.hp,maxHp:d.hp,angle:0,
queue:[], qProg:0, research:null, rProg:0, rally:null,
buildT:underConstruction?d.buildT:0, dead:false, selected:false, id:Math.random(),
r:d.r, dmg:d.dmg||0, range:d.range||0, rate:d.rate||1, dmgType:d.dmgType||'kinetic', targets:d.targets||'both', atkCd:0};
buildings.push(b); gridAdd(b);
return b;
}
function startGame(){
gameTime=0; paused=false; stats.k=0;stats.d=0;stats.b=0;stats.g=0;stats.t=0;
units=[];buildings=[];effects=[];sel=[];selBuilding=null;cg=[[],[],[],[],[],[],[],[],[],[]];
alertMsgs=[]; placing=null;
sideState[0]={flux:150,supply:0,reserve:0,up:{},upQueue:null,upProg:0};
sideState[1]={flux:150,supply:0,reserve:0,up:{},upQueue:null,upProg:0};
grid.clear();
const s=genWorld();
// (re)upload terrain texture now that the world exists
gl.bindTexture(gl.TEXTURE_2D,progFog.terrainTex);
gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,MAP,MAP,0,gl.RGBA,gl.UNSIGNED_BYTE,heightPx);
const hp=spawnBuilding('hq',SIDE_P,s.pStart.x,s.pStart.z);
const he=spawnBuilding('hq',SIDE_E,s.eStart.x,s.eStart.z);
for(let i=0;i<3;i++){
const u=spawnUnit('worker',SIDE_P,s.pStart.x+rr(-4,4),s.pStart.z+rr(-6,2));
const w=spawnUnit('worker',SIDE_E,s.eStart.x+rr(-4,4),s.eStart.z+rr(-6,2));
}
// initial workers ordered to gather nearest
for(const u of units) if(u.side===SIDE_P||true){
const res=nearestResource(u.x,u.z);
if(res)orderGather(u,res);
}
hp.rally={x:s.pStart.x+8,z:s.pStart.z+6}; he.rally={x:s.eStart.x-8,z:s.eStart.z+6};
reveal(SIDE_P,s.pStart.x,s.pStart.z,16);
reveal(SIDE_E,s.eStart.x,s.eStart.z,16);
ai=newAI(s);
// anchor on the player base; eye placed behind it (away from enemy), facing toward enemy
cam.x=s.pStart.x; cam.z=s.pStart.z; cam.y=12;
cam.yaw=Math.atan2(-(s.eStart.x-s.pStart.x), -(s.eStart.z-s.pStart.z));
cam.zoom=42; cam.pitch=0.92;
gameState='play';
hideScreen();
addAlert(SIDE_P,'Expedition deployed. Secure the frontier.','good');
}
function newAI(s){
return {
nextThink:0, scoutT:5, attackT:90, lastAttackT:0, lastExpandT:0,
knownPlayer:0, raidTarget:null, focus:null,
workersNeeded:3, plan:{},
stats:{units:0, lost:0}
};
}
function nearestResource(x,z){
let best=null,bd=1e9;
for(const r of resources){ if(r.amt<=0)continue; const d=dist2(x,z,r.x,r.z); if(d<bd){bd=d;best=r;} }
return best;
}
function nearestDepot(u){
let best=null,bd=1e9;
for(const b of buildings){ if(b.side!==u.side||b.hp<=0)continue; if(!BT[b.type].returns)continue; const d=dist2(u.x,u.z,b.x,b.z); if(d<bd){bd=d;best=b;} }
return best;
}
function nearestEnemy(u,rad){
const vis=u.side===SIDE_P?visP:visE;
let best=null,bd=1e9;
const near=gridNear(u.x,u.z,rad);
for(const e of near){
if(e.hp<=0||e.side===u.side)continue;
if(e.kind==='unit'&&!e.fly&&e.state==='idle'&&e.hp<=0)continue;
const d=dist2(u.x,u.z,e.x,e.z);
if(d<bd){bd=d;best=e;}
}
return best;
}
function visibleTo(u,e){
if(e.side===u.side)return true;
const vis=u.side===SIDE_P?visP:visE;
const cx=clamp(Math.floor(e.x/NCELL),0,NRES-1), cz=clamp(Math.floor(e.z/NCELL),0,NRES-1);
return vis[cz*NRES+cx]===1;
}
/* ---------- orders ---------- */
function orderMove(u,x,z){ u.state='move'; u.order={t:'move',x,z}; u.target=null; u.buildTarget=null; }
function orderGather(u,res){ u.state='gather'; u.order={t:'gather',res}; u.target=null; }
function orderReturn(u){ u.state='return'; u.order={t:'return'}; }
function orderAttack(u,t){ u.state='attack'; u.order={t:'attack',target:t}; u.target=t; }
function orderAttackMove(u,x,z){ u.state='attackmove'; u.order={t:'attackmove',x,z}; u.target=null; }
function orderStop(u){ u.state='idle'; u.order=null; u.target=null; u.buildTarget=null; }
function orderHold(u){ u.state='hold'; u.order=null; u.target=null; }
function orderBuild(u,b){ u.state='build'; u.order={t:'build',b}; u.buildTarget=b; u.target=null; }
function issueOrder(kind,x,z,shift){
if(gameState!=='play')return;
let list=[];
if(shift&&sel.length)list=sel.slice();
else if(sel.length)list=sel.slice();
if(!list.length&&selBuilding){ if(kind==='select')return; list=[]; }
if(!list.length&&kind!=='select'){ SFX.error(); return; }
// contextual target
let target=null;
if(kind==='attack'||kind==='move'||kind==='attackmove'||kind==='gather'||kind==='stop'||kind==='hold'){
if(kind!=='stop'&&kind!=='hold'){
// find entity at point
const near=gridNear(x,z,2.2);
for(const e of near){ if(e.hp<=0)continue; if(e.side!==SIDE_P){ target=e; break; } }
}
}
for(const u of list){
if(u.kind!=='unit'||u.side!==SIDE_P||u.dead)continue;
if(kind==='select')continue;
if(kind==='stop'){ orderStop(u); }
else if(kind==='hold'){ orderHold(u); }
else if(kind==='attack'){ if(target)orderAttack(u,target); else {orderAttackMove(u,x,z);} }
else if(kind==='attackmove'){ orderAttackMove(u,x,z); }
else if(kind==='gather'){
let res=null;
for(const r of resources){ if(r.amt>0&&dist2(x,z,r.x,r.z)<6*6){res=r;break;} }
if(res)orderGather(u,res); else orderMove(u,x,z);
}
else if(kind==='move'){
if(target){ orderAttack(u,target); }
else orderMove(u,x,z);
if(u.type==='worker'&&target&&target.kind==='building'&&BT[target.type].returns&&target.side===SIDE_P)orderReturn(u);
}
}
if(kind!=='select'&&list.length)SFX.move();
drawOrderMarker(x,z,kind);
}
let orderMarker=null;
function drawOrderMarker(x,z,kind){ orderMarker={x,z,kind,t:gameTime}; }
/* ---------- damage ---------- */
function damage(e,amt,src,side){
if(e.hp<=0)return;
let mul=1;
if(sideState[src].up.plating)mul*=0.8;
e.hp-=amt*mul;
if(e.kind==='unit'&&e.side===SIDE_P&&e.hp>0&&src===SIDE_E)SFX.shot(1);
if(e.hp<=0){
e.hp=0; e.dead=true;
gridRemove(e);
effects.push({type:'boom',x:e.x,z:e.z,y:e.kind==='building'?1:0.5,age:0,life:0.7,side:src,r:e.r});
SFX.boom(1);
if(e.side===SIDE_P){stats.d++; if(e.kind==='building')addAlert(SIDE_P,(e.type==='hq'?'COMMAND SPIRE':'Structure')+' destroyed!','bad');}
else {stats.k++;}
if(e.kind==='building'&&e.type==='hq'){
if(e.side===SIDE_E){ gameState='win'; showEnd(true); }
else { gameState='lose'; showEnd(false); }
}
} else {
effects.push({type:'hit',x:e.x,z:e.z,y:0.8,age:0,life:0.25,side:src});
}
}
</script>
<script>
/* ============================== SIMULATION ============================== */
function updateUnits(dt){
for(let i=units.length-1;i>=0;i--){
const u=units[i];
if(u.dead){ units.splice(i,1); continue; }
u.atkCd=Math.max(0,u.atkCd-dt);
// auto aggro
if(u.aggro>0&&(u.state==='idle'||u.state==='move'||u.state==='attackmove')){
const e=nearestEnemy(u,u.aggro);
if(e&&visibleTo(u,e)&&canTarget(u,e))orderAttack(u,e);
}
switch(u.state){
case 'idle':
u.moveT+=dt;
break;
case 'move': {
const o=u.order; if(!o){u.state='idle';break;}
const d=dist(u.x,u.z,o.x,o.z);
if(d<0.8){ u.state='idle'; u.order=null; }
else moveToward(u,o.x,o.z,dt);
break;
}
case 'attackmove': {
const o=u.order;
const e=nearestEnemy(u,Math.max(u.range+2,u.aggro));
if(e&&visibleTo(u,e)&&canTarget(u,e)){ orderAttack(u,e); u.amOrigin={x:o.x,z:o.z}; }
else {
const d=dist(u.x,u.z,o.x,o.z);
if(d<0.8){ u.state='idle'; u.order=null; }
else moveToward(u,o.x,o.z,dt);
}
break;
}
case 'attack': {
const t=u.target;
if(!t||t.hp<=0||!visibleTo(u,t)){
if(u.amOrigin){ const o=u.order; if(o){u.state='attackmove';u.order={t:'attackmove',x:o.x,z:o.z};} else u.state='idle'; }
else u.state='idle';
break;
}
const d=dist(u.x,u.z,t.x,t.z);
const need=u.range+ (u.r+t.r)*0.5;
if(d>need) moveToward(u,t.x,t.z,dt);
u.angle=angLerp(u.angle,Math.atan2(t.x-u.x,t.z-u.z),dt*10);
if(d<=need&&u.atkCd<=0&&u.dmg>0){
u.atkCd=1/u.rate;
fireWeapon(u,t);
}
break;
}
case 'hold':
u.angle+=dt*0.5;
break;
case 'gather': {
const o=u.order; const res=o&&o.res;
if(!res||res.amt<=0){
// depleted: return to depot if carrying, else idle
if(u.carry>0)orderReturn(u); else { u.state='idle'; u.order=null; }
break;
}
const d=dist(u.x,u.z,res.x,res.z);
if(d>1.2) moveToward(u,res.x,res.z,dt);
else {
// mining
u.moveT+=dt;
if(u.carry<5){
const amt=Math.min(5-u.carry, res.amt, dt*9);
res.amt-=amt; u.carry+=amt;
if(res.amt<=0){ orderReturn(u); }
}
if(u.carry>=4.99){ u.carry=5; orderReturn(u); }
}
break;
}
case 'return': {
const dep=nearestDepot(u);
if(!dep){ u.state='idle'; break; }
const d=dist(u.x,u.z,dep.x,dep.z);
if(d>dep.r*0.6) moveToward(u,dep.x,dep.z,dt);
else {
const gain=u.carry;
sideState[u.side].flux+=gain;
u.carry=0;
stats.g+=gain;
if(gain>0.5)SFX.harvest();
const res=nearestResource(u.x,u.z);
if(res)orderGather(u,res);
else { u.state='idle'; u.order=null; }
}
break;
}
case 'build': {
const b=u.buildTarget;
if(!b||b.hp>=BT[b.type].hp){ u.state='idle'; u.buildTarget=null; break; }
const d=dist(u.x,u.z,b.x,b.z);
if(d>b.r+1.5) moveToward(u,b.x+ (b.r+1.2)*Math.cos(u.id*7),b.z+(b.r+1.2)*Math.sin(u.id*7),dt);
else {
u.angle=Math.atan2(b.x-u.x,b.z-u.z);
b.hp=Math.min(BT[b.type].hp, b.hp+dt*BT[b.type].hp/ (BT[b.type].buildT*0.55));
if(b.hp>=BT[b.type].hp-0.5){ b.hp=BT[b.type].hp; u.state='idle'; u.buildTarget=null; SFX.complete(); }
}
break;
}
}
// separation
if(u.state!=='hold'){
const near=gridNear(u.x,u.z,u.r+1.2);
let px=0,pz=0;
for(const e of near){
if(e===u||e.hp<=0)continue;
const d=dist(u.x,u.z,e.x,e.z);
const min=u.r+e.r*0.6+0.35;
if(d<min&&d>0.001){
const f=(min-d)/min;
px+=(u.x-e.x)/d*f; pz+=(u.z-e.z)/d*f;
}
}
if(px||pz){
const l=Math.hypot(px,pz)||1;
u.x+=px/l*dt*4; u.z+=pz/l*dt*4;
}
}
// bounds
u.x=clamp(u.x,1,MAP-1); u.z=clamp(u.z,1,MAP-1);
gridMove(u);
// visibility
if(gameTime-u.lastSeen>0.2){
u.lastSeen=gameTime;
reveal(u.side,u.x,u.z,u.vision);
}
}
}
function canTarget(u,e){
if(u.targets==='ground'&&e.fly&&e.kind==='unit')return false;
if(u.targets==='ground'&&e.kind==='building')return true;
if(e.kind==='unit'&&u.targets==='ground'&&!e.fly)return true;
if(e.kind==='unit'&&u.targets==='both')return true;
return e.kind==='building';
}
function fireWeapon(u,t){
let dmg=u.dmg;
if(sideState[u.side].up.overdrive)dmg*=1.3;
if(u.type==='striker'&&sideState[u.side].up.airsup)dmg*=1.5;
const mod=DMG[u.dmgType][t.armor||'light'];
dmg*=mod;
damage(t,dmg,u.side,u.side);
effects.push({type:'shot',x:u.x,z:u.z,y:u.fly?FLY_H:0.8,tx:t.x,tz:t.z,ty:t.fly?FLY_H:0.8,age:0,life:0.12,side:u.side,melee:u.range<3});
if(u.side===SIDE_P||t.side===SIDE_P)SFX.shot(1);
}
function moveToward(u,tx,tz,dt){
const d=dist(u.x,u.z,tx,tz);
if(d<0.05)return;
let ang=Math.atan2(tx-u.x,tz-u.z);
// obstacle avoidance: raycast a bit ahead
const look=2.5;
const ax=u.x+Math.sin(ang)*look, az=u.z+Math.cos(ang)*look;
if(!u.fly&&blockedAt(ax,az,u.r)){
const a1=ang+0.9, a2=ang-0.9;
const b1=blockedAt(u.x+Math.sin(a1)*look,u.z+Math.cos(a1)*look,u.r);
const b2=blockedAt(u.x+Math.sin(a2)*look,u.z+Math.cos(a2)*look,u.r);
if(b1&&!b2)ang=a2; else if(b2&&!b1)ang=a1;
}
u.angle=angLerp(u.angle,ang,dt*8);
const sp=u.speed*(sideState[u.side].up.ion?1.25:1);
u.x+=Math.sin(u.angle)*sp*dt;
u.z+=Math.cos(u.angle)*sp*dt;
if(!u.fly&&blockedAt(u.x,u.z,u.r*0.8)){
// push out
u.x-=Math.sin(u.angle)*sp*dt*2; u.z-=Math.cos(u.angle)*sp*dt*2;
}
}
/* ---------- buildings ---------- */
function updateBuildings(dt){
for(const b of buildings){
if(b.dead)continue;
if(b.hp<=0){ b.dead=true; gridRemove(b);
effects.push({type:'boom',x:b.x,z:b.z,y:1,age:0,life:0.9,side:1-b.side,r:b.r});
SFX.boom(1);
continue;
}
const st=sideState[b.side];
// production
const d=BT[b.type];
if(d.produces&&b.hp>0){
if(b.queue.length===0){ b.qProg=0; }
else {
const it=b.queue[0];
if(it){
if(st.flux>=0&&st.reserve>=UT[it.type].supply){
b.qProg+=dt;
if(b.qProg>=UT[it.type].buildT){
b.qProg=0; st.reserve-=UT[it.type].supply;
const ang=R()*6.28;
const rx=b.x+Math.sin(ang)*(b.r+2.2), rz=b.z+Math.cos(ang)*(b.r+2.2);
const u=spawnUnit(it.type,b.side, clamp(rx,2,MAP-2), clamp(rz,2,MAP-2));
SFX.unit();
if(b.rally)orderMove(u,b.rally.x,b.rally.z);
else orderHold(u);
b.queue.shift();
if(b.side===SIDE_P&&it.type!=='worker')addAlert(SIDE_P,UT[it.type].name+' ready','good');
}
} else {
// supply shortage or (flux already deducted at queue) — pause with feedback
if(st.reserve<UT[it.type].supply&&b.side===SIDE_P){ b.supplyWarn=gameTime; }
}
}
}
}
// research
if(d.research&&b.hp>0){
if(st.upQueue){
const up=UP[st.upQueue];
st.upProg+=dt;
if(st.upProg>=up.time){
st.up[st.upQueue]=true; st.upQueue=null; st.upProg=0;
SFX.research();
if(b.side===SIDE_P)addAlert(SIDE_P,up.name+' researched','good');
}
}
}
// turret / building attack
if(b.dmg>0&&b.hp>0){
b.atkCd=Math.max(0,b.atkCd-dt);
if(b.atkCd<=0){
const e=nearestEnemy(b,b.range+2);
if(e&&visibleTo(b,e)&&canTarget(b,e)&&dist(b.x,b.z,e.x,e.z)<=b.range){
b.atkCd=1/b.rate;
let dmg=b.dmg;
if(sideState[b.side].up.overdrive)dmg*=1.3;
dmg*=DMG[b.dmgType][e.armor||'light'];
damage(e,dmg,b.side,b.side);
effects.push({type:'shot',x:b.x,z:b.z,y:2.2,tx:e.x,tz:e.z,ty:e.fly?FLY_H:0.8,age:0,life:0.12,side:b.side,melee:false});
if(e.side===SIDE_P)SFX.shot(1);
}
}
}
if(gameTime-b.lastSeen>0.2){
b.lastSeen=gameTime;
reveal(b.side,b.x,b.z,d.r+8);
}
}
buildings=buildings.filter(b=>!b.dead);
}
/* ---------- supply bookkeeping ---------- */
function refreshSupply(side){
const st=sideState[side];
let used=0;
for(const u of units) if(u.side===side&&!u.dead)used+=UT[u.type].supply;
st.supply=used;
}
function tryReserve(side,n){
const st=sideState[side];
refreshSupply(side);
if(st.supply+st.reserve+n>supplyCap(side))return false;
st.reserve+=n; return true;
}
function releaseReserve(side,n){
const st=sideState[side];
st.reserve=Math.max(0,st.reserve-n);
}
/* ---------- effects ---------- */
function updateEffects(dt){
for(let i=effects.length-1;i>=0;i--){
const e=effects[i];
e.age+=dt;
if(e.age>e.life)effects.splice(i,1);
}
}
/* ---------- win/lose ---------- */
function checkVictory(){
let p=0,e=0;
for(const b of buildings){ if(b.type==='hq'&&b.hp>0){ if(b.side===SIDE_P)p++; else e++; } }
if(e===0&&gameState==='play'){ gameState='win'; showEnd(true); }
if(p===0&&gameState==='play'){ gameState='lose'; showEnd(false); }
}
</script>
<script>
/* ============================== AI ============================== */
function aiUpdate(dt){
if(gameState!=='play'||paused)return;
if(gameTime<ai.nextThink)return;
ai.nextThink=gameTime+0.8;
const st=sideState[SIDE_E];
refreshSupply(SIDE_E);
const myB=buildings.filter(b=>b.side===SIDE_E&&b.hp>0);
const myU=units.filter(u=>u.side===SIDE_E&&!u.dead);
const workers=myU.filter(u=>u.type==='worker');
const combat=myU.filter(u=>u.type!=='worker');
ai.stats.units=combat.length;
// ---- workers: mine until needed, then expand ----
const hqs=myB.filter(b=>b.type==='hq');
const beacons=myB.filter(b=>b.type==='beacon');
const needed=Math.min(2+hqs.length*2,16) - workers.length;
if(needed>0){
const hq=hqs[0];
if(hq&&hq.queue.length<4&&hq.hp>0){
if(st.flux>=UT.worker.cost&&tryReserve(SIDE_E,UT.worker.supply)){
st.flux-=UT.worker.cost; hq.queue.push({type:'worker'});
}
}
}
// assign idle workers to nearest resource
for(const w of workers){
if(w.state==='idle'){
const res=nearestResource(w.x,w.z);
if(res)orderGather(w,res);
}
}
// ---- economy: beacons near mines, then production ----
const mines=resources.filter(r=>r.amt>60&&wNear(r,workers));
if(beacons.length<hqs.length+1&&mines.length){
const m=mines[Math.floor(R()*mines.length)];
const hq=hqs[0];
if(hq&&hq.queue.length<5&&st.flux>=BT.beacon.cost){
const x=clamp(m.x+rr(-6,6),6,MAP-6), z=clamp(m.z+rr(-6,6),6,MAP-6);
if(placeableBuilding(x,z,BT.beacon.r,SIDE_E)){
st.flux-=BT.beacon.cost;
const b=spawnBuilding('beacon',SIDE_E,x,z,true);
const w=workers[0]||null;
if(w)orderBuild(w,b);
}
}
}
// expansion
if(st.flux>500&&workers.length>4&&hqs.length<4&&gameTime-ai.lastExpandT>60){
// find a resource cluster far from home, visible or explored
let best=null,bd=-1;
for(const r of resources){
if(r.amt<80)continue;
const dHome=dist(r.x,r.z,hqs[0].x,hqs[0].z);
if(dHome<40)continue;
const cx=clamp(Math.floor(r.x/NCELL),0,NRES-1), cz=clamp(Math.floor(r.z/NCELL),0,NRES-1);
if(!fogE[cz*NRES+cx])continue; // unexplored
if(dHome>bd){bd=dHome;best=r;}
}
if(best){
const x=clamp(best.x+rr(-8,8),6,MAP-6), z=clamp(best.z+rr(-8,8),6,MAP-6);
if(placeableBuilding(x,z,BT.hq.r,SIDE_E)&&st.flux>=BT.hq.cost){
st.flux-=BT.hq.cost;
const b=spawnBuilding('hq',SIDE_E,x,z,true);
b.rally={x:x+6,z:z+6};
ai.lastExpandT=gameTime;
// send 2 workers
const ws=workers.filter(w=>w.state==='gather'||w.state==='idle').slice(0,2);
for(const w of ws)orderBuild(w,b);
addAlert(SIDE_E,'Enemy expansion detected!','bad');
}
}
}
// production buildings
const forges=myB.filter(b=>b.type==='forge');
const hangars=myB.filter(b=>b.type==='hangar');
const labs=myB.filter(b=>b.type==='lab');
if(forges.length<hqs.length&&st.flux>=BT.forge.cost){
const hq=hqs[0];
if(hq&&hq.queue.length<6){
st.flux-=BT.forge.cost;
const b=spawnBuilding('forge',SIDE_E,hq.x+rr(-9,9),hq.z+rr(-9,9),true);
const w=workers.find(w=>w.state!=='build');
if(w)orderBuild(w,b);
}
}
if(hangars.length<hqs.length&&st.flux>=BT.hangar.cost&&gameTime>120){
const hq=hqs[0];
if(hq&&hq.queue.length<6){
st.flux-=BT.hangar.cost;
const b=spawnBuilding('hangar',SIDE_E,hq.x+rr(-9,9),hq.z+rr(-9,9),true);
const w=workers.find(w=>w.state!=='build');
if(w)orderBuild(w,b);
}
}
if(labs.length<hqs.length&&st.flux>=BT.lab.cost&&gameTime>90){
const hq=hqs[0];
if(hq&&hq.queue.length<6){
st.flux-=BT.lab.cost;
const b=spawnBuilding('lab',SIDE_E,hq.x+rr(-9,9),hq.z+rr(-9,9),true);
const w=workers.find(w=>w.state!=='build');
if(w)orderBuild(w,b);
}
}
// defenses
const turrets=myB.filter(b=>b.type==='turret');
if(turrets.length<hqs.length*2&&st.flux>=BT.turret.cost){
const hq=hqs[0];
if(hq&&hq.queue.length<8){
st.flux-=BT.turret.cost;
const b=spawnBuilding('turret',SIDE_E,hq.x+rr(-11,11),hq.z+rr(-11,11),true);
const w=workers.find(w=>w.state!=='build');
if(w)orderBuild(w,b);
}
}
// research
if(labs.length&&st.flux>=150&&!st.upQueue){
const order=['heavyarmor','overdrive','harvest','plating','ion','airsup'];
for(const k of order) if(!st.up[k]){ st.upQueue=k; st.upProg=0; break; }
}
// ---- army training (ramps with economy) ----
const tier=combat.length;
const want={blade:0,lancer:0,goliath:0,striker:0};
if(tier<6){ want.blade=3; want.lancer=1; }
else if(tier<14){ want.blade=4; want.lancer=3; want.striker=2; }
else if(tier<26){ want.blade=4; want.lancer=4; want.striker=3; if(st.up.heavyarmor)want.goliath=2; }
else { want.blade=5; want.lancer=5; want.striker=4; if(st.up.heavyarmor)want.goliath=4; }
const counts={blade:0,lancer:0,goliath:0,striker:0};
for(const u of combat)counts[u.type]++;
for(const f of forges){
for(let i=0;i<f.queue.length;i++){
const t=f.queue[i].type;
if(counts[t]<(want[t]||0)&&st.reserve<supplyCap(SIDE_E)-st.supply-4){
const c=UT[t].cost;
if(t==='goliath'&&!st.up.heavyarmor)continue;
if(st.flux>=c&&tryReserve(SIDE_E,UT[t].supply)){ st.flux-=c; f.queue.push({type:t}); counts[t]++; }
}
}
}
for(const h of hangars){
for(let i=0;i<h.queue.length;i++){
if(counts.striker<(want.striker||0)&&st.flux>=UT.striker.cost&&tryReserve(SIDE_E,UT.striker.supply)){
st.flux-=UT.striker.cost; h.queue.push({type:'striker'}); counts.striker++;
}
}
}
// ---- scouting ----
if(gameTime>ai.scoutT&&combat.length>2){
ai.scoutT=gameTime+25;
const scout=combat.find(u=>u.type==='striker')||combat[0];
if(scout){
// scout toward player's known direction or map center
let tx=MAP/2,tz=MAP/2;
if(ai.knownPlayer>0){ tx=ai.knownPlayer.x; tz=ai.knownPlayer.z; }
orderAttackMove(scout,clamp(tx,6,MAP-6),clamp(tz,6,MAP-6));
}
}
// ---- attack planning ----
const playerHqKnown=ai.knownPlayer>0;
const ready=combat.length;
const minAtk=Math.min(4+Math.floor(gameTime/40),30);
if(ready>=minAtk&&gameTime-ai.lastAttackT>80&&gameTime>100){
// pick target: player HQ if known, else nearest visible player building
let tx, tz;
if(playerHqKnown){ tx=ai.knownPlayer.x; tz=ai.knownPlayer.z; }
else {
let best=null,bd=1e9;
for(const b of buildings){
if(b.side!==SIDE_P||b.hp<=0)continue;
const cx=clamp(Math.floor(b.x/NCELL),0,NRES-1), cz=clamp(Math.floor(b.z/NCELL),0,NRES-1);
if(!visE[cz*NRES+cx])continue;
const d=dist(b.x,b.z,hqs[0].x,hqs[0].z);
if(d<bd){bd=d;best=b;}
}
if(!best){ // no visible target: send raid to center
tx=MAP/2; tz=MAP/2;
} else { tx=best.x; tz=best.z; }
}
// send combat units (keep 25% home)
const keep=Math.max(2,Math.floor(combat.length*0.25));
const senders=combat.slice(keep);
for(const u of senders){
orderAttackMove(u,tx,tz);
u.amOrigin=null;
}
ai.lastAttackT=gameTime;
ai.lastAttackSize=senders.length;
addAlert(SIDE_P,'Enemy force detected on the move!','bad');
}
// ---- defense: if enemy units visible near my buildings, rally ----
const visPnear=units.filter(u=>u.side===SIDE_P&&!u.dead&&(u.fly?true:true));
let threat=null;
for(const u of visPnear){
const cx=clamp(Math.floor(u.x/NCELL),0,NRES-1), cz=clamp(Math.floor(u.z/NCELL),0,NRES-1);
if(!visE[cz*NRES+cx])continue;
for(const b of myB){
if(dist(u.x,u.z,b.x,b.z)<18){ threat={x:u.x,z:u.z}; break; }
}
if(threat)break;
}
if(threat){
const def=combat.filter(u=>u.state!=='attackmove'&&dist(u.x,u.z,hqs[0].x,hqs[0].z)<25);
for(const u of def.slice(0,6)) orderAttackMove(u,threat.x,threat.z);
}
}
function wNear(r,workers){
for(const w of workers){ if(dist(r.x,r.z,w.x,w.z)<20)return true; }
// also count explored beacon proximity
for(const b of buildings){ if(b.side===SIDE_E&&BT[b.type].returns&&dist(r.x,r.z,b.x,b.z)<14)return true; }
return false;
}
</script>
<script>
/* ============================== RENDERING ============================== */
let proj=null, view=null, vp=null;
function camUpdate(dt){
// edge scroll (only once the mouse has actually moved on screen)
let ex=0,ez=0;
const m=14;
if(!mouse.moved)return;
if(mouse.x<m)ex=-1; else if(mouse.x>ovc.width-m)ex=1;
if(mouse.y<m)ez=-1; else if(mouse.y>ovc.height-m)ez=1;
if(ex||ez){
const s=cam.zoom*0.7*dt;
cam.x+= (Math.cos(cam.yaw)*ex + Math.sin(cam.yaw)*ez)*s;
cam.z+= (-Math.sin(cam.yaw)*ex + Math.cos(cam.yaw)*ez)*s;
}
cam.x=clamp(cam.x,4,MAP-4); cam.z=clamp(cam.z,4,MAP-4);
cam.y=cam.zoom*Math.sin(cam.pitch)+4;
}
function camMatrices(){
const asp=glc.width/glc.height;
proj=m4Persp(0.9,asp,1,420);
const ex=cam.x+Math.sin(cam.yaw)*cam.zoom*Math.cos(cam.pitch);
const ez=cam.z+Math.cos(cam.yaw)*cam.zoom*Math.cos(cam.pitch);
const ey=cam.zoom*Math.sin(cam.pitch)+4;
view=m4LookAt(ex,ey,ez,cam.x,0,cam.z);
vp=m4Mul(proj,view);
return {ex,ey,ez};
}
const instData=new Float32Array(capInst*ISTRIDE);
function matRotY(x,y,z,ang,sx,sy,sz){
const c=Math.cos(ang),s=Math.sin(ang);
return[c*sx,0,-s*sz, x, 0,sy,0, y, s*sz,0,c*sx, z, 0,0,0, 1];
}
function render(){
if(!heightData)return; // world not generated yet (title screen)
const t=gameTime;
const eye=camMatrices();
gl.viewport(0,0,glc.width,glc.height);
gl.clearColor(0,0,0,1);
gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);
gl.enable(gl.DEPTH_TEST);
gl.disable(gl.CULL_FACE);
// ISOLATED TEST: fresh prism buffer, scale 100
gl.useProgram(progMain.p);
gl.uniformMatrix4fv(progMain.uMVP,false,vp);
gl.uniform3f(progMain.uCam,eye.ex,eye.ey,eye.ez);
gl.uniform3f(progMain.uLight,-0.35,1.0,-0.2);
if(!window.__fp){
const p=(function(){const pos=[],idx=[];for(let i=0;i<8;i++){const a=i/8*Math.PI*2;pos.push(Math.cos(a),0,Math.sin(a),0.5,0.5,1);}for(let i=0;i<8;i++){const a=i/8*Math.PI*2;pos.push(Math.cos(a)*0.4,1,Math.sin(a)*0.4,0.5,0.5,1);}for(let i=0;i<8;i++){const j=(i+1)%8;idx.push(i,j,8+j, i,8+j,8+i);}return{pos:new Float32Array(pos),idx:new Uint16Array(idx)};})();
const v=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,v);gl.bufferData(gl.ARRAY_BUFFER,p.pos,gl.STATIC_DRAW);
const ib=gl.createBuffer();gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,ib);gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,p.idx,gl.STATIC_DRAW);
window.__fp={vbo:v,ibo:ib,nIdx:p.idx.length};
}
_m4=matRotY(36,5,128,0,100,100,100);
gl.uniformMatrix4fv(progMain.uM,false,_m4); gl.uniform3f(progMain.uTint,1,0,1);
bindGeo(window.__fp);
gl.drawElements(gl.TRIANGLES,window.__fp.nIdx,gl.UNSIGNED_SHORT,0);
return;
// ---- terrain ----
{
gl.useProgram(progFog.p);
gl.activeTexture(gl.TEXTURE0);
gl.uniformMatrix4fv(progFog.uMVP,false,vp);
gl.uniform1i(progFog.uTex,0);
gl.bindTexture(gl.TEXTURE_2D,progFog.terrainTex);
bindGeo(progFog.terrain);
gl.bindBuffer(gl.ARRAY_BUFFER,progFog.terrainTint);
gl.vertexAttribPointer(2,3,gl.FLOAT,false,0,0);
gl.enableVertexAttribArray(2);
gl.drawElements(gl.TRIANGLES,progFog.terrain.nIdx,gl.UNSIGNED_SHORT,0);
gl.disableVertexAttribArray(2);
gl.vertexAttribDivisor(2,0);
// fog overlay (no depth write so it never occludes entities)
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA);
gl.depthMask(false);
gl.bindTexture(gl.TEXTURE_2D,progFog.tex);
gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,NRES,NRES,0,gl.RGBA,gl.UNSIGNED_BYTE,fogPx);
bindGeo(progFog.geo);
gl.drawElements(gl.TRIANGLES,progFog.geo.nIdx,gl.UNSIGNED_SHORT,0);
gl.depthMask(true);
gl.disable(gl.BLEND);
}
// ---- units & buildings (per-entity draw calls) ----
gl.enable(gl.DEPTH_TEST);
gl.useProgram(progMain.p);
gl.uniformMatrix4fv(progMain.uMVP,false,vp);
gl.uniform3f(progMain.uCam,eye.ex,eye.ey,eye.ez);
gl.uniform3f(progMain.uLight,-0.35,1.0,-0.2);
drawEntitiesMain(t);
drawObstacles();
// ---- glow pass (resource deposits) ----
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA,gl.ONE);
gl.depthMask(false);
gl.useProgram(progGlow.p);
gl.uniformMatrix4fv(progGlow.uMVP,false,vp);
gl.uniform1f(progGlow.uTime,t);
gl.uniform1f(progGlow.uAlpha,1);
drawGlowRes(t);
// selection rings
gl.uniform1f(progGlow.uAlpha,0.9);
drawGlowRings();
gl.depthMask(true);
gl.disable(gl.BLEND);
// ---- lines (shots) ----
if(effects.length){
gl.useProgram(progLine.p);
gl.uniformMatrix4fv(progLine.uMVP,false,vp);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA,gl.ONE);
gl.depthMask(false);
let n=0;
const lineData=[];
for(const e of effects){
if(e.type!=='shot')continue;
const c=PAL[e.side].glow;
if(e.melee){
lineData.push(e.x,0.6,e.z, 0,1, c[0],c[1],c[2]);
lineData.push(e.x+ (e.tx-e.x)*0.8, 0.6, e.z+(e.tz-e.z)*0.8, 0,0, c[0],c[1],c[2]);
} else {
lineData.push(e.x,e.y,e.z, 0,1, c[0],c[1],c[2]);
lineData.push(e.tx,e.ty,e.tz, 0,0, c[0],c[1],c[2]);
}
}
if(lineData.length){
const b=gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER,b);
gl.bufferData(gl.ARRAY_BUFFER,new Float32Array(lineData),gl.DYNAMIC_DRAW);
gl.vertexAttribPointer(0,3,gl.FLOAT,false,32,0);
gl.vertexAttribPointer(1,2,gl.FLOAT,false,32,12);
gl.vertexAttribPointer(2,3,gl.FLOAT,false,32,20);
gl.enableVertexAttribArray(0);gl.enableVertexAttribArray(1);gl.enableVertexAttribArray(2);
gl.vertexAttribDivisor(0,0);gl.vertexAttribDivisor(1,0);gl.vertexAttribDivisor(2,0);
gl.drawArrays(gl.LINES,0,lineData.length/8);
gl.deleteBuffer(b);
}
gl.depthMask(true);
gl.disable(gl.BLEND);
}
// ---- overlay canvas ----
drawOverlay(eye);
drawMinimap();
}
function bindGeo(g){
gl.bindBuffer(gl.ARRAY_BUFFER,g.vbo);
gl.vertexAttribPointer(0,3,gl.FLOAT,false,24,0);
gl.vertexAttribPointer(1,2,gl.FLOAT,false,24,12);
gl.enableVertexAttribArray(0);gl.enableVertexAttribArray(1);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,g.ibo);
}
let _m4=new Float32Array(16);
function drawEntitiesMain(t){
// units
for(const u of units){
if(u.dead)continue;
const cx=clamp(Math.floor(u.x/NCELL),0,NRES-1), cz=clamp(Math.floor(u.z/NCELL),0,NRES-1);
if(u.side===SIDE_P){ if(!visP[cz*NRES+cx]&&gameTime-u.vis>0.5)continue; }
else { if(!visP[cz*NRES+cx])continue; }
const pal=PAL[u.side];
const y=u.fly?FLY_H+Math.sin(t*2+u.id*9)*0.15:hAt(u.x,u.z);
const c=u.fly?pal.accent:pal.body;
_m4=matRotY(u.x,y,u.z,u.angle,1,1,1);
gl.uniformMatrix4fv(progMain.uM,false,_m4);
gl.uniform3f(progMain.uTint,c[0],c[1],c[2]);
bindGeo(GEO[u.type]);
gl.drawElements(gl.TRIANGLES,GEO[u.type].nIdx,gl.UNSIGNED_SHORT,0);
}
// buildings
for(const b of buildings){
if(b.hp<=0)continue;
const cx=clamp(Math.floor(b.x/NCELL),0,NRES-1), cz=clamp(Math.floor(b.z/NCELL),0,NRES-1);
if(b.side===SIDE_P){ if(!visP[cz*NRES+cx]&&gameTime-b.lastSeen>0.5)continue; }
else { if(!visP[cz*NRES+cx])continue; }
const pal=PAL[b.side];
const y=hAt(b.x,b.z);
const underC=b.hp<BT[b.type].hp*0.999;
const c=underC?[0.5,0.5,0.52]:pal.body;
_m4=matRotY(b.x,y,b.z,0,1,1,1);
gl.uniformMatrix4fv(progMain.uM,false,_m4);
gl.uniform3f(progMain.uTint,c[0],c[1],c[2]);
bindGeo(GEO[b.type]);
gl.drawElements(gl.TRIANGLES,GEO[b.type].nIdx,gl.UNSIGNED_SHORT,0);
}
}
function drawObstacles(){
const g=GEO.rock;
for(const o of obstacles){
const cx=clamp(Math.floor(o.x/NCELL),0,NRES-1), cz=clamp(Math.floor(o.z/NCELL),0,NRES-1);
if(!fogP[cz*NRES+cx])continue;
const y=hAt(o.x,o.z);
const s=o.r*(o.type==='rock'?1:0.9);
_m4=matRotY(o.x,y,o.z,o.x*0.7+o.z,s,o.h/s,s);
gl.uniformMatrix4fv(progMain.uM,false,_m4);
gl.uniform3f(progMain.uTint,0.42,0.45,0.5);
bindGeo(g);
gl.drawElements(gl.TRIANGLES,g.nIdx,gl.UNSIGNED_SHORT,0);
}
}
function drawGlowRes(t){
const g=GEO.dia;
for(const r of resources){
if(r.amt<=0)continue;
const cx=clamp(Math.floor(r.x/NCELL),0,NRES-1), cz=clamp(Math.floor(r.z/NCELL),0,NRES-1);
const known=fogP[cz*NRES+cx];
if(!known)continue;
const seen=visP[cz*NRES+cx];
if(!seen&&gameTime-lastSeenP[cz*NRES+cx]>3)continue;
const s=1.4+(r.amt/r.max)*1.2;
_m4=matRotY(r.x,hAt(r.x,r.z)+0.15,r.z,0,s,s*0.4,s);
gl.uniformMatrix4fv(progGlow.uM,false,_m4);
gl.uniform3f(progGlow.uTint,0.4,1.0,0.75);
bindGeo(g);
gl.drawElements(gl.TRIANGLES,g.nIdx,gl.UNSIGNED_SHORT,0);
}
}
function drawGlowRings(){
const g=GEO.dia;
const pushRing=(x,z,y,s,c)=>{
_m4=matRotY(x,y,z,0,s,s*0.25,s);
gl.uniformMatrix4fv(progGlow.uM,false,_m4);
gl.uniform3f(progGlow.uTint,c[0],c[1],c[2]);
bindGeo(g);
gl.drawElements(gl.TRIANGLES,g.nIdx,gl.UNSIGNED_SHORT,0);
};
for(const u of units){
if(u.side!==SIDE_P||u.dead||!u.selected)continue;
pushRing(u.x,u.z,u.fly?FLY_H+0.2:hAt(u.x,u.z)+0.12,u.r+0.5,[0.3,1.0,0.5]);
}
if(selBuilding&&selBuilding.hp>0){
pushRing(selBuilding.x,selBuilding.z,hAt(selBuilding.x,selBuilding.z)+0.12,selBuilding.r+0.8,[0.3,1.0,0.5]);
}
if(placing){
pushRing(placing.x,placing.z,hAt(placing.x,placing.z)+0.12,BT[placing.btype].r+0.5,placing.valid?[0.3,1.0,0.5]:[1.0,0.3,0.35]);
}
}
function initGL_instReset(){ return instBuf; }
/* ---------- overlay (2D) ---------- */
function drawOverlay(eye){
const w=ovc.width,h=ovc.height;
octx.clearRect(0,0,w,h);
const projP=(x,y,z)=>{
const v=m4Xform(vp,x,y,z);
if(v[3]<=0.01)return null;
const sx=(v[0]/v[3]*0.5+0.5)*w, sy=(-v[1]/v[3]*0.5+0.5)*h;
return {x:sx,y:sy};
};
// health bars
octx.font='10px Consolas,monospace';
for(const u of units){
if(u.dead)continue;
if(u.side===SIDE_P){
if(!u.selected&&u.hp>=u.maxHp)continue;
if(u.hp>=u.maxHp&&!u.selected)continue;
const cx=clamp(Math.floor(u.x/NCELL),0,NRES-1), cz=clamp(Math.floor(u.z/NCELL),0,NRES-1);
if(!visP[cz*NRES+cx])continue;
} else {
const cx=clamp(Math.floor(u.x/NCELL),0,NRES-1), cz=clamp(Math.floor(u.z/NCELL),0,NRES-1);
if(!visP[cz*NRES+cx])continue;
}
if(u.hp>=u.maxHp)continue;
const y=u.fly?FLY_H+1.4:hAt(u.x,u.z)+1.6;
const p=projP(u.x,y,u.z);
if(!p)continue;
const bw=Math.max(14,26*cam.zoom/26);
const f=u.hp/u.maxHp;
octx.fillStyle='rgba(0,0,0,0.55)';
octx.fillRect(p.x-bw/2,p.y,bw,3);
octx.fillStyle=u.side===SIDE_P?'#37e08a':'#ff5566';
octx.fillRect(p.x-bw/2,p.y,bw*f,3);
}
for(const b of buildings){
if(b.hp<=0||b.hp>=b.maxHp)continue;
const p=projP(b.x,hAt(b.x,b.z)+b.r*0.8,b.z);
if(!p)continue;
const bw=34;
octx.fillStyle='rgba(0,0,0,0.55)';
octx.fillRect(p.x-bw/2,p.y,bw,3);
octx.fillStyle=b.side===SIDE_P?'#37e08a':'#ff5566';
octx.fillRect(p.x-bw/2,p.y,bw*(b.hp/b.maxHp),3);
}
// order marker
if(orderMarker&&gameTime-orderMarker.t<1.2){
const p=projP(orderMarker.x,hAt(orderMarker.x,orderMarker.z)+0.2,orderMarker.z);
if(p){
const a=1-(gameTime-orderMarker.t)/1.2;
octx.strokeStyle=`rgba(120,255,160,${a})`;
octx.lineWidth=2;
const r=6+(gameTime-orderMarker.t)*14;
octx.beginPath();octx.arc(p.x,p.y,r,0,6.28);octx.stroke();
if(orderMarker.kind==='attack'||orderMarker.kind==='attackmove'){
octx.beginPath();octx.moveTo(p.x-5,p.y-5);octx.lineTo(p.x+5,p.y+5);octx.moveTo(p.x+5,p.y-5);octx.lineTo(p.x-5,p.y+5);octx.stroke();
}
}
}
// placement ghost
if(placing){
const d=BT[placing.btype];
const p=projP(placing.x,hAt(placing.x,placing.z)+0.3,placing.z);
if(p){
octx.strokeStyle=placing.valid?'rgba(80,255,140,0.8)':'rgba(255,80,90,0.8)';
octx.lineWidth=2;
octx.beginPath();
for(let i=0;i<=16;i++){
const a=i/16*Math.PI*2;
const q=projP(placing.x+Math.sin(a)*d.r,placing.y||hAt(placing.x,placing.z)+0.3,placing.z+Math.cos(a)*d.r);
if(i===0)octx.moveTo(q.x,q.y);else octx.lineTo(q.x,q.y);
}
octx.stroke();
octx.fillStyle=placing.valid?'rgba(80,255,140,0.12)':'rgba(255,80,90,0.12)';
octx.fill();
}
}
// selection box
if(dragBox){
octx.strokeStyle='rgba(90,220,255,0.9)';
octx.lineWidth=1;
octx.strokeRect(Math.min(dragBox.x0,dragBox.x1),Math.min(dragBox.y0,dragBox.y1),Math.abs(dragBox.x1-dragBox.x0),Math.abs(dragBox.y1-dragBox.y0));
octx.fillStyle='rgba(90,220,255,0.08)';
octx.fillRect(Math.min(dragBox.x0,dragBox.x1),Math.min(dragBox.y0,dragBox.y1),Math.abs(dragBox.x1-dragBox.x0),Math.abs(dragBox.y1-dragBox.y0));
}
}
let dragBox=null;
/* ---------- minimap ---------- */
const mmc=document.getElementById('mmc'), mmctx=mmc.getContext('2d');
const mmImg=mmctx.createImageData(100,100);
function drawMinimap(){
if(!fogP)return;
const S=100, sc=S/MAP;
// terrain + fog
for(let py=0;py<S;py+=2)for(let px=0;px<S;px+=2){
const wx=px/sc, wz=py/sc;
const cx=clamp(Math.floor(wx/NCELL),0,NRES-1), cz=clamp(Math.floor(wz/NCELL),0,NRES-1);
const i=(py*S+px)*4;
if(!fogP[cz*NRES+cx]){
mmImg.data[i]=5;mmImg.data[i+1]=8;mmImg.data[i+2]=13;
for(let dy=0;dy<2;dy++)for(let dx=0;dx<2;dx++){const j=((py+dy)*S+(px+dx))*4; mmImg.data[j]=5;mmImg.data[j+1]=8;mmImg.data[j+2]=13;}
} else {
const h=hAt(wx,wz)/2.2;
const r=visP[cz*NRES+cx]?1:0.45;
mmImg.data[i]=(30+h*110)*r;mmImg.data[i+1]=(40+h*130)*r;mmImg.data[i+2]=(52+h*115)*r;
for(let dy=0;dy<2;dy++)for(let dx=0;dx<2;dx++){const j=((py+dy)*S+(px+dx))*4; mmImg.data[j]=mmImg.data[i];mmImg.data[j+1]=mmImg.data[i+1];mmImg.data[j+2]=mmImg.data[i+2];}
}
}
mmctx.putImageData(mmImg,0,0);
// resources (explored)
mmctx.fillStyle='#5fe8a8';
for(const r of resources){
if(r.amt<=0)continue;
const cx=clamp(Math.floor(r.x/NCELL),0,NRES-1), cz=clamp(Math.floor(r.z/NCELL),0,NRES-1);
if(!fogP[cz*NRES+cx])continue;
mmctx.globalAlpha=visP[cz*NRES+cx]?0.9:0.35;
mmctx.fillRect(r.x*sc-1.5,r.z*sc-1.5,3,3);
}
mmctx.globalAlpha=1;
// buildings
for(const b of buildings){
if(b.hp<=0)continue;
const cx=clamp(Math.floor(b.x/NCELL),0,NRES-1), cz=clamp(Math.floor(b.z/NCELL),0,NRES-1);
if(b.side===SIDE_P){ if(!visP[cz*NRES+cx]&&gameTime-b.lastSeen>2)continue; }
else { if(!visP[cz*NRES+cx])continue; }
mmctx.fillStyle=b.side===SIDE_P?'#59d8ff':'#ff5566';
const s=b.type==='hq'?5:3;
mmctx.fillRect(b.x*sc-s/2,b.z*sc-s/2,s,s);
}
// units
for(const u of units){
if(u.dead)continue;
const cx=clamp(Math.floor(u.x/NCELL),0,NRES-1), cz=clamp(Math.floor(u.z/NCELL),0,NRES-1);
if(u.side===SIDE_P){ if(!visP[cz*NRES+cx])continue; }
else { if(!visP[cz*NRES+cx])continue; }
mmctx.fillStyle=u.side===SIDE_P?'#bfefff':'#ff8896';
mmctx.fillRect(u.x*sc-1,u.z*sc-1,2,2);
}
// camera footprint
const cz=cam.zoom*0.5;
mmctx.strokeStyle='rgba(255,255,255,0.75)';
mmctx.lineWidth=1;
mmctx.strokeRect((cam.x-cz)*sc,(cam.z-cz)*sc,cz*2*sc,cz*2*sc);
}
</script>
<script>
/* ============================== INPUT & UI ============================== */
const mouse={x:-1000,y:-1000,down:false,rdown:false,moved:false};
let mmDrag=null, keys={};
function resize(){
const dpr=Math.min(window.devicePixelRatio||1,1.5);
glc.width=innerWidth*dpr; glc.height=innerHeight*dpr;
ovc.width=innerWidth*dpr; ovc.height=innerHeight*dpr;
glc.style.width=ovc.style.width=innerWidth+'px';
glc.style.height=ovc.style.height=innerHeight+'px';
}
addEventListener('resize',resize);
function m4Inv(m){
const a00=m[0],a01=m[4],a02=m[8],a03=m[12],
a10=m[1],a11=m[5],a12=m[9],a13=m[13],
a20=m[2],a21=m[6],a22=m[10],a23=m[14],
a30=m[3],a31=m[7],a32=m[11],a33=m[15];
const b00=a00*a11-a01*a10, b01=a00*a12-a02*a10, b02=a00*a13-a03*a10,
b03=a01*a12-a02*a11, b04=a01*a13-a03*a11, b05=a02*a13-a03*a12,
b06=a20*a31-a21*a30, b07=a20*a32-a22*a30, b08=a20*a33-a23*a30,
b09=a21*a32-a22*a31, b10=a21*a33-a23*a31, b11=a22*a33-a23*a32;
let det=b00*b11-b01*b10+b02*b09+b03*b08-b04*b07+b05*b06;
if(!det)return null;
det=1/det;
return[
(a11*b11-a12*b10+a13*b09)*det, (a02*b10-a01*b11-a03*b09)*det,
(a31*b05-a32*b04+a33*b03)*det, (a22*b04-a21*b05-a23*b03)*det,
(a12*b08-a10*b11-a13*b07)*det, (a00*b11-a02*b08+a03*b07)*det,
(a32*b02-a30*b05-a33*b01)*det, (a20*b05-a22*b02+a23*b01)*det,
(a10*b10-a11*b08+a13*b06)*det, (a01*b08-a00*b10-a03*b06)*det,
(a30*b04-a31*b02+a33*b00)*det, (a21*b02-a20*b04-a23*b00)*det,
(a11*b07-a10*b09-a12*b06)*det, (a00*b09-a01*b07+a02*b06)*det,
(a31*b01-a30*b03-a32*b00)*det, (a20*b03-a21*b01+a22*b00)*det
];
}
function screenToWorld(sx,sy){
const w=glc.width,h=glc.height;
const nx=sx/w*2-1, ny=1-sy/h*2;
const inv=m4Inv(vp);
if(!inv)return null;
const p=m4Xform(inv,nx,ny,-1,1);
const q=m4Xform(inv,nx,ny,1,1);
if(p[3]===0||q[3]===0)return null;
const ox=p[0]/p[3],oy=p[1]/p[3],oz=p[2]/p[3];
const dx=q[0]/q[3]-ox, dy=q[1]/q[3]-oy, dz=q[2]/q[3]-oz;
if(Math.abs(dy)<1e-6)return null;
const t=-oy/dy;
if(t<0)return null;
return {x:ox+dx*t, z:oz+dz*t};
}
function project(sx,sy){}
function worldToScreen(x,y,z){
const v=m4Xform(vp,x,y,z);
if(v[3]<=0.01)return null;
return {x:(v[0]/v[3]*0.5+0.5)*glc.width, y:(-v[1]/v[3]*0.5+0.5)*glc.height};
}
function pickAt(sx,sy){
let best=null,bd=1e9;
const th=14;
for(const u of units){
if(u.side!==SIDE_P||u.dead)continue;
const y=u.fly?FLY_H+0.5:hAt(u.x,u.z)+0.5;
const p=worldToScreen(u.x,y,u.z);
if(!p)continue;
const d=dist2(p.x,p.y,sx,sy);
if(d<th*th&&d<bd){bd=d;best=u;}
}
for(const b of buildings){
if(b.side!==SIDE_P||b.hp<=0)continue;
const p=worldToScreen(b.x,hAt(b.x,b.z)+b.r*0.5,b.z);
if(!p)continue;
const d=dist2(p.x,p.y,sx,sy);
const thb=th+b.r*4;
if(d<thb*thb&&d<bd){bd=d;best=b;}
}
return best;
}
function doSelect(e,shift,ctrl){
if(gameState!=='play')return;
const e2=e.target;
if(e2===mmc||mmc.contains(e2))return;
const sx=e.clientX*dprScale(), sy=e.clientY*dprScale();
if(ctrl){
// select same type
const p=pickAt(sx,sy);
if(p){
const list=p.kind==='unit'?units.filter(u=>u.side===SIDE_P&&!u.dead&&u.type===p.type):buildings.filter(b=>b.side===SIDE_P&&b.type===p.type&&b.hp>0);
sel=list; selBuilding=list.find(b=>b.kind==='building')||null;
for(const u of units)u.selected=false; for(const b of buildings)b.selected=false;
for(const x of sel){x.selected=true; if(x.kind==='building')selBuilding=x;}
SFX.select(); refreshPanels();
}
return;
}
const p=pickAt(sx,sy);
if(p){
if(p.kind==='unit'){
if(shift){ p.selected=!p.selected; if(!sel.includes(p))sel.push(p); else sel=sel.filter(x=>x!==p); }
else { sel=[p]; selBuilding=null; }
} else {
if(shift){ p.selected=!p.selected; selBuilding=p; if(!sel.includes(p))sel.push(p); }
else { sel=[p]; selBuilding=p; }
}
for(const u of units)if(u.side===SIDE_P)u.selected=sel.includes(u);
SFX.select();
refreshPanels();
} else if(!shift){
sel=[]; selBuilding=null;
for(const u of units)u.selected=false;
refreshPanels();
}
}
function dprScale(){ return glc.width/innerWidth; }
function issueFromScreen(sx,sy,kind,shift){
const wpt=screenToWorld(sx,sy);
if(!wpt)return;
issueOrder(kind,wpt.x,wpt.z,shift);
}
function placeFromScreen(sx,sy){
const wpt=screenToWorld(sx,sy);
if(!wpt||!placing)return;
placing.x=wpt.x; placing.z=wpt.z;
const d=BT[placing.btype];
placing.valid=placeableBuilding(wpt.x,wpt.z,d.r,SIDE_P)&&sideState[0].flux>=d.cost;
}
/* ---- mouse events ---- */
ovc.addEventListener('mousedown',e=>{
if(gameState!=='play')return;
const d=dprScale();
mouse.x=e.clientX*d; mouse.y=e.clientY*d; mouse.moved=true;
if(e.button===0){
if(mmc.contains(e.target)){
mmDrag={sx:e.clientX,sy:e.clientY,moved:false};
return;
}
if(placing){
if(placing.valid){
const cost=BT[placing.btype].cost;
sideState[0].flux-=cost;
const b=spawnBuilding(placing.btype,SIDE_P,placing.x,placing.z,true);
// rally default
b.rally={x:placing.x+(placing.btype==='hq'?8:5),z:placing.z+4};
let w=sel.find(u=>u.kind==='unit'&&u.type==='worker');
if(w){ orderBuild(w,b); }
SFX.build();
placing=null;
} else SFX.error();
return;
}
mouse.down=true;
dragBox={x0:mouse.x,y0:mouse.y,x1:mouse.x,y1:mouse.y,shift:e.shiftKey};
}
else if(e.button===2){
if(mmc.contains(e.target)){
const r=mmc.getBoundingClientRect();
const wx=(e.clientX-r.left)/r.width*MAP, wz=(e.clientY-r.top)/r.height*MAP;
issueOrder('move', clamp(wx,2,MAP-2), clamp(wz,2,MAP-2), e.shiftKey);
return;
}
if(placing){ placing=null; return; }
issueFromScreen(mouse.x,mouse.y,'move',e.shiftKey);
}
});
ovc.addEventListener('mousemove',e=>{
const d=dprScale();
mouse.x=e.clientX*d; mouse.y=e.clientY*d; mouse.moved=true;
if(placing)placeFromScreen(mouse.x,mouse.y);
if(dragBox&&mouse.down){ dragBox.x1=mouse.x; dragBox.y1=mouse.y; }
if(mmDrag){
const dx=e.clientX-mmDrag.sx, dy=e.clientY-mmDrag.sy;
if(Math.abs(dx)+Math.abs(dy)>3)mmDrag.moved=true;
if(mmDrag.moved){
const r=mmc.getBoundingClientRect();
cam.x=clamp(mmDrag.sx+(e.clientX-mmDrag.sx)*MAP/r.width,4,MAP-4);
cam.z=clamp(mmDrag.sy+(e.clientY-mmDrag.sy)*MAP/r.height,4,MAP-4);
}
}
});
addEventListener('mouseup',e=>{
if(e.button===0){
if(dragBox){
const w=glc.width,h=glc.height;
const x0=Math.min(dragBox.x0,dragBox.x1), x1=Math.max(dragBox.x0,dragBox.x1);
const y0=Math.min(dragBox.y0,dragBox.y1), y1=Math.max(dragBox.y0,dragBox.y1);
if(x1-x0>6||y1-y0>6){
const list=[];
for(const u of units){
if(u.side!==SIDE_P||u.dead)continue;
const p=worldToScreen(u.x,u.fly?FLY_H+0.5:hAt(u.x,u.z)+0.5,u.z);
if(p&&p.x>=x0&&p.x<=x1&&p.y>=y0&&p.y<=y1)list.push(u);
}
if(dragBox.shift)for(const u of list){u.selected=true; if(!sel.includes(u))sel.push(u);}
else { sel=list; selBuilding=null; }
for(const u of units)if(u.side===SIDE_P)u.selected=sel.includes(u);
SFX.select(); refreshPanels();
} else {
if(!placing)doSelect(e,dragBox.shift,false);
}
dragBox=null;
}
if(mmDrag&&!mmDrag.moved){
const r=mmc.getBoundingClientRect();
cam.x=clamp((e.clientX-r.left)/r.width*MAP,4,MAP-4);
cam.z=clamp((e.clientY-r.top)/r.height*MAP,4,MAP-4);
}
mmDrag=null;
}
mouse.down=false;
});
ovc.addEventListener('contextmenu',e=>e.preventDefault());
mmc.addEventListener('contextmenu',e=>e.preventDefault());
mmc.addEventListener('mousedown',e=>{
if(gameState!=='play')return;
if(e.button===0){ mmDrag={sx:e.clientX,sy:e.clientY,moved:false}; }
else if(e.button===2){
const r=mmc.getBoundingClientRect();
const wx=(e.clientX-r.left)/r.width*MAP, wz=(e.clientY-r.top)/r.height*MAP;
issueOrder('move',clamp(wx,2,MAP-2),clamp(wz,2,MAP-2),e.shiftKey);
}
});
addEventListener('mousemove',e=>{
if(mmDrag){
const dx=e.clientX-mmDrag.sx, dy=e.clientY-mmDrag.sy;
if(Math.abs(dx)+Math.abs(dy)>3)mmDrag.moved=true;
if(mmDrag.moved){
const r=mmc.getBoundingClientRect();
cam.x=clamp(mmDrag.sx+(e.clientX-mmDrag.sx)*MAP/r.width,4,MAP-4);
cam.z=clamp(mmDrag.sy+(e.clientY-mmDrag.sy)*MAP/r.height,4,MAP-4);
}
}
});
ovc.addEventListener('wheel',e=>{
e.preventDefault();
cam.zoom=clamp(cam.zoom*(e.deltaY>0?1.12:0.89),8,80);
},{passive:false});
/* ---- keyboard ---- */
addEventListener('keydown',e=>{
if(e.repeat)return;
keys[e.key.toLowerCase()]=true;
if(e.key===' '){ e.preventDefault(); if(gameState==='play')togglePause(); else if(gameState==='title')startGame(); return; }
if(e.key==='Escape'){ if(placing){placing=null;} else if(gameState==='play')togglePause(); return; }
if(e.key==='F1'){ e.preventDefault(); showHelp(); return; }
if(e.key==='m'||e.key==='M'){ toggleMute(); return; }
if(gameState!=='play')return;
const k=e.key.toLowerCase();
if(k>='1'&&k<='0'){
const i=k==='0'?9:parseInt(k)-1;
if(e.altKey){ cg[i]=sel.slice(); SFX.select(); }
else { sel=cg[i].filter(x=>!x.dead&&x.hp>0); selBuilding=sel.find(b=>b.kind==='building')||null;
for(const u of units)u.selected=sel.includes(u);
SFX.select(); refreshPanels(); }
}
if(k==='a'){ for(const u of sel)if(u.kind==='unit')orderAttackMove(u,cam.x,cam.z); SFX.move(); }
if(k==='s'){ for(const u of sel)if(u.kind==='unit')orderStop(u); SFX.click(); }
if(k==='h'){ for(const u of sel)if(u.kind==='unit')orderHold(u); SFX.click(); }
if(e.ctrlKey&&k==='a'){ e.preventDefault(); for(const u of sel)if(u.kind==='unit')orderAttackMove(u,cam.x,cam.z); }
});
addEventListener('keyup',e=>{keys[e.key.toLowerCase()]=false;});
/* ============================== UI PANELS ============================== */
const elSel=document.getElementById('selinfo'), elAct=document.getElementById('actions'), elSide=document.getElementById('sidepanel');
function refreshPanels(){
// top stats
const st=sideState[0];
refreshSupply(0);
document.getElementById('fluxv').textContent=Math.floor(st.flux);
document.getElementById('supv').textContent=st.supply+'/'+supplyCap(0);
const m=Math.floor(gameTime/60), s=Math.floor(gameTime%60);
document.getElementById('timev').textContent=m+':'+(s<10?'0':'')+s;
// alerts
const ab=document.getElementById('alertbox');
ab.innerHTML=alertMsgs.map(a=>`<div class="alert ${a.cls}">${a.text}</div>`).join('');
// selection info
if(sel.length===1&&sel[0].kind==='unit'){
const u=sel[0], d=UT[u.type];
elSel.innerHTML=`<div class="t">${d.name} <small style="color:#7fa8c0">${d.desc}</small></div>
<div class="row">HP <div class="hpbar"><i class="${u.hp/u.maxHp>0.5?'':(u.hp/u.maxHp>0.25?'mid':'low')}" style="width:${u.hp/u.maxHp*100}%"></i></div> ${Math.ceil(u.hp)}/${u.maxHp}</div>
<div class="row">Supply ${d.supply} · Vision ${d.vision} · ${d.fly?'<span style="color:#8fd8ff">AIR</span>':'Ground'} · Armor ${d.armor}</div>`;
} else if(sel.length===1&&sel[0].kind==='building'){
const b=sel[0], d=BT[b.type];
elSel.innerHTML=`<div class="t">${d.name} <small style="color:#7fa8c0">${d.desc}</small></div>
<div class="row">HP <div class="hpbar"><i style="width:${b.hp/b.maxHp*100}%"></i></div> ${Math.ceil(b.hp)}/${b.maxHp}</div>`;
} else if(sel.length>1){
const counts={};
let hps=0,hpmax=0;
for(const x of sel){ if(x.kind==='unit'){counts[x.type]=(counts[x.type]||0)+1; hps+=x.hp;hpmax+=x.maxHp;} }
let html=`<div class="t">${sel.length} selected</div><div class="row">HP <div class="hpbar"><i style="width:${hps/hpmax*100}%"></i></div> ${Math.floor(hps/hpmax*100)}%</div>`;
for(const k in counts)html+=`<div class="row" style="font-size:11px">${UT[k].name} × ${counts[k]}</div>`;
elSel.innerHTML=html;
} else {
elSel.innerHTML=`<div class="t" style="color:#7fa8c0">No selection — drag to select, right-click to order</div>`;
}
// actions
let acts=[];
if(sel.length===1&&sel[0].kind==='unit'&&sel[0].type==='worker'){
acts.push({label:'Build…',fn:()=>showBuildMenu()});
acts.push({label:'Return to Stockpile',fn:()=>{orderReturn(sel[0]);SFX.click();}});
}
if(sel.some(x=>x.kind==='unit')){
acts.push({label:'Attack Move <small>A</small>',fn:()=>{for(const u of sel)if(u.kind==='unit')orderAttackMove(u,cam.x,cam.z);SFX.move();}});
acts.push({label:'Stop <small>S</small>',fn:()=>{for(const u of sel)if(u.kind==='unit')orderStop(u);SFX.click();}});
acts.push({label:'Hold Position <small>H</small>',fn:()=>{for(const u of sel)if(u.kind==='unit')orderHold(u);SFX.click();}});
}
if(selBuilding&&selBuilding.side===SIDE_P){
const b=selBuilding, d=BT[b.type];
if(d.produces){
for(const t of d.produces){
const u=UT[t];
const locked=t==='goliath'&&!sideState[0].up.heavyarmor;
const can=sideState[0].flux>=u.cost&&!locked;
acts.push({label:`Train ${u.name} <small>${u.cost}◆ ${u.supply}▲${locked?' 🔒':''}</small>`,
fn:()=>{
if(locked){addAlert(SIDE_P,'Requires Heavy Plating research','bad');SFX.error();return;}
if(sideState[0].flux<u.cost){addAlert(SIDE_P,'Not enough flux','bad');SFX.error();return;}
if(!tryReserve(0,u.supply)){addAlert(SIDE_P,'Supply capacity exceeded','bad');SFX.error();return;}
sideState[0].flux-=u.cost;
b.queue.push({type:t});
SFX.click(); refreshPanels();
}, disabled:!can});
}
}
if(d.research){
const order=['harvest','overdrive','plating','ion','airsup','heavyarmor'];
const nextUp=order.find(k=>!sideState[0].up[k]);
if(nextUp){
const u=UP[nextUp];
const can=sideState[0].flux>=u.cost&&!sideState[0].upQueue;
acts.push({label:`Research ${u.name} <small>${u.cost}◆</small>`,
fn:()=>{
if(sideState[0].upQueue){addAlert(SIDE_P,'Research already in progress','bad');return;}
if(sideState[0].flux<u.cost){addAlert(SIDE_P,'Not enough flux','bad');SFX.error();return;}
sideState[0].flux-=u.cost; sideState[0].upQueue=nextUp; sideState[0].upProg=0;
SFX.click(); refreshPanels();
},disabled:!can});
}
if(sideState[0].upQueue){
const u=UP[sideState[0].upQueue];
acts.push({label:`${u.name}… ${Math.floor(sideState[0].upProg/u.time*100)}%`,fn:()=>{},disabled:true});
}
}
// queue display
if(b.queue.length){
acts.push({label:`Queue: ${b.queue.length} — cancel last (full refund)`,fn:()=>{
const it=b.queue[b.queue.length-1];
if(it){ sideState[0].flux+=UT[it.type].cost; releaseReserve(0,UT[it.type].supply); b.queue.pop(); SFX.click(); refreshPanels(); }
}});
}
}
elAct.innerHTML='';
for(const a of acts){
const b=document.createElement('button');
b.innerHTML=a.label;
if(a.disabled)b.disabled=true;
else b.onclick=a.fn;
elAct.appendChild(b);
}
// side panel (build menu / production)
if(elSide.dataset.open==='build')renderBuildMenu();
}
function showBuildMenu(){
elSide.dataset.open='build';
renderBuildMenu();
}
function renderBuildMenu(){
let html='<h3>CONSTRUCT (select a worker first)</h3>';
for(const k in BT){
const d=BT[k];
const can=sideState[0].flux>=d.cost;
html+=`<button class="bbtn" data-b="${k}" ${can?'':'disabled'}><span class="nm">${d.name}<small>${d.desc}</small></span><span class="cost">${d.cost}◆</span></button>`;
}
elSide.innerHTML=html;
elSide.style.display='block';
elSide.querySelectorAll('[data-b]').forEach(btn=>{
btn.onclick=()=>{
const k=btn.dataset.b;
const worker=sel.find(u=>u.kind==='unit'&&u.type==='worker');
if(!worker){ addAlert(SIDE_P,'Select a worker to construct','bad'); return; }
if(sideState[0].flux<BT[k].cost){ addAlert(SIDE_P,'Not enough flux','bad'); SFX.error(); return; }
placing={btype:k,valid:false,x:worker.x,z:worker.z};
elSide.dataset.open=''; elSide.style.display='none';
};
});
}
/* ============================== SCREENS ============================== */
const screen=document.getElementById('screen');
function showScreen(html){ screen.innerHTML=`<div class="box panel">${html}</div>`; screen.style.display='flex'; }
function hideScreen(){ screen.style.display='none'; }
function showTitle(){
gameState='title';
showScreen(`
<h1>FRONTIER <span>WAR</span></h1>
<div class="sub">KHAROS-9 — EXPLORATION CAMPAIGN · SKIRMISH</div>
<p>You command the <b style="color:#59d8ff">AZURE EXPEDITION</b> against the rival <b style="color:#ff8896">CRIMSON SYNDICATE</b> on an uncharted frontier world. The map is unknown — scout it, build an economy, expand, and grow a combined-arms force.</p>
<div class="cols">
<div class="col">
<h4>OBJECTIVE</h4>
<ul><li>Destroy <b>all enemy Command Spires</b> to win.</li><li>Lose your last Spire and the match is over.</li><li>Build extra Spires as expansions — they are winnable targets too.</li></ul>
<h4>ECONOMY</h4>
<ul><li><b>Flux ◆</b> is harvested from glowing deposits by Harvester Drones.</li><li>Beacons add <b>supply ▲</b> and are flux drop-offs — place them near distant deposits.</li><li>Every unit consumes supply capacity. Shortages pause production.</li></ul>
</div>
<div class="col">
<h4>CONTROLS</h4>
<ul>
<li><span class="k">L-drag</span> select · <span class="k">Shift</span> add · <span class="k">Ctrl</span> same type</li>
<li><span class="k">R-click</span> move / attack / gather · <span class="k">A</span> attack-move <span class="k">S</span> stop <span class="k">H</span> hold</li>
<li><span class="k">Alt+1-0</span> control groups · <span class="k">1-0</span> select</li>
<li><span class="k">WASD</span> / screen edges pan · <span class="k">Wheel</span> zoom · <span class="k">Q</span>/<span class="k">E</span> rotate</li>
<li><span class="k">Space</span> pause · <span class="k">M</span> mute · <span class="k">F1</span> help</li>
<li>Minimap: click = camera, right-click = order</li>
</ul>
<h4>COUNTERS</h4>
<ul>
<li><b>Lancer</b> (energy, anti-air) beats heavies & aircraft.</li>
<li><b>Juggernaut</b> (heavy armor) shrugs off kinetic — needs research.</li>
<li><b>Wraith</b> flies over walls; turrets shoot down air.</li>
<li>Kinetic weapons halved vs heavy armor; energy boosted.</li>
</ul>
</div>
</div>
<button class="big" id="startbtn">BEGIN EXPEDITION</button>`);
document.getElementById('startbtn').onclick=()=>{ audioInit(); startGame(); };
}
function showHelp(){
const was=gameState;
showScreen(`
<h1 style="font-size:22px">FIELD MANUAL</h1>
<div class="cols">
<div class="col">
<h4>COMMAND</h4>
<ul><li>Left-drag to select units; right-click for contextual orders.</li><li>Attack-move engages enemies encountered en route.</li><li>Workers: select → Build… → click a valid spot. A worker constructs it.</li></ul>
<h4>ECONOMY</h4>
<ul><li>Drones carry 5 flux per trip. Beacons = supply + drop-off.</li><li>Cancel production = full refund. Supply is capacity, not a cost.</li></ul>
</div>
<div class="col">
<h4>WARFARE</h4>
<ul><li>Scout: the enemy is invisible until seen. Fog hides their base.</li><li>Defend expansions with turrets; raid enemy workers to starve them.</li><li>Escalate: more Spires → more supply → bigger armies.</li></ul>
</div>
</div>
<button class="big" id="closehelp">RESUME</button>`);
document.getElementById('closehelp').onclick=()=>{ hideScreen(); if(was==='title')showTitle(); };
}
function showEnd(won){
const m=Math.floor(gameTime/60), s=Math.floor(gameTime%60);
showScreen(`
<h1 class="${won?'win':'lose'}" style="font-size:30px">${won?'FRONTIER SECURED':'EXPEDITION LOST'}</h1>
<div class="sub">${won?'The Crimson Syndicate is destroyed.':'Your last Command Spire has fallen.'}</div>
<div class="stats">
Match time: ${m}:${s<10?'0':''}${s}<br>
Enemy units destroyed: ${stats.k}<br>
Own units lost: ${stats.d}<br>
Flux gathered: ${Math.floor(stats.g)}
</div>
<button class="big" id="restart">RESTART MATCH</button>`);
document.getElementById('restart').onclick=()=>startGame();
}
function togglePause(){
paused=!paused;
document.getElementById('btnpause').textContent=paused?'▶':'❚❚';
if(paused){
showScreen(`<h1 style="font-size:26px">PAUSED</h1><p>The frontier holds its breath.</p>
<button class="big" id="resume">RESUME (Space)</button>`);
document.getElementById('resume').onclick=togglePause;
} else hideScreen();
}
/* ============================== MAIN LOOP ============================== */
let lastT=0, fogT=0;
function loop(t){
requestAnimationFrame(loop);
const dt=Math.min(0.05,(t-lastT)/1000||0.016);
lastT=t;
if(gameState==='play'&&!paused){
gameTime+=dt;
// WASD pan
const sp=cam.zoom*0.8*dt;
if(keys['w']){cam.x+=Math.cos(cam.yaw)*sp; cam.z-=Math.sin(cam.yaw)*sp;}
if(keys['s']&&!sel.length){cam.x-=Math.cos(cam.yaw)*sp; cam.z+=Math.sin(cam.yaw)*sp;}
if(keys['a']){cam.x-=Math.sin(cam.yaw)*sp; cam.z-=Math.cos(cam.yaw)*sp;}
if(keys['d']){cam.x+=Math.sin(cam.yaw)*sp; cam.z+=Math.cos(cam.yaw)*sp;}
if(keys['q'])cam.yaw+=1.6*dt;
if(keys['e'])cam.yaw-=1.6*dt;
camUpdate(dt);
updateUnits(dt);
updateBuildings(dt);
aiUpdate(dt);
updateEffects(dt);
refreshSupply(0); refreshSupply(1);
checkVictory();
fogT+=dt;
if(fogT>0.2){ fogT=0; fogDraw(); }
}
render();
// periodic HUD refresh
hudT+=dt;
if(hudT>0.25){ hudT=0; refreshPanels(); }
}
let hudT=0;
/* boot */
resize();
initGL();
showTitle();
requestAnimationFrame(loop);
</script>
</body>
</html>
<!-- agent-meta {"model":"Qwen3.8","provider":"brigid","persona":"full","sessionId":"deed72cb-6464-40d3-a1ee-cd48caf93e89","tokensIn":5461984,"tokensOut":32615,"tokensTotal":5494599,"cost":0,"turns":48,"toolCalls":51,"failedToolCalls":0,"timestamp":"2026-09-30T15:54:06.747Z"} -->