@adatechnology/game-destruction-three 0.11.1 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -7,6 +7,9 @@ import { blastShove } from "@adatechnology/game-contracts";
7
7
  import { createVfx, effectForEvent, EVENT_EFFECTS_MAP } from "@adatechnology/game-vfx";
8
8
  import { createVfxRenderer } from "@adatechnology/game-vfx-three";
9
9
 
10
+ // src/fragments.ts
11
+ import { markKitOrigin } from "@adatechnology/game-contracts";
12
+
10
13
  // src/fitted-boxes.ts
11
14
  var SLICE_LENGTH = 0.12;
12
15
  var MAX_SLICES = 12;
@@ -88,6 +91,7 @@ var SHEET_AIR = 2.5;
88
91
  var SHEET_SWAY = 3;
89
92
  var BIG_LENGTH = 0.2;
90
93
  var BIG_WIDTH = 0.03;
94
+ var FRAGMENT_SEED = 1374496523;
91
95
  var isBig = (size) => {
92
96
  const [, middle, longest] = [...size].sort((a, b) => a - b);
93
97
  return longest >= BIG_LENGTH && middle >= BIG_WIDTH;
@@ -107,7 +111,7 @@ var FragmentSystem = class {
107
111
  __publicField(this, "m");
108
112
  __publicField(this, "v");
109
113
  __publicField(this, "w");
110
- __publicField(this, "rng", createRng(1374496523));
114
+ __publicField(this, "rng", createRng(FRAGMENT_SEED));
111
115
  /** Count of `update` calls: what makes the fragments of one burst tell apart from older ones. */
112
116
  __publicField(this, "tick", 0);
113
117
  __publicField(this, "markGeometry", null);
@@ -208,6 +212,8 @@ var FragmentSystem = class {
208
212
  mesh.scale.copy(sc);
209
213
  mesh.castShadow = true;
210
214
  mesh.userData.destructionDebris = true;
215
+ mesh.name = "fragment";
216
+ markKitOrigin(mesh, { owner: "game-destruction-three", kind: "fragment", id: f.material });
211
217
  this.o.scene.add(mesh);
212
218
  const live = {
213
219
  mesh,
@@ -224,7 +230,8 @@ var FragmentSystem = class {
224
230
  dying: false,
225
231
  air: f.shape === "scrap" && sz[2] <= SHEET_MAX ? SHEET_AIR : 0,
226
232
  body: null,
227
- born: this.tick
233
+ born: this.tick,
234
+ life: Math.max(0, typeof this.o.life === "function" ? this.o.life(f, home) : this.o.life)
228
235
  };
229
236
  if (f.shape !== "shard" && isBig(f.size)) {
230
237
  live.body = {
@@ -233,8 +240,7 @@ var FragmentSystem = class {
233
240
  live.vel.x = x;
234
241
  live.vel.z = z;
235
242
  live.vel.y = Math.max(live.vel.y, 0.3);
236
- live.asleep = false;
237
- live.rest = 0;
243
+ this.rouse(live);
238
244
  }
239
245
  };
240
246
  }
@@ -269,16 +275,20 @@ var FragmentSystem = class {
269
275
  d.y += k * 0.4;
270
276
  f.vel.add(d);
271
277
  f.ang.add(this.w.set(this.rng() - 0.5, this.rng() - 0.5, this.rng() - 0.5).multiplyScalar(k));
272
- f.asleep = false;
273
- f.rest = 0;
278
+ this.rouse(f);
274
279
  }
275
280
  }
281
+ /** A fragment that came to rest stops having its matrix recomputed on every frame; whatever moves it again calls this first. */
282
+ rouse(f) {
283
+ f.asleep = false;
284
+ f.rest = 0;
285
+ f.mesh.matrixAutoUpdate = true;
286
+ }
276
287
  /** What lies inside the disc looks at the ground again: it was dug or heaped under it. */
277
288
  wakeRegion(x, z, radius) {
278
289
  for (const f of this.list) {
279
290
  if (!f.asleep || Math.hypot(f.mesh.position.x - x, f.mesh.position.z - z) > radius) continue;
280
- f.asleep = false;
281
- f.rest = 0;
291
+ this.rouse(f);
282
292
  }
283
293
  }
284
294
  update(dt) {
@@ -287,6 +297,7 @@ var FragmentSystem = class {
287
297
  for (let i = this.list.length - 1; i >= 0; i--) {
288
298
  const f = this.list[i];
289
299
  if (f.dying) {
300
+ f.mesh.matrixAutoUpdate = true;
290
301
  f.fade -= dt / 0.6;
291
302
  if (f.fade <= 0) {
292
303
  this.o.scene.remove(f.mesh);
@@ -297,7 +308,10 @@ var FragmentSystem = class {
297
308
  }
298
309
  if (f.asleep) {
299
310
  f.rest += dt;
300
- if (this.o.life > 0 && f.rest > this.o.life && !f.body) f.dying = true;
311
+ if (f.life > 0 && f.rest > f.life && !f.body) {
312
+ f.dying = true;
313
+ f.mesh.matrixAutoUpdate = true;
314
+ }
301
315
  continue;
302
316
  }
303
317
  this.step(f, dt, g);
@@ -346,6 +360,8 @@ var FragmentSystem = class {
346
360
  f.still += dt;
347
361
  if (f.still > 0.4) {
348
362
  f.asleep = true;
363
+ f.mesh.updateMatrix();
364
+ f.mesh.matrixAutoUpdate = false;
349
365
  f.vel.set(0, 0, 0);
350
366
  f.ang.set(0, 0, 0);
351
367
  }
@@ -395,6 +411,12 @@ var FragmentSystem = class {
395
411
  for (const f of this.list) this.o.scene.remove(f.mesh);
396
412
  this.list.length = 0;
397
413
  }
414
+ /** `clear`, the random stream back to its seed and the tick to 0: what comes next flies as on a new system. */
415
+ reset() {
416
+ this.clear();
417
+ this.rng = createRng(FRAGMENT_SEED);
418
+ this.tick = 0;
419
+ }
398
420
  dispose() {
399
421
  this.clear();
400
422
  this.box.dispose();
@@ -493,6 +515,9 @@ var GroundSpots = class {
493
515
  var SUB_DT = 1 / 120;
494
516
  var MU = 0.7;
495
517
  var SLEEP_AFTER = 0.5;
518
+ var CALM_DRIFT = 0.01;
519
+ var CALM_TURN = 0.05;
520
+ var CALM_SLEEP_AFTER = 1.5;
496
521
  var HARD_LANDING = 3;
497
522
  var SLOP = 2e-3;
498
523
  var NARROW = 0.3;
@@ -501,6 +526,10 @@ var TIP_ACCELERATION = 30;
501
526
  var TIP_LYING = 0.25;
502
527
  var TIP_SECONDS = 3;
503
528
  var STEP = 0.03;
529
+ var WARM_SLOTS = 8 + 12 * 7;
530
+ var SOLVER_PASSES = 10;
531
+ var SIDE_BOUNCE = 0.1;
532
+ var EDGE_POINTS = [-0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75];
504
533
  var boxTopAt = (s, x, z) => {
505
534
  const e = s.e;
506
535
  const dx = x - s.c[0], dz = z - s.c[2];
@@ -571,6 +600,8 @@ var PieceSystem = class {
571
600
  localCenter: [localCenter.x, localCenter.y, localCenter.z],
572
601
  centerV: localCenter.clone(),
573
602
  still: 0,
603
+ calmAt: null,
604
+ calm: 0,
574
605
  lastHit: -1,
575
606
  onGround: false
576
607
  };
@@ -589,6 +620,11 @@ var PieceSystem = class {
589
620
  clear() {
590
621
  this.list.length = 0;
591
622
  }
623
+ /** `clear` and the clock back to 0. */
624
+ reset() {
625
+ this.clear();
626
+ this.time = 0;
627
+ }
592
628
  /** World position of the centre of mass. */
593
629
  centerOf(b, out) {
594
630
  return out.copy(b.centerV).applyQuaternion(b.root.quaternion).add(b.root.position);
@@ -613,6 +649,7 @@ var PieceSystem = class {
613
649
  }
614
650
  b.sleeping = false;
615
651
  b.still = 0;
652
+ b.calmAt = null;
616
653
  }
617
654
  /** I^-1 * (x, y, z) in world space for a box with the body's half extents and mass (needs `this.m` to hold the body's rotation). */
618
655
  mulInvInertia(b, x, y, z) {
@@ -630,12 +667,51 @@ var PieceSystem = class {
630
667
  const lz = (e[8] * x + e[9] * y + e[10] * z) * iz;
631
668
  return [e[0] * lx + e[4] * ly + e[8] * lz, e[1] * lx + e[5] * ly + e[9] * lz, e[2] * lx + e[6] * ly + e[10] * lz];
632
669
  }
670
+ /** The points of the box that touch the ground, in its own frame: the 8 corners, then (with `edges`) `EDGE_POINTS` along each of its 12 edges. */
671
+ samples(b, edges) {
672
+ const [hx, hy, hz] = b.halfExtents;
673
+ const out = [];
674
+ for (let i = 0; i < 8; i++) out.push([i & 1 ? hx : -hx, i & 2 ? hy : -hy, i & 4 ? hz : -hz]);
675
+ if (!edges) return out;
676
+ const h = [hx, hy, hz];
677
+ for (let axis = 0; axis < 3; axis++) {
678
+ const a1 = (axis + 1) % 3;
679
+ const a2 = (axis + 2) % 3;
680
+ for (let k = 0; k < 4; k++) {
681
+ for (const f of EDGE_POINTS) {
682
+ const p = [0, 0, 0];
683
+ p[axis] = f * h[axis];
684
+ p[a1] = k & 1 ? h[a1] : -h[a1];
685
+ p[a2] = k & 2 ? h[a2] : -h[a2];
686
+ out.push(p);
687
+ }
688
+ }
689
+ }
690
+ return out;
691
+ }
692
+ /**
693
+ * An impulse along the unit normal `n` at `r` (from the centre of mass, world) that stops the point closing in along `n` (with `bounce`); `e` holds the body's rotation.
694
+ */
695
+ contactImpulse(b, e, r, n, bounce) {
696
+ const v = b.velocity;
697
+ const w = b.angularVelocity;
698
+ const vn = (v[0] + w[1] * r[2] - w[2] * r[1]) * n[0] + (v[1] + w[2] * r[0] - w[0] * r[2]) * n[1] + (v[2] + w[0] * r[1] - w[1] * r[0]) * n[2];
699
+ if (vn >= 0) return;
700
+ const a = this.mulInvInertiaWith(b, e, r[1] * n[2] - r[2] * n[1], r[2] * n[0] - r[0] * n[2], r[0] * n[1] - r[1] * n[0]);
701
+ const k = (a[1] * r[2] - a[2] * r[1]) * n[0] + (a[2] * r[0] - a[0] * r[2]) * n[1] + (a[0] * r[1] - a[1] * r[0]) * n[2];
702
+ const j = -(1 + bounce) * vn / (1 / b.mass + k);
703
+ for (let i = 0; i < 3; i++) {
704
+ v[i] += j * n[i] / b.mass;
705
+ w[i] += a[i] * j;
706
+ }
707
+ }
633
708
  /** What lies inside the disc looks at the ground again: it was dug or heaped under it. */
634
709
  wakeRegion(x, z, radius) {
635
710
  for (const b of this.list) {
636
711
  if (!b.sleeping || b.held || Math.hypot(b.root.position.x - x, b.root.position.z - z) > radius) continue;
637
712
  b.sleeping = false;
638
713
  b.still = 0;
714
+ b.calmAt = null;
639
715
  }
640
716
  }
641
717
  update(dt) {
@@ -651,6 +727,7 @@ var PieceSystem = class {
651
727
  b.angularVelocity = [0, 0, 0];
652
728
  b.sleeping = false;
653
729
  b.still = 0;
730
+ b.calmAt = null;
654
731
  continue;
655
732
  }
656
733
  if (b.sleeping) continue;
@@ -675,7 +752,6 @@ var PieceSystem = class {
675
752
  const q = this.q.copy(b.root.quaternion);
676
753
  const v = b.velocity;
677
754
  const w = b.angularVelocity;
678
- const [hx, hy, hz] = b.halfExtents;
679
755
  const invM = 1 / b.mass;
680
756
  const steps = clamp(Math.ceil(dt / SUB_DT), 1, 8);
681
757
  const h = dt / steps;
@@ -684,7 +760,30 @@ var PieceSystem = class {
684
760
  let contactedLast = false;
685
761
  let touched = false;
686
762
  let unstable = false;
763
+ const corners = this.samples(b, false);
764
+ this.m.makeRotationFromQuaternion(q);
765
+ let gLow = Infinity;
766
+ let gHigh = -Infinity;
767
+ for (const [sx, sy, sz] of corners) {
768
+ const e0 = this.m.elements;
769
+ const y = pos[1] + e0[1] * sx + e0[5] * sy + e0[9] * sz;
770
+ const x = pos[0] + e0[0] * sx + e0[4] * sy + e0[8] * sz;
771
+ const z = pos[2] + e0[2] * sx + e0[6] * sy + e0[10] * sz;
772
+ const g = Math.max(this.o.groundAt(x, z), this.o.staticTopAt?.(x, z, y, STEP) ?? -Infinity);
773
+ gLow = Math.min(gLow, g);
774
+ gHigh = Math.max(gHigh, g);
775
+ }
776
+ const local = gHigh - gLow > STEP ? this.samples(b, true) : corners;
777
+ const prev = local.map(() => [0, 0, 0]);
687
778
  for (let s = 0; s < steps; s++) {
779
+ this.m.makeRotationFromQuaternion(q);
780
+ const e0 = this.m.elements;
781
+ for (let i = 0; i < local.length; i++) {
782
+ const [sx, sy, sz] = local[i];
783
+ prev[i][0] = pos[0] + e0[0] * sx + e0[4] * sy + e0[8] * sz;
784
+ prev[i][1] = pos[1] + e0[1] * sx + e0[5] * sy + e0[9] * sz;
785
+ prev[i][2] = pos[2] + e0[2] * sx + e0[6] * sy + e0[10] * sz;
786
+ }
688
787
  v[1] -= this.o.gravity * h;
689
788
  pos[0] += v[0] * h;
690
789
  pos[1] += v[1] * h;
@@ -701,20 +800,60 @@ var PieceSystem = class {
701
800
  this.m.makeRotationFromQuaternion(q);
702
801
  const e = this.m.elements;
703
802
  const rs = [];
803
+ const ids = [];
704
804
  let maxPen = 0;
705
- for (let i = 0; i < 8; i++) {
706
- const sx = i & 1 ? hx : -hx;
707
- const sy = i & 2 ? hy : -hy;
708
- const sz = i & 4 ? hz : -hz;
805
+ const rise = STEP + Math.max(0, -v[1]) * h * 2;
806
+ let side = null;
807
+ let lowest = Infinity;
808
+ for (let i = 0; i < local.length; i++) {
809
+ const [sx, sy, sz] = local[i];
709
810
  const r = [e[0] * sx + e[4] * sy + e[8] * sz, e[1] * sx + e[5] * sy + e[9] * sz, e[2] * sx + e[6] * sy + e[10] * sz];
811
+ const px = pos[0] + r[0];
812
+ const pz = pos[2] + r[2];
710
813
  const cy = pos[1] + r[1];
711
- const gy = this.supportAt(b, pos[0] + r[0], cy, pos[2] + r[2], STEP + Math.max(0, -v[1]) * h * 2);
814
+ const was = prev[i];
815
+ const raw = this.o.groundAt(px, pz);
816
+ if (raw > cy - SLOP && was[1] < raw - STEP) {
817
+ if (this.o.groundAt(was[0], was[2]) >= raw - STEP) {
818
+ const floor = b.floor;
819
+ if (floor !== void 0 && floor - cy > -SLOP && floor < raw - STEP) {
820
+ rs.push(r);
821
+ ids.push(i);
822
+ maxPen = Math.max(maxPen, floor - cy);
823
+ }
824
+ continue;
825
+ }
826
+ const dx = px - was[0];
827
+ const dz = pz - was[2];
828
+ const len = Math.hypot(dx, dz);
829
+ if (len > 1e-6) {
830
+ let lo = 0;
831
+ let hi = 1;
832
+ for (let it = 0; it < 6; it++) {
833
+ const mid = (lo + hi) / 2;
834
+ if (this.o.groundAt(was[0] + dx * mid, was[2] + dz * mid) >= raw - STEP) hi = mid;
835
+ else lo = mid;
836
+ }
837
+ const back = (1 - lo) * len + 1e-3;
838
+ if (!side || back > side.back) side = { back, dir: [dx / len, dz / len], r };
839
+ }
840
+ continue;
841
+ }
842
+ const gy = i < 8 ? this.supportAt(b, px, cy, pz, rise) : Math.max(raw, this.o.staticTopAt?.(px, pz, cy, rise) ?? -Infinity);
712
843
  const pen = gy - cy;
713
844
  if (pen > -SLOP) {
714
845
  rs.push(r);
846
+ ids.push(i);
715
847
  if (pen > maxPen) maxPen = pen;
848
+ lowest = Math.min(lowest, gy);
716
849
  }
717
850
  }
851
+ if (lowest < Infinity) b.floor = lowest;
852
+ if (side) {
853
+ pos[0] -= side.dir[0] * side.back;
854
+ pos[2] -= side.dir[1] * side.back;
855
+ this.contactImpulse(b, e, side.r, [-side.dir[0], 0, -side.dir[1]], SIDE_BOUNCE);
856
+ }
718
857
  contactedLast = rs.length > 0;
719
858
  if (contactedLast) touched = true;
720
859
  if (!contactedLast) continue;
@@ -734,45 +873,88 @@ var PieceSystem = class {
734
873
  const lz = pos[2] + sz / rs.length;
735
874
  landPoint = [lx, pos[1] - Math.max(...rs.map((r) => -r[1])), lz];
736
875
  }
737
- for (let it = 0; it < 4; it++) {
738
- for (const r of rs) {
739
- let vx = v[0] + (w[1] * r[2] - w[2] * r[1]);
740
- let vy = v[1] + (w[2] * r[0] - w[0] * r[2]);
741
- let vz = v[2] + (w[0] * r[1] - w[1] * r[0]);
742
- const vn = vy;
743
- if (vn >= 0) continue;
876
+ const goal = rs.map((r) => {
877
+ const vy = v[1] + (w[2] * r[0] - w[0] * r[2]);
878
+ return vy < 0 ? -restitution * vy : 0;
879
+ });
880
+ const vx0 = v[0];
881
+ const vz0 = v[2];
882
+ if (!b.warm || b.warm.length !== WARM_SLOTS * 3) b.warm = new Float64Array(WARM_SLOTS * 3);
883
+ const warm = b.warm;
884
+ const accN = ids.map((id) => id < WARM_SLOTS ? warm[id * 3] : 0);
885
+ const accT = ids.map((id) => id < WARM_SLOTS ? [warm[id * 3 + 1], warm[id * 3 + 2]] : [0, 0]);
886
+ for (let k = 0; k < rs.length; k++) {
887
+ const r = rs[k];
888
+ const jn = accN[k];
889
+ const [fx, fz] = accT[k];
890
+ if (jn === 0 && fx === 0 && fz === 0) continue;
891
+ v[0] += fx * invM;
892
+ v[1] += jn * invM;
893
+ v[2] += fz * invM;
894
+ const dw = this.mulInvInertiaWith(b, e, r[1] * fz - r[2] * jn, r[2] * fx - r[0] * fz, r[0] * jn - r[1] * fx);
895
+ w[0] += dw[0];
896
+ w[1] += dw[1];
897
+ w[2] += dw[2];
898
+ }
899
+ for (let it = 0; it < SOLVER_PASSES; it++) {
900
+ for (let k = 0; k < rs.length; k++) {
901
+ const r = rs[k];
902
+ const vy = v[1] + (w[2] * r[0] - w[0] * r[2]);
744
903
  const a = this.mulInvInertiaWith(b, e, -r[2], 0, r[0]);
745
904
  const denom = invM + (a[2] * r[0] - a[0] * r[2]);
746
- const j = -(1 + (it === 0 ? restitution : 0)) * vn / denom;
747
- v[1] += j * invM;
748
- const dw = this.mulInvInertiaWith(b, e, -r[2] * j, 0, r[0] * j);
749
- w[0] += dw[0];
750
- w[1] += dw[1];
751
- w[2] += dw[2];
752
- vx = v[0] + (w[1] * r[2] - w[2] * r[1]);
753
- vy = v[1] + (w[2] * r[0] - w[0] * r[2]);
754
- vz = v[2] + (w[0] * r[1] - w[1] * r[0]);
905
+ const total = Math.max(0, accN[k] + (goal[k] - vy) / denom);
906
+ const j = total - accN[k];
907
+ accN[k] = total;
908
+ if (j !== 0) {
909
+ v[1] += j * invM;
910
+ w[0] += a[0] * j;
911
+ w[1] += a[1] * j;
912
+ w[2] += a[2] * j;
913
+ }
914
+ const vx = v[0] + (w[1] * r[2] - w[2] * r[1]);
915
+ const vz = v[2] + (w[0] * r[1] - w[1] * r[0]);
755
916
  const vt = Math.hypot(vx, vz);
756
- if (vt > 1e-5) {
917
+ if (vt < 1e-6 && accT[k][0] === 0 && accT[k][1] === 0) continue;
918
+ let fx = accT[k][0];
919
+ let fz = accT[k][1];
920
+ if (vt > 1e-6) {
757
921
  const tx = vx / vt;
758
922
  const tz = vz / vt;
759
- const rx = r[1] * tz;
760
- const ry = r[2] * tx - r[0] * tz;
761
- const rz = -r[1] * tx;
762
- const at = this.mulInvInertiaWith(b, e, rx, ry, rz);
923
+ const at = this.mulInvInertiaWith(b, e, r[1] * tz, r[2] * tx - r[0] * tz, -r[1] * tx);
763
924
  const cx = at[1] * r[2] - at[2] * r[1];
764
925
  const cz = at[0] * r[1] - at[1] * r[0];
765
- const denomT = invM + cx * tx + cz * tz;
766
- const jt = Math.min(vt / denomT, MU * j);
767
- v[0] -= tx * jt * invM;
768
- v[2] -= tz * jt * invM;
769
- const dwt = this.mulInvInertiaWith(b, e, rx * jt, ry * jt, rz * jt);
770
- w[0] -= dwt[0];
771
- w[1] -= dwt[1];
772
- w[2] -= dwt[2];
926
+ const jt = vt / (invM + cx * tx + cz * tz);
927
+ fx -= tx * jt;
928
+ fz -= tz * jt;
929
+ }
930
+ const most = MU * accN[k];
931
+ const f = Math.hypot(fx, fz);
932
+ if (f > most) {
933
+ fx *= most / f;
934
+ fz *= most / f;
773
935
  }
936
+ const dx = fx - accT[k][0];
937
+ const dz = fz - accT[k][1];
938
+ accT[k] = [fx, fz];
939
+ if (dx === 0 && dz === 0) continue;
940
+ v[0] += dx * invM;
941
+ v[2] += dz * invM;
942
+ const dw = this.mulInvInertiaWith(b, e, r[1] * dz, r[2] * dx - r[0] * dz, -r[1] * dx);
943
+ w[0] += dw[0];
944
+ w[1] += dw[1];
945
+ w[2] += dw[2];
774
946
  }
775
947
  }
948
+ pos[0] += (v[0] - vx0) * h;
949
+ pos[2] += (v[2] - vz0) * h;
950
+ warm.fill(0);
951
+ for (let k = 0; k < rs.length; k++) {
952
+ const id = ids[k];
953
+ if (id >= WARM_SLOTS) continue;
954
+ warm[id * 3] = accN[k];
955
+ warm[id * 3 + 1] = accT[k][0];
956
+ warm[id * 3 + 2] = accT[k][1];
957
+ }
776
958
  let x0 = Infinity, x1 = -Infinity, z0 = Infinity, z1 = -Infinity, low = 0;
777
959
  for (const r of rs) {
778
960
  x0 = Math.min(x0, r[0]);
@@ -787,7 +969,9 @@ var PieceSystem = class {
787
969
  unstable = Math.min(wx, wz) < NARROW * height || x0 > 0 || x1 < 0 || z0 > 0 || z1 < 0;
788
970
  if (b.tipping) {
789
971
  b.tipping.seconds -= h;
790
- if (height < TIP_LYING || b.tipping.seconds <= 0) b.tipping = null;
972
+ const [hx, hy, hz] = b.halfExtents;
973
+ const upHalf = Math.abs(e[1]) * hx + Math.abs(e[5]) * hy + Math.abs(e[9]) * hz;
974
+ if (upHalf < TIP_LYING || b.tipping.seconds <= 0) b.tipping = null;
791
975
  else {
792
976
  w[0] += b.tipping.x * TIP_ACCELERATION * h;
793
977
  w[2] += b.tipping.z * TIP_ACCELERATION * h;
@@ -819,16 +1003,26 @@ var PieceSystem = class {
819
1003
  this.o.onHardLanding(b, landPoint, landing);
820
1004
  }
821
1005
  const still = touched && !unstable && Math.hypot(v[0], v[1], v[2]) < 0.12 && Math.hypot(w[0], w[1], w[2]) < 0.45;
822
- if (still) {
823
- b.still += dt;
824
- if (b.still >= SLEEP_AFTER) {
825
- b.sleeping = true;
826
- v[0] = v[1] = v[2] = 0;
827
- w[0] = w[1] = w[2] = 0;
828
- }
829
- } else {
830
- b.still = 0;
831
- }
1006
+ if (still) b.still += dt;
1007
+ else b.still = 0;
1008
+ if (b.still >= SLEEP_AFTER || this.staysInPlace(b, touched && !b.tipping, pos, q, dt)) {
1009
+ b.sleeping = true;
1010
+ b.calmAt = null;
1011
+ v[0] = v[1] = v[2] = 0;
1012
+ w[0] = w[1] = w[2] = 0;
1013
+ }
1014
+ }
1015
+ /** Whether the piece has kept to the same spot on the ground for `CALM_SLEEP_AFTER` (see `CALM_DRIFT`). */
1016
+ staysInPlace(b, touched, center, q, dt) {
1017
+ const at = b.calmAt;
1018
+ const turned = at ? 2 * Math.acos(Math.min(1, Math.abs(at.quaternion[0] * q.x + at.quaternion[1] * q.y + at.quaternion[2] * q.z + at.quaternion[3] * q.w))) : Infinity;
1019
+ if (!touched || !at || Math.hypot(center[0] - at.center[0], center[1] - at.center[1], center[2] - at.center[2]) > CALM_DRIFT || turned > CALM_TURN) {
1020
+ b.calmAt = touched ? { center: [center[0], center[1], center[2]], quaternion: [q.x, q.y, q.z, q.w] } : null;
1021
+ b.calm = 0;
1022
+ return false;
1023
+ }
1024
+ b.calm += dt;
1025
+ return b.calm >= CALM_SLEEP_AFTER;
832
1026
  }
833
1027
  /** Half the height of the box of a piece as it is turned now. */
834
1028
  halfHeight(b) {
@@ -846,8 +1040,9 @@ var PieceSystem = class {
846
1040
  const a = this.list[i];
847
1041
  for (let j = i + 1; j < n; j++) {
848
1042
  const b = this.list[j];
849
- if (a.held && b.held) continue;
850
- if (a.sleeping && b.sleeping) continue;
1043
+ const fixedA = a.held || a.sleeping;
1044
+ const fixedB = b.held || b.sleeping;
1045
+ if (fixedA && fixedB) continue;
851
1046
  const ra = Math.hypot(a.halfExtents[0], a.halfExtents[1], a.halfExtents[2]);
852
1047
  const rb = Math.hypot(b.halfExtents[0], b.halfExtents[1], b.halfExtents[2]);
853
1048
  this.centerOf(a, ca);
@@ -865,19 +1060,17 @@ var PieceSystem = class {
865
1060
  d = 1;
866
1061
  }
867
1062
  const push = Math.min(maxMove, (rh - d) * 0.5);
868
- const wa = a.held ? 0 : b.held ? 1 : b.mass / (a.mass + b.mass);
869
- const wb = b.held ? 0 : a.held ? 1 : a.mass / (a.mass + b.mass);
1063
+ const wa = fixedA ? 0 : fixedB ? 1 : b.mass / (a.mass + b.mass);
1064
+ const wb = fixedB ? 0 : fixedA ? 1 : a.mass / (a.mass + b.mass);
870
1065
  const ux = dx / (d || 1);
871
1066
  const uz = dz / (d || 1);
872
- if (!a.held) {
1067
+ if (!fixedA) {
873
1068
  a.root.position.x -= ux * push * 2 * wa;
874
1069
  a.root.position.z -= uz * push * 2 * wa;
875
- a.sleeping = false;
876
1070
  }
877
- if (!b.held) {
1071
+ if (!fixedB) {
878
1072
  b.root.position.x += ux * push * 2 * wb;
879
1073
  b.root.position.z += uz * push * 2 * wb;
880
- b.sleeping = false;
881
1074
  }
882
1075
  }
883
1076
  }
@@ -1014,6 +1207,7 @@ function specFromObject3D(THREE, root, options = {}) {
1014
1207
  const fill = options.fillOf?.(o, material);
1015
1208
  const tags = Array.isArray(ud.destructionTags) ? ud.destructionTags : void 0;
1016
1209
  const contains = containsOf(ud.destructionContains);
1210
+ const powder = typeof ud.destructionPowder?.color === "number" ? { color: ud.destructionPowder.color } : void 0;
1017
1211
  const thickness = typeof ud.destructionThickness === "number" && ud.destructionThickness > 0 ? ud.destructionThickness : void 0;
1018
1212
  const existing = explicit ? byKey.get(explicit) : void 0;
1019
1213
  if (existing) {
@@ -1027,9 +1221,10 @@ function specFromObject3D(THREE, root, options = {}) {
1027
1221
  existing.skin = existing.skin && skin;
1028
1222
  if (fill !== void 0) existing.fill = fill;
1029
1223
  if (contains) existing.contains = contains;
1224
+ if (powder) existing.powder = powder;
1030
1225
  if (thickness !== void 0) existing.thickness = Math.min(existing.thickness ?? thickness, thickness);
1031
1226
  } else {
1032
- const e = { key: explicit ?? `m${entries.length}`, meshes: [o], box, material, explicitId: explicit, fill, hollow, skin, tags, fixedId: !!explicit, ...contains ? { contains } : {}, ...thickness !== void 0 ? { thickness } : {}, name: o.name, parent: hint?.parent, jointKind: hint?.kind };
1227
+ const e = { key: explicit ?? `m${entries.length}`, meshes: [o], box, material, explicitId: explicit, fill, hollow, skin, tags, fixedId: !!explicit, ...contains ? { contains } : {}, ...powder ? { powder } : {}, ...thickness !== void 0 ? { thickness } : {}, name: o.name, parent: hint?.parent, jointKind: hint?.kind };
1033
1228
  entries.push(e);
1034
1229
  if (explicit) byKey.set(explicit, e);
1035
1230
  }
@@ -1104,6 +1299,7 @@ function specFromObject3D(THREE, root, options = {}) {
1104
1299
  }
1105
1300
  if (e.tags) part.tags = [...e.tags];
1106
1301
  if (e.contains) part.contains = { ...e.contains };
1302
+ if (e.powder) part.powder = { ...e.powder };
1107
1303
  const ao = options.anchorBelow ?? -1;
1108
1304
  if (ao >= 0 && e.box.min.y <= ao) part.anchored = true;
1109
1305
  parts.push(part);
@@ -1284,6 +1480,22 @@ var SKIN_FOLLOW = 0.85;
1284
1480
  var SKIN_SLACK = 5e-4;
1285
1481
  var SKIN_LAYER_SLACK = 4;
1286
1482
  var NEAREST_MARGIN = 1.5;
1483
+ var basePositions = (host) => {
1484
+ const p0 = host.mesh.userData._p0;
1485
+ if (p0) return p0;
1486
+ const op = positionAttr(host.origGeometry);
1487
+ return op ? op.array : null;
1488
+ };
1489
+ var collapseForeign = (mesh, arr) => {
1490
+ for (const [v0, v1] of mesh.userData.collapsedRanges ?? []) {
1491
+ for (let k = v0 + 1; k < v1; k++) {
1492
+ arr[k * 3] = arr[v0 * 3];
1493
+ arr[k * 3 + 1] = arr[v0 * 3 + 1];
1494
+ arr[k * 3 + 2] = arr[v0 * 3 + 2];
1495
+ }
1496
+ }
1497
+ };
1498
+ var DECAL_SNAP = 0.03;
1287
1499
  var Visuals = class {
1288
1500
  constructor(THREE, lowQuality, decalCap) {
1289
1501
  __publicField(this, "THREE", THREE);
@@ -1293,6 +1505,7 @@ var Visuals = class {
1293
1505
  __publicField(this, "a");
1294
1506
  __publicField(this, "b");
1295
1507
  __publicField(this, "m4");
1508
+ __publicField(this, "ray");
1296
1509
  __publicField(this, "box");
1297
1510
  __publicField(this, "t");
1298
1511
  __publicField(this, "lbox");
@@ -1303,6 +1516,7 @@ var Visuals = class {
1303
1516
  this.a = tmpV(THREE);
1304
1517
  this.b = tmpV(THREE);
1305
1518
  this.m4 = new THREE.Matrix4();
1519
+ this.ray = new THREE.Raycaster();
1306
1520
  this.box = new THREE.Box3();
1307
1521
  this.t = tmpV(THREE);
1308
1522
  this.lbox = new THREE.Box3();
@@ -1456,7 +1670,7 @@ var Visuals = class {
1456
1670
  host.ownGeo = true;
1457
1671
  const g = host.mesh.geometry.clone();
1458
1672
  host.mesh.geometry = g;
1459
- host.work = new Float32Array(positionAttr(g).array);
1673
+ host.work = new Float32Array(basePositions(host) ?? positionAttr(g).array);
1460
1674
  return true;
1461
1675
  }
1462
1676
  // ------------------------------------------------------------------------------------------------------------------------------------
@@ -1626,12 +1840,38 @@ var Visuals = class {
1626
1840
  // ------------------------------------------------------------------------------------------------------------------------------------
1627
1841
  // Decals
1628
1842
  // ------------------------------------------------------------------------------------------------------------------------------------
1843
+ /**
1844
+ * The point on the drawn face of `mesh` under a world point, within `DECAL_SNAP` metres along the normal: the world's box of a part and the face
1845
+ * the renderer drew (a coat of plaster, a merged house mesh) can be a few millimetres apart, and a mark behind the drawn face does not show.
1846
+ */
1847
+ onFace(mesh, worldPoint, worldNormal) {
1848
+ if (!mesh.isMesh || !mesh.geometry?.attributes?.position) return worldPoint;
1849
+ const n = worldNormal.clone().normalize();
1850
+ this.ray.set(worldPoint.clone().addScaledVector(n, DECAL_SNAP), n.clone().negate());
1851
+ this.ray.near = 0;
1852
+ this.ray.far = 2 * DECAL_SNAP;
1853
+ const hits = [];
1854
+ mesh.raycast(this.ray, hits);
1855
+ for (const c of mesh.parent?.children ?? []) {
1856
+ if (c === mesh || c.name !== "breach-shell") continue;
1857
+ c.raycast(this.ray, hits);
1858
+ const behind = c.userData.behind?.();
1859
+ if (behind) behind.raycast(this.ray, hits);
1860
+ }
1861
+ let best = null;
1862
+ for (const h of hits) {
1863
+ if (h.object.userData.cutAt?.(h.point)) continue;
1864
+ if (h.face && h.face.normal.clone().transformDirection(h.object.matrixWorld).dot(n) <= 0.3) continue;
1865
+ if (!best || Math.abs(h.distance - DECAL_SNAP) < Math.abs(best.distance - DECAL_SNAP)) best = h;
1866
+ }
1867
+ return best ? best.point : worldPoint;
1868
+ }
1629
1869
  /** Decal pose in mesh space: position and a rotation taking +Z to the surface normal. */
1630
1870
  decalPose(mesh, worldPoint, worldNormal, lift) {
1631
1871
  mesh.updateWorldMatrix(true, false);
1632
1872
  const s = mesh.matrixWorld.getMaxScaleOnAxis() || 1;
1633
1873
  const ln = worldNormal.clone().transformDirection(this.m4.copy(mesh.matrixWorld).invert()).normalize();
1634
- const pos = mesh.worldToLocal(worldPoint.clone()).addScaledVector(ln, lift / s);
1874
+ const pos = mesh.worldToLocal(this.onFace(mesh, worldPoint, worldNormal).clone()).addScaledVector(ln, lift / s);
1635
1875
  const quat = new this.THREE.Quaternion().setFromUnitVectors(new this.THREE.Vector3(0, 0, 1), ln);
1636
1876
  return { s, pos, quat };
1637
1877
  }
@@ -1870,6 +2110,7 @@ var Visuals = class {
1870
2110
  if (!pos || !work) return;
1871
2111
  const arr = pos.array;
1872
2112
  arr.set(work);
2113
+ collapseForeign(host.mesh, arr);
1873
2114
  for (const s of host.sharers) {
1874
2115
  if (s.merged && s.merged.host === host) {
1875
2116
  if (s.collapsed) this.collapseRange(arr, s.merged.range);
@@ -2074,9 +2315,9 @@ var Visuals = class {
2074
2315
  mv.touched = false;
2075
2316
  if (!host.ownGeo) return;
2076
2317
  const work = host.work;
2077
- const op = positionAttr(host.origGeometry);
2318
+ const op = basePositions(host);
2078
2319
  const [v0, v1] = merged.range;
2079
- if (op) work.set(op.array.subarray(v0 * 3, v1 * 3), v0 * 3);
2320
+ if (op) work.set(op.subarray(v0 * 3, v1 * 3), v0 * 3);
2080
2321
  const attr = host.mesh.geometry.getAttribute("color");
2081
2322
  if (attr) {
2082
2323
  const orig = host.origGeometry.getAttribute("color");
@@ -2090,6 +2331,13 @@ var Visuals = class {
2090
2331
  if (host.sharers.every((s) => !s.touched)) {
2091
2332
  host.mesh.geometry.dispose();
2092
2333
  host.mesh.geometry = host.origGeometry;
2334
+ const base = basePositions(host), back = positionAttr(host.origGeometry);
2335
+ if (base && back) {
2336
+ const arr = back.array;
2337
+ arr.set(base);
2338
+ collapseForeign(host.mesh, arr);
2339
+ back.needsUpdate = true;
2340
+ }
2093
2341
  host.work = null;
2094
2342
  host.ownGeo = false;
2095
2343
  if (host.ownMat) {
@@ -2149,9 +2397,12 @@ var Visuals = class {
2149
2397
  var rendererCount = 0;
2150
2398
  var COLLAPSE_CLOUD_KG = 1e3;
2151
2399
  var CHIP_DECAL_EDGE = 0.05;
2400
+ var OPAQUE_CRACK = 0.1;
2401
+ var WOOD_RIM = 1.5;
2152
2402
  var IMPACT_TAGS = ["metal", "glass", "wood"];
2153
2403
  var GLOW_FROM_C = 430;
2154
2404
  var QUEUE_CAP = 4e3;
2405
+ var MIN_FRAGMENTS_PER_SLICE = 8;
2155
2406
  var STATIC_REFRESH = 0.5;
2156
2407
  var STATIC_SPAN = 3;
2157
2408
  var FEET_BAND = 0.03;
@@ -2227,6 +2478,7 @@ function isMeshRange(v) {
2227
2478
  const r = v;
2228
2479
  return !!r.mesh && Array.isArray(r.range);
2229
2480
  }
2481
+ var RNG_SEED = 12648430;
2230
2482
  function createDestructionRenderer(THREE, options) {
2231
2483
  return new Renderer(THREE, options);
2232
2484
  }
@@ -2242,6 +2494,7 @@ var Renderer = class {
2242
2494
  __publicField(this, "gravity");
2243
2495
  __publicField(this, "autoSplit");
2244
2496
  __publicField(this, "maxFragments");
2497
+ __publicField(this, "eventBudgetMs");
2245
2498
  __publicField(this, "bounds", /* @__PURE__ */ new Map());
2246
2499
  /** World boxes (min xyz, max xyz) of the drawn meshes of the bound objects that are not pieces, for the pieces to land on. */
2247
2500
  __publicField(this, "staticBoxes", /* @__PURE__ */ new Map());
@@ -2252,6 +2505,10 @@ var Renderer = class {
2252
2505
  __publicField(this, "pieceBoxes", /* @__PURE__ */ new WeakMap());
2253
2506
  __publicField(this, "staticAt", 0);
2254
2507
  __publicField(this, "queue", []);
2508
+ /** Presentation work an event left for later frames (fragments still to spawn, loose groups still to split), run in order within the frame budget. */
2509
+ __publicField(this, "slices", []);
2510
+ /** When the work of this frame must stop; `Infinity` outside `drainQueue`, where a call finishes what it started. */
2511
+ __publicField(this, "sliceDeadline", Infinity);
2255
2512
  __publicField(this, "unsubscribe");
2256
2513
  __publicField(this, "visuals");
2257
2514
  __publicField(this, "fragments");
@@ -2265,7 +2522,7 @@ var Renderer = class {
2265
2522
  __publicField(this, "leaks", []);
2266
2523
  __publicField(this, "homeTags", []);
2267
2524
  __publicField(this, "throttles", /* @__PURE__ */ new Map());
2268
- __publicField(this, "rng", createRng(12648430));
2525
+ __publicField(this, "rng", createRng(RNG_SEED));
2269
2526
  __publicField(this, "vfx");
2270
2527
  /** Names this renderer to the world's drips, so a shared world tells each renderer where its own landed. */
2271
2528
  __publicField(this, "owner", `destruction-${++rendererCount}`);
@@ -2289,6 +2546,7 @@ var Renderer = class {
2289
2546
  this.gravity = o.gravity ?? 9.81;
2290
2547
  this.autoSplit = o.autoSplit !== false;
2291
2548
  this.maxFragments = o.maxFragments ?? 160;
2549
+ this.eventBudgetMs = o.eventBudgetMs ?? Infinity;
2292
2550
  this.box = new THREE.Box3();
2293
2551
  this.tmpQ = new THREE.Quaternion();
2294
2552
  this.v1 = new THREE.Vector3();
@@ -2374,6 +2632,7 @@ var Renderer = class {
2374
2632
  if (leftovers.length) this.matchByBox(objectId, root, leftovers, add);
2375
2633
  }
2376
2634
  const bound = { id: objectId, root, sync: options.syncTransform !== false, parts: /* @__PURE__ */ new Map(), lastPos: null, lastQuat: null, piece: null, isSplit: false };
2635
+ if (options.onFrame) bound.onFrame = options.onFrame;
2377
2636
  const hosts = /* @__PURE__ */ new Map();
2378
2637
  for (const pid of partIds) bound.parts.set(pid, this.makePartVis(objectId, pid, state?.parts.find((p) => p.id === pid)?.material ?? "", byPart.get(pid) ?? [], hosts));
2379
2638
  for (const [pid, meshes] of byPart) {
@@ -2533,6 +2792,7 @@ var Renderer = class {
2533
2792
  dt = clamp(dt, 0, 0.1);
2534
2793
  this.time += dt;
2535
2794
  this.drainQueue();
2795
+ for (const b of this.bounds.values()) b.onFrame?.(dt);
2536
2796
  for (const b of this.bounds.values()) if (b.soilNode && this.world.state(b.id)?.soil?.status === "yielding") this.onSettle(b.id);
2537
2797
  this.updateActive(dt);
2538
2798
  this.updateLeaks(dt);
@@ -2544,11 +2804,32 @@ var Renderer = class {
2544
2804
  }
2545
2805
  drainQueue() {
2546
2806
  const batch = this.queue.splice(0, this.queue.length);
2547
- for (const ev of batch) {
2807
+ const deadline = performance.now() + this.eventBudgetMs;
2808
+ this.sliceDeadline = deadline;
2809
+ try {
2810
+ this.runSlices(deadline);
2811
+ for (let i = 0; i < batch.length; i++) {
2812
+ if (i > 0 && performance.now() >= deadline) {
2813
+ this.queue.unshift(...batch.slice(i));
2814
+ return;
2815
+ }
2816
+ try {
2817
+ this.apply(batch[i]);
2818
+ } catch (err) {
2819
+ if (typeof console !== "undefined") console.error("game-destruction-three: failed to apply", batch[i].type, err);
2820
+ }
2821
+ }
2822
+ } finally {
2823
+ this.sliceDeadline = Infinity;
2824
+ }
2825
+ }
2826
+ /** Runs the work left by earlier frames until the deadline; at least one piece per frame, so it always finishes. */
2827
+ runSlices(deadline) {
2828
+ for (let ran = 0; this.slices.length > 0 && (ran === 0 || performance.now() < deadline); ran++) {
2548
2829
  try {
2549
- this.apply(ev);
2830
+ this.slices.shift()();
2550
2831
  } catch (err) {
2551
- if (typeof console !== "undefined") console.error("game-destruction-three: failed to apply", ev.type, err);
2832
+ if (typeof console !== "undefined") console.error("game-destruction-three: failed to run deferred work", err);
2552
2833
  }
2553
2834
  }
2554
2835
  }
@@ -2946,12 +3227,19 @@ var Renderer = class {
2946
3227
  const seed = hashNumbers(ev.time, point[0], point[1], point[2]);
2947
3228
  this.visuals.addCrack(mv, wp, normal, 7, Math.max(0.05, ev.hole.diameter * 5), 15267071, seed);
2948
3229
  } else {
3230
+ const fresh = def?.penetrationClass === "wood" ? def.visual?.innerColor : void 0;
3231
+ if (fresh !== void 0) this.visuals.addSpall(mv, wp, normal, Math.max(6e-3, ev.hole.diameter * WOOD_RIM), fresh);
2949
3232
  this.visuals.addHole(mv, wp, normal, ev.hole.diameter);
2950
3233
  }
2951
3234
  };
2952
3235
  draw(ev.worldPoint, n);
2953
3236
  if (ev.hole.through && ev.exitPoint) draw(ev.exitPoint, n.clone().negate());
2954
3237
  if (def?.tags?.includes("metal")) this.vfx.burst("sparks", ev.worldPoint, [n.x, n.y, n.z], 6, 3.5);
3238
+ if (ev.powder) {
3239
+ const puff = { color: ev.powder.color, size: 0.6, life: 1.6, alpha: 1.3 };
3240
+ this.vfx.burst("dust", ev.worldPoint, [n.x, n.y, n.z], this.low ? 5 : 10, 0.7, puff);
3241
+ if (ev.hole.through && ev.exitPoint) this.vfx.burst("dust", ev.exitPoint, [-n.x, -n.y, -n.z], this.low ? 10 : 20, 1.3, puff);
3242
+ }
2955
3243
  this.sound(impactKey(def), ev.worldPoint, 0.6);
2956
3244
  }
2957
3245
  onCrack(ev) {
@@ -2962,8 +3250,11 @@ var Renderer = class {
2962
3250
  const wp = new this.THREE.Vector3(ev.worldPoint[0], ev.worldPoint[1], ev.worldPoint[2]);
2963
3251
  const mv = this.nearestMesh(pv, wp);
2964
3252
  if (mv) {
2965
- const color = def?.visual?.transparent ? 15267071 : 2762532;
2966
- this.visuals.addCrack(mv, wp, this.worldDir(b, ev.crack.normal), ev.crack.rays, ev.crack.length, color, hashNumbers(ev.time, ev.worldPoint[0], ev.worldPoint[1], ev.worldPoint[2]));
3253
+ const glass = !!def?.visual?.transparent;
3254
+ const own = this.partColor(pv, def) ?? fallbackColor(pv.material, def);
3255
+ const color = glass ? 15267071 : new this.THREE.Color(own).multiplyScalar(0.45).getHex();
3256
+ const length = glass ? ev.crack.length : Math.min(ev.crack.length, OPAQUE_CRACK);
3257
+ this.visuals.addCrack(mv, wp, this.worldDir(b, ev.crack.normal), ev.crack.rays, length, color, hashNumbers(ev.time, ev.worldPoint[0], ev.worldPoint[1], ev.worldPoint[2]));
2967
3258
  }
2968
3259
  this.sound(impactKey(def), ev.worldPoint, 0.5);
2969
3260
  }
@@ -3001,6 +3292,7 @@ var Renderer = class {
3001
3292
  const core = def?.visual?.innerColor;
3002
3293
  if (core !== void 0) this.vfx.burst("chips", ev.point, [normal.x, normal.y, normal.z], 5, 2.5, { color: core });
3003
3294
  else this.playEvent({ kind: "spall", material: this.impactMaterial(def) }, ev.point);
3295
+ if (mv.mesh.userData.drawnSpalls?.includes(ev.part)) return;
3004
3296
  this.visuals.addSpall(mv, wp, normal, Math.sqrt(ev.area / Math.PI), core ?? fallbackColor(pv.material, def));
3005
3297
  }
3006
3298
  /**
@@ -3038,6 +3330,10 @@ var Renderer = class {
3038
3330
  const tone2 = fr.inner && fdef?.visual?.innerColor !== void 0 ? fdef.visual.innerColor : fdef === def ? color : fallbackColor(fr.material, fdef);
3039
3331
  this.fragments.spawn(fr, this.fragments.materialFor(fr.material, tone2, false), ev.object, maxAlive);
3040
3332
  }
3333
+ if (ev.torn) {
3334
+ this.sound(impactKey(def), ev.center, 0.5);
3335
+ return;
3336
+ }
3041
3337
  const T = this.THREE;
3042
3338
  const center = new T.Vector3(ev.center[0], ev.center[1], ev.center[2]);
3043
3339
  const half = new T.Vector3(ev.size[0] / 2 + CHIP_DECAL_EDGE, ev.size[1] / 2 + CHIP_DECAL_EDGE, ev.size[2] / 2 + CHIP_DECAL_EDGE);
@@ -3062,15 +3358,7 @@ var Renderer = class {
3062
3358
  if (m && m.transparent) opacity = clamp(m.opacity, 0.2, 0.8);
3063
3359
  this.visuals.hide(pv, "broken");
3064
3360
  }
3065
- const maxAlive = this.low ? Math.floor(this.maxFragments / 2) : this.maxFragments;
3066
- const step = this.low ? 2 : 1;
3067
- for (let i = 0; i < ev.fragments.length; i += step) {
3068
- const fr = ev.fragments[i];
3069
- const fdef = this.world.materials.get(fr.material);
3070
- const tone2 = fr.inner && fdef?.visual?.innerColor !== void 0 ? fdef.visual.innerColor : fdef === def || !fdef ? color : fallbackColor(fr.material, fdef);
3071
- const mat = this.fragments.materialFor(fr.material, tone2, transparent || fdef?.visual?.transparent === true, opacity);
3072
- this.fragments.spawn(fr, mat, ev.object, maxAlive);
3073
- }
3361
+ this.spawnFragments(ev, 0, { def, color, opacity, transparent });
3074
3362
  const up = [0, 1, 0];
3075
3363
  const sizeK = clamp(ev.fragments.length / 10, 0.5, 2);
3076
3364
  switch (ev.style) {
@@ -3096,6 +3384,22 @@ var Renderer = class {
3096
3384
  }
3097
3385
  this.sound(fractureKey(def, ev.style), ev.point, clamp(0.4 + ev.fragments.length * 0.03, 0.4, 1));
3098
3386
  }
3387
+ /** Spawns the fragments of a fracture from index `from`; what does not fit in this frame's budget is spawned by the next frames. */
3388
+ spawnFragments(ev, from, look) {
3389
+ const maxAlive = this.low ? Math.floor(this.maxFragments / 2) : this.maxFragments;
3390
+ const step = this.low ? 2 : 1;
3391
+ for (let i = from; i < ev.fragments.length; i += step) {
3392
+ if (i - from >= MIN_FRAGMENTS_PER_SLICE * step && performance.now() >= this.sliceDeadline) {
3393
+ this.slices.push(() => this.spawnFragments(ev, i, look));
3394
+ return;
3395
+ }
3396
+ const fr = ev.fragments[i];
3397
+ const fdef = this.world.materials.get(fr.material);
3398
+ const tone2 = fr.inner && fdef?.visual?.innerColor !== void 0 ? fdef.visual.innerColor : fdef === look.def || !fdef ? look.color : fallbackColor(fr.material, fdef);
3399
+ const mat = this.fragments.materialFor(fr.material, tone2, look.transparent || fdef?.visual?.transparent === true, look.opacity);
3400
+ this.fragments.spawn(fr, mat, ev.object, maxAlive);
3401
+ }
3402
+ }
3099
3403
  onDetach(ev) {
3100
3404
  this.sound("detach", this.rootPoint(ev.object), 0.5);
3101
3405
  this.splitLoose(ev.object);
@@ -3109,6 +3413,13 @@ var Renderer = class {
3109
3413
  if (!st || st.groups.length < 2) return;
3110
3414
  const loose = st.groups.length - 1;
3111
3415
  for (let i = 0; i < loose; i++) {
3416
+ if (i > 0 && performance.now() >= this.sliceDeadline) {
3417
+ this.slices.push(() => {
3418
+ this.staticDirty.add(objectId);
3419
+ this.splitLoose(objectId);
3420
+ });
3421
+ return;
3422
+ }
3112
3423
  let newId;
3113
3424
  try {
3114
3425
  newId = this.world.splitGroup(objectId, 1);
@@ -3125,8 +3436,9 @@ var Renderer = class {
3125
3436
  old.mass = Math.max(0.05, this.massOf(objectId));
3126
3437
  old.sleeping = false;
3127
3438
  } else if (kitOriginOf(b.root)?.kind === "furniture") {
3128
- this.makePiece(objectId);
3439
+ const piece = this.makePiece(objectId);
3129
3440
  this.tipIfTopHeavy(b);
3441
+ this.o.onLoosened?.(objectId, piece, "apart");
3130
3442
  }
3131
3443
  }
3132
3444
  /** The renderer side of `splitGroup`: a new group holding the meshes of the new object, made a piece. */
@@ -3235,7 +3547,7 @@ var Renderer = class {
3235
3547
  const piece = this.makePiece(b.id, { mass });
3236
3548
  const body = this.pieceSys.get(b.id);
3237
3549
  if (!body) continue;
3238
- this.o.onLoosened?.(b.id, piece);
3550
+ this.o.onLoosened?.(b.id, piece, "blast");
3239
3551
  const spread = half[0];
3240
3552
  const momentum = hit.speed * mass;
3241
3553
  this.pieceSys.impulse(body, [hit.direction[0] * momentum, hit.direction[1] * momentum, hit.direction[2] * momentum], [world.x + (this.rng() - 0.5) * spread, world.y + spread * 0.5, world.z + (this.rng() - 0.5) * spread]);
@@ -3430,6 +3742,7 @@ var Renderer = class {
3430
3742
  }
3431
3743
  clear() {
3432
3744
  this.queue.length = 0;
3745
+ this.slices.length = 0;
3433
3746
  this.active.clear();
3434
3747
  for (const key of [...this.fires.keys()]) this.stopFire(key);
3435
3748
  this.leaks.length = 0;
@@ -3449,6 +3762,15 @@ var Renderer = class {
3449
3762
  this.vfx.sprites().clear();
3450
3763
  this.ownVfx?.world.clear();
3451
3764
  }
3765
+ reset() {
3766
+ this.clear();
3767
+ this.rng = createRng(RNG_SEED);
3768
+ this.time = 0;
3769
+ this.staticAt = 0;
3770
+ this.throttles.clear();
3771
+ this.fragments.reset();
3772
+ this.pieceSys.reset();
3773
+ }
3452
3774
  wakeRegion(x, z, radius) {
3453
3775
  this.fragments.wakeRegion(x, z, radius);
3454
3776
  this.pieceSys.wakeRegion(x, z, radius);
@@ -3574,7 +3896,18 @@ var DRAG_SOUNDS = [
3574
3896
  drag("drag_masonry", [noise({ type: "bandpass", f: 260, q: 0.9, d: 0.3, vol: 0.62 }), ...many(3, () => noise({ type: "bandpass", f: [600, 1200], q: 2, d: 0.04, vol: 0.14, delay: [0, 0.2] }))]),
3575
3897
  drag("drag_soft", [noise({ type: "lowpass", f: 420, d: 0.28, vol: 0.3 })])
3576
3898
  ];
3577
- var DESTRUCTION_SOUND_BANK = { id: "destruction_three", sounds: [...SCENE_SOUNDS, ...DRAG_SOUNDS, EXPLOSION_BOOM], loops: [BURN_LOOP] };
3899
+ var leaf = (id, layers) => ({ ...sceneRecipe(id, layers, ""), sources: ["sdk: a door, lid or drawer worked by hand"] });
3900
+ var LEAF_SOUNDS = [
3901
+ leaf("latch", [noise({ type: "bandpass", f: 2400, q: 4, d: 0.03, vol: 0.35 }), tone({ f: 1900, f2: 1500, d: 0.025, vol: 0.06, delay: 0.035 })]),
3902
+ leaf("creak_wood", [tone({ f: [190, 240], f2: 310, d: 0.5, vol: 0.05 }), noise({ type: "bandpass", f: 520, q: 6, a: 0.05, d: 0.45, vol: 0.12 })]),
3903
+ leaf("creak_metal", [tone({ f: [620, 760], f2: 900, d: 0.35, vol: 0.035 }), noise({ type: "bandpass", f: 1400, q: 8, a: 0.04, d: 0.3, vol: 0.08 })]),
3904
+ leaf("shut_wood", [tone({ f: 120, f2: 60, d: 0.14, vol: 0.5 }), noise({ type: "bandpass", f: 600, q: 1.2, d: 0.1, vol: 0.55, wet: 0.25 }), noise({ type: "bandpass", f: 2400, q: 4, d: 0.03, vol: 0.25, delay: 0.02 })]),
3905
+ leaf("shut_metal", [tone({ f: 160, f2: 85, d: 0.18, vol: 0.55 }), noise({ type: "bandpass", f: 900, q: 1.6, d: 0.12, vol: 0.45, wet: 0.3 }), tone({ f: 1250, f2: 1150, d: 0.12, vol: 0.06 })]),
3906
+ leaf("shut_soft", [tone({ f: 95, f2: 55, d: 0.12, vol: 0.35 }), noise({ type: "lowpass", f: 500, d: 0.1, vol: 0.3 })]),
3907
+ leaf("rattle", many(5, () => noise({ type: "bandpass", f: [1600, 2600], q: 3, d: 0.035, vol: 0.32, delay: [0, 0.45] }))),
3908
+ leaf("slide", [noise({ type: "bandpass", f: 700, q: 2, a: 0.04, d: 0.35, vol: 0.22 }), noise({ type: "bandpass", f: 2200, q: 5, d: 0.3, vol: 0.05 })])
3909
+ ];
3910
+ var DESTRUCTION_SOUND_BANK = { id: "destruction_three", sounds: [...SCENE_SOUNDS, ...DRAG_SOUNDS, ...LEAF_SOUNDS, EXPLOSION_BOOM], loops: [BURN_LOOP] };
3578
3911
 
3579
3912
  // src/audio/sound.ts
3580
3913
  import { GAME_SFX_BANK } from "@adatechnology/game-audio";
@@ -3590,6 +3923,32 @@ var DESTRUCTION_SOUND_KEYS = {
3590
3923
  drag_glass: { sound: "drag_glass" },
3591
3924
  drag_masonry: { sound: "drag_masonry" },
3592
3925
  drag_soft: { sound: "drag_soft" },
3926
+ // a leaf worked by hand (doors, lids, drawers): the latch, the hinge turning, the leaf meeting the frame, a locked one shaken
3927
+ leaf_latch: { sound: "latch" },
3928
+ leaf_creak_wood: { sound: "creak_wood" },
3929
+ leaf_creak_metal: { sound: "creak_metal" },
3930
+ leaf_shut_wood: { sound: "shut_wood" },
3931
+ leaf_shut_metal: { sound: "shut_metal" },
3932
+ leaf_shut_glass: { sound: "shut_metal", volumeScale: 0.6 },
3933
+ leaf_shut_masonry: { sound: "shut_wood" },
3934
+ leaf_shut_soft: { sound: "shut_soft" },
3935
+ leaf_rattle: { sound: "rattle" },
3936
+ leaf_slide: { sound: "slide" },
3937
+ // the keys of the item clips (furniture and vehicle doors, hoods, trunks, drawers)
3938
+ door_latch: { sound: "latch" },
3939
+ hood_latch: { sound: "latch" },
3940
+ trunk_latch: { sound: "latch" },
3941
+ door_open: { sound: "latch" },
3942
+ door_shut: { sound: "shut_metal" },
3943
+ hood_shut: { sound: "shut_metal" },
3944
+ trunk_shut: { sound: "shut_metal" },
3945
+ door_close: { sound: "shut_wood", volumeScale: 0.7 },
3946
+ drawer_open: { sound: "slide" },
3947
+ drawer_close: { sound: "slide" },
3948
+ lid_open: { sound: "latch" },
3949
+ lid_close: { sound: "shut_soft" },
3950
+ flap_open: { sound: "latch" },
3951
+ flap_close: { sound: "shut_soft" },
3593
3952
  shatter_glass: { sound: "glass_shatter" },
3594
3953
  crumble: { sound: "crumble" },
3595
3954
  splinter: { sound: "crack" },