@adatechnology/game-destruction-three 0.7.2 → 0.8.0

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
@@ -3,6 +3,7 @@ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { en
3
3
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
4
4
 
5
5
  // src/renderer.ts
6
+ import { blastShove } from "@adatechnology/game-contracts";
6
7
  import { createVfx, effectForEvent, EVENT_EFFECTS_MAP } from "@adatechnology/game-vfx";
7
8
  import { createVfxRenderer } from "@adatechnology/game-vfx-three";
8
9
 
@@ -135,6 +136,7 @@ var FragmentSystem = class {
135
136
  const sc = new this.THREE.Vector3(Math.max(5e-3, sz[0]), Math.max(5e-3, sz[1]), Math.max(3e-3, sz[2]));
136
137
  mesh.scale.copy(sc);
137
138
  mesh.castShadow = true;
139
+ mesh.userData.destructionDebris = true;
138
140
  this.o.scene.add(mesh);
139
141
  const live = {
140
142
  mesh,
@@ -342,6 +344,7 @@ var GroundSpots = class {
342
344
  mesh.position.set(x, y, z);
343
345
  mesh.scale.setScalar(1e-3);
344
346
  mesh.renderOrder = 1;
347
+ mesh.userData.destructionDebris = true;
345
348
  this.scene.add(mesh);
346
349
  this.spots.push({ mesh, kind, home, x, z, radius: 1e-3, target: Math.min(MAX_RADIUS, radius) });
347
350
  }
@@ -770,7 +773,12 @@ var RebarCages = class {
770
773
  };
771
774
 
772
775
  // src/spec.ts
773
- import { guessMaterial, inferJoints } from "@adatechnology/game-destruction";
776
+ import { guessMaterial, inferJoints, MATERIALS, partMass } from "@adatechnology/game-destruction";
777
+ function containsOf(value) {
778
+ if (!value || typeof value !== "object") return void 0;
779
+ const v = value;
780
+ return typeof v.fluid === "string" && typeof v.liters === "number" && v.liters > 0 ? { fluid: v.fluid, liters: v.liters } : void 0;
781
+ }
774
782
  function partHintOf(value) {
775
783
  if (typeof value === "string") return value ? { id: value } : null;
776
784
  if (!value || typeof value !== "object") return null;
@@ -791,11 +799,17 @@ function isMesh(o) {
791
799
  function sizeOf(box) {
792
800
  return [box.max.x - box.min.x, box.max.y - box.min.y, box.max.z - box.min.z];
793
801
  }
802
+ function specFrameInverse(THREE, root) {
803
+ root.updateMatrixWorld(true);
804
+ const position = new THREE.Vector3();
805
+ const quaternion = new THREE.Quaternion();
806
+ root.matrixWorld.decompose(position, quaternion, new THREE.Vector3());
807
+ return new THREE.Matrix4().compose(position, quaternion, new THREE.Vector3(1, 1, 1)).invert();
808
+ }
794
809
  function specFromObject3D(THREE, root, options = {}) {
795
810
  const fallback = options.fallback ?? "plastic";
796
811
  const minSize = options.minSize ?? 0.015;
797
- root.updateMatrixWorld(true);
798
- const inv = root.matrixWorld.clone().invert();
812
+ const inv = specFrameInverse(THREE, root);
799
813
  const entries = [];
800
814
  const byKey = /* @__PURE__ */ new Map();
801
815
  const visit = (o) => {
@@ -812,8 +826,10 @@ function specFromObject3D(THREE, root, options = {}) {
812
826
  const material = options.materialOf?.(o) ?? (typeof ud.destructionMaterial === "string" ? ud.destructionMaterial : void 0) ?? hint?.material ?? guessMaterial(`${o.name} ${matName} ${typeof ud.material === "string" ? ud.material : ""}`) ?? fallback;
813
827
  const explicit = hint?.id ?? null;
814
828
  const hollow = ud.hollow === true || /hollow/i.test(o.name);
829
+ const skin = ud.destructionSkin === true;
815
830
  const fill = options.fillOf?.(o, material);
816
831
  const tags = Array.isArray(ud.destructionTags) ? ud.destructionTags : void 0;
832
+ const contains = containsOf(ud.destructionContains);
817
833
  const existing = explicit ? byKey.get(explicit) : void 0;
818
834
  if (existing) {
819
835
  const before = sizeOf(existing.box);
@@ -823,9 +839,11 @@ function specFromObject3D(THREE, root, options = {}) {
823
839
  existing.meshes.push(o);
824
840
  if (ns[0] * ns[1] * ns[2] > bv) existing.material = material;
825
841
  existing.hollow = existing.hollow || hollow;
842
+ existing.skin = existing.skin && skin;
826
843
  if (fill !== void 0) existing.fill = fill;
844
+ if (contains) existing.contains = contains;
827
845
  } else {
828
- const e = { key: explicit ?? `m${entries.length}`, meshes: [o], box, material, explicitId: explicit, fill, hollow, tags, fixedId: !!explicit, name: o.name, parent: hint?.parent, jointKind: hint?.kind };
846
+ 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 };
829
847
  entries.push(e);
830
848
  if (explicit) byKey.set(explicit, e);
831
849
  }
@@ -844,7 +862,7 @@ function specFromObject3D(THREE, root, options = {}) {
844
862
  const largest = (e) => Math.max(...sizeOf(e.box));
845
863
  const kept = [];
846
864
  const tiny = [];
847
- for (const e of entries) (!e.fixedId && largest(e) < minSize ? tiny : kept).push(e);
865
+ for (const e of entries) (e.skin || !e.fixedId && largest(e) < minSize ? tiny : kept).push(e);
848
866
  for (const t of tiny) {
849
867
  let best = null;
850
868
  let bestV = 0;
@@ -898,11 +916,21 @@ function specFromObject3D(THREE, root, options = {}) {
898
916
  part.thickness = Math.max(minSide, 5e-4);
899
917
  }
900
918
  if (e.tags) part.tags = [...e.tags];
919
+ if (e.contains) part.contains = { ...e.contains };
901
920
  const ao = options.anchorBelow ?? -1;
902
921
  if (ao >= 0 && e.box.min.y <= ao) part.anchored = true;
903
922
  parts.push(part);
904
923
  meshesOf[id] = e.meshes;
905
924
  }
925
+ const knownMass = root.userData?.destructionMass;
926
+ if (typeof knownMass === "number" && knownMass > 0) {
927
+ const shares = parts.map((p) => {
928
+ const material = MATERIALS.find((m) => m.id === p.material);
929
+ return material ? partMass(p, material) : 0;
930
+ });
931
+ const total = shares.reduce((a, b) => a + b, 0);
932
+ if (total > 0) parts.forEach((p, i) => p.mass = knownMass * shares[i] / total);
933
+ }
906
934
  const spec = { parts };
907
935
  if (options.name !== void 0) spec.name = options.name;
908
936
  else if (root.name) spec.name = root.name;
@@ -954,6 +982,46 @@ function heatColor(temperatureC) {
954
982
  return last[1];
955
983
  }
956
984
 
985
+ // src/skin-dent.ts
986
+ function shellDisplacementAt(THREE, worldPoint, shells, out) {
987
+ const triangle = new THREE.Triangle();
988
+ const corners = [new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()];
989
+ const local = new THREE.Vector3();
990
+ const closest = new THREE.Vector3();
991
+ const weights = new THREE.Vector3();
992
+ const moved = new THREE.Vector3();
993
+ let nearest = Infinity;
994
+ out.set(0, 0, 0);
995
+ let nearestScale = 1;
996
+ for (const shell of shells) {
997
+ shell.mesh.updateWorldMatrix(true, false);
998
+ shell.mesh.worldToLocal(local.copy(worldPoint));
999
+ const index = shell.mesh.geometry.index;
1000
+ const triangles = index ? index.count / 3 : shell.before.length / 9;
1001
+ for (let t = 0; t < triangles; t++) {
1002
+ const ids = [0, 1, 2].map((corner) => index ? index.getX(t * 3 + corner) : t * 3 + corner);
1003
+ ids.forEach((id, corner) => corners[corner].set(shell.before[id * 3], shell.before[id * 3 + 1], shell.before[id * 3 + 2]));
1004
+ triangle.set(corners[0], corners[1], corners[2]);
1005
+ triangle.closestPointToPoint(local, closest);
1006
+ const distance = closest.distanceToSquared(local);
1007
+ if (distance >= nearest) continue;
1008
+ nearest = distance;
1009
+ nearestScale = shell.mesh.matrixWorld.getMaxScaleOnAxis() || 1;
1010
+ triangle.getBarycoord(closest, weights);
1011
+ moved.set(0, 0, 0);
1012
+ ids.forEach((id, corner) => {
1013
+ const weight = corner === 0 ? weights.x : corner === 1 ? weights.y : weights.z;
1014
+ moved.x += weight * (shell.after[id * 3] - shell.before[id * 3]);
1015
+ moved.y += weight * (shell.after[id * 3 + 1] - shell.before[id * 3 + 1]);
1016
+ moved.z += weight * (shell.after[id * 3 + 2] - shell.before[id * 3 + 2]);
1017
+ });
1018
+ out.copy(moved).transformDirection(shell.mesh.matrixWorld);
1019
+ out.multiplyScalar(moved.length() * (shell.mesh.matrixWorld.getMaxScaleOnAxis() || 1));
1020
+ }
1021
+ }
1022
+ return Math.sqrt(nearest) * nearestScale;
1023
+ }
1024
+
957
1025
  // src/visual.ts
958
1026
  var POS_ATTR = "position";
959
1027
  function positionAttr(geo) {
@@ -1018,6 +1086,12 @@ var DecalKit = class {
1018
1086
  }
1019
1087
  };
1020
1088
  var tmpV = (THREE) => new THREE.Vector3();
1089
+ var MIN_DENT_VERTICES = 10;
1090
+ var WIDEN_MARGIN = 1.1;
1091
+ var SKIN_FOLLOW = 0.85;
1092
+ var SKIN_SLACK = 5e-4;
1093
+ var SKIN_LAYER_SLACK = 4;
1094
+ var NEAREST_MARGIN = 1.5;
1021
1095
  var Visuals = class {
1022
1096
  constructor(THREE, lowQuality, decalCap) {
1023
1097
  __publicField(this, "THREE", THREE);
@@ -1186,14 +1260,14 @@ var Visuals = class {
1186
1260
  * Push the vertices of the mesh within `dent.radius` of `worldPoint` inwards along `worldNormal` by `depth * (1 - (d/r)^2)^2`.
1187
1261
  * Returns false when nothing was changed (too far, low quality on a heavy mesh, or no editable positions).
1188
1262
  */
1189
- applyDent(mv, worldPoint, worldNormal, dent) {
1263
+ applyDent(mv, worldPoint, worldNormal, dent, reachWorld = 0) {
1190
1264
  const mesh = mv.mesh;
1191
1265
  if (this.lowQuality && this.vertexCount(mv) > 2e4) return false;
1192
1266
  mesh.updateWorldMatrix(true, false);
1193
1267
  const s = mesh.matrixWorld.getMaxScaleOnAxis() || 1;
1194
1268
  const lp = mesh.worldToLocal(this.a.copy(worldPoint));
1195
1269
  const ln = this.b.copy(worldNormal).transformDirection(this.m4.copy(mesh.matrixWorld).invert()).normalize();
1196
- const r = dent.radius / s;
1270
+ const r = Math.max(dent.radius, reachWorld) / s;
1197
1271
  const depth = dent.depth / s;
1198
1272
  const geo = mesh.geometry;
1199
1273
  let bb;
@@ -1206,34 +1280,110 @@ var Visuals = class {
1206
1280
  if (!this.ownGeometry(mv)) return false;
1207
1281
  const work = mv.host ? mv.host.work : mv.work;
1208
1282
  const pos = positionAttr(mesh.geometry);
1209
- const r2 = r * r;
1210
- let touched = false;
1211
- const px = lp.x;
1212
- const py = lp.y;
1213
- const pz = lp.z;
1214
- const nx = ln.x;
1215
- const ny = ln.y;
1216
- const nz = ln.z;
1217
1283
  const from = mv.range ? mv.range[0] * 3 : 0;
1218
1284
  const to = mv.range ? mv.range[1] * 3 : work.length;
1285
+ const reach = r;
1286
+ const reach2 = reach * reach;
1219
1287
  for (let i = from; i < to; i += 3) {
1220
- const dx = work[i] - px;
1221
- const dy = work[i + 1] - py;
1222
- const dz = work[i + 2] - pz;
1288
+ const dx = work[i] - lp.x;
1289
+ const dy = work[i + 1] - lp.y;
1290
+ const dz = work[i + 2] - lp.z;
1223
1291
  const d2 = dx * dx + dy * dy + dz * dz;
1224
- if (d2 >= r2) continue;
1225
- const f = 1 - d2 / r2;
1292
+ if (d2 >= reach2) continue;
1293
+ const f = 1 - d2 / reach2;
1226
1294
  const k = depth * f * f;
1227
- work[i] -= nx * k;
1228
- work[i + 1] -= ny * k;
1229
- work[i + 2] -= nz * k;
1230
- touched = true;
1295
+ work[i] -= ln.x * k;
1296
+ work[i + 1] -= ln.y * k;
1297
+ work[i + 2] -= ln.z * k;
1231
1298
  }
1232
- if (!touched) return false;
1233
1299
  mv.touched = true;
1234
1300
  this.writePositions(mv, pos);
1235
1301
  return true;
1236
1302
  }
1303
+ /**
1304
+ * World radius a dent needs on this mesh to reach `MIN_DENT_VERTICES` vertices (the dent's own radius when the mesh is fine enough). A part with several meshes
1305
+ * (a shell and its label) must dent them all with the largest of these, so the skin and the shell follow the same smooth surface and never cross.
1306
+ */
1307
+ dentReach(mv, worldPoint, dent) {
1308
+ const mesh = mv.mesh;
1309
+ mesh.updateWorldMatrix(true, false);
1310
+ const s = mesh.matrixWorld.getMaxScaleOnAxis() || 1;
1311
+ const lp = mesh.worldToLocal(this.a.copy(worldPoint));
1312
+ const source = (mv.host ? mv.host.work : mv.work) ?? positionAttr(mesh.geometry)?.array;
1313
+ if (!source) return dent.radius;
1314
+ let bounds;
1315
+ if (mv.host) bounds = this.localBox(mv, this.lbox);
1316
+ else {
1317
+ if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox();
1318
+ bounds = mesh.geometry.boundingBox;
1319
+ }
1320
+ if (bounds && !bounds.isEmpty() && bounds.distanceToPoint(lp) > dent.radius / s) return dent.radius;
1321
+ const from = mv.range ? mv.range[0] * 3 : 0;
1322
+ const to = mv.range ? mv.range[1] * 3 : source.length;
1323
+ const r2 = (dent.radius / s) ** 2;
1324
+ const distances = [];
1325
+ let inside = 0;
1326
+ for (let i = from; i < to; i += 3) {
1327
+ const dx = source[i] - lp.x;
1328
+ const dy = source[i + 1] - lp.y;
1329
+ const dz = source[i + 2] - lp.z;
1330
+ const d2 = dx * dx + dy * dy + dz * dz;
1331
+ if (d2 < r2) inside++;
1332
+ distances.push(d2);
1333
+ }
1334
+ if (inside >= MIN_DENT_VERTICES || distances.length < MIN_DENT_VERTICES) return dent.radius;
1335
+ distances.sort((x, y) => x - y);
1336
+ const nearest = Math.sqrt(distances[0]);
1337
+ return Math.max(dent.radius, Math.sqrt(distances[MIN_DENT_VERTICES - 1]) * s * WIDEN_MARGIN, nearest * s * NEAREST_MARGIN);
1338
+ }
1339
+ /**
1340
+ * Dent all the meshes of a part. The shells take the dent as `applyDent` gives it; a skin (label, coat: `userData.destructionSkin`) sits a hair above its shell
1341
+ * and moves with the shell's surface instead (see `shellDisplacementAt`), so the shell never shows through it whatever the two meshes' vertex grids are.
1342
+ */
1343
+ applyPartDent(meshes, worldPoint, worldNormal, dent, reachWorld) {
1344
+ const isSkin = (mv) => mv.mesh.userData.destructionSkin === true && !mv.host;
1345
+ const shells = meshes.filter((mv) => !isSkin(mv));
1346
+ const snapshots = [];
1347
+ for (const mv of shells) {
1348
+ const heavy = this.lowQuality && this.vertexCount(mv) > 2e4;
1349
+ const plain = !mv.host && !heavy && this.ownGeometry(mv) && mv.work;
1350
+ const before = plain ? new Float32Array(mv.work) : null;
1351
+ this.applyDent(mv, worldPoint, worldNormal, dent, reachWorld);
1352
+ if (before) snapshots.push({ mesh: mv.mesh, before, after: mv.work });
1353
+ }
1354
+ const displacement = this.t;
1355
+ const vertex = this.a;
1356
+ for (const mv of meshes) {
1357
+ if (!isSkin(mv)) continue;
1358
+ if (snapshots.length === 0) {
1359
+ this.applyDent(mv, worldPoint, worldNormal, dent, reachWorld);
1360
+ continue;
1361
+ }
1362
+ if (!this.ownGeometry(mv)) continue;
1363
+ const work = mv.work;
1364
+ const mesh = mv.mesh;
1365
+ mesh.updateWorldMatrix(true, false);
1366
+ const toLocal = this.m4.copy(mesh.matrixWorld).invert();
1367
+ const scale = mesh.matrixWorld.getMaxScaleOnAxis() || 1;
1368
+ let touched = false;
1369
+ for (let i = 0; i < work.length; i += 3) {
1370
+ vertex.set(work[i], work[i + 1], work[i + 2]).applyMatrix4(mesh.matrixWorld);
1371
+ if (vertex.distanceTo(worldPoint) > reachWorld) continue;
1372
+ const height = shellDisplacementAt(this.THREE, vertex, snapshots, displacement);
1373
+ const length = displacement.length();
1374
+ if (length === 0) continue;
1375
+ const falloff = 1 - (vertex.distanceTo(worldPoint) / reachWorld) ** 2;
1376
+ const smooth = dent.depth * falloff * falloff;
1377
+ const pull = Math.max(0, Math.min(length, smooth) * SKIN_FOLLOW - SKIN_SLACK - height * SKIN_LAYER_SLACK);
1378
+ displacement.transformDirection(toLocal).multiplyScalar(pull / scale);
1379
+ work[i] += displacement.x;
1380
+ work[i + 1] += displacement.y;
1381
+ work[i + 2] += displacement.z;
1382
+ touched = true;
1383
+ }
1384
+ if (touched) this.writePositions(mv, positionAttr(mesh.geometry));
1385
+ }
1386
+ }
1237
1387
  /** Copy `work` (dents) into the geometry, adding the melt sag when the mesh is melting. */
1238
1388
  writePositions(mv, pos) {
1239
1389
  if (mv.host) {
@@ -1280,6 +1430,7 @@ var Visuals = class {
1280
1430
  return { s, pos, quat };
1281
1431
  }
1282
1432
  addDecal(mv, decal) {
1433
+ decal.obj.userData.destructionDebris = true;
1283
1434
  mv.mesh.add(decal.obj);
1284
1435
  mv.decals.push(decal);
1285
1436
  this.decalCount++;
@@ -1864,6 +2015,8 @@ var Renderer = class {
1864
2015
  __publicField(this, "tmpQ");
1865
2016
  __publicField(this, "v1");
1866
2017
  __publicField(this, "v2");
2018
+ // The last warm-up keeps its materials until the next one: a material that does not use lights keeps the program of the lights it was first drawn with, so each warm-up needs materials of its own.
2019
+ __publicField(this, "warmProbes");
1867
2020
  this.world = o.world;
1868
2021
  this.scene = o.scene;
1869
2022
  this.quality = o.quality ?? "medium";
@@ -1961,6 +2114,24 @@ var Renderer = class {
1961
2114
  if (!bound.parts.has(pid)) bound.parts.set(pid, this.makePartVis(objectId, pid, this.world.part(objectId, pid)?.material ?? "", meshes, hosts));
1962
2115
  }
1963
2116
  this.bounds.set(objectId, bound);
2117
+ for (const pv of bound.parts.values()) this.tagPart(pv, objectId);
2118
+ }
2119
+ /**
2120
+ * Label the meshes of a part so a host can go from a raycast hit to the world: `userData.destructionPart` (the part id) and
2121
+ * `userData.destructionObject` (the id of the world object that part belongs to now; a loose piece has an id of its own). Parts drawn by a range
2122
+ * of a merged mesh share that mesh with others and are left alone until they get a mesh of their own. Decals are marked `destructionDebris`.
2123
+ */
2124
+ tagPart(pv, objectId) {
2125
+ for (const mv of pv.meshes) {
2126
+ if (mv.merged && !mv.loose) continue;
2127
+ mv.mesh.traverse((c) => {
2128
+ if (!c.isMesh) return;
2129
+ const ud = c.userData;
2130
+ if (ud.destructionDebris) return;
2131
+ if (partHintOf(ud.destructionPart)?.id !== pv.id) ud.destructionPart = pv.id;
2132
+ ud.destructionObject = objectId;
2133
+ });
2134
+ }
1964
2135
  }
1965
2136
  makePartVis(home, id, material, meshes, hosts) {
1966
2137
  return { id, home, material, meshes: meshes.map((m) => this.visuals.makeMeshVis(m.mesh, home, m.range, hosts)), charApplied: 0, glowApplied: 0, meltApplied: 0, gone: "none" };
@@ -1974,13 +2145,13 @@ var Renderer = class {
1974
2145
  specParts = void 0;
1975
2146
  }
1976
2147
  if (!specParts) return;
1977
- root.updateMatrixWorld(true);
2148
+ const frame = specFrameInverse(this.THREE, root);
1978
2149
  const e = new this.THREE.Euler();
1979
2150
  const q = new this.THREE.Quaternion();
1980
2151
  for (const m of meshes) {
1981
2152
  this.box.setFromObject(m);
1982
2153
  if (this.box.isEmpty()) continue;
1983
- const c = root.worldToLocal(this.box.getCenter(this.v1));
2154
+ const c = this.box.getCenter(this.v1).applyMatrix4(frame);
1984
2155
  let best = null;
1985
2156
  let bestVol = Infinity;
1986
2157
  for (const p of specParts) {
@@ -2017,10 +2188,9 @@ var Renderer = class {
2017
2188
  // ----------------------------------------------------------------------------------------------------------------------------------
2018
2189
  // Pieces
2019
2190
  // ----------------------------------------------------------------------------------------------------------------------------------
2020
- /** Half extents and centre of the box around some meshes, in the local space of `root`. */
2191
+ /** Half extents and centre of the box around some meshes, in the metric frame of `root` (its pose removed, its scale kept). */
2021
2192
  measure(root, meshes) {
2022
- root.updateMatrixWorld(true);
2023
- const inv = root.matrixWorld.clone().invert();
2193
+ const inv = specFrameInverse(this.THREE, root);
2024
2194
  const total = new this.THREE.Box3();
2025
2195
  const m4 = new this.THREE.Matrix4();
2026
2196
  const lb = new this.THREE.Box3();
@@ -2095,6 +2265,7 @@ var Renderer = class {
2095
2265
  dt = clamp(dt, 0, 0.1);
2096
2266
  this.time += dt;
2097
2267
  this.drainQueue();
2268
+ for (const b of this.bounds.values()) if (b.soilNode && this.world.state(b.id)?.soil?.status === "yielding") this.onSettle(b.id);
2098
2269
  this.updateActive(dt);
2099
2270
  this.updateLeaks(dt);
2100
2271
  this.pieceSys.update(dt);
@@ -2113,6 +2284,27 @@ var Renderer = class {
2113
2284
  }
2114
2285
  }
2115
2286
  }
2287
+ /**
2288
+ * The ground under an object settled or tilted: its parts are drawn under a node with the soil's offset (`ObjectState.soil.offset`), so the host's
2289
+ * root keeps the pose the host gave and the world keeps getting it from `syncTransform`.
2290
+ */
2291
+ onSettle(objectId) {
2292
+ const b = this.bounds.get(objectId);
2293
+ const soil = this.world.state(objectId)?.soil;
2294
+ if (!b || !soil) return;
2295
+ let node = b.soilNode;
2296
+ if (!node) {
2297
+ node = new this.THREE.Group();
2298
+ node.name = "destruction-soil";
2299
+ for (const c of [...b.root.children]) node.add(c);
2300
+ b.root.add(node);
2301
+ b.soilNode = node;
2302
+ }
2303
+ const { position: p, rotation: q } = soil.offset;
2304
+ node.position.set(p[0], p[1], p[2]);
2305
+ node.quaternion.set(q[0], q[1], q[2], q[3]);
2306
+ node.updateMatrixWorld(true);
2307
+ }
2116
2308
  syncTransforms() {
2117
2309
  for (const b of this.bounds.values()) this.syncOne(b);
2118
2310
  }
@@ -2167,6 +2359,9 @@ var Renderer = class {
2167
2359
  case "detach":
2168
2360
  this.onDetach(ev);
2169
2361
  break;
2362
+ case "settle":
2363
+ this.onSettle(ev.object);
2364
+ break;
2170
2365
  case "collapse":
2171
2366
  this.splitLoose(ev.object);
2172
2367
  this.playEvent({ kind: "collapse" }, this.rootPoint(ev.object));
@@ -2258,7 +2453,7 @@ var Renderer = class {
2258
2453
  same.direction = ev.direction;
2259
2454
  } else {
2260
2455
  if (this.leaks.length >= 24) this.leaks.shift();
2261
- this.leaks.push({ object: ev.object, part: ev.part, fluid: ev.fluid, point: ev.point, direction: ev.direction, rate: ev.rate, pressurized: ev.pressurized, acc: 0 });
2456
+ this.leaks.push({ object: ev.object, part: ev.part, fluid: ev.fluid, point: ev.point, direction: ev.direction, rate: ev.rate, pressurized: ev.pressurized, acc: 0, opened: this.world.part(ev.object, ev.part)?.holes.some((h) => h.through) ?? false });
2262
2457
  }
2263
2458
  void key;
2264
2459
  break;
@@ -2335,10 +2530,9 @@ var Renderer = class {
2335
2530
  const def = this.world.materials.get(pv.material);
2336
2531
  const wp = new this.THREE.Vector3(ev.worldPoint[0], ev.worldPoint[1], ev.worldPoint[2]);
2337
2532
  const wn = this.worldDir(b, ev.dent.normal);
2338
- for (const mv of pv.meshes) {
2339
- if (!this.visuals.shown(mv)) continue;
2340
- this.visuals.applyDent(mv, wp, wn, ev.dent);
2341
- }
2533
+ const shown = pv.meshes.filter((mv) => this.visuals.shown(mv));
2534
+ const reach = shown.reduce((widest, mv) => Math.max(widest, this.visuals.dentReach(mv, wp, ev.dent)), ev.dent.radius);
2535
+ this.visuals.applyPartDent(shown, wp, wn, ev.dent, reach);
2342
2536
  if (def?.tags?.includes("metal")) this.vfx.burst("sparks", ev.worldPoint, [wn.x, wn.y, wn.z], 4 + Math.min(8, ev.dent.depth * 150), 3.2);
2343
2537
  this.sound(impactKey(def), ev.worldPoint, clamp(0.3 + ev.dent.depth * 12, 0.3, 1));
2344
2538
  }
@@ -2534,6 +2728,7 @@ var Renderer = class {
2534
2728
  }
2535
2729
  }
2536
2730
  this.bounds.set(newId, nb);
2731
+ for (const pv of nb.parts.values()) this.tagPart(pv, newId);
2537
2732
  const meshes = this.meshesOf(nb);
2538
2733
  if (meshes.length === 0) return;
2539
2734
  const { half, center } = this.measure(group, meshes);
@@ -2561,24 +2756,40 @@ var Renderer = class {
2561
2756
  const report = options.report;
2562
2757
  const confinement = report?.find((r) => r.blast !== void 0)?.blast?.confinement ?? 1;
2563
2758
  const energy = ev.energy * confinement;
2564
- for (const pc of this.pieceSys.list) {
2759
+ const reachOf = (objectId) => report?.find((r) => r.object === objectId)?.blast?.shelter ?? 1;
2760
+ const shove = (pc) => {
2565
2761
  this.pieceSys.centerOf(pc, c);
2566
- const dx = c.x - p[0];
2567
- const dy = c.y - p[1];
2568
- const dz = c.z - p[2];
2569
- const d = Math.hypot(dx, dy, dz);
2570
- if (d > ev.radius) continue;
2571
- const fall = (1 - d / ev.radius) ** 2;
2572
- const reach = report?.find((r) => r.object === pc.objectId)?.blast?.shelter ?? 1;
2573
- const speed = Math.min(30, Math.sqrt(2 * energy * reach * 0.02 / pc.mass)) * fall;
2574
- const inv = d > 1e-4 ? 1 / d : 0;
2575
- const dir = d > 1e-4 ? [dx * inv, dy * inv + 0.3, dz * inv] : [0, 1, 0];
2762
+ const hit = blastShove({ point: p, energy, radius: ev.radius, reach: reachOf(pc.objectId) }, { position: [c.x, c.y, c.z], mass: pc.mass }, this.o.blastShove);
2763
+ if (!hit) return;
2576
2764
  const spread = pc.halfExtents[0];
2577
- this.pieceSys.impulse(pc, [dir[0] * speed * pc.mass, dir[1] * speed * pc.mass, dir[2] * speed * pc.mass], [c.x + (this.rng() - 0.5) * spread, c.y + spread * 0.5, c.z + (this.rng() - 0.5) * spread]);
2578
- }
2765
+ const momentum = hit.speed * pc.mass;
2766
+ this.pieceSys.impulse(pc, [hit.direction[0] * momentum, hit.direction[1] * momentum, hit.direction[2] * momentum], [c.x + (this.rng() - 0.5) * spread, c.y + spread * 0.5, c.z + (this.rng() - 0.5) * spread]);
2767
+ };
2768
+ for (const pc of [...this.pieceSys.list]) shove(pc);
2769
+ if (this.o.shoveable) this.loosen(p, ev.radius, energy, reachOf);
2579
2770
  this.fragments.blast(p, ev.radius, Math.min(20, Math.sqrt(energy) * 0.08));
2580
2771
  this.sound("explode", p, clamp(0.5 + ev.radius / 10, 0.5, 1));
2581
2772
  }
2773
+ /** The bound objects without a piece that the host lets a blast move (`shoveable`): the ones it reaches with enough speed become pieces and get the shove. */
2774
+ loosen(point, radius, energy, reachOf) {
2775
+ const shoveable = this.o.shoveable;
2776
+ if (!shoveable) return;
2777
+ for (const b of [...this.bounds.values()]) {
2778
+ if (b.piece || this.pieceSys.get(b.id) || !shoveable(b.id) || !this.world.has(b.id)) continue;
2779
+ const { half, center } = this.measure(b.root, this.meshesOf(b));
2780
+ const world = center.clone().applyQuaternion(b.root.quaternion).add(b.root.position);
2781
+ const mass = this.massOf(b.id);
2782
+ const hit = blastShove({ point, energy, radius, reach: reachOf(b.id) }, { position: [world.x, world.y, world.z], mass }, this.o.blastShove);
2783
+ if (!hit) continue;
2784
+ const piece = this.makePiece(b.id, { mass });
2785
+ const body = this.pieceSys.get(b.id);
2786
+ if (!body) continue;
2787
+ this.o.onLoosened?.(b.id, piece);
2788
+ const spread = half[0];
2789
+ const momentum = hit.speed * mass;
2790
+ 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]);
2791
+ }
2792
+ }
2582
2793
  playExplosion(ev, options) {
2583
2794
  const named = options.effect ?? ev.effect;
2584
2795
  const direction = options.direction;
@@ -2679,7 +2890,7 @@ var Renderer = class {
2679
2890
  for (let i = this.leaks.length - 1; i >= 0; i--) {
2680
2891
  const l = this.leaks[i];
2681
2892
  const ps = this.world.part(l.object, l.part);
2682
- if (!ps || ps.liters === void 0 || ps.liters <= 0) {
2893
+ if (!ps || ps.liters === void 0 || ps.liters <= 0 || l.opened && !ps.holes.some((h) => h.through)) {
2683
2894
  this.leaks.splice(i, 1);
2684
2895
  continue;
2685
2896
  }
@@ -2730,6 +2941,7 @@ var Renderer = class {
2730
2941
  for (const [pid, pv] of [...b.parts]) {
2731
2942
  if (pv.home !== home) continue;
2732
2943
  this.visuals.restorePart(pv);
2944
+ this.tagPart(pv, home);
2733
2945
  if (b !== homeBound && homeBound) {
2734
2946
  b.parts.delete(pid);
2735
2947
  homeBound.parts.set(pid, pv);
@@ -2771,10 +2983,31 @@ var Renderer = class {
2771
2983
  const { puffs, sparks, glows, chunks } = this.vfx.stats();
2772
2984
  return puffs + sparks + glows + chunks;
2773
2985
  }
2986
+ warmUpProbes() {
2987
+ this.releaseWarmProbes();
2988
+ const { kit } = this.visuals;
2989
+ const T = this.THREE;
2990
+ const geometry = new T.BufferGeometry();
2991
+ geometry.setAttribute("position", new T.BufferAttribute(new Float32Array(6), 3));
2992
+ const materials = [kit.discMaterial().clone(), kit.patchMaterial(8421504).clone(), kit.lineMaterial(0).clone()];
2993
+ const group = new T.Group();
2994
+ group.add(new T.Mesh(kit.discGeometry(), materials[0]), new T.Mesh(kit.discGeometry(), materials[1]), new T.LineSegments(geometry, materials[2]));
2995
+ for (const probe of group.children) probe.frustumCulled = false;
2996
+ this.warmProbes = { group, geometry, materials };
2997
+ return { group, dispose: () => {
2998
+ } };
2999
+ }
3000
+ releaseWarmProbes() {
3001
+ if (!this.warmProbes) return;
3002
+ this.warmProbes.geometry.dispose();
3003
+ for (const material of this.warmProbes.materials) material.dispose();
3004
+ this.warmProbes = void 0;
3005
+ }
2774
3006
  rebarCount() {
2775
3007
  return this.cages.count;
2776
3008
  }
2777
3009
  dispose() {
3010
+ this.releaseWarmProbes();
2778
3011
  if (this.disposed) return;
2779
3012
  this.unsubscribe();
2780
3013
  this.clear();