Spaces:
Running
Running
File size: 51,652 Bytes
84bc42a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 | /* ============================================================================
MASSFRONT — RIGID BODY PHYSICS
----------------------------------------------------------------------------
WHAT THIS IS, AND WHAT IT DELIBERATELY IS NOT
This is a compact impulse-based rigid-body solver: real mass, a real inertia
tensor, real angular velocity carried on a quaternion, and contacts resolved
with normal + Coulomb-friction impulses applied AT THE CONTACT POINT — which
is the whole reason a piece of rubble tumbles instead of sliding flat. It is
NOT a general physics engine and it is not trying to be one. It runs for
DESTRUCTION ONLY: debris thrown off a hit, the pieces a destroyed structure
breaks into, and the shove an explosion gives to both.
Everything else in this game deliberately stays parametric:
* unit movement and pathing (flow field, thousands of agents)
* projectiles (analytic arcs)
* sparks / embers / smoke / fire (the type 0-6,8-10 particle ring)
* the textured shatter shards (shX/shY in sim.js — billboards)
* terrain deformation and water
A full rigid-body sim per unit would cost more than the entire rest of the
frame and would buy nothing the player can see. Impactful destruction is the
goal; a physics engine is not.
WHY A PURPOSE-BUILT SOLVER AND NOT RAPIER/WASM
1. The OTA path is TEXT ONLY. src/updater.js does
`out[f.path]=new TextDecoder().decode(bytes)`, boot.js's validBundle()
requires `typeof b.files[path]==='string'`, and runBundle() hands each
one to `new Blob([src],{type:'text/javascript'})`. A .wasm cannot
survive that round trip — TextDecoder mangles non-UTF-8 bytes
irreversibly. Shipping wasm would mean base64 inside a .js (+33%,
~3.4 MB for rapier3d) listed in assets/data/manifest.json's `order`,
which tools/publish-hf-release.ps1 gates byte-for-byte.
2. Instantiating wasm is ASYNCHRONOUS. Every system here self-initialises
synchronously inside one global scope in manifest order, and main.js's
init loop has no await point. There is nowhere for `await init()` to
live without restructuring the boot contract.
3. The body count that matters is ~100-200 pieces of debris, not 10k. What
Rapier is good at (broad phase, convex-convex, joints, islands) is not
what this needs; what this needs is heightfield contact, which is 30
lines. The JS<->wasm transform marshalling per body per step on an
Android WebView is a real cost that would eat the theoretical win.
4. APK size is actively being fought (tools/shrink-apk.ps1). 1.5 MB of
binary for tumbling rubble is a bad trade.
BUDGET (measured, see mfPhysStats().stepMs)
perfScale <= 0.34 -> 40 bodies
perfScale <= 0.50 -> 96 bodies (the target device sits here, 0.4125)
otherwise -> 224 bodies
Sleeping bodies cost approximately nothing: they are skipped by the
integrator, the contact solver and the ground query alike.
RENDERING
This file does not own a shader and does not touch the renderer's files.
Each body is drawn as a small CLUSTER of chunk instances pushed into an
existing lit instanced stream (FX.wreck — solid pass, depth tested, already
a CSM shadow caster; FX.rock and FX.crate are the fallbacks). The cluster's
chunk positions are transformed by the
body's FULL rotation matrix, so three-axis tumble is genuinely visible even
though the instance stream carries only a yaw. That is a deliberate
workaround: instance attribute 10 is taken by Material V2 surface state and
the vertex stage only knows `aYaw` (src/engine/mesh.js). Attribute slots
11-15 are free; see the report for the one-attribute change that would let a
single instance carry a full orientation.
InstMesh accumulates until flush() and flush() zeroes n, so instances added
before render() reaches its flush are drawn. mfPhysEmit() therefore runs
once per FRAME (not per sim step — the accumulator in main.js can take zero
steps in a frame, which would strobe the debris).
============================================================================ */
/* ---------------------------------------------------------------------------
Pool. Structure-of-arrays: the step touches BZ/BVZ for every body and
nothing else, so keeping them in separate typed arrays is what makes a
sleeping field free.
--------------------------------------------------------------------------- */
const MFPHYS_MAX = 256; // hard pool ceiling (allocation, not budget)
const MFPHYS_CHUNKS = 6; // render chunks per body
const MFPHYS_EVENT_MAX = 3; // one grouped debris layer = at most 3 readable slabs
const MFPHYS_G = 290; // wu/s^2 — matches SH_G / DEBRIS_G exactly
const MFPHYS_MAX_V = 650; // cosmetic rubble must never become a simulation projectile
const MFPHYS_MAX_W = 32; // rad/s; above this rotation aliases at RTS scale
const mfpX = new Float32Array(MFPHYS_MAX), mfpY = new Float32Array(MFPHYS_MAX), mfpZ = new Float32Array(MFPHYS_MAX);
const mfpVX = new Float32Array(MFPHYS_MAX), mfpVY= new Float32Array(MFPHYS_MAX), mfpVZ= new Float32Array(MFPHYS_MAX);
const mfpQX = new Float32Array(MFPHYS_MAX), mfpQY= new Float32Array(MFPHYS_MAX), mfpQZ= new Float32Array(MFPHYS_MAX), mfpQW=new Float32Array(MFPHYS_MAX);
const mfpWX = new Float32Array(MFPHYS_MAX), mfpWY= new Float32Array(MFPHYS_MAX), mfpWZ= new Float32Array(MFPHYS_MAX);
const mfpHX = new Float32Array(MFPHYS_MAX), mfpHY= new Float32Array(MFPHYS_MAX), mfpHZ= new Float32Array(MFPHYS_MAX);
const mfpIM = new Float32Array(MFPHYS_MAX); // inverse mass
const mfpIIX= new Float32Array(MFPHYS_MAX), mfpIIY=new Float32Array(MFPHYS_MAX), mfpIIZ=new Float32Array(MFPHYS_MAX); // inverse inertia, body diag
const mfpLife=new Float32Array(MFPHYS_MAX), mfpTTL=new Float32Array(MFPHYS_MAX);
const mfpRest=new Float32Array(MFPHYS_MAX), mfpFric=new Float32Array(MFPHYS_MAX);
const mfpSleepT=new Float32Array(MFPHYS_MAX);
const mfpR=new Uint8Array(MFPHYS_MAX), mfpG=new Uint8Array(MFPHYS_MAX), mfpB=new Uint8Array(MFPHYS_MAX);
const mfpState=new Uint8Array(MFPHYS_MAX); // 0 free, 1 awake, 2 asleep
const mfpNCh =new Uint8Array(MFPHYS_MAX);
const mfpTrail=new Uint8Array(MFPHYS_MAX); // render-only velocity line; never a sim particle
const mfpSeq =new Float64Array(MFPHYS_MAX); // spawn order, for eviction
/* Local ground plane cache: g(x,y) ~= g0 + gx*(x-ax) + gy*(y-ay).
terrainH() is nine rawH() taps; sampling it per corner per step would be the
single most expensive thing in this file. Three taps per body per refresh,
then every corner is two multiplies. */
const mfpG0=new Float32Array(MFPHYS_MAX), mfpGX=new Float32Array(MFPHYS_MAX), mfpGY=new Float32Array(MFPHYS_MAX);
const mfpAX=new Float32Array(MFPHYS_MAX), mfpAY=new Float32Array(MFPHYS_MAX);
const mfpGT=new Float32Array(MFPHYS_MAX); // seconds until the plane is re-sampled
/* Chunk offsets in BODY space + chunk radius: ox,oy,oz,r */
const mfpCh=new Float32Array(MFPHYS_MAX*MFPHYS_CHUNKS*4);
let mfpLive=0, mfpAwake=0, mfpSeqNext=1;
let mfpStepMs=0, mfpEmitMs=0, mfpChunksDrawn=0;
let mfpHooked=false, mfpEmittedFrame=-1, mfpFrame=0;
let mfpEnabled=true;
/* Physics is presentation-only, so it must not consume Math.random() and
perturb gameplay's random stream. A private xorshift32 makes destruction
reproducible for a map, event order and quality tier without adding any
save/replay/network field. mfPhysClear() re-seeds it at match reset. */
let mfpSeedBase=0x6d2b79f5, mfpRandState=mfpSeedBase;
const mfpAudit={
steps:0, spawns:0, retired:0, invalidRetires:0,
evictions:0, budgetTrims:0, motionClamps:0, groundQueries:0,
burstEvents:0, collapseEvents:0, blastEvents:0,
impulseEvents:0, impulseHits:0, rigidPieces:0,
attractEvents:0, attractHits:0, attractConsumed:0,
attractClamps:0, attractWakeups:0, attractPeakAccel:0,
groupClamps:0, maxGroup:0, maxLive:0, rngDraws:0,
emitCalls:0, emittedChunks:0, velocityTrails:0, pausedEmitSkips:0,
offscreenRetires:0, subpixelRetires:0, acceleratedLife:0
};
function mfPhysHashWord(h,v){
h^=v>>>0;
return Math.imul(h,16777619)>>>0;
}
function mfPhysWorldSeed(){
let h=2166136261>>>0;
const key=(typeof curMap==='string'&&curMap)?curMap:'massfront';
for(let i=0;i<key.length;i++) h=mfPhysHashWord(h,key.charCodeAt(i));
if(typeof MAPDEFS!=='undefined'&&MAPDEFS&&MAPDEFS[key]&&Number.isFinite(MAPDEFS[key].seed))
h=mfPhysHashWord(h,MAPDEFS[key].seed);
return h||0x6d2b79f5;
}
function mfPhysSeed(seed){
let s;
if(seed===undefined||seed===null) s=mfPhysWorldSeed();
else if(typeof seed==='number'&&Number.isFinite(seed)) s=seed>>>0;
else{
const key=String(seed); s=2166136261>>>0;
for(let i=0;i<key.length;i++) s=mfPhysHashWord(s,key.charCodeAt(i));
}
mfpSeedBase=(s>>>0)||0x6d2b79f5;
mfpRandState=mfpSeedBase;
return mfpSeedBase;
}
function mfPhysRand(){
let x=mfpRandState>>>0;
x^=x<<13; x^=x>>>17; x^=x<<5;
mfpRandState=(x>>>0)||0x6d2b79f5;
mfpAudit.rngDraws++;
return mfpRandState/4294967296;
}
function mfPhysResetAudit(){
for(const k in mfpAudit) mfpAudit[k]=0;
}
/* Sleep thresholds. Deliberately generous: rubble that keeps micro-jittering
reads as broken, and every sleeping body is a body the solver skips. */
const MFPHYS_SLEEP_V=2.20, MFPHYS_SLEEP_W=0.90, MFPHYS_SLEEP_T=0.34;
function mfPhysGround(x,y){
return (typeof terrainH==='function') ? terrainH(x,y) : 0;
}
/* How many bodies this device may keep alive at once. Read live rather than
cached: perfScale moves with load and with the graphics preset. */
function mfPhysBudget(){
const ps=(typeof perfScale==='number'&&perfScale>0)?perfScale:1;
if(ps<=0.34) return 40;
if(ps<=0.50) return 96;
return 224;
}
/* Camera-aware retirement is presentation policy only: it changes no damage,
collision, salvage or gameplay state. One camBounds sample is shared by the
complete step so the policy does not turn into a per-body layout query. */
let mfpViewClass=0,mfpViewPx=999;
function mfPhysViewSample(i,B){
const x=mfpX[i],y=mfpY[i],r=Math.max(mfpHX[i],mfpHY[i],mfpHZ[i]);
mfpViewClass=0;mfpViewPx=999;
if(B&&Number.isFinite(B.x0)&&Number.isFinite(B.x1)&&Number.isFinite(B.y0)&&Number.isFinite(B.y1)){
const dx=x<B.x0?B.x0-x:x>B.x1?x-B.x1:0;
const dy=y<B.y0?B.y0-y:y>B.y1?y-B.y1:0;
const w=Math.max(1,B.x1-B.x0),h=Math.max(1,B.y1-B.y0);
mfpViewClass=(dx<=60+r&&dy<=60+r)?0:(dx<=w*.42+120&&dy<=h*.42+120?1:2);
const vh=(typeof innerHeight==='number'&&innerHeight>0)?innerHeight:720;
mfpViewPx=r*2*vh/h;
}else if(typeof orthoSpan==='number'&&orthoSpan>0){
const vh=(typeof innerHeight==='number'&&innerHeight>0)?innerHeight:720;
mfpViewPx=r*2*vh/orthoSpan;
}
return mfpViewClass;
}
function mfPhysCamBounds(){
if(typeof camBounds!=='function')return null;
try{return camBounds()||null;}catch(err){return null;}
}
function mfPhysScaledTTL(i,base){
const ps=Math.max(.2,Math.min(1,(typeof perfScale==='number'&&perfScale>0)?perfScale:1));
const pressure=Math.max(0,Math.min(1,mfpLive/Math.max(1,mfPhysBudget())));
const view=mfPhysViewSample(i,mfPhysCamBounds());
let scale=(.70+.30*ps)*(1-.36*pressure*pressure);
if(view===1)scale*=.72;else if(view===2)scale*=.46;
if(mfpViewPx<1.5)scale*=.72;
return Math.max(1.35,base*scale);
}
function mfPhysSampleGround(i){
const x=mfpX[i], y=mfpY[i], d=6;
const g0=mfPhysGround(x,y);
mfpG0[i]=g0;
mfpGX[i]=(mfPhysGround(x+d,y)-g0)/d;
mfpGY[i]=(mfPhysGround(x,y+d)-g0)/d;
mfpAX[i]=x; mfpAY[i]=y;
mfpGT[i]=0.28+((i*0.0137)%0.22); // staggered so refreshes never bunch
mfpAudit.groundQueries+=3;
}
function mfPhysPlaneAt(i,x,y){
return mfpG0[i]+mfpGX[i]*(x-mfpAX[i])+mfpGY[i]*(y-mfpAY[i]);
}
/* ---------------------------------------------------------------------------
Allocation. When the pool is at budget the OLDEST body goes — sleeping ones
first, because a settled chunk of rubble disappearing behind the player is
far less noticeable than one vanishing mid-flight.
--------------------------------------------------------------------------- */
function mfPhysAlloc(){
const budget=mfPhysBudget();
let free=-1;
for(let i=0;i<MFPHYS_MAX;i++) if(mfpState[i]===0){ free=i; break; }
if(free>=0&&mfpLive<budget){ mfpLive++; return free; }
let victim=-1, best=Infinity, victimAsleep=false;
for(let i=0;i<MFPHYS_MAX;i++){
if(mfpState[i]===0) continue;
const asleep=mfpState[i]===2;
if(victimAsleep&&!asleep) continue;
if(asleep&&!victimAsleep){ victim=i; best=mfpSeq[i]; victimAsleep=true; continue; }
if(mfpSeq[i]<best){ victim=i; best=mfpSeq[i]; }
}
if(victim<0){
if(free>=0){ mfpLive++; return free; }
return -1;
}
mfpAudit.evictions++;
return victim;
}
/* perfScale can fall after a dense scene is already alive. Allocation alone
cannot enforce the new lower budget, so trim deterministically: oldest
sleeping rubble first, then oldest airborne rubble. This runs only on a
tier transition (normally one frame), not as a permanent O(n^2) cost. */
function mfPhysTrimToBudget(){
const budget=mfPhysBudget();
let removed=0;
while(mfpLive>budget){
let victim=-1, best=Infinity, victimAsleep=false;
for(let i=0;i<MFPHYS_MAX;i++){
if(mfpState[i]===0) continue;
const asleep=mfpState[i]===2;
if(victimAsleep&&!asleep) continue;
if(asleep&&!victimAsleep){ victim=i; best=mfpSeq[i]; victimAsleep=true; continue; }
if(mfpSeq[i]<best){ victim=i; best=mfpSeq[i]; }
}
if(victim<0) break;
if(mfpState[victim]===1&&mfpAwake>0) mfpAwake--;
mfpState[victim]=0; mfpLive--; removed++;
}
mfpAudit.budgetTrims+=removed;
mfpAudit.retired+=removed;
return removed;
}
/* ---------------------------------------------------------------------------
PUBLIC: spawn one rigid body.
o = {hx,hy,hz half extents (wu)
vx,vy,vz launch velocity (wu/s)
wx,wy,wz launch angular velocity (rad/s)
r,g,b tint 0..255
mass, restitution, friction, ttl, chunks}
Returns the body handle, or -1 when the budget refuses it.
--------------------------------------------------------------------------- */
function mfPhysSpawn(x,y,z,o){
if(!mfpEnabled) return -1;
o=o||{};
const i=mfPhysAlloc();
if(i<0) return -1;
const hx=Math.max(0.35,o.hx||1.4), hy=Math.max(0.35,o.hy||1.4), hz=Math.max(0.30,o.hz||1.0);
const m=Math.max(0.25,o.mass||(hx*hy*hz*2.4));
mfpX[i]=x; mfpY[i]=y; mfpZ[i]=z;
mfpVX[i]=o.vx||0; mfpVY[i]=o.vy||0; mfpVZ[i]=o.vz||0;
/* Random start orientation: a slab that always begins axis-aligned reads as
a spawned prop, not as something that just came off a building. */
const a=mfPhysRand()*Math.PI*2, b=mfPhysRand()*Math.PI*2, c=mfPhysRand()*Math.PI*2;
const ca=Math.cos(a*0.5), sa=Math.sin(a*0.5), cb=Math.cos(b*0.5), sb=Math.sin(b*0.5), cc=Math.cos(c*0.5), sc=Math.sin(c*0.5);
mfpQW[i]=ca*cb*cc+sa*sb*sc; mfpQX[i]=sa*cb*cc-ca*sb*sc;
mfpQY[i]=ca*sb*cc+sa*cb*sc; mfpQZ[i]=ca*cb*sc-sa*sb*cc;
mfpWX[i]=o.wx!==undefined?o.wx:(mfPhysRand()*2-1)*7;
mfpWY[i]=o.wy!==undefined?o.wy:(mfPhysRand()*2-1)*7;
mfpWZ[i]=o.wz!==undefined?o.wz:(mfPhysRand()*2-1)*7;
mfpHX[i]=hx; mfpHY[i]=hy; mfpHZ[i]=hz;
mfpIM[i]=1/m;
/* Solid box inertia about the centre of mass. Full extents are 2h, so
I = m/12 * ((2a)^2 + (2b)^2) = m/3 * (a^2 + b^2). */
mfpIIX[i]=3/(m*(hy*hy+hz*hz));
mfpIIY[i]=3/(m*(hx*hx+hz*hz));
mfpIIZ[i]=3/(m*(hx*hx+hy*hy));
const baseTTL=o.ttl||18;
mfpTTL[i]=mfPhysScaledTTL(i,baseTTL); mfpLife[i]=mfpTTL[i];
mfpRest[i]=o.restitution!==undefined?o.restitution:0.22;
mfpFric[i]=o.friction!==undefined?o.friction:0.62;
mfpSleepT[i]=0;
mfpR[i]=o.r!==undefined?o.r:150; mfpG[i]=o.g!==undefined?o.g:142; mfpB[i]=o.b!==undefined?o.b:126;
mfpTrail[i]=o.trail?1:0;
mfpState[i]=1;
mfpSeq[i]=mfpSeqNext++;
mfPhysSampleGround(i);
/* Chunk cluster. Laid along the body's LONGEST axis so a slab flipping end
over end is unmistakable; a cube gets a compact clump. */
const want=Math.max(1,Math.min(MFPHYS_CHUNKS,o.chunks||(hx>2.2||hy>2.2?4:2)));
mfpNCh[i]=want;
const base=i*MFPHYS_CHUNKS*4;
const longX=hx>=hy&&hx>=hz, longY=hy>=hx&&hy>=hz;
for(let k=0;k<want;k++){
const t=want===1?0:(k/(want-1))*2-1; // -1..1 along the long axis
const j=mfPhysRand()*0.34;
const ox=longX?t*hx*0.72:(mfPhysRand()*2-1)*hx*0.42;
const oy=longY?t*hy*0.72:(mfPhysRand()*2-1)*hy*0.42;
const oz=(!longX&&!longY)?t*hz*0.72:(mfPhysRand()*2-1)*hz*0.42;
/* Overlapping, not spaced: the cluster has to read as ONE solid piece
whose shape rotates, and gaps between chunks read as separate specks. */
const r=Math.max(hx,hy,hz)*(0.66+j)/Math.max(1,Math.sqrt(want)*0.68);
mfpCh[base+k*4 ]=ox; mfpCh[base+k*4+1]=oy;
mfpCh[base+k*4+2]=oz; mfpCh[base+k*4+3]=r;
}
mfpAudit.spawns++;
if(mfpLive>mfpAudit.maxLive) mfpAudit.maxLive=mfpLive;
mfPhysHook();
return i;
}
function mfPhysWake(i){
if(mfpState[i]===2){ mfpState[i]=1; mfpSleepT[i]=0; }
}
/* Rotate a body-space vector into world space by the body's quaternion. */
function mfPhysRot(i,vx,vy,vz,out){
const qx=mfpQX[i],qy=mfpQY[i],qz=mfpQZ[i],qw=mfpQW[i];
const tx=2*(qy*vz-qz*vy), ty=2*(qz*vx-qx*vz), tz=2*(qx*vy-qy*vx);
out[0]=vx+qw*tx+(qy*tz-qz*ty);
out[1]=vy+qw*ty+(qz*tx-qx*tz);
out[2]=vz+qw*tz+(qx*ty-qy*tx);
return out;
}
/* Inverse rotation — world into body space. */
function mfPhysUnrot(i,vx,vy,vz,out){
const qx=-mfpQX[i],qy=-mfpQY[i],qz=-mfpQZ[i],qw=mfpQW[i];
const tx=2*(qy*vz-qz*vy), ty=2*(qz*vx-qx*vz), tz=2*(qx*vy-qy*vx);
out[0]=vx+qw*tx+(qy*tz-qz*ty);
out[1]=vy+qw*ty+(qz*tx-qx*tz);
out[2]=vz+qw*tz+(qx*ty-qy*tx);
return out;
}
const _mfpA=[0,0,0], _mfpB=[0,0,0], _mfpC=[0,0,0];
/* World-space I^-1 * L, without ever forming the 3x3: rotate into body space,
scale by the diagonal, rotate back. */
function mfPhysInvInertiaMul(i,lx,ly,lz,out){
mfPhysUnrot(i,lx,ly,lz,_mfpB);
_mfpB[0]*=mfpIIX[i]; _mfpB[1]*=mfpIIY[i]; _mfpB[2]*=mfpIIZ[i];
return mfPhysRot(i,_mfpB[0],_mfpB[1],_mfpB[2],out);
}
/* Apply an impulse J at world offset r from the centre of mass.
dv = J/m, dw = I^-1 (r x J). This is the single line that makes debris
tumble: an off-centre hit produces torque, and nothing else in this game's
FX has ever produced torque. */
function mfPhysApplyImpulse(i,jx,jy,jz,rx,ry,rz){
const im=mfpIM[i];
mfpVX[i]+=jx*im; mfpVY[i]+=jy*im; mfpVZ[i]+=jz*im;
const tx=ry*jz-rz*jy, ty=rz*jx-rx*jz, tz=rx*jy-ry*jx;
mfPhysInvInertiaMul(i,tx,ty,tz,_mfpC);
mfpWX[i]+=_mfpC[0]; mfpWY[i]+=_mfpC[1]; mfpWZ[i]+=_mfpC[2];
/* Bound pathological chain blasts. Beyond these speeds a one-frame streak
is all the player sees, while contact impulses and quaternion integration
become needlessly unstable. Direction and momentum ratio are preserved. */
const v2=mfpVX[i]*mfpVX[i]+mfpVY[i]*mfpVY[i]+mfpVZ[i]*mfpVZ[i];
if(v2>MFPHYS_MAX_V*MFPHYS_MAX_V){
const s=MFPHYS_MAX_V/Math.sqrt(v2);
mfpVX[i]*=s; mfpVY[i]*=s; mfpVZ[i]*=s; mfpAudit.motionClamps++;
}
const w2=mfpWX[i]*mfpWX[i]+mfpWY[i]*mfpWY[i]+mfpWZ[i]*mfpWZ[i];
if(w2>MFPHYS_MAX_W*MFPHYS_MAX_W){
const s=MFPHYS_MAX_W/Math.sqrt(w2);
mfpWX[i]*=s; mfpWY[i]*=s; mfpWZ[i]*=s; mfpAudit.motionClamps++;
}
}
/* ---------------------------------------------------------------------------
THE STEP.
Semi-implicit Euler for the integrator, sequential impulses for the
contacts. Contacts are the eight box corners against the cached local
ground plane; the plane's normal is the terrain's real slope, so a chunk
thrown onto a hillside slides downhill and settles against the grade rather
than balancing on a flat imaginary floor.
--------------------------------------------------------------------------- */
function mfPhysStep(dt){
if(!mfpEnabled||mfpLive<=0){ mfpAwake=0; mfpStepMs=mfpStepMs*0.9; return; }
if(!(dt>0)) return;
if(dt>0.1) dt=0.1; // never integrate a stall
mfPhysTrimToBudget();
if(mfpLive<=0){ mfpAwake=0; return; }
const t0=performance.now();
let awake=0, live=0;
const viewB=mfPhysCamBounds();
const ps=Math.max(.2,Math.min(1,(typeof perfScale==='number'&&perfScale>0)?perfScale:1));
const pressure=Math.max(0,Math.min(1,mfpLive/Math.max(1,mfPhysBudget())));
const pressureAge=1+(1-ps)*.42+pressure*.68;
mfpAudit.steps++;
for(let i=0;i<MFPHYS_MAX;i++){
const st=mfpState[i];
if(st===0) continue;
live++;
const view=mfPhysViewSample(i,viewB),subpixel=mfpViewPx<1.5;
let ageMul=pressureAge;
if(view===1)ageMul*=1.75;else if(view===2)ageMul*=3.40;
if(subpixel)ageMul*=1.55;
if(st===2)ageMul*=1.35;
mfpAudit.acceleratedLife+=dt*Math.max(0,ageMul-1);
mfpLife[i]-=dt*ageMul;
if(mfpLife[i]<=0){
mfpState[i]=0;live--;mfpAudit.retired++;
if(view>0)mfpAudit.offscreenRetires++;if(subpixel)mfpAudit.subpixelRetires++;
continue;
}
/* Sleeping bodies are skipped entirely, but the ground under them can
still move — a crater opening beneath settled rubble has to relaunch
it. One cheap re-sample per second, staggered. */
if(st===2){
mfpGT[i]-=dt;
if(mfpGT[i]<=0){
const before=mfPhysPlaneAt(i,mfpX[i],mfpY[i]);
mfPhysSampleGround(i);
mfpGT[i]+=0.85;
if(Math.abs(mfPhysPlaneAt(i,mfpX[i],mfpY[i])-before)>0.35) mfPhysWake(i);
}
continue;
}
awake++;
/* ---- integrate ---- */
mfpVZ[i]-=MFPHYS_G*dt;
const air=1-Math.min(0.45,0.42*dt); // air drag only; ground drag is friction
mfpVX[i]*=air; mfpVY[i]*=air;
mfpX[i]+=mfpVX[i]*dt; mfpY[i]+=mfpVY[i]*dt; mfpZ[i]+=mfpVZ[i]*dt;
/* quaternion: q' = q + 0.5 * (w as pure quaternion) * q * dt */
const wx=mfpWX[i], wy=mfpWY[i], wz=mfpWZ[i];
let qx=mfpQX[i], qy=mfpQY[i], qz=mfpQZ[i], qw=mfpQW[i];
const h=dt*0.5;
const nx=qx+h*( wx*qw + wy*qz - wz*qy);
const ny=qy+h*(-wx*qz + wy*qw + wz*qx);
const nz=qz+h*( wx*qy - wy*qx + wz*qw);
const nw=qw+h*(-wx*qx - wy*qy - wz*qz);
const ql=Math.sqrt(nx*nx+ny*ny+nz*nz+nw*nw)||1;
qx=nx/ql; qy=ny/ql; qz=nz/ql; qw=nw/ql;
mfpQX[i]=qx; mfpQY[i]=qy; mfpQZ[i]=qz; mfpQW[i]=qw;
mfpWX[i]*=air; mfpWY[i]*=air; mfpWZ[i]*=air;
/* ---- ground plane refresh ---- */
mfpGT[i]-=dt;
const moved=Math.abs(mfpX[i]-mfpAX[i])+Math.abs(mfpY[i]-mfpAY[i]);
if(mfpGT[i]<=0||moved>5) mfPhysSampleGround(i);
/* ---- contacts: eight corners vs the local plane ---- */
const gx=mfpGX[i], gy=mfpGY[i];
let nlen=Math.sqrt(gx*gx+gy*gy+1);
const Nx=-gx/nlen, Ny=-gy/nlen, Nz=1/nlen;
const hx=mfpHX[i], hy=mfpHY[i], hz=mfpHZ[i];
const e=mfpRest[i], mu=mfpFric[i];
let deepest=0, support=0;
for(let iter=0;iter<2;iter++){
for(let c=0;c<8;c++){
const cx=(c&1)?hx:-hx, cy=(c&2)?hy:-hy, cz=(c&4)?hz:-hz;
mfPhysRot(i,cx,cy,cz,_mfpA);
const rx=_mfpA[0], ry=_mfpA[1], rz=_mfpA[2];
const px=mfpX[i]+rx, py=mfpY[i]+ry, pz=mfpZ[i]+rz;
const g=mfPhysPlaneAt(i,px,py);
const pen=(g-pz)*Nz; // perpendicular depth
/* SUPPORT is proximity, not penetration. Counting only penetrating
corners meant the positional correction below lifted the body out
of "contact" every other step, the count alternated, and the sleep
timer could never run to completion — measured 0/9 asleep. */
if(iter===0&&pen>-0.45){ support++; if(pen>deepest) deepest=pen; }
if(pen<=0) continue;
/* relative velocity at the contact point */
const vpx=mfpVX[i]+(mfpWY[i]*rz-mfpWZ[i]*ry);
const vpy=mfpVY[i]+(mfpWZ[i]*rx-mfpWX[i]*rz);
const vpz=mfpVZ[i]+(mfpWX[i]*ry-mfpWY[i]*rx);
const vn=vpx*Nx+vpy*Ny+vpz*Nz;
if(vn>=0) continue;
/* effective mass along the normal: 1/m + N . ((I^-1 (r x N)) x r) */
let ax=ry*Nz-rz*Ny, ay=rz*Nx-rx*Nz, az=rx*Ny-ry*Nx;
mfPhysInvInertiaMul(i,ax,ay,az,_mfpC);
const kx=_mfpC[1]*rz-_mfpC[2]*ry, ky=_mfpC[2]*rx-_mfpC[0]*rz, kz=_mfpC[0]*ry-_mfpC[1]*rx;
const kn=mfpIM[i]+(kx*Nx+ky*Ny+kz*Nz);
if(kn<=1e-6) continue;
/* Restitution only above a threshold: applying it to a 0.2 wu/s
micro-contact is what makes resting bodies buzz forever. */
const bounce=(-vn>60)?e:0;
const jn=Math.max(0,-(1+bounce)*vn/kn);
mfPhysApplyImpulse(i,Nx*jn,Ny*jn,Nz*jn,rx,ry,rz);
/* Coulomb friction along the tangential slip direction. This is what
stops a landed chunk from skating, and what converts a glancing
landing into a roll. */
const vpx2=mfpVX[i]+(mfpWY[i]*rz-mfpWZ[i]*ry);
const vpy2=mfpVY[i]+(mfpWZ[i]*rx-mfpWX[i]*rz);
const vpz2=mfpVZ[i]+(mfpWX[i]*ry-mfpWY[i]*rx);
const vn2=vpx2*Nx+vpy2*Ny+vpz2*Nz;
let tx2=vpx2-vn2*Nx, ty2=vpy2-vn2*Ny, tz2=vpz2-vn2*Nz;
const tl=Math.sqrt(tx2*tx2+ty2*ty2+tz2*tz2);
if(tl>0.02){
tx2/=tl; ty2/=tl; tz2/=tl;
let bx=ry*tz2-rz*ty2, by=rz*tx2-rx*tz2, bz=rx*ty2-ry*tx2;
mfPhysInvInertiaMul(i,bx,by,bz,_mfpC);
const fx=_mfpC[1]*rz-_mfpC[2]*ry, fy=_mfpC[2]*rx-_mfpC[0]*rz, fz=_mfpC[0]*ry-_mfpC[1]*rx;
const kt=mfpIM[i]+(fx*tx2+fy*ty2+fz*tz2);
if(kt>1e-6){
const jt=Math.min(mu*jn,tl/kt);
mfPhysApplyImpulse(i,-tx2*jt,-ty2*jt,-tz2*jt,rx,ry,rz);
}
}
}
}
/* Positional correction. Only the deepest corner, only the excess past a
slop band, and only partially — a full correction per corner ejects the
body off the ground and the whole pile pops. */
if(deepest>0.03) mfpZ[i]+=Math.min(1.2,(deepest-0.03)*0.55)/Math.max(0.35,Nz);
/* ---- sleep ---- */
const sv=Math.abs(mfpVX[i])+Math.abs(mfpVY[i])+Math.abs(mfpVZ[i]);
const sw=Math.abs(mfpWX[i])+Math.abs(mfpWY[i])+Math.abs(mfpWZ[i]);
/* mfpSleepT counts CONTINUOUS GROUND SUPPORT, not "time spent already
slow". The earlier version reset it whenever the body was still moving,
so a slab sliding down a grade steeper than atan(friction), or one
rocking on a corner, reset it forever: measured 41 of 96 bodies still
awake after fourteen seconds. Rubble that never stops is not an option.
Damping now ramps WITH the supported time — negligible during the
landing and the first roll, decisive after a second — and a body that
has been on the ground for three seconds is put down regardless. */
if(support>0) mfpSleepT[i]+=dt; else mfpSleepT[i]=0;
if(support>0){
const ramp=Math.min(1,mfpSleepT[i]/1.1);
const rd=1-Math.min(0.62,(0.9+5.4*ramp*ramp)*dt);
mfpVX[i]*=rd; mfpVY[i]*=rd; mfpVZ[i]*=rd;
mfpWX[i]*=rd; mfpWY[i]*=rd; mfpWZ[i]*=rd;
}
const slow=sv<MFPHYS_SLEEP_V&&sw<MFPHYS_SLEEP_W;
if(support>=1&&((slow&&mfpSleepT[i]>=MFPHYS_SLEEP_T)||mfpSleepT[i]>=3.0)){
mfpState[i]=2; mfpSleepT[i]=0;
mfpVX[i]=mfpVY[i]=mfpVZ[i]=0; mfpWX[i]=mfpWY[i]=mfpWZ[i]=0;
mfpGT[i]=0.85;
awake--;
}
/* A body that has fallen through the world (map edge, a torn heightfield)
is retired rather than integrated forever. */
if(mfpZ[i]<-400||!Number.isFinite(mfpX[i])||!Number.isFinite(mfpY[i])||
!Number.isFinite(mfpZ[i])||!Number.isFinite(mfpVX[i])||
!Number.isFinite(mfpVY[i])||!Number.isFinite(mfpVZ[i])){
mfpState[i]=0; live--; awake--; mfpAudit.retired++; mfpAudit.invalidRetires++;
}
}
mfpLive=live; mfpAwake=awake;
/* Exponential average — a single frame's number is noise on a WebView. */
mfpStepMs=mfpStepMs*0.86+(performance.now()-t0)*0.14;
}
/* ---------------------------------------------------------------------------
PUBLIC: explosion impulse. Real momentum transfer, applied at an off-centre
point so a blast SPINS what it shoves instead of sliding it.
--------------------------------------------------------------------------- */
function mfPhysImpulse(x,y,z,radius,power){
if(!mfpEnabled||mfpLive<=0) return 0;
mfpAudit.impulseEvents++;
const r2=radius*radius;
let hit=0;
for(let i=0;i<MFPHYS_MAX;i++){
if(mfpState[i]===0) continue;
const dx=mfpX[i]-x, dy=mfpY[i]-y, dz=mfpZ[i]-z;
const d2=dx*dx+dy*dy+dz*dz;
if(d2>r2) continue;
const d=Math.sqrt(d2)||0.001;
const fall=1-d/radius;
mfPhysWake(i);
/* Upward bias: a ground blast lifts as much as it pushes, and rubble that
only slides outward reads as a wind gust. */
const mass=1/Math.max(1e-5,mfpIM[i]);
const mag=power*fall*fall*mass*0.85;
const ux=dx/d, uy=dy/d, uz=Math.max(0.35,dz/d+0.55);
const ul=Math.sqrt(ux*ux+uy*uy+uz*uz)||1;
const off=Math.max(mfpHX[i],mfpHY[i],mfpHZ[i])*0.7;
mfPhysApplyImpulse(i,ux/ul*mag,uy/ul*mag,uz/ul*mag,
(mfPhysRand()*2-1)*off,(mfPhysRand()*2-1)*off,(mfPhysRand()*2-1)*off);
hit++;
}
mfpAudit.impulseHits+=hit;
return hit;
}
/* ---------------------------------------------------------------------------
PUBLIC: bounded attraction field. This is deliberately an acceleration,
not an outward-style impulse multiplied by body mass: every loose chunk
should visibly fall into a singularity, while the authoritative unit mass
resistance remains in sim.js. `tangent` adds an accretion-orbit component;
`consumeRadius` retires cosmetic matter that crosses the horizon.
--------------------------------------------------------------------------- */
function mfPhysAttract(x,y,z,radius,strength,dt,tangent,consumeRadius){
if(!mfpEnabled||mfpLive<=0||!Number.isFinite(x)||!Number.isFinite(y)||
!Number.isFinite(z)||!Number.isFinite(radius)||!Number.isFinite(strength)||
!Number.isFinite(dt)||!(dt>0)||!(radius>0)||!(strength>0)) return 0;
mfpAudit.attractEvents++;
/* The positional form is the shipped compatibility surface:
attract(x,y,z,radius,strength,dt,orbit,consumeRadius)
New callers may pass an options object in `orbit` without creating a
second singularity API. All limits are presentation-only and deterministic. */
const o=tangent&&typeof tangent==='object'?tangent:null;
let orbit=o?o.orbit:tangent;
orbit=Number.isFinite(orbit)?Math.max(-1.5,Math.min(1.5,orbit)):0;
let horizon=o?o.consumeRadius:consumeRadius;
horizon=Number.isFinite(horizon)?Math.max(0,Math.min(radius*.85,horizon)):0;
const maxConsume=Math.max(0,Math.min(16,Math.floor(o&&Number.isFinite(o.maxConsume)?o.maxConsume:6)));
const maxAccel=Math.max(1,Math.min(2400,o&&Number.isFinite(o.maxAcceleration)?o.maxAcceleration:strength));
const maxSpeed=Math.max(8,Math.min(MFPHYS_MAX_V,o&&Number.isFinite(o.maxSpeed)?o.maxSpeed:MFPHYS_MAX_V));
const verticalScale=Math.max(0,Math.min(1.5,o&&Number.isFinite(o.verticalScale)?o.verticalScale:1));
const step=Math.min(dt,1/15);
if(step!==dt||maxAccel!==strength) mfpAudit.attractClamps++;
const r2=radius*radius;
const horizon2=horizon*horizon;
let hit=0, consumed=0;
for(let i=0;i<MFPHYS_MAX;i++){
const state=mfpState[i];
if(state===0) continue;
const dx=x-mfpX[i],dy=y-mfpY[i],dz=z-mfpZ[i];
const d2=dx*dx+dy*dy+dz*dz;
if(d2>r2) continue;
if(horizon>0&&d2<=horizon2&&consumed<maxConsume){
mfpState[i]=0;
mfpLive=Math.max(0,mfpLive-1);
if(state===1) mfpAwake=Math.max(0,mfpAwake-1);
mfpAudit.retired++; consumed++; continue;
}
const d=Math.sqrt(d2)||0.001, fall=1-d/radius;
const accel=Math.min(maxAccel,strength*fall*fall);
const invD=1/d, tx=-dy*invD,ty=dx*invD;
if(state===2) mfpAudit.attractWakeups++;
mfPhysWake(i);
mfpVX[i]+=(dx*invD+tx*orbit)*accel*step;
mfpVY[i]+=(dy*invD+ty*orbit)*accel*step;
mfpVZ[i]+=dz*invD*accel*verticalScale*step;
const v2=mfpVX[i]*mfpVX[i]+mfpVY[i]*mfpVY[i]+mfpVZ[i]*mfpVZ[i];
if(v2>maxSpeed*maxSpeed){
const s=maxSpeed/Math.sqrt(v2);
mfpVX[i]*=s;mfpVY[i]*=s;mfpVZ[i]*=s;
mfpAudit.motionClamps++;mfpAudit.attractClamps++;
}
mfpSleepT[i]=0;
if(accel>mfpAudit.attractPeakAccel) mfpAudit.attractPeakAccel=accel;
hit++;
}
mfpAudit.attractHits+=hit;
mfpAudit.attractConsumed+=consumed;
return hit+consumed;
}
/* ---------------------------------------------------------------------------
PUBLIC: a burst of rigid debris. `size` is the source's world scale.
--------------------------------------------------------------------------- */
function mfPhysBurst(x,y,z,size,o){
if(!mfpEnabled) return 0;
o=o||{};
mfpAudit.burstEvents++;
const ps=(typeof perfScale==='number'&&perfScale>0)?perfScale:1;
const room=Math.max(0,mfPhysBudget()-mfpLive);
const raw=Math.max(0,Math.round(o.count!==undefined?o.count:Math.max(1,2+size*0.30)));
const requested=Math.min(MFPHYS_EVENT_MAX,raw);
if(raw>requested) mfpAudit.groupClamps++;
let n=requested>0?Math.max(1,Math.round(requested*Math.max(0.45,ps))):0;
n=Math.min(n,room);
if(n<=0) return 0;
if(n>mfpAudit.maxGroup) mfpAudit.maxGroup=n;
const sp=o.speed!==undefined?o.speed:(24+size*0.9);
const up=o.up!==undefined?o.up:(70+size*1.5);
const r=o.r!==undefined?o.r:150, g=o.g!==undefined?o.g:142, b=o.b!==undefined?o.b:126;
let aim=0,aimed=false;
if(o.direction&&o.direction.length>=2){
const dl=Math.hypot(o.direction[0],o.direction[1]);
if(dl>.0001){aim=Math.atan2(o.direction[1]/dl,o.direction[0]/dl);aimed=true;}
}
const spread=o.spread!==undefined?Math.max(.05,o.spread):.62;
let made=0;
for(let k=0;k<n;k++){
const a=aimed?aim+(mfPhysRand()*2-1)*spread:mfPhysRand()*Math.PI*2;
const v=sp*(0.72+mfPhysRand()*0.62);
/* Each body is an unmistakable shard: one long axis and two unequal thin
axes. The former independent 0.7-1.6 multipliers frequently converged
on a cube and the 4.2% scale floor vanished at the tactical camera. */
const s=Math.max(.82,size*(.050+mfPhysRand()*.040));
const longAxis=(mfPhysRand()*3)|0,long=1.9+mfPhysRand()*.9;
const thinA=.52+mfPhysRand()*.32,thinB=.38+mfPhysRand()*.28;
const hx=s*(longAxis===0?long:longAxis===1?thinA:thinB);
const hy=s*(longAxis===1?long:longAxis===2?thinA:thinB);
const hz=s*(longAxis===2?long:longAxis===0?thinA:thinB);
const launchR=o.launchRadius!==undefined?Math.max(0,o.launchRadius):size*.12;
const id=mfPhysSpawn(x+Math.cos(a)*launchR,y+Math.sin(a)*launchR,z+mfPhysRand()*size*.28,{
hx:hx,hy:hy,hz:hz,
vx:Math.cos(a)*v, vy:Math.sin(a)*v, vz:up*(0.5+mfPhysRand()*0.8),
r:r,g:g,b:b, ttl:o.ttl||(11+mfPhysRand()*7),
restitution:0.24, friction:0.66,
chunks:o.chunks!==undefined?o.chunks:2,trail:!!o.trail
});
if(id>=0) made++;
}
mfpAudit.rigidPieces+=made;
return made;
}
/* ---------------------------------------------------------------------------
PUBLIC: STRUCTURE COLLAPSE.
The current wreck path scales one uniform mesh to 22-38% for fourteen
seconds (src/ui/render3d.js:1351). This breaks the same footprint into real
1-3 large slabs that fall, hit the ground, tip over the debris already on it and come
to rest at whatever angle they land at. Pieces are biased OUTWARD and
DOWNWARD from the structure's own volume, so the pile grows from the
footprint instead of erupting from a point.
--------------------------------------------------------------------------- */
function mfPhysCollapse(x,y,size,o){
if(!mfpEnabled) return 0;
o=o||{};
mfpAudit.collapseEvents++;
const ps=(typeof perfScale==='number'&&perfScale>0)?perfScale:1;
const room=Math.max(0,mfPhysBudget()-mfpLive);
const sz=Math.max(8,Math.min(size||24,72));
const raw=Math.max(0,Math.round(o.count!==undefined?o.count:3));
const requested=Math.min(MFPHYS_EVENT_MAX,raw);
if(raw>requested) mfpAudit.groupClamps++;
let n=requested>0?Math.max(1,Math.round(requested*Math.max(0.45,ps))):0;
n=Math.min(room,n);
if(n<=0) return 0;
if(n>mfpAudit.maxGroup) mfpAudit.maxGroup=n;
const g=mfPhysGround(x,y);
const civic=!!o.civic;
const r=o.r!==undefined?o.r:(civic?146:132), gg=o.g!==undefined?o.g:(civic?140:128), b=o.b!==undefined?o.b:(civic?128:118);
let made=0;
for(let k=0;k<n;k++){
const a=mfPhysRand()*Math.PI*2;
const rad=sz*0.14+mfPhysRand()*sz*0.34;
/* Slabs, not cubes: one axis two to four times the others. A slab is what
makes end-over-end rotation legible at RTS camera distance. */
/* BT sizes are diameters (fac is size:48, r:24). Pieces sit between a
sixth and a third of the footprint — big enough to read as masonry,
small enough that a dozen of them look like a collapse. */
const base=sz*(0.036+mfPhysRand()*0.042);
const longAxis=(mfPhysRand()*3)|0;
const hx=base*(longAxis===0?2.0+mfPhysRand()*1.0:0.72+mfPhysRand()*0.46);
const hy=base*(longAxis===1?2.0+mfPhysRand()*1.0:0.72+mfPhysRand()*0.46);
const hz=base*(longAxis===2?2.15+mfPhysRand()*0.85:0.44+mfPhysRand()*0.38);
/* Launch from the structure's own height band, thrown outward, with a
modest lift — masonry falls, it does not fountain. */
const zStart=g+sz*(0.18+mfPhysRand()*0.75);
const out=14+mfPhysRand()*sz*0.55;
const id=mfPhysSpawn(x+Math.cos(a)*rad,y+Math.sin(a)*rad,zStart,{
hx:hx, hy:hy, hz:hz,
vx:Math.cos(a)*out, vy:Math.sin(a)*out, vz:18+mfPhysRand()*sz*1.05,
r:r,g:gg,b:b,
ttl:o.ttl||(24+mfPhysRand()*10),
restitution:0.15, friction:0.78,
chunks:Math.max(3,Math.min(MFPHYS_CHUNKS,3+((sz/22)|0)))
});
if(id>=0) made++;
}
mfpAudit.rigidPieces+=made;
return made;
}
/* ---------------------------------------------------------------------------
PUBLIC: one call for an explosion — shove what is already there, then throw
new fragments. This is what sim.js's spawnExplosion hooks.
--------------------------------------------------------------------------- */
function mfPhysBlast(x,y,size,o){
if(!mfpEnabled) return 0;
o=o||{};
mfpAudit.blastEvents++;
const g=mfPhysGround(x,y);
const sz=Math.max(3,size||8);
mfPhysImpulse(x,y,g+sz*0.30,sz*2.6,sz*0.36);
/* Only hull-scale detonations mint bodies. Every rifle hit calling
mfPhysBurst would evict a collapsing factory's rubble within a second —
the budget is small on purpose and structure collapse has first claim. */
if(sz<12&&!(o.count>0)) return 0;
return mfPhysBurst(x,y,g+sz*0.25,sz,
Object.assign({count:Math.min(3,2+Math.round(sz*0.10))},o));
}
/* ---------------------------------------------------------------------------
RENDER EMIT.
Runs once per FRAME. Pushes chunk instances into an existing lit instanced
stream, which is flushed later in the same frame by render3d.js. Because
the cluster's chunk positions come through the body's full rotation matrix,
three-axis tumble is visible with a yaw-only instance stream.
--------------------------------------------------------------------------- */
/* {mesh, halfW, cy}: halfW is the model's own half-width in model units and
cy the height of its centre above its anchor, both from
src/engine/models-world-data.js. The models are BASE-anchored, so without cy
every chunk floats half its own height. */
let mfpDraw=null;
function mfPhysDrawStream(){
if(typeof FX==='undefined'||!FX) return null;
if(mfpDraw&&mfpDraw.mesh) return mfpDraw;
/* Rigid debris is fractured material, not a miniature wreck token. The
wreck mesh has rectangular panels and a pale cap, so clustered bodies
read as tiny cubes around the blast. mdlShard is a closed asymmetric
extrusion; multiple overlapping instances form a jagged slab while the
solver still owns the body's full 3-axis tumble and terrain contact. */
if(FX.shard) mfpDraw={mesh:FX.shard, halfW:0.62, cy:0.0};
else if(FX.wreck) mfpDraw={mesh:FX.wreck, halfW:8.15, cy:4.0};
else if(FX.rock) mfpDraw={mesh:FX.rock, halfW:10.0, cy:3.6};
else if(FX.crate) mfpDraw={mesh:FX.crate, halfW:10.8, cy:10.0};
else return null;
return mfpDraw;
}
function mfPhysEmit(){
mfpFrame++;
mfpAudit.emitCalls++;
/* Pausing freezes mfPhysStep, not presentation. Instanced streams are
flushed every render, so the same frozen bodies must be re-submitted or
every shard disappears the instant a capture/pause overlay opens. */
if(!mfpEnabled||mfpLive<=0){ mfpChunksDrawn=0; return 0; }
const D=mfPhysDrawStream();
if(!D||!D.mesh||typeof D.mesh.add!=='function'){ mfpChunksDrawn=0; return 0; }
const M=D.mesh;
const t0=performance.now();
/* Cull to the camera. camBounds() is the same rectangle the weather and
scenery loops use, so debris obeys the same visibility rule they do. */
let bx0=-Infinity,by0=-Infinity,bx1=Infinity,by1=Infinity;
if(typeof camBounds==='function'){
try{ const B=camBounds(); if(B){ bx0=B.x0-60; by0=B.y0-60; bx1=B.x1+60; by1=B.y1+60; } }catch(err){}
}
let drawn=0;
for(let i=0;i<MFPHYS_MAX;i++){
if(mfpState[i]===0) continue;
const x=mfpX[i], y=mfpY[i];
if(x<bx0||x>bx1||y<by0||y>by1) continue;
/* Destruction is not intelligence. Enemy rubble can be spawned by combat
outside allied vision, so the renderer must obey the same disclosure
gate as every other battlefield effect. */
if(typeof fogPointVisible==='function'&&!fogPointVisible(x,y)) continue;
/* Fade out over the last second rather than blinking off. */
const a=mfpLife[i]<1?Math.max(0,mfpLife[i])*255:255;
if(a<=4) continue;
/* One short velocity-aligned streak belongs to this rigid body. It is
queued straight into the render stream and creates no particle/entity,
so three bodies remain one bounded debris layer and paused renders
cannot grow a pool. Only the fast, early ballistic phase receives it. */
const age=1-mfpLife[i]/Math.max(.001,mfpTTL[i]);
const flightAge=Math.max(0,mfpTTL[i]-mfpLife[i]);
const speed=Math.hypot(mfpVX[i],mfpVY[i],mfpVZ[i]);
if(mfpTrail[i]&&mfpState[i]===1&&flightAge<.72&&speed>18&&
typeof FX!=='undefined'&&FX.beam){
const lag=Math.min(.13,.050+flightAge*.11),tx=x-mfpVX[i]*lag,ty=y-mfpVY[i]*lag;
const floor=mfPhysPlaneAt(i,tx,ty)+.35,tz=Math.max(floor,mfpZ[i]-mfpVZ[i]*lag);
const fade=Math.max(0,1-flightAge/.72);
const w=Math.max(.46,Math.min(1.40,Math.max(mfpHX[i],mfpHY[i],mfpHZ[i])*.16));
const tr=Math.max(146,mfpR[i]),tg=Math.max(104,mfpG[i]),tb=Math.max(68,mfpB[i]);
/* Soft heated wake plus a narrow leading velocity line. Both are
render-only stamps on this one body—no pool growth and no extra
logical layer. The paired widths remain readable against fire and
disappear before the first bounce. */
if(typeof addBeamRibbon==='function'&&typeof sprites!=='undefined'&&sprites.glow){
addBeamRibbon(sprites.glow,tx,tz,ty,x,mfpZ[i],y,w*1.55,tr,tg,tb,26+44*fade,150);
addBeamRibbon(sprites.glow,tx,tz,ty,x,mfpZ[i],y,w*.48,255,218,164,54+72*fade,150);
}else if(typeof addBeam3D==='function'){
addBeam3D(FX.beam,tx,tz,ty,x,mfpZ[i],y,w,tr,tg,tb,72+98*fade,
{projectile:1,noMuzzle:true});
}
mfpAudit.velocityTrails++;
}
/* Yaw for the instance stream: the heading of the body's own +X axis
projected onto the ground plane. Positions carry pitch and roll. */
mfPhysRot(i,1,0,0,_mfpA);
const yaw=Math.atan2(_mfpA[1],_mfpA[0]);
const nch=mfpNCh[i], base=i*MFPHYS_CHUNKS*4;
const r=mfpR[i], g=mfpG[i], b=mfpB[i];
for(let k=0;k<nch;k++){
const ox=mfpCh[base+k*4], oy=mfpCh[base+k*4+1], oz=mfpCh[base+k*4+2], cr=mfpCh[base+k*4+3];
mfPhysRot(i,ox,oy,oz,_mfpA);
const s=Math.max(0.035,cr/D.halfW);
M.add(x+_mfpA[0], y+_mfpA[1], mfpZ[i]+_mfpA[2]-D.cy*s, s, yaw+k*0.9, r,g,b, a);
drawn++;
}
}
mfpChunksDrawn=drawn;
mfpAudit.emittedChunks+=drawn;
mfpEmitMs=mfpEmitMs*0.86+(performance.now()-t0)*0.14;
return drawn;
}
/* ---------------------------------------------------------------------------
FRAME HOOK.
render() is a global function declaration in src/ui/render3d.js, so it is a
property of the global object and can be wrapped from here. That keeps the
whole integration inside this one file: no other system's source is touched.
The equivalent explicit hook, if the renderer ever wants to own it, is one
line at the top of render(): if(typeof mfPhysEmit==='function') mfPhysEmit();
(delete mfPhysHook and this wrapper at the same time).
--------------------------------------------------------------------------- */
function mfPhysHook(){
if(mfpHooked) return true;
if(typeof window==='undefined'||typeof window.render!=='function') return false;
const inner=window.render;
if(inner.__mfPhys) { mfpHooked=true; return true; }
const wrapped=function(){
try{ mfPhysEmit(); }catch(err){ /* never let debris take the frame down */ }
return inner.apply(this,arguments);
};
wrapped.__mfPhys=true;
window.render=wrapped;
mfpHooked=true;
return true;
}
function mfPhysClear(){
for(let i=0;i<MFPHYS_MAX;i++) mfpState[i]=0;
mfpLive=0; mfpAwake=0; mfpChunksDrawn=0; mfpSeqNext=1;
mfPhysResetAudit();
mfPhysSeed();
}
function mfPhysEnable(on){ mfpEnabled=!!on; if(!mfpEnabled) mfPhysClear(); }
function mfPhysStats(){
let live=0, awake=0;
for(let i=0;i<MFPHYS_MAX;i++){ if(mfpState[i]===0) continue; live++; if(mfpState[i]===1) awake++; }
return {bodies:live, awake:awake, asleep:live-awake,
chunks:mfpChunksDrawn, budget:mfPhysBudget(),
eventMax:MFPHYS_EVENT_MAX, stepMs:mfpStepMs, emitMs:mfpEmitMs,
velocityTrails:mfpAudit.velocityTrails,
pausedEmitSkips:mfpAudit.pausedEmitSkips, budgetTrims:mfpAudit.budgetTrims,
attractEvents:mfpAudit.attractEvents, attractHits:mfpAudit.attractHits,
attractConsumed:mfpAudit.attractConsumed, attractClamps:mfpAudit.attractClamps,
attractWakeups:mfpAudit.attractWakeups, attractPeakAccel:mfpAudit.attractPeakAccel,
offscreenRetires:mfpAudit.offscreenRetires,subpixelRetires:mfpAudit.subpixelRetires,
hooked:mfpHooked, enabled:mfpEnabled};
}
/* Exact float32 state hash for replay/device probes. It intentionally omits
timing telemetry and render counters; equal seeds + event/step sequences
should yield the same hash even when frames are rendered at different rates. */
const _mfpHashBuf=new ArrayBuffer(4), _mfpHashView=new DataView(_mfpHashBuf);
function mfPhysHashFloat(h,v){
_mfpHashView.setFloat32(0,v,true);
return mfPhysHashWord(h,_mfpHashView.getUint32(0,true));
}
function mfPhysStateHash(){
let h=2166136261>>>0;
for(let i=0;i<MFPHYS_MAX;i++){
if(mfpState[i]===0) continue;
h=mfPhysHashWord(h,i); h=mfPhysHashWord(h,mfpState[i]);
h=mfPhysHashFloat(h,mfpX[i]); h=mfPhysHashFloat(h,mfpY[i]); h=mfPhysHashFloat(h,mfpZ[i]);
h=mfPhysHashFloat(h,mfpVX[i]); h=mfPhysHashFloat(h,mfpVY[i]); h=mfPhysHashFloat(h,mfpVZ[i]);
h=mfPhysHashFloat(h,mfpQX[i]); h=mfPhysHashFloat(h,mfpQY[i]);
h=mfPhysHashFloat(h,mfpQZ[i]); h=mfPhysHashFloat(h,mfpQW[i]);
h=mfPhysHashFloat(h,mfpWX[i]); h=mfPhysHashFloat(h,mfpWY[i]); h=mfPhysHashFloat(h,mfpWZ[i]);
h=mfPhysHashFloat(h,mfpLife[i]);
}
return ('00000000'+(h>>>0).toString(16)).slice(-8);
}
function mfPhysProbe(reset){
let live=0, finite=true;
for(let i=0;i<MFPHYS_MAX;i++){
if(mfpState[i]===0) continue;
live++;
if(!Number.isFinite(mfpX[i])||!Number.isFinite(mfpY[i])||!Number.isFinite(mfpZ[i])||
!Number.isFinite(mfpVX[i])||!Number.isFinite(mfpVY[i])||!Number.isFinite(mfpVZ[i])) finite=false;
}
const out={
bodies:live, budget:mfPhysBudget(), withinBudget:live<=mfPhysBudget(), finite:finite,
eventMax:MFPHYS_EVENT_MAX, maxGroup:mfpAudit.maxGroup,
layerBounded:mfpAudit.maxGroup<=MFPHYS_EVENT_MAX,
stateHash:mfPhysStateHash(), seed:mfpSeedBase>>>0, rngState:mfpRandState>>>0,
steps:mfpAudit.steps, spawns:mfpAudit.spawns, retired:mfpAudit.retired,
invalidRetires:mfpAudit.invalidRetires, evictions:mfpAudit.evictions,
budgetTrims:mfpAudit.budgetTrims, motionClamps:mfpAudit.motionClamps,
groundQueries:mfpAudit.groundQueries, burstEvents:mfpAudit.burstEvents,
collapseEvents:mfpAudit.collapseEvents, blastEvents:mfpAudit.blastEvents,
impulseEvents:mfpAudit.impulseEvents, impulseHits:mfpAudit.impulseHits,
attractEvents:mfpAudit.attractEvents, attractHits:mfpAudit.attractHits,
attractConsumed:mfpAudit.attractConsumed, attractClamps:mfpAudit.attractClamps,
attractWakeups:mfpAudit.attractWakeups, attractPeakAccel:mfpAudit.attractPeakAccel,
rigidPieces:mfpAudit.rigidPieces, groupClamps:mfpAudit.groupClamps,
rngDraws:mfpAudit.rngDraws, emitCalls:mfpAudit.emitCalls,
emittedChunks:mfpAudit.emittedChunks, velocityTrails:mfpAudit.velocityTrails,
pausedEmitSkips:mfpAudit.pausedEmitSkips,
offscreenRetires:mfpAudit.offscreenRetires,subpixelRetires:mfpAudit.subpixelRetires,
acceleratedLife:mfpAudit.acceleratedLife
};
if(reset) mfPhysResetAudit();
return out;
}
/* Read-only view for any system that wants to draw or query bodies itself —
a renderer with a real orientation attribute, an audio impact layer, or a
test harness. cb(handle, view) with view reused between calls. */
const _mfpView={i:0,x:0,y:0,z:0,vx:0,vy:0,vz:0,wx:0,wy:0,wz:0,
qx:0,qy:0,qz:0,qw:1,hx:0,hy:0,hz:0,life:0,ttl:0,trail:false,asleep:false};
function mfPhysForEach(cb){
let n=0;
for(let i=0;i<MFPHYS_MAX;i++){
if(mfpState[i]===0) continue;
_mfpView.i=i;
_mfpView.x=mfpX[i]; _mfpView.y=mfpY[i]; _mfpView.z=mfpZ[i];
_mfpView.vx=mfpVX[i]; _mfpView.vy=mfpVY[i]; _mfpView.vz=mfpVZ[i];
_mfpView.wx=mfpWX[i]; _mfpView.wy=mfpWY[i]; _mfpView.wz=mfpWZ[i];
_mfpView.qx=mfpQX[i]; _mfpView.qy=mfpQY[i]; _mfpView.qz=mfpQZ[i]; _mfpView.qw=mfpQW[i];
_mfpView.hx=mfpHX[i]; _mfpView.hy=mfpHY[i]; _mfpView.hz=mfpHZ[i];
_mfpView.life=mfpLife[i]; _mfpView.ttl=mfpTTL[i]; _mfpView.trail=!!mfpTrail[i];
_mfpView.asleep=mfpState[i]===2;
cb(i,_mfpView); n++;
}
return n;
}
/* ---------------------------------------------------------------------------
SELF-INITIALISE. This file loads before src/ui/render3d.js, so the render
wrapper cannot be installed here; mfPhysHook() is idempotent and is retried
on the first spawn. Everything else is arrays, already allocated above.
The namespace is a convenience for tooling — the callable API is the global
function declarations, exactly like every other system in this build.
--------------------------------------------------------------------------- */
(function mfPhysInit(){
mfPhysSeed();
if(typeof window==='undefined') return;
window.MFPhys={
spawn:mfPhysSpawn, burst:mfPhysBurst, collapse:mfPhysCollapse,
blast:mfPhysBlast, impulse:mfPhysImpulse, attract:mfPhysAttract, step:mfPhysStep,
emit:mfPhysEmit, stats:mfPhysStats, probe:mfPhysProbe, stateHash:mfPhysStateHash,
seed:mfPhysSeed, clear:mfPhysClear, enable:mfPhysEnable,
forEach:mfPhysForEach, budget:mfPhysBudget
};
/* Try once now in case the load order ever changes; harmless if render() is
not there yet, because mfPhysSpawn retries. */
mfPhysHook();
})();
|