RTS
Deepseek v4.1 Flash · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 111,860 bytes · SHA-256 a2f42bacf2f0
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<title>FRONTIER WAR</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
html,body{width:100%;height:100%;overflow:hidden;background:#05070c;font-family:"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:#cfe6f5;user-select:none;-webkit-user-select:none}
canvas{display:block;position:absolute;top:0;left:0}
#gl{z-index:1}
#fx{z-index:2;pointer-events:none}
#hud{position:absolute;inset:0;z-index:3;pointer-events:none}
#hud *{pointer-events:auto}
.panel{background:linear-gradient(180deg,rgba(12,22,34,.94),rgba(8,14,22,.96));border:1px solid #1d3d55;box-shadow:0 0 0 1px rgba(0,0,0,.6),inset 0 0 24px rgba(30,120,170,.09);border-radius:3px}
#topbar{position:absolute;top:0;left:0;right:0;height:38px;display:flex;align-items:center;gap:14px;padding:0 12px;background:linear-gradient(180deg,rgba(10,20,30,.96),rgba(6,11,18,.92));border-bottom:1px solid #1d3d55;font-size:13px;letter-spacing:.4px}
.stat{display:flex;align-items:center;gap:6px;white-space:nowrap}
.stat b{font-weight:600;color:#eaf7ff;font-variant-numeric:tabular-nums}
.ico{width:12px;height:12px;display:inline-block;border-radius:2px}
.icocry{background:linear-gradient(135deg,#7ff0ff,#1c8fb5);box-shadow:0 0 8px #35c8f0}
.icosup{background:linear-gradient(135deg,#ffd479,#c07a10);box-shadow:0 0 8px #e8a020}
.spacer{flex:1}
.tbtn{background:#12293c;border:1px solid #24536f;color:#a8d6ee;padding:5px 11px;border-radius:3px;cursor:pointer;font-size:12px;letter-spacing:.6px}
.tbtn:hover{background:#1b3d57;color:#e6f7ff;border-color:#3d86ad}
.tbtn.on{background:#1d5a7a;color:#eafaff;border-color:#57b6e0}
#warn{position:absolute;top:44px;left:50%;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;gap:4px;pointer-events:none}
.toast{background:rgba(90,20,20,.9);border:1px solid #b8453a;color:#ffd9d2;padding:4px 14px;border-radius:3px;font-size:12.5px;animation:fade 3s forwards}
.toast.good{background:rgba(14,60,50,.9);border-color:#2ea88a;color:#c9fff0}
@keyframes fade{0%{opacity:0;transform:translateY(-6px)}10%{opacity:1;transform:none}80%{opacity:1}100%{opacity:0}}
#bottombar{position:absolute;left:0;right:0;bottom:0;height:168px;display:flex;gap:8px;padding:8px;background:linear-gradient(0deg,rgba(6,12,20,.97),rgba(8,16,26,.86));border-top:1px solid #1d3d55}
#mmwrap{position:relative;width:152px;height:152px;flex:0 0 152px;border:1px solid #24536f;border-radius:3px;overflow:hidden;background:#04070b}
#minimap{width:150px;height:150px;image-rendering:pixelated;cursor:crosshair}
#selpanel{flex:1 1 auto;min-width:200px;display:flex;flex-direction:column;gap:6px;padding:8px 10px;overflow:hidden}
#selhead{font-size:13px;color:#8fd0ef;letter-spacing:1px;text-transform:uppercase;border-bottom:1px solid #1a3348;padding-bottom:5px;display:flex;justify-content:space-between;align-items:center}
#selbody{flex:1;overflow-y:auto;display:flex;flex-wrap:wrap;gap:5px;align-content:flex-start}
#selbody::-webkit-scrollbar{width:8px}#selbody::-webkit-scrollbar-thumb{background:#1d3d55;border-radius:4px}
.ucard{width:100%;display:flex;flex-direction:column;gap:3px}
.urow{display:flex;justify-content:space-between;font-size:12px;color:#b6d8ea}
.bar{height:7px;background:#0d1a26;border:1px solid #234358;border-radius:2px;overflow:hidden;position:relative}
.bar>i{display:block;height:100%;background:linear-gradient(90deg,#2fd07a,#7ef0b0);transition:width .15s}
.bar.hp>i{background:linear-gradient(90deg,#2fd07a,#7ef0b0)}
.bar.pr>i{background:linear-gradient(90deg,#2f8fd0,#7ed0f0)}
.bar.rs>i{background:linear-gradient(90deg,#d0a02f,#f0d07e)}
#actions{flex:0 0 372px;display:flex;flex-wrap:wrap;gap:6px;align-content:flex-start;padding:8px;overflow-y:auto}
#actions::-webkit-scrollbar{width:8px}#actions::-webkit-scrollbar-thumb{background:#1d3d55;border-radius:4px}
.abtn{width:86px;height:60px;background:linear-gradient(180deg,#12293c,#0c1c29);border:1px solid #24536f;border-radius:3px;cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;position:relative;overflow:hidden;transition:background .1s}
.abtn:hover{background:linear-gradient(180deg,#1d4a68,#12324a);border-color:#4f9fc8}
.abtn.dis{opacity:.42;cursor:not-allowed;filter:grayscale(.7)}
.abtn .an{font-size:10.5px;color:#d3ecfa;text-align:center;line-height:1.1;padding:0 2px;letter-spacing:.2px}
.abtn .ac{font-size:10px;color:#7fd0f0;font-weight:600}
.abtn .hk{position:absolute;top:2px;left:4px;font-size:9px;color:#5d8aa5}
.abtn .pg{position:absolute;left:0;bottom:0;height:3px;background:#3fd0ff;width:0%}
.abadge{position:absolute;top:2px;right:3px;font-size:9px;background:#1d5a7a;color:#cdf3ff;border-radius:2px;padding:0 3px}
#selbox{position:absolute;border:1px solid #5fe0ff;background:rgba(60,200,255,.12);display:none;z-index:4;pointer-events:none}
#overlay{position:absolute;inset:0;z-index:8;display:flex;align-items:center;justify-content:center;background:radial-gradient(ellipse at 50% 40%,rgba(10,26,40,.86),rgba(2,4,8,.97))}
#overlay.hide{display:none}
.card{width:min(760px,92vw);max-height:92vh;overflow-y:auto;padding:26px 30px;background:linear-gradient(180deg,rgba(12,24,36,.97),rgba(6,12,20,.98));border:1px solid #2a5c7c;border-radius:5px;box-shadow:0 0 60px rgba(20,120,180,.25),inset 0 0 60px rgba(20,120,180,.06)}
.card::-webkit-scrollbar{width:9px}#.card::-webkit-scrollbar-thumb{background:#1d3d55}
h1{font-size:38px;letter-spacing:9px;font-weight:300;color:#dff4ff;text-shadow:0 0 26px rgba(63,208,255,.55);margin-bottom:2px}
h2{font-size:15px;letter-spacing:4px;color:#5fb6dd;font-weight:400;margin-bottom:16px;text-transform:uppercase}
h3{font-size:13px;letter-spacing:2px;color:#7fd0f0;margin:14px 0 6px;text-transform:uppercase;border-bottom:1px solid #1a3348;padding-bottom:4px}
p,li{font-size:13px;line-height:1.6;color:#a9c9dc}
ul{padding-left:18px}
.cols{display:grid;grid-template-columns:1fr 1fr;gap:0 26px}
kbd{background:#12293c;border:1px solid #2a5c7c;border-bottom-width:2px;border-radius:3px;padding:0 5px;font-size:11.5px;color:#cdeeff;font-family:inherit}
.big{display:block;width:100%;margin-top:20px;padding:13px;background:linear-gradient(180deg,#1a6b93,#0f4460);border:1px solid #4fb0dc;color:#eafaff;font-size:15px;letter-spacing:4px;cursor:pointer;border-radius:3px;text-transform:uppercase}
.big:hover{background:linear-gradient(180deg,#2389b8,#14587c)}
.flexrow{display:flex;gap:10px;margin-top:20px}
.flexrow .big{margin-top:0}
.tag{display:inline-block;font-size:10.5px;padding:1px 6px;border-radius:2px;background:#12324a;border:1px solid #24536f;color:#8fd0ef;margin-right:5px}
#help{position:absolute;right:8px;top:46px;width:330px;max-height:calc(100% - 230px);overflow-y:auto;padding:12px 14px;font-size:12px;line-height:1.55;display:none}
#help.show{display:block}
#help b{color:#8fd0ef}
#help .k{color:#ffd479}
</style>
</head>
<body>
<canvas id="gl"></canvas>
<canvas id="fx"></canvas>
<div id="hud">
<div id="topbar">
<div class="stat"><span class="ico icocry"></span>CRYSTAL <b id="sCry">0</b></div>
<div class="stat"><span class="ico icosup"></span>SUPPLY <b id="sSup">0 / 0</b></div>
<div class="stat" id="sPop">UNITS <b>0</b></div>
<div class="spacer"></div>
<div class="stat" id="sTime">00:00</div>
<button class="tbtn" id="bHelp">HELP (H)</button>
<button class="tbtn" id="bMute">SOUND: ON</button>
<button class="tbtn" id="bPause">PAUSE (P)</button>
</div>
<div id="warn"></div>
<div id="help" class="panel"></div>
<div id="bottombar">
<div id="mmwrap"><canvas id="minimap" width="150" height="150"></canvas></div>
<div id="selpanel" class="panel">
<div id="selhead"><span id="seltitle">NO SELECTION</span><span id="selmeta"></span></div>
<div id="selbody"></div>
</div>
<div id="actions" class="panel"></div>
</div>
</div>
<div id="selbox"></div>
<div id="overlay"><div class="card" id="ocard"></div></div>
<script>
"use strict";
/* ============================================================
FRONTIER WAR — single-file 3D RTS
Custom WebGL2 instanced renderer, no external dependencies.
============================================================ */
/* ---------------- math ---------------- */
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 TAU=Math.PI*2;
function m4mul(a,b,o){
for(let c=0;c<4;c++){
const b0=b[c*4],b1=b[c*4+1],b2=b[c*4+2],b3=b[c*4+3];
o[c*4+0]=a[0]*b0+a[4]*b1+a[8]*b2+a[12]*b3;
o[c*4+1]=a[1]*b0+a[5]*b1+a[9]*b2+a[13]*b3;
o[c*4+2]=a[2]*b0+a[6]*b1+a[10]*b2+a[14]*b3;
o[c*4+3]=a[3]*b0+a[7]*b1+a[11]*b2+a[15]*b3;
}
return o;
}
function m4persp(o,fovy,asp,n,f){
const t=1/Math.tan(fovy/2);
o[0]=t/asp;o[1]=0;o[2]=0;o[3]=0;
o[4]=0;o[5]=t;o[6]=0;o[7]=0;
o[8]=0;o[9]=0;o[10]=(f+n)/(n-f);o[11]=-1;
o[12]=0;o[13]=0;o[14]=2*f*n/(n-f);o[15]=0;
return o;
}
function m4look(o,ex,ey,ez,cx,cy,cz,ux,uy,uz){
let zx=ex-cx,zy=ey-cy,zz=ez-cz;
let l=Math.hypot(zx,zy,zz)||1; zx/=l;zy/=l;zz/=l;
let xx=uy*zz-uz*zy, xy=uz*zx-ux*zz, xz=ux*zy-uy*zx;
l=Math.hypot(xx,xy,xz)||1; xx/=l;xy/=l;xz/=l;
const yx=zy*xz-zz*xy, yy=zz*xx-zx*xz, yz=zx*xy-zy*xx;
o[0]=xx;o[1]=yx;o[2]=zx;o[3]=0;
o[4]=xy;o[5]=yy;o[6]=zy;o[7]=0;
o[8]=xz;o[9]=yz;o[10]=zz;o[11]=0;
o[12]=-(xx*ex+xy*ey+xz*ez);
o[13]=-(yx*ex+yy*ey+yz*ez);
o[14]=-(zx*ex+zy*ey+zz*ez);
o[15]=1;
return o;
}
function m4inv(m,o){
const a00=m[0],a01=m[1],a02=m[2],a03=m[3],a10=m[4],a11=m[5],a12=m[6],a13=m[7],
a20=m[8],a21=m[9],a22=m[10],a23=m[11],a30=m[12],a31=m[13],a32=m[14],a33=m[15];
const b00=a00*a11-a01*a10,b01=a00*a12-a02*a10,b02=a00*a13-a03*a10,b03=a01*a12-a02*a11,
b04=a01*a13-a03*a11,b05=a02*a13-a03*a12,b06=a20*a31-a21*a30,b07=a20*a32-a22*a30,
b08=a20*a33-a23*a30,b09=a21*a32-a22*a31,b10=a21*a33-a23*a31,b11=a22*a33-a23*a32;
let d=b00*b11-b01*b10+b02*b09+b03*b08-b04*b07+b05*b06;
if(!d)return null; d=1/d;
o[0]=(a11*b11-a12*b10+a13*b09)*d; o[1]=(a02*b10-a01*b11-a03*b09)*d;
o[2]=(a31*b05-a32*b04+a33*b03)*d; o[3]=(a22*b04-a21*b05-a23*b03)*d;
o[4]=(a12*b08-a10*b11-a13*b07)*d; o[5]=(a00*b11-a02*b08+a03*b07)*d;
o[6]=(a32*b02-a30*b05-a33*b01)*d; o[7]=(a20*b05-a22*b02+a23*b01)*d;
o[8]=(a10*b10-a11*b08+a13*b06)*d; o[9]=(a01*b08-a00*b10-a03*b06)*d;
o[10]=(a30*b04-a31*b02+a33*b00)*d; o[11]=(a21*b02-a20*b04-a23*b00)*d;
o[12]=(a11*b07-a10*b09-a12*b06)*d; o[13]=(a00*b09-a01*b07+a02*b06)*d;
o[14]=(a31*b01-a30*b03-a32*b00)*d; o[15]=(a20*b03-a21*b01+a22*b00)*d;
return o;
}
/* seeded rng */
let _seed=1337;
function srand(s){_seed=s>>>0;}
function rnd(){_seed=(_seed*1664525+1013904223)>>>0;return _seed/4294967296;}
function rrange(a,b){return a+rnd()*(b-a);}
function pick(arr){return arr[(rnd()*arr.length)|0];}
/* ---------------- GL core ---------------- */
const canvas=document.getElementById("gl");
const gl=canvas.getContext("webgl2",{antialias:true,alpha:false,powerPreference:"high-performance"});
if(!gl){document.body.innerHTML="<div style='color:#fff;padding:40px;font:16px sans-serif'>WebGL2 is required to run FRONTIER WAR.</div>";throw new Error("no webgl2");}
gl.enable(gl.DEPTH_TEST);
gl.enable(gl.CULL_FACE);
gl.cullFace(gl.BACK);
function compile(vs,fs){
const p=gl.createProgram();
const a=gl.createShader(gl.VERTEX_SHADER);gl.shaderSource(a,vs);gl.compileShader(a);
if(!gl.getShaderParameter(a,gl.COMPILE_STATUS))throw new Error(gl.getShaderInfoLog(a));
const b=gl.createShader(gl.FRAGMENT_SHADER);gl.shaderSource(b,fs);gl.compileShader(b);
if(!gl.getShaderParameter(b,gl.COMPILE_STATUS))throw new Error(gl.getShaderInfoLog(b));
gl.attachShader(p,a);gl.attachShader(p,b);gl.linkProgram(p);
if(!gl.getProgramParameter(p,gl.LINK_STATUS))throw new Error(gl.getProgramInfoLog(p));
return p;
}
/* --- geometry --- */
function tri(P,N,a,b,c,n){
P.push(a[0],a[1],a[2],b[0],b[1],b[2],c[0],c[1],c[2]);
for(let i=0;i<3;i++)N.push(n[0],n[1],n[2]);
}
function makeBox(){
const P=[],N=[],h=0.5;
const F=[
{n:[0,0,1], v:[[-h,-h,h],[h,-h,h],[h,h,h],[-h,h,h]]},
{n:[0,0,-1],v:[[h,-h,-h],[-h,-h,-h],[-h,h,-h],[h,h,-h]]},
{n:[1,0,0], v:[[h,-h,h],[h,-h,-h],[h,h,-h],[h,h,h]]},
{n:[-1,0,0],v:[[-h,-h,-h],[-h,-h,h],[-h,h,h],[-h,h,-h]]},
{n:[0,1,0], v:[[-h,h,h],[h,h,h],[h,h,-h],[-h,h,-h]]},
{n:[0,-1,0],v:[[-h,-h,-h],[h,-h,-h],[h,-h,h],[-h,-h,h]]}
];
for(const f of F){
const a=f.v[0],b=f.v[1],c=f.v[2],d=f.v[3];
tri(P,N,a,b,c,f.n); tri(P,N,a,c,d,f.n);
}
return {pos:new Float32Array(P),nrm:new Float32Array(N)};
}
function makeCyl(seg){
const P=[],N=[],h=0.5,r=0.5;
for(let i=0;i<seg;i++){
const a0=i/seg*TAU,a1=(i+1)/seg*TAU;
const x0=Math.cos(a0)*r,z0=Math.sin(a0)*r,x1=Math.cos(a1)*r,z1=Math.sin(a1)*r;
const n0=[Math.cos(a0),0,Math.sin(a0)],n1=[Math.cos(a1),0,Math.sin(a1)];
tri(P,N,[x0,-h,z0],[x1,-h,z1],[x1,h,z1],n0);
tri(P,N,[x0,-h,z0],[x1,h,z1],[x0,h,z0],n0);
tri(P,N,[0,h,0],[x0,h,z0],[x1,h,z1],[0,1,0]);
tri(P,N,[0,-h,0],[x1,-h,z1],[x0,-h,z0],[0,-1,0]);
}
return {pos:new Float32Array(P),nrm:new Float32Array(N)};
}
function makeCone(seg){
const P=[],N=[],h=0.5,r=0.5;
const apex=[0,h,0];
for(let i=0;i<seg;i++){
const a0=i/seg*TAU,a1=(i+1)/seg*TAU;
const x0=Math.cos(a0)*r,z0=Math.sin(a0)*r,x1=Math.cos(a1)*r,z1=Math.sin(a1)*r;
const nm=[Math.cos((a0+a1)/2),0.45,Math.sin((a0+a1)/2)];
tri(P,N,[x0,-h,z0],[x1,-h,z1],apex,nm);
tri(P,N,[0,-h,0],[x1,-h,z1],[x0,-h,z0],[0,-1,0]);
}
return {pos:new Float32Array(P),nrm:new Float32Array(N)};
}
function makeSphere(seg,rings){
const P=[],N=[],r=0.5;
for(let j=0;j<rings;j++){
const p0=j/rings*Math.PI,p1=(j+1)/rings*Math.PI;
for(let i=0;i<seg;i++){
const t0=i/seg*TAU,t1=(i+1)/seg*TAU;
const v=(p,t)=>[Math.sin(p)*Math.cos(t)*r,Math.cos(p)*r,Math.sin(p)*Math.sin(t)*r];
const a=v(p0,t0),b=v(p1,t0),c=v(p1,t1),d=v(p0,t1);
const nn=(q)=>[q[0]/r,q[1]/r,q[2]/r];
tri(P,N,a,b,c,nn(a)); tri(P,N,a,c,d,nn(a));
}
}
return {pos:new Float32Array(P),nrm:new Float32Array(N)};
}
function makeDisc(seg){
const P=[],N=[],r=0.5;
for(let i=0;i<seg;i++){
const a0=i/seg*TAU,a1=(i+1)/seg*TAU;
tri(P,N,[0,0,0],[Math.cos(a0)*r,0,Math.sin(a0)*r],[Math.cos(a1)*r,0,Math.sin(a1)*r],[0,1,0]);
}
return {pos:new Float32Array(P),nrm:new Float32Array(N)};
}
function makeRing(seg,ri){
const P=[],N=[];
for(let i=0;i<seg;i++){
const a0=i/seg*TAU,a1=(i+1)/seg*TAU;
const x0=Math.cos(a0),z0=Math.sin(a0),x1=Math.cos(a1),z1=Math.sin(a1);
tri(P,N,[x0*ri,0,z0*ri],[x0,0,z0],[x1,0,z1],[0,1,0]);
tri(P,N,[x0*ri,0,z0*ri],[x1,0,z1],[x1*ri,0,z1*ri],[0,1,0]);
}
return {pos:new Float32Array(P),nrm:new Float32Array(N)};
}
function axisPermute(g,axis){
if(axis==="y")return g;
const P=g.pos,N=g.nrm;
for(let i=0;i<P.length;i+=3){
if(axis==="x"){const y=P[i+1];P[i+1]=P[i];P[i]=y; const n=N[i+1];N[i+1]=N[i];N[i]=n;}
else{const y=P[i+1];P[i+1]=P[i+2];P[i+2]=y; const n=N[i+1];N[i+1]=N[i+2];N[i+2]=n;}
}
return g;
}
const GEOMS=[
makeBox(), // 0 box
makeCyl(14), // 1 cyl Y
axisPermute(makeCyl(14),"x"), // 2 cyl X
axisPermute(makeCyl(14),"z"), // 3 cyl Z
makeCone(12), // 4 cone Y
makeSphere(12,8), // 5 sphere
makeDisc(24), // 6 disc
makeRing(28,0.82) // 7 ring
];
const GBOX=0,GCYL=1,GCYLX=2,GCYLZ=3,GCONE=4,GSPH=5,GDISC=6,GRING=7;
/* --- instanced program --- */
const MAXI=16000;
const instVS=`
attribute vec3 aPos; attribute vec3 aNrm;
attribute vec4 aM0; attribute vec4 aM1; attribute vec4 aM2; attribute vec4 aM3;
attribute vec4 aCol;
uniform mat4 uVP;
varying vec3 vN; varying vec4 vC; varying vec3 vW;
void main(){
mat4 m=mat4(aM0,aM1,aM2,aM3);
vec4 wp=m*vec4(aPos,1.0);
vW=wp.xyz;
vN=normalize((m*vec4(aNrm,0.0)).xyz);
vC=aCol;
gl_Position=uVP*wp;
}`;
const instFS=`
precision mediump float;
uniform vec3 uLight; uniform vec3 uCam; uniform vec3 uFogCol;
varying vec3 vN; varying vec4 vC; varying vec3 vW;
void main(){
vec3 n=normalize(vN);
float d=max(dot(n,normalize(uLight)),0.0);
float sky=0.5+0.5*n.y;
vec3 lit=vC.rgb*(0.26+0.62*d+0.20*sky);
vec3 vd=normalize(uCam-vW);
float rim=pow(1.0-max(dot(n,vd),0.0),3.0)*0.30;
lit+=vC.rgb*rim;
vec3 c=mix(lit,vC.rgb,vC.a);
gl_FragColor=vec4(c,1.0);
}`;
const instProg=compile(instVS,instFS);
const IU={VP:gl.getUniformLocation(instProg,"uVP"),L:gl.getUniformLocation(instProg,"uLight"),C:gl.getUniformLocation(instProg,"uCam")};
const IA={pos:gl.getAttribLocation(instProg,"aPos"),nrm:gl.getAttribLocation(instProg,"aNrm"),
m0:gl.getAttribLocation(instProg,"aM0"),m1:gl.getAttribLocation(instProg,"aM1"),
m2:gl.getAttribLocation(instProg,"aM2"),m3:gl.getAttribLocation(instProg,"aM3"),
col:gl.getAttribLocation(instProg,"aCol")};
class Batch{
constructor(geom){
this.geom=geom; this.count=0;
this.data=new Float32Array(MAXI*20);
this.vbo=gl.createBuffer();
this.vao=gl.createVertexArray();
gl.bindVertexArray(this.vao);
const pb=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,pb);
gl.bufferData(gl.ARRAY_BUFFER,geom.pos,gl.STATIC_DRAW);
gl.enableVertexAttribArray(IA.pos); gl.vertexAttribPointer(IA.pos,3,gl.FLOAT,false,0,0);
const nb=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,nb);
gl.bufferData(gl.ARRAY_BUFFER,geom.nrm,gl.STATIC_DRAW);
gl.enableVertexAttribArray(IA.nrm); gl.vertexAttribPointer(IA.nrm,3,gl.FLOAT,false,0,0);
gl.bindBuffer(gl.ARRAY_BUFFER,this.vbo);
gl.bufferData(gl.ARRAY_BUFFER,this.data.byteLength,gl.DYNAMIC_DRAW);
const S=20*4;
for(let i=0;i<4;i++){
gl.enableVertexAttribArray(IA.m0+i);
gl.vertexAttribPointer(IA.m0+i,4,gl.FLOAT,false,S,i*16);
gl.vertexAttribDivisor(IA.m0+i,1);
}
gl.enableVertexAttribArray(IA.col);
gl.vertexAttribPointer(IA.col,4,gl.FLOAT,false,S,64);
gl.vertexAttribDivisor(IA.col,1);
gl.bindVertexArray(null);
this.vcount=geom.pos.length/3;
}
reset(){this.count=0;}
add(m,r,g,b,a){
if(this.count>=MAXI)return;
const o=this.count*20,d=this.data;
d[o]=m[0];d[o+1]=m[1];d[o+2]=m[2];d[o+3]=m[3];
d[o+4]=m[4];d[o+5]=m[5];d[o+6]=m[6];d[o+7]=m[7];
d[o+8]=m[8];d[o+9]=m[9];d[o+10]=m[10];d[o+11]=m[11];
d[o+12]=m[12];d[o+13]=m[13];d[o+14]=m[14];d[o+15]=m[15];
d[o+16]=r;d[o+17]=g;d[o+18]=b;d[o+19]=a;
this.count++;
}
flush(){
if(!this.count)return;
gl.bindVertexArray(this.vao);
gl.bindBuffer(gl.ARRAY_BUFFER,this.vbo);
gl.bufferSubData(gl.ARRAY_BUFFER,0,this.data.subarray(0,this.count*20));
gl.drawArraysInstanced(gl.TRIANGLES,0,this.vcount,this.count);
}
}
const BATCHES=GEOMS.map(g=>new Batch(g));
/* --- terrain program --- */
const MAP=256, HALF=MAP/2;
const terrVS=`
attribute vec3 aPos; attribute vec3 aNrm; attribute vec3 aCol;
uniform mat4 uVP;
varying vec3 vN; varying vec3 vC; varying vec2 vUv;
void main(){ vN=aNrm; vC=aCol; vUv=aPos.xz/256.0; gl_Position=uVP*vec4(aPos,1.0); }`;
const terrFS=`
precision mediump float;
uniform sampler2D uFog; uniform vec3 uLight;
varying vec3 vN; varying vec3 vC; varying vec2 vUv;
void main(){
float d=max(dot(normalize(vN),normalize(uLight)),0.0);
vec3 c=vC*(0.42+0.58*d);
vec2 g=abs(fract(vUv*128.0)-0.5);
float gline=1.0-smoothstep(0.44,0.5,min(g.x,g.y));
c+=vec3(0.035,0.06,0.08)*gline;
vec4 f=texture2D(uFog,vUv);
c=mix(c,f.rgb,f.a);
gl_FragColor=vec4(c,1.0);
}`;
const terrProg=compile(terrVS,terrFS);
const TU={VP:gl.getUniformLocation(terrProg,"uVP"),F:gl.getUniformLocation(terrProg,"uFog"),L:gl.getUniformLocation(terrProg,"uLight")};
const TA={pos:gl.getAttribLocation(terrProg,"aPos"),nrm:gl.getAttribLocation(terrProg,"aNrm"),col:gl.getAttribLocation(terrProg,"aCol")};
/* --- fog of war texture --- */
const FOGN=128, FOGC=MAP/FOGN;
const fogVis=new Uint8Array(FOGN*FOGN);
const fogExp=new Uint8Array(FOGN*FOGN);
const fogTex=new Uint8Array(FOGN*FOGN*4);
const fogTexObj=gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D,fogTexObj);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);
function fogIndex(x,z){return ((z/FOGC)|0)*FOGN+((x/FOGC)|0);}
function isVisible(x,z){
const cx=(x/FOGC)|0, cz=(z/FOGC)|0;
if(cx<0||cz<0||cx>=FOGN||cz>=FOGN)return false;
return fogVis[cz*FOGN+cx]>0;
}
function isExplored(x,z){
const cx=(x/FOGC)|0, cz=(z/FOGC)|0;
if(cx<0||cz<0||cx>=FOGN||cz>=FOGN)return 0;
return fogExp[cz*FOGN+cx];
}
function markVision(x,z,r,arr){
const r2=r*r;
const x0=clamp(((x-r)/FOGC)|0,0,FOGN-1),x1=clamp(((x+r)/FOGC)|0,0,FOGN-1);
const z0=clamp(((z-r)/FOGC)|0,0,FOGN-1),z1=clamp(((z+r)/FOGC)|0,0,FOGN-1);
for(let cz=z0;cz<=z1;cz++){
const wz=cz*FOGC+FOGC*0.5, dz=wz-z;
for(let cx=x0;cx<=x1;cx++){
const wx=cx*FOGC+FOGC*0.5, dx=wx-x;
if(dx*dx+dz*dz<=r2)arr[cz*FOGN+cx]=1;
}
}
}
function markExplored(x,z,r){
const r2=r*r;
const x0=clamp(((x-r)/FOGC)|0,0,FOGN-1),x1=clamp(((x+r)/FOGC)|0,0,FOGN-1);
const z0=clamp(((z-r)/FOGC)|0,0,FOGN-1),z1=clamp(((z+r)/FOGC)|0,0,FOGN-1);
for(let cz=z0;cz<=z1;cz++){
const wz=cz*FOGC+FOGC*0.5, dz=wz-z;
for(let cx=x0;cx<=x1;cx++){
const wx=cx*FOGC+FOGC*0.5, dx=wx-x;
if(dx*dx+dz*dz<=r2)fogExp[cz*FOGN+cx]=1;
}
}
}
let fogDirty=true;
function rebuildFogTex(){
for(let i=0;i<FOGN*FOGN;i++){
const v=fogVis[i],e=fogExp[i];
let r,g,b,a;
if(v){r=0;g=0;b=0;a=0;}
else if(e){r=0.03;g=0.055;b=0.10;a=0.72;}
else {r=0.008;g=0.014;b=0.028;a=1.0;}
fogTex[i*4]=r*255;fogTex[i*4+1]=g*255;fogTex[i*4+2]=b*255;fogTex[i*4+3]=a*255;
}
gl.bindTexture(gl.TEXTURE_2D,fogTexObj);
gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,FOGN,FOGN,0,gl.RGBA,gl.UNSIGNED_BYTE,fogTex);
fogDirty=false;
}
/* --- terrain generation --- */
function hash2(x,y){
let n=(x*374761393+y*668265263)|0;
n=(n^(n>>13))*1274126177|0;
return ((n^(n>>16))>>>0)/4294967295;
}
function vnoise(x,y){
const xi=Math.floor(x),yi=Math.floor(y),xf=x-xi,yf=y-yi;
const u=xf*xf*(3-2*xf),v=yf*yf*(3-2*yf);
const a=hash2(xi,yi),b=hash2(xi+1,yi),c=hash2(xi,yi+1),d=hash2(xi+1,yi+1);
return lerp(lerp(a,b,u),lerp(c,d,u),v);
}
const START_P={x:46,z:46}, START_A={x:210,z:210};
function baseFlat(x,z){
let h=0;
const dp=Math.hypot(x-START_P.x,z-START_P.z);
const da=Math.hypot(x-START_A.x,z-START_A.z);
if(dp<34)h=Math.max(h,1-(dp/34));
if(da<34)h=Math.max(h,1-(da/34));
return h;
}
function terrainH(x,z){
let h=vnoise(x*0.011,z*0.011)*7.0+vnoise(x*0.028,z*0.028)*2.4+vnoise(x*0.075,z*0.075)*0.9;
h-=3.4;
const f=baseFlat(x,z);
h=lerp(h,2.2,f*f);
const edge=Math.min(Math.min(x,z),Math.min(MAP-x,MAP-z));
h+=Math.max(0,(14-edge))*0.30;
return h;
}
/* grid */
const GN=64, CELL=MAP/GN;
const passable=new Uint8Array(GN*GN);
const heightGrid=new Float32Array((GN+1)*(GN+1));
for(let z=0;z<=GN;z++)for(let x=0;x<=GN;x++)heightGrid[z*(GN+1)+x]=terrainH(x*CELL,z*CELL);
function gH(x,z){
const gx=clamp(x/CELL,0,GN-0.001),gz=clamp(z/CELL,0,GN-0.001);
const x0=gx|0,z0=gz|0,fx=gx-x0,fz=gz-z0;
const i=z0*(GN+1)+x0;
return lerp(lerp(heightGrid[i],heightGrid[i+1],fx),lerp(heightGrid[i+GN+1],heightGrid[i+GN+2],fx),fz);
}
function terrainNormal(x,z){
const e=1.2;
const hL=gH(x-e,z),hR=gH(x+e,z),hD=gH(x,z-e),hU=gH(x,z+e);
const nx=hL-hR,ny=2*e,nz=hD-hU;
const l=Math.hypot(nx,ny,nz)||1;
return [nx/l,ny/l,nz/l];
}
/* obstacles: rock formations */
const rocks=[];
function addRock(cx,cz,r,scale){
rocks.push({x:cx,z:cz,r:r});
const c0=clamp(((cx-r)/CELL)|0,0,GN-1),c1=clamp(((cx+r)/CELL)|0,0,GN-1);
const d0=clamp(((cz-r)/CELL)|0,0,GN-1),d1=clamp(((cz+r)/CELL)|0,0,GN-1);
for(let gz=d0;gz<=d1;gz++)for(let gx=c0;gx<=c1;gx++){
const wx=gx*CELL+CELL/2,wz=gz*CELL+CELL/2;
if(dist2(wx,wz,cx,cz)<r*r)passable[gz*GN+gx]=0;
}
const n=Math.max(3,(r*1.3)|0);
for(let i=0;i<n;i++){
const a=rnd()*TAU,rr=rnd()*r*0.85;
const px=cx+Math.cos(a)*rr,pz=cz+Math.sin(a)*rr;
rocks.push({x:px,z:pz,r:0,deco:true,sz:rrange(0.9,2.6)*scale,ry:rnd()*TAU,h:gH(px,pz)});
}
}
function genMap(){
srand(20240613);
rocks.length=0;
passable.fill(1);
for(let i=0;i<GN;i++){passable[i]=0;passable[(GN-1)*GN+i]=0;passable[i*GN]=0;passable[i*GN+GN-1]=0;}
const blobs=[];
for(let i=0;i<34;i++){
let x,z,ok=false,tries=0;
while(!ok&&tries++<60){
x=rrange(24,MAP-24);z=rrange(24,MAP-24);
ok=Math.hypot(x-START_P.x,z-START_P.z)>42&&Math.hypot(x-START_A.x,z-START_A.z)>42;
for(const b of blobs)if(dist2(x,z,b.x,b.z)<(b.r+14)*(b.r+14))ok=false;
}
if(!ok)continue;
const r=rrange(5,11);
blobs.push({x,z,r});
addRock(x,z,r,rrange(0.8,1.4));
}
/* chokepoint ridges */
const ridges=[
{x:110,z:70,w:46,h:5},
{x:150,z:186,w:40,h:5},
{x:70,z:150,w:38,h:5},
{x:190,z:96,w:34,h:5}
];
for(const rg of ridges){
for(let t=-rg.w/2;t<rg.w/2;t+=CELL){
const x=rg.x+t,z=rg.z+Math.sin(t*0.11)*4;
if(Math.hypot(x-START_P.x,z-START_P.z)<38||Math.hypot(x-START_A.x,z-START_A.z)<38)continue;
addRock(x,z,rg.h,0.9);
}
}
/* resource nodes */
resNodes.length=0;
const spots=[
{x:62,z:62,n:2},{x:70,z:34,n:1},{x:34,z:74,n:1},
{x:196,z:196,n:2},{x:186,z:224,n:1},{x:226,z:186,n:1},
{x:112,z:118,n:2},{x:146,z:140,n:2},{x:128,z:76,n:1},{x:76,z:132,n:1},
{x:180,z:118,n:1},{x:118,z:182,n:1},{x:96,z:206,n:1},{x:206,z:96,n:1},
{x:160,z:60,n:1},{x:60,z:160,n:1}
];
for(const s of spots){
for(let i=0;i<s.n;i++){
const a=rnd()*TAU,rr=s.n>1?rrange(6,10):0;
let x=s.x+Math.cos(a)*rr,z=s.z+Math.sin(a)*rr;
x=clamp(x,10,MAP-10);z=clamp(z,10,MAP-10);
const cr=[];
const nc=4+((rnd()*3)|0);
for(let k=0;k<nc;k++){
const aa=rnd()*TAU, rr=rnd()*1.7;
cr.push({dx:Math.cos(aa)*rr,dz:Math.sin(aa)*rr,h:rrange(1.3,2.7),r:rrange(0.8,1.7),ry:rnd()*TAU});
}
resNodes.push({x,z,amt:2400,max:2400,r:2.6,id:resNodes.length,crystals:cr});
const c0=clamp(((x-3)/CELL)|0,0,GN-1),c1=clamp(((x+3)/CELL)|0,0,GN-1);
const d0=clamp(((z-3)/CELL)|0,0,GN-1),d1=clamp(((z+3)/CELL)|0,0,GN-1);
for(let gz=d0;gz<=d1;gz++)for(let gx=c0;gx<=c1;gx++){
const wx=gx*CELL+CELL/2,wz=gz*CELL+CELL/2;
if(dist2(wx,wz,x,z)<9)passable[gz*GN+gx]=0;
}
}
}
}
const resNodes=[];
function buildTerrainMesh(){
const N=128, step=MAP/N;
const verts=(N+1)*(N+1);
const pos=new Float32Array(verts*3),nrm=new Float32Array(verts*3),col=new Float32Array(verts*3);
for(let z=0;z<=N;z++)for(let x=0;x<=N;x++){
const i=z*(N+1)+x, wx=x*step, wz=z*step;
const h=terrainH(wx,wz);
pos[i*3]=wx;pos[i*3+1]=h;pos[i*3+2]=wz;
const n=terrainNormal(wx,wz);
nrm[i*3]=n[0];nrm[i*3+1]=n[1];nrm[i*3+2]=n[2];
const t=clamp((h+1)/9,0,1);
let r=0.16+0.10*t,g=0.20+0.16*t,b=0.22+0.10*t;
const nz=vnoise(wx*0.09,wz*0.09);
r+=nz*0.05;g+=nz*0.06;b+=nz*0.03;
if(h>6.0){r=0.42;g=0.44;b=0.47;}
col[i*3]=r;col[i*3+1]=g;col[i*3+2]=b;
}
const idx=[];
for(let z=0;z<N;z++)for(let x=0;x<N;x++){
const a=z*(N+1)+x,b=a+1,c=a+(N+1),d=c+1;
idx.push(a,c,b,b,c,d);
}
const vao=gl.createVertexArray();
gl.bindVertexArray(vao);
function buf(data,loc,size){
const b=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,b);
gl.bufferData(gl.ARRAY_BUFFER,data,gl.STATIC_DRAW);
gl.enableVertexAttribArray(loc);gl.vertexAttribPointer(loc,size,gl.FLOAT,false,0,0);
}
buf(pos,TA.pos,3);buf(nrm,TA.nrm,3);buf(col,TA.col,3);
const ib=gl.createBuffer();gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,ib);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,new Uint32Array(idx),gl.STATIC_DRAW);
gl.bindVertexArray(null);
return {vao,count:idx.length};
}
/* ============================================================
ENTITY DEFINITIONS
============================================================ */
const COL={
metal:[0.44,0.48,0.54], dark:[0.19,0.22,0.27], amber:[0.95,0.72,0.25],
glass:[0.30,0.72,0.92], glow:[0.60,0.96,1.0], accent:[0.90,0.55,0.14],
rock:[0.34,0.34,0.38], crystal:[0.42,0.92,1.0], crystal2:[0.85,0.55,0.95],
white:[0.92,0.95,1.0], team:null
};
const TEAMCOL=[null,[0.16,0.78,1.0],[1.0,0.30,0.16]];
const TEAMDARK=[null,[0.05,0.26,0.36],[0.34,0.09,0.05]];
function partM(p){
const g=p[0],x=p[1],y=p[2],z=p[3],sx=p[4],sy=p[5],sz=p[6],ry=p[7]||0;
const c=Math.cos(ry),s=Math.sin(ry);
const m=new Float32Array(16);
m[0]=c*sx; m[1]=0; m[2]=-s*sx; m[3]=0;
m[4]=0; m[5]=sy;m[6]=0; m[7]=0;
m[8]=s*sz; m[9]=0; m[10]=c*sz; m[11]=0;
m[12]=x; m[13]=y;m[14]=z; m[15]=1;
return m;
}
function buildParts(def){
def._parts=def.parts.map(p=>({g:p[0],m:partM(p),c:p[8]||"team"}));
return def;
}
/* ---------- unit definitions ---------- */
const UNIT_DEFS={
hauler:{
name:"Hauler", key:"hauler", hot:"1", role:"Worker / Harvester",
hp:50, armor:"light", dmgType:"kinetic", dmg:5, range:1.3, cd:1.0,
speed:5.4, sight:11, supply:1, cost:50, bt:9, radius:0.62,
tGround:1, tAir:0, isAir:0, isWorker:1, desc:"Mines crystal, constructs buildings. Weak in a fight.",
parts:[
[GBOX,0,0.34,0, 0.62,0.30,0.80,0,"team"],
[GBOX,0,0.60,-0.14, 0.44,0.26,0.34, 0,"metal"],
[GBOX,0,0.62,0.26, 0.44,0.30,0.34, 0,"amber"],
[GBOX,-0.34,0.20,0, 0.14,0.24,0.86, 0,"dark"],
[GBOX,0.34,0.20,0, 0.14,0.24,0.86, 0,"dark"],
[GBOX,0,0.78,-0.14, 0.30,0.10,0.26, 0,"glass"]
]
},
trooper:{
name:"Trooper", key:"trooper", hot:"2", role:"Ranged Infantry",
hp:72, armor:"light", dmgType:"kinetic", dmg:9, range:7.5, cd:0.85,
speed:4.5, sight:13, supply:1, cost:75, bt:11, radius:0.52,
tGround:1, tAir:1, isAir:0, desc:"Cheap rifleman. Kinetic rounds shred light armour and can hit aircraft.",
parts:[
[GBOX,0,0.22,0, 0.30,0.44,0.30, 0,"dark"],
[GBOX,0,0.62,0, 0.44,0.42,0.32, 0,"team"],
[GBOX,0,0.90,0, 0.22,0.22,0.22, 0,"metal"],
[GCYLZ,0.17,0.66,0.36, 0.09,0.09,0.60, 0,"dark"],
[GBOX,-0.30,0.66,0, 0.14,0.36,0.14, 0,"team"]
]
},
ravager:{
name:"Ravager", key:"ravager", hot:"3", role:"Assault Walker",
hp:200, armor:"heavy", dmgType:"kinetic", dmg:28, range:1.7, cd:1.05,
speed:5.1, sight:11, supply:2, cost:140, bt:16, radius:0.82,
tGround:1, tAir:0, isAir:0, desc:"Heavy close-combat walker. Crushes infantry and shrugs off kinetic fire.",
parts:[
[GBOX,0,0.72,0, 0.92,0.52,1.10, 0,"team"],
[GBOX,0,0.74,0.72, 0.54,0.42,0.44, 0,"metal"],
[GBOX,-0.42,0.30,-0.34, 0.20,0.62,0.20, 0,"dark"],
[GBOX,0.42,0.30,-0.34, 0.20,0.62,0.20, 0,"dark"],
[GBOX,-0.42,0.30,0.36, 0.20,0.62,0.20, 0,"dark"],
[GBOX,0.42,0.30,0.36, 0.20,0.62,0.20, 0,"dark"],
[GCONE,-0.26,1.12,0, 0.22,0.42,0.22, 0,"accent"],
[GCONE,0.26,1.12,0, 0.22,0.42,0.22, 0,"accent"],
[GCONE,0,0.80,1.00, 0.16,0.34,0.16, 0,"accent"]
]
},
bastion:{
name:"Bastion", key:"bastion", hot:"4", role:"Siege Tank",
hp:320, armor:"heavy", dmgType:"explosive", dmg:36, range:9.5, cd:2.0,
speed:2.9, sight:14, supply:3, cost:240, bt:26, radius:1.02,
tGround:1, tAir:0, isAir:0, desc:"Armoured artillery. Explosive shells melt heavy targets. Cannot hit air.",
parts:[
[GBOX,0,0.46,0, 1.10,0.44,1.50, 0,"team"],
[GBOX,-0.66,0.26,0, 0.30,0.34,1.50, 0,"dark"],
[GBOX,0.66,0.26,0, 0.30,0.34,1.50, 0,"dark"],
[GBOX,0,0.82,-0.10, 0.80,0.38,0.90, 0,"metal"],
[GCYLZ,0,0.86,0.72, 0.15,0.15,1.20, 0,"dark"],
[GCYLZ,0,0.86,1.32, 0.20,0.20,0.24, 0,"accent"],
[GBOX,0,1.06,-0.10, 0.40,0.16,0.44, 0,"glass"]
]
},
wasp:{
name:"Wasp", key:"wasp", hot:"5", role:"Gunship",
hp:115, armor:"light", dmgType:"explosive", dmg:19, range:6.5, cd:1.0,
speed:7.6, sight:15, supply:2, cost:170, bt:20, radius:0.85,
tGround:1, tAir:1, isAir:1, desc:"Fast attack aircraft. Ignores terrain. Strong against armour, fragile to flak.",
parts:[
[GBOX,0,0,0, 0.50,0.22,0.90, 0,"team"],
[GBOX,-0.62,0.06,0, 0.90,0.06,0.30, 0,"metal"],
[GBOX,0.62,0.06,0, 0.90,0.06,0.30, 0,"metal"],
[GCYL,-0.62,0.18,0, 0.62,0.05,0.62, 0,"dark"],
[GCYL,0.62,0.18,0, 0.62,0.05,0.62, 0,"dark"],
[GCONE,0,-0.02,-0.66, 0.20,0.36,0.20, 0,"accent"],
[GBOX,0,0.16,0.30, 0.26,0.14,0.30, 0,"glass"]
]
}
};
for(const k in UNIT_DEFS)buildParts(UNIT_DEFS[k]);
/* ---------- building definitions ---------- */
const BLD_DEFS={
hq:{
name:"Command Nexus", key:"hq", size:5, hp:2400, armor:"heavy", sight:17,
cost:500, bt:55, supply:20, dropoff:1, produces:["hauler"], prereq:null,
desc:"Headquarters. Trains Haulers, accepts crystal, provides 20 supply. Losing every Nexus loses the war.",
parts:[
[GCYL,0,0.55,0, 7.0,1.10,7.0, 0,"dark"],
[GBOX,0,1.60,0, 4.6,2.10,4.6, 0,"team"],
[GBOX,0,2.90,0, 3.0,0.70,3.0, 0,"metal"],
[GCONE,0,3.80,0, 3.2,1.60,3.2, 0,"accent"],
[GCYL,-2.5,2.4,0, 0.7,2.6,0.7, 0,"metal"],
[GCYL,2.5,2.4,0, 0.7,2.6,0.7, 0,"metal"],
[GCYL,0,2.4,-2.5, 0.7,2.6,0.7, 0,"metal"],
[GCYL,0,2.4,2.5, 0.7,2.6,0.7, 0,"metal"],
[GSPH,0,4.70,0, 1.1,1.1,1.1, 0,"glow"]
]
},
depot:{
name:"Supply Relay", key:"depot", size:3, hp:750, armor:"heavy", sight:9,
cost:110, bt:18, supply:10, dropoff:0, produces:[], prereq:null,
desc:"Increases supply capacity by 10. Build many to sustain a large army.",
parts:[
[GBOX,0,0.55,0, 3.0,1.10,3.0, 0,"dark"],
[GBOX,0,1.35,0, 2.4,0.70,2.4, 0,"team"],
[GCYL,-0.8,2.05,0, 0.9,1.4,0.9, 0,"metal"],
[GCYL,0.8,2.05,0, 0.9,1.4,0.9, 0,"metal"],
[GCONE,0,3.00,0, 0.9,0.9,0.9, 0,"accent"]
]
},
barracks:{
name:"Foundry", key:"barracks", size:4, hp:1200, armor:"heavy", sight:10,
cost:220, bt:30, supply:0, dropoff:0, produces:["trooper","ravager","bastion"], prereq:null,
desc:"Ground production. Trains Troopers, Ravagers and Bastions. Set a rally point with right-click.",
parts:[
[GBOX,0,0.60,0, 4.0,1.20,3.4, 0,"dark"],
[GBOX,0,1.70,0, 3.2,1.00,2.8, 0,"team"],
[GCYL,0,2.60,-1.0, 0.8,1.4,0.8, 0,"metal"],
[GCYL,0,2.60,1.0, 0.8,1.4,0.8, 0,"metal"],
[GBOX,0,1.00,1.75, 1.4,1.4,0.20, 0,"amber"],
[GCONE,-1.2,3.05,0, 0.7,1.0,0.7, 0,"accent"]
]
},
airpad:{
name:"Aerie", key:"airpad", size:4, hp:1050, armor:"heavy", sight:11,
cost:260, bt:34, supply:0, dropoff:0, produces:["wasp"], prereq:"barracks",
desc:"Air production. Builds Wasp gunships. Requires a Foundry.",
parts:[
[GCYL,0,0.35,0, 4.2,0.70,4.2, 0,"dark"],
[GRING,0,0.74,0, 3.6,1,3.6, 0,"team"],
[GBOX,-1.8,1.20,0, 0.5,1.6,0.5, 0,"metal"],
[GBOX,1.8,1.20,0, 0.5,1.6,0.5, 0,"metal"],
[GBOX,0,1.20,-1.8, 0.5,1.6,0.5, 0,"metal"],
[GBOX,0,1.20,1.8, 0.5,1.6,0.5, 0,"metal"],
[GCYL,0,2.05,0, 1.0,0.5,1.0, 0,"accent"]
]
},
lab:{
name:"Research Spire", key:"lab", size:3, hp:950, armor:"heavy", sight:10,
cost:260, bt:32, supply:0, dropoff:0, produces:[], prereq:"barracks",
desc:"Unlocks permanent upgrades for your whole army. Requires a Foundry.",
parts:[
[GBOX,0,0.60,0, 3.0,1.20,3.0, 0,"dark"],
[GCYL,0,1.90,0, 1.8,1.40,1.8, 0,"team"],
[GSPH,0,3.05,0, 1.6,1.6,1.6, 0,"accent"],
[GBOX,-1.4,1.00,0, 0.4,1.2,1.2, 0,"metal"],
[GBOX,1.4,1.00,0, 0.4,1.2,1.2, 0,"metal"]
]
},
turret:{
name:"Sentry Gun", key:"turret", size:2, hp:850, armor:"heavy", sight:13,
cost:150, bt:22, supply:0, dropoff:0, produces:[], prereq:null,
dmgType:"explosive", dmg:24, range:11.5, cd:1.3, tGround:1, tAir:1,
desc:"Automated defensive emplacement. Fires at ground and air targets.",
parts:[
[GCYL,0,0.40,0, 1.8,0.80,1.8, 0,"dark"],
[GBOX,0,1.10,0, 1.2,0.90,1.2, 0,"team"],
[GCYLZ,0,1.25,0.90, 0.22,0.22,1.40, 0,"metal"],
[GCYLZ,0,1.25,1.62, 0.30,0.30,0.30, 0,"accent"],
[GSPH,0,1.72,0, 0.5,0.5,0.5, 0,"glow"]
]
}
};
for(const k in BLD_DEFS)buildParts(BLD_DEFS[k]);
/* ---------- upgrades ---------- */
const UPGRADES=[
{key:"weapon",name:"Weapon Calibration",cost:250,bt:45,desc:"+20% damage for all units and turrets.",icon:"W"},
{key:"armor", name:"Armour Plating", cost:250,bt:45,desc:"+20% maximum health for all units.",icon:"A"},
{key:"servo", name:"Drive Servos", cost:200,bt:40,desc:"+25% movement speed for all units.",icon:"S"},
{key:"logi", name:"Logistics Grid", cost:200,bt:40,desc:"+30% crystal gathered per trip.",icon:"L"}
];
/* ---------- damage table ---------- */
const ARMOR={light:0,heavy:1};
function damageMult(dt,armor){
const a=armor==="heavy"?1:0;
if(dt==="kinetic")return a?0.70:1.30;
return a?1.35:0.75;
}
/* ============================================================
GAME STATE
============================================================ */
const TEAM_P=1, TEAM_A=2;
let units=[], buildings=[], projectiles=[], effects=[];
let nextId=1;
let gameTime=0, running=false, paused=false, gameOver=0;
const teamState=[
null,
{crystal:900, supply:0, supplyCap:0, upgrades:{}, ai:false},
{crystal:900, supply:0, supplyCap:0, upgrades:{}, ai:true}
];
const knownEnemyBld=new Map(); /* id -> {x,z,key,team,t} last-known silhouettes (player view) */
const aiKnownBld=new Map(); /* id -> {x,z,key,t} AI memory */
const aiKnownUnits=new Map();
let aiMemoryTimer=0;
function mkUnit(type,team,x,z,yaw){
const d=UNIT_DEFS[type];
const u={
id:nextId++, kind:"u", team, type, d,
x, z, y:0, yaw:yaw||0, hp:d.hp*(1+0.2*(teamState[team].upgrades.armor?1:0)), maxHp:0,
cd:rnd()*d.cd, target:null, order:null, path:null, pathI:0, dest:null,
gatherNode:null, carry:0, gatherT:0, state:"idle", flash:0, dead:false,
repathT:0, stuckT:0, lx:x, lz:z, sepX:0, sepZ:0, anim:rnd()*10, homeX:x, homeZ:z,
hold:false, atkMove:false, buildTgt:null, sight:d.sight, rally:null, lastHit:0
};
u.maxHp=u.hp;
units.push(u);
return u;
}
function mkBuilding(type,team,x,z,complete){
const d=BLD_DEFS[type];
const b={
id:nextId++, kind:"b", team, type, d, x, z, yaw:0,
hp:complete?d.hp:d.hp*0.15, maxHp:d.hp, built:complete?1:0, buildP:complete?1:0,
queue:[], target:null, cd:rnd()*2, flash:0, dead:false, sight:d.sight,
rally:null, builders:0, lastHit:0
};
b.maxHp=d.hp;
buildings.push(b);
return b;
}
function unitAt(x,z,r,team){
let best=null,bd=r*r;
for(const u of units){
if(u.dead)continue;
if(team&&u.team!==team)continue;
const d=dist2(u.x,u.z,x,z);
if(d<bd){bd=d;best=u;}
}
return best;
}
function buildingAt(x,z,team){
for(const b of buildings){
if(b.dead)continue;
if(team&&b.team!==team)continue;
const h=b.d.size*CELL*0.5;
if(Math.abs(x-b.x)<=h&&Math.abs(z-b.z)<=h)return b;
}
return null;
}
function supplyUsed(team){
let s=0;
for(const u of units)if(!u.dead&&u.team===team)s+=u.d.supply;
for(const b of buildings)if(!b.dead&&b.team===team&&b.queue.length){
for(const q of b.queue){const ud=UNIT_DEFS[q.type];if(ud)s+=ud.supply;}
}
return s;
}
function supplyCap(team){
let s=0;
for(const b of buildings)if(!b.dead&&b.team===team&&b.built)s+=b.d.supply||0;
return s;
}
function hasBuilding(team,type){
for(const b of buildings)if(!b.dead&&b.team===team&&b.type===type&&b.built)return true;
return false;
}
function teamHasHQ(team){
for(const b of buildings)if(!b.dead&&b.team===team&&b.type==="hq")return true;
return false;
}
function nearestDropoff(team,x,z){
let best=null,bd=1e18;
for(const b of buildings){
if(b.dead||b.team!==team||!b.built||!b.d.dropoff)continue;
const d=dist2(b.x,b.z,x,z);
if(d<bd){bd=d;best=b;}
}
return best;
}
function nearestRes(x,z,maxD){
let best=null,bd=maxD*maxD;
for(const n of resNodes){
if(n.amt<=0)continue;
const d=dist2(n.x,n.z,x,z);
if(d<bd){bd=d;best=n;}
}
return best;
}
function spawnProjectile(team,sx,sy,sz,tx,ty,tz,dmg,dt,arc,color){
const dx=tx-sx,dy=ty-sy,dz=tz-sz;
const d=Math.hypot(dx,dy,dz)||1;
const sp=arc?46:130;
projectiles.push({team,x:sx,y:sy,z:sz,vx:dx/d*sp,vy:dy/d*sp,vz:dz/d*sp,
life:d/sp+0.02,dmg,dt,arc,color,tx,ty,tz,target:null,homing:arc});
}
function addEffect(x,y,z,r,g,b,life,size){
effects.push({x,y,z,r,g,b,life,max:life,size});
}
/* ============================================================
SPATIAL HASH
============================================================ */
const SH_CELL=8, SH_N=Math.ceil(MAP/SH_CELL)+1;
const shBuckets=new Array(SH_N*SH_N);
for(let i=0;i<shBuckets.length;i++)shBuckets[i]=[];
function shKey(x,z){
const cx=clamp((x/SH_CELL)|0,0,SH_N-1), cz=clamp((z/SH_CELL)|0,0,SH_N-1);
return cz*SH_N+cx;
}
function rebuildHash(){
for(let i=0;i<shBuckets.length;i++)if(shBuckets[i].length)shBuckets[i].length=0;
for(const u of units){ if(u.dead)continue; shBuckets[shKey(u.x,u.z)].push(u); }
}
function queryUnits(x,z,r,cb){
const r2=r*r;
const c0=clamp(((x-r)/SH_CELL)|0,0,SH_N-1),c1=clamp(((x+r)/SH_CELL)|0,0,SH_N-1);
const d0=clamp(((z-r)/SH_CELL)|0,0,SH_N-1),d1=clamp(((z+r)/SH_CELL)|0,0,SH_N-1);
for(let cz=d0;cz<=d1;cz++)for(let cx=c0;cx<=c1;cx++){
const arr=shBuckets[cz*SH_N+cx];
for(let i=0;i<arr.length;i++){
const u=arr[i];
if(dist2(u.x,u.z,x,z)<=r2)cb(u);
}
}
}
/* ============================================================
PATHFINDING (A* on coarse grid)
============================================================ */
const pathBlocked=new Uint8Array(GN*GN);
function rebuildPathGrid(){
for(let i=0;i<GN*GN;i++)pathBlocked[i]=passable[i]?0:1;
for(const b of buildings){
if(b.dead)continue;
const h=b.d.size*0.5;
const c0=clamp(((b.x-h)/CELL)|0,0,GN-1),c1=clamp(((b.x+h)/CELL)|0,0,GN-1);
const d0=clamp(((b.z-h)/CELL)|0,0,GN-1),d1=clamp(((b.z+h)/CELL)|0,0,GN-1);
for(let gz=d0;gz<=d1;gz++)for(let gx=c0;gx<=c1;gx++)pathBlocked[gz*GN+gx]=1;
}
}
const _g=new Float32Array(GN*GN);
const _f=new Float32Array(GN*GN);
const _from=new Int32Array(GN*GN);
const _closed=new Uint8Array(GN*GN);
const _stamp=new Int32Array(GN*GN);
let _stampV=0;
const _heap=new Int32Array(GN*GN+4);
let _hn=0;
function hpush(i){
_heap[_hn]=i; let c=_hn++;
while(c>0){const p=(c-1)>>1; if(_f[_heap[p]]<=_f[_heap[c]])break; const t=_heap[p];_heap[p]=_heap[c];_heap[c]=t;c=p;}
}
function hpop(){
const top=_heap[0]; _hn--;
if(_hn>0){
_heap[0]=_heap[_hn]; let p=0;
for(;;){const l=2*p+1,r=l+1;let m=p;
if(l<_hn&&_f[_heap[l]]<_f[_heap[m]])m=l;
if(r<_hn&&_f[_heap[r]]<_f[_heap[m]])m=r;
if(m===p)break;
const t=_heap[p];_heap[p]=_heap[m];_heap[m]=t;p=m;}
}
return top;
}
function cellFree(gx,gz){
if(gx<0||gz<0||gx>=GN||gz>=GN)return false;
return !pathBlocked[gz*GN+gx];
}
function nearestFree(gx,gz){
if(cellFree(gx,gz))return gz*GN+gx;
for(let r=1;r<10;r++){
for(let dz=-r;dz<=r;dz++)for(let dx=-r;dx<=r;dx++){
if(Math.abs(dx)!==r&&Math.abs(dz)!==r)continue;
if(cellFree(gx+dx,gz+dz))return (gz+dz)*GN+(gx+dx);
}
}
return -1;
}
/* Exact grid traversal (Amanatides-Woo DDA) — samples every cell the
segment actually crosses, so string-pulled paths never clip corners
of blocked cells. */
function losClear(x0,z0,x1,z1){
let gx=(x0/CELL)|0, gz=(z0/CELL)|0;
const ex=(x1/CELL)|0, ez=(z1/CELL)|0;
if(!cellFree(gx,gz)||!cellFree(ex,ez))return false;
const dx=x1-x0, dz=z1-z0;
const stepX=dx>0?1:(dx<0?-1:0);
const stepZ=dz>0?1:(dz<0?-1:0);
const tdx=dx!==0?Math.abs(CELL/dx):Infinity;
const tdz=dz!==0?Math.abs(CELL/dz):Infinity;
let tmx=dx!==0?Math.abs(((gx+(stepX>0?1:0))*CELL-x0)/dx):Infinity;
let tmz=dz!==0?Math.abs(((gz+(stepZ>0?1:0))*CELL-z0)/dz):Infinity;
let guard=0;
while((gx!==ex||gz!==ez)&&guard++<512){
if(tmx<tmz){tmx+=tdx;gx+=stepX;}
else{tmz+=tdz;gz+=stepZ;}
if(!cellFree(gx,gz))return false;
}
return true;
}
function findPath(sx,sz,tx,tz){
const sgx0=clamp((sx/CELL)|0,0,GN-1), sgz0=clamp((sz/CELL)|0,0,GN-1);
const sn=nearestFree(sgx0,sgz0);
if(sn<0)return null;
const sgx=sn%GN, sgz=(sn/GN)|0;
let tgx=clamp((tx/CELL)|0,0,GN-1), tgz=clamp((tz/CELL)|0,0,GN-1);
const tn=nearestFree(tgx,tgz);
if(tn<0)return null;
tgx=tn%GN; tgz=(tn/GN)|0;
const start=sgz*GN+sgx, goal=tgz*GN+tgx;
if(start===goal)return [{x:tx,z:tz}];
_stampV++;
_hn=0;
_g[start]=0; _f[start]=Math.hypot(tgx-sgx,tgz-sgz); _from[start]=-1; _closed[start]=0; _stamp[start]=_stampV;
hpush(start);
let found=false, expanded=0;
const CAP=4200;
while(_hn>0&&expanded<CAP){
const cur=hpop();
if(_stamp[cur]!==_stampV||_closed[cur])continue;
_closed[cur]=1; expanded++;
if(cur===goal){found=true;break;}
const cx=cur%GN, cz=(cur/GN)|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(!cellFree(nx,nz))continue;
if(dx&&dz&&(!cellFree(cx+dx,cz)||!cellFree(cx,cz+dz)))continue;
const ni=nz*GN+nx;
if(_stamp[ni]===_stampV&&_closed[ni])continue;
const step=(dx&&dz)?1.4142:1.0;
const ng=_g[cur]+step;
if(_stamp[ni]!==_stampV||ng<_g[ni]){
_stamp[ni]=_stampV; _g[ni]=ng; _from[ni]=cur; _closed[ni]=0;
const hx=Math.abs(nx-tgx),hz=Math.abs(nz-tgz);
_f[ni]=ng+(hx>hz?(hx-hz+hz*1.4142):(hz-hx+hx*1.4142));
hpush(ni);
}
}
}
if(!found)return null;
const pts=[];
let cur=goal;
while(cur!==-1){
pts.push({x:(cur%GN)*CELL+CELL/2, z:((cur/GN)|0)*CELL+CELL/2});
cur=_from[cur];
}
pts.reverse();
pts[pts.length-1]={x:tx,z:tz};
/* string pull */
const out=[];
let i=0;
let guard=0;
while(i<pts.length&&guard++<400){
let j=pts.length-1;
while(j>i+1&&!losClear(pts[i].x,pts[i].z,pts[j].x,pts[j].z))j--;
out.push(pts[j]);
i=j;
if(j===pts.length-1)break;
}
if(!out.length)out.push({x:tx,z:tz});
return out;
}
/* ============================================================
COMBAT
============================================================ */
function canTarget(a,b){
if(!b||b.dead)return false;
if(b.kind==="u"&&b.d.isAir&&!a.d.tAir)return false;
if(b.kind==="u"&&!b.d.isAir&&!a.d.tGround)return false;
if(b.kind==="b"&&!a.d.tGround)return false;
return true;
}
function applyDamage(tgt,dmg,dt,attacker){
if(!tgt||tgt.dead)return;
const m=damageMult(dt,tgt.d.armor);
tgt.hp-=dmg*m;
tgt.flash=0.16;
tgt.lastHit=gameTime;
if(tgt.hp<=0)killEntity(tgt,attacker);
}
function killEntity(e,attacker){
if(e.dead)return;
e.dead=true;
const y=e.kind==="u"?e.y+0.8:1.6;
const big=e.kind==="b";
const n=big?14:6;
for(let i=0;i<n;i++){
addEffect(e.x+rrange(-1,1)*(big?2:0.6), y+rrange(-0.5,1)*(big?2:0.8), e.z+rrange(-1,1)*(big?2:0.6),
1,0.55+rnd()*0.3,0.15, rrange(0.35,0.8), rrange(0.4,1.1)*(big?2.2:1));
}
sfx("explode",big);
if(e.kind==="b"){
if(e.type==="hq")toast((e.team===TEAM_P?"Your":"Enemy")+" Command Nexus destroyed",e.team===TEAM_P?"bad":"good");
rebuildPathGrid();
/* push units out of footprint */
const h=e.d.size*0.5+0.6;
for(const u of units){
if(u.dead||u.d.isAir)continue;
if(Math.abs(u.x-e.x)<h&&Math.abs(u.z-e.z)<h){
const dx=u.x-e.x,dz=u.z-e.z;
const l=Math.hypot(dx,dz)||1;
u.x=e.x+dx/l*(h+0.5); u.z=e.z+dz/l*(h+0.5);
}
}
}
checkVictory();
}
function acquireTarget(u){
const d=u.d;
let best=null,bd=d.sight*d.sight;
queryUnits(u.x,u.z,d.sight,(o)=>{
if(o.team===u.team||o.dead)return;
if(!canTarget(u,o))return;
if(u.team===TEAM_P){if(!isVisible(o.x,o.z))return;}
else{if(!aiVisible(o.x,o.z))return;}
const dd=dist2(u.x,u.z,o.x,o.z);
if(dd<bd){bd=dd;best=o;}
});
if(!best){
for(const b of buildings){
if(b.dead||b.team===u.team)continue;
if(!canTarget(u,b))continue;
if(u.team===TEAM_P){if(!isVisible(b.x,b.z))continue;}
else{if(!aiVisible(b.x,b.z))continue;}
const dd=dist2(u.x,u.z,b.x,b.z);
if(dd<bd){bd=dd;best=b;}
}
}
return best;
}
function entityRadius(e){return e.kind==="u"?e.d.radius:e.d.size*CELL*0.5;}
function inRange(u,t){
const r=u.d.range+entityRadius(t)*0.85;
return dist2(u.x,u.z,t.x,t.z)<=r*r;
}
function doAttack(u,t){
const d=u.d;
const dmg=d.dmg*(1+0.2*(teamState[u.team].upgrades.weapon?1:0));
const ty=t.kind==="u"?(t.d.isAir?t.y+0.2:t.y+0.7):1.5;
if(d.range>3.2){
const col=d.dmgType==="explosive"?[1.0,0.62,0.16]:[1.0,0.94,0.55];
projectiles.push({team:u.team,x:u.x,y:u.y+1.15,z:u.z,vx:0,vy:0,vz:0,
life:3.0,dmg,dt:d.dmgType,arc:d.dmgType==="explosive",color:col,tgt:t,homing:true,speed:d.dmgType==="explosive"?42:150});
sfx(d.dmgType==="explosive"?"boom":"shot",false);
}else{
applyDamage(t,dmg,d.dmgType,u);
addEffect(t.x,ty,t.z,1,0.85,0.4,0.18,0.7);
sfx("hit",false);
}
u.cd=d.cd;
}
function buildingAttack(b,t){
const d=b.d;
const dmg=d.dmg*(1+0.2*(teamState[b.team].upgrades.weapon?1:0));
const ty=t.kind==="u"?(t.d.isAir?t.y+0.2:t.y+0.7):1.5;
projectiles.push({team:b.team,x:b.x,y:gH(b.x,b.z)+1.6,z:b.z,vx:0,vy:0,vz:0,
life:3.0,dmg,dt:d.dmgType,arc:false,color:[1.0,0.62,0.16],tgt:t,homing:true,speed:120});
b.cd=d.cd;
sfx("shot",false);
}
/* ============================================================
VISION
============================================================ */
let visTimer=0;
function updateVision(){
fogVis.fill(0);
for(const u of units){
if(u.dead||u.team!==TEAM_P)continue;
markVision(u.x,u.z,u.d.sight,fogVis);
markExplored(u.x,u.z,u.d.sight);
}
for(const b of buildings){
if(b.dead||b.team!==TEAM_P)continue;
const r=b.d.sight*(b.built?1:0.6);
markVision(b.x,b.z,r,fogVis);
markExplored(b.x,b.z,r);
}
/* last-known enemy buildings */
for(const b of buildings){
if(b.dead||b.team!==TEAM_A)continue;
if(isVisible(b.x,b.z))knownEnemyBld.set(b.id,{x:b.x,z:b.z,key:b.type,t:gameTime,built:b.built});
}
for(const [id,rec] of knownEnemyBld){
let alive=false;
for(const b of buildings)if(b.id===id&&!b.dead){alive=true;break;}
if(!alive)knownEnemyBld.delete(id);
}
fogDirty=true;
}
function aiVision(){
aiVis.fill(0);
const seenB=new Set(), seenU=new Set();
const check=(x,z,r)=>{
markVision(x,z,r,aiVis);
markExpArr(x,z,r,aiExp);
for(const b of buildings){
if(b.dead||b.team!==TEAM_P)continue;
if(dist2(x,z,b.x,b.z)<r*r){seenB.add(b.id);aiKnownBld.set(b.id,{x:b.x,z:b.z,key:b.type,t:gameTime});}
}
for(const u of units){
if(u.dead||u.team!==TEAM_P)continue;
if(dist2(x,z,u.x,u.z)<r*r){seenU.add(u.id);aiKnownUnits.set(u.id,{x:u.x,z:u.z,type:u.type,t:gameTime});}
}
};
for(const u of units)if(!u.dead&&u.team===TEAM_A)check(u.x,u.z,u.d.sight);
for(const b of buildings)if(!b.dead&&b.team===TEAM_A)check(b.x,b.z,b.d.sight);
for(const [id,rec] of aiKnownBld){
let alive=false;
for(const b of buildings)if(b.id===id&&!b.dead){alive=true;break;}
if(!alive)aiKnownBld.delete(id);
}
for(const [id,rec] of aiKnownUnits){
let alive=false;
for(const u of units)if(u.id===id&&!u.dead){alive=true;break;}
if(!alive)aiKnownUnits.delete(id);
}
}
/* ============================================================
MOVEMENT HELPERS
============================================================ */
function setPathTo(u,tx,tz){
u.pathDestX=tx; u.pathDestZ=tz;
if(u.d.isAir){u.path=[{x:tx,z:tz}];u.pathI=0;return;}
const p=findPath(u.x,u.z,tx,tz);
if(p&&p.length){u.path=p;u.pathI=0;}else{u.path=null;}
}
function needPath(u,tx,tz){
return !u.path||u.pathI>=u.path.length||u.pathDestX===undefined||
Math.abs(u.pathDestX-tx)>2.6||Math.abs(u.pathDestZ-tz)>2.6;
}
function moveAlong(u,dt){
if(!u.path||u.pathI>=u.path.length)return true;
const wp=u.path[u.pathI];
const dx=wp.x-u.x,dz=wp.z-u.z;
const dd=Math.hypot(dx,dz);
const last=u.pathI===u.path.length-1;
if(dd<(last?0.45:0.85)){u.pathI++;if(u.pathI>=u.path.length){u.path=null;return true;}return false;}
const sp=u.d.speed*(1+0.25*(teamState[u.team].upgrades.servo?1:0));
const step=Math.min(dd,sp*dt);
const px=u.x,pz=u.z;
u.x+=dx/dd*step; u.z+=dz/dd*step;
u.yaw=Math.atan2(dx,dz);
if(!u.d.isAir){
const gx=(u.x/CELL)|0,gz=(u.z/CELL)|0;
const ogx=(px/CELL)|0,ogz=(pz/CELL)|0;
if(!cellFree(gx,gz)&&cellFree(ogx,ogz)){
/* slide along the free axis before giving up */
if(cellFree(gx,ogz)){u.z=pz;}
else if(cellFree(ogx,gz)){u.x=px;}
else{u.x=px;u.z=pz;u.path=null;u.pathDestX=undefined;}
}
}
u.x=clamp(u.x,2,MAP-2); u.z=clamp(u.z,2,MAP-2);
return false;
}
function separate(u,dt){
let px=0,pz=0,n=0;
queryUnits(u.x,u.z,3.2,(o)=>{
if(o===u||o.dead)return;
if(o.d.isAir!==u.d.isAir)return;
const dx=u.x-o.x,dz=u.z-o.z;
const d2=dx*dx+dz*dz;
const md=u.d.radius+o.d.radius;
if(d2<md*md&&d2>0.0004){
const dd=Math.sqrt(d2);
const f=(md-dd)/md;
px+=dx/dd*f; pz+=dz/dd*f; n++;
}
});
if(n){
const k=Math.min(1,dt*7);
u.x+=px*k*0.9; u.z+=pz*k*0.9;
u.x=clamp(u.x,2,MAP-2); u.z=clamp(u.z,2,MAP-2);
}
}
/* ============================================================
UNIT ORDERS / BEHAVIOUR
============================================================ */
function orderMove(u,x,z,queue){
u.cmd="move"; u.dest={x,z}; u.target=null; u.node=null; u.buildTgt=null;
u.pathDestX=undefined;
setPathTo(u,x,z);
}
function orderAttackMove(u,x,z){
u.cmd="amove"; u.dest={x,z}; u.target=null; u.node=null; u.buildTgt=null;
u.pathDestX=undefined;
setPathTo(u,x,z);
}
function orderAttack(u,t){
u.cmd="attack"; u.target=t; u.dest={x:t.x,z:t.z}; u.node=null; u.buildTgt=null;
u.pathDestX=undefined;
}
function orderGather(u,node){
u.cmd="gather"; u.node=node; u.target=null; u.buildTgt=null; u.path=null; u.pathDestX=undefined;
}
function orderBuild(u,b){
u.cmd="build"; u.buildTgt=b; u.target=null; u.node=null; u.pathDestX=undefined;
setPathTo(u,b.x,b.z);
}
function orderStop(u){
u.cmd="hold"; u.path=null; u.target=null; u.node=null; u.buildTgt=null; u.dest=null;
}
function updateGather(u,dt){
const d=u.d;
if(u.carry>0){
const dp=nearestDropoff(u.team,u.x,u.z);
if(!dp){u.carry=0;return;}
const reach=dp.d.size*CELL*0.5+2.0;
if(dist2(dp.x,dp.z,u.x,u.z)<reach*reach){
teamState[u.team].crystal+=u.carry;
u.carry=0; u.gatherT=0; u.path=null; u.pathDestX=undefined;
if(u.team===TEAM_P)sfx("deposit",false);
}else{
if(needPath(u,dp.x,dp.z))setPathTo(u,dp.x,dp.z);
moveAlong(u,dt);
}
return;
}
if(!u.node||u.node.amt<=0){
u.node=nearestRes(u.x,u.z,70);
if(!u.node){u.cmd="hold";return;}
u.path=null;u.pathDestX=undefined;
}
const nd=u.node;
const reach=nd.r+1.5;
if(dist2(nd.x,nd.z,u.x,u.z)<reach*reach){
u.path=null;
u.yaw=Math.atan2(nd.x-u.x,nd.z-u.z);
u.gatherT+=dt;
if(u.gatherT>1.1){
const amt=Math.min(9*(1+0.3*(teamState[u.team].upgrades.logi?1:0)),nd.amt);
nd.amt-=amt; u.carry=amt; u.gatherT=0;
addEffect(nd.x,gH(nd.x,nd.z)+1.2,nd.z,0.35,0.9,1.0,0.35,0.45);
u.path=null;u.pathDestX=undefined;
}
}else{
if(needPath(u,nd.x,nd.z))setPathTo(u,nd.x,nd.z);
moveAlong(u,dt);
}
}
function updateBuildOrder(u,dt){
const b=u.buildTgt;
if(!b||b.dead||b.built){
u.buildTgt=null;
/* return to mining automatically once the structure is up */
const n=nearestRes(u.x,u.z,110);
if(n)orderGather(u,n); else u.cmd="hold";
return;
}
const reach=b.d.size*CELL*0.5+2.0;
if(dist2(b.x,b.z,u.x,u.z)>reach*reach){
if(needPath(u,b.x,b.z))setPathTo(u,b.x,b.z);
moveAlong(u,dt);
}else{
u.path=null;
u.yaw=Math.atan2(b.x-u.x,b.z-u.z);
u.anim+=dt*3;
}
}
function updateCombat(u,dt){
let tgt=u.target;
if(tgt&&tgt.dead)tgt=null;
if(!tgt&&(u.cmd==="idle"||u.cmd==="hold"||u.cmd==="amove"||u.cmd==="attack")){
tgt=acquireTarget(u); u.target=tgt;
}
if(tgt&&!canTarget(u,tgt)){tgt=null;u.target=null;}
if(!tgt)return false;
if(inRange(u,tgt)){
u.path=null;
u.yaw=Math.atan2(tgt.x-u.x,tgt.z-u.z);
if(u.cd<=0)doAttack(u,tgt);
return true;
}
if(u.cmd==="attack"||u.cmd==="amove"){
if(needPath(u,tgt.x,tgt.z))setPathTo(u,tgt.x,tgt.z);
moveAlong(u,dt);
return true;
}
u.target=null;
return false;
}
function updateUnit(u,dt){
if(u.dead)return;
u.flash=Math.max(0,u.flash-dt);
u.cd-=dt;
u.anim+=dt;
const d=u.d;
if(d.isAir){
const gh=gH(u.x,u.z);
u.y=lerp(u.y,gh+7.5+Math.sin(u.anim*2.2)*0.3,Math.min(1,dt*3));
}else{
u.y=gH(u.x,u.z);
}
if(d.isWorker&&u.cmd==="gather"){updateGather(u,dt);separate(u,dt);return;}
if(u.cmd==="build"&&u.buildTgt){updateBuildOrder(u,dt);separate(u,dt);return;}
if(updateCombat(u,dt)){separate(u,dt);return;}
if(u.cmd==="move"||u.cmd==="amove"){
if(u.path){
const arrived=moveAlong(u,dt);
if(arrived){u.cmd="hold";u.dest=null;}
}else if(u.dest){
/* path lost (blocked/revert) — re-route unless we are basically there */
if(dist2(u.x,u.z,u.dest.x,u.dest.z)<9){u.cmd="hold";u.dest=null;}
else setPathTo(u,u.dest.x,u.dest.z);
}else{u.cmd="hold";}
}else if(u.cmd==="idle"){
u.cmd="hold";
}
separate(u,dt);
/* stuck detection */
if(u.path){
const moved=dist2(u.x,u.z,u.lx,u.lz);
if(moved<0.02){u.stuckT+=dt;}else{u.stuckT=0;}
if(u.stuckT>1.4){
u.stuckT=0;
const wp=u.path[u.path.length-1];
setPathTo(u,wp.x,wp.z);
}
}else u.stuckT=0;
u.lx=u.x; u.lz=u.z;
}
/* ============================================================
BUILDINGS
============================================================ */
function finishBuilding(b){
b.built=1; b.buildP=1;
rebuildPathGrid();
for(const u of units){
if(u.buildTgt===b){u.buildTgt=null;u.cmd="hold";}
}
addEffect(b.x,gH(b.x,b.z)+2,b.z,0.4,0.9,1,0.6,2.4);
sfx("build",false);
if(b.team===TEAM_P)toast(b.d.name+" online","good");
}
function updateBuilding(b,dt){
if(b.dead)return;
b.flash=Math.max(0,b.flash-dt);
b.cd-=dt;
if(!b.built){
let n=0;
for(const u of units){
if(u.dead||u.team!==b.team||!u.d.isWorker||u.buildTgt!==b)continue;
const reach=b.d.size*CELL*0.5+2.4;
if(dist2(u.x,u.z,b.x,b.z)<reach*reach)n++;
}
if(n>0){
b.buildP+=dt/b.d.bt*Math.min(n,3)*0.95;
if(b.buildP>=1)finishBuilding(b);
}
return;
}
/* production */
if(b.queue.length){
const q=b.queue[0];
const cap=supplyCap(b.team), used=supplyUsed(b.team);
if(cap<used){
q.blocked=true;
if(b.team===TEAM_P&&!b._warned){toast("Supply blocked — build a Supply Relay","bad");b._warned=true;}
}else{
q.blocked=false; b._warned=false;
q.t+=dt;
if(q.t>=q.bt){
b.queue.shift();
if(q.type==="__up"){
teamState[b.team].upgrades[q.up]=true;
if(q.up==="armor"){
for(const u of units)if(u.team===b.team){u.maxHp=u.d.hp*1.2;u.hp*=1.2;}
}
if(b.team===TEAM_P){toast(UPGRADES.find(x=>x.key===q.up).name+" complete","good");sfx("build",false);}
return;
}
const ud=UNIT_DEFS[q.type];
const ang=rnd()*TAU;
const off=b.d.size*CELL*0.5+1.6;
let sx=b.x+Math.cos(ang)*off, sz=b.z+Math.sin(ang)*off;
if(!cellFree((sx/CELL)|0,(sz/CELL)|0)){sx=b.x+off;sz=b.z;}
const nu=mkUnit(q.type,b.team,clamp(sx,3,MAP-3),clamp(sz,3,MAP-3),rnd()*TAU);
nu.cmd="hold";
if(b.rally)orderMove(nu,b.rally.x,b.rally.z);
if(b.team===TEAM_P)sfx("ready",false);
}
}
}
/* turret combat */
if(b.d.dmg){
let tgt=b.target;
if(tgt&&tgt.dead)tgt=null;
if(!tgt){
let best=null,bd=b.d.range*b.d.range;
queryUnits(b.x,b.z,b.d.range,(o)=>{
if(o.team===b.team||o.dead)return;
if(o.d.isAir&&!b.d.tAir)return;
if(!o.d.isAir&&!b.d.tGround)return;
const dd=dist2(b.x,b.z,o.x,o.z);
if(dd<bd){bd=dd;best=o;}
});
tgt=best;
}
b.target=tgt;
if(tgt&&b.cd<=0)buildingAttack(b,tgt);
}
}
/* ============================================================
PROJECTILES & EFFECTS
============================================================ */
function updateProjectiles(dt){
for(let i=projectiles.length-1;i>=0;i--){
const p=projectiles[i];
p.life-=dt;
const t=p.tgt;
if(t&&!t.dead){
const ty=t.kind==="u"?(t.d.isAir?t.y+0.2:t.y+0.75):1.5;
const dx=t.x-p.x,dy=ty-p.y,dz=t.z-p.z;
const dd=Math.hypot(dx,dy,dz)||1;
if(p.homing){
const sp=p.speed;
p.vx=lerp(p.vx,dx/dd*sp,Math.min(1,dt*8));
p.vy=lerp(p.vy,dy/dd*sp,Math.min(1,dt*8));
p.vz=lerp(p.vz,dz/dd*sp,Math.min(1,dt*8));
}
if(dd<1.1){
applyDamage(t,p.dmg,p.dt,null);
addEffect(p.x,p.y,p.z,p.color[0],p.color[1],p.color[2],0.22,p.arc?1.3:0.6);
if(p.arc)sfx("boom",false);
projectiles.splice(i,1);
continue;
}
}
p.x+=p.vx*dt; p.y+=p.vy*dt; p.z+=p.vz*dt;
if(p.arc)p.vy-=26*dt;
if(p.life<=0||p.y<gH(p.x,p.z)-1)projectiles.splice(i,1);
}
}
function updateEffects(dt){
for(let i=effects.length-1;i>=0;i--){
const e=effects[i];
e.life-=dt;
if(e.life<=0)effects.splice(i,1);
}
}
/* ============================================================
MAIN SIM STEP
============================================================ */
let compactT=0;
function stepSim(dt){
gameTime+=dt;
rebuildHash();
for(let i=0;i<units.length;i++)updateUnit(units[i],dt);
for(let i=0;i<buildings.length;i++)updateBuilding(buildings[i],dt);
updateProjectiles(dt);
updateEffects(dt);
visTimer-=dt;
if(visTimer<=0){visTimer=0.22;updateVision();}
aiMemoryTimer-=dt;
if(aiMemoryTimer<=0){aiMemoryTimer=0.5;aiVision();}
compactT-=dt;
if(compactT<=0){
compactT=2.0;
units=units.filter(u=>!u.dead);
buildings=buildings.filter(b=>!b.dead);
}
updateAI(dt);
checkVictory();
}
/* ============================================================
CAMERA
============================================================ */
const cam={x:46,z:46,dist:78,yaw:0.75,pitch:0.82,tx:46,tz:46};
let W=1,H=1;
const mVP=new Float32Array(16),mP=new Float32Array(16),mV=new Float32Array(16),mIVP=new Float32Array(16);
function camEye(){
const cp=Math.cos(cam.pitch),sp=Math.sin(cam.pitch);
const ty=gH(cam.x,cam.z)+2;
return [cam.x+Math.sin(cam.yaw)*cp*cam.dist, ty+sp*cam.dist, cam.z+Math.cos(cam.yaw)*cp*cam.dist, ty];
}
function updateCameraMatrices(){
const e=camEye();
m4persp(mP,0.62,W/H,1.5,900);
m4look(mV,e[0],e[1],e[2],cam.x,e[3],cam.z,0,1,0);
m4mul(mP,mV,mVP);
m4inv(mVP,mIVP);
}
function unproject(nx,ny,nz){
const m=mIVP;
const x=nx,y=ny,z=nz;
const w=m[3]*x+m[7]*y+m[11]*z+m[15];
const iw=1/(w||1e-6);
return [(m[0]*x+m[4]*y+m[8]*z+m[12])*iw,
(m[1]*x+m[5]*y+m[9]*z+m[13])*iw,
(m[2]*x+m[6]*y+m[10]*z+m[14])*iw];
}
function screenToGround(sx,sy){
const nx=(sx/W)*2-1, ny=1-(sy/H)*2;
const a=unproject(nx,ny,-1), b=unproject(nx,ny,1);
const dx=b[0]-a[0],dy=b[1]-a[1],dz=b[2]-a[2];
if(Math.abs(dy)<1e-6)return null;
let h=2;
for(let i=0;i<5;i++){
const t=(h-a[1])/dy;
if(t<0)return null;
const x=a[0]+dx*t,z=a[2]+dz*t;
h=gH(x,z);
if(i===4)return {x:clamp(x,0,MAP),z:clamp(z,0,MAP)};
}
return null;
}
function worldToScreen(x,y,z){
const m=mVP;
const w=m[3]*x+m[7]*y+m[11]*z+m[15];
if(w<=0.001)return null;
const iw=1/w;
return {x:(m[0]*x+m[4]*y+m[8]*z+m[12])*iw*0.5*W+0.5*W,
y:0.5*H-(m[1]*x+m[5]*y+m[9]*z+m[13])*iw*0.5*H, w};
}
/* ============================================================
INPUT
============================================================ */
const keys={};
let mouseX=0,mouseY=0,overHUD=false;
let dragStart=null,dragging=false;
let placing=null; /* {type, valid} */
let pendingCmd=null; /* 'amove' */
let selBox=document.getElementById("selbox");
let selected=[];
let controlGroups={};
function isHUD(t){
while(t&&t!==document.body){
if(t.id==="topbar"||t.id==="bottombar"||t.id==="help"||t.id==="overlay")return true;
t=t.parentElement;
}
return false;
}
canvas.addEventListener("mousemove",e=>{
mouseX=e.clientX; mouseY=e.clientY;
if(dragging&&dragStart){
const x=Math.min(dragStart.x,e.clientX),y=Math.min(dragStart.y,e.clientY);
const w=Math.abs(e.clientX-dragStart.x),h=Math.abs(e.clientY-dragStart.y);
if(w>6||h>6){
selBox.style.display="block";
selBox.style.left=x+"px";selBox.style.top=y+"px";
selBox.style.width=w+"px";selBox.style.height=h+"px";
}
}
});
window.addEventListener("mouseleave",()=>{overHUD=true;});
canvas.addEventListener("mousedown",e=>{
if(e.button===0){
if(placing){tryPlace(mouseX,mouseY);return;}
if(pendingCmd){issuePending(mouseX,mouseY);return;}
dragStart={x:e.clientX,y:e.clientY,shift:e.shiftKey};dragging=true;
}else if(e.button===1){
dragStart={rot:true,x:e.clientX,y:e.clientY};
}else if(e.button===2){
if(placing){placing=null;refreshActions();return;}
if(pendingCmd){pendingCmd=null;refreshActions();return;}
issueOrder(mouseX,mouseY);
}
});
window.addEventListener("mouseup",e=>{
if(e.button===0&&dragging){
dragging=false;selBox.style.display="none";
const w=Math.abs(e.clientX-dragStart.x),h=Math.abs(e.clientY-dragStart.y);
if(w>6||h>6)boxSelect(Math.min(dragStart.x,e.clientX),Math.min(dragStart.y,e.clientY),w,h,dragStart.shift);
else pickAt(e.clientX,e.clientY,dragStart.shift);
dragStart=null;
}
if(e.button===1)dragStart=null;
});
canvas.addEventListener("dblclick",e=>{
const g=screenToGround(e.clientX,e.clientY);
if(!g)return;
const u=unitAt(g.x,g.z,2.0,TEAM_P);
if(u)selectSameType(u);
});
canvas.addEventListener("contextmenu",e=>e.preventDefault());
canvas.addEventListener("wheel",e=>{
e.preventDefault();
cam.dist=clamp(cam.dist*(1+Math.sign(e.deltaY)*0.12),26,150);
},{passive:false});
window.addEventListener("keydown",e=>{
const k=e.key.toLowerCase();
keys[k]=true;
if(k==="p"){togglePause();e.preventDefault();return;}
if(k==="f1"){toggleHelp();e.preventDefault();return;}
if(k==="m"){toggleMute();e.preventDefault();return;}
if(k==="escape"){placing=null;pendingCmd=null;refreshActions();return;}
if(e.key>="1"&&e.key<="9"){
const n=parseInt(e.key);
if(e.ctrlKey||e.metaKey){controlGroups[n]=selected.slice();toast("Control group "+n+" set","good");}
else if(controlGroups[n]&&controlGroups[n].length){
selected=controlGroups[n].filter(u=>!u.dead);
refreshSelection();
}
e.preventDefault();return;
}
if(k==="a"){pendingCmd="amove";refreshActions();e.preventDefault();return;}
if(k==="s"){cmdStop();e.preventDefault();return;}
if(k==="h"){cmdHold();e.preventDefault();return;}
if(k===" "){centerOnSelection();e.preventDefault();return;}
if(k==="arrowleft"||k==="arrowright"||k==="arrowup"||k==="arrowdown")e.preventDefault();
});
window.addEventListener("keyup",e=>{keys[e.key.toLowerCase()]=false;});
window.addEventListener("blur",()=>{for(const k in keys)keys[k]=false;});
function updateCamera(dt){
const sp=(30+cam.dist*0.55)*dt;
const fx=-Math.sin(cam.yaw),fz=-Math.cos(cam.yaw);
const rx=-Math.cos(cam.yaw),rz=Math.sin(cam.yaw);
let mx=0,mz=0;
if(keys["arrowup"]){mx+=fx;mz+=fz;}
if(keys["arrowdown"]){mx-=fx;mz-=fz;}
if(keys["arrowleft"]){mx-=rx;mz-=rz;}
if(keys["arrowright"]){mx+=rx;mz+=rz;}
if(!overHUD){
const M=14;
if(mouseX<M){mx-=rx;mz-=rz;}
if(mouseX>W-M){mx+=rx;mz+=rz;}
if(mouseY<M+38){mx+=fx;mz+=fz;}
if(mouseY>H-M-160){mx-=fx;mz-=fz;}
}
const l=Math.hypot(mx,mz);
if(l>0){cam.x+=mx/l*sp;cam.z+=mz/l*sp;}
if(keys["q"])cam.yaw+=dt*1.5;
if(keys["e"])cam.yaw-=dt*1.5;
if(dragStart&&dragStart.rot){
cam.yaw-=(mouseX-dragStart.x)*0.006;
cam.pitch=clamp(cam.pitch+(mouseY-dragStart.y)*0.004,0.35,1.35);
dragStart.x=mouseX;dragStart.y=mouseY;
}
cam.x=clamp(cam.x,0,MAP);cam.z=clamp(cam.z,0,MAP);
}
function centerOnSelection(){
if(!selected.length)return;
let x=0,z=0;
for(const s of selected){x+=s.x;z+=s.z;}
cam.x=x/selected.length;cam.z=z/selected.length;
}
/* ============================================================
SELECTION
============================================================ */
function pickAt(sx,sy,add){
let best=null,bestScore=1e9;
for(const u of units){
if(u.dead)continue;
if(u.team!==TEAM_P&&!isVisible(u.x,u.z))continue;
const s=worldToScreen(u.x,u.y+0.8,u.z);
if(!s)continue;
const dx=s.x-sx,dy=s.y-sy;
const d=Math.hypot(dx,dy);
const px=Math.max(14,(u.d.radius/s.w)*W*0.55);
if(d<px&&s.w<bestScore){bestScore=s.w;best=u;}
}
if(!best){
for(const b of buildings){
if(b.dead)continue;
if(b.team!==TEAM_P&&!isVisible(b.x,b.z))continue;
const s=worldToScreen(b.x,gH(b.x,b.z)+1.6,b.z);
if(!s)continue;
const dx=s.x-sx,dy=s.y-sy;
const d=Math.hypot(dx,dy);
const px=Math.max(20,(b.d.size*CELL*0.5/s.w)*W*0.75);
if(d<px&&s.w<bestScore){bestScore=s.w;best=b;}
}
}
if(!best){if(!add){selected=[];refreshSelection();}return;}
if(add){
const i=selected.indexOf(best);
if(i>=0)selected.splice(i,1);else selected.push(best);
}else selected=[best];
refreshSelection();
}
function boxSelect(x0,y0,w,h,add){
const x1=x0+w,y1=y0+h;
const found=[];
for(const u of units){
if(u.dead||u.team!==TEAM_P)continue;
const s=worldToScreen(u.x,u.y+0.8,u.z);
if(!s)continue;
if(s.x>=x0&&s.x<=x1&&s.y>=y0&&s.y<=y1)found.push(u);
}
if(!found.length){
for(const b of buildings){
if(b.dead||b.team!==TEAM_P)continue;
const s=worldToScreen(b.x,gH(b.x,b.z)+1.6,b.z);
if(!s)continue;
if(s.x>=x0&&s.x<=x1&&s.y>=y0&&s.y<=y1)found.push(b);
}
}
if(add)selected=selected.concat(found.filter(f=>selected.indexOf(f)<0));
else selected=found;
refreshSelection();
}
function selectSameType(ref){
if(!ref||ref.kind!=="u")return;
const vis=[];
for(const u of units){
if(u.dead||u.team!==TEAM_P||u.type!==ref.type)continue;
const s=worldToScreen(u.x,u.y+0.8,u.z);
if(s&&s.x>0&&s.x<W&&s.y>0&&s.y<H)vis.push(u);
}
selected=vis;refreshSelection();
}
/* ============================================================
ORDERS
============================================================ */
function issueOrder(sx,sy){
if(!selected.length)return;
const g=screenToGround(sx,sy);
if(!g)return;
let node=null;
for(const n of resNodes){ if(n.amt>0&&dist2(n.x,n.z,g.x,g.z)<(n.r+2.2)*(n.r+2.2)){node=n;break;} }
const tgtU=unitAt(g.x,g.z,1.6,0);
const tgtB=buildingAt(g.x,g.z,0);
let enemy=null;
if(tgtU&&tgtU.team!==TEAM_P&&isVisible(tgtU.x,tgtU.z))enemy=tgtU;
else if(tgtB&&tgtB.team!==TEAM_P&&isVisible(tgtB.x,tgtB.z))enemy=tgtB;
let any=false;
const workers=selected.filter(s=>s.kind==="u"&&s.d.isWorker);
const fighters=selected.filter(s=>s.kind==="u"&&!s.d.isWorker);
const blds=selected.filter(s=>s.kind==="b");
if(enemy){
for(const s of fighters)orderAttack(s,enemy);
for(const s of workers)orderAttack(s,enemy);
any=true;
}else if(node&&workers.length){
for(const s of workers)orderGather(s,node);
for(const s of fighters)orderMove(s,g.x,g.z);
any=true;
}else{
/* formation offsets */
const list=selected.filter(s=>s.kind==="u");
const n=list.length;
const cols=Math.ceil(Math.sqrt(n));
list.forEach((s,i)=>{
const ox=((i%cols)-(cols-1)/2)*2.0;
const oz=((i/cols|0)-((Math.ceil(n/cols))-1)/2)*2.0;
orderMove(s,clamp(g.x+ox,2,MAP-2),clamp(g.z+oz,2,MAP-2));
});
for(const s of blds){
s.rally={x:g.x,z:g.z};
addEffect(g.x,gH(g.x,g.z)+0.4,g.z,0.3,0.9,1,0.7,1.2);
}
any=true;
}
if(any){
addEffect(g.x,gH(g.x,g.z)+0.3,g.z,0.35,0.9,1.0,0.5,1.4);
sfx("order",false);
}
}
function issuePending(sx,sy){
const g=screenToGround(sx,sy);
pendingCmd=null;
if(!g)return;
for(const s of selected)if(s.kind==="u")orderAttackMove(s,g.x,g.z);
addEffect(g.x,gH(g.x,g.z)+0.3,g.z,1,0.4,0.3,0.6,1.6);
refreshActions();
}
function cmdStop(){
for(const s of selected)if(s.kind==="u")orderStop(s);
sfx("order",false);
}
function cmdHold(){
for(const s of selected)if(s.kind==="u"){s.cmd="hold";s.path=null;s.dest=null;s.target=null;s.node=null;s.buildTgt=null;}
sfx("order",false);
}
function tryPlace(sx,sy){
const g=screenToGround(sx,sy);
if(!g)return;
const def=BLD_DEFS[placing];
const snapX=Math.round(g.x/CELL)*CELL, snapZ=Math.round(g.z/CELL)*CELL;
const ok=canPlace(placing,snapX,snapZ);
if(!ok){toast("Cannot build there","bad");return;}
if(teamState[TEAM_P].crystal<def.cost){toast("Not enough crystal","bad");return;}
teamState[TEAM_P].crystal-=def.cost;
const b=mkBuilding(placing,TEAM_P,snapX,snapZ,false);
b.buildP=0.02;
rebuildPathGrid();
/* auto-assign nearest idle worker */
let best=null,bd=1e9;
for(const u of units){
if(u.dead||u.team!==TEAM_P||!u.d.isWorker)continue;
if(u.cmd!=="hold"&&u.cmd!=="idle"&&u.cmd!=="gather")continue;
const d=dist2(u.x,u.z,snapX,snapZ);
if(d<bd){bd=d;best=u;}
}
if(best)orderBuild(best,b);
placing=null;
sfx("place",false);
refreshActions();
}
function canPlace(type,x,z){
const def=BLD_DEFS[type];
const h=def.size*0.5;
if(x-h<2||z-h<2||x+h>MAP-2||z+h>MAP-2)return false;
const c0=((x-h)/CELL)|0,c1=((x+h)/CELL)|0,d0=((z-h)/CELL)|0,d1=((z+h)/CELL)|0;
for(let gz=d0;gz<=d1;gz++)for(let gx=c0;gx<=c1;gx++){
if(gx<0||gz<0||gx>=GN||gz>=GN)return false;
if(!passable[gz*GN+gx])return false;
}
for(const b of buildings){
if(b.dead)continue;
const bh=(b.d.size+def.size)*0.5*CELL*0.5;
if(Math.abs(b.x-x)<bh&&Math.abs(b.z-z)<bh)return false;
}
for(const u of units){
if(u.dead||u.d.isAir)continue;
if(Math.abs(u.x-x)<h+0.6&&Math.abs(u.z-z)<h+0.6)return false;
}
return true;
}
/* ============================================================
AUDIO (procedural)
============================================================ */
let actx=null,muted=false,sfxBudget=0,sfxFrame=0;
function ensureAudio(){
if(actx)return true;
try{actx=new (window.AudioContext||window.webkitAudioContext)();}catch(e){return false;}
return true;
}
function tone(freq,dur,type,gain,slideTo){
const t0=actx.currentTime;
const o=actx.createOscillator(),g=actx.createGain();
o.type=type||"square";
o.frequency.setValueAtTime(freq,t0);
if(slideTo)o.frequency.exponentialRampToValueAtTime(Math.max(20,slideTo),t0+dur);
g.gain.setValueAtTime(0.0001,t0);
g.gain.exponentialRampToValueAtTime(gain||0.05,t0+0.008);
g.gain.exponentialRampToValueAtTime(0.0001,t0+dur);
o.connect(g);g.connect(actx.destination);o.start(t0);o.stop(t0+dur+0.02);
}
function noiseBurst(dur,gain,filt){
const n=(actx.sampleRate*dur)|0;
const buf=actx.createBuffer(1,n,actx.sampleRate);
const d=buf.getChannelData(0);
for(let i=0;i<n;i++)d[i]=(Math.random()*2-1)*Math.pow(1-i/n,2.2);
const s=actx.createBufferSource();s.buffer=buf;
const f=actx.createBiquadFilter();f.type="lowpass";f.frequency.value=filt||900;
const g=actx.createGain();g.gain.value=gain||0.08;
s.connect(f);f.connect(g);g.connect(actx.destination);s.start();
}
function sfx(type,big){
if(muted||!running)return;
if(sfxFrame>6)return;
sfxFrame++;
if(!ensureAudio())return;
switch(type){
case "shot": tone(680,0.05,"square",0.018,300);break;
case "boom": noiseBurst(big?0.55:0.22,big?0.22:0.09,big?520:1400);break;
case "hit": tone(220,0.05,"sawtooth",0.02,120);break;
case "explode": noiseBurst(big?0.8:0.35,big?0.3:0.12,big?380:1100);if(big)tone(90,0.5,"sine",0.14,40);break;
case "order": tone(880,0.05,"sine",0.03,1200);break;
case "place": tone(300,0.14,"triangle",0.06,600);break;
case "build": tone(440,0.12,"sine",0.05,880);setTimeout(()=>{if(actx&&!muted)tone(660,0.16,"sine",0.05,1100);},110);break;
case "ready": tone(520,0.09,"sine",0.05,780);break;
case "deposit": tone(1200,0.05,"sine",0.022,1500);break;
case "alarm": tone(300,0.35,"sawtooth",0.07,180);break;
case "win": tone(523,0.3,"sine",0.09);setTimeout(()=>tone(659,0.3,"sine",0.09),180);setTimeout(()=>tone(784,0.6,"sine",0.1),360);break;
case "lose": tone(330,0.5,"sawtooth",0.09,110);setTimeout(()=>tone(220,0.9,"sawtooth",0.09,80),300);break;
}
}
function toggleMute(){
muted=!muted;
document.getElementById("bMute").textContent="SOUND: "+(muted?"OFF":"ON");
if(!muted)ensureAudio();
}
/* ============================================================
TOASTS
============================================================ */
let toastCount=0;
function toast(msg,kind){
const w=document.getElementById("warn");
if(w.childElementCount>4)return;
const d=document.createElement("div");
d.className="toast"+(kind==="good"?" good":"");
d.textContent=msg;
w.appendChild(d);
setTimeout(()=>d.remove(),3000);
}
/* ============================================================
UI: SELECTION PANEL
============================================================ */
const elTitle=document.getElementById("seltitle"),elMeta=document.getElementById("selmeta"),elBody=document.getElementById("selbody");
const elActions=document.getElementById("actions");
let actionButtons=[];
function refreshSelection(){
const n=selected.length;
if(!n){elTitle.textContent="NO SELECTION";elMeta.textContent="";elBody.innerHTML="";refreshActions();return;}
if(n===1){
const s=selected[0];
elTitle.textContent=s.d.name;
elMeta.textContent=s.kind==="u"?s.d.role:"Structure";
}else{
const kinds={};
for(const s of selected)kinds[s.d.name]=(kinds[s.d.name]||0)+1;
elTitle.textContent=n+" UNITS SELECTED";
elMeta.textContent=Object.keys(kinds).map(k=>kinds[k]+"× "+k).join(", ").slice(0,90);
}
elBody.innerHTML="";
if(n<=14){
for(const s of selected){
const c=document.createElement("div");c.className="ucard";
const r=document.createElement("div");r.className="urow";
const nm=document.createElement("span");nm.textContent=s.d.name;
const hp=document.createElement("span");hp.textContent=Math.max(0,Math.ceil(s.hp))+" / "+Math.ceil(s.maxHp);
r.appendChild(nm);r.appendChild(hp);
const bar=document.createElement("div");bar.className="bar hp";
const i=document.createElement("i");i.style.width=(clamp(s.hp/s.maxHp,0,1)*100)+"%";
bar.appendChild(i);
c.appendChild(r);c.appendChild(bar);
if(s.kind==="b"&&s.queue.length){
for(const q of s.queue.slice(0,4)){
const qr=document.createElement("div");qr.className="urow";
const qn=q.type==="__up"?UPGRADES.find(x=>x.key===q.up).name:UNIT_DEFS[q.type].name;
qr.innerHTML="<span>"+qn+(q.blocked?" (blocked)":"")+"</span><span>"+Math.floor(q.t/q.bt*100)+"%</span>";
const qb=document.createElement("div");qb.className="bar pr";
const qi=document.createElement("i");qi.style.width=(q.t/q.bt*100)+"%";qb.appendChild(qi);
c.appendChild(qr);c.appendChild(qb);
}
}
if(s.kind==="b"&&!s.built){
const qr=document.createElement("div");qr.className="urow";
qr.innerHTML="<span>Constructing</span><span>"+Math.floor(s.buildP*100)+"%</span>";
const qb=document.createElement("div");qb.className="bar rs";
const qi=document.createElement("i");qi.style.width=(s.buildP*100)+"%";qb.appendChild(qi);
c.appendChild(qr);c.appendChild(qb);
}
elBody.appendChild(c);
}
}else{
const groups={};
for(const s of selected){
const k=s.d.name;
if(!groups[k])groups[k]={def:s.d,list:[],kind:s.kind};
groups[k].list.push(s);
}
for(const k in groups){
const g=groups[k];
let hp=0,mhp=0;
for(const s of g.list){hp+=Math.max(0,s.hp);mhp+=s.maxHp;}
const c=document.createElement("div");c.className="ucard";
const r=document.createElement("div");r.className="urow";
r.innerHTML="<span>"+g.list.length+"× "+k+"</span><span>"+Math.ceil(hp)+" / "+Math.ceil(mhp)+"</span>";
const bar=document.createElement("div");bar.className="bar hp";
const i=document.createElement("i");i.style.width=(clamp(hp/mhp,0,1)*100)+"%";
bar.appendChild(i);c.appendChild(r);c.appendChild(bar);
elBody.appendChild(c);
}
}
refreshActions();
}
function mkBtn(label,sub,hot,enabled,onClick,title){
const b=document.createElement("div");
b.className="abtn"+(enabled?"":" dis");
b.title=title||"";
b.innerHTML='<span class="hk">'+(hot||"")+'</span><span class="an">'+label+'</span>'+
(sub?'<span class="ac">'+sub+'</span>':'')+'<span class="pg"></span>';
if(enabled)b.addEventListener("mousedown",ev=>{ev.stopPropagation();onClick();});
elActions.appendChild(b);
return b;
}
function refreshActions(){
elActions.innerHTML="";actionButtons=[];
if(!selected.length){
elActions.innerHTML='<div style="font-size:12px;color:#5d8aa5;padding:8px;line-height:1.6">'+
'Select a unit or building.<br>Drag to box-select · Shift adds · Right-click orders.<br>'+
'Keys: <b>A</b> attack-move · <b>S</b> stop · <b>H</b> hold · <b>Ctrl+1-9</b> groups</div>';
return;
}
const uSel=selected.filter(s=>s.kind==="u");
const bSel=selected.filter(s=>s.kind==="b");
const cry=teamState[TEAM_P].crystal;
if(uSel.length){
mkBtn("ATTACK-MOVE","A","A",true,()=>{pendingCmd="amove";toast("Click a destination","good");},"Move and engage anything encountered");
mkBtn("STOP","S","S",true,cmdStop,"Cancel current orders");
mkBtn("HOLD","H","H",true,cmdHold,"Hold position, fire only in range");
}
if(uSel.some(s=>s.d.isWorker)){
const list=[
["hq","Command Nexus",500,"Q"],["depot","Supply Relay",110,"W"],
["barracks","Foundry",220,"E"],["airpad","Aerie",260,"R"],
["lab","Research Spire",260,"T"],["turret","Sentry Gun",150,"Y"]
];
for(const [t,nm,cost,hk] of list){
const def=BLD_DEFS[t];
const pre=def.prereq&&!hasBuilding(TEAM_P,def.prereq);
const ok=cry>=cost&&!pre;
const b=mkBtn(nm,cost,hk,ok,()=>{placing=t;pendingCmd=null;refreshActions();},
pre?("Requires "+BLD_DEFS[def.prereq].name):def.desc);
if(ok)b.addEventListener("mouseenter",()=>{});
}
}
for(const b of bSel){
if(b.d.produces&&b.d.produces.length){
const hk=["Q","W","E","R"];
b.d.produces.forEach((t,i)=>{
const ud=UNIT_DEFS[t];
const ok=cry>=ud.cost;
mkBtn(ud.name,ud.cost,hk[i],ok,()=>queueUnit(b,t),
ud.desc+" · "+ud.supply+" supply · "+ud.bt+"s");
});
}
if(b.type==="lab"){
const hk=["Q","W","E","R"];
UPGRADES.forEach((u,i)=>{
const done=teamState[TEAM_P].upgrades[u.key];
const ok=!done&&cry>=u.cost&&!b.queue.length;
mkBtn(u.name,u.cost,hk[i],ok,()=>queueUpgrade(b,u),
done?"Already researched":u.desc);
});
}
if(b.queue.length){
mkBtn("CANCEL","queue","X",true,()=>cancelQueue(b),"Refund the first queued item (75%)");
}
}
}
function queueUnit(b,type){
const ud=UNIT_DEFS[type];
if(teamState[b.team].crystal<ud.cost){toast("Not enough crystal","bad");return;}
if(b.queue.length>=8){toast("Queue full","bad");return;}
teamState[b.team].crystal-=ud.cost;
b.queue.push({type,t:0,bt:ud.bt,cost:ud.cost,blocked:false});
if(b.team===TEAM_P){sfx("order",false);refreshSelection();}
}
function queueUpgrade(b,u){
if(teamState[b.team].crystal<u.cost)return;
teamState[b.team].crystal-=u.cost;
b.queue.push({type:"__up",up:u.key,t:0,bt:u.bt,cost:u.cost,blocked:false});
if(b.team===TEAM_P)refreshSelection();
}
function cancelQueue(b){
if(!b.queue.length)return;
const q=b.queue.shift();
teamState[b.team].crystal+=Math.floor(q.cost*0.75);
refreshSelection();
}
function updateTopBar(){
document.getElementById("sCry").textContent=Math.floor(teamState[TEAM_P].crystal);
const cap=supplyCap(TEAM_P),used=supplyUsed(TEAM_P);
const sup=document.getElementById("sSup");
sup.textContent=used+" / "+cap;
sup.style.color=used>cap?"#ff7a6a":(used>cap*0.9?"#ffd479":"#eaf7ff");
let n=0;
for(const u of units)if(!u.dead&&u.team===TEAM_P)n++;
document.getElementById("sPop").innerHTML="UNITS <b>"+n+"</b>";
const t=Math.floor(gameTime);
document.getElementById("sTime").textContent=String((t/60)|0).padStart(2,"0")+":"+String(t%60).padStart(2,"0");
}
/* ============================================================
MINIMAP
============================================================ */
const mm=document.getElementById("minimap"),mmc=mm.getContext("2d");
const MMN=128,MMS=150/MAP;
let mmBase=null;
const fogCanvas=document.createElement("canvas");fogCanvas.width=MMN;fogCanvas.height=MMN;
const fogCtx=fogCanvas.getContext("2d");
function buildMinimapBase(){
mmBase=document.createElement("canvas");mmBase.width=MMN;mmBase.height=MMN;
const c=mmBase.getContext("2d");
const img=c.createImageData(MMN,MMN);
for(let z=0;z<MMN;z++)for(let x=0;x<MMN;x++){
const wx=x*2,wz=z*2,h=terrainH(wx,wz);
const t=clamp((h+1)/9,0,1);
let r=(0.16+0.10*t)*255,g=(0.20+0.16*t)*255,b=(0.22+0.10*t)*255;
if(h>6){r=100;g=105;b=114;}
const i=(z*MMN+x)*4;
img.data[i]=r;img.data[i+1]=g;img.data[i+2]=b;img.data[i+3]=255;
}
c.putImageData(img,0,0);
}
function updateFogCanvas(){
const img=fogCtx.createImageData(MMN,MMN);
for(let i=0;i<MMN*MMN;i++){
const v=fogVis[i],e=fogExp[i];
let a;
if(v)a=0;else if(e)a=190;else a=252;
img.data[i*4]=4;img.data[i*4+1]=8;img.data[i*4+2]=14;img.data[i*4+3]=a;
}
fogCtx.putImageData(img,0,0);
}
function drawMinimap(){
mmc.clearRect(0,0,150,150);
mmc.drawImage(mmBase,0,0,150,150);
mmc.drawImage(fogCanvas,0,0,150,150);
for(const n of resNodes){
if(n.amt<=0||!isExplored(n.x,n.z))continue;
mmc.fillStyle="#5fe0ff";
mmc.fillRect(n.x*MMS-1.5,n.z*MMS-1.5,3,3);
}
for(const b of buildings){
if(b.dead)continue;
if(b.team===TEAM_P){mmc.fillStyle="#3fd0ff";mmc.fillRect(b.x*MMS-3,b.z*MMS-3,6,6);}
else if(isVisible(b.x,b.z)){mmc.fillStyle="#ff5533";mmc.fillRect(b.x*MMS-3,b.z*MMS-3,6,6);}
}
for(const u of units){
if(u.dead)continue;
if(u.team===TEAM_P){mmc.fillStyle="#9fe8ff";mmc.fillRect(u.x*MMS-1,u.z*MMS-1,2,2);}
else if(isVisible(u.x,u.z)){mmc.fillStyle="#ff8866";mmc.fillRect(u.x*MMS-1,u.z*MMS-1,2,2);}
}
/* camera footprint */
const pts=[];
for(const p of [[0,0],[W,0],[W,H],[0,H]]){
const g=screenToGround(p[0],p[1]);
if(g)pts.push([g.x*MMS,g.z*MMS]);
}
if(pts.length===4){
mmc.strokeStyle="rgba(180,240,255,.85)";mmc.lineWidth=1;
mmc.beginPath();mmc.moveTo(pts[0][0],pts[0][1]);
for(let i=1;i<4;i++)mmc.lineTo(pts[i][0],pts[i][1]);
mmc.closePath();mmc.stroke();
}
/* selection */
mmc.fillStyle="#ffffff";
for(const s of selected)mmc.fillRect(s.x*MMS-1,s.z*MMS-1,2,2);
}
function mmCoords(e){
const r=mm.getBoundingClientRect();
return {x:clamp((e.clientX-r.left)/r.width*MAP,0,MAP), z:clamp((e.clientY-r.top)/r.height*MAP,0,MAP)};
}
mm.addEventListener("mousedown",e=>{
e.preventDefault();
const g=mmCoords(e);
if(e.button===0){cam.x=g.x;cam.z=g.z;}
else if(e.button===2){
const savedX=mouseX,savedY=mouseY;
const s=worldToScreen(g.x,gH(g.x,g.z)+1,g.z);
if(s){mouseX=s.x;mouseY=s.y;issueOrder(s.x,s.y);mouseX=savedX;mouseY=savedY;}
}
});
mm.addEventListener("contextmenu",e=>e.preventDefault());
/* ============================================================
RENDER
============================================================ */
const fxc=document.getElementById("fx"),fxx=fxc.getContext("2d");
const _wm=new Float32Array(16),_im=new Float32Array(16);
function setWorld(x,y,z,yaw){
const c=Math.cos(yaw),s=Math.sin(yaw);
_wm[0]=c;_wm[1]=0;_wm[2]=-s;_wm[3]=0;
_wm[4]=0;_wm[5]=1;_wm[6]=0;_wm[7]=0;
_wm[8]=s;_wm[9]=0;_wm[10]=c;_wm[11]=0;
_wm[12]=x;_wm[13]=y;_wm[14]=z;_wm[15]=1;
}
function mulW(p,o){
const c=_wm[0],s=_wm[8],tx=_wm[12],ty=_wm[13],tz=_wm[14];
for(let i=0;i<4;i++){
const p0=p[i*4],p1=p[i*4+1],p2=p[i*4+2],p3=p[i*4+3];
o[i*4+0]=c*p0+s*p2;
o[i*4+1]=p1;
o[i*4+2]=-s*p0+c*p2;
o[i*4+3]=p3;
}
o[12]+=tx;o[13]+=ty;o[14]+=tz;
}
function partColor(team,pc){
if(pc==="team")return TEAMCOL[team];
return COL[pc]||COL.metal;
}
function drawParts(parts,team,flash,scaleY){
for(let i=0;i<parts.length;i++){
const pt=parts[i];
mulW(pt.m,_im);
if(scaleY!==1){_im[5]*=scaleY;_im[13]*=scaleY;}
const c=partColor(team,pt.c);
let r=c[0],g=c[1],b=c[2];
if(flash>0){r=lerp(r,1,flash);g=lerp(g,1,flash);b=lerp(b,1,flash);}
BATCHES[pt.g].add(_im,r,g,b,0);
}
}
const rockInst=[];
function buildRockInstances(){
rockInst.length=0;
for(const r of rocks){
if(!r.deco)continue;
const m=partM([GBOX,0,r.sz*0.5,0, r.sz*1.1,r.sz,r.sz*1.1, r.ry]);
rockInst.push({g:GBOX,m,x:r.x,z:r.z,c:[0.30,0.31,0.34]});
if(rnd()<0.6){
const m2=partM([GCONE,r.sz*0.3,r.sz*0.9,r.sz*0.2, r.sz*0.7,r.sz*0.9,r.sz*0.7, r.ry]);
rockInst.push({g:GCONE,m:m2,x:r.x,z:r.z,c:[0.36,0.37,0.41]});
}
}
}
function buildScene(){
for(const b of BATCHES)b.reset();
const t=performance.now()*0.001;
for(const r of rockInst){
if(!isExplored(r.x,r.z))continue;
BATCHES[r.g].add(r.m,r.c[0],r.c[1],r.c[2],0);
}
/* resource nodes */
for(const n of resNodes){
if(n.amt<=0||!isExplored(n.x,n.z))continue;
const f=clamp(n.amt/n.max,0.15,1);
const y=gH(n.x,n.z);
for(const c of n.crystals){
setWorld(n.x+c.dx,y,n.z+c.dz,c.ry);
const hh=c.h*f;
const col=n.amt/n.max>0.5?COL.crystal:COL.crystal2;
/* faceted prism body */
let m=partM([GCYL,0,hh*0.32,0, c.r,hh*0.64,c.r, 0]);
mulW(m,_im);
BATCHES[GCYL].add(_im,col[0]*0.85,col[1]*0.85,col[2]*0.9,0.22);
/* bright tip */
m=partM([GCONE,0,hh*0.64+hh*0.26,0, c.r*0.98,hh*0.55,c.r*0.98, 0]);
mulW(m,_im);
BATCHES[GCONE].add(_im,col[0],col[1],col[2],0.42);
}
/* faint ground glow */
const gm=partM([GDISC,n.x,y+0.12,n.z, 5.2,1,5.2, 0]);
BATCHES[GDISC].add(gm,0.13,0.52,0.70,0.42);
}
/* buildings */
for(const b of buildings){
if(b.dead)continue;
let ghost=false;
if(b.team!==TEAM_P){
if(isVisible(b.x,b.z)){}
else if(knownEnemyBld.has(b.id)){ghost=true;}
else continue;
}
const y=gH(b.x,b.z);
setWorld(b.x,y,b.z,b.yaw);
const sy=b.built?1:(0.22+0.78*b.buildP);
if(ghost){
for(const pt of b.d._parts){
mulW(pt.m,_im);
_im[5]*=sy;_im[13]*=sy;
BATCHES[pt.g].add(_im,0.10,0.13,0.19,1);
}
continue;
}
drawParts(b.d._parts,b.team,b.flash,sy);
if(!b.built){
const m=partM([GBOX,0,1.2,0, b.d.size*CELL*0.95,2.4,b.d.size*CELL*0.95,0]);
BATCHES[GBOX].add(m,0.15,0.55,0.75,0.85);
}
}
/* units */
for(const u of units){
if(u.dead)continue;
if(u.team!==TEAM_P&&!isVisible(u.x,u.z))continue;
setWorld(u.x,u.y,u.z,u.yaw);
const bob=u.d.isAir?0:0;
drawParts(u.d._parts,u.team,u.flash,1);
}
/* projectiles */
for(const p of projectiles){
const m=partM([GSPH,p.x,p.y,p.z, p.arc?0.34:0.22,p.arc?0.34:0.22,p.arc?0.34:0.22,0]);
BATCHES[GSPH].add(m,p.color[0],p.color[1],p.color[2],1);
if(!p.arc){
const m2=partM([GCYLZ,p.x-p.vx*0.02,p.y,p.z-p.vz*0.02, 0.10,0.10,0.9,0]);
BATCHES[GCYLZ].add(m2,p.color[0],p.color[1],p.color[2],1);
}
}
/* effects */
for(const e of effects){
const k=1-e.life/e.max;
const s=e.size*(0.5+k*1.6);
const m=partM([GSPH,e.x,e.y,e.z, s,s,s,0]);
const a=clamp(e.life/e.max,0,1);
BATCHES[GSPH].add(m,e.r,e.g,e.b,a*0.9+0.1);
}
/* selection rings */
for(const s of selected){
if(s.dead)continue;
const y=gH(s.x,s.z)+0.09;
const sc=s.kind==="u"?s.d.radius*2.6:s.d.size*CELL*0.62;
const m=partM([GRING,s.x,y,s.z, sc,1,sc,0]);
const c=TEAMCOL[TEAM_P];
BATCHES[GRING].add(m,c[0],c[1],c[2],1);
}
/* order destinations */
for(const s of selected){
if(s.dead||s.kind!=="u"||!s.dest)continue;
const y=gH(s.dest.x,s.dest.z)+0.09;
const m=partM([GRING,s.dest.x,y,s.dest.z, 1.6,1,1.6,0]);
BATCHES[GRING].add(m,0.35,0.95,1.0,1);
}
/* placement preview */
if(placing){
const g=screenToGround(mouseX,mouseY);
if(g){
const sx=Math.round(g.x/CELL)*CELL, sz=Math.round(g.z/CELL)*CELL;
const ok=canPlace(placing,sx,sz)&&teamState[TEAM_P].crystal>=BLD_DEFS[placing].cost;
const def=BLD_DEFS[placing];
setWorld(sx,gH(sx,sz),sz,0);
for(const pt of def._parts){
mulW(pt.m,_im);
if(ok)BATCHES[pt.g].add(_im,0.25,0.95,0.55,0.85);
else BATCHES[pt.g].add(_im,1.0,0.25,0.2,0.85);
}
const m=partM([GBOX,sx,gH(sx,sz)+0.06,sz, def.size*CELL,0.12,def.size*CELL,0]);
BATCHES[GBOX].add(m,ok?0.2:1.0,ok?0.9:0.2,ok?0.5:0.2,0.9);
}
}
if(pendingCmd){
const g=screenToGround(mouseX,mouseY);
if(g){
const m=partM([GRING,g.x,gH(g.x,g.z)+0.1,g.z, 3.0,1,3.0,0]);
BATCHES[GRING].add(m,1.0,0.35,0.3,1);
}
}
}
function render(){
if(!terrain)return;
gl.viewport(0,0,canvas.width,canvas.height);
gl.clearColor(0.02,0.03,0.05,1);
gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);
updateCameraMatrices();
buildScene();
gl.useProgram(terrProg);
gl.uniformMatrix4fv(TU.VP,false,mVP);
gl.uniform3f(TU.L,0.42,0.84,0.34);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D,fogTexObj);
gl.uniform1i(TU.F,0);
gl.enable(gl.CULL_FACE);
gl.bindVertexArray(terrain.vao);
gl.drawElements(gl.TRIANGLES,terrain.count,gl.UNSIGNED_INT,0);
gl.useProgram(instProg);
gl.uniformMatrix4fv(IU.VP,false,mVP);
gl.uniform3f(IU.L,0.42,0.84,0.34);
const e=camEye();
gl.uniform3f(IU.C,e[0],e[1],e[2]);
gl.disable(gl.CULL_FACE);
for(const b of BATCHES)b.flush();
gl.enable(gl.CULL_FACE);
}
function drawOverlay(){
fxx.clearRect(0,0,W,H);
const drawBar=(x,y,w,frac,col)=>{
fxx.fillStyle="rgba(0,0,0,.62)";fxx.fillRect(x-1,y-1,w+2,5);
fxx.fillStyle=col;fxx.fillRect(x,y,w*clamp(frac,0,1),3);
};
const list=[];
for(const u of units){
if(u.dead)continue;
const friendly=u.team===TEAM_P;
if(!friendly&&!isVisible(u.x,u.z))continue;
if(friendly&&u.hp>=u.maxHp&&selected.indexOf(u)<0)continue;
if(!friendly&&u.hp>=u.maxHp&&!friendly)continue;
list.push(u);
}
for(const b of buildings){
if(b.dead)continue;
if(b.team!==TEAM_P&&!isVisible(b.x,b.z))continue;
if(b.team===TEAM_P&&b.built&&b.hp>=b.maxHp&&selected.indexOf(b)<0)continue;
list.push(b);
}
let count=0;
for(const o of list){
if(count>260)break;
const yTop=o.kind==="u"?(o.d.isAir?o.y+1.3:o.y+1.35):gH(o.x,o.z)+o.d.size*CELL*0.55+1.0;
const s=worldToScreen(o.x,yTop,o.z);
if(!s||s.w>260)continue;
count++;
const w=o.kind==="u"?Math.max(16,26/s.w*14):Math.max(24,o.d.size*CELL/s.w*14);
const frac=o.hp/o.maxHp;
const col=o.team===TEAM_P?(frac>0.5?"#3fe08a":"#e0c03f"):"#ff5533";
drawBar(s.x-w/2,s.y,w,frac,col);
}
/* selection markers */
fxx.strokeStyle="rgba(120,230,255,.9)";
fxx.lineWidth=1;
for(const s of selected){
if(s.dead)continue;
const s2=worldToScreen(s.x,s.kind==="u"?s.y+0.1:gH(s.x,s.z)+0.1,s.z);
if(!s2)continue;
fxx.beginPath();fxx.arc(s2.x,s2.y,3,0,TAU);fxx.stroke();
}
}
/* ============================================================
AI OPPONENT
============================================================ */
const aiExp=new Uint8Array(FOGN*FOGN);
const aiVis=new Uint8Array(FOGN*FOGN);
function aiVisible(x,z){
const cx=(x/FOGC)|0, cz=(z/FOGC)|0;
if(cx<0||cz<0||cx>=FOGN||cz>=FOGN)return false;
return aiVis[cz*FOGN+cx]>0;
}
function markExpArr(x,z,r,arr){
const r2=r*r;
const x0=clamp(((x-r)/FOGC)|0,0,FOGN-1),x1=clamp(((x+r)/FOGC)|0,0,FOGN-1);
const z0=clamp(((z-r)/FOGC)|0,0,FOGN-1),z1=clamp(((z+r)/FOGC)|0,0,FOGN-1);
for(let cz=z0;cz<=z1;cz++){
const wz=cz*FOGC+FOGC*0.5,dz=wz-z;
for(let cx=x0;cx<=x1;cx++){
const wx=cx*FOGC+FOGC*0.5,dx=wx-x;
if(dx*dx+dz*dz<=r2)arr[cz*FOGN+cx]=1;
}
}
}
const ai={think:1.5,attackT:75,waveSize:6,wave:0,expandT:120,scoutT:8,aggro:0};
function aiBase(){
let x=0,z=0,n=0;
for(const b of buildings)if(!b.dead&&b.team===TEAM_A&&b.type==="hq"){x+=b.x;z+=b.z;n++;}
if(!n)return {x:START_A.x,z:START_A.z};
return {x:x/n,z:z/n};
}
function aiPlace(type,cx,cz,rad){
for(let i=0;i<90;i++){
const a=rnd()*TAU,r=rrange(5,rad);
const x=clamp(cx+Math.cos(a)*r,12,MAP-12);
const z=clamp(cz+Math.sin(a)*r,12,MAP-12);
const sx=Math.round(x/CELL)*CELL,sz=Math.round(z/CELL)*CELL;
if(canPlace(type,sx,sz))return {x:sx,z:sz};
}
return null;
}
function aiBuild(type,cx,cz,rad){
const def=BLD_DEFS[type];
if(teamState[TEAM_A].crystal<def.cost)return null;
const p=aiPlace(type,cx,cz,rad);
if(!p)return null;
teamState[TEAM_A].crystal-=def.cost;
const b=mkBuilding(type,TEAM_A,p.x,p.z,false);
b.buildP=0.02;
rebuildPathGrid();
let best=null,bd=1e9;
for(const u of units){
if(u.dead||u.team!==TEAM_A||!u.d.isWorker)continue;
if(u.cmd==="build")continue;
const d=dist2(u.x,u.z,p.x,p.z);
if(d<bd){bd=d;best=u;}
}
if(best)orderBuild(best,b);
return b;
}
function aiPickTarget(){
const base=aiBase();
let best=null,bd=1e9;
for(const [id,rec] of aiKnownBld){
const d=dist2(rec.x,rec.z,base.x,base.z);
if(d<bd){bd=d;best=rec;}
}
return best;
}
function aiRandomUnexplored(){
for(let i=0;i<40;i++){
const cx=(rnd()*FOGN)|0,cz=(rnd()*FOGN)|0;
if(!aiExp[cz*FOGN+cx])return {x:cx*FOGC+FOGC/2,z:cz*FOGC+FOGC/2};
}
return {x:rrange(20,MAP-20),z:rrange(20,MAP-20)};
}
function updateAI(dt){
if(gameOver)return;
ai.think-=dt;
if(ai.think>0)return;
ai.think=1.0;
aiThink();
}
function aiThink(){
const T=TEAM_A,S=teamState[T];
const myU=[],workers=[],army=[];
for(const u of units){
if(u.dead||u.team!==T)continue;
myU.push(u);
if(u.d.isWorker)workers.push(u);else army.push(u);
}
const myB=[],hqs=[],barracks=[],airpads=[],labs=[],depots=[],turrets=[];
for(const b of buildings){
if(b.dead||b.team!==T)continue;
myB.push(b);
if(b.type==="hq")hqs.push(b);
else if(b.type==="barracks")barracks.push(b);
else if(b.type==="airpad")airpads.push(b);
else if(b.type==="lab")labs.push(b);
else if(b.type==="depot")depots.push(b);
else if(b.type==="turret")turrets.push(b);
}
const base=aiBase();
const cap=supplyCap(T),used=supplyUsed(T);
/* --- worker assignment --- */
for(const w of workers){
if(w.cmd==="hold"||w.cmd==="idle"){
if(w.carry>0){w.cmd="gather";}
else{const n=nearestRes(w.x,w.z,120);if(n)orderGather(w,n);}
}
}
/* --- economy --- */
const targetWorkers=Math.min(34,6+hqs.length*5);
const hqIdle=hqs.some(h=>h.built&&h.queue.length===0);
if(workers.length<targetWorkers&&hqIdle&&S.crystal>=50){
const h=hqs.find(x=>x.built&&x.queue.length===0);
if(h)queueUnit(h,"hauler");
}
/* supply */
if(cap-used<8&&S.crystal>=110){
const d=aiBuild("depot",base.x,base.z,26);
if(!d&&S.crystal>=200){/* retry later */}
}
/* production chain */
if(!barracks.length&&S.crystal>=220)aiBuild("barracks",base.x,base.z,24);
if(barracks.length&&!labs.length&&S.crystal>=460)aiBuild("lab",base.x,base.z,26);
if(barracks.length&&!airpads.length&&S.crystal>=560)aiBuild("airpad",base.x,base.z,28);
if(barracks.length>=1&&barracks.length<3&&S.crystal>=700&&workers.length>=10)aiBuild("barracks",base.x,base.z,30);
if(turrets.length<3+hqs.length&&S.crystal>=420&&barracks.length)aiBuild("turret",base.x,base.z,30);
/* expansion */
ai.expandT-=1;
if(ai.expandT<=0&&S.crystal>=900&&workers.length>=10){
let bestNode=null,bd=1e9;
for(const n of resNodes){
if(n.amt<600)continue;
let near=false;
for(const h of hqs)if(dist2(h.x,h.z,n.x,n.z)<900)near=true;
if(near)continue;
const d=dist2(n.x,n.z,base.x,base.z);
if(d<bd){bd=d;bestNode=n;}
}
if(bestNode){
const b=aiBuild("hq",bestNode.x,bestNode.z,14);
if(b){ai.expandT=150;if(isExplored(b.x,b.z))toast("Enemy expansion detected","bad");}
else ai.expandT=40;
}else ai.expandT=60;
}
/* --- production --- */
for(const b of barracks){
if(!b.built||b.queue.length>=3)continue;
const r=rnd();
let t="trooper";
if(r<0.34)t="trooper";
else if(r<0.72)t="ravager";
else t="bastion";
if(S.crystal>=UNIT_DEFS[t].cost)queueUnit(b,t);
}
for(const b of airpads){
if(!b.built||b.queue.length>=2)continue;
if(S.crystal>=UNIT_DEFS.wasp.cost)queueUnit(b,"wasp");
}
for(const b of labs){
if(!b.built||b.queue.length)continue;
const order=["weapon","armor","logi","servo"];
for(const k of order){
if(!S.upgrades[k]){
const u=UPGRADES.find(x=>x.key===k);
if(S.crystal>=u.cost+120){queueUpgrade(b,u);}
break;
}
}
}
/* --- defence --- */
let threat=null;
for(const b of myB){
if(gameTime-b.lastHit<6){threat=b;break;}
}
if(threat){
for(const u of army){
if(u.cmd==="hold"||u.cmd==="idle"){
orderAttackMove(u,threat.x+rrange(-6,6),threat.z+rrange(-6,6));
}
}
ai.attackT=Math.max(ai.attackT,30);
return;
}
/* --- scouting --- */
ai.scoutT-=1;
if(ai.scoutT<=0){
ai.scoutT=14;
const scouts=army.filter(u=>u.cmd==="hold"&&u.d.speed>4);
const dest=aiRandomUnexplored();
if(scouts.length){
const s=scouts[0];
orderMove(s,dest.x,dest.z);
}
}
/* --- attack waves --- */
ai.attackT-=1;
const idle=army.filter(u=>u.cmd==="hold"||u.cmd==="idle");
if(ai.attackT<=0&&idle.length>=ai.waveSize){
const tgt=aiPickTarget();
let tx,tz;
if(tgt){tx=tgt.x;tz=tgt.z;}
else{const d=aiRandomUnexplored();tx=d.x;tz=d.z;}
let sent=0;
for(const u of idle){
orderAttackMove(u,tx+rrange(-8,8),tz+rrange(-8,8));
sent++;
if(sent>44)break;
}
ai.wave++;
ai.waveSize=Math.min(38,ai.waveSize+3);
ai.attackT=Math.max(38,70-ai.wave*4);
toast("Enemy assault force incoming","bad");
sfx("alarm",false);
}
/* rally idle army toward base */
for(const u of army){
if(u.cmd==="hold"&&dist2(u.x,u.z,base.x,base.z)>60*60){
orderMove(u,base.x+rrange(-10,10),base.z+rrange(-10,10));
}
}
}
/* ============================================================
VICTORY / SCREENS
============================================================ */
function checkVictory(){
if(gameOver||!running)return;
if(!teamHasHQ(TEAM_A)){
gameOver=1;sfx("win",false);showEnd(true);
}else if(!teamHasHQ(TEAM_P)){
gameOver=2;sfx("lose",false);showEnd(false);
}
}
const overlay=document.getElementById("overlay"),ocard=document.getElementById("ocard");
function showCard(html){ocard.innerHTML=html;overlay.classList.remove("hide");}
function hideCard(){overlay.classList.add("hide");}
function showStart(){
running=false;
showCard(`
<h1>FRONTIER WAR</h1>
<h2>Skirmish on Kepler-9 Frontier</h2>
<p>You command a <b style="color:#4fd8ff">Terran Expedition</b>. A rival <b style="color:#ff6a4a">Crimson Legion</b> force landed on the opposite side of the frontier. Both sides are harvesting the same finite crystal fields.</p>
<div class="cols">
<div>
<h3>Victory</h3>
<ul>
<li>Destroy <b>every</b> enemy Command Nexus.</li>
<li>You lose if all of your own Nexuses fall.</li>
<li>Build extra Nexuses to expand and to survive.</li>
</ul>
<h3>Economy</h3>
<ul>
<li><b>Haulers</b> mine crystal and return it to a Nexus.</li>
<li><b>Supply Relays</b> raise your supply cap (+10 each).</li>
<li>Crystal deposits are finite — expand to new fields.</li>
</ul>
<h3>Counters</h3>
<ul>
<li><b>Kinetic</b> (Trooper, Ravager) beats light armour.</li>
<li><b>Explosive</b> (Bastion, Wasp, Sentry) beats heavy armour.</li>
<li>Troopers, Wasps and Sentries can hit aircraft. Bastions cannot.</li>
</ul>
</div>
<div>
<h3>Controls</h3>
<ul>
<li><kbd>LMB</kbd> select · drag to box-select · <kbd>Shift</kbd> adds</li>
<li><kbd>RMB</kbd> move / attack / gather / set rally</li>
<li><kbd>A</kbd> attack-move · <kbd>S</kbd> stop · <kbd>H</kbd> hold</li>
<li>Arrows or screen edge: pan · drag the minimap to jump</li>
<li><kbd>Q</kbd><kbd>E</kbd> or middle-drag: rotate · wheel: zoom</li>
<li><kbd>Ctrl</kbd>+<kbd>1-9</kbd> set group · <kbd>1-9</kbd> recall</li>
<li><kbd>Space</kbd> centre on selection · <kbd>P</kbd> pause · <kbd>F1</kbd> help</li>
<li>Click the minimap to jump · right-click it to order</li>
</ul>
<h3>Opening moves</h3>
<ul>
<li>Queue a couple of Haulers, then a Supply Relay.</li>
<li>Build a Foundry, then a Research Spire or Aerie.</li>
<li>Scout — the enemy base is hidden in the fog.</li>
</ul>
</div>
</div>
<button class="big" id="btnStart">BEGIN OPERATION</button>`);
document.getElementById("btnStart").onclick=()=>{ensureAudio();hideCard();resetGame();};
}
function showEnd(win){
running=false;
showCard(`
<h1 style="color:${win?"#7ff0c0":"#ff8a70"}">${win?"FRONTIER SECURED":"EXPEDITION LOST"}</h1>
<h2>${win?"All Crimson Legion Nexuses destroyed":"Your command structure was annihilated"}</h2>
<p>Match time: <b>${Math.floor(gameTime/60)}m ${Math.floor(gameTime%60)}s</b> · Units fielded: <b>${units.filter(u=>!u.dead&&u.team===TEAM_P).length}</b> surviving · Crystal banked: <b>${Math.floor(teamState[TEAM_P].crystal)}</b></p>
<div class="flexrow">
<button class="big" id="btnAgain">PLAY AGAIN</button>
<button class="big" id="btnMenu" style="background:linear-gradient(180deg,#2a3f52,#16222e);border-color:#3d6a86">MAIN MENU</button>
</div>`);
document.getElementById("btnAgain").onclick=()=>{hideCard();resetGame();};
document.getElementById("btnMenu").onclick=()=>{showStart();};
}
function togglePause(){
if(gameOver||!running)return;
paused=!paused;
document.getElementById("bPause").textContent=paused?"RESUME (P)":"PAUSE (P)";
if(paused){
showCard(`<h1>PAUSED</h1><h2>Operation suspended</h2>
<p>Press <kbd>P</kbd> or the button below to resume.</p>
<button class="big" id="btnResume">RESUME</button>`);
document.getElementById("btnResume").onclick=()=>{togglePause();};
}else hideCard();
}
function toggleHelp(){
const h=document.getElementById("help");
h.classList.toggle("show");
}
document.getElementById("help").innerHTML=`
<b>FRONTIER WAR — quick reference</b><br><br>
<b>Goal:</b> destroy every enemy Command Nexus.<br>
<b>Economy:</b> Haulers mine crystal → Nexus. Supply Relays give +10 supply each. Deposits run dry — expand.<br>
<b>Build:</b> select a Hauler, pick a structure, click a valid spot. A nearby idle Hauler walks over and builds it.<br>
<b>Produce:</b> select a Foundry / Aerie / Nexus and queue units. Right-click the ground with a building selected to set its rally point.<br>
<b>Fight:</b> <span class="k">A</span> attack-move, <span class="k">S</span> stop, <span class="k">H</span> hold. Right-click an enemy to focus it.<br>
<b>Counters:</b> Kinetic beats light armour · Explosive beats heavy armour · Bastions cannot shoot air.<br>
<b>Fog:</b> unexplored ground is black, explored-but-unwatched is dim, enemy units vanish when unobserved. Last-known enemy buildings stay as silhouettes.<br>
<b>Camera:</b> arrows / screen edge · <span class="k">Q</span><span class="k">E</span> rotate · wheel zoom · minimap click.<br>
<b>Groups:</b> <span class="k">Ctrl</span>+<span class="k">1-9</span> assign, <span class="k">1-9</span> recall.`;
document.getElementById("bHelp").onclick=toggleHelp;
document.getElementById("bPause").onclick=togglePause;
document.getElementById("bMute").onclick=toggleMute;
/* ============================================================
INIT / RESET
============================================================ */
let terrain=null;
function resize(){
W=window.innerWidth;H=window.innerHeight;
const dpr=Math.min(window.devicePixelRatio||1,1.5);
canvas.width=Math.floor(W*dpr);canvas.height=Math.floor(H*dpr);
canvas.style.width=W+"px";canvas.style.height=H+"px";
fxc.width=W;fxc.height=H;fxc.style.width=W+"px";fxc.style.height=H+"px";
gl.viewport(0,0,canvas.width,canvas.height);
}
window.addEventListener("resize",resize);
document.addEventListener("mousemove",e=>{overHUD=isHUD(e.target);});
function resetGame(){
hideCard();
units=[];buildings=[];projectiles=[];effects=[];
nextId=1;gameTime=0;gameOver=0;paused=false;running=true;
selected=[];controlGroups={};placing=null;pendingCmd=null;
knownEnemyBld.clear();aiKnownBld.clear();aiKnownUnits.clear();
teamState[TEAM_P]={crystal:900,supply:0,supplyCap:0,upgrades:{},ai:false};
teamState[TEAM_A]={crystal:900,supply:0,supplyCap:0,upgrades:{},ai:true};
ai.think=1.5;ai.attackT=75;ai.waveSize=6;ai.wave=0;ai.expandT=120;ai.scoutT=8;
fogVis.fill(0);fogExp.fill(0);aiExp.fill(0);aiVis.fill(0);
aiMemoryTimer=0;visTimer=0;
srand(20240613+((Math.random()*1000)|0));
genMap();
buildRockInstances();
buildMinimapBase();
if(!terrain)terrain=buildTerrainMesh();
rebuildPathGrid();
/* starting forces */
const ph=mkBuilding("hq",TEAM_P,START_P.x,START_P.z,true);
const ah=mkBuilding("hq",TEAM_A,START_A.x,START_A.z,true);
rebuildPathGrid();
for(let i=0;i<5;i++){
const a=i/5*TAU;
const u=mkUnit("hauler",TEAM_P,START_P.x+Math.cos(a)*13,START_P.z+Math.sin(a)*13,a);
const n=nearestRes(u.x,u.z,80);
if(n)orderGather(u,n);
}
for(let i=0;i<5;i++){
const a=i/5*TAU;
const u=mkUnit("hauler",TEAM_A,START_A.x+Math.cos(a)*13,START_A.z+Math.sin(a)*13,a);
const n=nearestRes(u.x,u.z,80);
if(n)orderGather(u,n);
}
cam.x=START_P.x;cam.z=START_P.z;cam.yaw=0.75;cam.pitch=0.82;cam.dist=78;
updateVision();rebuildFogTex();updateFogCanvas();
refreshSelection();updateTopBar();
toast("Establish your base — scout to find the enemy","good");
document.getElementById("bPause").textContent="PAUSE (P)";
}
/* ============================================================
MAIN LOOP
============================================================ */
let lastT=0;
function loop(ts){
requestAnimationFrame(loop);
const dt=Math.min(0.05,(ts-lastT)/1000||0.016);
lastT=ts;
sfxFrame=0;
if(running&&!paused&&!gameOver){
stepSim(dt);
updateCamera(dt);
}
if(fogDirty){rebuildFogTex();updateFogCanvas();}
render();
drawOverlay();
drawMinimap();
updateTopBar();
}
resize();
resetGame();
showStart();
requestAnimationFrame(loop);
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4.1-flash","provider":"openrouter","persona":"full","sessionId":"8eca9336-2a59-4aae-a9e2-2b0fc6b9f656","tokensIn":6304465,"tokensOut":30045,"tokensTotal":6334510,"cost":0.11774588400000002,"turns":59,"toolCalls":58,"failedToolCalls":0,"timestamp":"2026-10-01T15:01:28.526Z"} -->