CREATORJD commited on
Commit
eabf9d4
·
verified ·
1 Parent(s): 8b385f3

MASSFRONT v1.32.24 — header redesign, EMP ability, stim fix

Browse files
assets/app.webmanifest CHANGED
@@ -11,17 +11,17 @@
11
  "theme_color": "#060c16",
12
  "icons": [
13
  {
14
- "src": "./icons/icon-192.png?v=1.32.23",
15
  "sizes": "192x192",
16
  "type": "image/png"
17
  },
18
  {
19
- "src": "./icons/icon-512.png?v=1.32.23",
20
  "sizes": "512x512",
21
  "type": "image/png"
22
  },
23
  {
24
- "src": "./icons/icon-1024.png?v=1.32.23",
25
  "sizes": "1024x1024",
26
  "type": "image/png",
27
  "purpose": "any maskable"
 
11
  "theme_color": "#060c16",
12
  "icons": [
13
  {
14
+ "src": "./icons/icon-192.png?v=1.32.24",
15
  "sizes": "192x192",
16
  "type": "image/png"
17
  },
18
  {
19
+ "src": "./icons/icon-512.png?v=1.32.24",
20
  "sizes": "512x512",
21
  "type": "image/png"
22
  },
23
  {
24
+ "src": "./icons/icon-1024.png?v=1.32.24",
25
  "sizes": "1024x1024",
26
  "type": "image/png",
27
  "purpose": "any maskable"
boot.js CHANGED
@@ -35,7 +35,7 @@
35
  otherwise reuse a stale source even after the installer or local preview
36
  has changed, which made new settings appear to be missing until cache was
37
  cleared manually. Patch bundles keep their content-addressed Blob URLs. */
38
- var PACKAGED_REV='1.32.23';
39
  var DB='massfront-updates', STORE='bundles';
40
  var bootShield=null, bootShieldTimer=0, bootShieldWatchdog=0;
41
  var bootShieldEvents=['pointerdown','pointerup','touchstart','touchend','click'];
 
35
  otherwise reuse a stale source even after the installer or local preview
36
  has changed, which made new settings appear to be missing until cache was
37
  cleared manually. Patch bundles keep their content-addressed Blob URLs. */
38
+ var PACKAGED_REV='1.32.24';
39
  var DB='massfront-updates', STORE='bundles';
40
  var bootShield=null, bootShieldTimer=0, bootShieldWatchdog=0;
41
  var bootShieldEvents=['pointerdown','pointerup','touchstart','touchend','click'];
index.html CHANGED
@@ -9,19 +9,19 @@
9
  <meta name="mobile-web-app-capable" content="yes">
10
  <meta name="theme-color" content="#060c16">
11
  <meta name="format-detection" content="telephone=no">
12
- <link rel="apple-touch-icon" href="./assets/icons/icon-180.png?v=1.32.23">
13
- <link rel="apple-touch-icon" sizes="152x152" href="./assets/icons/icon-152.png?v=1.32.23">
14
- <link rel="apple-touch-startup-image" href="./assets/icons/splash-2732.png?v=1.32.23">
15
- <link rel="icon" href="./assets/icons/icon-192.png?v=1.32.23">
16
- <link rel="manifest" href="./assets/app.webmanifest?v=1.32.23">
17
  <title>MASSFRONT — Mobile RTS</title>
18
- <link rel="stylesheet" href="./src/styles/ui.css?v=1.32.23">
19
- <link rel="stylesheet" href="./src/styles/auth.css?v=1.32.23">
20
- <link rel="stylesheet" href="./src/styles/tutorial.css?v=1.32.23">
21
- <link rel="stylesheet" href="./src/styles/ads.css?v=1.32.23">
22
- <link rel="stylesheet" href="./src/styles/store.css?v=1.32.23">
23
- <link rel="stylesheet" href="./src/styles/restree.css?v=1.32.23">
24
- <link rel="stylesheet" href="./src/styles/intro.css?v=1.32.23">
25
  <style>
26
  /* CRITICAL — inline so it holds on the FIRST painted frame even if the linked
27
  intro.css above is slow or fails. The launch reveal (src/intro.js, z-index 950)
@@ -114,6 +114,7 @@ body:not(.mfIntroDone) .overlay{display:none!important}
114
  <button class="cbtn abtn" id="abHeal"><span class="em">🛠</span><span>Repair</span><div class="cdring"></div></button>
115
  <button class="cbtn abtn" id="abRage"><span class="em">⚡</span><span>Surge</span><div class="cdring"></div></button>
116
  <button class="cbtn abtn" id="abLance"><span class="em">🛰</span><span>Lance</span><div class="cdring"></div></button>
 
117
  <button class="cbtn abtn" id="abJump" aria-label="Commander jump jets"><span class="em">↗</span><span>Jump</span><div class="cdring"></div></button>
118
  <button class="cbtn abtn" id="abClass" style="display:none" aria-label="Selected unit class ability"><span class="em" id="classAbEm">➤</span><span id="classAbNm">Class</span><div class="cdring"></div></button>
119
  <button class="cbtn abtn" id="abBarrage" aria-label="Charged Barrage" title="Charged Barrage"><span class="em">☄</span><span>Barrage</span><div class="cdring"></div></button>
@@ -154,14 +155,22 @@ body:not(.mfIntroDone) .overlay{display:none!important}
154
  tappable, because it is the shortest route to the screen the rest of it
155
  moved to — plus the cores/record box, which is a live figure rather
156
  than a progression readout. -->
157
- <!-- Corner mailbox. Deliberately NOT in .menuStrip: a notification surface
158
- wants a fixed, always-in-the-same-place home, and the strip is a row of
159
- peers that reflows. -->
160
- <button type="button" id="inboxBtn" aria-label="Inbox"><span class="sEmj">✉</span></button>
 
 
161
  <div id="metaHead">
162
- <button type="button" id="rankEm" aria-label="Open your profile and career record">🎖</button>
163
- <div id="rankGreet"><b id="greetLine">COMMANDER</b><span id="greetSub">Tap the rank badge for your career</span></div>
164
- <div id="coreBox"><span class="lbl">CORES</span><b id="coreV">0</b><div id="metaRec">0W / 0L</div></div>
 
 
 
 
 
 
165
  </div>
166
  <!-- The supplied metallic MASSFRONT key art replaces the old drawn shield SVG
167
  and the CSS wordmark/tagline, so the menu carries the same brand as the
@@ -202,7 +211,7 @@ body:not(.mfIntroDone) .overlay{display:none!important}
202
  <div class="updHead">
203
  <div>
204
  <div id="updTxt">GAME VERSION</div>
205
- <div id="updSub">v1.32.23</div>
206
  </div>
207
  <div class="updBtns">
208
  <button id="updCancel" style="display:none">✕</button>
 
9
  <meta name="mobile-web-app-capable" content="yes">
10
  <meta name="theme-color" content="#060c16">
11
  <meta name="format-detection" content="telephone=no">
12
+ <link rel="apple-touch-icon" href="./assets/icons/icon-180.png?v=1.32.24">
13
+ <link rel="apple-touch-icon" sizes="152x152" href="./assets/icons/icon-152.png?v=1.32.24">
14
+ <link rel="apple-touch-startup-image" href="./assets/icons/splash-2732.png?v=1.32.24">
15
+ <link rel="icon" href="./assets/icons/icon-192.png?v=1.32.24">
16
+ <link rel="manifest" href="./assets/app.webmanifest?v=1.32.24">
17
  <title>MASSFRONT — Mobile RTS</title>
18
+ <link rel="stylesheet" href="./src/styles/ui.css?v=1.32.24">
19
+ <link rel="stylesheet" href="./src/styles/auth.css?v=1.32.24">
20
+ <link rel="stylesheet" href="./src/styles/tutorial.css?v=1.32.24">
21
+ <link rel="stylesheet" href="./src/styles/ads.css?v=1.32.24">
22
+ <link rel="stylesheet" href="./src/styles/store.css?v=1.32.24">
23
+ <link rel="stylesheet" href="./src/styles/restree.css?v=1.32.24">
24
+ <link rel="stylesheet" href="./src/styles/intro.css?v=1.32.24">
25
  <style>
26
  /* CRITICAL — inline so it holds on the FIRST painted frame even if the linked
27
  intro.css above is slow or fails. The launch reveal (src/intro.js, z-index 950)
 
114
  <button class="cbtn abtn" id="abHeal"><span class="em">🛠</span><span>Repair</span><div class="cdring"></div></button>
115
  <button class="cbtn abtn" id="abRage"><span class="em">⚡</span><span>Surge</span><div class="cdring"></div></button>
116
  <button class="cbtn abtn" id="abLance"><span class="em">🛰</span><span>Lance</span><div class="cdring"></div></button>
117
+ <button class="cbtn abtn" id="abEmp"><span class="em">⚡</span><span>EMP</span><div class="cdring"></div></button>
118
  <button class="cbtn abtn" id="abJump" aria-label="Commander jump jets"><span class="em">↗</span><span>Jump</span><div class="cdring"></div></button>
119
  <button class="cbtn abtn" id="abClass" style="display:none" aria-label="Selected unit class ability"><span class="em" id="classAbEm">➤</span><span id="classAbNm">Class</span><div class="cdring"></div></button>
120
  <button class="cbtn abtn" id="abBarrage" aria-label="Charged Barrage" title="Charged Barrage"><span class="em">☄</span><span>Barrage</span><div class="cdring"></div></button>
 
155
  tappable, because it is the shortest route to the screen the rest of it
156
  moved to — plus the cores/record box, which is a live figure rather
157
  than a progression readout. -->
158
+ <!-- Identity first, economy second. The old header put the rank ladder, the
159
+ cores and the record on one crowded line and pushed the whole thing under
160
+ the OS status bar. Now: a profile icon carrying the account level, the
161
+ name beside it, the mailbox in the corner OF THE HEADER (so it cannot
162
+ overlap the numbers the way a free-floating button did), and the
163
+ resources on their own row underneath. -->
164
  <div id="metaHead">
165
+ <button type="button" id="rankEm" aria-label="Open your profile and career record">
166
+ <span id="rankEmGlyph">🎖</span><span id="rankLvl">1</span>
167
+ </button>
168
+ <div id="rankGreet"><b id="greetLine">COMMANDER</b><span id="greetSub">Tap your profile for career details</span></div>
169
+ <button type="button" id="inboxBtn" aria-label="Inbox"><span class="sEmj">✉</span></button>
170
+ <div id="metaRes">
171
+ <div class="metaResCell"><span class="lbl">CORES</span><b id="coreV">0</b></div>
172
+ <div class="metaResCell"><span class="lbl">RECORD</span><b id="metaRec">0W / 0L</b></div>
173
+ </div>
174
  </div>
175
  <!-- The supplied metallic MASSFRONT key art replaces the old drawn shield SVG
176
  and the CSS wordmark/tagline, so the menu carries the same brand as the
 
211
  <div class="updHead">
212
  <div>
213
  <div id="updTxt">GAME VERSION</div>
214
+ <div id="updSub">v1.32.24</div>
215
  </div>
216
  <div class="updBtns">
217
  <button id="updCancel" style="display:none">✕</button>
src/game/commander.js CHANGED
@@ -3,8 +3,8 @@
3
  ============================================================ */
4
  let heroLvl=1, heroXp=0, heroXpNext=100;
5
  let heroDmgMult=1, heroRegen=14;
6
- let abCool=[0,0,0,0]; // blast, repair, surge, orbital lance
7
- const AB_CD=[26,20,30,70];
8
  let heroJumpCool=0;
9
  const HERO_JUMP={range:275,cool:28,energy:170};
10
  let heroStuckFor=0,heroRescueAt=0;
@@ -281,6 +281,15 @@ const UPGRADES=[
281
  {em:'💥', nm:'Wider Blast', ds:'Orbital Blast radius +35%', fn:()=>{ blastRadius*=1.35; }},
282
  ];
283
  let bonusMass=0, bonusEnergy=0, armyDmgMult=1, blastRadius=110;
 
 
 
 
 
 
 
 
 
284
  function flashScreen(){
285
  const f=document.getElementById('flash');
286
  if(!f) return;
@@ -319,6 +328,7 @@ function tryAbility(k){
319
  if(heroIdx<0){ toast('Commander is down'); return; }
320
  if(!abUnlock[k]){
321
  toast(k===3?'🛰 Buy the Orbital Uplink in the Armory to unlock this'
 
322
  :'Unlocks at Commander level '+(k===1?2:3));
323
  return;
324
  }
@@ -337,6 +347,37 @@ function tryAbility(k){
337
  addParticle(3,hx,hy,0,0,.7,190, 90,255,160);
338
  sfx('heal'); if(typeof radioAck==='function')radioAck('ability',1,hx,hy); return;
339
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  if(k===2){ // combat surge
341
  abCool[2]=AB_CD[2];
342
  const hx=ux[heroIdx], hy=uy[heroIdx];
 
3
  ============================================================ */
4
  let heroLvl=1, heroXp=0, heroXpNext=100;
5
  let heroDmgMult=1, heroRegen=14;
6
+ let abCool=[0,0,0,0,0]; // blast, repair, surge, orbital lance, EMP
7
+ const AB_CD=[26,20,30,70,45];
8
  let heroJumpCool=0;
9
  const HERO_JUMP={range:275,cool:28,energy:170};
10
  let heroStuckFor=0,heroRescueAt=0;
 
281
  {em:'💥', nm:'Wider Blast', ds:'Orbital Blast radius +35%', fn:()=>{ blastRadius*=1.35; }},
282
  ];
283
  let bonusMass=0, bonusEnergy=0, armyDmgMult=1, blastRadius=110;
284
+ /* Combat Stims are a TEMPORARY multiplier and must be kept out of armyDmgMult.
285
+ armyDmgMult is written ADDITIVELY by thirteen different sources — research,
286
+ the Veteran Doctrine level-up card, five armory weapon items, consumables,
287
+ develop modules and operation modifiers. Folding a *=1.45 into it and later
288
+ doing /=1.45 is not an inverse once any of those has landed in between:
289
+ (1.00*1.45 + 0.12)/1.45 = 1.083, not 1.12. A player who completed Ballistics I
290
+ during a stim permanently kept 0.083 of the 0.12 they paid 500 mass and 2500
291
+ energy for, and level-ups are MORE likely during a stim because kills spike. */
292
+ let stimDmgMult=1;
293
  function flashScreen(){
294
  const f=document.getElementById('flash');
295
  if(!f) return;
 
328
  if(heroIdx<0){ toast('Commander is down'); return; }
329
  if(!abUnlock[k]){
330
  toast(k===3?'🛰 Buy the Orbital Uplink in the Armory to unlock this'
331
+ :k===4?'⚡ Craft the EMP Charge module in Development to unlock this'
332
  :'Unlocks at Commander level '+(k===1?2:3));
333
  return;
334
  }
 
347
  addParticle(3,hx,hy,0,0,.7,190, 90,255,160);
348
  sfx('heal'); if(typeof radioAck==='function')radioAck('ability',1,hx,hy); return;
349
  }
350
+ if(k===4){ // EMP charge — the craftable module from Development
351
+ /* This module has been on sale in Development for 5 Relic Cores + 30 Isotope
352
+ since it was added, described as "stun everything in a wide radius", with
353
+ no implementation anywhere in the codebase and a flag that a four-entry
354
+ reset array threw away every match. Both are fixed; this is the ability.
355
+
356
+ Centred on the Commander rather than aimed: it is a panic button for when
357
+ the Commander is caught, and a self-centred blast needs no aiming mode —
358
+ one less state machine to get wrong. Friendlies are untouched. */
359
+ abCool[4]=AB_CD[4];
360
+ const hx=ux[heroIdx], hy=uy[heroIdx], R=260;
361
+ let n=0;
362
+ forUnitsIn(hx,hy,R,j=>{
363
+ if(!ualive[j]||uteam[j]===0||j===heroIdx) return;
364
+ ustun[j]=4; ucool[j]=Math.max(ucool[j],4); n++;
365
+ addParticle(0,ux[j],uy[j],0,-12,.5,7,150,220,255);
366
+ });
367
+ /* Enemy defensive structures are on the same grid and are just as much
368
+ "everything in a wide radius" as the units are. */
369
+ let b=0;
370
+ for(const B of blds){
371
+ if(!B.alive||B.team===0) continue;
372
+ if(Math.hypot(B.x-hx,B.y-hy)>R) continue;
373
+ B.cool=Math.max(B.cool||0,4); b++;
374
+ }
375
+ addParticle(3,hx,hy,0,0,.8,R,150,220,255);
376
+ toast('⚡ EMP — '+n+' unit'+(n===1?'':'s')+(b?' and '+b+' structure'+(b===1?'':'s'):'')+' disabled for 4s');
377
+ sfx('surge',hx,hy,1.2);
378
+ if(typeof radioAck==='function')radioAck('ability',n,hx,hy);
379
+ return;
380
+ }
381
  if(k===2){ // combat surge
382
  abCool[2]=AB_CD[2];
383
  const hx=ux[heroIdx], hy=uy[heroIdx];
src/game/meta.js CHANGED
@@ -340,7 +340,12 @@ function invApplyLoadout(){
340
  b.ready=[]; metaSave();
341
  return used;
342
  }
343
- const AB_BASE=[26,20,30,70]; // must stay in lockstep with AB_CD (blast/repair/surge/lance)
 
 
 
 
 
344
  /* The setup screen's supply-drop choice, kept so perks can be re-applied on top
345
  of it every match instead of on top of last match's result. */
346
  let crateRateBase=1;
@@ -570,13 +575,15 @@ function renderMetaHead(){
570
  const r=metaRankIdx(), R=RANKS[r], next=RANKS[r+1], P=activeProf();
571
  const set=(id,v)=>{ const e=document.getElementById(id); if(e) e.textContent=v; };
572
  const ID=profIdentity();
573
- const badge=document.getElementById('rankEm');
574
- if(badge){
575
  /* Wearing a commander replaces the emoji with their portrait, framed by
576
- whichever frame the account has unlocked and chosen. */
577
- if(ID.portrait) badge.innerHTML='<img class="badgePortrait fr-'+ID.frame+'" src="'+ID.portrait+'" alt="">';
578
- else badge.textContent=ID.emblem;
 
579
  }
 
580
  /* The name / rank label / XP bar / XP count moved to Profile ▸ CAREER
581
  (renderCareer). The header keeps the badge and a one-line greeting; the
582
  badge's tooltip carries the rank so the symbol is never a mystery. */
 
340
  b.ready=[]; metaSave();
341
  return used;
342
  }
343
+ /* MUST stay in lockstep with AB_CD in commander.js — the rebuild loop below is
344
+ driven by AB_CD.length, so a shorter AB_BASE writes `undefined * cd` = NaN
345
+ into the extra slots. A NaN cooldown never satisfies `abCool[k]>0`, so the
346
+ ability reads as permanently ready and its cooldown never displays. Adding
347
+ the EMP as a fifth ability without this line did exactly that. */
348
+ const AB_BASE=[26,20,30,70,45]; // blast, repair, surge, lance, EMP
349
  /* The setup screen's supply-drop choice, kept so perks can be re-applied on top
350
  of it every match instead of on top of last match's result. */
351
  let crateRateBase=1;
 
575
  const r=metaRankIdx(), R=RANKS[r], next=RANKS[r+1], P=activeProf();
576
  const set=(id,v)=>{ const e=document.getElementById(id); if(e) e.textContent=v; };
577
  const ID=profIdentity();
578
+ const glyph=document.getElementById('rankEmGlyph');
579
+ if(glyph){
580
  /* Wearing a commander replaces the emoji with their portrait, framed by
581
+ whichever frame the account has unlocked and chosen. The level badge is a
582
+ sibling, so it survives this swap. */
583
+ if(ID.portrait) glyph.innerHTML='<img class="badgePortrait fr-'+ID.frame+'" src="'+ID.portrait+'" alt="">';
584
+ else glyph.textContent=ID.emblem;
585
  }
586
+ set('rankLvl', r+1);
587
  /* The name / rank label / XP bar / XP count moved to Profile ▸ CAREER
588
  (renderCareer). The header keeps the badge and a one-line greeting; the
589
  badge's tooltip carries the rank so the symbol is never a mystery. */
src/game/sim.js CHANGED
@@ -177,6 +177,12 @@ const HAZ_SPD=0.72, HAZ_RNG=0.70;
177
  normal engagement resumes, so this cannot make a wave ignore the base it came
178
  to attack. */
179
  const umarch=new Uint8Array(MAXU);
 
 
 
 
 
 
180
  let freeList=[], unitHigh=0;
181
  const teamCount=[0,0,0];
182
  const titanCount=[0,0];
@@ -390,7 +396,7 @@ function spawnUnit(type,team,x,y){
390
  a dead marcher's slot silently refused to engage for the rest of the match.
391
  main.js clears the array between matches, which is why this only ever
392
  surfaced mid-match and read as an aggro/AI bug rather than a spawn bug. */
393
- umarch[i]=0;
394
  uPatrolRoute[i]=-1; uPatrolStep[i]=0; uPatrolSlot[i]=0;
395
  uMoveCohort[i]=-1; uCohesion[i]=1;
396
  umode[i]=0; umodeT[i]=0;
@@ -1702,7 +1708,8 @@ function crateTick(dt){
1702
  }
1703
  if(boostDmgT>0){
1704
  boostDmgT-=dt;
1705
- if(boostDmgT<=0){ armyDmgMult/=1.45; toast('🎖 Combat stims worn off'); }
 
1706
  }
1707
  }
1708
  function applyCrate(k,x,y){
@@ -1713,7 +1720,7 @@ function applyCrate(k,x,y){
1713
  pickupToast(k,'+250 MASS · +900 ENERGY');
1714
  }
1715
  else if(k.id==='vet'){
1716
- if(boostDmgT<=0) armyDmgMult*=1.45;
1717
  boostDmgT=30; pickupToast(k,'ARMY DAMAGE +45% · 30 SECONDS');
1718
  }
1719
  else if(k.id==='repair'){
@@ -3314,6 +3321,13 @@ function unitTick(dt){
3314
  if(ubroodLed[i]>0) ubroodLed[i]-=dt;
3315
  if(uhaz[i]>0) uhaz[i]-=dt;
3316
  if(ustomp[i]>0) ustomp[i]-=dt;
 
 
 
 
 
 
 
3317
  if(ushielded[i]>0) ushielded[i]-=dt;
3318
  /* ---- FACTION HERO BEHAVIOURS ------------------------------------------
3319
  Each hero does something its faction's army cannot, so killing it changes
@@ -3433,7 +3447,7 @@ function unitTick(dt){
3433
  const vet=1+uvet[i]*0.15;
3434
  ucool[i]=T.cool*modeCoolMul(md)*classCoolMul(i)*broodCoolMul(i)/(ubuff[i]>0?1.4:1);
3435
  if(md===4){ umode[i]=0; umodeT[i]=MODE_SWITCH*0.4; } // firing breaks GHOST cover
3436
- const dmg=T.dmg*vet*modeDmgMul(md)*classDmgMul(i)*broodDmgMul(i)*(ubuff[i]>0?1.5:1)*(uteam[i]===1?aiDmgMult:(uteam[i]===0?armyDmgMult:1))*(i===heroIdx?heroDmgMult:1);
3437
  const ma=(T.tur?uturr[i]:uang[i])-Math.PI/2;
3438
  const mx=ux[i]+Math.cos(ma)*T.size*0.62, my=uy[i]+Math.sin(ma)*T.size*0.62;
3439
  if(T.wk==='m'){
 
177
  normal engagement resumes, so this cannot make a wave ignore the base it came
178
  to attack. */
179
  const umarch=new Uint8Array(MAXU);
180
+ /* EMP stun, in seconds. A stunned unit neither moves nor fires. Declared here
181
+ with the rest of the per-unit state so it is impossible to miss when reading
182
+ what a slot carries — and it is cleared in spawnUnit() alongside the others,
183
+ because a recycled slot inherits every array it is not explicitly told to
184
+ reset. That exact omission on umarch shipped as a live bug in 1.32.18. */
185
+ const ustun=new Float32Array(MAXU);
186
  let freeList=[], unitHigh=0;
187
  const teamCount=[0,0,0];
188
  const titanCount=[0,0];
 
396
  a dead marcher's slot silently refused to engage for the rest of the match.
397
  main.js clears the array between matches, which is why this only ever
398
  surfaced mid-match and read as an aggro/AI bug rather than a spawn bug. */
399
+ umarch[i]=0; ustun[i]=0;
400
  uPatrolRoute[i]=-1; uPatrolStep[i]=0; uPatrolSlot[i]=0;
401
  uMoveCohort[i]=-1; uCohesion[i]=1;
402
  umode[i]=0; umodeT[i]=0;
 
1708
  }
1709
  if(boostDmgT>0){
1710
  boostDmgT-=dt;
1711
+ /* Set, not divided — see the stimDmgMult note in commander.js. */
1712
+ if(boostDmgT<=0){ stimDmgMult=1; toast('🎖 Combat stims worn off'); }
1713
  }
1714
  }
1715
  function applyCrate(k,x,y){
 
1720
  pickupToast(k,'+250 MASS · +900 ENERGY');
1721
  }
1722
  else if(k.id==='vet'){
1723
+ stimDmgMult=1.45;
1724
  boostDmgT=30; pickupToast(k,'ARMY DAMAGE +45% · 30 SECONDS');
1725
  }
1726
  else if(k.id==='repair'){
 
3321
  if(ubroodLed[i]>0) ubroodLed[i]-=dt;
3322
  if(uhaz[i]>0) uhaz[i]-=dt;
3323
  if(ustomp[i]>0) ustomp[i]-=dt;
3324
+ if(ustun[i]>0){
3325
+ ustun[i]-=dt;
3326
+ /* Held under EMP: no movement, no firing. The arc particle is what tells
3327
+ the player WHY a line of enemies has stopped, so it is not optional. */
3328
+ if(((i+tick)&7)===0) addParticle(0,ux[i],uy[i],rr(-14,14),rr(-14,14),.26,4,150,220,255);
3329
+ continue;
3330
+ }
3331
  if(ushielded[i]>0) ushielded[i]-=dt;
3332
  /* ---- FACTION HERO BEHAVIOURS ------------------------------------------
3333
  Each hero does something its faction's army cannot, so killing it changes
 
3447
  const vet=1+uvet[i]*0.15;
3448
  ucool[i]=T.cool*modeCoolMul(md)*classCoolMul(i)*broodCoolMul(i)/(ubuff[i]>0?1.4:1);
3449
  if(md===4){ umode[i]=0; umodeT[i]=MODE_SWITCH*0.4; } // firing breaks GHOST cover
3450
+ const dmg=T.dmg*vet*modeDmgMul(md)*classDmgMul(i)*broodDmgMul(i)*(ubuff[i]>0?1.5:1)*(uteam[i]===1?aiDmgMult:(uteam[i]===0?armyDmgMult*stimDmgMult:1))*(i===heroIdx?heroDmgMult:1);
3451
  const ma=(T.tur?uturr[i]:uang[i])-Math.PI/2;
3452
  const mx=ux[i]+Math.cos(ma)*T.size*0.62, my=uy[i]+Math.sin(ma)*T.size*0.62;
3453
  if(T.wk==='m'){
src/main.js CHANGED
@@ -231,7 +231,7 @@ function resetWorld(){
231
  aiHpMult=1;
232
  stats.kills=[0,0,0]; stats.built=[0,0]; stats.t=0; stats.nests=0; stats.reclaimed=0;
233
  heroLvl=1; heroXp=0; heroXpNext=100; pendingLevels=0;
234
- heroDmgMult=1; heroRegen=14; armyDmgMult=1; bonusMass=0; bonusEnergy=0;
235
  playerBuildMult=1; blastRadius=110;
236
  titanCount[0]=0; titanCount[1]=0; teamCount[2]=0;
237
  beams.length=0; fogCov.fill(0); fogSeen.fill(0); fogSources.fill(0); fogScans.length=0;
@@ -1320,6 +1320,7 @@ function wire(){
1320
  $('abHeal').addEventListener('pointerdown',()=>tryAbility(1));
1321
  $('abRage').addEventListener('pointerdown',()=>tryAbility(2));
1322
  $('abLance').addEventListener('pointerdown',()=>tryAbility(3));
 
1323
  if($('abJump')) $('abJump').addEventListener('pointerdown',()=>tryCommanderJump());
1324
  $('abBarrage').addEventListener('pointerdown',()=>tryArtilleryBarrage());
1325
  if($('abClass')) $('abClass').addEventListener('pointerdown',()=>tryClassAbility());
 
231
  aiHpMult=1;
232
  stats.kills=[0,0,0]; stats.built=[0,0]; stats.t=0; stats.nests=0; stats.reclaimed=0;
233
  heroLvl=1; heroXp=0; heroXpNext=100; pendingLevels=0;
234
+ heroDmgMult=1; heroRegen=14; armyDmgMult=1; stimDmgMult=1; boostDmgT=0; bonusMass=0; bonusEnergy=0;
235
  playerBuildMult=1; blastRadius=110;
236
  titanCount[0]=0; titanCount[1]=0; teamCount[2]=0;
237
  beams.length=0; fogCov.fill(0); fogSeen.fill(0); fogSources.fill(0); fogScans.length=0;
 
1320
  $('abHeal').addEventListener('pointerdown',()=>tryAbility(1));
1321
  $('abRage').addEventListener('pointerdown',()=>tryAbility(2));
1322
  $('abLance').addEventListener('pointerdown',()=>tryAbility(3));
1323
+ $('abEmp').addEventListener('pointerdown',()=>tryAbility(4));
1324
  if($('abJump')) $('abJump').addEventListener('pointerdown',()=>tryCommanderJump());
1325
  $('abBarrage').addEventListener('pointerdown',()=>tryArtilleryBarrage());
1326
  if($('abClass')) $('abClass').addEventListener('pointerdown',()=>tryClassAbility());
src/styles/ui.css CHANGED
@@ -9,7 +9,13 @@
9
  --red:#ff5d43; --gold:#ffd257; --green:#5dff9a;
10
  /* Capacitor injects --safe-area-inset-* on Android WebView versions where
11
  CSS env() reports zero. Keep env() as the browser/iOS fallback. */
12
- --sat:var(--safe-area-inset-top,env(safe-area-inset-top,0px));
 
 
 
 
 
 
13
  --sar:var(--safe-area-inset-right,env(safe-area-inset-right,0px));
14
  --sab:var(--safe-area-inset-bottom,env(safe-area-inset-bottom,0px));
15
  --sal:var(--safe-area-inset-left,env(safe-area-inset-left,0px));
@@ -517,7 +523,8 @@ canvas#gl{position:absolute;inset:0;width:100%;height:100%;display:block;touch-a
517
  .goData small{display:block;color:#7fb29c;font:600 9px var(--fU);letter-spacing:.03em}.goData.muted{color:#7f9aab;font-size:10px}
518
 
519
  /* ---------- META HEADER ---------- */
520
- #metaHead{display:flex;align-items:center;gap:10px;width:min(92vw,384px);border-radius:13px;padding:8px 13px;
 
521
  border:1px solid transparent;background:var(--panelG) padding-box,var(--steelB) border-box;
522
  box-shadow:0 4px 14px rgba(0,0,0,.55),inset 0 1px 0 rgba(255,255,255,.1);
523
  clip-path:polygon(11px 0,calc(100% - 11px) 0,100% 11px,100% calc(100% - 11px),calc(100% - 11px) 100%,11px 100%,0 calc(100% - 11px),0 11px)}
@@ -600,7 +607,12 @@ canvas#gl{position:absolute;inset:0;width:100%;height:100%;display:block;touch-a
600
  #profileScr,#settingsScr,#setupScr{display:none;justify-content:flex-start;padding-top:calc(var(--sat) + 16px)}
601
  #setupScr{background:
602
  radial-gradient(ellipse at 50% -10%,rgba(28,68,104,.55),transparent 60%),rgba(3,7,14,.94)}
603
- #startScreen{justify-content:center;padding-top:calc(var(--sat) + 10px)}
 
 
 
 
 
604
  #startScreen .title{margin-top:14px}
605
  .menuGrid{display:grid;grid-template-columns:1fr 1fr;gap:9px;width:min(88vw,340px);margin-top:12px}
606
  .gbtn{display:flex;align-items:center;justify-content:center;gap:8px;min-height:54px;padding:13px 8px;border-radius:11px;
@@ -1777,7 +1789,7 @@ body.godMode #godBadge{display:flex}
1777
  body.uiPrimaryOpen .menuRoleInspect{min-height:27px;margin-top:auto;padding:4px 8px}
1778
  }
1779
  @media (max-width:380px) and (orientation:portrait){
1780
- /* On 320–360px phones the live rates are the only expendable text. Values,
1781
  resource identities, and both 44px command targets always remain visible. */
1782
  #topbar .res .rate{display:none}
1783
  #topbar{grid-template-columns:repeat(3,minmax(0,1fr)) 44px 44px}
@@ -2010,3 +2022,33 @@ body.godMode #godBadge{display:flex}
2010
  background:#5be69f;box-shadow:0 0 7px rgba(91,230,159,.9)}
2011
  #inboxBtn{position:absolute}
2012
  body.menuMode #inboxHudBtn{display:none}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  --red:#ff5d43; --gold:#ffd257; --green:#5dff9a;
10
  /* Capacitor injects --safe-area-inset-* on Android WebView versions where
11
  CSS env() reports zero. Keep env() as the browser/iOS fallback. */
12
+ --satRaw:var(--safe-area-inset-top,env(safe-area-inset-top,0px));
13
+ /* Android WebView reports env(safe-area-inset-top) as 0px unless the activity
14
+ opts into cutout mode, so relying on it alone painted the OS status bar —
15
+ clock, wifi, battery — directly on top of the menu header. A floor keeps the
16
+ UI clear of it everywhere; on a device that DOES report a real inset (an
17
+ iPhone notch at 47px) max() leaves that untouched. */
18
+ --sat:max(var(--satRaw),26px);
19
  --sar:var(--safe-area-inset-right,env(safe-area-inset-right,0px));
20
  --sab:var(--safe-area-inset-bottom,env(safe-area-inset-bottom,0px));
21
  --sal:var(--safe-area-inset-left,env(safe-area-inset-left,0px));
 
523
  .goData small{display:block;color:#7fb29c;font:600 9px var(--fU);letter-spacing:.03em}.goData.muted{color:#7f9aab;font-size:10px}
524
 
525
  /* ---------- META HEADER ---------- */
526
+ #metaHead{display:grid;grid-template-columns:auto minmax(0,1fr) auto;grid-template-areas:'icon name mail' 'res res res';
527
+ align-items:center;gap:8px 10px;width:min(92vw,384px);border-radius:13px;padding:9px 12px 10px;
528
  border:1px solid transparent;background:var(--panelG) padding-box,var(--steelB) border-box;
529
  box-shadow:0 4px 14px rgba(0,0,0,.55),inset 0 1px 0 rgba(255,255,255,.1);
530
  clip-path:polygon(11px 0,calc(100% - 11px) 0,100% 11px,100% calc(100% - 11px),calc(100% - 11px) 100%,11px 100%,0 calc(100% - 11px),0 11px)}
 
607
  #profileScr,#settingsScr,#setupScr{display:none;justify-content:flex-start;padding-top:calc(var(--sat) + 16px)}
608
  #setupScr{background:
609
  radial-gradient(ellipse at 50% -10%,rgba(28,68,104,.55),transparent 60%),rgba(3,7,14,.94)}
610
+ /* `safe center` instead of plain `center`. A centred flex column that overflows
611
+ clips its START — on a 640px-tall screen the menu header sat at -3.6px, above
612
+ the top of the viewport and unreachable, which is the same "UI under the OS
613
+ chrome" complaint in a different disguise. `safe` falls back to flex-start the
614
+ moment the content no longer fits, so nothing is ever pushed off the top. */
615
+ #startScreen{justify-content:safe center;padding-top:calc(var(--sat) + 10px)}
616
  #startScreen .title{margin-top:14px}
617
  .menuGrid{display:grid;grid-template-columns:1fr 1fr;gap:9px;width:min(88vw,340px);margin-top:12px}
618
  .gbtn{display:flex;align-items:center;justify-content:center;gap:8px;min-height:54px;padding:13px 8px;border-radius:11px;
 
1789
  body.uiPrimaryOpen .menuRoleInspect{min-height:27px;margin-top:auto;padding:4px 8px}
1790
  }
1791
  @media (max-width:380px) and (orientation:portrait){
1792
+ /* On 320–360px phones the live rates are the only expendable text. Values,
1793
  resource identities, and both 44px command targets always remain visible. */
1794
  #topbar .res .rate{display:none}
1795
  #topbar{grid-template-columns:repeat(3,minmax(0,1fr)) 44px 44px}
 
2022
  background:#5be69f;box-shadow:0 0 7px rgba(91,230,159,.9)}
2023
  #inboxBtn{position:absolute}
2024
  body.menuMode #inboxHudBtn{display:none}
2025
+
2026
+
2027
+ /* ── Menu header: profile icon + level, resources underneath ───────────────
2028
+ Grid areas rather than a flex row, so the mailbox can sit in the header's own
2029
+ top-right corner instead of floating over the numbers, and the resources get
2030
+ a full-width row of their own. */
2031
+ #rankEm{grid-area:icon;position:relative;display:flex;align-items:center;justify-content:center}
2032
+ #rankEmGlyph{font-size:30px;line-height:1;display:block;filter:drop-shadow(0 0 6px rgba(255,210,87,.5))}
2033
+ /* The account level, as a badge on the corner of the portrait. */
2034
+ #rankLvl{position:absolute;right:-3px;bottom:-3px;min-width:19px;height:19px;padding:0 4px;
2035
+ display:flex;align-items:center;justify-content:center;border-radius:10px;
2036
+ font-family:var(--fT);font-size:10px;font-weight:800;line-height:1;color:#08131d;
2037
+ background:linear-gradient(180deg,#ffe9a3,#e0b757);border:1px solid rgba(10,20,30,.65);
2038
+ box-shadow:0 1px 4px rgba(0,0,0,.6)}
2039
+ #rankGreet{grid-area:name}
2040
+ #inboxBtn{grid-area:mail;position:static;width:40px;height:40px;flex:0 0 auto}
2041
+ #metaRes{grid-area:res;display:grid;grid-template-columns:1fr 1fr;gap:8px;
2042
+ padding-top:8px;border-top:1px solid rgba(120,170,205,.18)}
2043
+ .metaResCell{display:flex;align-items:baseline;justify-content:center;gap:6px;min-width:0}
2044
+ .metaResCell .lbl{font-size:8.5px;letter-spacing:.16em;color:#7fa8c4;font-weight:700;flex:0 0 auto}
2045
+ .metaResCell b{font-family:var(--fT);font-size:15px;color:#8be8ff;font-variant-numeric:tabular-nums;
2046
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2047
+ .metaResCell:last-child b{font-size:13px;color:#cfe8ff}
2048
+ #metaRec{margin:0;font-size:inherit;color:inherit}
2049
+ #rankEm .badgePortrait{width:40px;height:40px;border-radius:50%}
2050
+ @media (max-width:430px){
2051
+ #rankEmGlyph{font-size:26px}
2052
+ #rankEm .badgePortrait{width:36px;height:36px}
2053
+ .metaResCell b{font-size:14px}
2054
+ }
src/ui/hud.js CHANGED
@@ -1539,8 +1539,10 @@ function updateHUD(fps){
1539
  $('xpFill').style.width=(heroXp/heroXpNext*100)+'%';
1540
  $('heroLvlBadge').textContent=heroLvl;
1541
  } else $('heroBar').style.display='none';
1542
- const btns=[$('abOver'),$('abHeal'),$('abRage'),$('abLance')];
1543
- for(let k=0;k<4;k++){
 
 
1544
  const cd=btns[k].querySelector('.cdring');
1545
  if(!abUnlock[k]){ btns[k].classList.add('cd'); cd.style.display='flex'; cd.textContent='🔒'; }
1546
  else if(abCool[k]>0){ btns[k].classList.add('cd'); cd.style.display='flex'; cd.textContent=Math.ceil(abCool[k]); }
 
1539
  $('xpFill').style.width=(heroXp/heroXpNext*100)+'%';
1540
  $('heroLvlBadge').textContent=heroLvl;
1541
  } else $('heroBar').style.display='none';
1542
+ /* Length-driven, not a hardcoded 4. The EMP module added a fifth ability and
1543
+ the old literal silently left it out of the cooldown/lock rendering. */
1544
+ const btns=[$('abOver'),$('abHeal'),$('abRage'),$('abLance'),$('abEmp')].filter(Boolean);
1545
+ for(let k=0;k<btns.length;k++){
1546
  const cd=btns[k].querySelector('.cdring');
1547
  if(!abUnlock[k]){ btns[k].classList.add('cd'); cd.style.display='flex'; cd.textContent='🔒'; }
1548
  else if(abCool[k]>0){ btns[k].classList.add('cd'); cd.style.display='flex'; cd.textContent=Math.ceil(abCool[k]); }
src/updater.js CHANGED
@@ -29,7 +29,7 @@
29
  ============================================================================ */
30
 
31
  /* Bumped by the release script. Compared against the manifest's `version`. */
32
- const APP_VERSION = '1.32.23';
33
 
34
  /* The channel URL in update-config.json remains publisher-configurable, but a
35
  production checker also needs one known-good recovery path. More importantly,
 
29
  ============================================================================ */
30
 
31
  /* Bumped by the release script. Compared against the manifest's `version`. */
32
+ const APP_VERSION = '1.32.24';
33
 
34
  /* The channel URL in update-config.json remains publisher-configurable, but a
35
  production checker also needs one known-good recovery path. More importantly,