Spaces:
Running
Running
Download src/game/commander.js from CREATORJD/massfront-playtest: direct link, hf CLI and curl.
- Browser
- Download file 97.5 kB
-
https://huggingface.co/spaces/CREATORJD/massfront-playtest/resolve/main/src/game/commander.js
- Command line
-
hf download hf://spaces/CREATORJD/massfront-playtest/src/game/commander.js
-
curl -L -o commander.js https://huggingface.co/spaces/CREATORJD/massfront-playtest/resolve/main/src/game/commander.js
97.5 kB
| ; | |
| ; | |
| /* ============================================================ | |
| 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+mfSimRandom()*6; | |
| if(!S.weaponCool) S.weaponCool=[0,4+mfSimRandom()*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='<span class="em"></span><span class="wnm"></span><div class="cdring"></div>'; | |
| const before=document.getElementById('abCommander')||row.firstChild;row.insertBefore(b,before); | |
| mfBindNativePress(b,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;commanderWeaponButtonState();if(typeof hotSlotSync==='function')hotSlotSync(false); | |
| toast(W.nm.toUpperCase()+' TARGETING CANCELLED');return; | |
| } | |
| aiming=7+slot; | |
| /* A contextual hot-slot mirrors this owner. Paint the armed state in the | |
| same press turn, including while gameplay is paused by a test or overlay. */ | |
| commanderWeaponButtonState();if(typeof hotSlotSync==='function')hotSlotSync(false); | |
| 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; | |
| commanderWeaponButtonState();if(typeof hotSlotSync==='function')hotSlotSync(false); | |
| const mz=typeof mfUnitMuzzle==='function'?mfUnitMuzzle(heroIdx):[hx,hy]; | |
| const pk=fireProj(W.ptype,0,mz[0],mz[1],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,mz[0],mz[1],tx-mz[0],ty-mz[1]); | |
| } | |
| const C=playerCommanderDef(),col=C&&C.active&&C.active.col||[110,215,255]; | |
| addParticle(0,mz[0],mz[1],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); | |
| toast((W.em||'β’')+' '+W.nm.toUpperCase()+' FIRED β REARMING '+Math.ceil(commanderWeaponCool[slot])+'s'); | |
| 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='<span class="em" id="cmdAbEm">β¦</span><span id="cmdAbNm">SIGNATURE</span><div class="cdring"></div>'; | |
| const jump=document.getElementById('abJump');row.insertBefore(b,jump||row.firstChild); | |
| mfBindNativePress(b,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<blds.length;bi++){const B=blds[bi];if(B.alive&&B.team!==team&&dist2(x,y,B.x,B.y)<=r*r){damageBld(bi,bldDmg,team);n++;}} | |
| return n; | |
| } | |
| function fireCommanderActive(wx,wy){ | |
| if(heroIdx>=0&&ualive[heroIdx]&&window.MFMatchCommandConsumer&&typeof MFMatchCommandConsumer.takeover==='function'&& | |
| MFMatchCommandConsumer.takeover({type:'commander',commander:{id:heroIdx,generation:ugen[heroIdx]},action:'active', | |
| x:Math.round(wx),y:Math.round(wy)}))return true; | |
| return fireCommanderActiveAt(heroIdx,wx,wy,false); | |
| } | |
| function fireCommanderActiveAt(idx,wx,wy,quiet){ | |
| const C=commanderDefForUnit(idx),A=C&&C.active; | |
| if(!A||idx<0||!ualive[idx]){if(!quiet)aiming=-1;return false;} | |
| const hx=ux[idx],hy=uy[idx],team=uteam[idx],player=typeof heroIdx!=='undefined'&&idx===heroIdx; | |
| const dmgM=player?heroDmgMult:1; | |
| if(A.target&&Math.hypot(wx-hx,wy-hy)>(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<pts.length;q++){ | |
| const x=wx+pts[q][0],y=wy+pts[q][1]; | |
| affected+=commanderActiveDamageCircle(x,y,72,190*dmgM,130*dmgM,team); | |
| addBeam(x,y-680,x,y,10,A.col[0],A.col[1],A.col[2],.25,'orbital'); | |
| spawnExplosion(x,y,34,1);addCrater(x,y,28); | |
| } | |
| if(player){ | |
| if(typeof requestShake==='function') requestShake(wx,wy,7,'blast'); | |
| else shake=Math.max(shake,7); | |
| } | |
| }else if(A.id==='fieldworkshop'){ | |
| forUnitsIn(hx,hy,A.range,j=>{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){ | |
| if(typeof requestShake==='function') requestShake(wx,wy,11,'blast'); | |
| else 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;n<unitHigh&&chosen.length<8;n++)if(ualive[n]&&uteam[n]===team&&usel[n]&&n!==idx)chosen.push(n);} | |
| else{ | |
| const near=[]; | |
| for(let n=0;n<unitHigh;n++)if(ualive[n]&&uteam[n]===team&&n!==idx&&dist2(ux[n],uy[n],hx,hy)<720*720)near.push(n); | |
| near.sort((a,b)=>dist2(ux[a],uy[a],hx,hy)-dist2(ux[b],uy[b],hx,hy)); | |
| for(let n=0;n<near.length&&chosen.length<8;n++)chosen.push(near[n]); | |
| } | |
| for(let n=0;n<chosen.length;n++){const u=chosen[n],ang=n/Math.max(1,chosen.length)*TAU,rad=52+22*(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]<m?'M':'E'):''); | |
| b.classList.toggle('cd',!!cover);b.classList.toggle('on',aiming===6);cd.style.display=cover?'flex':'none';cd.textContent=cover; | |
| const cost=(m?m+' mass'+(e?' + ':''):'')+(e?e+' energy':''); | |
| const tip=(A.em||'β¦')+' '+A.nm+' β '+cost+', '+Math.round(commanderCool(A.cool))+'s cooldown. '+A.ds; | |
| b.title=tip;b.setAttribute('aria-label',tip); | |
| } | |
| /* ---------- CHARGED ARTILLERY BARRAGE -------------------------------------- | |
| A Development unlock grants a new COMMAND, never a passive stat. Selected | |
| artillery must visibly brace for the shot and can be interrupted by orders, | |
| displacement or heavy incoming damage. All timing is simulation time: pause, | |
| game speed and reset remain deterministic, unlike a setTimeout salvo. */ | |
| const ART_BARRAGE={energy:240,cool:52,charge:2.8,range:720,shells:6, | |
| cadence:.28,speed:115,damage:155,aoe:48,spread:104,arc:620}; | |
| const ART_BARRAGE_OFF=[[-.68,-.42],[.08,-.78],[.76,-.25],[.58,.57],[-.16,.80],[-.80,.18]]; | |
| let artBarrageCool=0, artBarrageAim=null, artBarrageCharge=null, artBarrageQueue=[]; | |
| function artBarrageUnlocked(){ return true; } | |
| function artBarrageAdvanced(){ return typeof devHas==='function'&&devHas('firemission'); } | |
| function artBarrageProfile(){ | |
| return artBarrageAdvanced() | |
| ?{shells:ART_BARRAGE.shells,energy:ART_BARRAGE.energy,cool:ART_BARRAGE.cool,spread:78,label:'coordinated'} | |
| :{shells:3,energy:180,cool:46,spread:108,label:'basic'}; | |
| } | |
| function artBarragePattern(solution,profile){ | |
| if(Number.isFinite(solution)){ | |
| const x=solution,y=Number(profile); | |
| solution=typeof intelArtillerySolution==='function' | |
| ?intelArtillerySolution(0,{source:'player',x:x,y:y},typeof stats!=='undefined'?stats.t:0) | |
| :{eligible:true,source:'player',confidence:1,age:0,x:x,y:y,target:-1,generation:-1}; | |
| profile=artBarrageProfile(); | |
| } | |
| profile=profile||artBarrageProfile(); | |
| const seed=((tick|0)^Math.imul(Math.round(solution.x*8)|0,73856093)^ | |
| Math.imul(Math.round(solution.y*8)|0,19349663))|0; | |
| return ART_BARRAGE_OFF.slice(0,profile.shells).map((o,n)=>{ | |
| const S=typeof intelArtilleryScatterPoint==='function' | |
| ?intelArtilleryScatterPoint(solution,profile.spread,n,seed):solution; | |
| return {x:clamp(S.x+o[0]*profile.spread*.58,15,MAP-15), | |
| y:clamp(S.y+o[1]*profile.spread*.58,15,MAP-15)}; | |
| }); | |
| } | |
| function artBarrageSelected(tx,ty,limit){ | |
| const out=[]; | |
| for(let i=0;i<unitHigh;i++) if(ualive[i]&&uteam[i]===0&&usel[i]&&TYPES[utype[i]].cat==='art'){ | |
| if(tx!==undefined&&dist2(ux[i],uy[i],tx,ty)>ART_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){ | |
| if(typeof mfWeaponChargeInterrupt==='function')mfWeaponChargeInterrupt(artBarrageCharge,'manual-cancel'); | |
| for(const m of artBarrageCharge.members)artBarrageRestore(m); | |
| artBarrageCharge=null;toast('BARRAGE CHARGE INTERRUPTED');sfx('ui');return; | |
| } | |
| if(aiming===5){cancelArtilleryBarrageAim(false);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;} | |
| const profile=artBarrageProfile(); | |
| if(!canAfford(0,0,profile.energy)){toast('BARRAGE NEEDS '+profile.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((artBarrageAdvanced()?'COORDINATED':'BASIC')+' BARRAGE β '+profile.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,target,generation){ | |
| if(!artBarrageUnlocked()||artBarrageCool>0||artBarrageCharge||artBarrageQueue.length){cancelArtilleryBarrageAim(true);return false;} | |
| const now=typeof stats!=='undefined'&&stats?stats.t:0; | |
| const request=Number.isInteger(target)&&target>=0?{target:target,generation:generation}:{source:'player',x:x,y:y}; | |
| const solution=typeof intelArtillerySolution==='function'?intelArtillerySolution(0,request,now): | |
| {eligible:true,source:'player',confidence:1,age:0,x:x,y:y,target:-1,generation:-1}; | |
| if(!solution||!solution.eligible){setArtBarragePreview(x,y);toast('NO VALID FIRE-MISSION CONTACT');return false;} | |
| x=solution.x;y=solution.y; | |
| const src=artBarrageSelected(x,y,4),profile=artBarrageProfile(); | |
| 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,profile.energy)){cancelArtilleryBarrageAim(true);toast('BARRAGE NEEDS '+profile.energy+' ENERGY');return false;} | |
| pay(0,0,profile.energy);artBarrageCool=profile.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(solution,profile),fx:0, | |
| intelSource:solution.source,intelConfidence:solution.confidence,intelAge:solution.age}; | |
| if(typeof mfWeaponChargeSet==='function') | |
| mfWeaponChargeSet(artBarrageCharge,'commanderBarrage',MF_WEAPON_CHARGE_STATE.CHARGING,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 β '+String(solution.source||'player').toUpperCase()+ | |
| ' solution Β· moving or heavy damage interrupts'); | |
| return true; | |
| } | |
| function beginArtilleryBarrageContact(target,generation){ | |
| /* Radar, visual and stale contacts all resolve through the same authority; | |
| no hidden live position is read here. Confidence decay becomes bounded, | |
| deterministic shell scatter in artBarragePattern(). */ | |
| const solution=typeof intelArtillerySolution==='function' | |
| ?intelArtillerySolution(0,{target:target,generation:generation},typeof stats!=='undefined'?stats.t:0):null; | |
| if(!solution||!solution.eligible){toast('NO VALID FIRE-MISSION CONTACT');return false;} | |
| return beginArtilleryBarrage(solution.x,solution.y,target,generation); | |
| } | |
| 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 mz=typeof mfUnitMuzzle==='function'?mfUnitMuzzle(i):[ux[i]+Math.cos(a)*T.size*.72,uy[i]+Math.sin(a)*T.size*.72]; | |
| const sx=mz[0],sy=mz[1]; | |
| 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){ | |
| if(typeof mfWeaponChargeInterrupt==='function')mfWeaponChargeInterrupt(C,'battery-lost'); | |
| 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); | |
| if(typeof mfWeaponChargeSet==='function') | |
| mfWeaponChargeSet(C,'commanderBarrage',p>=1?MF_WEAPON_CHARGE_STATE.COMMITTED:MF_WEAPON_CHARGE_STATE.CHARGING,p); | |
| 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){ | |
| if(typeof mfWeaponChargeSet==='function')mfWeaponChargeSet(C,'commanderBarrage',MF_WEAPON_CHARGE_STATE.FIRING,1); | |
| for(const m of live)artBarrageRestore(m); | |
| for(let n=0;n<C.pattern.length;n++)artBarrageQueue.push({t:n*ART_BARRAGE.cadence,m:live[n%live.length],p:C.pattern[n]}); | |
| artBarrageCharge=null;toast('BARRAGE AWAY β '+C.pattern.length+' shells'); | |
| } | |
| } | |
| } | |
| for(let q=artBarrageQueue.length-1;q>=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=false,n=artBarrageSelected().length,profile=artBarrageProfile(); | |
| let cover='',desc=(artBarrageAdvanced()?'Coordinated':'Basic')+' Barrage β '+profile.energy+' energy, '+profile.cool+'s cooldown. '+ | |
| 'Fire Mission Protocol improves salvo size and dispersion. Selected artillery braces for '+ART_BARRAGE.charge+'s.'; | |
| 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]<profile.energy){cover='E';} | |
| else if(!n){cover='ARTY';} | |
| b.classList.toggle('cd',!!cover);b.classList.toggle('on',aiming===5||!!artBarrageCharge); | |
| cd.style.display=cover?'flex':'none';cd.textContent=cover; | |
| b.title=desc;b.setAttribute('aria-label',desc); | |
| } | |
| /* ---------- CONTEXTUAL CLASS DOCTRINES ------------------------------------ | |
| One adaptive button keeps the phone HUD readable while still giving three | |
| major unit families a real unlocked active. The selected composition decides | |
| which command is presented; mixed selections choose the family with the most | |
| eligible units. Artillery retains its authored target-and-charge Barrage. */ | |
| const CLASS_AB={ | |
| assault:{nm:'BREAK',em:'β€',req:'breakthrough',energy:130,cool:44,dur:9, | |
| cats:['inf','veh','at','aoe','exp'], | |
| ds:'+28% damage, +22% speed and faster fire for 9s; units take +12% damage.'}, | |
| intercept:{nm:'INTERCEPT',em:'β',req:'intercept',energy:95,cool:38,dur:8, | |
| cats:['air','aa'], | |
| ds:'+58% speed, +22% range and faster tracking for 8s.'}, | |
| service:{nm:'SERVICE',em:'β',req:'fieldservice',energy:115,cool:34,dur:7, | |
| cats:['sup'], | |
| ds:'Support units restore nearby allies and project a 28% damage screen for 7s.'} | |
| }; | |
| const classAbCool={assault:0,intercept:0,service:0}; | |
| function classAbilityEligible(A){ | |
| const out=[]; | |
| for(let i=0;i<unitHigh;i++)if(ualive[i]&&uteam[i]===0&&usel[i]&&A.cats.indexOf(TYPES[utype[i]].cat)>=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]<A.energy)cover='E'; | |
| b.classList.toggle('cd',!!cover);cd.style.display=cover?'flex':'none';cd.textContent=cover; | |
| const desc=W.nm+' β '+A.energy+' energy, '+A.cool+'s cooldown. '+W.ds; | |
| b.title=desc;b.setAttribute('aria-label',desc); | |
| } | |
| function heroXP(x){ | |
| if(heroIdx<0) return; | |
| heroXp+=x; | |
| while(heroXp>=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'); | |
| } | |
| /* heroXP runs inside the simulation step, on every kill. An unguarded | |
| dereference here is not a cosmetic failure: a missing #levelUp throws out | |
| of the sim block sixty times a second while rendering and audio continue, | |
| which reads to the player as a frozen match with no error. The element is | |
| part of the shipped shell today, but OTA installs a new index.html | |
| independently of this file, so the lookup has to survive not finding it. */ | |
| const luEl=document.getElementById('levelUp'); | |
| if(pendingLevels>0 && (!luEl||luEl.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; | |
| /* PAUSE LAST, NOT FIRST. | |
| `paused=true` used to run before any of the DOM work below. Every line | |
| between it and `el.style.display='flex'` is a way to leave the match | |
| stopped with NOTHING on screen: a missing #levelUp or #luLvl after an OTA | |
| shell change, a short UPGRADES pool making `pick` undefined, a throwing | |
| faction text hook. The rendering thread is unaffected, so audio and CSS | |
| animation carry on and the game looks alive - and the only thing in the | |
| build that clears `paused` is a tap on a card that was never created. | |
| That is a permanent, silent freeze, which is precisely the shape of the | |
| freeze reported from live play. Build the chooser, verify it has choices, | |
| show it, and only then stop the clock. */ | |
| const el=document.getElementById('levelUp'); | |
| const head=document.getElementById('luLvl'); | |
| const cards=document.getElementById('luCards'); | |
| if(!el||!head||!cards) return; | |
| head.textContent='Commander reached level '+(heroLvl-pendingLevels+1+0)+' β choose an upgrade'; | |
| 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&&pool.length;k++){ | |
| const gi=pool.splice(Math.floor(mfSimRandom()*pool.length),1)[0]; | |
| const pick=UPGRADES[gi]; | |
| if(!pick) continue; | |
| 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='<div class="em">'+upEm+'</div><b>'+upNm+'</b>'+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); | |
| } | |
| /* A chooser with nothing to choose cannot be dismissed. Award the level | |
| silently rather than stopping the match behind an empty panel - and | |
| release the clock, because the recursive call from a card tap reaches | |
| here with the previous chooser's pause still held. */ | |
| if(!cards.children.length){ | |
| el.style.display='none'; | |
| pendingLevels=0; | |
| paused=false; | |
| return; | |
| } | |
| el.style.display='flex'; | |
| paused=true; | |
| } | |
| // ---------- 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++; | |
| }); | |
| /* 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++; | |
| } | |
| /* One field wave is the whole presentation. Per-target flashes plus the | |
| old GPU point spray turned a single EMP into dozens of independent | |
| particles. Reuse the bounded macro ripple primitive; gameplay stun and | |
| building cooldown resolution above are unchanged. */ | |
| if(typeof mfEmitMacroFx==='function') | |
| mfEmitMacroFx(MF_MACRO_FX_SHIELD,hx,hy,{life:.8,radius:R,size:R,hot:[150,220,255]}); | |
| else addParticle(3,hx,hy,0,0,.8,R,150,220,255); | |
| 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;b<blds.length;b++){ | |
| const B=blds[b]; if(!B.alive) continue; | |
| if(dist2(x,y,B.x,B.y)<(B.r+rad+12)*(B.r+rad+12)) return false; | |
| } | |
| return true; | |
| } | |
| /* Craters alter the passability grid after they are drawn. A commander that | |
| was already standing in the new flooded cell otherwise has no legal first | |
| step, so normal pathfinding can never escape. Search outward in rings and | |
| use a tiny automatic jet-assist only when the terrain (or a real stall) has | |
| trapped the player. This does not spend energy or consume the chosen Jump | |
| Jets ability. */ | |
| function nearestCommanderGround(x,y,range){ | |
| const T=TYPES[utype[heroIdx]]; | |
| for(let d=18;d<=range;d+=18){ | |
| for(let n=0;n<16;n++){ | |
| /* `let`, not `const`: the clamp below reassigns these. battlefieldClampPoint | |
| is always defined (main.js), so that branch ALWAYS ran and always threw | |
| "Assignment to constant variable" β from inside unitTick, which is the | |
| first call in frame(). Every later sim step and the entire render were | |
| skipped, so a Commander trapped by crater terrain froze the whole game | |
| on a still picture. The one function whose job is to un-stick the | |
| Commander could not complete even once. */ | |
| let a=n/16*TAU+(d/18&1)*.19,px=clamp(x+Math.cos(a)*d,10,MAP-10),py=clamp(y+Math.sin(a)*d,10,MAP-10); | |
| if(typeof battlefieldClampPoint==='function'){const p=battlefieldClampPoint(px,py,T.r+8);px=p[0];py=p[1];} | |
| if(jumpLandingClear(px,py)) return [px,py]; | |
| } | |
| } | |
| return null; | |
| } | |
| function commanderTerrainRecovery(i,travel,dt){ | |
| if(i!==heroIdx||!ualive[i]||!TYPES[utype[i]]||TYPES[utype[i]].cat!=='hero') return; | |
| const ordered=Math.hypot(utx[i]-ux[i],uty[i]-uy[i])>34; | |
| 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); | |
| if(typeof requestShake==='function') requestShake(wx,wy,3.2,'step'); | |
| else 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]<HERO_JUMP.energy?'E':''); | |
| b.classList.toggle('cd',!!cover); b.classList.toggle('on',aiming===4); | |
| cd.style.display=cover?'flex':'none'; cd.textContent=cover; | |
| b.title='Jump Jets β '+HERO_JUMP.energy+' energy, '+Math.round(commanderCool(HERO_JUMP.cool))+'s cooldown. Emergency move through blocked terrain.'; | |
| } | |
| function fireBlast(wx,wy){ | |
| buzz(30); | |
| abCool[0]=commanderCool(AB_CD[0]); | |
| aiming=-1; | |
| const fac=typeof mfCombatFactionTeam==='function'?mfCombatFactionTeam(0):'nova'; | |
| /* Ability 0 used spawnExplosion(46, victimTeam) β size>=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); | |
| if(typeof requestShake==='function') requestShake(wx,wy,6,'blast'); | |
| else 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<pts.length;q++){ | |
| const x=wx+pts[q][0], y=wy+pts[q][1]; | |
| const r=q?40:48, ud=(q?115:170)*dmgM, bd=(q?85:120)*dmgM; | |
| n+=commanderActiveDamageCircle(x,y,r,ud,bd,0); | |
| } | |
| /* Eight damage circles are gameplay, not eight complete visual recipes. | |
| Coalesce the synchronous bomblets into one authoritative core, one soft | |
| shockwave, one bounded debris group and one merged ground footprint. */ | |
| if(typeof mfEmitMacroFx==='function') | |
| mfEmitMacroFx(MF_MACRO_FX_EXPLOSIVE,wx,wy,{size:Math.max(30,R*.42), | |
| coreRadius:Math.max(28,R*.34),shockRadius:R*.78,coreLife:1.05, | |
| hot:[255,226,174],rim:[255,190,94],dust:[142,132,116],debrisCount:3}); | |
| else spawnExplosion(wx,wy,Math.min(36,Math.max(28,R*.34)),0,{debrisCount:3}); | |
| addCrater(wx,wy,Math.max(24,R*.36)); | |
| if(typeof addGroundBurn==='function')addGroundBurn(wx,wy,R*.68,1); | |
| damageScenery(wx,wy,R,700); | |
| deformTerrain(wx,wy,70,0.035); | |
| sfx('boom',wx,wy,2.2); | |
| if(typeof radioAck==='function')radioAck('ability',1,wx,wy); | |
| if(typeof requestShake==='function') requestShake(wx,wy,8,'blast'); | |
| else shake=8; | |
| toast('βΉ CLUSTER BOMB β '+n+' '+(n===1?'ASSET':'ASSETS')+' HIT'); | |
| } | |
| function commanderAiAimPoint(i,A){ | |
| const hx=ux[i],hy=uy[i],team=uteam[i],R=A.range||180; | |
| if(!A.target) return {x:hx,y:hy,n:1}; | |
| let cx=0,cy=0,n=0; | |
| forUnitsIn(hx,hy,R,j=>{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;w<wrecks.length;w++) if(dist2(hx,hy,wrecks[w].x,wrecks[w].y)<=A.range*A.range) return true; | |
| 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) return true; | |
| return false; | |
| } | |
| if(A.id==='naniterecall'){ | |
| for(let n=0;n<unitHigh;n++) if(ualive[n]&&uteam[n]===team&&n!==i&&dist2(ux[n],uy[n],hx,hy)<720*720) return true; | |
| return false; | |
| } | |
| if(A.id==='fieldworkshop'||A.id==='crimsonadvance') return true; | |
| return !!(aim&&aim.n); | |
| } | |
| function commanderAiFireSecondary(i,S){ | |
| const C=commanderDefForUnit(i),W=C&&C.secondary; | |
| if(!W||!S.weaponCool) return; | |
| if(S.weaponCool[1]>0) 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 mz=typeof mfUnitMuzzle==='function'?mfUnitMuzzle(i):[ux[i],uy[i]]; | |
| const pk=fireProj(W.ptype,uteam[i],mz[0],mz[1],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,mz[0],mz[1],ux[e]-mz[0],uy[e]-mz[1]);} | |
| sfx(W.sfx||'cannon',ux[i],uy[i],1.2); | |
| } | |
| /* Which wallet a commander power spends. The four baseline powers hardcoded | |
| pay(0,0,...) - the PLAYER bank - so an ally commander firing its primary, | |
| barrage, class ability or jump jets billed the human. Harmless while ally | |
| seats had no wallet; a straight transfer now that they do. */ | |
| function commanderUnitSeatSlot(i){ | |
| if(i==null||i<0) return null; | |
| if(typeof uCmd==='undefined') return null; | |
| const s=uCmd[i]; | |
| return (s!=null&&s>=0)?s:null; | |
| } | |
| 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; | |
| /* A seat a human occupies is never driven by the director. Nothing sets | |
| S.human yet - co-op has not landed - so this is inert today, and the | |
| ===true test keeps it inert for an undefined field rather than | |
| accidentally parking every AI commander. */ | |
| if(S&&S.human===true) 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; | |
| /* ALLY abilities now cost the ally seat. fireCommanderActiveAt pays | |
| NOTHING - the player UI callers pay before invoking it - so an AI | |
| commander cast has always been free. That was invisible while ally | |
| seats had no wallet; now that they earn from real structures it should | |
| cost them, exactly as it costs the player. Enemy seats are deliberately | |
| left free: charging them is a live difficulty change and belongs to a | |
| balance pass, not to this one. A seat that cannot afford its ability | |
| simply does not cast, which is what canAfford is for. | |
| commanderUnitSeatSlot resolves the seat from uCmd on the firing unit. */ | |
| if(uteam[i]===0 && A.energy){ | |
| const seat=commanderUnitSeatSlot(i); | |
| if(seat!=null){ | |
| if(!canAfford(0,0,A.energy,seat)) continue; | |
| pay(0,0,A.energy,seat); | |
| } | |
| } | |
| fireCommanderActiveAt(i,aim.x,aim.y,true); | |
| } | |
| } | |
| function abilTick(dt){ | |
| for(let i=0;i<abCool.length;i++) if(abCool[i]>0) abCool[i]-=dt; | |
| if(commanderActiveCool>0)commanderActiveCool=Math.max(0,commanderActiveCool-dt); | |
| for(let i=0;i<commanderWeaponCool.length;i++)if(commanderWeaponCool[i]>0)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=mfSimRandom()*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); | |
| if(typeof requestShake==='function') requestShake(px2,py2,7,'blast'); | |
| else 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); | |
| /* Meta-perk income, so it belongs to the human by definition - but it goes | |
| through credit() like every other income event, which clamps to cap and | |
| keeps the A6 invariant (no direct bank writes outside economy.js) true. | |
| Behaviour is identical: credit() applies the same Math.min. */ | |
| credit(0,bonusMass*dt,bonusEnergy*dt); | |
| mRate+=bonusMass; eRate+=bonusEnergy; | |
| }; | |
| /* ============================================================================ | |
| COMMANDER IDENTITY β one authoritative descriptor per playable commander | |
| ---------------------------------------------------------------------------- | |
| WHY THIS EXISTS. Before this block a commander was nine loose facts spread | |
| across three files: a display name with the rank glued into the string | |
| (COMMANDER_ROSTERS in src/factions.js), a weapon pair keyed by id | |
| (COMMANDER_WEAPON_PROFILES, same file), a chassis chosen by a bare integer in | |
| two unrelated tables (src/main.js FH.hero and the per-faction tables in | |
| src/game/ai.js), a portrait resolved by a helper, and no faction key, no | |
| biography binding, no voice binding and no place to put readiness at all. | |
| Anything that wanted to ASK a question about a commander had to know all of | |
| that. This is the one place that answers. | |
| WHAT IT IS NOT. It is not a second roster and it does not own balance. Every | |
| number a commander has β cooldowns, energy, damage, the passive multipliers | |
| in applyCommanderChoice() β stays exactly where it was; the descriptor | |
| carries LABELS and BINDINGS and keeps a live back-reference in `def` for | |
| anything that needs the authored numbers. Nothing here is persisted: | |
| META.setup.pc is still the commander id and nothing else, so an existing save | |
| loads unchanged and a save written after this block is readable by a build | |
| without it. | |
| LOAD ORDER. src/game/commander.js is manifest index 50; src/factions.js is | |
| 61, src/audio.js 64 and src/story.js 75. Everything below therefore resolves | |
| its dependencies at CALL time and never at load time. | |
| ============================================================================ */ | |
| const COMMANDER_IDENTITY_VERSION=1; | |
| /* The commander chassis, which is a per-FACTION fact that no per-commander | |
| table records. These indices are the ones already spawned: src/main.js:893 | |
| (player, FH.hero, default 4) and the faction tables at src/game/ai.js:117 / | |
| :130 for the AI seats. Naming them here does not change spawning β it makes | |
| the binding inspectable, and tools/probe-commander-voice.mjs asserts each row | |
| against the real TYPES table in src/game/sim.js so a renumber cannot drift | |
| past unnoticed. */ | |
| const COMMANDER_CHASSIS={ | |
| nova:{heroType:4, unit:'Commander', sprite:'cdr'}, | |
| legion:{heroType:28, unit:'Lord Darion Vex', sprite:'praetor'}, | |
| syndicate:{heroType:29, unit:'Broker Lys Renn', sprite:'archon'} | |
| }; | |
| /* Rank and short name are AUTHORED, not parsed out of `nm`. Parsing would have | |
| to cope with 'Cmdr.' versus 'Commander', with 'Operative Nyx Calder' whose | |
| given name is the middle token, and with any future name that breaks the | |
| pattern β and it would fail silently in the HUD rather than loudly here. | |
| `voiceTone` is a delivery note for a future voice pack; it drives nothing. */ | |
| const COMMANDER_IDENTITY={ | |
| nova_kai: {faction:'nova', rank:'Captain', shortName:'Kai', callsign:'LANTERN', voiceTone:'measured'}, | |
| nova_holt: {faction:'nova', rank:'Major', shortName:'Holt', callsign:'ANVIL', voiceTone:'dry'}, | |
| nova_vale: {faction:'nova', rank:'Commander', shortName:'Vale', callsign:'LONGSIGHT',voiceTone:'clipped'}, | |
| legion_vex: {faction:'legion', rank:'Lord', shortName:'Vex', callsign:'ASCENDANT',voiceTone:'imperious'}, | |
| legion_korr: {faction:'legion', rank:'Marshal', shortName:'Korr', callsign:'CADENCE', voiceTone:'driving'}, | |
| legion_dravik: {faction:'legion', rank:'Prefect', shortName:'Dravik', callsign:'REDOUBT', voiceTone:'flat'}, | |
| syndicate_renn:{faction:'syndicate',rank:'Broker', shortName:'Renn', callsign:'LEDGER', voiceTone:'wry'}, | |
| syndicate_nyx: {faction:'syndicate',rank:'Operative', shortName:'Calder', callsign:'GHOST', voiceTone:'hushed'}, | |
| syndicate_voss:{faction:'syndicate',rank:'Director', shortName:'Voss', callsign:'CORE', voiceTone:'precise'} | |
| }; | |
| /* Runtime faction key -> voice-bank speaker key. src/audio.js owns the same | |
| mapping for unit radio (VO_BANK_ALIAS); duplicated here as a two-entry | |
| difference rather than a call, because commander.js loads 14 files earlier | |
| and must be able to answer this before audio.js exists. */ | |
| const COMMANDER_BANK_KEY={nova:'nova', legion:'ascendancy', syndicate:'syndicate'}; | |
| /* READINESS / FATIGUE / INJURY are declared and inert. | |
| No system reads or writes them today and NOTHING below applies them to | |
| damage, cooldowns, income or any other number β a commander's balance is | |
| byte-for-byte what it was. They exist so that when a campaign persistence | |
| layer does arrive it inherits a schema instead of inventing one, and so a | |
| save written meanwhile has a defined shape to be read back into. `applied` | |
| is the honest flag: it stays false until something actually consumes the | |
| value, and the probe asserts it is false for every commander. */ | |
| function commanderConditionDefaults(){ | |
| return { | |
| readiness:{value:1, range:[0,1], applied:false, note:'1 = fully rested and equipped.'}, | |
| fatigue: {value:0, range:[0,1], applied:false, note:'0 = no accumulated strain.'}, | |
| injury: {value:0, range:[0,1], applied:false, note:'0 = uninjured; 1 = out of action.'} | |
| }; | |
| } | |
| let COMMANDER_IDENTITY_CACHE=null; | |
| /* Portrait BINDING, not a portrait. COMMANDER_ROSTERS carries a base64 image on | |
| each playable row and commanderPortraitSrc() already resolves it with a | |
| filesystem fallback; copying either into the descriptor would double a | |
| multi-hundred-kilobyte payload for no gain. This records how to get one and | |
| what to show if it fails. */ | |
| function commanderPortraitBinding(id,def,bank){ | |
| const src=(typeof commanderPortraitSrc==='function'&&def)?commanderPortraitSrc(def):''; | |
| return { | |
| src:src||('./assets/factions/commanders/'+id+'.jpg'), | |
| fallback:'./assets/factions/'+bank+'_192.jpg', | |
| resolver:'commanderPortraitSrc', | |
| alt:((def&&def.nm)||id)+' command portrait' | |
| }; | |
| } | |
| /* Voice BINDING. Commander lines are ordinary voice-bank entries: speaker key | |
| `cmdr_<id>`, action `<category>_<kind>`, slot `vo_cmdr_<id>_<category>_<kind>` | |
| β the exact shape audLoadVoiceBank() already walks. `available` is computed | |
| live against the real bank, so it tells the truth on a build whose pack has | |
| not downloaded yet as well as on one that never had commander takes. | |
| TODAY IT IS FALSE FOR ALL NINE. The shipped bank is nine unit-radio actions | |
| per faction plus KEEN's 54 training lines; there are no commander takes and | |
| this lane does not fabricate any. `slots` is the list a future pack would | |
| have to provide. */ | |
| function commanderVoiceDescriptor(id){ | |
| const bankKey=(typeof commanderVoiceBank==='function')?commanderVoiceBank(id):('cmdr_'+id); | |
| const kinds=commanderDialogueKindList(); | |
| const slots=kinds.map(k=>'vo_'+bankKey+'_'+k.replace('.','_')); | |
| let present=[]; | |
| if(typeof audMapList==='function'){ | |
| for(const s of slots) if(audMapList(s).length) present.push(s); | |
| } | |
| return { | |
| bank:bankKey, | |
| channel:'cmdr', | |
| speaker:(typeof VOICE_BANK!=='undefined'&&VOICE_BANK&&VOICE_BANK.voices&&VOICE_BANK.voices[bankKey])||null, | |
| slotPrefix:'vo_'+bankKey+'_', | |
| slots:slots, | |
| present:present, | |
| available:present.length>0, | |
| /* A commander cue NEVER aliases onto a unit-radio take. src/audio.js already | |
| records what that costs: aliasing victory onto `ability` once made the | |
| win line say "Commander system armed". Silence plus a correct subtitle is | |
| the supported degradation. */ | |
| aliasesUnitRadio:false | |
| }; | |
| } | |
| /* THE DESCRIPTOR. Built once per commander and cached; call | |
| commanderIdentityInvalidate() if a roster is ever rebuilt at runtime. Returns | |
| null for an unknown id and for the AI-only Brood leaders, which have no | |
| playable identity by design. */ | |
| function commanderIdentity(id){ | |
| if(!id) return null; | |
| const hit=COMMANDER_IDENTITY_CACHE&&COMMANDER_IDENTITY_CACHE[id]; | |
| if(hit){ | |
| /* The voice bank arrives asynchronously and long after the first descriptor | |
| is built β audLoadVoiceBank() is awaited off the audio init path, and on a | |
| cold start the pack may still be downloading. Caching `available:false` | |
| from that first call would make a build with a commander pack report | |
| forever that it has none, which is exactly the class of bug the voHas / | |
| voReady split in src/audio.js exists to prevent. Everything else about a | |
| commander is static, so only this part is recomputed. */ | |
| hit.voice=commanderVoiceDescriptor(id); | |
| return hit; | |
| } | |
| const meta=COMMANDER_IDENTITY[id]; | |
| if(!meta) return null; | |
| const def=(typeof commanderById==='function')?commanderById(id):null; | |
| if(!def||def.aiOnly) return null; | |
| const bank=COMMANDER_BANK_KEY[meta.faction]||meta.faction; | |
| const lore=(typeof commanderLore==='function')?commanderLore(id):null; | |
| const chassis=COMMANDER_CHASSIS[meta.faction]||null; | |
| const out={ | |
| version:COMMANDER_IDENTITY_VERSION, | |
| id:id, | |
| faction:meta.faction, | |
| bank:bank, | |
| rank:meta.rank, | |
| name:def.nm||id, | |
| shortName:meta.shortName, | |
| callsign:meta.callsign, | |
| role:def.role||'', | |
| voiceTone:meta.voiceTone, | |
| loreKey:'commander.'+id, | |
| epithet:lore?lore.epithet:'', | |
| service:lore?lore.service:'', | |
| bio:lore?lore.bio:'', | |
| chassis:chassis?{heroType:chassis.heroType,unit:chassis.unit,sprite:chassis.sprite}:null, | |
| /* Labels only. The multipliers behind `passive` live in | |
| applyCommanderChoice() (src/factions.js:415) and are not restated here, | |
| so this block cannot drift from balance or silently become balance. */ | |
| passive:{label:def.passive||'',perk:def.perk||''}, | |
| baseline:{index:(def.ability!=null?def.ability:-1),label:def.abilityNm||''}, | |
| signature:def.active?{id:def.active.id,label:def.active.nm,em:def.active.em||''}:null, | |
| weapons:{primary:def.primary?def.primary.nm:'',secondary:def.secondary?def.secondary.nm:''}, | |
| portrait:commanderPortraitBinding(id,def,bank), | |
| voice:commanderVoiceDescriptor(id), | |
| condition:commanderConditionDefaults(), | |
| def:def | |
| }; | |
| COMMANDER_IDENTITY_CACHE=COMMANDER_IDENTITY_CACHE||{}; | |
| COMMANDER_IDENTITY_CACHE[id]=out; | |
| return out; | |
| } | |
| function commanderIdentityInvalidate(){ COMMANDER_IDENTITY_CACHE=null; } | |
| /* Every playable commander id, in a stable authored order. Brood leaders are | |
| absent because they are aiOnly, which is the same rule playableFactions() | |
| enforces one level up. */ | |
| function commanderIdentityIds(){ return Object.keys(COMMANDER_IDENTITY); } | |
| function commanderIdentityAll(){ return commanderIdentityIds().map(commanderIdentity).filter(Boolean); } | |
| /* Versioned, JSON-only export seam for Galactic Campaign hosts. The base game | |
| remains the roster authority; this snapshot is deliberately not consumed by | |
| the isolated module yet. It excludes the live `def` reference, resolved | |
| base64 portraits and voice-pack availability so its fingerprint cannot | |
| change with load timing. */ | |
| const COMMANDER_ROSTER_SNAPSHOT_VERSION=1; | |
| const COMMANDER_CAMPAIGN_FACTION={nova:'nova',legion:'dominion',syndicate:'syndicate'}; | |
| const COMMANDER_ROSTER_AUTHORED_IDS_V1=Object.freeze([ | |
| 'nova_kai','nova_holt','nova_vale', | |
| 'legion_vex','legion_korr','legion_dravik', | |
| 'syndicate_renn','syndicate_nyx','syndicate_voss' | |
| ]); | |
| const COMMANDER1_BY_CAMPAIGN_FACTION_V1=Object.freeze({ | |
| nova:'nova_kai',dominion:'legion_vex',syndicate:'syndicate_renn' | |
| }); | |
| /* This is source-package metadata, not a runtime asset registration. In | |
| particular it carries no VRM/GLB URL and keeps both runtime flags false. | |
| The accepted Broker Lys Renn source may therefore be audited and displayed | |
| in development metadata without accidentally entering a runtime loader. */ | |
| const COMMANDER_SOURCE_ART_BINDING_V1=Object.freeze({ | |
| syndicate_renn:Object.freeze({ | |
| assetId:'syndicate-broker-lys-renn-v1', | |
| assetType:'character-source', | |
| status:'SOURCE_ACCEPTED_RUNTIME_UNREGISTERED', | |
| runtimeReady:false, | |
| runtimeRegistered:false, | |
| canonicalPath:'modules/space_exploration/assets/source/blender/characters/syndicate-broker-lys-renn-v1', | |
| manifestPath:'modules/space_exploration/assets/source/blender/characters/syndicate-broker-lys-renn-v1/syndicate-broker-lys-renn-v1.asset-manifest.json' | |
| }) | |
| }); | |
| function commanderRosterDeepFreezeV1(value){ | |
| if(!value||typeof value!=='object'||Object.isFrozen(value))return value; | |
| Object.freeze(value); | |
| for(const child of Object.values(value))commanderRosterDeepFreezeV1(child); | |
| return value; | |
| } | |
| function commanderRosterStableValueV1(value){ | |
| if(value===null||typeof value==='string'||typeof value==='boolean')return value; | |
| if(typeof value==='number'){ | |
| if(!Number.isFinite(value))throw new TypeError('Commander roster contains a non-finite number'); | |
| return Object.is(value,-0)?0:value; | |
| } | |
| if(Array.isArray(value))return value.map(commanderRosterStableValueV1); | |
| if(value&&typeof value==='object'){ | |
| const out={}; | |
| for(const key of Object.keys(value).sort())if(value[key]!==undefined)out[key]=commanderRosterStableValueV1(value[key]); | |
| return out; | |
| } | |
| throw new TypeError('Commander roster must contain deterministic JSON data'); | |
| } | |
| function commanderRosterSnapshotFingerprintV1(snapshot){ | |
| const payload={}; | |
| for(const key of Object.keys(snapshot||{}))if(key!=='fingerprint')payload[key]=snapshot[key]; | |
| const text=JSON.stringify(commanderRosterStableValueV1(payload)); | |
| let hash=0x811c9dc5; | |
| for(let i=0;i<text.length;i++){hash^=text.charCodeAt(i);hash=Math.imul(hash,0x01000193);} | |
| return 'fnv1a32:'+('00000000'+(hash>>>0).toString(16)).slice(-8); | |
| } | |
| function commanderRosterSnapshotV1(){ | |
| const commanders=commanderIdentityAll().map(d=>({ | |
| id:d.id, | |
| sourceFactionId:d.faction, | |
| campaignFactionId:COMMANDER_CAMPAIGN_FACTION[d.faction], | |
| name:d.name, | |
| rank:d.rank, | |
| shortName:d.shortName, | |
| callsign:d.callsign, | |
| role:d.role, | |
| lore:{key:d.loreKey,epithet:d.epithet,service:d.service,bio:d.bio}, | |
| chassis:d.chassis?{heroType:d.chassis.heroType,unit:d.chassis.unit,sprite:d.chassis.sprite}:null, | |
| passive:{label:d.passive.label,perk:d.passive.perk}, | |
| baseline:{index:d.baseline.index,label:d.baseline.label}, | |
| signature:d.signature?{id:d.signature.id,label:d.signature.label,em:d.signature.em}:null, | |
| weapons:{primary:d.weapons.primary,secondary:d.weapons.secondary}, | |
| portrait:{resolver:d.portrait.resolver,fallback:d.portrait.fallback,alt:d.portrait.alt}, | |
| voice:{bank:d.voice.bank,channel:d.voice.channel,slotPrefix:d.voice.slotPrefix}, | |
| sourceArt:COMMANDER_SOURCE_ART_BINDING_V1[d.id]?Object.assign({},COMMANDER_SOURCE_ART_BINDING_V1[d.id]):null | |
| })); | |
| const ids=commanders.map(c=>c.id); | |
| if(ids.length!==COMMANDER_ROSTER_AUTHORED_IDS_V1.length||ids.some((id,i)=>id!==COMMANDER_ROSTER_AUTHORED_IDS_V1[i])) | |
| throw new TypeError('Commander roster authority order changed'); | |
| for(const commander of commanders){ | |
| if(COMMANDER_CAMPAIGN_FACTION[commander.sourceFactionId]!==commander.campaignFactionId) | |
| throw new TypeError('Commander roster faction mapping changed for '+commander.id); | |
| } | |
| for(const faction of Object.keys(COMMANDER1_BY_CAMPAIGN_FACTION_V1)){ | |
| const id=COMMANDER1_BY_CAMPAIGN_FACTION_V1[faction],commander=commanders.find(c=>c.id===id); | |
| if(!commander||commander.campaignFactionId!==faction) | |
| throw new TypeError('Commander 1 authority changed for '+faction); | |
| } | |
| const snapshot={ | |
| schemaVersion:COMMANDER_ROSTER_SNAPSHOT_VERSION, | |
| kind:'CommanderRosterSnapshotV1', | |
| source:'massfront-base', | |
| sourceVersion:COMMANDER_IDENTITY_VERSION, | |
| commanderCount:commanders.length, | |
| commander1ByCampaignFaction:Object.assign({},COMMANDER1_BY_CAMPAIGN_FACTION_V1), | |
| commanders:commanders | |
| }; | |
| snapshot.fingerprint=commanderRosterSnapshotFingerprintV1(snapshot); | |
| return commanderRosterDeepFreezeV1(snapshot); | |
| } | |
| /* The identity of whoever the player is currently commanding. Falls back | |
| through playerCommanderDef() so a save with an unknown id still resolves to | |
| that faction's default rather than to nothing. */ | |
| function playerCommanderIdentity(){ | |
| let id=(typeof playerCommanderId!=='undefined'&&playerCommanderId)||null; | |
| if(!id||!COMMANDER_IDENTITY[id]){ | |
| const D=(typeof playerCommanderDef==='function')?playerCommanderDef():null; | |
| id=D?D.id:null; | |
| } | |
| return id?commanderIdentity(id):null; | |
| } | |
| function commanderPresentingIdentity(){ | |
| /* Each multiplayer client simulates the same team-0 Commander, but its | |
| command-link is local presentation. Reusing playerCommanderId on seat 2 | |
| made a Horde player receive Kai's Nova portrait and voice. An AI-only | |
| Brood leader has no playable identity yet, so no commander cue is more | |
| truthful than impersonating Nova; the objective toast still appears. */ | |
| if(typeof window!=='undefined'&&window.__MF_NETWORK_SETUP__){ | |
| const local=typeof mfLocalCommander==='function'?mfLocalCommander():-1; | |
| const id=local>=0&&typeof commanderIdForUnit==='function'?commanderIdForUnit(local):null; | |
| return id?commanderIdentity(id):null; | |
| } | |
| return playerCommanderIdentity(); | |
| } | |
| /* ============================================================================ | |
| COMMANDER DIALOGUE β the EVENT half of the commander voice system | |
| ---------------------------------------------------------------------------- | |
| src/audio.js owns playback and the mixer-level spacing (see its COMMANDER | |
| VOICE block). This owns everything above that: what happened, who says | |
| something about it, whether it is worth saying, in what order, and what the | |
| subtitle reads. It works with no audio at all β a build with no AudioContext, | |
| no voice bank, or a voice bank with no commander takes (which is every build | |
| today) still produces correct, ordered, deduplicated subtitles. | |
| DETERMINISM IS A REQUIREMENT, NOT A PREFERENCE. There is no Math.random and | |
| no Date.now anywhere in this block. Time enters through a single `now` | |
| argument that defaults to performance.now(); variant selection is a hash of | |
| the cue identity plus a per-key repeat counter. The same event sequence | |
| against the same clock produces byte-identical output, which is what makes | |
| the whole thing testable and what stops a replay or a campaign script from | |
| diverging. | |
| NOTHING IS WIRED. Per this lane's scope, no cue is raised from sim.js, ai.js | |
| or the HUD. The integration points are named exactly at the bottom of this | |
| block. | |
| ============================================================================ */ | |
| /* THE TAXONOMY. Six categories, and the kinds each one admits. Kept in one | |
| table so the copy in src/story.js and the slot list in the voice descriptor | |
| are both generated from it and cannot fall out of step; the probe asserts | |
| the two agree. */ | |
| const COMMANDER_DIALOGUE_TAXONOMY={ | |
| objective:['assigned','complete','failed'], | |
| sighting:['first','heavy','air'], | |
| research:['complete','started'], | |
| casualty:['unit','structure','commander'], | |
| strategic:['ready','launch','incoming'], | |
| outcome:['victory','defeat','withdraw'] | |
| }; | |
| /* Higher wins. Order matters more than the exact numbers: a mission ending must | |
| never be pushed out by a research chime, and an inbound strategic weapon must | |
| outrank the casualty report for the units it is about to hit. */ | |
| const COMMANDER_DIALOGUE_PRIORITY={outcome:100,strategic:85,casualty:70,objective:60,sighting:45,research:30}; | |
| /* Per-category quiet time after a line of that category is SPOKEN OR SHOWN. | |
| Stamped on emit, never on admission, so a cue that is queued and later | |
| dropped cannot silence its whole category. Outcome is zero because a match | |
| ends once. */ | |
| const COMMANDER_DIALOGUE_COOLDOWN_MS={outcome:0,strategic:6000,casualty:15000,objective:12000,sighting:20000,research:8000}; | |
| /* Same event about the same subject inside this window is the same event. The | |
| subject is the caller's key β a unit type, an objective id, a base index β | |
| so "we lost a Rhino" and "we lost a Factory" are not each other. */ | |
| const COMMANDER_DIALOGUE_DEDUPE_MS=30000; | |
| /* Floor between any two emitted cues, whatever their category. This is the | |
| event-level pacing that stops a burst of simultaneous state changes from | |
| reading as a commander talking to themselves; src/audio.js applies a second, | |
| shorter floor to actual playback. */ | |
| const COMMANDER_DIALOGUE_GLOBAL_MS=3500; | |
| /* A held cue older than this is stale β the moment it described has passed and | |
| saying it now is worse than silence. Same reasoning as the 6000 ms deadline | |
| voPlay() applies to a late-arriving take. */ | |
| const COMMANDER_DIALOGUE_STALE_MS=9000; | |
| const COMMANDER_DIALOGUE_QUEUE_MAX=4; | |
| /* How many alternate recordings a commander slot is ASSUMED to hold when | |
| choosing a take index. The real count is not knowable until a pack exists, and | |
| voPlay() takes `idx % list.length`, so an assumption that is too high is safe | |
| and an assumption that is too low would simply never reach the later takes. | |
| Four matches the per-action take count the shipped unit-radio bank uses. */ | |
| const COMMANDER_DIALOGUE_TAKES=4; | |
| const COMMANDER_DIALOGUE={ | |
| queue:[], listeners:[], seq:0, lastAt:-1e9, catAt:{}, emitAt:{}, repeat:{}, | |
| enabled:true, log:[], logMax:64, | |
| stats:{raised:0,accepted:0,emitted:0,played:0,silent:0,gated:0,absent:0, | |
| rejectedDedupe:0,rejectedCooldown:0,rejectedQueue:0,rejectedUnknown:0,rejectedDisabled:0,dropped:0} | |
| }; | |
| function commanderDialogueClock(){ | |
| return (typeof performance!=='undefined'&&performance&&performance.now)?performance.now():0; | |
| } | |
| /* Flat list of 'category.kind' in taxonomy order. Used by the voice descriptor | |
| to enumerate the slots a pack would have to provide, and by the probe to | |
| compare against commanderDialogueKinds() in src/story.js. */ | |
| function commanderDialogueKindList(){ | |
| const out=[]; | |
| for(const c of Object.keys(COMMANDER_DIALOGUE_TAXONOMY)) | |
| for(const k of COMMANDER_DIALOGUE_TAXONOMY[c]) out.push(c+'.'+k); | |
| return out; | |
| } | |
| function commanderDialogueValid(category,kind){ | |
| const ks=COMMANDER_DIALOGUE_TAXONOMY[category]; | |
| return !!(ks&&ks.indexOf(kind)>=0); | |
| } | |
| /* FNV-1a, 32-bit. A named, stable hash rather than a string-length trick: the | |
| variant a commander uses for a given event must be the same on every device | |
| and in every replay, and must not shift when an unrelated line is added. */ | |
| function commanderDialogueHash(s){ | |
| let h=0x811c9dc5; | |
| s=String(s); | |
| for(let i=0;i<s.length;i++){ h^=s.charCodeAt(i); h=(h+((h<<1)+(h<<4)+(h<<7)+(h<<8)+(h<<24)))>>>0; } | |
| return h>>>0; | |
| } | |
| /* Deterministic variant choice: hash of (commander, category.kind) selects the | |
| starting line, and the per-key repeat counter walks forward from there. Two | |
| different commanders reporting the same event open on different variants; the | |
| same commander reporting it twice does not repeat itself. */ | |
| function commanderDialogueVariant(id,key,count,total){ | |
| if(!total) return 0; | |
| return ((commanderDialogueHash(id+'|'+key)+(count|0))%total+total)%total; | |
| } | |
| function commanderDialogueFactionName(faction){ | |
| try{ | |
| if(typeof facArt==='function'){ const A=facArt(faction); if(A&&A.nm) return A.nm; } | |
| }catch(e){} | |
| return String(faction||'').toUpperCase(); | |
| } | |
| function commanderDialogueText(identity,category,kind,count,override){ | |
| if(override) return String(override); | |
| const lines=(typeof commanderDialogueLines==='function') | |
| ? commanderDialogueLines(identity.id,identity.faction,category,kind) | |
| : ['Commander reporting.']; | |
| const idx=commanderDialogueVariant(identity.id,category+'.'+kind,count,lines.length); | |
| return String(lines[idx]) | |
| .replace(/\{cdr\}/g,identity.shortName||identity.name) | |
| .replace(/\{fac\}/g,commanderDialogueFactionName(identity.faction)); | |
| } | |
| /* Is the training narrator on the air? tutorial.js exports | |
| trainingMissionActive() on window; absent that, the answer is no. This is the | |
| ONE place the commander lane asks, so the deferral policy below has a single | |
| definition. */ | |
| function commanderDialogueTrainingActive(){ | |
| try{ | |
| const g=(typeof window!=='undefined'&&window)||(typeof globalThis!=='undefined'?globalThis:null); | |
| if(g&&typeof g.trainingMissionActive==='function') return !!g.trainingMissionActive(); | |
| if(typeof trainingMissionActive==='function') return !!trainingMissionActive(); | |
| }catch(e){} | |
| return false; | |
| } | |
| /* TRAINING COMPATIBILITY, as a wrapper rather than a fork. | |
| KEEN is teaching and the player is following her. A commander chiming in | |
| about research or a distant sighting during a lesson is noise competing with | |
| instruction, so during training this lane admits only the categories a | |
| trainee genuinely needs to hear β the ones that describe something happening | |
| TO them right now. Everything else is rejected at admission, which also means | |
| it never occupies a queue slot. | |
| The audio side yields separately and unconditionally: commanderVoiceGate() in | |
| src/audio.js refuses while any KEEN line is playing, so even a permitted | |
| commander cue cannot talk over her. Two independent guards, because either | |
| one alone has a hole. */ | |
| const COMMANDER_DIALOGUE_TRAINING_ALLOW={outcome:1,strategic:1,casualty:1}; | |
| function commanderDialogueTrainingAllows(category){ | |
| return !commanderDialogueTrainingActive()||!!COMMANDER_DIALOGUE_TRAINING_ALLOW[category]; | |
| } | |
| /* The single speech funnel for anything in this lane that wants to hand a raw | |
| line to the existing voice system rather than raise a cue. | |
| src/tutorial.js declares speakVoice() inside its IIFE and assigns | |
| window.speakVoice so this lane, and the minimap transmission overlay, share | |
| the same voice pipeline. The first branch is the live path; voPlay remains | |
| the fallback if the export is missing. Never falls through to speechSynthesis: | |
| a synthesised commander is worse than a quiet one. */ | |
| function commanderVoiceLegacySpeak(text,faction,action,idx,wx,wy){ | |
| try{ | |
| const g=(typeof window!=='undefined'&&window)||(typeof globalThis!=='undefined'?globalThis:null); | |
| if(g&&typeof g.speakVoice==='function'){ g.speakVoice(text,faction,action,idx,wx,wy); return 'speakVoice'; } | |
| if(typeof speakVoice==='function'){ speakVoice(text,faction,action,idx,wx,wy); return 'speakVoice'; } | |
| }catch(e){} | |
| try{ | |
| if(typeof voPlay==='function'&&action&&voPlay(faction,action,wx,wy,idx)) return 'voPlay'; | |
| }catch(e){} | |
| return 'silent'; | |
| } | |
| /* Clear all dialogue state. Call at match start and match end. Listeners are | |
| NOT dropped: a HUD rail that subscribed once should not have to resubscribe | |
| every match. */ | |
| function commanderDialogueReset(){ | |
| COMMANDER_DIALOGUE.queue.length=0; | |
| COMMANDER_DIALOGUE.seq=0; | |
| COMMANDER_DIALOGUE.lastAt=-1e9; | |
| COMMANDER_DIALOGUE.catAt={}; | |
| COMMANDER_DIALOGUE.emitAt={}; | |
| COMMANDER_DIALOGUE.repeat={}; | |
| COMMANDER_DIALOGUE.log.length=0; | |
| for(const k in COMMANDER_DIALOGUE.stats) COMMANDER_DIALOGUE.stats[k]=0; | |
| if(typeof commanderVoiceReset==='function'){ try{ commanderVoiceReset(); }catch(e){} } | |
| } | |
| function commanderDialogueSetEnabled(on){ COMMANDER_DIALOGUE.enabled=!!on; } | |
| function commanderDialogueOn(fn){ | |
| if(typeof fn==='function'&&COMMANDER_DIALOGUE.listeners.indexOf(fn)<0) COMMANDER_DIALOGUE.listeners.push(fn); | |
| return fn; | |
| } | |
| function commanderDialogueOff(fn){ | |
| const i=COMMANDER_DIALOGUE.listeners.indexOf(fn); | |
| if(i>=0) COMMANDER_DIALOGUE.listeners.splice(i,1); | |
| } | |
| /* RAISE AN EVENT. The one entry point. | |
| category one of COMMANDER_DIALOGUE_TAXONOMY | |
| kind one of that category's kinds | |
| opts {commanderId, subject, wx, wy, now, text, data, force} | |
| Returns {ok, reason, cue}. `ok:false` is ORDINARY β most events a running | |
| match raises are correctly suppressed β so the reason is always reported | |
| rather than thrown, and every rejection is counted in stats for the probe. | |
| `force:true` skips dedupe and cooldown but never skips validation, the queue | |
| bound or the training policy. */ | |
| function commanderCue(category,kind,opts){ | |
| const o=opts||{}; | |
| const now=(typeof o.now==='number')?o.now:commanderDialogueClock(); | |
| const S=COMMANDER_DIALOGUE; | |
| S.stats.raised++; | |
| if(!S.enabled){ S.stats.rejectedDisabled++; return {ok:false,reason:'disabled',cue:null}; } | |
| if(!commanderDialogueValid(category,kind)){ S.stats.rejectedUnknown++; return {ok:false,reason:'unknown-event',cue:null}; } | |
| if(!commanderDialogueTrainingAllows(category)){ S.stats.rejectedDisabled++; return {ok:false,reason:'training',cue:null}; } | |
| const identity=o.commanderId?commanderIdentity(o.commanderId):commanderPresentingIdentity(); | |
| if(!identity){ S.stats.rejectedUnknown++; return {ok:false,reason:'no-commander',cue:null}; } | |
| const key=category+'.'+kind; | |
| const dedupeKey=identity.id+'|'+key+'|'+(o.subject==null?'':String(o.subject)); | |
| if(!o.force){ | |
| const last=S.emitAt[dedupeKey]; | |
| if(last!=null&&now-last<COMMANDER_DIALOGUE_DEDUPE_MS){ S.stats.rejectedDedupe++; return {ok:false,reason:'dedupe',cue:null}; } | |
| for(const q of S.queue) if(q.dedupeKey===dedupeKey){ S.stats.rejectedDedupe++; return {ok:false,reason:'dedupe-queued',cue:null}; } | |
| const catLast=S.catAt[category]; | |
| if(catLast!=null&&now-catLast<(COMMANDER_DIALOGUE_COOLDOWN_MS[category]||0)){ | |
| S.stats.rejectedCooldown++; return {ok:false,reason:'cooldown',cue:null}; | |
| } | |
| } | |
| const priority=COMMANDER_DIALOGUE_PRIORITY[category]||0; | |
| const count=S.repeat[dedupeKey]|0; | |
| const cue={ | |
| seq:S.seq++, | |
| at:now, | |
| commanderId:identity.id, | |
| faction:identity.faction, | |
| bank:identity.bank, | |
| category:category, | |
| kind:kind, | |
| key:key, | |
| priority:priority, | |
| dedupeKey:dedupeKey, | |
| subject:(o.subject==null?null:String(o.subject)), | |
| take:commanderDialogueVariant(identity.id,key,count,COMMANDER_DIALOGUE_TAKES), | |
| wx:(typeof o.wx==='number')?o.wx:undefined, | |
| wy:(typeof o.wy==='number')?o.wy:undefined, | |
| data:o.data||null, | |
| subtitle:{ | |
| speaker:identity.name, | |
| rank:identity.rank, | |
| shortName:identity.shortName, | |
| callsign:identity.callsign, | |
| tag:commanderDialogueFactionName(identity.faction).toUpperCase()+' // '+category.toUpperCase(), | |
| text:commanderDialogueText(identity,category,kind,count,o.text) | |
| }, | |
| portrait:identity.portrait, | |
| voice:{bank:identity.voice.bank,channel:identity.voice.channel, | |
| slot:'vo_'+identity.voice.bank+'_'+category+'_'+kind, | |
| available:identity.voice.available}, | |
| audio:'pending', | |
| holds:0 | |
| }; | |
| /* QUEUE BOUND. Full queue: evict the weakest pending cue, but only if the | |
| newcomer actually outranks it. Priority first, then age β an older cue of | |
| equal priority is the one that has already waited, so it survives and the | |
| newcomer is refused. */ | |
| if(S.queue.length>=COMMANDER_DIALOGUE_QUEUE_MAX){ | |
| let weak=null; | |
| for(const q of S.queue) if(!weak||q.priority<weak.priority||(q.priority===weak.priority&&q.seq>weak.seq)) weak=q; | |
| if(!weak||priority<=weak.priority){ S.stats.rejectedQueue++; return {ok:false,reason:'queue-full',cue:null}; } | |
| S.queue.splice(S.queue.indexOf(weak),1); | |
| S.stats.dropped++; | |
| } | |
| S.queue.push(cue); | |
| S.stats.accepted++; | |
| return {ok:true,reason:'queued',cue:cue}; | |
| } | |
| /* DRAIN. Emits at most one cue per call β a commander says one thing at a time | |
| β and returns the cues emitted this call as an array so the caller does not | |
| have to care whether that is zero or one. | |
| Ordering is a total order with no ties: priority descending, then seq | |
| ascending. Two cues raised in the same millisecond therefore emit in the | |
| order they were raised, on every device. */ | |
| function commanderDialogueDrain(now){ | |
| const S=COMMANDER_DIALOGUE; | |
| const t=(typeof now==='number')?now:commanderDialogueClock(); | |
| const out=[]; | |
| if(!S.queue.length) return out; | |
| /* Drop what has gone stale before choosing, so a stale high-priority cue | |
| cannot keep winning the sort and starving a live low-priority one. */ | |
| for(let i=S.queue.length-1;i>=0;i--){ | |
| if(t-S.queue[i].at>COMMANDER_DIALOGUE_STALE_MS){ S.queue.splice(i,1); S.stats.dropped++; } | |
| } | |
| if(!S.queue.length) return out; | |
| if(t-S.lastAt<COMMANDER_DIALOGUE_GLOBAL_MS){ for(const q of S.queue) q.holds++; return out; } | |
| S.queue.sort((a,b)=> b.priority-a.priority || a.seq-b.seq); | |
| const cue=S.queue.shift(); | |
| /* PLAYBACK IS OPTIONAL AND ALWAYS SECOND. The subtitle is the product; audio | |
| is an enhancement that may not exist. A gated cue goes back in the queue to | |
| be tried again; a silent one is emitted anyway. */ | |
| let verdict='absent'; | |
| if(typeof commanderVoiceSpeak==='function'){ | |
| try{ verdict=commanderVoiceSpeak(cue,t)||'silent'; }catch(e){ verdict='silent'; } | |
| } | |
| if(verdict==='gated'){ | |
| cue.holds++; | |
| if(t-cue.at<=COMMANDER_DIALOGUE_STALE_MS){ S.queue.push(cue); S.stats.gated++; return out; } | |
| S.stats.dropped++; return out; | |
| } | |
| cue.audio=verdict; | |
| if(verdict==='played') S.stats.played++; | |
| else if(verdict==='absent') S.stats.absent++; | |
| else S.stats.silent++; | |
| S.lastAt=t; | |
| S.catAt[cue.category]=t; | |
| S.emitAt[cue.dedupeKey]=t; | |
| S.repeat[cue.dedupeKey]=(S.repeat[cue.dedupeKey]|0)+1; | |
| S.stats.emitted++; | |
| S.log.push({seq:cue.seq,at:t,id:cue.commanderId,key:cue.key,subject:cue.subject, | |
| priority:cue.priority,audio:cue.audio,text:cue.subtitle.text}); | |
| while(S.log.length>S.logMax) S.log.shift(); | |
| /* One bad listener must not silence the rest, and must not take the frame | |
| down with it. */ | |
| for(const fn of S.listeners.slice()){ try{ fn(cue); }catch(e){} } | |
| out.push(cue); | |
| return out; | |
| } | |
| /* Read-only snapshot for the probe, the debug overlay and anything that wants | |
| to know what the lane is doing without driving it. */ | |
| function commanderDialogueState(){ | |
| const S=COMMANDER_DIALOGUE; | |
| return { | |
| version:COMMANDER_IDENTITY_VERSION, | |
| enabled:S.enabled, | |
| queued:S.queue.length, | |
| listeners:S.listeners.length, | |
| lastAt:S.lastAt, | |
| stats:Object.assign({},S.stats), | |
| queue:S.queue.map(q=>({seq:q.seq,key:q.key,priority:q.priority,subject:q.subject,holds:q.holds})), | |
| log:S.log.slice() | |
| }; | |
| } | |
| /* ---------------------------------------------------------------------------- | |
| INTEGRATION POINTS β deliberately NOT wired in this lane. | |
| Every hook below is a stable, guarded call. Each call site should be written | |
| as `if(typeof commanderCue==='function') commanderCue(...)` so a build | |
| without this block, or a headless harness, is unaffected. | |
| src/game/sim.js | |
| damage/destroy path, where a unit dies -> commanderCue('casualty','unit',{subject:TYPES[ut[i]].name}) | |
| building destroy path -> commanderCue('casualty','structure',{subject:BLD[b.t].nm}) | |
| first hostile entering player vision -> commanderCue('sighting','first',{subject:teamKey}) | |
| first heavy / first air contact -> commanderCue('sighting','heavy'|'air',{subject:TYPES[..].name}) | |
| src/game/ai.js | |
| an AI seat losing its commander unit -> commanderCue('casualty','commander',{commanderId:S.commanderId}) | |
| src/game/commander.js (this file) | |
| fireLance() / commanderActive superweapons -> commanderCue('strategic','launch',{wx,wy}) | |
| ability coming off cooldown -> commanderCue('strategic','ready') | |
| src/develop.js or src/restree3d.js | |
| research completing -> commanderCue('research','complete',{subject:techId}) | |
| src/endgame.js | |
| endGame(win,reason) -> commanderCue('outcome',win?'victory':'defeat',{force:true}) | |
| src/story.js | |
| campaign objective state change -> commanderCue('objective','assigned'|'complete'|'failed',{subject:m.id}) | |
| src/main.js frame loop (after the sim step) -> commanderDialogueDrain() | |
| src/main.js resetWorld() / match start -> commanderDialogueReset(); commanderVoicePrewarm(playerCommanderId) | |
| src/ui/hud.js subtitle rail -> commanderDialogueOn(cue => renderCommanderSubtitle(cue)) | |
| The HUD side needs nothing from this file beyond the cue object: cue.subtitle | |
| carries speaker, rank, callsign, tag and text, and cue.portrait carries src, | |
| fallback and alt. cue.audio is 'played' | 'silent' | 'absent' and is the | |
| correct signal for how long to hold the rail β hold on the text, not on the | |
| sound, because today there is no sound. | |
| -------------------------------------------------------------------------- */ | |