; ; /* ============================================================================ RUMBLE — camera shake + device vibration for heavy chassis and big blasts ---------------------------------------------------------------------------- Isolated from render3d.js so terrain/HQ/tower work does not collide with the camera nudge. The renderer already applies the global `shake` as an eye-space jitter; this file only REQUESTS a value and fires haptics. Why a takeover instead of editing every sim.js `shake=` site: Nova/Legion FX authors own those numbers (particle size, gpfxBurst counts, super pow). We wrap the events, then replace an unscaled assignment with a distance- and quality-scaled request so a far-off TITAN does not rattle the phone. ============================================================================ */ const RUMBLE_STEP_R=720; const RUMBLE_BLAST_R=1400; let rumbleReduced=false; let rumbleLastHap=0; let rumbleStepHap=0; const rumbleAcc=typeof MAXU==='number'?new Float32Array(MAXU):new Float32Array(1); function rumbleReadReduced(){ try{ rumbleReduced=!!(window.matchMedia&&matchMedia('(prefers-reduced-motion: reduce)').matches); }catch(e){ rumbleReduced=false; } } rumbleReadReduced(); if(typeof matchMedia==='function'){ try{ const mq=matchMedia('(prefers-reduced-motion: reduce)'); const on=()=>{ rumbleReadReduced(); if(typeof applySettings==='function') applySettings(); }; if(mq.addEventListener) mq.addEventListener('change',on); else if(mq.addListener) mq.addListener(on); }catch(e){} } function rumbleGfxMul(){ /* MEDIUM/LOW must actually cheapen the kick — a 16-unit blast on a mid preset is the same fill-rate tax as HIGH if we only skip particles. */ const q=typeof qualityKey==='function'?qualityKey():((typeof META!=='undefined'&&META.settings&&META.settings.quality)||'high'); return q==='low'?0.42:q==='medium'?0.68:q==='cinematic'?1.08:1; } function rumbleDistMul(x,y,reach){ if(typeof cam==='undefined'||typeof x!=='number') return 1; const d=Math.hypot(x-cam.x,y-cam.y); if(d>=reach) return 0; const t=1-d/reach; return t*t; } function rumbleHaptic(ms,pattern){ if(rumbleReduced) return; if(typeof META!=='undefined'&&META.settings&&META.settings.haptics===false) return; const now=performance.now(); if(now-rumbleLastHap<72) return; rumbleLastHap=now; const payload=pattern||ms; try{ const H=window.Capacitor&&Capacitor.Plugins&&Capacitor.Plugins.Haptics; if(H){ if(H.impact){ const style=ms>=40?'HEAVY':ms>=22?'MEDIUM':'LIGHT'; H.impact({style:style}); return; } if(H.vibrate){ H.vibrate({duration:typeof ms==='number'?ms:40}); return; } } }catch(e){} try{ if(navigator.vibrate) navigator.vibrate(payload); }catch(e){} } function requestShake(x,y,power,kind){ if(!(power>0)) return; const step=kind==='step'; const settings=typeof META!=='undefined'&&META.settings; if(settings&&settings.shake===false) return; if(typeof shakeMult==='number'&&shakeMult<=0) return; if(rumbleReduced&&step) return; if(step&&typeof camDist==='number'&&camDist>2200) return; const reach=step?RUMBLE_STEP_R:RUMBLE_BLAST_R; const mul=rumbleDistMul(x,y,reach)*rumbleGfxMul(); const amt=power*mul; if(amt<0.35) return; if(typeof shake==='number') shake=Math.max(shake,amt); if(step){ const t=performance.now(); if(t-rumbleStepHap<90) return; rumbleStepHap=t; rumbleHaptic(amt>=2.6?18:12); }else{ rumbleHaptic(amt>=12?55:amt>=7?36:22, amt>=12?[40,28,48]:null); } } function rumbleHeavyWeight(type,T){ if(!T) return 0; if(type===8) return 1; if(T.cat==='hero'||T.hero) return 0.82; if(typeof MF_UT_MASSFLESH==='number'&&(type===MF_UT_MASSFLESH||type===MF_UT_MASSFLESH_AIR)) return 0.9; if(typeof MF_UT_AIRLIFT==='number'&&type===MF_UT_AIRLIFT) return 0.55; if(T.name==='Basilisk'||T.name==='Harbinger') return 0.7; if(T.name==='Goliath'||T.name==='Alpha Ravager') return 0.5; if(T.cat==='exp'&&T.size>=24) return 0.7; if(T.legs&&T.size>=20&&(T.tier|0)>=2) return 0.45; return 0; } function rumbleUnitMove(i,T,travel,prevWalk){ const w=rumbleHeavyWeight(utype[i],T); if(!w||!(travel>0.02)) return; if(T.legs){ /* Plant when the gait sine falls through zero — one foot down, matching the vertex bob (abs(sin)) that hits twice per TAU. */ const s0=Math.sin(prevWalk), s1=Math.sin(uwalk[i]); if(!(s0>0&&s1<=0)) return; const power=(T.size>=40?3.4:T.cat==='hero'?2.6:2.0)*w; requestShake(ux[i],uy[i],power,'step'); }else{ rumbleAcc[i]+=travel; const period=T.air?28:18; if(rumbleAcc[i]0&&shake>=raw&&shake<=raw+0.01)) shake=prevShake; } (function rumbleTakeover(){ if(typeof applySettings==='function'){ const prev=applySettings; applySettings=function(){ prev.apply(this,arguments); /* Settings already has Impact Camera Shake. prefers-reduced-motion minimizes the leftover unwrapped `shake=` sites (rifle, buildings) without adding a second toggle. */ if(rumbleReduced&&typeof shakeMult==='number') shakeMult*=0.25; }; } if(typeof killUnit==='function'){ const prev=killUnit; killUnit=function(i,silent){ const t=utype[i], x=ux[i], y=uy[i]; const T=TYPES[t]; const titanOrCdr=t===8||t===4; const big=titanOrCdr||(T&&(T.cat==='hero'||T.hero||T.size>=40)); const prevShake=shake; prev.apply(this,arguments); if(!big) return; if(titanOrCdr) rumbleUndoRaw(prevShake,16); requestShake(x,y,titanOrCdr?16:14,'blast'); }; } if(typeof novaFire==='function'){ const prev=novaFire; novaFire=function(b,wx,wy){ const prevShake=shake; const r=prev.apply(this,arguments); if(r){ rumbleUndoRaw(prevShake,22); requestShake(wx,wy,22,'blast'); } return r; }; } if(typeof superDetonation==='function'){ const prev=superDetonation; superDetonation=function(x,y,pow,byTeam){ const prevShake=shake; const r=prev.apply(this,arguments); shake=prevShake; requestShake(x,y,14*(pow||1),'blast'); return r; }; } if(typeof updateSingularities==='function'){ const prev=updateSingularities; updateSingularities=function(dt){ const collapsing=[]; if(typeof singularities!=='undefined'){ for(let i=0;i=2) requestShake(S.x,S.y,16*(S.pow||1),'blast'); } }; } if(typeof projImpact==='function'){ const prev=projImpact; projImpact=function(i){ const cluster=ptype[i]===9&&!pSplit[i]; const x=px[i], y=py[i]; prev.apply(this,arguments); if(cluster) requestShake(x,y,8,'blast'); }; } if(typeof spawnExplosion==='function'){ const prev=spawnExplosion; spawnExplosion=function(x,y,size,victimTeam){ const r=prev.apply(this,arguments); /* Superweapons (size>=40) already went through superDetonation. This catches Atlas/Massflesh-scale hulls that stay under that handoff. */ if(size>=30&&size<40) requestShake(x,y,6+size*0.18,'blast'); return r; }; } if(typeof damageBld==='function'){ const prev=damageBld; damageBld=function(b,dmg,attTeam){ const B=blds[b]; const typ=B&&B.type, x=B&&B.x, y=B&&B.y, was=B&&B.alive; const r=prev.apply(this,arguments); if(was&&B&&!B.alive&&(typ==='nova'||typ==='silo')) requestShake(x,y,typ==='nova'?12:8,'blast'); return r; }; } })();