@adatechnology/game-test-room 0.1.2 → 0.1.3

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.
@@ -1646,6 +1646,7 @@ var GameTestRoom = (() => {
1646
1646
  var clamp2 = (v, a, b) => Math.max(a, Math.min(b, v));
1647
1647
  var isLost = (p) => p.st.status === "broken" || p.st.status === "melted" || p.st.status === "ashes";
1648
1648
  var clone = (v) => structuredClone(v);
1649
+ var overloadRate = (stress) => Math.min(0.6, 0.12 + 0.8 * (stress - 1));
1649
1650
  var updateBound = (o) => {
1650
1651
  if (!o.parts.length) return;
1651
1652
  const c = [0, 0, 0];
@@ -1956,7 +1957,10 @@ var GameTestRoom = (() => {
1956
1957
  for (const g of o.st.groups) {
1957
1958
  const ps = g.map((id) => o.byId.get(id)).filter((p) => !isLost(p) && !o.collapsed.has(p.spec.id));
1958
1959
  if (ps.length === 0 || g.some((id) => o.byId.get(id).anchored) || !ps.some((p) => p.structural)) continue;
1959
- for (const p of ps) o.collapsed.add(p.spec.id);
1960
+ for (const p of ps) {
1961
+ o.collapsed.add(p.spec.id);
1962
+ p.st.status = "collapsed";
1963
+ }
1960
1964
  emit({ ...base(o, src), type: "collapse", parts: ps.map((p) => p.spec.id), mass: ps.reduce((sum, p) => sum + p.st.mass, 0) });
1961
1965
  }
1962
1966
  }
@@ -2256,7 +2260,7 @@ var GameTestRoom = (() => {
2256
2260
  const effM = p.thickness / Math.max(sin, 0.3);
2257
2261
  const thr = mat.penetration * effM * 1e3;
2258
2262
  const penetrated = E > thr;
2259
- const scale2 = mat.damageScale?.ballistic ?? 1;
2263
+ const scale2 = (mat.damageScale?.ballistic ?? 1) * (mat.fracture === "shatter" ? fragility(mat.ductility, p.thickness) : 1);
2260
2264
  const id = p.spec.id;
2261
2265
  if (!penetrated && mat.ricochetDeg && angle < mat.ricochetDeg) {
2262
2266
  const r = vec.sub(d, vec.scale(n, 2 * vec.dot(d, n)));
@@ -2606,7 +2610,7 @@ var GameTestRoom = (() => {
2606
2610
  p.rt.stressT = 0.5;
2607
2611
  emit({ ...base(o), type: "stress", part: p.spec.id, stress, load: p.st.load ?? 0 });
2608
2612
  }
2609
- applyDamage(o, p, (stress - 1) * p.st.maxHp * 0.5 * dt, "blunt", null, null, 5, 3);
2613
+ applyDamage(o, p, overloadRate(stress) * p.st.maxHp * dt, "blunt", null, null, 5, 3);
2610
2614
  } else if (stress > 0.8 && p.mat.ductility < 0.3 && p.rt.stressT <= 0 && p.st.cracks.length < 6) {
2611
2615
  p.rt.stressT = 0.5;
2612
2616
  const { c, q } = partXf(o, p);
@@ -3096,7 +3100,7 @@ var GameTestRoom = (() => {
3096
3100
  wax: "wax",
3097
3101
  soil: "soil"
3098
3102
  },
3099
- partStatus: { intact: "intact", damaged: "damaged", broken: "broken", melted: "melted", ashes: "ashes" },
3103
+ partStatus: { intact: "intact", damaged: "damaged", collapsed: "collapsed", broken: "broken", melted: "melted", ashes: "ashes" },
3100
3104
  jointKinds: {
3101
3105
  screw: "screw",
3102
3106
  bolt: "nut and bolt",
@@ -3177,7 +3181,7 @@ var GameTestRoom = (() => {
3177
3181
  wax: "cera",
3178
3182
  soil: "terra"
3179
3183
  },
3180
- partStatus: { intact: "intacto", damaged: "danificado", broken: "quebrado", melted: "derretido", ashes: "cinzas" },
3184
+ partStatus: { intact: "intacto", damaged: "danificado", collapsed: "desabado", broken: "quebrado", melted: "derretido", ashes: "cinzas" },
3181
3185
  jointKinds: {
3182
3186
  screw: "parafuso",
3183
3187
  bolt: "porca e parafuso",
@@ -4472,6 +4476,8 @@ void main() {
4472
4476
  __publicField(this, "b");
4473
4477
  __publicField(this, "m4");
4474
4478
  __publicField(this, "box");
4479
+ __publicField(this, "t");
4480
+ __publicField(this, "lbox");
4475
4481
  __publicField(this, "decalCount", 0);
4476
4482
  __publicField(this, "tints", /* @__PURE__ */ new WeakMap());
4477
4483
  this.kit = new DecalKit(THREE);
@@ -4479,9 +4485,22 @@ void main() {
4479
4485
  this.b = tmpV(THREE);
4480
4486
  this.m4 = new THREE.Matrix4();
4481
4487
  this.box = new THREE.Box3();
4488
+ this.t = tmpV(THREE);
4489
+ this.lbox = new THREE.Box3();
4482
4490
  }
4483
- makeMeshVis(mesh, home) {
4484
- return {
4491
+ /**
4492
+ * @param range vertex range `[v0, v1)` of a merged mesh this part is drawn by (see `MeshRange`); `hosts` caches the shared state per mesh for one `bind`.
4493
+ */
4494
+ makeMeshVis(mesh, home, range, hosts) {
4495
+ let host = null;
4496
+ if (range) {
4497
+ host = hosts?.get(mesh) ?? null;
4498
+ if (!host) {
4499
+ host = { mesh, origGeometry: mesh.geometry, origMaterial: mesh.material, ownGeo: false, work: null, ownMat: false, sharers: [] };
4500
+ hosts?.set(mesh, host);
4501
+ }
4502
+ }
4503
+ const mv = {
4485
4504
  mesh,
4486
4505
  home,
4487
4506
  origGeometry: mesh.geometry,
@@ -4498,31 +4517,72 @@ void main() {
4498
4517
  decals: [],
4499
4518
  meltApplied: 0,
4500
4519
  bottomY: 0,
4501
- moved: false
4520
+ moved: false,
4521
+ host,
4522
+ range: range ? [range[0], range[1]] : null,
4523
+ merged: host && range ? { host, range: [range[0], range[1]] } : null,
4524
+ loose: false,
4525
+ collapsed: false,
4526
+ hidden: false,
4527
+ touched: false,
4528
+ charK: 0,
4529
+ charHex: 0,
4530
+ glowHex: 0,
4531
+ glowK: 0
4502
4532
  };
4533
+ host?.sharers.push(mv);
4534
+ return mv;
4503
4535
  }
4504
4536
  vertexCount(mv) {
4537
+ if (mv.range) return mv.range[1] - mv.range[0];
4505
4538
  return mv.mesh.geometry.getAttribute(POS_ATTR)?.count ?? 0;
4506
4539
  }
4540
+ /** Is the part drawn? (A ranged part shares its mesh, so `mesh.visible` says nothing about it.) */
4541
+ shown(mv) {
4542
+ return mv.host ? !mv.hidden : mv.mesh.visible;
4543
+ }
4544
+ /** Box of the part's vertices in the space of its mesh (the whole geometry for a plain mesh). */
4545
+ localBox(mv, out) {
4546
+ out.makeEmpty();
4547
+ if (!mv.host || !mv.range) {
4548
+ const geo = mv.mesh.geometry;
4549
+ if (!geo.boundingBox) geo.computeBoundingBox();
4550
+ return geo.boundingBox ? out.copy(geo.boundingBox) : out;
4551
+ }
4552
+ const arr = mv.host.work ?? positionAttr(mv.mesh.geometry)?.array;
4553
+ if (!arr) return out;
4554
+ for (let i = mv.range[0] * 3; i < mv.range[1] * 3; i += 3) out.expandByPoint(this.t.set(arr[i], arr[i + 1], arr[i + 2]));
4555
+ return out;
4556
+ }
4557
+ /** Box of the part in world space. */
4558
+ worldBox(mv, out) {
4559
+ if (!mv.host) return out.setFromObject(mv.mesh);
4560
+ this.localBox(mv, out);
4561
+ mv.mesh.updateWorldMatrix(true, false);
4562
+ return out.isEmpty() ? out : out.applyMatrix4(mv.mesh.matrixWorld);
4563
+ }
4507
4564
  /**
4508
4565
  * Average vertex colour of a mesh whose material uses `vertexColors` (computed once per mesh, from its original geometry), else null. Fragments
4509
4566
  * and debris of such a mesh multiply it by `material.color`; without it they come out white.
4510
4567
  */
4511
4568
  vertexTint(mv) {
4512
- if (this.tints.has(mv.mesh)) return this.tints.get(mv.mesh) ?? null;
4569
+ if (this.tints.has(mv)) return this.tints.get(mv) ?? null;
4513
4570
  let tint = null;
4514
4571
  const used = materialsOf(mv.origMaterial).some((m) => m.vertexColors === true);
4515
4572
  const attr = used ? mv.origGeometry.getAttribute("color") : void 0;
4516
4573
  if (attr && attr.count > 0 && attr.itemSize >= 3) {
4574
+ const from = mv.merged ? mv.merged.range[0] : 0;
4575
+ const to = mv.merged ? Math.min(attr.count, mv.merged.range[1]) : attr.count;
4517
4576
  let r = 0, g = 0, b = 0;
4518
- for (let i = 0; i < attr.count; i++) {
4577
+ for (let i = from; i < to; i++) {
4519
4578
  r += attr.getX(i);
4520
4579
  g += attr.getY(i);
4521
4580
  b += attr.getZ(i);
4522
4581
  }
4523
- tint = new this.THREE.Color(r / attr.count, g / attr.count, b / attr.count);
4582
+ const n = Math.max(1, to - from);
4583
+ tint = new this.THREE.Color(r / n, g / n, b / n);
4524
4584
  }
4525
- this.tints.set(mv.mesh, tint);
4585
+ this.tints.set(mv, tint);
4526
4586
  return tint;
4527
4587
  }
4528
4588
  /** Colour of the original material (before any char) times the vertex tint, `0xRRGGBB`, or -1. */
@@ -4546,6 +4606,7 @@ void main() {
4546
4606
  }
4547
4607
  /** Clone the geometry (vertex edits); false when its positions cannot be edited. */
4548
4608
  ownGeometry(mv) {
4609
+ if (mv.host) return this.ownHostGeometry(mv.host);
4549
4610
  if (mv.ownGeo) return true;
4550
4611
  const pos = positionAttr(mv.mesh.geometry);
4551
4612
  if (!pos) return false;
@@ -4555,6 +4616,16 @@ void main() {
4555
4616
  mv.work = new Float32Array(positionAttr(g).array);
4556
4617
  return true;
4557
4618
  }
4619
+ ownHostGeometry(host) {
4620
+ if (host.ownGeo) return true;
4621
+ const pos = positionAttr(host.mesh.geometry);
4622
+ if (!pos) return false;
4623
+ host.ownGeo = true;
4624
+ const g = host.mesh.geometry.clone();
4625
+ host.mesh.geometry = g;
4626
+ host.work = new Float32Array(positionAttr(g).array);
4627
+ return true;
4628
+ }
4558
4629
  // ------------------------------------------------------------------------------------------------------------------------------------
4559
4630
  // Dents
4560
4631
  // ------------------------------------------------------------------------------------------------------------------------------------
@@ -4572,10 +4643,15 @@ void main() {
4572
4643
  const r = dent.radius / s;
4573
4644
  const depth = dent.depth / s;
4574
4645
  const geo = mesh.geometry;
4575
- if (!geo.boundingBox) geo.computeBoundingBox();
4576
- if (geo.boundingBox && geo.boundingBox.distanceToPoint(lp) > r) return false;
4646
+ let bb;
4647
+ if (mv.host) bb = this.localBox(mv, this.lbox);
4648
+ else {
4649
+ if (!geo.boundingBox) geo.computeBoundingBox();
4650
+ bb = geo.boundingBox;
4651
+ }
4652
+ if (bb && !bb.isEmpty() && bb.distanceToPoint(lp) > r) return false;
4577
4653
  if (!this.ownGeometry(mv)) return false;
4578
- const work = mv.work;
4654
+ const work = mv.host ? mv.host.work : mv.work;
4579
4655
  const pos = positionAttr(mesh.geometry);
4580
4656
  const r2 = r * r;
4581
4657
  let touched = false;
@@ -4585,7 +4661,9 @@ void main() {
4585
4661
  const nx = ln.x;
4586
4662
  const ny = ln.y;
4587
4663
  const nz = ln.z;
4588
- for (let i = 0; i < work.length; i += 3) {
4664
+ const from = mv.range ? mv.range[0] * 3 : 0;
4665
+ const to = mv.range ? mv.range[1] * 3 : work.length;
4666
+ for (let i = from; i < to; i += 3) {
4589
4667
  const dx = work[i] - px;
4590
4668
  const dy = work[i + 1] - py;
4591
4669
  const dz = work[i + 2] - pz;
@@ -4599,11 +4677,16 @@ void main() {
4599
4677
  touched = true;
4600
4678
  }
4601
4679
  if (!touched) return false;
4680
+ mv.touched = true;
4602
4681
  this.writePositions(mv, pos);
4603
4682
  return true;
4604
4683
  }
4605
4684
  /** Copy `work` (dents) into the geometry, adding the melt sag when the mesh is melting. */
4606
4685
  writePositions(mv, pos) {
4686
+ if (mv.host) {
4687
+ this.rebuildHost(mv.host);
4688
+ return;
4689
+ }
4607
4690
  const work = mv.work;
4608
4691
  const arr = pos.array;
4609
4692
  const melt = mv.meltApplied;
@@ -4717,6 +4800,12 @@ void main() {
4717
4800
  pv.charApplied = char;
4718
4801
  const target = new this.THREE.Color(charColor);
4719
4802
  for (const mv of pv.meshes) {
4803
+ if (mv.host) {
4804
+ mv.charK = clamp3(char, 0, 1);
4805
+ mv.charHex = charColor;
4806
+ this.paint(mv);
4807
+ continue;
4808
+ }
4720
4809
  this.ownMaterial(mv);
4721
4810
  const mats = materialsOf(mv.mesh.material);
4722
4811
  mats.forEach((m, i) => {
@@ -4734,6 +4823,12 @@ void main() {
4734
4823
  pv.glowApplied = key;
4735
4824
  const intensity = hex === 0 ? 0 : clamp3(0.35 + (temperatureC - 450) / 900, 0, 1.4);
4736
4825
  for (const mv of pv.meshes) {
4826
+ if (mv.host) {
4827
+ mv.glowHex = hex;
4828
+ mv.glowK = intensity;
4829
+ this.paint(mv);
4830
+ continue;
4831
+ }
4737
4832
  this.ownMaterial(mv);
4738
4833
  for (const m of materialsOf(mv.mesh.material)) {
4739
4834
  const e = m.emissive;
@@ -4754,6 +4849,12 @@ void main() {
4754
4849
  pv.meltApplied = m;
4755
4850
  for (const mv of pv.meshes) {
4756
4851
  const mesh = mv.mesh;
4852
+ if (mv.host) {
4853
+ mv.meltApplied = m;
4854
+ mv.touched = true;
4855
+ if (!(this.lowQuality && this.vertexCount(mv) > 2e4) && this.ownGeometry(mv)) this.rebuildHost(mv.host);
4856
+ continue;
4857
+ }
4757
4858
  if (mv.meltApplied === 0 && m > 0) {
4758
4859
  mesh.updateWorldMatrix(true, false);
4759
4860
  mv.bottomY = this.box.setFromObject(mesh).min.y;
@@ -4784,10 +4885,270 @@ void main() {
4784
4885
  // ------------------------------------------------------------------------------------------------------------------------------------
4785
4886
  hide(pv, reason) {
4786
4887
  pv.gone = reason;
4787
- for (const mv of pv.meshes) mv.mesh.visible = false;
4888
+ for (const mv of pv.meshes) {
4889
+ if (!mv.host || mv.loose) {
4890
+ mv.mesh.visible = false;
4891
+ mv.hidden = true;
4892
+ continue;
4893
+ }
4894
+ mv.hidden = true;
4895
+ for (const d of mv.decals) d.obj.visible = false;
4896
+ this.collapse(mv);
4897
+ }
4898
+ }
4899
+ // ------------------------------------------------------------------------------------------------------------------------------------
4900
+ // Parts drawn by a vertex range of a merged mesh
4901
+ // ------------------------------------------------------------------------------------------------------------------------------------
4902
+ /** Collapse the part's vertices in the merged mesh to one point (its triangles become degenerate and vanish). */
4903
+ collapse(mv) {
4904
+ if (!mv.merged || !this.ownHostGeometry(mv.merged.host)) return;
4905
+ mv.collapsed = true;
4906
+ mv.touched = true;
4907
+ this.rebuildHost(mv.merged.host);
4908
+ }
4909
+ /** Write `work` into the geometry again with the melt of ranged parts and the collapsed ranges applied. */
4910
+ rebuildHost(host) {
4911
+ const geo = host.mesh.geometry;
4912
+ const pos = positionAttr(geo);
4913
+ const work = host.work;
4914
+ if (!pos || !work) return;
4915
+ const arr = pos.array;
4916
+ arr.set(work);
4917
+ for (const s of host.sharers) {
4918
+ if (s.merged && s.merged.host === host) {
4919
+ if (s.collapsed) this.collapseRange(arr, s.merged.range);
4920
+ else if (s.meltApplied > 0 && !s.loose) this.meltRange(arr, work, s.merged.range, s.meltApplied);
4921
+ } else if (s.host === host && s.range && s.meltApplied > 0) this.meltRange(arr, work, s.range, s.meltApplied);
4922
+ }
4923
+ pos.needsUpdate = true;
4924
+ geo.computeVertexNormals();
4925
+ geo.boundingBox = null;
4926
+ geo.boundingSphere = null;
4927
+ }
4928
+ collapseRange(arr, range) {
4929
+ const x = arr[range[0] * 3];
4930
+ const y = arr[range[0] * 3 + 1];
4931
+ const z = arr[range[0] * 3 + 2];
4932
+ for (let i = range[0] * 3; i < range[1] * 3; i += 3) {
4933
+ arr[i] = x;
4934
+ arr[i + 1] = y;
4935
+ arr[i + 2] = z;
4936
+ }
4937
+ }
4938
+ /** Melt of one range: shorter in Y (down to 25 %) with the bottom fixed, a bit wider around its centre, with the same noise as a whole mesh. */
4939
+ meltRange(arr, work, range, melt) {
4940
+ let minY = Infinity;
4941
+ let maxY = -Infinity;
4942
+ let cx = 0;
4943
+ let cz = 0;
4944
+ const n = Math.max(1, range[1] - range[0]);
4945
+ for (let i = range[0] * 3; i < range[1] * 3; i += 3) {
4946
+ minY = Math.min(minY, work[i + 1]);
4947
+ maxY = Math.max(maxY, work[i + 1]);
4948
+ cx += work[i];
4949
+ cz += work[i + 2];
4950
+ }
4951
+ cx /= n;
4952
+ cz /= n;
4953
+ const h = Math.max(1e-4, maxY - minY);
4954
+ const sy = lerp(1, 0.25, melt);
4955
+ const sxz = 1 + 0.18 * melt;
4956
+ for (let i = range[0] * 3, v = 0; i < range[1] * 3; i += 3, v++) {
4957
+ const noise = (Math.imul(v + 1, 2654435761) >>> 0) % 1e3 / 1e3;
4958
+ arr[i] = cx + (work[i] - cx) * sxz;
4959
+ arr[i + 2] = cz + (work[i + 2] - cz) * sxz;
4960
+ arr[i + 1] = Math.max(minY, minY + (work[i + 1] - minY) * sy - noise * melt * h * 0.04);
4961
+ }
4962
+ }
4963
+ /** The colour attribute of the host's current geometry (created white when there is none) with vertex colours switched on in its material. */
4964
+ ensureColors(host) {
4965
+ if (!this.ownHostGeometry(host)) return null;
4966
+ const geo = host.mesh.geometry;
4967
+ let attr = geo.getAttribute("color");
4968
+ if (!attr) {
4969
+ const n = geo.getAttribute(POS_ATTR).count;
4970
+ attr = new this.THREE.BufferAttribute(new Float32Array(n * 3).fill(1), 3);
4971
+ geo.setAttribute("color", attr);
4972
+ }
4973
+ if (!host.ownMat) {
4974
+ host.ownMat = true;
4975
+ const src = materialsOf(host.mesh.material);
4976
+ const cloned = src.map((m) => m.clone());
4977
+ host.mesh.material = Array.isArray(host.mesh.material) ? cloned : cloned[0];
4978
+ }
4979
+ for (const m of materialsOf(host.mesh.material)) {
4980
+ if (m.vertexColors !== true) {
4981
+ ;
4982
+ m.vertexColors = true;
4983
+ m.needsUpdate = true;
4984
+ }
4985
+ }
4986
+ return attr;
4987
+ }
4988
+ /** Colour of the part's vertices from the original colours, its char and its heat. Only the part's range is written. */
4989
+ paint(mv) {
4990
+ if (!mv.host || !mv.range || !mv.merged) return;
4991
+ const idle = mv.charK <= 0 && mv.glowK <= 0;
4992
+ if (idle && !mv.touched) return;
4993
+ const attr = this.ensureColors(mv.host);
4994
+ if (!attr) return;
4995
+ const size = attr.itemSize;
4996
+ const arr = attr.array;
4997
+ const orig = mv.merged.host.origGeometry.getAttribute("color");
4998
+ const oSize = orig?.itemSize ?? 3;
4999
+ const oArr = orig?.array;
5000
+ const shift = mv.merged.range[0] - mv.range[0];
5001
+ const ch = new this.THREE.Color(mv.charHex);
5002
+ const heat = new this.THREE.Color(mv.glowHex);
5003
+ const k = clamp3(mv.charK, 0, 1);
5004
+ const gk = clamp3(mv.glowK, 0, 1);
5005
+ const boost = 1 + Math.max(0, mv.glowK - 1);
5006
+ for (let i = mv.range[0]; i < mv.range[1]; i++) {
5007
+ const o = (i + shift) * oSize;
5008
+ let r = oArr ? oArr[o] : 1;
5009
+ let g = oArr ? oArr[o + 1] : 1;
5010
+ let b = oArr ? oArr[o + 2] : 1;
5011
+ if (k > 0) {
5012
+ r = lerp(r, ch.r, k);
5013
+ g = lerp(g, ch.g, k);
5014
+ b = lerp(b, ch.b, k);
5015
+ }
5016
+ if (gk > 0) {
5017
+ r = lerp(r, heat.r * boost, gk);
5018
+ g = lerp(g, heat.g * boost, gk);
5019
+ b = lerp(b, heat.b * boost, gk);
5020
+ }
5021
+ arr[i * size] = r;
5022
+ arr[i * size + 1] = g;
5023
+ arr[i * size + 2] = b;
5024
+ }
5025
+ mv.touched = true;
5026
+ attr.needsUpdate = true;
5027
+ }
5028
+ /**
5029
+ * Move a ranged part to a mesh of its own: the faces whose vertices all lie in its range are copied (with the current dents and colours) into a
5030
+ * new mesh that takes the merged mesh's transform and material, then attached to `group`; the range is collapsed in the merged mesh. Decals
5031
+ * go with it.
5032
+ */
5033
+ extract(mv, group) {
5034
+ if (!mv.host || !mv.range || !mv.merged || mv.loose) return;
5035
+ const host = mv.host;
5036
+ const [v0, v1] = mv.range;
5037
+ const src = host.mesh.geometry;
5038
+ const geo = new this.THREE.BufferGeometry();
5039
+ const count = v1 - v0;
5040
+ for (const name of Object.keys(src.attributes)) {
5041
+ const a = src.getAttribute(name);
5042
+ if (a.isInterleavedBufferAttribute) continue;
5043
+ const size = a.itemSize;
5044
+ const from = (name === POS_ATTR ? host.work : null) ?? a.array;
5045
+ const slice = from.slice(v0 * size, v1 * size);
5046
+ const Ctor = a.array.constructor;
5047
+ geo.setAttribute(name, new this.THREE.BufferAttribute(new Ctor(slice), size, a.normalized));
5048
+ }
5049
+ const index = src.getIndex();
5050
+ if (index) {
5051
+ const out = [];
5052
+ for (let i = 0; i + 2 < index.count; i += 3) {
5053
+ const a = index.getX(i);
5054
+ const b = index.getX(i + 1);
5055
+ const c = index.getX(i + 2);
5056
+ if (a >= v0 && a < v1 && b >= v0 && b < v1 && c >= v0 && c < v1) out.push(a - v0, b - v0, c - v0);
5057
+ }
5058
+ geo.setIndex(count > 65535 ? new this.THREE.Uint32BufferAttribute(out, 1) : new this.THREE.Uint16BufferAttribute(out, 1));
5059
+ }
5060
+ const mats = materialsOf(host.mesh.material).map((m) => m.clone());
5061
+ const loose = new this.THREE.Mesh(geo, mats[0]);
5062
+ loose.name = `${host.mesh.name || "mesh"}:${v0}-${v1}`;
5063
+ loose.castShadow = host.mesh.castShadow;
5064
+ loose.receiveShadow = host.mesh.receiveShadow;
5065
+ loose.renderOrder = host.mesh.renderOrder;
5066
+ loose.frustumCulled = host.mesh.frustumCulled;
5067
+ loose.layers.mask = host.mesh.layers.mask;
5068
+ loose.position.copy(host.mesh.position);
5069
+ loose.quaternion.copy(host.mesh.quaternion);
5070
+ loose.scale.copy(host.mesh.scale);
5071
+ const parent = host.mesh.parent;
5072
+ if (parent) parent.add(loose);
5073
+ loose.updateMatrixWorld(true);
5074
+ this.collapse(mv);
5075
+ for (const d of mv.decals) {
5076
+ d.obj.visible = true;
5077
+ loose.add(d.obj);
5078
+ }
5079
+ const lp = positionAttr(geo);
5080
+ const lh = {
5081
+ mesh: loose,
5082
+ origGeometry: geo,
5083
+ origMaterial: loose.material,
5084
+ ownGeo: true,
5085
+ work: new Float32Array(lp.array),
5086
+ ownMat: true,
5087
+ sharers: [mv]
5088
+ };
5089
+ mv.host = lh;
5090
+ mv.range = [0, count];
5091
+ mv.mesh = loose;
5092
+ mv.loose = true;
5093
+ mv.moved = true;
5094
+ group.attach(loose);
5095
+ for (const m of materialsOf(loose.material)) m.needsUpdate = true;
5096
+ }
5097
+ restoreRanged(mv) {
5098
+ const merged = mv.merged;
5099
+ const host = merged.host;
5100
+ for (const d of mv.decals) this.disposeDecal(d);
5101
+ mv.decals.length = 0;
5102
+ if (mv.loose) {
5103
+ const loose = mv.mesh;
5104
+ loose.parent?.remove(loose);
5105
+ loose.geometry.dispose();
5106
+ for (const m of materialsOf(loose.material)) m.dispose();
5107
+ mv.host = host;
5108
+ mv.range = [merged.range[0], merged.range[1]];
5109
+ mv.mesh = host.mesh;
5110
+ mv.loose = false;
5111
+ mv.moved = false;
5112
+ }
5113
+ mv.collapsed = false;
5114
+ mv.hidden = false;
5115
+ mv.meltApplied = 0;
5116
+ mv.charK = 0;
5117
+ mv.glowK = 0;
5118
+ mv.touched = false;
5119
+ if (!host.ownGeo) return;
5120
+ const work = host.work;
5121
+ const op = positionAttr(host.origGeometry);
5122
+ const [v0, v1] = merged.range;
5123
+ if (op) work.set(op.array.subarray(v0 * 3, v1 * 3), v0 * 3);
5124
+ const attr = host.mesh.geometry.getAttribute("color");
5125
+ if (attr) {
5126
+ const orig = host.origGeometry.getAttribute("color");
5127
+ const size = attr.itemSize;
5128
+ const a = attr.array;
5129
+ for (let i = v0; i < v1; i++) {
5130
+ for (let c = 0; c < 3; c++) a[i * size + c] = orig ? orig.array[i * orig.itemSize + c] : 1;
5131
+ }
5132
+ attr.needsUpdate = true;
5133
+ }
5134
+ if (host.sharers.every((s) => !s.touched)) {
5135
+ host.mesh.geometry.dispose();
5136
+ host.mesh.geometry = host.origGeometry;
5137
+ host.work = null;
5138
+ host.ownGeo = false;
5139
+ if (host.ownMat) {
5140
+ for (const m of materialsOf(host.mesh.material)) m.dispose();
5141
+ host.mesh.material = host.origMaterial;
5142
+ host.ownMat = false;
5143
+ }
5144
+ } else this.rebuildHost(host);
4788
5145
  }
4789
5146
  /** Bring one mesh back to how it was when it was bound. */
4790
5147
  restoreMesh(mv) {
5148
+ if (mv.merged) {
5149
+ this.restoreRanged(mv);
5150
+ return;
5151
+ }
4791
5152
  const mesh = mv.mesh;
4792
5153
  for (const d of mv.decals) this.disposeDecal(d);
4793
5154
  mv.decals.length = 0;
@@ -4885,6 +5246,10 @@ void main() {
4885
5246
  function materialsOf2(m) {
4886
5247
  return Array.isArray(m) ? m : [m];
4887
5248
  }
5249
+ function isMeshRange(v) {
5250
+ const r = v;
5251
+ return !!r.mesh && Array.isArray(r.range);
5252
+ }
4888
5253
  function createDestructionRenderer(THREE, options) {
4889
5254
  return new Renderer(THREE, options);
4890
5255
  }
@@ -4978,12 +5343,23 @@ void main() {
4978
5343
  const partIds = new Set((state?.parts ?? []).map((p) => p.id));
4979
5344
  const byPart = /* @__PURE__ */ new Map();
4980
5345
  const taken = /* @__PURE__ */ new Set();
5346
+ const put = (pid, e) => {
5347
+ const l = byPart.get(pid);
5348
+ if (l) l.push(e);
5349
+ else byPart.set(pid, [e]);
5350
+ };
4981
5351
  const add2 = (pid, m) => {
4982
5352
  if (taken.has(m)) return;
4983
5353
  taken.add(m);
4984
- const l = byPart.get(pid);
4985
- if (l) l.push(m);
4986
- else byPart.set(pid, [m]);
5354
+ put(pid, { mesh: m });
5355
+ };
5356
+ const addRange = (pid, r) => {
5357
+ const n = r.mesh.geometry.getAttribute("position")?.count ?? 0;
5358
+ const [v0, v1] = r.range;
5359
+ if (!Number.isInteger(v0) || !Number.isInteger(v1) || v0 < 0 || v1 <= v0 || v1 > n) {
5360
+ throw new Error(`game-destruction-three: invalid vertex range [${v0}, ${v1}) for part "${pid}" (the mesh has ${n} vertices)`);
5361
+ }
5362
+ put(pid, { mesh: r.mesh, range: [v0, v1] });
4987
5363
  };
4988
5364
  const meshesIn = (o) => {
4989
5365
  const out = [];
@@ -4994,7 +5370,10 @@ void main() {
4994
5370
  };
4995
5371
  if (options.parts) {
4996
5372
  for (const [pid, v] of Object.entries(options.parts)) {
4997
- for (const o of Array.isArray(v) ? v : [v]) for (const m of meshesIn(o)) add2(pid, m);
5373
+ for (const o of Array.isArray(v) ? v : [v]) {
5374
+ if (isMeshRange(o)) addRange(pid, o);
5375
+ else for (const m of meshesIn(o)) add2(pid, m);
5376
+ }
4998
5377
  }
4999
5378
  } else {
5000
5379
  const leftovers = [];
@@ -5007,14 +5386,15 @@ void main() {
5007
5386
  if (leftovers.length) this.matchByBox(objectId, root, leftovers, add2);
5008
5387
  }
5009
5388
  const bound = { id: objectId, root, sync: options.syncTransform !== false, parts: /* @__PURE__ */ new Map(), lastPos: null, lastQuat: null, piece: null, isSplit: false };
5010
- for (const pid of partIds) bound.parts.set(pid, this.makePartVis(objectId, pid, state?.parts.find((p) => p.id === pid)?.material ?? "", byPart.get(pid) ?? []));
5389
+ const hosts = /* @__PURE__ */ new Map();
5390
+ 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));
5011
5391
  for (const [pid, meshes] of byPart) {
5012
- if (!bound.parts.has(pid)) bound.parts.set(pid, this.makePartVis(objectId, pid, this.world.part(objectId, pid)?.material ?? "", meshes));
5392
+ if (!bound.parts.has(pid)) bound.parts.set(pid, this.makePartVis(objectId, pid, this.world.part(objectId, pid)?.material ?? "", meshes, hosts));
5013
5393
  }
5014
5394
  this.bounds.set(objectId, bound);
5015
5395
  }
5016
- makePartVis(home, id, material, meshes) {
5017
- return { id, home, material, meshes: meshes.map((m) => this.visuals.makeMeshVis(m, home)), charApplied: 0, glowApplied: 0, meltApplied: 0, gone: "none" };
5396
+ makePartVis(home, id, material, meshes, hosts) {
5397
+ 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" };
5018
5398
  }
5019
5399
  /** Match unlabelled meshes to the part whose shape contains the centre of the mesh's bounding box. */
5020
5400
  matchByBox(objectId, root, meshes, add2) {
@@ -5074,11 +5454,13 @@ void main() {
5074
5454
  const inv = root.matrixWorld.clone().invert();
5075
5455
  const total = new this.THREE.Box3();
5076
5456
  const m4 = new this.THREE.Matrix4();
5077
- for (const m of meshes) {
5078
- const g = m.geometry;
5079
- if (!g.boundingBox) g.computeBoundingBox();
5080
- if (!g.boundingBox || g.boundingBox.isEmpty()) continue;
5081
- total.union(g.boundingBox.clone().applyMatrix4(m4.multiplyMatrices(inv, m.matrixWorld)));
5457
+ const lb = new this.THREE.Box3();
5458
+ for (const mv of meshes) {
5459
+ const m = mv.mesh;
5460
+ m.updateWorldMatrix(true, false);
5461
+ this.visuals.localBox(mv, lb);
5462
+ if (lb.isEmpty()) continue;
5463
+ total.union(lb.clone().applyMatrix4(m4.multiplyMatrices(inv, m.matrixWorld)));
5082
5464
  }
5083
5465
  if (total.isEmpty()) return { half: [0.25, 0.25, 0.25], center: new this.THREE.Vector3() };
5084
5466
  const size = total.getSize(new this.THREE.Vector3());
@@ -5086,7 +5468,7 @@ void main() {
5086
5468
  }
5087
5469
  meshesOf(b) {
5088
5470
  const out = [];
5089
- for (const pv of b.parts.values()) if (pv.gone === "none") for (const mv of pv.meshes) out.push(mv.mesh);
5471
+ for (const pv of b.parts.values()) if (pv.gone === "none") for (const mv of pv.meshes) out.push(mv);
5090
5472
  return out;
5091
5473
  }
5092
5474
  massOf(objectId) {
@@ -5217,6 +5599,9 @@ void main() {
5217
5599
  case "detach":
5218
5600
  this.onDetach(ev);
5219
5601
  break;
5602
+ case "collapse":
5603
+ this.splitLoose(ev.object);
5604
+ break;
5220
5605
  case "disassemble_progress": {
5221
5606
  const k = `unscrew:${ev.joint}`;
5222
5607
  if (this.time - (this.throttles.get(k) ?? -1) > 0.25) {
@@ -5335,8 +5720,8 @@ void main() {
5335
5720
  let best = null;
5336
5721
  let bd = Infinity;
5337
5722
  for (const mv of pv.meshes) {
5338
- if (!mv.mesh.visible) continue;
5339
- const d = this.box.setFromObject(mv.mesh).distanceToPoint(p);
5723
+ if (!this.visuals.shown(mv)) continue;
5724
+ const d = this.visuals.worldBox(mv, this.box).distanceToPoint(p);
5340
5725
  if (d < bd) {
5341
5726
  bd = d;
5342
5727
  best = mv;
@@ -5356,7 +5741,7 @@ void main() {
5356
5741
  const wp = new this.THREE.Vector3(ev.worldPoint[0], ev.worldPoint[1], ev.worldPoint[2]);
5357
5742
  const wn = this.worldDir(b, ev.dent.normal);
5358
5743
  for (const mv of pv.meshes) {
5359
- if (!mv.mesh.visible) continue;
5744
+ if (!this.visuals.shown(mv)) continue;
5360
5745
  this.visuals.applyDent(mv, wp, wn, ev.dent);
5361
5746
  }
5362
5747
  if (def?.tags?.includes("metal")) this.particles.burst("sparks", ev.worldPoint, [wn.x, wn.y, wn.z], 4 + Math.min(8, ev.dent.depth * 150), 3.2);
@@ -5447,16 +5832,20 @@ void main() {
5447
5832
  }
5448
5833
  onDetach(ev) {
5449
5834
  this.sound("detach", this.rootPoint(ev.object), 0.5);
5835
+ this.splitLoose(ev.object);
5836
+ }
5837
+ /** Turn every loose group of the object (a detach, a collapse) into a piece of its own. */
5838
+ splitLoose(objectId) {
5450
5839
  if (!this.autoSplit) return;
5451
- const b = this.bounds.get(ev.object);
5840
+ const b = this.bounds.get(objectId);
5452
5841
  if (!b) return;
5453
- const st = this.world.state(ev.object);
5842
+ const st = this.world.state(objectId);
5454
5843
  if (!st || st.groups.length < 2) return;
5455
5844
  const loose = st.groups.length - 1;
5456
5845
  for (let i = 0; i < loose; i++) {
5457
5846
  let newId;
5458
5847
  try {
5459
- newId = this.world.splitGroup(ev.object, 1);
5848
+ newId = this.world.splitGroup(objectId, 1);
5460
5849
  } catch {
5461
5850
  break;
5462
5851
  }
@@ -5467,7 +5856,7 @@ void main() {
5467
5856
  const { half, center } = this.measure(b.root, this.meshesOf(b));
5468
5857
  old.halfExtents = half;
5469
5858
  this.pieceSys.setCenter(old, center);
5470
- old.mass = Math.max(0.05, this.massOf(ev.object));
5859
+ old.mass = Math.max(0.05, this.massOf(objectId));
5471
5860
  old.sleeping = false;
5472
5861
  }
5473
5862
  }
@@ -5489,8 +5878,11 @@ void main() {
5489
5878
  from.parts.delete(p.id);
5490
5879
  nb.parts.set(p.id, pv);
5491
5880
  for (const mv of pv.meshes) {
5492
- group.attach(mv.mesh);
5493
- mv.moved = true;
5881
+ if (mv.host && !mv.loose) this.visuals.extract(mv, group);
5882
+ else {
5883
+ group.attach(mv.mesh);
5884
+ mv.moved = true;
5885
+ }
5494
5886
  }
5495
5887
  }
5496
5888
  this.bounds.set(newId, nb);
@@ -5606,7 +5998,7 @@ void main() {
5606
5998
  }
5607
5999
  onGone(objectId, pv, reason, def) {
5608
6000
  const bb = new this.THREE.Box3();
5609
- for (const mv of pv.meshes) bb.union(this.box.setFromObject(mv.mesh));
6001
+ for (const mv of pv.meshes) bb.union(this.visuals.worldBox(mv, this.box));
5610
6002
  this.visuals.hide(pv, reason);
5611
6003
  if (bb.isEmpty()) return;
5612
6004
  const c = bb.getCenter(new this.THREE.Vector3());
@@ -5623,7 +6015,7 @@ void main() {
5623
6015
  const bb = this.box;
5624
6016
  bb.makeEmpty();
5625
6017
  const tmp = new this.THREE.Box3();
5626
- for (const mv of pv.meshes) if (mv.mesh.visible) bb.union(tmp.setFromObject(mv.mesh));
6018
+ for (const mv of pv.meshes) if (this.visuals.shown(mv)) bb.union(this.visuals.worldBox(mv, tmp));
5627
6019
  let cx = f.point[0];
5628
6020
  let cy = f.point[1];
5629
6021
  let cz = f.point[2];
@@ -10885,8 +11277,8 @@ void main() {
10885
11277
  "sc.concrete_stops_bullet.check": "9 mm into 40 cm of concrete: went through: {through}, {dents}, {holes}, {cracks}, block {hp} hp (expected: stopped, wall behind untouched)",
10886
11278
  "sc.hammer_dents_drum": "Hammer dents a steel drum",
10887
11279
  "sc.hammer_dents_drum.check": "Hammer on the drum: {swings} swing(s), {dents} dent(s), first {depth} mm deep, body {hp} hp (expected: dented, not broken)",
10888
- "sc.glass_shatters": "Glass pane shatters after a few 9 mm shots",
10889
- "sc.glass_shatters.check": "Glass pane: broken after {shots} shot(s) into {n} shards (expected: broken, at least 4 shards)",
11280
+ "sc.glass_shatters": "Glass pane shatters on the first 9 mm shot",
11281
+ "sc.glass_shatters.check": "Glass pane: broken after {shots} shot(s) into {n} shards (expected: broken on the first shot, at least 4 shards)",
10890
11282
  "sc.shotgun_splinters_crate": "Shotgun splinters a wooden crate",
10891
11283
  "sc.shotgun_splinters_crate.check": "12 ga on the crate: {shots} shot(s), {splinters} board(s) splintered, {holes} holes (expected: at least one splintered)",
10892
11284
  "sc.kick_chair_leg": "A kick takes a chair leg off",
@@ -11174,8 +11566,8 @@ void main() {
11174
11566
  "sc.concrete_stops_bullet.check": "9 mm em 40 cm de concreto: atravessou: {through}, {dents}, {holes}, {cracks}, bloco com {hp} hp (esperado: parou, parede atr\xE1s intacta)",
11175
11567
  "sc.hammer_dents_drum": "Martelo amassa um tambor de a\xE7o",
11176
11568
  "sc.hammer_dents_drum.check": "Martelo no tambor: {swings} golpe(s), {dents} amassado(s), o primeiro com {depth} mm, corpo com {hp} hp (esperado: amassado, sem quebrar)",
11177
- "sc.glass_shatters": "Vidro estilha\xE7a depois de alguns tiros de 9 mm",
11178
- "sc.glass_shatters.check": "Vidro: quebrou depois de {shots} tiro(s) em {n} cacos (esperado: quebrado, pelo menos 4 cacos)",
11569
+ "sc.glass_shatters": "Vidro estilha\xE7a no primeiro tiro de 9 mm",
11570
+ "sc.glass_shatters.check": "Vidro: quebrou depois de {shots} tiro(s) em {n} cacos (esperado: quebrado no primeiro tiro, pelo menos 4 cacos)",
11179
11571
  "sc.shotgun_splinters_crate": "Escopeta lasca um caixote de madeira",
11180
11572
  "sc.shotgun_splinters_crate.check": "Calibre 12 no caixote: {shots} tiro(s), {splinters} t\xE1bua(s) lascada(s), {holes} furos (esperado: pelo menos uma lascada)",
11181
11573
  "sc.kick_chair_leg": "Um chute solta a perna da cadeira",
@@ -12617,7 +13009,7 @@ void main() {
12617
13009
  step(0.5);
12618
13010
  const fr = room.log.entries({ type: "fracture", objectId: id }).find((e) => e.part === "pane");
12619
13011
  const n = Number(fr?.data?.fragments ?? 0);
12620
- expect(part(room, id, "pane")?.status === "broken" && n >= 4, t("sc.glass_shatters.check", { shots, n }), { shots, fragments: n });
13012
+ expect(part(room, id, "pane")?.status === "broken" && shots === 1 && n >= 4, t("sc.glass_shatters.check", { shots, n }), { shots, fragments: n });
12621
13013
  }),
12622
13014
  sc("shotgun_splinters_crate", ({ room, step, expect }) => {
12623
13015
  const id = at(room, "wooden_crate", 0, -4);