/* ============================================================================ ORGANIC FX — Brood ichor / bile / violet-black fluid ---------------------------------------------------------------------------- Trail/smoke/energy live in gpufx.js and the CPU particle switch. This file owns GROWN-tissue liquid only: droplets, short puddles, and wisps. Why not gpfxBurst: that path is sparks/embers. A lime GPU fountain reads as the same energy Nova/Legion already spray. Ichor has to sit on the ground as a wet stain with a sheen, not a point-sprite firework. Civic rule: never addCrater / addGroundBurn / deformTerrain for ichor. Pavement already has a soot carpet; a crater-sprite stain reads as a broken placement preview. Puddles are billboards, not terrain decals. Mechanical factions stay sparks/oil/energy. unitIsBrood / horde kit only. ============================================================================ */ const ORGFX_CAP=384, ORGFX_DROP=0, ORGFX_SPLAT=1, ORGFX_WISP=2; const orgX=new Float32Array(ORGFX_CAP), orgY=new Float32Array(ORGFX_CAP); const orgZ=new Float32Array(ORGFX_CAP), orgVx=new Float32Array(ORGFX_CAP); const orgVy=new Float32Array(ORGFX_CAP), orgVz=new Float32Array(ORGFX_CAP); const orgLife=new Float32Array(ORGFX_CAP), orgMax=new Float32Array(ORGFX_CAP); const orgSize=new Float32Array(ORGFX_CAP), orgRot=new Float32Array(ORGFX_CAP); const orgAsp=new Float32Array(ORGFX_CAP); const orgR=new Uint8Array(ORGFX_CAP), orgG=new Uint8Array(ORGFX_CAP), orgB=new Uint8Array(ORGFX_CAP); const orgKind=new Uint8Array(ORGFX_CAP); let orgHead=0, orgN=0, orgfxOn=0, orgfxHurtT=null; /* Fallback if BRD_ICHOR / INF_ICHOR have not loaded. Same triples as the brood slime tile and infestation mucus/sac/glow — not human crimson. */ const ORGFX_PAL=[ [[115,177,77],[62,88,30],[185,255,72]], [[214,168,40],[78,48,12],[255,220,110]], [[186,82,245],[48,18,64],[220,150,255]] ]; function orgfxQ(){ return (typeof mfVfxQ==='function')?mfVfxQ() :((typeof GFX!=='undefined'&&GFX.particles!=null)?GFX.particles:1); } function orgfxH(x,y){ return (typeof terrainH==='function')?terrainH(x,y):0.4; } function orgfxCaste(sz,name){ const s=String(name||''); if(s.indexOf('Nest')>=0||s.indexOf('Sovereign')>=0||s.indexOf('Hive')>=0 ||s.indexOf('Tidecaster')>=0) return 2; if(s.indexOf('Bile')>=0||s.indexOf('Acid')>=0||s.indexOf('Spore')>=0 ||s.indexOf('Geyser')>=0||s.indexOf('Toxic')>=0) return 1; return sz>=28?2:sz>=18?1:0; } function orgfxPal(caste){ const c=caste|0; if(typeof BRD_ICHOR!=='undefined'&&BRD_ICHOR[c]&&BRD_ICHOR[c][0]) return BRD_ICHOR[c]; if(c===0&&typeof INF_ICHOR!=='undefined'&&INF_ICHOR.wet) return [INF_ICHOR.wet,INF_ICHOR.dark,INF_ICHOR.hi]; return ORGFX_PAL[c]||ORGFX_PAL[0]; } function orgfxUnitOrganic(i){ if(typeof unitIsBrood==='function') return unitIsBrood(i); return !!(typeof TYPES!=='undefined'&&TYPES[utype[i]]&&TYPES[utype[i]].brood) || uteam[i]===2; } function orgfxBldOrganic(B){ if(!B) return false; if(B.team===2||B.type==='nest') return true; if(typeof bldFactionKit==='function') return bldFactionKit(B)==='horde'; if(B.team===1&&typeof AI!=='undefined'&&AI&&AI.fac==='horde') return true; const pf=(typeof playerFaction!=='undefined'&&playerFaction)||''; return B.team===0&&(pf==='horde'||pf==='brood'); } function orgfxStrategic(){ return (typeof orthoSpan==='number'&&orthoSpan>900); } function orgfxOverview(){ return (typeof orthoSpan==='number'&&orthoSpan>2400); } function orgfxAdd(kind,x,y,z,vx,vy,vz,life,size,r,g,b,rot,asp){ if(orgN>=ORGFX_CAP) return; const i=orgHead; orgHead=(orgHead+1)%ORGFX_CAP; if(!orgLife[i]) orgN++; orgKind[i]=kind; orgX[i]=x; orgY[i]=y; orgZ[i]=z; orgVx[i]=vx; orgVy[i]=vy; orgVz[i]=vz; orgLife[i]=life; orgMax[i]=life; orgSize[i]=size; orgRot[i]=rot||0; orgAsp[i]=asp>0.05?asp:1; orgR[i]=r; orgG[i]=g; orgB[i]=b; } function orgfxBurst(x,y,size,dirX,dirY,death){ const q=orgfxQ(); /* LOW particles is 0.5. Below ~0.28 is "FX off" leftovers. */ if(q<0.28) return; const strat=orgfxStrategic(), over=orgfxOverview(); const caste=arguments.length>6?arguments[6]:orgfxCaste(size); const pal=orgfxPal(caste); const wet=pal[0], dark=pal[1], hi=pal[2]; const floor=orgfxH(x,y); const hasDir=dirX*dirX+dirY*dirY>0.01; const dl=hasDir?Math.hypot(dirX,dirY):1; const dx=hasDir?dirX/dl:0, dy=hasDir?dirY/dl:0; /* Preset split: LOW (q<0.65) tinted puffs only — no pools, no beads MEDIUM (q<0.95) few large splats, still readable HIGH+ volume + wet sheen + lingering pools Strategic zoom keeps wisps; a puddle field vanishes into noise. */ let nDrop, nSplat, nWisp; if(over){ nDrop=0; nSplat=0; nWisp=death?2:1; } else if(strat){ nDrop=death?2:0; nSplat=0; nWisp=death?3:1; } else if(q<0.65){ nDrop=0; nSplat=0; nWisp=death?3:2; } else if(q<0.95){ nDrop=death?5:3; nSplat=death?3:2; nWisp=death?2:1; } else { nDrop=death?(q>=1.25?12:8):(q>=1.25?6:5); nSplat=death?(q>=1.25?6:4):3; nWisp=death?(q>=1.25?3:2):1; } const scale=Math.max(10,size); for(let k=0;k=1; const splatN=civic?Math.max(1,nSplat-1):nSplat; /* HIGH lingering pools. MEDIUM shorter but still a wet mark. Civic stays a stain, never a crater-sprite carpet. */ const splatLife=civic?(q>=0.95?1.6:0.85) :(q>=1.25?4.0:q>=0.95?2.8:1.25); for(let k=0;know) return; if(orgfxHurtT) orgfxHurtT[j]=now+(q>=0.95?0.08:0.14); /* HIGH: almost every real hit sprays. MEDIUM must still read as liquid. */ const keep=q>=0.95?0.92:q>=0.65?0.78:0.34; if(Math.random()>keep) return; const T=TYPES[utype[j]], sz=T&&T.size||12; let dx=0,dy=0; if(attacker>=0&&ualive[attacker]){ dx=ux[j]-ux[attacker]; dy=uy[j]-uy[attacker]; } /* Rim of the carapace facing the shot — hull-center spray looked like a green energy fountain sitting on the navel. */ const il=Math.hypot(dx,dy)||1; orgfxHit(ux[j]-(dx/il)*sz*0.42, uy[j]-(dy/il)*sz*0.42, sz, dx, dy, orgfxCaste(sz,T&&T.name)); } function orgfxOnDeath(x,y,size,name){ orgfxDeath(x,y,size,orgfxCaste(size,name)); } function orgfxOnBld(B,dmg,died){ if(!orgfxBldOrganic(B)||!B||B.x==null) return; const sz=(typeof BT!=='undefined'&&BT[B.type]&&BT[B.type].size)||B.r*2||24; const caste=orgfxCaste(sz,B.type); if(died){ orgfxDeath(B.x,B.y,sz,caste); return; } if(!(dmg>=10)||Math.random()>0.45) return; orgfxHit(B.x,B.y,sz,0,0,caste); } function orgfxSeep(x,y,size){ const q=orgfxQ(); if(q<0.95||orgfxStrategic()) return; const pal=orgfxPal(orgfxCaste(size)); const wet=pal[0], floor=orgfxH(x,y); const a=Math.random()*TAU, spd=6+Math.random()*8; orgfxAdd(ORGFX_DROP,x,y,floor+Math.max(8,size)*0.38, Math.cos(a)*spd,Math.sin(a)*spd,8+Math.random()*10, 0.36+Math.random()*0.18, 5.4+Math.random()*1.8, wet[0],wet[1],wet[2], a, 1.06); } function orgfxTick(dt){ if(!orgN) return; dt=Math.min(dt,0.05); for(let i=0;i=0.65&&Math.random()<(q>=0.95?0.72:0.38)){ orgKind[i]=ORGFX_SPLAT; orgLife[i]=q>=0.95?Math.max(orgLife[i],1.6):Math.max(orgLife[i],0.7); orgMax[i]=orgLife[i]; orgSize[i]=Math.min(8,orgSize[i]*1.55); orgAsp[i]=1.10+Math.random()*0.16; orgR[i]=orgR[i]*0.48|0; orgG[i]=orgG[i]*0.52|0; orgB[i]=orgB[i]*0.42|0; } else orgLife[i]=Math.min(orgLife[i],0.10); } } else { orgVz[i]*=0.94; orgVx[i]*=0.96; orgVy[i]*=0.96; } } } function orgfxEnqueue(){ if(!orgN||typeof bbAdd==='undefined'||!bbAdd) return; if(typeof sprites==='undefined'||!sprites.glow) return; const glow=sprites.glow, smoke=sprites.smoke||glow; const q=orgfxQ(), strat=orgfxStrategic(), over=orgfxOverview(); const t=(typeof performance!=='undefined')?performance.now()*0.001:0; const addRect=bbAlpha.addOrientedRect?bbAlpha.addOrientedRect.bind(bbAlpha):null; const addGlow=bbAdd.addOrientedRect?bbAdd.addOrientedRect.bind(bbAdd):null; for(let i=0;ilim*lim) continue; } if(typeof fogPointVisible==='function'&&!fogPointVisible(X,Y)) continue; const lf=orgLife[i]/orgMax[i], k=orgKind[i], H=orgZ[i]; const rot=orgRot[i], asp=orgAsp[i]||1, sz=orgSize[i]; if(k===ORGFX_SPLAT){ if(strat) continue; /* Dark elliptical stain + saturated wet body + moving sheen. Sheen is HIGH-only so MEDIUM stays a cheaper readable slick. */ const fade=Math.min(1,lf*1.15); const aStain=Math.min(200,175*fade); const w=sz*asp, h=sz; if(addRect) addRect.call(bbAlpha,smoke,X,Y,H,w,h,rot,orgR[i],orgG[i],orgB[i],aStain); else bbAlpha.add(smoke,X,Y,H,sz*(1.05+(1-lf)*0.3),rot,orgR[i],orgG[i],orgB[i],aStain); const wr=Math.min(255,orgR[i]+48), wg=Math.min(255,orgG[i]+62), wb=Math.min(255,orgB[i]+28); if(addRect) addRect.call(bbAlpha,glow,X,Y,H+0.12,w*0.70,h*0.64,rot,wr,wg,wb,88*fade); else bbAlpha.add(glow,X,Y,H+0.12,sz*0.62,rot,wr,wg,wb,95*fade); if(q>=0.95){ const shimmer=0.55+0.45*Math.sin(t*5.2+i); const ox=Math.cos(rot)*sz*0.12, oy=Math.sin(rot)*sz*0.12; if(addGlow) addGlow.call(bbAdd,glow,X+ox,Y+oy,H+0.22,w*0.38,h*0.22,rot, Math.min(255,wr+40),Math.min(255,wg+30),Math.min(255,wb+20),52*fade*shimmer); else bbAdd.add(glow,X+ox,Y+oy,H+0.22,sz*0.34,rot, Math.min(255,wr+40),Math.min(255,wg+30),Math.min(255,wb+20),52*fade*shimmer); } } else if(k===ORGFX_WISP){ const lift=H+(1-lf)*12; bbAlpha.add(smoke,X,Y,lift,sz*(0.95+(1-lf)*1.15),t*0.28+i, orgR[i],orgG[i],orgB[i], (strat?80:130)*lf); } else { /* In-flight bead: circular body + wet core. Aspect stays ~1. */ const w=sz*asp, h=sz; if(addRect) addRect.call(bbAlpha,smoke,X,Y,H-0.25,w*1.12,h*1.12,rot, orgR[i]*0.45|0,orgG[i]*0.5|0,orgB[i]*0.4|0,150*lf); else bbAlpha.add(smoke,X,Y,H-0.25,sz*1.35,rot, orgR[i]*0.45|0,orgG[i]*0.5|0,orgB[i]*0.4|0,150*lf); if(addGlow) addGlow.call(bbAdd,glow,X,Y,H,w*0.92,h*0.92,rot,orgR[i],orgG[i],orgB[i],230*lf); else bbAdd.add(glow,X,Y,H,sz,rot,orgR[i],orgG[i],orgB[i],230*lf); } } } function orgfxInstall(){ if(orgfxOn) return; orgfxOn=1; if(typeof MAXU==='number'&&!orgfxHurtT) orgfxHurtT=new Float32Array(MAXU); } (function orgfxHook(){ if(typeof beginBB==='function'){ const prev=beginBB; beginBB=function(tex){ orgfxInstall(); const r=prev.apply(this,arguments); /* Enqueue after beginBB so frustum cull matches this camera. */ if(!tex) orgfxEnqueue(); return r; }; } if(typeof gpfxFrame==='function'){ const prev=gpfxFrame; gpfxFrame=function(dt,matVP,viewH){ orgfxInstall(); const r=prev.apply(this,arguments); orgfxTick((typeof dt==='number'&&dt>0)?dt:0.016); return r; }; } })();