@adatechnology/game-destruction-three 0.10.0 → 0.11.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,44 @@ 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/fitted-boxes.ts
11
+ var SLICE_LENGTH = 0.12;
12
+ var MAX_SLICES = 12;
13
+ var SNUG_RATIO = 1.1;
14
+ function pushFittedBoxes(local, matrix, out) {
15
+ const e = matrix.elements;
16
+ const sizes = [0, 1, 2].map((k) => k === 0 ? local.max.x - local.min.x : k === 1 ? local.max.y - local.min.y : local.max.z - local.min.z);
17
+ const lengths = [0, 1, 2].map((k) => sizes[k] * Math.hypot(e[k * 4], e[k * 4 + 1], e[k * 4 + 2]));
18
+ const longest = lengths.indexOf(Math.max(...lengths));
19
+ const whole = worldBoxOf(local, matrix, longest, 0, 1);
20
+ const span = (whole[3] - whole[0]) * (whole[4] - whole[1]) * (whole[5] - whole[2]);
21
+ const volume = lengths[0] * lengths[1] * lengths[2];
22
+ const slices = span <= volume * SNUG_RATIO ? 1 : Math.min(MAX_SLICES, Math.ceil(lengths[longest] / SLICE_LENGTH));
23
+ if (slices <= 1) {
24
+ out.push(...whole);
25
+ return;
26
+ }
27
+ for (let i = 0; i < slices; i++) out.push(...worldBoxOf(local, matrix, longest, i / slices, (i + 1) / slices));
28
+ }
29
+ function worldBoxOf(local, matrix, axis, from, to) {
30
+ const e = matrix.elements;
31
+ const low = [local.min.x, local.min.y, local.min.z];
32
+ const high = [local.max.x, local.max.y, local.max.z];
33
+ const start = low[axis];
34
+ const size = high[axis] - start;
35
+ low[axis] = start + size * from;
36
+ high[axis] = start + size * to;
37
+ const box = [Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity];
38
+ for (const x of [low[0], high[0]]) for (const y of [low[1], high[1]]) for (const z of [low[2], high[2]]) {
39
+ for (let k = 0; k < 3; k++) {
40
+ const v = e[k] * x + e[k + 4] * y + e[k + 8] * z + e[k + 12];
41
+ box[k] = Math.min(box[k], v);
42
+ box[k + 3] = Math.max(box[k + 3], v);
43
+ }
44
+ }
45
+ return box;
46
+ }
47
+
10
48
  // src/math.ts
