; ; /* ============================================================ RPG — hero commander: XP, levels, upgrades, active abilities ============================================================ */ let heroLvl=1, heroXp=0, heroXpNext=100; let heroDmgMult=1, heroRegen=14; let commanderHpMult=1; let abCool=[0,0,0,0,0]; // blast, repair, surge, orbital lance, EMP const AB_CD=[26,20,30,70,45]; let heroJumpCool=0; const HERO_JUMP={range:275,cool:28,energy:170}; let heroStuckFor=0,heroRescueAt=0; /* Index 4 is the craftable EMP: unlocked by a module rather than by rank or by the store, so it is the one ability that can be LOST when its module wears out. */ /* No universal Blast button. The selected commander grants their authored baseline power in applyCommanderChoice(); rank, Armory and modules add the others later. This makes the action rail describe the commander rather than exposing a generic legacy power menu. */ let abUnlock=[false,false,false,false,false]; let salvageMult=1, bldHpMult=1; let aiming=-1; // ability awaiting a target tap let pendingLevels=0; let commanderActiveCool=0; let commanderWeaponCool=[0,0]; /* Voss −14% is a live multiplier at ASSIGN time, not a rewrite of AB_CD. applyMetaPerks rebuilds AB_CD from AB_BASE (capacitor / Chrono Rig), and Rapid Systems mutates that table mid-match. Folding Voss into AB_CD missed signature, jump and commander weapons, and would double-apply if the table was rebuilt. Class doctrine and barrage stay army unlocks, not commander CD. */ let commanderCdMult=1; function commanderCool(t){return (t||0)*commanderCdMult;} /* Seat identity. main.js stores AI.bases[].commanderId (and the same field on AI.allies) so Large 1v3 is not three copies of the faction default. Mesh and weapon overlays key off that id — never TYPES, never playerCommanderId for an enemy. applyCommanderChoice stays the player perk stack. */ function commanderIdForUnit(i){ if(i==null||i<0) return null; if(typeof heroIdx!=='undefined'&&i===heroIdx) return (typeof playerCommanderId!=='undefined'&&playerCommanderId)||null; if(typeof AI==='undefined'||!AI) return null; const hit=S=>{ if(!S||S.commander!==i) return null; if(S.commanderGen!=null&&typeof ugen!=='undefined'&&ugen[i]!==S.commanderGen) return null; return S.commanderId||null; }; for(const S of AI.bases||[]){ const id=hit(S); if(id) return id; } for(const S of AI.allies||[]){ const id=hit(S); if(id) return id; } return null; } function commanderWeaponProfileFor(id){ if(typeof COMMANDER_WEAPON_PROFILES==='undefined'||!id) return null; return COMMANDER_WEAPON_PROFILES[id]||null; } function commanderStampAiSeats(){ if(typeof AI==='undefined'||!AI) return; const stamp=seats=>{ if(!seats) return; for(const S of seats){ if(!S||!S.commanderId) continue; const P=commanderWeaponProfileFor(S.commanderId)|| (S.fac&&typeof COMMANDER_WEAPON_PROFILES!=='undefined'&&COMMANDER_WEAPON_PROFILES[S.fac])||null; if(!P) continue; S.primary=P.primary; S.secondary=P.secondary; if(S.activeCool==null) S.activeCool=8+Math.random()*6; if(!S.weaponCool) S.weaponCool=[0,4+Math.random()*6]; } }; stamp(AI.bases); stamp(AI.allies); } function commanderDefForUnit(i){ const id=commanderIdForUnit(i); if(id&&typeof commanderById==='function'){ const C=commanderById(id); if(C) return C; } if(typeof heroIdx!=='undefined'&&i===heroIdx&&typeof playerCommanderDef==='function') return playerCommanderDef(); return null; } function commanderSeatForUnit(i){ if(typeof heroIdx!=='undefined'&&i===heroIdx) return {team:0,slot:-1,seat:null}; if(typeof AI!=='undefined'&&AI){ const hit=S=>{ if(!S||S.commander!==i) return null; if(S.commanderGen!=null&&ugen[i]!==S.commanderGen) return null; return S; }; for(const S of AI.bases||[]){ const s=hit(S); if(s) return {team:uteam[i],slot:s.slot,seat:s}; } for(const S of AI.allies||[]){ const s=hit(S); if(s) return {team:uteam[i],slot:s.slot,seat:s}; } } return {team:uteam[i],slot:typeof uCmd!=='undefined'?uCmd[i]:-1,seat:null}; } /* ---------- PRIMARY / SECONDARY FIRE ------------------------------------- Auto-attack keeps the Commander useful when the player is managing an army. These two manual weapons provide the missing deliberate shot: primary is a quick focus-fire command, secondary spends grid energy on faction-specific ordnance. They are selection-bound through hotslots.js, not global menus. */ function commanderWeaponDef(slot){ try{const C=typeof playerCommanderDef==='function'?playerCommanderDef():null;return C?(slot?C.secondary:C.primary):null;} catch(e){return null;} } function commanderWeaponButton(slot){ const id=slot?'abSecondary':'abPrimary';let b=document.getElementById(id);if(b)return b; const row=document.getElementById('heroRow');if(!row)return null; b=document.createElement('button');b.type='button';b.className='cbtn abtn';b.id=id; b.innerHTML='
'; const before=document.getElementById('abCommander')||row.firstChild;row.insertBefore(b,before); b.addEventListener('pointerdown',e=>{e.stopPropagation();tryCommanderWeapon(slot);}); return b; } function commanderWeaponRefresh(reset){ if(reset)commanderWeaponCool=[0,0]; for(let slot=0;slot<2;slot++){ const W=commanderWeaponDef(slot),b=commanderWeaponButton(slot);if(!b)continue; b.style.display=W?'flex':'none';if(!W)continue; b.querySelector('.em').textContent=W.em||'•';b.querySelector('.wnm').textContent=(W.nm||(slot?'SECONDARY':'PRIMARY')).toUpperCase(); } commanderWeaponButtonState(); } function tryCommanderWeapon(slot){ const W=commanderWeaponDef(slot);if(!W||heroIdx<0||!ualive[heroIdx]){toast('Commander weapon unavailable');return;} if(commanderWeaponCool[slot]>0){toast(W.nm+' REARMING — '+Math.ceil(commanderWeaponCool[slot])+'s');return;} if((W.energy||0)>resE[0]){toast(W.nm+' NEEDS '+W.energy+' ENERGY');return;} if(aiming===7+slot){aiming=-1;toast(W.nm.toUpperCase()+' TARGETING CANCELLED');return;} aiming=7+slot; toast((W.em||'•')+' '+W.nm.toUpperCase()+' — tap within '+W.range+'m'); if(typeof radioAck==='function')radioAck('ability',1,ux[heroIdx],uy[heroIdx]); } function fireCommanderWeapon(slot,wx,wy){ const W=commanderWeaponDef(slot);if(!W||heroIdx<0||!ualive[heroIdx]){aiming=-1;return false;} const hx=ux[heroIdx],hy=uy[heroIdx],d=Math.hypot(wx-hx,wy-hy); if(d>W.range){toast(W.nm.toUpperCase()+' TARGET OUT OF RANGE');return false;} if((W.energy||0)>resE[0]){toast(W.nm+' NEEDS '+W.energy+' ENERGY');return false;} /* Direct primary rounds need an actual contact. Passing the Commander as the projectile target made an otherwise valid shot curl back toward its owner; passing no target made zero-splash rounds land harmlessly. Snap the reticle to a nearby hostile, while authored splash secondaries may still bombard clear ground to deny an approach. */ const snap=Math.max(22,Math.min(48,(W.aoe||0)*.55)); let tgt=findEnemy(wx,wy,0,snap),tx=wx,ty=wy; if(tgt>=0){tx=ux[tgt];ty=uy[tgt];} else { const b=findEnemyBld(wx,wy,0,snap+14); if(b>=0){tgt=-2-b;tx=blds[b].x;ty=blds[b].y;} else if(!slot&&!W.aoe){toast('NO HOSTILE UNDER RETICLE');return false;} } if(W.energy)pay(0,0,W.energy); commanderWeaponCool[slot]=commanderCool(W.cool);aiming=-1; const pk=fireProj(W.ptype,0,hx,hy,tx,ty,W.speed,W.damage*heroDmgMult,W.aoe||0,tgt); if(pk>=0){ pwk[pk]=W.wk||'p'; if(slot){pCannon[pk]=1;if(W.ptype===2){pBarrage[pk]=1;pArc[pk]=90;}} projectileFireFX(pk,hx,hy,tx-hx,ty-hy); } const C=playerCommanderDef(),col=C&&C.active&&C.active.col||[110,215,255]; addParticle(0,hx,hy,0,0,.22,slot?22:13,col[0],col[1],col[2]); sfx(W.sfx||'shot',hx,hy,slot?1.45:1.05);shake=Math.max(shake,slot?2.4:.65); if(typeof radioAck==='function')radioAck('ability',1,tx,ty); return true; } function commanderWeaponButtonState(){ for(let slot=0;slot<2;slot++){ const W=commanderWeaponDef(slot),b=document.getElementById(slot?'abSecondary':'abPrimary');if(!b||!W)continue; const cover=commanderWeaponCool[slot]>0?Math.ceil(commanderWeaponCool[slot]):((W.energy||0)>resE[0]?'E':''); const cd=b.querySelector('.cdring');b.classList.toggle('cd',!!cover);b.classList.toggle('on',aiming===7+slot); cd.style.display=cover?'flex':'none';cd.textContent=cover; const tip=(slot?'Secondary':'Primary')+' fire — '+W.nm+(W.energy?' · '+W.energy+' energy':'')+' · '+Math.round(commanderCool(W.cool))+'s. '+W.ds; b.title=tip;b.setAttribute('aria-label',tip); } } /* ---------- COMMANDER SIGNATURE ACTIVES ----------------------------------- Baseline powers remain baseline powers. The selected commander adds one signature command through its own source button, so identity changes play without taking Orbital Blast, Repair Pulse, Surge, Lance or EMP away. */ function commanderActiveDef(){ try{ const C=typeof playerCommanderDef==='function'?playerCommanderDef():null;return C&&C.active?C.active:null; } catch(e){ return null; } } function commanderActiveButton(){ let b=document.getElementById('abCommander'); if(b) return b; const row=document.getElementById('heroRow');if(!row)return null; b=document.createElement('button');b.type='button';b.className='cbtn abtn';b.id='abCommander'; b.innerHTML='SIGNATURE
'; const jump=document.getElementById('abJump');row.insertBefore(b,jump||row.firstChild); b.addEventListener('pointerdown',e=>{e.stopPropagation();tryCommanderActive();}); return b; } function commanderActiveRefresh(reset){ const b=commanderActiveButton(),A=commanderActiveDef();if(!b)return; if(reset) commanderActiveCool=0; b.style.display=A?'flex':'none';if(!A)return; const em=document.getElementById('cmdAbEm'),nm=document.getElementById('cmdAbNm'); if(em)em.textContent=A.em||'✦';if(nm)nm.textContent=A.nm||'SIGNATURE'; commanderActiveButtonState(); } function commanderActiveReset(){ commanderActiveCool=0;commanderWeaponCool=[0,0]; if(aiming===6||aiming===7||aiming===8)aiming=-1; commanderActiveRefresh(false);commanderWeaponRefresh(false); } function commanderActiveFx(A,x,y,size){ const c=A.col||[110,210,255],s=size||A.range||150; addParticle(3,x,y,0,0,.72,s,c[0],c[1],c[2]); addParticle(0,x,y,0,-10,.5,Math.max(9,s*.08),c[0],c[1],c[2]); } function commanderActivePayAt(i,A,quiet){ const m=A.mass||0,e=A.energy||0,seat=commanderSeatForUnit(i); if(!canAfford(seat.team,m,e,seat.slot)){ if(!quiet) toast(A.nm+' NEEDS '+(m?m+' MASS'+(e?' + ':''):'')+(e?e+' ENERGY':'')); return false; } pay(seat.team,m,e,seat.slot); const cd=commanderCool(A.cool||30); if(typeof heroIdx!=='undefined'&&i===heroIdx) commanderActiveCool=cd; else if(seat.seat) seat.seat.activeCool=cd; return true; } function commanderActivePay(A){ return commanderActivePayAt(heroIdx,A,false); } function commanderActiveRefundAt(i,A){ const seat=commanderSeatForUnit(i); if(typeof heroIdx!=='undefined'&&i===heroIdx) commanderActiveCool=0; else if(seat.seat) seat.seat.activeCool=0; if(seat.team===1&&seat.seat){ seat.seat.mass=(seat.seat.mass||0)+(A.mass||0); seat.seat.energy=(seat.seat.energy||0)+(A.energy||0); return; } const t=seat.team||0; resM[t]=Math.min(RES_MCAP[t],resM[t]+(A.mass||0)); resE[t]=Math.min(RES_ECAP[t],resE[t]+(A.energy||0)); } function commanderActiveRefund(A){ commanderActiveRefundAt(heroIdx,A); } function tryCommanderActive(){ const A=commanderActiveDef(); if(!A){toast('Commander signature unavailable — baseline powers remain operational');sfx('ui');return;} if(heroIdx<0||!ualive[heroIdx]){toast('Commander is down');return;} if(commanderActiveCool>0){toast(A.nm+' REARMING — '+Math.ceil(commanderActiveCool)+'s');return;} if(!canAfford(0,A.mass||0,A.energy||0)){commanderActivePay(A);return;} if(aiming===7||aiming===8)aiming=-1; if(A.target){ if(aiming===6){aiming=-1;toast(A.nm.toUpperCase()+' TARGETING CANCELLED');return;} aiming=6;toast((A.em||'✦')+' '+A.nm.toUpperCase()+' — tap within '+A.range+'m'); if(typeof radioAck==='function')radioAck('ability',1,ux[heroIdx],uy[heroIdx]);return; } fireCommanderActive(ux[heroIdx],uy[heroIdx]); } function commanderActiveDamageCircle(x,y,r,unitDmg,bldDmg,team){ if(team==null) team=0; let n=0; forUnitsIn(x,y,r,j=>{if(uteam[j]===team)return;dealDamage(j,unitDmg,team,-1);n++;}); for(let bi=0;bi(A.range||0)){ if(!quiet)toast(A.nm.toUpperCase()+' TARGET OUT OF RANGE');return false; } if(A.id==='phasebreach'&&!jumpLandingClear(wx,wy,idx)){ if(!quiet)toast('PHASE BREACH NEEDS CLEAR GROUND');return false; } if(!commanderActivePayAt(idx,A,quiet))return false; if(player)aiming=-1;let affected=0; if(A.id==='skybreaker'){ const pts=[[0,0],[36,-24],[-31,28]]; for(let q=0;q{if(uteam[j]!==team)return;affected++;uhp[j]=Math.min(uhpm[j],uhp[j]+uhpm[j]*.34+55);uclassBuff[j]=3;uclassBuffT[j]=Math.max(uclassBuffT[j],8);}); for(const B of blds)if(B.alive&&B.team===team&&dist2(hx,hy,B.x,B.y)<=A.range*A.range){B.hp=Math.min(B.hpm,B.hp+B.hpm*.24+90);B.shieldT=Math.max(B.shieldT||0,8);affected++;} }else if(A.id==='ghostnet'){ if(typeof fogStartScan==='function')fogStartScan(wx,wy,22,18); forUnitsIn(wx,wy,225,j=>{affected++;if(uteam[j]===team){uclassBuff[j]=2;uclassBuffT[j]=Math.max(uclassBuffT[j],9);}else{ustun[j]=Math.max(ustun[j],1.6);ucool[j]=Math.max(ucool[j],2.2);}}); }else if(A.id==='seismicdecree'){ affected=commanderActiveDamageCircle(wx,wy,125,420*dmgM,920*dmgM,team); damageScenery(wx,wy,145,1300);spawnExplosion(wx,wy,36,team);addCrater(wx,wy,105);deformTerrain(wx,wy,125,.09); if(player)shake=Math.max(shake,11); }else if(A.id==='crimsonadvance'){ forUnitsIn(hx,hy,A.range,j=>{if(uteam[j]!==team||j===idx)return;affected++;uhp[j]=Math.min(uhpm[j],uhp[j]+uhpm[j]*.15);uclassBuff[j]=1;uclassBuffT[j]=Math.max(uclassBuffT[j],11);}); affected+=commanderActiveDamageCircle(hx,hy,95,120*dmgM,70*dmgM,team); }else if(A.id==='ironredoubt'){ for(const B of blds)if(B.alive&&B.team===team&&dist2(hx,hy,B.x,B.y)<=A.range*A.range){affected++;B.hp=Math.min(B.hpm,B.hp+B.hpm*.17+100);B.shieldT=Math.max(B.shieldT||0,12);commanderActiveFx(A,B.x,B.y,B.r*2.1);} if(!affected){commanderActiveRefundAt(idx,A);if(!quiet)toast('IRON REDOUBT NEEDS A FRIENDLY STRUCTURE IN RANGE — RESOURCES REFUNDED');return false;} }else if(A.id==='liquidation'){ let claimed=0; for(let w=wrecks.length-1;w>=0&&claimed<4;w--){const W=wrecks[w];if(dist2(hx,hy,W.x,W.y)>A.range*A.range)continue; if(team===1){const seat=commanderSeatForUnit(idx);if(seat.seat){seat.seat.mass=(seat.seat.mass||0)+W.mass;seat.seat.energy=(seat.seat.energy||0)+W.en;}} else{resM[team]=Math.min(RES_MCAP[team],resM[team]+W.mass);resE[team]=Math.min(RES_ECAP[team],resE[team]+W.en);} commanderActiveFx(A,W.x,W.y,38);wrecks.splice(w,1);claimed++;} for(const B of blds)if(B.alive&&B.team===team&&['fac','tgate','harbor','airfield'].includes(B.type)&&dist2(hx,hy,B.x,B.y)<=A.range*A.range){B.boost=Math.max(B.boost,16);affected++;} affected+=claimed; if(!affected){commanderActiveRefundAt(idx,A);if(!quiet)toast('COMBAT LIQUIDATION NEEDS A WRECK OR FACTORY — RESOURCES REFUNDED');return false;} }else if(A.id==='phasebreach'){ commanderActiveFx(A,hx,hy,65);ux[idx]=wx;uy[idx]=wy;utx[idx]=wx;uty[idx]=wy;utgt[idx]=-1;ustate[idx]=0; forUnitsIn(wx,wy,165,j=>{if(uteam[j]===team)return;ustun[j]=Math.max(ustun[j],3.5);ucool[j]=Math.max(ucool[j],4);affected++;}); for(const B of blds)if(B.alive&&B.team!==team&&dist2(wx,wy,B.x,B.y)<=165*165){B.cool=Math.max(B.cool||0,4);affected++;} commanderCrushScenery(idx,true); }else if(A.id==='naniterecall'){ const chosen=[]; if(player){for(let n=0;ndist2(ux[a],uy[a],hx,hy)-dist2(ux[b],uy[b],hx,hy)); for(let n=0;n>2);let x=hx+Math.cos(ang)*rad,y=hy+Math.sin(ang)*rad; if(typeof battlefieldClampPoint==='function'){const p=battlefieldClampPoint(x,y,TYPES[utype[u]].r+5);x=p[0];y=p[1];} if(!TYPES[utype[u]].air&&typeof isWalkable==='function'&&!isWalkable(x,y))continue; commanderActiveFx(A,ux[u],uy[u],30);ux[u]=x;uy[u]=y;utx[u]=x;uty[u]=y;utgt[u]=-1;ustate[u]=0;uhp[u]=Math.min(uhpm[u],uhp[u]+uhpm[u]*.18);uclassBuff[u]=3;uclassBuffT[u]=7;affected++;commanderActiveFx(A,x,y,34);} if(!affected){commanderActiveRefundAt(idx,A);if(!quiet)toast('NANITE RECALL NEEDS SELECTED ALLIED UNITS — RESOURCES REFUNDED');return false;} }else{ commanderActiveRefundAt(idx,A); if(!quiet)toast('Signature command unavailable — resources refunded');return false; } commanderActiveFx(A,wx,wy,Math.max(70,Math.min(260,A.range||150))); sfx(A.sfx||'surge',wx,wy,1.2); if(typeof radioAck==='function'&&player)radioAck('ability',Math.max(1,affected),wx,wy); if(!quiet)toast((A.em||'✦')+' '+A.nm.toUpperCase()+' — '+affected+' '+(affected===1?'ASSET':'ASSETS')+' AFFECTED'); return true; } function commanderActiveButtonState(){ const b=commanderActiveButton(),A=commanderActiveDef();if(!b)return; b.style.display=A?'flex':'none';if(!A)return; const cd=b.querySelector('.cdring'),m=A.mass||0,e=A.energy||0; let cover=commanderActiveCool>0?Math.ceil(commanderActiveCool):(!canAfford(0,m,e)?(m&&resM[0]({ x:clamp(x+o[0]*ART_BARRAGE.spread,15,MAP-15), y:clamp(y+o[1]*ART_BARRAGE.spread,15,MAP-15) })); } function artBarrageSelected(tx,ty,limit){ const out=[]; for(let i=0;iART_BARRAGE.range*ART_BARRAGE.range) continue; out.push(i); } if(tx!==undefined) out.sort((a,b)=>dist2(ux[a],uy[a],tx,ty)-dist2(ux[b],uy[b],tx,ty)); return limit?out.slice(0,limit):out; } function setArtBarragePreview(x,y){ artBarrageAim={x:clamp(x,15,MAP-15),y:clamp(y,15,MAP-15)}; } function cancelArtilleryBarrageAim(quiet){ if(aiming===5) aiming=-1; artBarrageAim=null; if(!quiet){toast('BARRAGE TARGETING CANCELLED');sfx('ui');} } function artBarrageRemember(i){ return {i:i,g:ugen[i],x:ux[i],y:uy[i],hp:uhp[i],state:ustate[i],hold:uhold[i], tx:utx[i],ty:uty[i],tgt:utgt[i],tgtg:utgtg[i],field:ufield[i], route:uPatrolRoute[i],step:uPatrolStep[i],slot:uPatrolSlot[i],cohort:uMoveCohort[i]}; } function artBarrageRestore(m){ const i=m.i;if(!ualive[i]||ugen[i]!==m.g||uteam[i]!==0||ustate[i]!==6)return; ustate[i]=m.state;uhold[i]=m.hold;utx[i]=m.tx;uty[i]=m.ty;utgt[i]=m.tgt;utgtg[i]=m.tgtg; ufield[i]=m.field;uPatrolRoute[i]=m.route;uPatrolStep[i]=m.step;uPatrolSlot[i]=m.slot;uMoveCohort[i]=m.cohort; } function artBarrageMemberLive(m){ const i=m.i; return !!(ualive[i]&&ugen[i]===m.g&&uteam[i]===0&&TYPES[utype[i]].cat==='art'&& ustate[i]===6&&dist2(ux[i],uy[i],m.x,m.y)<=28*28&&uhp[i]>m.hp-uhpm[i]*.08); } function tryArtilleryBarrage(){ if(artBarrageCharge){ for(const m of artBarrageCharge.members)artBarrageRestore(m); artBarrageCharge=null;toast('BARRAGE CHARGE INTERRUPTED');sfx('ui');return; } if(aiming===5){cancelArtilleryBarrageAim(false);return;} if(!artBarrageUnlocked()){ toast('LOCKED — research Fire Mission Protocol in DEVELOPMENT > DOCTRINE');sfx('ui');return; } if(artBarrageCool>0){toast('BARRAGE REARMING — '+Math.ceil(artBarrageCool)+'s');return;} if(artBarrageQueue.length){toast('BARRAGE VOLLEY IS STILL IN FLIGHT');return;} const src=artBarrageSelected(); if(!src.length){toast('Select a Thumper or Bombard artillery unit first');sfx('ui');return;} if(!canAfford(0,0,ART_BARRAGE.energy)){toast('BARRAGE NEEDS '+ART_BARRAGE.energy+' ENERGY');return;} let cx=0,cy=0;for(const i of src){cx+=ux[i];cy+=uy[i];}cx/=src.length;cy/=src.length; let dx=cam.x-cx,dy=cam.y-cy,d=Math.hypot(dx,dy); if(d<60){dx=0;dy=-1;d=1;} const step=Math.min(310,Math.max(180,d)); setArtBarragePreview(cx+dx/d*step,cy+dy/d*step); aiming=5; toast('CHARGED BARRAGE — '+ART_BARRAGE.energy+' energy · tap or drag a target area'); if(typeof radioAck==='function')radioAck('ability',src.length,cx,cy);else sfx('radio',cx,cy,.9); } function beginArtilleryBarrage(x,y){ if(!artBarrageUnlocked()||artBarrageCool>0||artBarrageCharge||artBarrageQueue.length){cancelArtilleryBarrageAim(true);return false;} const src=artBarrageSelected(x,y,4); if(!src.length){setArtBarragePreview(x,y);toast('TARGET OUT OF RANGE — move selected artillery within '+ART_BARRAGE.range+'m');return false;} if(!canAfford(0,0,ART_BARRAGE.energy)){cancelArtilleryBarrageAim(true);toast('BARRAGE NEEDS '+ART_BARRAGE.energy+' ENERGY');return false;} pay(0,0,ART_BARRAGE.energy);artBarrageCool=ART_BARRAGE.cool; const members=src.map(artBarrageRemember); for(const m of members){ const i=m.i;ustate[i]=6;uhold[i]=1;utgt[i]=-1;utgtg[i]=-1;utx[i]=ux[i];uty[i]=uy[i]; uPatrolRoute[i]=-1;uMoveCohort[i]=-1; } artBarrageCharge={x:clamp(x,15,MAP-15),y:clamp(y,15,MAP-15),t:0,total:ART_BARRAGE.charge, members:members,pattern:artBarragePattern(x,y),fx:0}; aiming=-1;artBarrageAim=null; const c0=members[0],sx=ux[c0.i],sy=uy[c0.i]; addParticle(3,sx,sy,0,0,.55,34,255,190,80); if(typeof fogFxVisible!=='function'||fogFxVisible(sx,sy,0))sfx('surge',sx,sy,1.05); toast('FIRE MISSION CHARGING — moving or heavy damage interrupts'); return true; } function artBarrageLaunch(m,P){ const i=m.i;if(!ualive[i]||ugen[i]!==m.g||uteam[i]!==0||TYPES[utype[i]].cat!=='art')return false; const T=TYPES[utype[i]],a=Math.atan2(P.y-uy[i],P.x-ux[i]); uturr[i]=a+Math.PI/2; const sx=ux[i]+Math.cos(a)*T.size*.72,sy=uy[i]+Math.sin(a)*T.size*.72; const k=fireProj(2,0,sx,sy,P.x,P.y,ART_BARRAGE.speed,ART_BARRAGE.damage,ART_BARRAGE.aoe,-1); if(k<0)return false; pwk[k]='e';pmu0[k]=1;pBarrage[k]=1; pArc[k]=ART_BARRAGE.arc+Math.min(180,Math.sqrt(dist2(sx,sy,P.x,P.y))*.22); addParticle(0,sx,sy,0,0,.16,18,255,238,188); addParticle(3,sx,sy,0,0,.28,29,255,170,62); addParticle(1,sx,sy,rr(-5,5),rr(-14,-5),.9,10,65,60,55); if(typeof fogFxVisible!=='function'||fogFxVisible(sx,sy,0)){ sfx('cannon',sx,sy,1.22); if(typeof artilleryWorldAudio==='function')artilleryWorldAudio('launch',sx,sy,0,1.18); } return true; } function artBarrageTick(dt){ if(artBarrageCool>0)artBarrageCool=Math.max(0,artBarrageCool-dt); const C=artBarrageCharge; if(C){ const live=[]; for(const m of C.members){if(artBarrageMemberLive(m))live.push(m);else artBarrageRestore(m);} C.members=live; if(!live.length){artBarrageCharge=null;toast('BARRAGE INTERRUPTED — battery lost the firing solution');sfx('ui');} else{ C.t+=dt;C.fx-=dt; const p=clamp(C.t/C.total,0,1); for(const m of live){ const i=m.i,a=Math.atan2(C.y-uy[i],C.x-ux[i])+Math.PI/2,d=a-uturr[i]; uturr[i]+=clamp(Math.atan2(Math.sin(d),Math.cos(d)),-2.8*dt,2.8*dt); } if(C.fx<=0){ C.fx=.17; for(const m of live){const i=m.i;addParticle(0,ux[i],uy[i],rr(-3,3),rr(-4,1),.22,5+8*p,255,185,68);} } if(C.t>=C.total){ for(const m of live)artBarrageRestore(m); for(let n=0;n=0;q--){ const Q=artBarrageQueue[q];Q.t-=dt; if(Q.t<=0){artBarrageLaunch(Q.m,Q.p);artBarrageQueue.splice(q,1);} } } function artBarrageReset(){ artBarrageCool=0;artBarrageAim=null;artBarrageCharge=null;artBarrageQueue.length=0; } function artBarrageButtonState(){ const b=document.getElementById('abBarrage');if(!b)return; const cd=b.querySelector('.cdring'),locked=!artBarrageUnlocked(),n=artBarrageSelected().length; let cover='',desc='Charged Barrage — '+ART_BARRAGE.energy+' energy, '+ART_BARRAGE.cool+'s cooldown. '+ 'Selected artillery braces for '+ART_BARRAGE.charge+'s; movement or heavy damage interrupts.'; if(locked){cover='LOCK';desc='Locked — research Fire Mission Protocol in Development > Doctrine.';} else if(artBarrageCharge){cover=Math.round(clamp(artBarrageCharge.t/artBarrageCharge.total,0,1)*100)+'%';} else if(artBarrageQueue.length){cover='FIRE';} else if(artBarrageCool>0){cover=String(Math.ceil(artBarrageCool));} else if(resE[0]=0)out.push(i); return out; } function classAbilityChoice(){ let best=null,bestN=0; for(const k of ['service','intercept','assault']){ const n=classAbilityEligible(CLASS_AB[k]).length; if(n>bestN){best=k;bestN=n;} } return best?{key:best,A:CLASS_AB[best],units:classAbilityEligible(CLASS_AB[best])}:null; } function classAbilityUnlocked(A){return typeof devHas==='function'&&devHas(A.req);} /* THE ARMY'S OWN WORD FOR ITS OWN DOCTRINE. CLASS_AB holds the rules — cost, cooldown, duration, eligible categories — and those are identical for every faction. Only the label, glyph and sentence move, resolved once here so the button, its aria-label, the hot-slot chip and every toast cannot disagree about what the player just pressed. */ function classAbWords(key){ const A=CLASS_AB[key]||{}; if(typeof factionClassAbName!=='function') return {nm:A.nm||'',em:A.em||'',ds:A.ds||''}; const kit=(typeof factionTextKit==='function')?factionTextKit(0):undefined; return {nm:factionClassAbName(key,kit)||A.nm||'', em:factionClassAbEm(key,kit)||A.em||'', ds:factionClassAbDesc(key,kit)||A.ds||''}; } function tryClassAbility(){ const C=classAbilityChoice(); if(!C){toast('Select assault, interceptor, anti-air, or support units first');sfx('ui');return;} const A=C.A,W=classAbWords(C.key); if(!classAbilityUnlocked(A)){ const n=(typeof DEVTREE!=='undefined'&&DEVTREE.find(x=>x.id===A.req)); toast('LOCKED — research '+(n?n.nm:A.req)+' in DEVELOPMENT > DOCTRINE');sfx('ui');return; } if(classAbCool[C.key]>0){toast(W.nm+' REARMING — '+Math.ceil(classAbCool[C.key])+'s');return;} if(!canAfford(0,0,A.energy)){toast(W.nm+' NEEDS '+A.energy+' ENERGY');return;} pay(0,0,A.energy);classAbCool[C.key]=A.cool; const touched=new Set(),centres=C.units; if(C.key==='service'){ for(const i of centres){ uclassBuff[i]=3;uclassBuffT[i]=A.dur; forUnitsIn(ux[i],uy[i],125,j=>{ if(uteam[j]!==0)return;touched.add(j);uclassBuff[j]=3;uclassBuffT[j]=Math.max(uclassBuffT[j],A.dur); uhp[j]=Math.min(uhpm[j],uhp[j]+uhpm[j]*.26+55); addParticle(0,ux[j],uy[j],0,-8,.42,6,82,255,164); }); for(const B of blds)if(B.alive&&B.team===0&&dist2(ux[i],uy[i],B.x,B.y)<=145*145){ B.hp=Math.min(B.hpm,B.hp+B.hpm*.18+80);touched.add(B); } addParticle(3,ux[i],uy[i],0,0,.75,250,82,255,164); } }else{ const kind=C.key==='intercept'?2:1; for(const i of centres){uclassBuff[i]=kind;uclassBuffT[i]=A.dur;touched.add(i); const col=kind===2?[75,225,255]:[255,112,54]; addParticle(3,ux[i],uy[i],0,0,.5,TYPES[utype[i]].size*2.5,col[0],col[1],col[2]); } } let cx=0,cy=0;for(const i of centres){cx+=ux[i];cy+=uy[i];}cx/=centres.length;cy/=centres.length; toast(W.em+' '+W.nm+' — '+touched.size+' '+(touched.size===1?'ASSET':'ASSETS')+' AFFECTED'); sfx(C.key==='service'?'heal':'surge',cx,cy,1.05); if(typeof radioAck==='function')radioAck('ability',touched.size,cx,cy); } function classAbilityReset(){ for(const k in classAbCool)classAbCool[k]=0; /* resetWorld already calls this after wiping the pop ledger. airlift.js loads before main.js so it cannot wrap resetWorld; this is the world-reset hook we own. mfAirliftResetHolds is idempotent with the ledger wrap. */ if(typeof mfAirliftResetHolds==='function') mfAirliftResetHolds(); } function classAbilityTick(dt){for(const k in classAbCool)if(classAbCool[k]>0)classAbCool[k]=Math.max(0,classAbCool[k]-dt);} function classAbilityButtonState(){ const b=document.getElementById('abClass');if(!b)return; const C=classAbilityChoice(),cd=b.querySelector('.cdring'),em=document.getElementById('classAbEm'),nm=document.getElementById('classAbNm'); b.style.display=C?'flex':'none';if(!C)return; const A=C.A,W=classAbWords(C.key),locked=!classAbilityUnlocked(A);em.textContent=W.em;nm.textContent=W.nm; let cover='';if(locked)cover='LOCK';else if(classAbCool[C.key]>0)cover=Math.ceil(classAbCool[C.key]);else if(resE[0]=heroXpNext){ heroXp-=heroXpNext; heroLvl++; heroXpNext=Math.round(heroXpNext*1.45); pendingLevels++; if(heroLvl===2){ abUnlock[1]=true; toast('🛠 REPAIR PULSE unlocked'); } if(heroLvl===3){ abUnlock[2]=true; toast('⚡ COMBAT SURGE unlocked'); } sfx('level'); } if(pendingLevels>0 && document.getElementById('levelUp').style.display!=='flex') showLevelUp(); } /* The eight level-up cards. Nova is the base roster's own voice so these ARE its words; the other three armies overlay `upgrade` in src/factext.js and a Brood commander is never offered "Nano Plating". Names stay <= 16 characters because .upCard is 136px wide on a 380px phone — "Assembly Protocols" was 18 and wrapped. Whatever the words, `fn` is the effect and is never overridden. */ const UPGRADES=[ {em:'🗡', nm:'Weapon Overload', ds:'+25% Commander damage', fn:()=>{ heroDmgMult*=1.25; }}, {em:'🛡', nm:'Nano Plating', ds:'+20% Commander max HP (heals)',fn:()=>{ if(heroIdx>=0){ uhpm[heroIdx]*=1.2; uhp[heroIdx]=Math.min(uhpm[heroIdx],uhp[heroIdx]+uhpm[heroIdx]*0.25);} }}, /* Was *=1.8 uncapped: three picks took regen from 14 to 82/s and the commander stopped being killable. Capped compounding keeps the card worth taking without ever out-regenerating a defended base's damage. */ {em:'♻', nm:'Regen Matrix', ds:'+45% Commander regeneration', fn:()=>{ heroRegen=Math.min(heroRegen*1.45,70); }}, {em:'⏱', nm:'Rapid Systems', ds:'Ability cooldowns −20%', fn:()=>{ for(let i=0;i<3;i++) AB_CD[i]*=0.8; }}, {em:'📦', nm:'Logistics Core', ds:'+2.5 mass & +8 energy income', fn:()=>{ bonusMass+=2.5; bonusEnergy+=8; }}, {em:'🎖', nm:'Veteran Doctrine', ds:'All your units +10% damage', fn:()=>{ armyDmgMult+=0.10; }}, {em:'🏭', nm:'Assembly Lines', ds:'Factories build 25% faster', fn:()=>{ playerBuildMult+=0.25; }}, {em:'💥', nm:'Wider Blast', ds:'Orbital Blast radius +35%', fn:()=>{ blastRadius*=1.35; }}, ]; let bonusMass=0, bonusEnergy=0, armyDmgMult=1, blastRadius=110; /* Combat Stims are a TEMPORARY multiplier and must be kept out of armyDmgMult. armyDmgMult is written ADDITIVELY by thirteen different sources — research, the Veteran Doctrine level-up card, five armory weapon items, consumables, develop modules and operation modifiers. Folding a *=1.45 into it and later doing /=1.45 is not an inverse once any of those has landed in between: (1.00*1.45 + 0.12)/1.45 = 1.083, not 1.12. A player who completed Ballistics I during a stim permanently kept 0.083 of the 0.12 they paid 500 mass and 2500 energy for, and level-ups are MORE likely during a stim because kills spike. */ let stimDmgMult=1; function flashScreen(){ const f=document.getElementById('flash'); if(!f) return; f.style.transition='none'; f.style.opacity=0.85; requestAnimationFrame(()=>{ f.style.transition='opacity .9s'; f.style.opacity=0; }); } function showLevelUp(){ if(pendingLevels<=0) return; paused=true; const el=document.getElementById('levelUp'); document.getElementById('luLvl').textContent='Commander reached level '+(heroLvl-pendingLevels+1+0)+' — choose an upgrade'; const cards=document.getElementById('luCards'); cards.innerHTML=''; /* Draw INDICES, not the card objects: the words on a card are resolved through the player's faction kit (src/factext.js) and the index is what names them. A Brood commander is not offered "Nano Plating". The `fn` is never overridden, so the effect can never drift from the promise. */ const pool=UPGRADES.map((u,i)=>i); const upKit=(typeof factionTextKit==='function')?factionTextKit(0):undefined; for(let k=0;k<2;k++){ const gi=pool.splice(Math.floor(Math.random()*pool.length),1)[0]; const pick=UPGRADES[gi]; const upEm=(typeof factionUpgradeEm==='function')?factionUpgradeEm(gi,upKit)||pick.em:pick.em; const upNm=(typeof factionUpgradeName==='function')?factionUpgradeName(gi,upKit)||pick.nm:pick.nm; const upDs=(typeof factionUpgradeDesc==='function')?factionUpgradeDesc(gi,upKit)||pick.ds:pick.ds; const d=document.createElement('div'); d.className='upCard'; d.innerHTML='
'+upEm+'
'+upNm+''+upDs; d.addEventListener('pointerdown',ev=>{ ev.stopPropagation(); pick.fn(); sfx('ui'); pendingLevels--; if(pendingLevels>0){ showLevelUp(); } else { el.style.display='none'; paused=false; } }); cards.appendChild(d); } el.style.display='flex'; } // ---------- abilities ---------- function tryAbility(k){ if(aiming===5) cancelArtilleryBarrageAim(true); if(aiming===6) aiming=-1; if(aiming===7||aiming===8)aiming=-1; if(heroIdx<0){ toast('Commander is down'); return; } if(!abUnlock[k]){ toast(k===3?'🛰 Buy the Orbital Uplink in the Armory to unlock this' :k===4?'⚡ Craft the EMP Charge module in Development to unlock this' :'Unlocks at Commander level '+(k===1?2:3)); return; } if(abCool[k]>0) return; if(k===0){ aiming=0; const fac=typeof mfCombatFactionTeam==='function'?mfCombatFactionTeam(0):'nova'; toast(fac==='legion'||fac==='syndicate' ? '◐ Tap a location to open a GRAVITY WELL' : '✹ Tap a location to fire CLUSTER BOMB'); if(typeof radioAck==='function')radioAck('ability',1,ux[heroIdx],uy[heroIdx]); return; } if(k===3){ aiming=3; toast('🛰 Tap a location to call the ORBITAL LANCE'); if(typeof radioAck==='function')radioAck('ability',1,ux[heroIdx],uy[heroIdx]); return; } if(k===1){ // repair pulse abCool[1]=commanderCool(AB_CD[1]); const hx=ux[heroIdx], hy=uy[heroIdx]; forUnitsIn(hx,hy,190,j=>{ if(uteam[j]===0){ uhp[j]=Math.min(uhpm[j],uhp[j]+uhpm[j]*0.45+40); addParticle(0,ux[j],uy[j],0,-10,.5,7, 120,255,170); } }); addParticle(3,hx,hy,0,0,.7,190, 90,255,160); sfx('heal'); if(typeof radioAck==='function')radioAck('ability',1,hx,hy); return; } if(k===4){ // EMP charge — the craftable module from Development /* This module has been on sale in Development for 5 Relic Cores + 30 Isotope since it was added, described as "stun everything in a wide radius", with no implementation anywhere in the codebase and a flag that a four-entry reset array threw away every match. Both are fixed; this is the ability. Centred on the Commander rather than aimed: it is a panic button for when the Commander is caught, and a self-centred blast needs no aiming mode — one less state machine to get wrong. Friendlies are untouched. */ abCool[4]=commanderCool(AB_CD[4]); const hx=ux[heroIdx], hy=uy[heroIdx], R=260; let n=0; forUnitsIn(hx,hy,R,j=>{ if(!ualive[j]||uteam[j]===0||j===heroIdx) return; ustun[j]=4; ucool[j]=Math.max(ucool[j],4); n++; addParticle(0,ux[j],uy[j],0,-12,.5,7,150,220,255); }); /* Enemy defensive structures are on the same grid and are just as much "everything in a wide radius" as the units are. */ let b=0; for(const B of blds){ if(!B.alive||B.team===0) continue; if(Math.hypot(B.x-hx,B.y-hy)>R) continue; B.cool=Math.max(B.cool||0,4); b++; } addParticle(3,hx,hy,0,0,.8,R,150,220,255); if(typeof gpfxEnergyBlast==='function') gpfxEnergyBlast(hx,hy,16,52,[150,220,255],{speed:180,up:0.12,life:0.85,size:3.4,min:8,jit:10}); toast('⚡ EMP — '+n+' unit'+(n===1?'':'s')+(b?' and '+b+' structure'+(b===1?'':'s'):'')+' disabled for 4s'); sfx('surge',hx,hy,1.2); if(typeof radioAck==='function')radioAck('ability',n,hx,hy); return; } if(k===2){ // combat surge abCool[2]=commanderCool(AB_CD[2]); const hx=ux[heroIdx], hy=uy[heroIdx]; let n=0; forUnitsIn(hx,hy,230,j=>{ if(uteam[j]===0){ ubuff[j]=8; n++; } }); addParticle(3,hx,hy,0,0,.7,230, 255,220,90); toast('⚡ '+n+' units surging (+dmg +speed)'); sfx('surge'); if(typeof radioAck==='function')radioAck('ability',n,hx,hy); return; } } /* Emergency mobility is deliberately short-range and terrain-aware. It gets the heavy commander out of a blocked street or a surround, without turning it into a free cross-map teleport. */ function jumpLandingClear(x,y,i){ if(i==null) i=heroIdx; if(typeof isWalkable==='function'&&!isWalkable(x,y)) return false; const rad=(TYPES[utype[i]]&&TYPES[utype[i]].r)||12; for(let b=0;b34; const trapped=(typeof isWalkable==='function'&&!isWalkable(ux[i],uy[i])); heroStuckFor=(trapped||(ordered&&travel<.08&&umode[i]!==1&&umode[i]!==5))?heroStuckFor+dt:0; if(heroStuckFor<0.9) return; const P=nearestCommanderGround(ux[i],uy[i],trapped?220:120); heroStuckFor=0; if(!P) return; const ox=ux[i],oy=uy[i];ux[i]=P[0];uy[i]=P[1];utgt[i]=-1;utgtg[i]=-1;ufield[i]=-1; utx[i]=P[0];uty[i]=P[1];ustate[i]=0; addParticle(3,ox,oy,0,0,.32,42,110,205,255);addParticle(3,P[0],P[1],0,0,.42,50,110,205,255); if(performance.now()>heroRescueAt){heroRescueAt=performance.now()+2800;toast('↗ JET ASSIST — Commander cleared crater terrain');sfx('surge',P[0],P[1],.72);} } function tryCommanderJump(){ if(heroIdx<0||!ualive[heroIdx]){ toast('Commander is down'); return; } if(heroJumpCool>0){ toast('JUMP JETS REARMING — '+Math.ceil(heroJumpCool)+'s'); return; } if(!canAfford(0,0,HERO_JUMP.energy)){ toast('JUMP JETS NEED '+HERO_JUMP.energy+' ENERGY'); return; } if(aiming===4){ aiming=-1; toast('JUMP TARGETING CANCELLED'); return; } aiming=4; toast('JUMP JETS — tap clear ground within '+HERO_JUMP.range+'m'); if(typeof radioAck==='function') radioAck('ability',1,ux[heroIdx],uy[heroIdx]); } function commanderCrushScenery(i,impact){ if(i<0||!ualive[i]||!TYPES[utype[i]]||TYPES[utype[i]].cat!=='hero') return; const T=TYPES[utype[i]],rad=T.r+(impact?36:16),r2=rad*rad; for(const R of relics){ /* Cities and large derelicts remain terrain decisions. Cottages, houses, props and small blocks are destructible under a commander's weight. */ if(!R.alive||R.s>56||!(R.kind===2||R.kind===3)||dist2(ux[i],uy[i],R.x,R.y)>r2) continue; R.hp-=impact?9999:Math.max(95,T.size*4); R.burn=Math.min(1,(R.burn||0)+.16); if(R.hp<=0) collapseBlock(R,0); } } function fireCommanderJump(wx,wy){ if(heroIdx<0||!ualive[heroIdx]){ aiming=-1; return; } const hx=ux[heroIdx],hy=uy[heroIdx],d=Math.hypot(wx-hx,wy-hy); if(d>HERO_JUMP.range){ toast('JUMP TARGET OUT OF RANGE'); return; } if(!jumpLandingClear(wx,wy)){ toast('JUMP NEEDS CLEAR LANDING GROUND'); return; } pay(0,0,HERO_JUMP.energy); heroJumpCool=commanderCool(HERO_JUMP.cool); aiming=-1; addParticle(3,hx,hy,0,0,.42,58,110,215,255); addParticle(1,hx,hy,0,-16,.72,18,85,80,72); ux[heroIdx]=wx; uy[heroIdx]=wy; utx[heroIdx]=wx; uty[heroIdx]=wy; utgt[heroIdx]=-1; ustate[heroIdx]=0; commanderCrushScenery(heroIdx,true); addParticle(3,wx,wy,0,0,.66,72,130,225,255); addParticle(1,wx,wy,0,-18,.86,24,95,88,76); shake=Math.max(shake,3.2); sfx('surge',wx,wy,1.25); toast('↗ COMMANDER JUMP — landing zone secured'); } function commanderJumpTick(dt){ if(heroJumpCool>0) heroJumpCool=Math.max(0,heroJumpCool-dt); } function commanderJumpButtonState(){ const b=document.getElementById('abJump'); if(!b) return; const cd=b.querySelector('.cdring'),cover=heroJumpCool>0?Math.ceil(heroJumpCool):(resE[0]=40 is the superweapon handoff, and victim-team made Nova's blast inherit the enemy's singularity. Legion/Syndicate own the well; Nova is bomblets. */ if(fac==='legion'||fac==='syndicate'){ spawnSingularity(wx,wy,1.05,0); if(typeof radioAck==='function')radioAck('ability',1,wx,wy); shake=Math.max(shake,6); return; } const R=blastRadius, dmgM=heroDmgMult, spread=R*(72/110); const pts=[[0,0]]; for(let k=0;k<7;k++){ const a=k/7*TAU+0.18, d=spread*(0.42+((k*3)&1)*0.28); pts.push([Math.cos(a)*d,Math.sin(a)*d]); } let n=0; for(let q=0;q{if(uteam[j]===team)return;cx+=ux[j];cy+=uy[j];n++;}); if(n>=2) return {x:cx/n,y:cy/n,n}; const e=findEnemy(hx,hy,team,R); if(e>=0) return {x:ux[e],y:uy[e],n:1}; const b=findEnemyBld(hx,hy,team,Math.min(R,420)); if(b>=0) return {x:blds[b].x,y:blds[b].y,n:1}; return null; } function commanderAiShouldCast(i,A,aim){ const hx=ux[i],hy=uy[i],team=uteam[i]; if(A.id==='ironredoubt'){ for(const B of blds)if(B.alive&&B.team===team&&dist2(hx,hy,B.x,B.y)<=A.range*A.range) return true; return false; } if(A.id==='liquidation'){ for(let w=0;w0) return; const e=findEnemy(ux[i],uy[i],uteam[i],W.range||280); if(e<0) return; const seat=commanderSeatForUnit(i); if((W.energy||0)&&!canAfford(seat.team,0,W.energy,seat.slot)) return; if(W.energy) pay(seat.team,0,W.energy,seat.slot); S.weaponCool[1]=W.cool||16; const pk=fireProj(W.ptype,uteam[i],ux[i],uy[i],ux[e],uy[e],W.speed,W.damage,W.aoe||0,e); if(pk>=0){pwk[pk]=W.wk||'p';pCannon[pk]=1;if(W.ptype===2){pBarrage[pk]=1;pArc[pk]=90;}projectileFireFX(pk,ux[i],uy[i],ux[e]-ux[i],uy[e]-uy[i]);} sfx(W.sfx||'cannon',ux[i],uy[i],1.2); } function commanderAiTick(dt){ if(typeof AI==='undefined'||!AI) return; const seats=[].concat(AI.bases||[],AI.allies||[]); for(const S of seats){ if(!S||S.commander==null||S.commander<0) continue; const i=S.commander; if(!ualive[i]||(S.commanderGen!=null&&ugen[i]!==S.commanderGen)) continue; if(typeof heroIdx!=='undefined'&&i===heroIdx) continue; S.activeCool=Math.max(0,(S.activeCool||0)-dt); if(!S.weaponCool) S.weaponCool=[0,0]; S.weaponCool[0]=Math.max(0,S.weaponCool[0]-dt); S.weaponCool[1]=Math.max(0,S.weaponCool[1]-dt); commanderAiFireSecondary(i,S); if(S.activeCool>0) continue; const C=commanderDefForUnit(i),A=C&&C.active; if(!A) continue; const aim=commanderAiAimPoint(i,A); if(!commanderAiShouldCast(i,A,aim)||!aim) continue; fireCommanderActiveAt(i,aim.x,aim.y,true); } } function abilTick(dt){ for(let i=0;i0) abCool[i]-=dt; if(commanderActiveCool>0)commanderActiveCool=Math.max(0,commanderActiveCool-dt); for(let i=0;i0)commanderWeaponCool[i]=Math.max(0,commanderWeaponCool[i]-dt); commanderWeaponButtonState(); commanderJumpTick(dt); classAbilityTick(dt); artBarrageTick(dt); commanderAiTick(dt); } /* ---------- ORBITAL LANCE — a sweeping beam bought in the Armory ---------- */ function fireLance(wx,wy){ abCool[3]=commanderCool(AB_CD[3]); aiming=-1; const ang=Math.random()*TAU, LEN=560, W=64; const dxl=Math.cos(ang), dyl=Math.sin(ang); for(let k=0;k<9;k++){ const px2=wx+dxl*(k-4)*(LEN/9), py2=wy+dyl*(k-4)*(LEN/9); setTimeout(()=>{ if(!running) return; forUnitsIn(px2,py2,W,j=>{ if(uteam[j]!==0) dealDamage(j,760*heroDmgMult,0,-1); }); const nb=findEnemyBld(px2,py2,0,W); if(nb>=0) damageBld(nb,620,0); damageScenery(px2,py2,W,500); spawnExplosion(px2,py2,34,1); addParticle(3,px2,py2,0,0,.6,W*2.2, 190,225,255); addBeam(px2,py2-700,px2,py2,16,200,235,255,0.28,'orbital'); deformTerrain(px2,py2,W*0.8,0.03); shake=Math.max(shake,7); sfx('boom',px2,py2,1.7); },k*85); } toast('🛰 ORBITAL LANCE — cutting the surface'); sfx('laser',wx,wy,2.4); if(typeof radioAck==='function')radioAck('ability',1,wx,wy); } // hook bonus income into economy const _econTick=econTick; econTick=function(dt){ _econTick(dt); resM[0]=Math.min(RES_MCAP[0],resM[0]+bonusMass*dt); resE[0]=Math.min(RES_ECAP[0],resE[0]+bonusEnergy*dt); mRate+=bonusMass; eRate+=bonusEnergy; };