@adatechnology/game-floorplan-three 0.7.0 → 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
@@ -6236,6 +6236,10 @@ function installDestructionPieces(K, shared) {
6236
6236
  function collapseRange(mesh, v0, v1) {
6237
6237
  const pos = mesh.geometry.attributes.position;
6238
6238
  const a = pos.array;
6239
+ const u = mesh.userData;
6240
+ if (!u._p0) u._p0 = a.slice();
6241
+ const list = u.collapsedRanges ?? (u.collapsedRanges = []);
6242
+ if (!list.some((r) => r[0] === v0 && r[1] === v1)) list.push([v0, v1]);
6239
6243
  const saved = a.slice(v0 * 3, v1 * 3);
6240
6244
  const x = a[v0 * 3], y = a[v0 * 3 + 1], z = a[v0 * 3 + 2];
6241
6245
  for (let k = v0; k < v1; k++) {
@@ -6250,6 +6254,8 @@ function restoreRange(mesh, v0, saved) {
6250
6254
  const pos = mesh.geometry.attributes.position;
6251
6255
  pos.array.set(saved, v0 * 3);
6252
6256
  pos.needsUpdate = true;
6257
+ const list = mesh.userData.collapsedRanges;
6258
+ if (list) mesh.userData.collapsedRanges = list.filter((r) => r[0] !== v0);
6253
6259
  }
6254
6260
  function noise2(x, y, cell) {
6255
6261
  const h = (i2, j2) => {
@@ -6264,13 +6270,16 @@ var MASK_TEXEL = 0.02;
6264
6270
  var SPALL_MIN = 0.025;
6265
6271
  var SPALL_RAGGED = 0.045;
6266
6272
  var COVER = 0.015;
6273
+ var MAX_FLAKES = 64;
6267
6274
  function installDestructionBreach(K, _shared) {
6268
6275
  const { THREE, fragmentMaterial, invalidate, invalidateShadow } = K;
6269
6276
  function createBreachView(opts) {
6270
6277
  const { house, structure } = opts;
6271
6278
  const views = /* @__PURE__ */ new Map();
6279
+ const dirty = /* @__PURE__ */ new Set();
6280
+ const paintBudgetMs = opts.paintBudgetMs ?? Infinity;
6281
+ const dropped = [];
6272
6282
  let lod = 0;
6273
- const unitMaterial = new THREE.MeshLambertMaterial({ color: 16777215 });
6274
6283
  const unitGeometry = new THREE.BoxGeometry(1, 1, 1);
6275
6284
  const tmp = { m: new THREE.Matrix4(), q: new THREE.Quaternion(), p: new THREE.Vector3(), s: new THREE.Vector3(), c: new THREE.Color() };
6276
6285
  const local = (p) => {
@@ -6339,6 +6348,11 @@ function installDestructionBreach(K, _shared) {
6339
6348
  const mesh = new THREE.Mesh(g, material);
6340
6349
  mesh.name = "breach-shell";
6341
6350
  mesh.userData.breachMask = mask;
6351
+ mesh.userData.cutAt = (p) => {
6352
+ const v = local([p.x, p.y, p.z]);
6353
+ const i = Math.floor(((mask.alongU ? v[0] : v[2]) - mask.start) / mask.len * mask.w), j = Math.floor((v[1] - mask.y0) / mask.height * mask.h);
6354
+ return i >= 0 && i < mask.w && j >= 0 && j < mask.h && mask.data[j * mask.w + i] > 127;
6355
+ };
6342
6356
  mesh.position.copy(it.mesh.position);
6343
6357
  mesh.quaternion.copy(it.mesh.quaternion);
6344
6358
  mesh.scale.copy(it.mesh.scale);
@@ -6354,6 +6368,19 @@ function installDestructionBreach(K, _shared) {
6354
6368
  toHouse.value.copy(house.matrixWorld).invert().multiply(mesh.matrixWorld);
6355
6369
  return mesh;
6356
6370
  }
6371
+ function unitsMaterial(core) {
6372
+ const material = new THREE.MeshLambertMaterial({ color: 16777215, side: THREE.DoubleSide });
6373
+ material.onBeforeCompile = (sh) => {
6374
+ sh.uniforms.uCore = { value: core.texture };
6375
+ sh.uniforms.uCoreRect = { value: core.rect };
6376
+ sh.uniforms.uCoreAlongX = { value: core.alongU ? 1 : 0 };
6377
+ sh.vertexShader = sh.vertexShader.replace("void main() {", "varying vec3 vHousePos;\nvoid main() {").replace("#include <begin_vertex>", "#include <begin_vertex>\n#ifdef USE_INSTANCING\nvHousePos = (instanceMatrix * vec4(transformed, 1.0)).xyz;\n#else\nvHousePos = transformed;\n#endif");
6378
+ sh.fragmentShader = sh.fragmentShader.replace("void main() {", "uniform sampler2D uCore;\nuniform vec4 uCoreRect;\nuniform float uCoreAlongX;\nvarying vec3 vHousePos;\nvoid main() {\n vec2 cUv = vec2(((uCoreAlongX > 0.5 ? vHousePos.x : vHousePos.z) - uCoreRect.x) / uCoreRect.z, (vHousePos.y - uCoreRect.y) / uCoreRect.w);\n if (cUv.x >= 0.0 && cUv.x <= 1.0 && cUv.y >= 0.0 && cUv.y <= 1.0 && texture2D(uCore, cUv).r > 0.5) discard;").replace("#include <color_fragment>", "#include <color_fragment>\nif (!gl_FrontFacing) diffuseColor.rgb *= 0.55;");
6379
+ };
6380
+ material.customProgramCacheKey = () => "breachCore";
6381
+ material.userData.breachCore = core;
6382
+ return material;
6383
+ }
6357
6384
  function maskOf(hostId, parts) {
6358
6385
  const host = structure.byId.get(hostId);
6359
6386
  const [u0, v0, u1, v1] = host.aabb;
@@ -6369,41 +6396,73 @@ function installDestructionBreach(K, _shared) {
6369
6396
  return { data, texture, w, h, alongU, start: alongU ? u0 : v0, len, y0, height: y1 - y0, rect: new THREE.Vector4(alongU ? u0 : v0, y0, len, y1 - y0) };
6370
6397
  }
6371
6398
  const boxDistance = (s, y, a, b, c, d) => Math.hypot(Math.max(a - s, 0, s - b), Math.max(c - y, 0, y - d));
6399
+ const ellipseDistance = (s, y, cs, cy, rs, ry) => (Math.hypot((s - cs) / Math.max(rs, 1e-4), (y - cy) / Math.max(ry, 1e-4)) - 1) * Math.min(rs, ry);
6400
+ function covered(c, boxes) {
6401
+ const n = 8;
6402
+ let hit = 0;
6403
+ for (let j = 0; j < n; j++) {
6404
+ const y = c.y0 + (j + 0.5) / n * (c.y1 - c.y0);
6405
+ for (let i = 0; i < n; i++) {
6406
+ const s = c.a + (i + 0.5) / n * (c.b - c.a);
6407
+ if (boxes.some((k) => s >= k.s0 && s <= k.s1 && y >= k.y0 && y <= k.y1)) hit++;
6408
+ }
6409
+ }
6410
+ return hit / (n * n);
6411
+ }
6372
6412
  function paint(view) {
6373
- const { mask } = view;
6413
+ const { mask, core } = view;
6374
6414
  const along = (p) => (mask.alongU ? p[0] : p[2]) - mask.start;
6375
6415
  const holes = [];
6416
+ const pits = [];
6376
6417
  const discs = [];
6418
+ const flakes = [];
6377
6419
  const shown = [];
6378
6420
  for (const id of view.parts) {
6379
- const part = structure.byId.get(id), units2 = specOf(id).units, chips = view.chips.get(id), opening = view.openings.get(id);
6421
+ const part = structure.byId.get(id), units2 = specOf(id).units, chips = view.chips.get(id), opening = view.openings.get(id), spalls = view.spalls.get(id);
6380
6422
  const all = cells(part, units2);
6381
6423
  const thick = mask.alongU ? part.aabb[3] - part.aabb[1] : part.aabb[2] - part.aabb[0];
6382
6424
  const mid = mask.alongU ? (part.aabb[1] + part.aabb[3]) / 2 : (part.aabb[0] + part.aabb[2]) / 2;
6383
6425
  if (opening) discs.push({ s: along(opening.center), y: opening.center[1], r: opening.radius });
6384
- const gone = (c) => chips.some((p) => p[1] >= c.y0 && p[1] <= c.y1 && along(p) >= c.a && along(p) <= c.b) || opening && Math.hypot((c.a + c.b) / 2 - along(opening.center), (c.y0 + c.y1) / 2 - opening.center[1]) < opening.radius;
6426
+ for (const k of spalls) flakes.push({ s: along(k.center), y: k.center[1], r: k.radius });
6427
+ const boxes = chips.map((k) => ({ s0: along(k.center) - k.along, s1: along(k.center) + k.along, y0: k.center[1] - k.up, y1: k.center[1] + k.up }));
6428
+ const gone = (c) => covered(c, boxes.filter((k) => k.s1 > c.a && k.s0 < c.b && k.y1 > c.y0 && k.y0 < c.y1)) >= 0.9 || opening && Math.hypot((c.a + c.b) / 2 - along(opening.center), (c.y0 + c.y1) / 2 - opening.center[1]) < opening.radius;
6385
6429
  const kept = [];
6386
6430
  for (const c of all) (gone(c) ? holes : kept).push(c);
6387
6431
  for (const c of kept) shown.push({ c, part, units: units2, thick, mid });
6432
+ for (const k of boxes) {
6433
+ if (holes.some((h) => k.s0 >= h.a - 1e-4 && k.s1 <= h.b + 1e-4 && k.y0 >= h.y0 - 1e-4 && k.y1 <= h.y1 + 1e-4)) continue;
6434
+ pits.push({ s: (k.s0 + k.s1) / 2, y: (k.y0 + k.y1) / 2, rs: (k.s1 - k.s0) / 2, ry: (k.y1 - k.y0) / 2 });
6435
+ }
6388
6436
  }
6389
6437
  const reach = SPALL_MIN + SPALL_RAGGED;
6438
+ const ragged = (s, y) => 0.6 * noise2(s, y, 0.03) + 0.4 * noise2(s + 7.3, y - 3.1, 0.011);
6390
6439
  mask.data.fill(0);
6391
- const stamp = (s0, s1, y0, y1, inside) => {
6392
- const i0 = Math.max(0, Math.floor((s0 - reach) / mask.len * mask.w)), i1 = Math.min(mask.w - 1, Math.ceil((s1 + reach) / mask.len * mask.w));
6393
- const j0 = Math.max(0, Math.floor((y0 - reach - mask.y0) / mask.height * mask.h)), j1 = Math.min(mask.h - 1, Math.ceil((y1 + reach - mask.y0) / mask.height * mask.h));
6440
+ core.data.fill(0);
6441
+ const stamp = (m, s0, s1, y0, y1, inside) => {
6442
+ const i0 = Math.max(0, Math.floor((s0 - reach) / m.len * m.w)), i1 = Math.min(m.w - 1, Math.ceil((s1 + reach) / m.len * m.w));
6443
+ const j0 = Math.max(0, Math.floor((y0 - reach - m.y0) / m.height * m.h)), j1 = Math.min(m.h - 1, Math.ceil((y1 + reach - m.y0) / m.height * m.h));
6394
6444
  for (let j = j0; j <= j1; j++) {
6395
- const y = mask.y0 + (j + 0.5) / mask.h * mask.height;
6445
+ const y = m.y0 + (j + 0.5) / m.h * m.height;
6396
6446
  for (let i = i0; i <= i1; i++) {
6397
- const s = (i + 0.5) / mask.w * mask.len;
6398
- const edge = SPALL_MIN + SPALL_RAGGED * (0.6 * noise2(s, y, 0.03) + 0.4 * noise2(s + 7.3, y - 3.1, 0.011));
6399
- if (inside(s, y) <= edge) mask.data[j * mask.w + i] = 255;
6447
+ const s = (i + 0.5) / m.w * m.len;
6448
+ if (inside(s, y)) m.data[j * m.w + i] = 255;
6400
6449
  }
6401
6450
  }
6402
6451
  };
6403
- for (const c of holes) stamp(c.a, c.b, c.y0, c.y1, (s, y) => boxDistance(s, y, c.a, c.b, c.y0, c.y1));
6404
- for (const d of discs) stamp(d.s - d.r, d.s + d.r, d.y - d.r, d.y + d.r, (s, y) => Math.max(0, Math.hypot(s - d.s, y - d.y) - d.r));
6452
+ const plaster = (d, s, y) => d <= SPALL_MIN + SPALL_RAGGED * ragged(s, y);
6453
+ for (const c of holes) stamp(mask, c.a, c.b, c.y0, c.y1, (s, y) => plaster(boxDistance(s, y, c.a, c.b, c.y0, c.y1), s, y));
6454
+ for (const d of discs) stamp(mask, d.s - d.r, d.s + d.r, d.y - d.r, d.y + d.r, (s, y) => plaster(Math.max(0, Math.hypot(s - d.s, y - d.y) - d.r), s, y));
6455
+ for (const f of flakes) {
6456
+ const rough = (s, y) => Math.hypot(s - f.s, y - f.y) - f.r * (0.65 + 0.7 * ragged(s * 2.3, y * 2.3));
6457
+ stamp(mask, f.s - 2 * f.r, f.s + 2 * f.r, f.y - 2 * f.r, f.y + 2 * f.r, (s, y) => rough(s, y) <= 0);
6458
+ }
6459
+ for (const p of pits) {
6460
+ stamp(mask, p.s - p.rs, p.s + p.rs, p.y - p.ry, p.y + p.ry, (s, y) => plaster(Math.max(0, ellipseDistance(s, y, p.s, p.y, p.rs, p.ry)), s, y));
6461
+ stamp(core, p.s - p.rs, p.s + p.rs, p.y - p.ry, p.y + p.ry, (s, y) => ellipseDistance(s, y, p.s, p.y, p.rs, p.ry) <= -0.35 * Math.min(p.rs, p.ry) * noise2(s * 1.7, y * 1.3, 0.012));
6462
+ }
6405
6463
  mask.texture.needsUpdate = true;
6406
- const near = (c) => holes.some((h) => boxDistance((c.a + c.b) / 2, (c.y0 + c.y1) / 2, h.a - (c.b - c.a) / 2, h.b + (c.b - c.a) / 2, h.y0 - (c.y1 - c.y0) / 2, h.y1 + (c.y1 - c.y0) / 2) <= reach) || discs.some((d) => boxDistance(d.s, d.y, c.a, c.b, c.y0, c.y1) <= d.r + reach);
6464
+ core.texture.needsUpdate = true;
6465
+ const near = (c) => holes.some((h) => boxDistance((c.a + c.b) / 2, (c.y0 + c.y1) / 2, h.a - (c.b - c.a) / 2, h.b + (c.b - c.a) / 2, h.y0 - (c.y1 - c.y0) / 2, h.y1 + (c.y1 - c.y0) / 2) <= reach) || discs.some((d) => boxDistance(d.s, d.y, c.a, c.b, c.y0, c.y1) <= d.r + reach) || pits.some((p) => boxDistance(p.s, p.y, c.a, c.b, c.y0, c.y1) <= Math.max(p.rs, p.ry) + reach) || flakes.some((f) => boxDistance(f.s, f.y, c.a, c.b, c.y0, c.y1) <= 2 * f.r);
6407
6466
  const list = [];
6408
6467
  const tone = (hex, k) => tmp.c.setHex(hex).multiplyScalar(k).getHex();
6409
6468
  for (const { c, part, units: units2, thick, mid } of shown) {
@@ -6413,7 +6472,7 @@ function installDestructionBreach(K, _shared) {
6413
6472
  const base = fragmentMaterial(part.material).color.getHex();
6414
6473
  const n = noise2(centre * 3.1, y * 2.7, 0.07);
6415
6474
  if (units2.mortar === "concrete") {
6416
- at(len, h, thick - 2 * COVER - 0.03 * n, tone(base, 0.86 + 0.2 * n));
6475
+ at(len, h, thick - 2 * COVER, tone(base, 0.9 + 0.04 * n));
6417
6476
  } else {
6418
6477
  at(len, h, thick - 2 * COVER - 0.012, 10327948);
6419
6478
  at(len - 0.012, h - 0.012, thick - 2 * COVER, tone(base, 0.8 + 0.35 * n));
@@ -6423,7 +6482,7 @@ function installDestructionBreach(K, _shared) {
6423
6482
  view.units.removeFromParent();
6424
6483
  view.units.dispose();
6425
6484
  }
6426
- const units = new THREE.InstancedMesh(unitGeometry, unitMaterial, Math.max(1, list.length));
6485
+ const units = new THREE.InstancedMesh(unitGeometry, view.unitMaterial, Math.max(1, list.length));
6427
6486
  units.name = "breach:" + view.host;
6428
6487
  list.forEach((u, i) => {
6429
6488
  tmp.m.compose(tmp.p.set(u.x, u.y, u.z), tmp.q.identity(), tmp.s.set(u.sx, u.sy, u.sz));
@@ -6439,6 +6498,19 @@ function installDestructionBreach(K, _shared) {
6439
6498
  house.add(units);
6440
6499
  view.units = units;
6441
6500
  view.gone = holes.length;
6501
+ view.pits = pits.length;
6502
+ view.flakes = flakes.length;
6503
+ }
6504
+ function chipBox(chip, alongU) {
6505
+ house.updateMatrixWorld(true);
6506
+ const q = tmp.q.set(chip.rotation[0], chip.rotation[1], chip.rotation[2], chip.rotation[3]).premultiply(house.getWorldQuaternion(new THREE.Quaternion()).invert());
6507
+ let a = 0, u = 0;
6508
+ for (let k = 0; k < 3; k++) {
6509
+ const axis = tmp.p.set(k === 0 ? 1 : 0, k === 1 ? 1 : 0, k === 2 ? 1 : 0).applyQuaternion(q);
6510
+ a += Math.abs(alongU ? axis.x : axis.z) * chip.size[k] / 2;
6511
+ u += Math.abs(axis.y) * chip.size[k] / 2;
6512
+ }
6513
+ return { center: local(chip.center), along: a, up: u };
6442
6514
  }
6443
6515
  function draw2(partId, change) {
6444
6516
  if (lod !== 0) return false;
@@ -6450,23 +6522,49 @@ function installDestructionBreach(K, _shared) {
6450
6522
  if (!items.length) return false;
6451
6523
  const parts = [hostId, ...riders(hostId)].filter((id) => structure.byId.get(id) && specOf(id)?.units);
6452
6524
  if (!parts.includes(partId)) return false;
6453
- const mask = maskOf(hostId, parts);
6454
- view = { host: hostId, parts, items, mask, shells: [], saved: null, units: null, gone: 0, chips: new Map(parts.map((id) => [id, []])), openings: /* @__PURE__ */ new Map() };
6525
+ const mask = maskOf(hostId, parts), core = maskOf(hostId, parts);
6526
+ view = {
6527
+ host: hostId,
6528
+ parts,
6529
+ items,
6530
+ mask,
6531
+ core,
6532
+ unitMaterial: unitsMaterial(core),
6533
+ shells: [],
6534
+ saved: null,
6535
+ units: null,
6536
+ gone: 0,
6537
+ pits: 0,
6538
+ chips: new Map(parts.map((id) => [id, []])),
6539
+ spalls: new Map(parts.map((id) => [id, []])),
6540
+ openings: /* @__PURE__ */ new Map(),
6541
+ flakes: 0
6542
+ };
6455
6543
  }
6456
6544
  if (!view.chips.has(partId)) return false;
6457
6545
  if (change.opening) {
6458
6546
  if (view.openings.has(partId)) return false;
6459
6547
  view.openings.set(partId, { center: local(change.opening.center), radius: change.opening.radius });
6460
6548
  }
6461
- if (change.chip) view.chips.get(partId).push(local(change.chip));
6549
+ if (change.chip) view.chips.get(partId).push(chipBox(change.chip, view.mask.alongU));
6550
+ if (change.spall) {
6551
+ const list = view.spalls.get(partId);
6552
+ list.push({ center: local(change.spall.center), radius: change.spall.radius });
6553
+ if (list.length > MAX_FLAKES) list.shift();
6554
+ }
6462
6555
  if (!view.saved) {
6463
6556
  view.shells = view.items.map((it) => shell(it, view.mask));
6557
+ for (const m of view.shells) m.userData.behind = () => view.units;
6464
6558
  view.saved = view.items.map((it) => ({ it, v0: it.v0, data: collapseRange(it.mesh, it.v0, it.v1) }));
6465
6559
  }
6466
- paint(view);
6467
6560
  views.set(hostId, view);
6468
- invalidate();
6469
- invalidateShadow();
6561
+ if (opts.deferPaint && change.spall) dirty.add(view);
6562
+ else {
6563
+ dirty.delete(view);
6564
+ paint(view);
6565
+ invalidate();
6566
+ invalidateShadow();
6567
+ }
6470
6568
  return true;
6471
6569
  }
6472
6570
  function plain(view, shown) {
@@ -6475,24 +6573,70 @@ function installDestructionBreach(K, _shared) {
6475
6573
  else s.data = collapseRange(s.it.mesh, s.v0, s.it.v1);
6476
6574
  }
6477
6575
  }
6576
+ function flush(budgetMs = paintBudgetMs) {
6577
+ if (dirty.size === 0) return 0;
6578
+ const deadline = performance.now() + budgetMs;
6579
+ for (const view of dirty) {
6580
+ dirty.delete(view);
6581
+ paint(view);
6582
+ if (performance.now() >= deadline) break;
6583
+ }
6584
+ invalidate();
6585
+ invalidateShadow();
6586
+ return dirty.size;
6587
+ }
6478
6588
  function dispose(view) {
6479
- view.units.removeFromParent();
6480
- view.units.dispose();
6589
+ dirty.delete(view);
6590
+ if (view.units) {
6591
+ view.units.removeFromParent();
6592
+ view.units.dispose();
6593
+ }
6594
+ view.unitMaterial.dispose();
6481
6595
  for (const m of view.shells) {
6482
6596
  m.removeFromParent();
6483
6597
  m.geometry.dispose();
6484
6598
  m.material.dispose();
6485
6599
  }
6486
6600
  view.mask.texture.dispose();
6601
+ view.core.texture.dispose();
6487
6602
  }
6488
6603
  return {
6489
6604
  /** Handles a `stage` event of the house's object. Returns true when the wall was cut. */
6490
6605
  stage(event) {
6491
6606
  return event.stage === 2 && !!event.opening ? draw2(event.part, { opening: event.opening }) : false;
6492
6607
  },
6493
- /** Handles a `chip` event: the unit that came out leaves a hole in the wall where it was. Returns true when the wall was cut. */
6608
+ /**
6609
+ * Handles a `spall` of a wall of units: the coat comes off in a ragged patch and the units show under it, drawn by the house (the destruction
6610
+ * renderer leaves its flat patch out on the meshes `claim` marked). Returns true when the wall was cut.
6611
+ */
6612
+ spall(event) {
6613
+ const r = Math.sqrt(Math.max(0, event.area) / Math.PI);
6614
+ return r > 0 ? draw2(event.part, { spall: { center: event.point, radius: r } }) : false;
6615
+ },
6616
+ /**
6617
+ * Marks the meshes that draw a wall of units as drawing its spalls itself (`userData.drawnSpalls`, the part ids): the destruction renderer then
6618
+ * throws the bits and leaves its patch out. Call it once the spec is known.
6619
+ */
6620
+ claim() {
6621
+ var _a;
6622
+ for (const q of opts.spec()?.parts ?? []) {
6623
+ if (!q.units) continue;
6624
+ const host = hostOf(q.id);
6625
+ if (!host) continue;
6626
+ for (const it of structure.meshesOf(host)) {
6627
+ if (!it.mesh) continue;
6628
+ const list = (_a = it.mesh.userData).drawnSpalls ?? (_a.drawnSpalls = []);
6629
+ if (!list.includes(q.id)) list.push(q.id);
6630
+ }
6631
+ }
6632
+ },
6633
+ /**
6634
+ * Handles a `chip` event: what came out (a unit, or a crater a shot broke through one) leaves a hole in the wall where it was; a `torn` chip (what a blow
6635
+ * tore off a face) leaves the marks the renderer drew. Returns true when the wall was cut.
6636
+ */
6494
6637
  chip(event) {
6495
- return draw2(event.part, { chip: event.center });
6638
+ if (event.torn) return false;
6639
+ return draw2(event.part, { chip: { center: event.center, size: event.size ?? [0, 0, 0], rotation: event.rotation ?? [0, 0, 0, 1] } });
6496
6640
  },
6497
6641
  /** The part broke or fell: its damage goes (the plain range stays collapsed, the renderer hides the part). */
6498
6642
  drop(partId) {
@@ -6500,9 +6644,11 @@ function installDestructionBreach(K, _shared) {
6500
6644
  if (!v) return;
6501
6645
  dispose(v);
6502
6646
  views.delete(partId);
6647
+ if (v.saved) dropped.push(v);
6503
6648
  },
6504
6649
  /** LOD 0 draws the cut wall; LOD 1 and 2 give the wall its box back. */
6505
6650
  setLod(level) {
6651
+ flush(Infinity);
6506
6652
  const was = lod;
6507
6653
  lod = level;
6508
6654
  if (was === 0 === (level === 0)) return;
@@ -6521,15 +6667,20 @@ function installDestructionBreach(K, _shared) {
6521
6667
  if (lod === 0) plain(v, true);
6522
6668
  }
6523
6669
  views.clear();
6670
+ for (const v of dropped) plain(v, true);
6671
+ dropped.length = 0;
6524
6672
  },
6673
+ flush,
6525
6674
  /** The wall has damage drawn. */
6526
6675
  has(partId) {
6527
6676
  return views.has(partId);
6528
6677
  },
6529
- /** Walls with damage drawn, units out of them, and units laid bare at the edge of the cuts. */
6530
- statistics: () => ({
6678
+ /** Walls with damage drawn, units out of them, craters shots broke through units that stay, and units laid bare at the edge of the cuts. */
6679
+ statistics: () => (flush(Infinity), {
6531
6680
  walls: views.size,
6532
6681
  gone: [...views.values()].reduce((n, v) => n + v.gone, 0),
6682
+ craters: [...views.values()].reduce((n, v) => n + v.pits, 0),
6683
+ flakes: [...views.values()].reduce((n, v) => n + v.flakes, 0),
6533
6684
  units: [...views.values()].reduce((n, v) => n + v.units.count, 0)
6534
6685
  })
6535
6686
  };
@@ -6538,6 +6689,7 @@ function installDestructionBreach(K, _shared) {
6538
6689
  }
6539
6690
 
6540
6691
  // src/destruction/world.ts
6692
+ var FOLLOW_FLUSH_FALLBACK_MS = 100;
6541
6693
  function installDestructionWorld(K, shared) {
6542
6694
  const { THREE, cfg, invalidateShadow, invalidate, rngString, FRAGMENT_GEO, fragmentMaterial, createBreachView } = K;
6543
6695
  function createHouseDestruction(root, house, structure, parts, colliders, water) {
@@ -6546,7 +6698,7 @@ function installDestructionWorld(K, shared) {
6546
6698
  const R = cfg.destructionRenderer || null, listeners = /* @__PURE__ */ new Set();
6547
6699
  D.renderer = R;
6548
6700
  D.spec = null;
6549
- const breach = createBreachView({ house, structure, spec: () => D.spec });
6701
+ const breach = createBreachView({ house, structure, spec: () => D.spec, deferPaint: true, ...cfg.breachPaintBudgetMs !== void 0 ? { paintBudgetMs: cfg.breachPaintBudgetMs } : {} });
6550
6702
  D.breach = breach;
6551
6703
  D.onEvent = (fn) => {
6552
6704
  listeners.add(fn);
@@ -6731,6 +6883,9 @@ function installDestructionWorld(K, shared) {
6731
6883
  case "chip":
6732
6884
  breach.chip(e);
6733
6885
  break;
6886
+ case "spall":
6887
+ breach.spall(e);
6888
+ break;
6734
6889
  case "collapse":
6735
6890
  for (const id of e.parts) {
6736
6891
  breach.drop(id);
@@ -6772,7 +6927,8 @@ function installDestructionWorld(K, shared) {
6772
6927
  D.specOptions = specOptions || null;
6773
6928
  D.spec = structure.spec(specOptions);
6774
6929
  D.id = D.world.add(D.spec, { id: "house3d:" + ++shared.destructionSeq, position: [p.x, p.y, p.z], rotation: [q.x, q.y, q.z, q.w] });
6775
- if (R) R.bind(D.id, house, { parts: rangesOf() });
6930
+ breach.claim();
6931
+ if (R) R.bind(D.id, house, { parts: rangesOf(), onFrame: () => breach.flush() });
6776
6932
  off = D.world.on("*", handleEvent);
6777
6933
  D.active = true;
6778
6934
  return D;
@@ -6787,6 +6943,8 @@ function installDestructionWorld(K, shared) {
6787
6943
  };
6788
6944
  D.follow = (world, id, spec) => {
6789
6945
  D.spec = spec;
6946
+ breach.claim();
6947
+ let fallback = null;
6790
6948
  const offFollow = world.on("*", (e) => {
6791
6949
  if (e.object !== id) return;
6792
6950
  switch (e.type) {
@@ -6796,6 +6954,9 @@ function installDestructionWorld(K, shared) {
6796
6954
  case "chip":
6797
6955
  breach.chip(e);
6798
6956
  break;
6957
+ case "spall":
6958
+ breach.spall(e);
6959
+ break;
6799
6960
  case "fracture":
6800
6961
  breach.drop(e.part);
6801
6962
  break;
@@ -6805,10 +6966,16 @@ function installDestructionWorld(K, shared) {
6805
6966
  default:
6806
6967
  break;
6807
6968
  }
6969
+ if (!fallback && typeof setTimeout === "function") fallback = setTimeout(() => {
6970
+ fallback = null;
6971
+ breach.flush(Infinity);
6972
+ }, FOLLOW_FLUSH_FALLBACK_MS);
6808
6973
  });
6809
6974
  return { restore: () => breach.restore(), dispose: () => {
6810
6975
  offFollow();
6811
6976
  breach.restore();
6977
+ }, update: () => {
6978
+ breach.flush();
6812
6979
  } };
6813
6980
  };
6814
6981
  D.piece = (id) => D.active ? D.world.part(D.id, id) : null;
@@ -6841,6 +7008,7 @@ function installDestructionWorld(K, shared) {
6841
7008
  return r;
6842
7009
  };
6843
7010
  D.animate = (dt) => {
7011
+ breach.flush();
6844
7012
  if (R) return;
6845
7013
  let moved = false;
6846
7014
  for (const [id, list] of bodies) {
@@ -8307,6 +8475,7 @@ function createFloorplanTestModule(options = {}) {
8307
8475
  houseRenderer = create(THREE, {
8308
8476
  locale,
8309
8477
  lod: { interior: 10, hide: 40 },
8478
+ breachPaintBudgetMs: 4,
8310
8479
  ...options.house,
8311
8480
  quality: settings.quality,
8312
8481
  ...options.world ? { destruction: options.world } : {},
@@ -8462,9 +8631,39 @@ function createFloorplanTestModule(options = {}) {
8462
8631
  host?.log(text.steps.no_door);
8463
8632
  return;
8464
8633
  }
8465
- p.house.interact(eyeOf(p));
8634
+ const fo = target.target;
8635
+ const reach = fo && host?.reachFor && fo.type === "swing" && fo.gs?.[0] && fo.handles?.[0] ? doorReach(p, fo) : null;
8636
+ if (reach && host?.reachFor) {
8637
+ host.reachFor({ ...reach, onGrip: () => void p.house.toggleDoor(fo) });
8638
+ } else p.house.interact(eyeOf(p));
8466
8639
  host?.log(fill(text.steps.door, { text: String(target.text) }));
8467
8640
  };
8641
+ const doorReach = (p, fo) => {
8642
+ const g = fo.gs[0];
8643
+ const pivot = fo.handles[0].pivot;
8644
+ const at = (angle) => {
8645
+ const c = Math.cos(angle);
8646
+ const s = Math.sin(angle);
8647
+ const v = new THREE.Vector3(pivot[0] * c + pivot[2] * s, pivot[1], -pivot[0] * s + pivot[2] * c).add(g.position);
8648
+ return g.parent ? g.parent.localToWorld(v) : v;
8649
+ };
8650
+ const camera = host?.camera;
8651
+ const eye = new THREE.Vector3(camera.position.x, 0, camera.position.z);
8652
+ const flat = (v) => Math.hypot(v.x - eye.x, v.z - eye.z);
8653
+ const closed = at(0);
8654
+ const open = at(fo.rot);
8655
+ const opening = !fo.open;
8656
+ return {
8657
+ handle: () => {
8658
+ g.updateMatrixWorld(true);
8659
+ return g.localToWorld(new THREE.Vector3(pivot[0], pivot[1], pivot[2])).toArray();
8660
+ },
8661
+ hinge: () => g.getWorldPosition(new THREE.Vector3()).toArray(),
8662
+ seconds: fo.locked || fo.blockedFlag ? 0.6 : 0.55,
8663
+ pull: opening ? flat(open) <= flat(closed) : flat(closed) <= flat(open),
8664
+ locked: !!(fo.locked || fo.blockedFlag)
8665
+ };
8666
+ };
8468
8667
  const strike = () => {
8469
8668
  const world = options.world;
8470
8669
  if (!world || !host) return;