11
49
  function createRng(seed) {
12
50
  let a = seed >>> 0;
@@ -48,12 +86,20 @@ function mixHex(a, b, t) {
48
86
  var SHEET_MAX = 4e-3;
49
87
  var SHEET_AIR = 2.5;
50
88
  var SHEET_SWAY = 3;
89
+ var BIG_LENGTH = 0.2;
90
+ var BIG_WIDTH = 0.03;
91
+ var isBig = (size) => {
92
+ const [, middle, longest] = [...size].sort((a, b) => a - b);
93
+ return longest >= BIG_LENGTH && middle >= BIG_WIDTH;
94
+ };
51
95
  var FragmentSystem = class {
52
96
  constructor(THREE, o) {
53
97
  __publicField(this, "THREE", THREE);
54
98
  __publicField(this, "o", o);
55
99
  __publicField(this, "list", []);
56
100
  __publicField(this, "box");
101
+ /** The mesh box of a fragment before its scale. */
102
+ __publicField(this, "unitBox", { min: { x: -0.5, y: -0.5, z: -0.5 }, max: { x: 0.5, y: 0.5, z: 0.5 } });
57
103
  __publicField(this, "shardGeos", []);
58
104
  __publicField(this, "materials", /* @__PURE__ */ new Map());
59
105
  __publicField(this, "q");
@@ -62,6 +108,8 @@ var FragmentSystem = class {
62
108
  __publicField(this, "v");
63
109
  __publicField(this, "w");
64
110
  __publicField(this, "rng", createRng(1374496523));
111
+ /** Count of `update` calls: what makes the fragments of one burst tell apart from older ones. */
112
+ __publicField(this, "tick", 0);
65
113
  __publicField(this, "markGeometry", null);
66
114
  __publicField(this, "markMaterial", null);
67
115
  this.box = new THREE.BoxGeometry(1, 1, 1);
@@ -129,8 +177,28 @@ var FragmentSystem = class {
129
177
  mesh.add(disc);
130
178
  }
131
179
  }
132
- /** Create the mesh of a fragment and start its motion. `maxAllowed` is the live budget. */
180
+ /**
181
+ * Create the mesh of a fragment and start its motion. `maxAllowed` is the live budget: over it the oldest fragment still showing fades out to make room, and a burst
182
+ * bigger than the budget (every live fragment was made in this same frame) does not make the rest at all, so a blast never builds a thousand meshes only to fade them
183
+ * out. Returns null when the fragment was not made.
184
+ */
133
185
  spawn(f, material, home, maxAllowed) {
186
+ if (this.list.length >= maxAllowed) {
187
+ let alive = 0;
188
+ let oldest;
189
+ let oldestSmall;
190
+ for (const l of this.list) {
191
+ if (l.dying) continue;
192
+ alive++;
193
+ oldest ?? (oldest = l);
194
+ if (!l.body) oldestSmall ?? (oldestSmall = l);
195
+ }
196
+ oldest = oldestSmall ?? oldest;
197
+ if (alive >= maxAllowed) {
198
+ if (!oldest || oldest.born === this.tick) return null;
199
+ oldest.dying = true;
200
+ }
201
+ }
134
202
  const geo = f.shape === "shard" ? this.shardGeometry(hashString(f.id)) : this.box;
135
203
  const mesh = new this.THREE.Mesh(geo, material);
136
204
  mesh.position.set(f.center[0], f.center[1], f.center[2]);
@@ -154,19 +222,42 @@ var FragmentSystem = class {
154
222
  rest: 0,
155
223
  fade: 1,
156
224
  dying: false,
157
- air: f.shape === "scrap" && sz[2] <= SHEET_MAX ? SHEET_AIR : 0
225
+ air: f.shape === "scrap" && sz[2] <= SHEET_MAX ? SHEET_AIR : 0,
226
+ body: null,
227
+ born: this.tick
158
228
  };
159
- this.list.push(live);
160
- let alive = this.list.reduce((n, l) => n + (l.dying ? 0 : 1), 0);
161
- for (let i = 0; i < this.list.length && alive > maxAllowed; i++) {
162
- if (!this.list[i].dying) {
163
- this.list[i].dying = true;
164
- alive--;
165
- }
229
+ if (f.shape !== "shard" && isBig(f.size)) {
230
+ live.body = {
231
+ mass: f.mass,
232
+ push: (x, z) => {
233
+ live.vel.x = x;
234
+ live.vel.z = z;
235
+ live.vel.y = Math.max(live.vel.y, 0.3);
236
+ live.asleep = false;
237
+ live.rest = 0;
238
+ }
239
+ };
166
240
  }
241
+ this.list.push(live);
167
242
  this.addMarks(mesh, f);
168
243
  return mesh;
169
244
  }
245
+ /** The boxes of the big fragments around (x, z). */
246
+ solidsNear(x, z, radius, found) {
247
+ const boxes = [];
248
+ for (const f of this.list) {
249
+ if (!f.body || f.dying) continue;
250
+ const reach = f.half.length() + radius;
251
+ if (Math.abs(f.mesh.position.x - x) > reach || Math.abs(f.mesh.position.z - z) > reach) continue;
252
+ f.mesh.updateMatrixWorld();
253
+ boxes.length = 0;
254
+ pushFittedBoxes(this.unitBox, f.mesh.matrixWorld, boxes);
255
+ for (let i = 0; i < boxes.length; i += 6) {
256
+ if (x < boxes[i] - radius || x > boxes[i + 3] + radius || z < boxes[i + 2] - radius || z > boxes[i + 5] + radius) continue;
257
+ found(f.home, [boxes[i], boxes[i + 1], boxes[i + 2]], [boxes[i + 3], boxes[i + 4], boxes[i + 5]], f.body);
258
+ }
259
+ }
260
+ }
170
261
  /** Add velocity to every fragment within `radius` of a point (blasts). */
171
262
  blast(point, radius, speed) {
172
263
  for (const f of this.list) {
@@ -182,7 +273,16 @@ var FragmentSystem = class {
182
273
  f.rest = 0;
183
274
  }
184
275
  }
276
+ /** What lies inside the disc looks at the ground again: it was dug or heaped under it. */
277
+ wakeRegion(x, z, radius) {
278
+ for (const f of this.list) {
279
+ 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;
282
+ }
283
+ }
185
284
  update(dt) {
285
+ this.tick++;
186
286
  const g = this.o.gravity;
187
287
  for (let i = this.list.length - 1; i >= 0; i--) {
188
288
  const f = this.list[i];
@@ -197,7 +297,7 @@ var FragmentSystem = class {
197
297
  }
198
298
  if (f.asleep) {
199
299
  f.rest += dt;
200
- if (this.o.life > 0 && f.rest > this.o.life) f.dying = true;
300
+ if (this.o.life > 0 && f.rest > this.o.life && !f.body) f.dying = true;
201
301
  continue;
202
302
  }
203
303
  this.step(f, dt, g);
@@ -271,7 +371,7 @@ var FragmentSystem = class {
271
371
  let small = 0;
272
372
  for (let i = 1; i < 3; i++) if (half[i] < half[small]) small = i;
273
373
  let axis = small;
274
- if (Math.abs(up[small]) < 0.35) {
374
+ if (Math.abs(up[small]) < 0.35 && !f.body) {
275
375
  axis = 0;
276
376
  for (let i = 1; i < 3; i++) if (Math.abs(up[i]) > Math.abs(up[axis])) axis = i;
277
377
  }
@@ -397,6 +497,30 @@ var HARD_LANDING = 3;
397
497
  var SLOP = 2e-3;
398
498
  var NARROW = 0.3;
399
499
  var TOPPLE = 3;
500
+ var TIP_ACCELERATION = 30;
501
+ var TIP_LYING = 0.25;
502
+ var TIP_SECONDS = 3;
503
+ var STEP = 0.03;
504
+ var boxTopAt = (s, x, z) => {
505
+ const e = s.e;
506
+ const dx = x - s.c[0], dz = z - s.c[2];
507
+ let t0 = -Infinity, t1 = Infinity;
508
+ for (let k = 0; k < 3; k++) {
509
+ const p = e[k * 4] * dx + e[k * 4 + 2] * dz;
510
+ const d = e[k * 4 + 1];
511
+ const h = s.half[k];
512
+ if (Math.abs(d) < 1e-9) {
513
+ if (Math.abs(p) > h) return -Infinity;
514
+ continue;
515
+ }
516
+ let a = (-h - p) / d, b = (h - p) / d;
517
+ if (a > b) [a, b] = [b, a];
518
+ t0 = Math.max(t0, a);
519
+ t1 = Math.min(t1, b);
520
+ if (t0 > t1) return -Infinity;
521
+ }
522
+ return s.c[1] + t1;
523
+ };
400
524
  var PieceSystem = class {
401
525
  constructor(THREE, o) {
402
526
  __publicField(this, "THREE", THREE);
@@ -408,6 +532,7 @@ var PieceSystem = class {
408
532
  __publicField(this, "dq");
409
533
  __publicField(this, "m");
410
534
  __publicField(this, "ax");
535
+ __publicField(this, "solids", []);
411
536
  this.p = new THREE.Vector3();
412
537
  this.q = new THREE.Quaternion();
413
538
  this.dq = new THREE.Quaternion();
@@ -417,6 +542,11 @@ var PieceSystem = class {
417
542
  get(objectId) {
418
543
  return this.list.find((b) => b.objectId === objectId) ?? null;
419
544
  }
545
+ /** Sets a piece falling over, about the world axes x and z (a unit axis): its feet are thinner than the box that stands for it, so the box alone would hold it up. */
546
+ tip(body, x, z) {
547
+ body.tipping = { x, z, seconds: TIP_SECONDS };
548
+ body.sleeping = false;
549
+ }
420
550
  create(objectId, root, halfExtents, localCenter, mass, velocity, angular) {
421
551
  const existing = this.get(objectId);
422
552
  if (existing) {
@@ -430,6 +560,7 @@ var PieceSystem = class {
430
560
  }
431
561
  const body = {
432
562
  objectId,
563
+ tipping: null,
433
564
  root,
434
565
  velocity: velocity ? [...velocity] : [0, 0, 0],
435
566
  angularVelocity: angular ? [...angular] : [0, 0, 0],
@@ -499,8 +630,21 @@ var PieceSystem = class {
499
630
  const lz = (e[8] * x + e[9] * y + e[10] * z) * iz;
500
631
  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];
501
632
  }
633
+ /** What lies inside the disc looks at the ground again: it was dug or heaped under it. */
634
+ wakeRegion(x, z, radius) {
635
+ for (const b of this.list) {
636
+ if (!b.sleeping || b.held || Math.hypot(b.root.position.x - x, b.root.position.z - z) > radius) continue;
637
+ b.sleeping = false;
638
+ b.still = 0;
639
+ }
640
+ }
502
641
  update(dt) {
503
642
  this.time += dt;
643
+ this.solids = this.list.map((body) => {
644
+ const c = this.centerOf(body, new this.THREE.Vector3());
645
+ const h = body.halfExtents;
646
+ return { body, c: [c.x, c.y, c.z], e: [...new this.THREE.Matrix4().makeRotationFromQuaternion(body.root.quaternion).elements], half: h, r: Math.hypot(h[0], h[1], h[2]) };
647
+ });
504
648
  for (const b of this.list) {
505
649
  if (b.held) {
506
650
  b.velocity = [0, 0, 0];
@@ -514,6 +658,17 @@ var PieceSystem = class {
514
658
  }
515
659
  this.separate(dt);
516
660
  }
661
+ /** Where a corner of `self` at `(x, y, z)` lands: the ground, the top of another piece or of something fixed under it. */
662
+ supportAt(self, x, y, z, step) {
663
+ let top = this.o.groundAt(x, z);
664
+ for (const s of this.solids) {
665
+ if (s.body === self || s.body.held || Math.abs(x - s.c[0]) > s.r || Math.abs(z - s.c[2]) > s.r) continue;
666
+ const t = boxTopAt(s, x, z);
667
+ if (t > top && t <= y + step) top = t;
668
+ }
669
+ const fixed = this.o.staticTopAt?.(x, z, y, step) ?? -Infinity;
670
+ return Math.max(top, fixed);
671
+ }
517
672
  integrate(b, dt) {
518
673
  const c = this.centerOf(b, this.p);
519
674
  const pos = [c.x, c.y, c.z];
@@ -552,8 +707,9 @@ var PieceSystem = class {
552
707
  const sy = i & 2 ? hy : -hy;
553
708
  const sz = i & 4 ? hz : -hz;
554
709
  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];
555
- const gy = this.o.groundAt(pos[0] + r[0], pos[2] + r[2]);
556
- const pen = gy - (pos[1] + r[1]);
710
+ 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);
712
+ const pen = gy - cy;
557
713
  if (pen > -SLOP) {
558
714
  rs.push(r);
559
715
  if (pen > maxPen) maxPen = pen;
@@ -576,7 +732,7 @@ var PieceSystem = class {
576
732
  }
577
733
  const lx = pos[0] + sx / rs.length;
578
734
  const lz = pos[2] + sz / rs.length;
579
- landPoint = [lx, this.o.groundAt(lx, lz), lz];
735
+ landPoint = [lx, pos[1] - Math.max(...rs.map((r) => -r[1])), lz];
580
736
  }
581
737
  for (let it = 0; it < 4; it++) {
582
738
  for (const r of rs) {
@@ -629,6 +785,15 @@ var PieceSystem = class {
629
785
  const wx = x1 - x0;
630
786
  const wz = z1 - z0;
631
787
  unstable = Math.min(wx, wz) < NARROW * height || x0 > 0 || x1 < 0 || z0 > 0 || z1 < 0;
788
+ if (b.tipping) {
789
+ b.tipping.seconds -= h;
790
+ if (height < TIP_LYING || b.tipping.seconds <= 0) b.tipping = null;
791
+ else {
792
+ w[0] += b.tipping.x * TIP_ACCELERATION * h;
793
+ w[2] += b.tipping.z * TIP_ACCELERATION * h;
794
+ unstable = true;
795
+ }
796
+ }
632
797
  if (unstable) {
633
798
  const dirX = x0 + x1 > 0 ? -1 : 1;
634
799
  const dirZ = z0 + z1 > 0 ? -1 : 1;
@@ -665,6 +830,11 @@ var PieceSystem = class {
665
830
  b.still = 0;
666
831
  }
667
832
  }
833
+ /** Half the height of the box of a piece as it is turned now. */
834
+ halfHeight(b) {
835
+ const e = this.m.makeRotationFromQuaternion(b.root.quaternion).elements;
836
+ return Math.abs(e[1]) * b.halfExtents[0] + Math.abs(e[5]) * b.halfExtents[1] + Math.abs(e[9]) * b.halfExtents[2];
837
+ }
668
838
  /** Push overlapping pieces apart (horizontal bounding circles), gently, so they do not interpenetrate forever. */
669
839
  separate(dt) {
670
840
  const n = this.list.length;
@@ -683,6 +853,7 @@ var PieceSystem = class {
683
853
  this.centerOf(a, ca);
684
854
  this.centerOf(b, cb);
685
855
  if (Math.abs(ca.y - cb.y) > ra + rb) continue;
856
+ if (Math.abs(ca.y - cb.y) >= (this.halfHeight(a) + this.halfHeight(b)) * 0.9) continue;
686
857
  const rh = (Math.max(a.halfExtents[0], a.halfExtents[2]) + Math.max(b.halfExtents[0], b.halfExtents[2])) * 0.8;
687
858
  let dx = cb.x - ca.x;
688
859
  let dz = cb.z - ca.z;
@@ -843,6 +1014,7 @@ function specFromObject3D(THREE, root, options = {}) {
843
1014
  const fill = options.fillOf?.(o, material);
844
1015
  const tags = Array.isArray(ud.destructionTags) ? ud.destructionTags : void 0;
845
1016
  const contains = containsOf(ud.destructionContains);
1017
+ const thickness = typeof ud.destructionThickness === "number" && ud.destructionThickness > 0 ? ud.destructionThickness : void 0;
846
1018
  const existing = explicit ? byKey.get(explicit) : void 0;
847
1019
  if (existing) {
848
1020
  const before = sizeOf(existing.box);
@@ -855,8 +1027,9 @@ function specFromObject3D(THREE, root, options = {}) {
855
1027
  existing.skin = existing.skin && skin;
856
1028
  if (fill !== void 0) existing.fill = fill;
857
1029
  if (contains) existing.contains = contains;
1030
+ if (thickness !== void 0) existing.thickness = Math.min(existing.thickness ?? thickness, thickness);
858
1031
  } else {
859
- const e = { key: explicit ?? `m${entries.length}`, meshes: [o], box, material, explicitId: explicit, fill, hollow, skin, tags, fixedId: !!explicit, ...contains ? { contains } : {}, name: o.name, parent: hint?.parent, jointKind: hint?.kind };
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 };
860
1033
  entries.push(e);
861
1034
  if (explicit) byKey.set(explicit, e);
862
1035
  }
@@ -924,7 +1097,8 @@ function specFromObject3D(THREE, root, options = {}) {
924
1097
  const minSide = Math.min(...size);
925
1098
  if (e.fill !== void 0) part.fill = e.fill;
926
1099
  else if (e.hollow) part.fill = 0.05;
927
- if (minSide < 0.01 && METAL.test(e.material)) {
1100
+ if (e.thickness !== void 0) part.thickness = e.thickness;
1101
+ else if (minSide < 0.01 && METAL.test(e.material)) {
928
1102
  part.fill = e.fill ?? 1;
929
1103
  part.thickness = Math.max(minSide, 5e-4);
930
1104
  }
@@ -1018,9 +1192,10 @@ function shellDisplacementAt(THREE, worldPoint, shells, out) {
1018
1192
  const ids = [0, 1, 2].map((corner) => index ? index.getX(t * 3 + corner) : t * 3 + corner);
1019
1193
  ids.forEach((id, corner) => corners[corner].set(shell.before[id * 3], shell.before[id * 3 + 1], shell.before[id * 3 + 2]));
1020
1194
  triangle.set(corners[0], corners[1], corners[2]);
1195
+ if (triangle.getArea() <= 1e-14) continue;
1021
1196
  triangle.closestPointToPoint(local, closest);
1022
1197
  const distance = closest.distanceToSquared(local);
1023
- if (distance >= nearest) continue;
1198
+ if (!(distance < nearest)) continue;
1024
1199
  nearest = distance;
1025
1200
  nearestScale = shell.mesh.matrixWorld.getMaxScaleOnAxis() || 1;
1026
1201
  triangle.getBarycoord(closest, weights);
@@ -1199,6 +1374,20 @@ var Visuals = class {
1199
1374
  return out;
1200
1375
  }
1201
1376
  /** Box of the part in world space. */
1377
+ /** Appends the boxes that follow the mesh in the world (`pushFittedBoxes`) to `out`. */
1378
+ fittedBoxes(mv, out) {
1379
+ const box = new this.THREE.Box3();
1380
+ if (mv.host) this.localBox(mv, box);
1381
+ else {
1382
+ const geometry = mv.mesh.geometry;
1383
+ if (!geometry.boundingBox) geometry.computeBoundingBox();
1384
+ if (geometry.boundingBox) box.copy(geometry.boundingBox);
1385
+ else box.makeEmpty();
1386
+ }
1387
+ if (box.isEmpty()) return;
1388
+ mv.mesh.updateWorldMatrix(true, false);
1389
+ pushFittedBoxes(box, mv.mesh.matrixWorld, out);
1390
+ }
1202
1391
  worldBox(mv, out) {
1203
1392
  if (!mv.host) return out.setFromObject(mv.mesh);
1204
1393
  this.localBox(mv, out);
@@ -1462,6 +1651,21 @@ var Visuals = class {
1462
1651
  d.geometry?.dispose();
1463
1652
  this.decalCount = Math.max(0, this.decalCount - 1);
1464
1653
  }
1654
+ /**
1655
+ * Takes away the decals of a mesh that sit inside a box (world `center`, `half` extents along the axes of the world `rotation`): the surface they
1656
+ * marked is gone (a unit came out of the wall with its bullet holes and spalls).
1657
+ */
1658
+ clearDecals(mv, center, half, rotation) {
1659
+ const inv = rotation.clone().invert();
1660
+ const p = new this.THREE.Vector3();
1661
+ for (let i = mv.decals.length - 1; i >= 0; i--) {
1662
+ const d = mv.decals[i];
1663
+ d.obj.getWorldPosition(p).sub(center).applyQuaternion(inv);
1664
+ if (Math.abs(p.x) > half.x || Math.abs(p.y) > half.y || Math.abs(p.z) > half.z) continue;
1665
+ this.disposeDecal(d);
1666
+ mv.decals.splice(i, 1);
1667
+ }
1668
+ }
1465
1669
  addHole(mv, worldPoint, worldNormal, diameter) {
1466
1670
  const { s, pos, quat } = this.decalPose(mv.mesh, worldPoint, worldNormal, 25e-4);
1467
1671
  const work = mv.host ? mv.host.work : mv.work;
@@ -1944,9 +2148,15 @@ var Visuals = class {
1944
2148
  // src/renderer.ts
1945
2149
  var rendererCount = 0;
1946
2150
  var COLLAPSE_CLOUD_KG = 1e3;
2151
+ var CHIP_DECAL_EDGE = 0.05;
1947
2152
  var IMPACT_TAGS = ["metal", "glass", "wood"];
1948
2153
  var GLOW_FROM_C = 430;
1949
2154
  var QUEUE_CAP = 4e3;
2155
+ var STATIC_REFRESH = 0.5;
2156
+ var STATIC_SPAN = 3;
2157
+ var FEET_BAND = 0.03;
2158
+ var TIP_MIN_HEIGHT = 0.3;
2159
+ var TIP_NARROW = 0.3;
1950
2160
  var FIRE_CEILING_SECONDS = 3600;
1951
2161
  var GENERIC_REFERENCE_RADIUS = 15;
1952
2162
  var PRIMED_FIRE_RADIUS = 0.2;
@@ -1991,6 +2201,7 @@ function impactKey(def) {
1991
2201
  if (def.fracture === "tear") return "impact_soft";
1992
2202
  return def.fracture === "none" ? "impact_metal" : "impact_wood";
1993
2203
  }
2204
+ var dragSoundKey = (def) => impactKey(def).replace("impact_", "drag_");
1994
2205
  function fractureKey(def, style) {
1995
2206
  switch (style) {
1996
2207
  case "shatter":
@@ -2032,6 +2243,14 @@ var Renderer = class {
2032
2243
  __publicField(this, "autoSplit");
2033
2244
  __publicField(this, "maxFragments");
2034
2245
  __publicField(this, "bounds", /* @__PURE__ */ new Map());
2246
+ /** 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
+ __publicField(this, "staticBoxes", /* @__PURE__ */ new Map());
2248
+ /** Objects to measure again; `staticAll` measures every one. */
2249
+ __publicField(this, "staticDirty", /* @__PURE__ */ new Set());
2250
+ __publicField(this, "staticAll", true);
2251
+ /** Boxes of the pieces' meshes (as `staticBoxes`), with the time they were measured. */
2252
+ __publicField(this, "pieceBoxes", /* @__PURE__ */ new WeakMap());
2253
+ __publicField(this, "staticAt", 0);
2035
2254
  __publicField(this, "queue", []);
2036
2255
  __publicField(this, "unsubscribe");
2037
2256
  __publicField(this, "visuals");
@@ -2093,6 +2312,7 @@ var Renderer = class {
2093
2312
  this.cages = new RebarCages(THREE, o.scene);
2094
2313
  this.pieceSys = new PieceSystem(THREE, {
2095
2314
  groundAt: this.groundAt,
2315
+ staticTopAt: (x, z, y, step) => this.staticTopAt(x, z, y, step),
2096
2316
  gravity: this.gravity,
2097
2317
  scene: o.scene,
2098
2318
  onHardLanding: (p, point, speed) => this.hardLanding(p, point, speed)
@@ -2105,6 +2325,7 @@ var Renderer = class {
2105
2325
  // Binding
2106
2326
  // ----------------------------------------------------------------------------------------------------------------------------------
2107
2327
  bind(objectId, root, options = {}) {
2328
+ this.staticDirty.add(objectId);
2108
2329
  if (this.bounds.has(objectId)) this.unbind(objectId);
2109
2330
  const state = this.world.state(objectId);
2110
2331
  const partIds = new Set((state?.parts ?? []).map((p) => p.id));
@@ -2224,6 +2445,7 @@ var Renderer = class {
2224
2445
  }
2225
2446
  }
2226
2447
  unbind(objectId) {
2448
+ this.staticDirty.add(objectId);
2227
2449
  const b = this.bounds.get(objectId);
2228
2450
  if (!b) return;
2229
2451
  this.restore(objectId);
@@ -2263,6 +2485,7 @@ var Renderer = class {
2263
2485
  return m > 0 ? m : 5;
2264
2486
  }
2265
2487
  makePiece(objectId, options = {}) {
2488
+ this.staticDirty.add(objectId);
2266
2489
  const b = this.bounds.get(objectId);
2267
2490
  if (!b) throw new Error(`game-destruction-three: object "${objectId}" is not bound`);
2268
2491
  if (b.root.parent && b.root.parent !== this.scene) this.scene.attach(b.root);
@@ -2375,7 +2598,100 @@ var Renderer = class {
2375
2598
  // ----------------------------------------------------------------------------------------------------------------------------------
2376
2599
  // Events
2377
2600
  // ----------------------------------------------------------------------------------------------------------------------------------
2601
+ /**
2602
+ * The highest top of a bound object that is not a piece under (x, z) a corner at height `y` can land on (at most `step` above it). The world boxes
2603
+ * of their drawn meshes are kept until an event or a moved root changes them; parts drawn inside a merged mesh and meshes wider than `STATIC_SPAN`
2604
+ * are left out (walls, floors, roofs: the terrain under them is the host's `groundAt`).
2605
+ */
2606
+ staticTopAt(x, z, y, step) {
2607
+ let top = -Infinity;
2608
+ for (const a of this.staticNow().values()) {
2609
+ if (a.length === 0 || x < a[0] || x > a[3] || z < a[2] || z > a[5]) continue;
2610
+ for (let i = 6; i < a.length; i += 6) {
2611
+ if (x < a[i] || x > a[i + 3] || z < a[i + 2] || z > a[i + 5]) continue;
2612
+ const t = a[i + 4];
2613
+ if (t > top && t <= y + step) top = t;
2614
+ }
2615
+ }
2616
+ return top;
2617
+ }
2618
+ /** The boxes of the fixed objects (per object: the box around them all, then each one), each object measured again when an event touched it (all of them every `STATIC_REFRESH` s). */
2619
+ staticNow() {
2620
+ if (this.time - this.staticAt > STATIC_REFRESH) {
2621
+ this.staticAt = this.time;
2622
+ this.staticAll = true;
2623
+ }
2624
+ if (!this.staticAll && this.staticDirty.size === 0) return this.staticBoxes;
2625
+ const box = new this.THREE.Box3();
2626
+ const measure = (b) => {
2627
+ if (b.piece || this.pieceSys.get(b.id)) {
2628
+ this.staticBoxes.delete(b.id);
2629
+ return;
2630
+ }
2631
+ const out = [Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity];
2632
+ for (const mv of this.meshesOf(b)) {
2633
+ if (mv.host || mv.loose || !this.visuals.shown(mv)) continue;
2634
+ this.visuals.worldBox(mv, box);
2635
+ if (box.isEmpty() || box.max.x - box.min.x > STATIC_SPAN || box.max.z - box.min.z > STATIC_SPAN) continue;
2636
+ this.visuals.fittedBoxes(mv, out);
2637
+ for (let k = 0; k < 3; k++) {
2638
+ out[k] = Math.min(out[k], box.min.getComponent(k));
2639
+ out[k + 3] = Math.max(out[k + 3], box.max.getComponent(k));
2640
+ }
2641
+ }
2642
+ this.staticBoxes.set(b.id, out.length > 6 ? out : []);
2643
+ };
2644
+ if (this.staticAll) {
2645
+ this.staticBoxes.clear();
2646
+ for (const b of this.bounds.values()) measure(b);
2647
+ } else {
2648
+ for (const id of this.staticDirty) {
2649
+ const b = this.bounds.get(id);
2650
+ if (b) measure(b);
2651
+ else this.staticBoxes.delete(id);
2652
+ }
2653
+ }
2654
+ this.staticAll = false;
2655
+ this.staticDirty.clear();
2656
+ return this.staticBoxes;
2657
+ }
2658
+ /** The boxes of a piece's drawn meshes, measured once per frame. */
2659
+ pieceBoxesOf(pc) {
2660
+ const cached = this.pieceBoxes.get(pc);
2661
+ if (cached && cached.at === this.time) return cached.boxes;
2662
+ const boxes = [];
2663
+ const b = this.bounds.get(pc.objectId);
2664
+ if (b) {
2665
+ for (const mv of this.meshesOf(b)) {
2666
+ if (this.visuals.shown(mv)) this.visuals.fittedBoxes(mv, boxes);
2667
+ }
2668
+ }
2669
+ this.pieceBoxes.set(pc, { at: this.time, boxes });
2670
+ return boxes;
2671
+ }
2672
+ solidsNear(x, z, radius) {
2673
+ const found = [];
2674
+ const scan = (a, id, piece, from) => {
2675
+ if (from > 0 && (a.length === 0 || x < a[0] - radius || x > a[3] + radius || z < a[2] - radius || z > a[5] + radius)) return;
2676
+ for (let i = from; i < a.length; i += 6) {
2677
+ if (x < a[i] - radius || x > a[i + 3] + radius || z < a[i + 2] - radius || z > a[i + 5] + radius) continue;
2678
+ found.push({ objectId: id, min: [a[i], a[i + 1], a[i + 2]], max: [a[i + 3], a[i + 4], a[i + 5]], piece });
2679
+ }
2680
+ };
2681
+ for (const [id, a] of this.staticNow()) scan(a, id, null, 6);
2682
+ const c = this.v1;
2683
+ for (const pc of this.pieceSys.list) {
2684
+ if (pc.held) continue;
2685
+ this.pieceSys.centerOf(pc, c);
2686
+ const reach = Math.hypot(pc.halfExtents[0], pc.halfExtents[1], pc.halfExtents[2]) + radius;
2687
+ if (Math.abs(c.x - x) > reach || Math.abs(c.z - z) > reach) continue;
2688
+ scan(this.pieceBoxesOf(pc), pc.objectId, pc, 0);
2689
+ }
2690
+ this.fragments.solidsNear(x, z, radius, (home, min, max, loose) => found.push({ objectId: home, min, max, piece: null, loose }));
2691
+ return found;
2692
+ }
2378
2693
  apply(ev) {
2694
+ this.staticDirty.add(ev.object);
2379
2695
  switch (ev.type) {
2380
2696
  case "damage":
2381
2697
  if (ev.damageType === "heat" || ev.damageType === "fire" || ev.damageType === "explosive") this.activate(ev.object, ev.part);
@@ -2396,7 +2712,7 @@ var Renderer = class {
2396
2712
  break;
2397
2713
  }
2398
2714
  case "debris":
2399
- this.vfx.burst(ev.kind, ev.point, ev.normal, ev.count, ev.speed > 0 ? ev.speed : DEFAULT_SPEED[ev.kind] ?? 1, this.debrisOptions(ev.kind, ev.object, ev.part));
2715
+ this.vfx.burst(ev.kind, ev.point, ev.normal, ev.count, ev.speed > 0 ? ev.speed : DEFAULT_SPEED[ev.kind] ?? 1, this.debrisOptions(ev.kind, ev.object, ev.part, ev.material));
2400
2716
  break;
2401
2717
  case "fracture":
2402
2718
  this.onFracture(ev);
@@ -2569,10 +2885,11 @@ var Renderer = class {
2569
2885
  }
2570
2886
  return def ? fallbackColor(def.id, def) : null;
2571
2887
  }
2572
- debrisOptions(kind, objectId, partId) {
2888
+ debrisOptions(kind, objectId, partId, material) {
2573
2889
  const pv = this.partVis(objectId, partId);
2574
- const def = pv ? this.world.materials.get(pv.material) : void 0;
2890
+ const def = this.world.materials.get(material ?? pv?.material ?? "");
2575
2891
  if (kind === "smoke") return { color: SMOKE_TONES[def?.thermal.smoke ?? "grey"] };
2892
+ if ((kind === "dust" || kind === "chips") && def?.visual?.innerColor !== void 0) return { color: def.visual.innerColor };
2576
2893
  if (kind === "fibers" || kind === "leaves") {
2577
2894
  if (def?.visual?.innerColor !== void 0) return { color: def.visual.innerColor };
2578
2895
  const c = pv ? this.visuals.baseColorHex(pv.meshes[0] ?? {}) : -1;
@@ -2680,8 +2997,11 @@ var Renderer = class {
2680
2997
  const mv = this.nearestMesh(pv, wp);
2681
2998
  if (!mv) return;
2682
2999
  const def = this.world.materials.get(pv.material);
2683
- this.playEvent({ kind: "spall", material: this.impactMaterial(def) }, ev.point);
2684
- this.visuals.addSpall(mv, wp, this.faceNormal(mv, wp), Math.sqrt(ev.area / Math.PI), fallbackColor(pv.material, def));
3000
+ const normal = this.faceNormal(mv, wp);
3001
+ const core = def?.visual?.innerColor;
3002
+ if (core !== void 0) this.vfx.burst("chips", ev.point, [normal.x, normal.y, normal.z], 5, 2.5, { color: core });
3003
+ else this.playEvent({ kind: "spall", material: this.impactMaterial(def) }, ev.point);
3004
+ this.visuals.addSpall(mv, wp, normal, Math.sqrt(ev.area / Math.PI), core ?? fallbackColor(pv.material, def));
2685
3005
  }
2686
3006
  /**
2687
3007
  * Stage 2 of a staged part: the unit-sized fragments come out and the part stays drawn (hiding it is what `fracture` does). The opening itself
@@ -2704,16 +3024,27 @@ var Renderer = class {
2704
3024
  this.sound(impactKey(def), ev.opening.center, 0.8);
2705
3025
  }
2706
3026
  /**
2707
- * A unit came out of a wall of units: the unit flies as a fragment with a puff of its dust. The gap itself is the drawing's (the house renderer redraws
2708
- * the wall as its courses without that unit); a part drawn as one box keeps its box.
3027
+ * A unit came out of a wall of units: the unit flies as a fragment with a puff of its dust, and the marks drawn on it (bullet holes, spalls, the impact
3028
+ * marks of the effects) go with it. The gap itself is the drawing's (the house renderer cuts the wall where the unit was); a part drawn as one box
3029
+ * keeps its box.
2709
3030
  */
2710
3031
  onChip(ev) {
2711
3032
  const pv = this.partVis(ev.object, ev.part);
2712
3033
  const def = pv ? this.world.materials.get(pv.material) : void 0;
2713
3034
  const color = (pv ? this.partColor(pv, def) : null) ?? fallbackColor(pv?.material ?? "", def);
2714
3035
  const maxAlive = this.low ? Math.floor(this.maxFragments / 2) : this.maxFragments;
2715
- for (const fr of ev.fragments) this.fragments.spawn(fr, this.fragments.materialFor(fr.material, fallbackColor(fr.material, def), false), ev.object, maxAlive);
2716
- if (!this.playEvent({ kind: "spall", material: this.impactMaterial(def) }, ev.center)) this.vfx.burst("dust", ev.center, [0, 1, 0], 6, 0.8, { color });
3036
+ for (const fr of ev.fragments) {
3037
+ const fdef = this.world.materials.get(fr.material);
3038
+ const tone2 = fr.inner && fdef?.visual?.innerColor !== void 0 ? fdef.visual.innerColor : fdef === def ? color : fallbackColor(fr.material, fdef);
3039
+ this.fragments.spawn(fr, this.fragments.materialFor(fr.material, tone2, false), ev.object, maxAlive);
3040
+ }
3041
+ const T = this.THREE;
3042
+ const center = new T.Vector3(ev.center[0], ev.center[1], ev.center[2]);
3043
+ 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);
3044
+ const rotation = new T.Quaternion(ev.rotation[0], ev.rotation[1], ev.rotation[2], ev.rotation[3]);
3045
+ for (const part of this.bounds.get(ev.object)?.parts.values() ?? []) for (const mv of part.meshes) this.visuals.clearDecals(mv, center, half, rotation);
3046
+ this.vfx.peekWorkshop()?.decals.clearInside(ev.center, [half.x, half.y, half.z], ev.rotation);
3047
+ this.vfx.burst("dust", ev.center, [0, 1, 0], 8, 0.8, { color });
2717
3048
  this.sound(impactKey(def), ev.center, 0.7);
2718
3049
  }
2719
3050
  onFracture(ev) {
@@ -2793,6 +3124,9 @@ var Renderer = class {
2793
3124
  this.pieceSys.setCenter(old, center);
2794
3125
  old.mass = Math.max(0.05, this.massOf(objectId));
2795
3126
  old.sleeping = false;
3127
+ } else if (kitOriginOf(b.root)?.kind === "furniture") {
3128
+ this.makePiece(objectId);
3129
+ this.tipIfTopHeavy(b);
2796
3130
  }
2797
3131
  }
2798
3132
  /** The renderer side of `splitGroup`: a new group holding the meshes of the new object, made a piece. */
@@ -2839,8 +3173,32 @@ var Renderer = class {
2839
3173
  }
2840
3174
  const piece = this.pieceSys.create(newId, group, half, center, this.massOf(newId), vel, ang);
2841
3175
  nb.piece = piece;
3176
+ if (kitOriginOf(from.root)?.kind === "furniture") this.tipIfTopHeavy(nb);
2842
3177
  this.o.onPiece?.(piece);
2843
3178
  }
3179
+ /**
3180
+ * A piece of furniture standing on feet too close for its height (a board on two legs in a line, a lone leg) does not stay up: the box of a piece hides that its weight is up
3181
+ * high and its feet are thin, so it gets the start of the fall, across the line of its feet.
3182
+ */
3183
+ tipIfTopHeavy(b) {
3184
+ const piece = b.piece;
3185
+ if (!piece) return;
3186
+ const box = new this.THREE.Box3();
3187
+ const boxes = this.meshesOf(b).filter((mv) => this.visuals.shown(mv)).map((mv) => this.visuals.worldBox(mv, new this.THREE.Box3()));
3188
+ if (boxes.length === 0) return;
3189
+ for (const one of boxes) box.union(one);
3190
+ const height = box.max.y - box.min.y;
3191
+ const feet = new this.THREE.Box3();
3192
+ for (const one of boxes) if (one.min.y < box.min.y + FEET_BAND) feet.union(one);
3193
+ const spanX = feet.max.x - feet.min.x;
3194
+ const spanZ = feet.max.z - feet.min.z;
3195
+ if (height < TIP_MIN_HEIGHT || Math.min(spanX, spanZ) >= TIP_NARROW * height) return;
3196
+ const across = spanX <= spanZ ? 0 : 2;
3197
+ const lean = across === 0 ? box.min.x + box.max.x - feet.min.x - feet.max.x : box.min.z + box.max.z - feet.min.z - feet.max.z;
3198
+ const away = lean < 0 ? -1 : 1;
3199
+ if (across === 0) this.pieceSys.tip(piece, 0, -away);
3200
+ else this.pieceSys.tip(piece, away, 0);
3201
+ }
2844
3202
  onExplode(ev, options = {}) {
2845
3203
  const p = ev.point;
2846
3204
  if (options.visual !== false) this.playExplosion(ev, options);
@@ -3091,6 +3449,10 @@ var Renderer = class {
3091
3449
  this.vfx.sprites().clear();
3092
3450
  this.ownVfx?.world.clear();
3093
3451
  }
3452
+ wakeRegion(x, z, radius) {
3453
+ this.fragments.wakeRegion(x, z, radius);
3454
+ this.pieceSys.wakeRegion(x, z, radius);
3455
+ }
3094
3456
  stats() {
3095
3457
  return { fragments: this.fragments.count, particles: this.vfx.sprites().live + this.ownedVfxParticles(), pieces: this.pieceSys.list.length, decals: this.visuals.decalCount + this.ground.count };
3096
3458
  }
@@ -3204,7 +3566,15 @@ var BURN_LOOP = {
3204
3566
  volume: 0,
3205
3567
  sources: ["cena:L1316 fire loop (silent in the page: its gain is never raised)", "sdk: the level while burning"]
3206
3568
  };
3207
- var DESTRUCTION_SOUND_BANK = { id: "destruction_three", sounds: [...SCENE_SOUNDS, EXPLOSION_BOOM], loops: [BURN_LOOP] };
3569
+ var drag = (id, layers) => ({ ...sceneRecipe(id, layers, ""), sources: ["sdk: a base dragged on a floor, one grain per stretch"] });
3570
+ var DRAG_SOUNDS = [
3571
+ drag("drag_wood", [noise({ type: "bandpass", f: 380, q: 1.4, d: 0.28, vol: 0.5 }), noise({ type: "bandpass", f: [900, 1300], q: 3, d: 0.08, vol: 0.14 })]),
3572
+ drag("drag_metal", [noise({ type: "bandpass", f: 1500, q: 4, d: 0.26, vol: 0.32 }), tone({ f: [880, 1240], f2: 760, d: 0.18, vol: 0.05 })]),
3573
+ drag("drag_glass", [noise({ type: "bandpass", f: 2600, q: 5, d: 0.2, vol: 0.22 })]),
3574
+ 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
+ drag("drag_soft", [noise({ type: "lowpass", f: 420, d: 0.28, vol: 0.3 })])
3576
+ ];
3577
+ var DESTRUCTION_SOUND_BANK = { id: "destruction_three", sounds: [...SCENE_SOUNDS, ...DRAG_SOUNDS, EXPLOSION_BOOM], loops: [BURN_LOOP] };
3208
3578
 
3209
3579
  // src/audio/sound.ts
3210
3580
  import { GAME_SFX_BANK } from "@adatechnology/game-audio";
@@ -3215,6 +3585,11 @@ var DESTRUCTION_SOUND_KEYS = {
3215
3585
  impact_glass: { sound: "ping" },
3216
3586
  impact_masonry: { sound: "chip" },
3217
3587
  impact_soft: { sound: "thud" },
3588
+ drag_wood: { sound: "drag_wood" },
3589
+ drag_metal: { sound: "drag_metal" },
3590
+ drag_glass: { sound: "drag_glass" },
3591
+ drag_masonry: { sound: "drag_masonry" },
3592
+ drag_soft: { sound: "drag_soft" },
3218
3593
  shatter_glass: { sound: "glass_shatter" },
3219
3594
  crumble: { sound: "crumble" },
3220
3595
  splinter: { sound: "crack" },
@@ -3287,11 +3662,14 @@ export {
3287
3662
  DEFAULT_FIRE,
3288
3663
  DESTRUCTION_SOUND_BANK,
3289
3664
  DESTRUCTION_SOUND_KEYS,
3665
+ DRAG_SOUNDS,
3290
3666
  EXPLOSION_BOOM,
3291
3667
  SCENE_SOUNDS,
3292
3668
  createDestructionRenderer,
3293
3669
  createDestructionSound,
3670
+ dragSoundKey,
3294
3671
  heatColor,
3672
+ impactKey,
3295
3673
  specFromObject3D
3296
3674
  };
3297
3675
  //# sourceMappingURL=index.js.map