@adatechnology/game-floorplan-three 0.6.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,65 +6254,57 @@ 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
  }
6260
+ function noise2(x, y, cell) {
6261
+ const h = (i2, j2) => {
6262
+ const s = Math.sin(i2 * 127.1 + j2 * 311.7) * 43758.5453;
6263
+ return s - Math.floor(s);
6264
+ };
6265
+ const fx = x / cell, fy = y / cell, i = Math.floor(fx), j = Math.floor(fy), u = fx - i, v = fy - j;
6266
+ const su = u * u * (3 - 2 * u), sv = v * v * (3 - 2 * v);
6267
+ return (h(i, j) * (1 - su) + h(i + 1, j) * su) * (1 - sv) + (h(i, j + 1) * (1 - su) + h(i + 1, j + 1) * su) * sv;
6268
+ }
6269
+ var MASK_TEXEL = 0.02;
6270
+ var SPALL_MIN = 0.025;
6271
+ var SPALL_RAGGED = 0.045;
6272
+ var COVER = 0.015;
6273
+ var MAX_FLAKES = 64;
6254
6274
  function installDestructionBreach(K, _shared) {
6255
6275
  const { THREE, fragmentMaterial, invalidate, invalidateShadow } = K;
6256
6276
  function createBreachView(opts) {
6257
6277
  const { house, structure } = opts;
6258
6278
  const views = /* @__PURE__ */ new Map();
6279
+ const dirty = /* @__PURE__ */ new Set();
6280
+ const paintBudgetMs = opts.paintBudgetMs ?? Infinity;
6281
+ const dropped = [];
6259
6282
  let lod = 0;
6260
- const unitMaterial = new THREE.MeshLambertMaterial({ color: 16777215 });
6261
6283
  const unitGeometry = new THREE.BoxGeometry(1, 1, 1);
6262
6284
  const tmp = { m: new THREE.Matrix4(), q: new THREE.Quaternion(), p: new THREE.Vector3(), s: new THREE.Vector3(), c: new THREE.Color() };
6263
- const wallColour = (items) => {
6264
- for (const it of items) {
6265
- if (!it.mesh) continue;
6266
- const c = it.mesh.geometry.attributes.color;
6267
- if (!c) continue;
6268
- const a = c.array;
6269
- tmp.c.setRGB(a[it.v0 * 3], a[it.v0 * 3 + 1], a[it.v0 * 3 + 2]);
6270
- return tmp.c.getHex();
6271
- }
6272
- return null;
6273
- };
6274
6285
  const local = (p) => {
6275
6286
  house.updateMatrixWorld(true);
6276
6287
  const v = house.worldToLocal(tmp.p.set(p[0], p[1], p[2]));
6277
6288
  return [v.x, v.y, v.z];
6278
6289
  };
6279
- function courses(part, units, opening, chips, paint) {
6290
+ function cells(part, units) {
6280
6291
  const [u0, v0, u1, v1] = part.aabb;
6281
6292
  const alongU = u1 - u0 >= v1 - v0;
6282
6293
  const len = alongU ? u1 - u0 : v1 - v0;
6283
- const thick = alongU ? v1 - v0 : u1 - u0;
6284
6294
  const [ul, uh] = units.size;
6285
6295
  const rows = Math.max(1, Math.round((part.y1 - part.y0) / uh));
6286
6296
  const rowH = (part.y1 - part.y0) / rows;
6287
6297
  const per = Math.max(1, Math.round(len / ul));
6288
6298
  const w = len / per;
6289
- const edge = fragmentMaterial(part.material).color.getHex();
6290
- const list = [];
6299
+ const out = [];
6291
6300
  for (let r = 0; r < rows; r++) {
6292
6301
  const shift = units.bond === "running" && r & 1 ? w / 2 : 0;
6293
- const cells = per + (shift ? 1 : 0);
6294
- for (let i = 0; i < cells; i++) {
6295
- let a = i * w - shift, b = a + w;
6296
- a = Math.max(0, a);
6297
- b = Math.min(len, b);
6298
- if (b - a < 1e-3) continue;
6299
- const mid = (a + b) / 2;
6300
- const x = alongU ? u0 + mid : (u0 + u1) / 2;
6301
- const z = alongU ? (v0 + v1) / 2 : v0 + mid;
6302
- const y = part.y0 + (r + 0.5) * rowH;
6303
- const d = opening ? Math.hypot(x - opening.center[0], y - opening.center[1], z - opening.center[2]) : Infinity;
6304
- if (opening && d < opening.radius) continue;
6305
- const gone = (c) => Math.abs(c[1] - y) <= rowH / 2 && (alongU ? c[0] >= u0 + a && c[0] <= u0 + b : c[2] >= v0 + a && c[2] <= v0 + b);
6306
- if (chips.some(gone)) continue;
6307
- const near = opening && d < opening.radius + 0.25 || chips.some((c) => Math.hypot(c[0] - x, c[1] - y, c[2] - z) < 0.15);
6308
- list.push({ x, y, z, sx: alongU ? b - a : thick, sy: rowH, sz: alongU ? thick : b - a, colour: near || paint === null ? edge : paint });
6302
+ for (let i = 0; i < per + (shift ? 1 : 0); i++) {
6303
+ const a = Math.max(0, i * w - shift), b = Math.min(len, i * w - shift + w);
6304
+ if (b - a > 1e-3) out.push({ a, b, y0: part.y0 + r * rowH, y1: part.y0 + (r + 1) * rowH, r, i });
6309
6305
  }
6310
6306
  }
6311
- return list;
6307
+ return out;
6312
6308
  }
6313
6309
  const specOf = (id) => opts.spec()?.parts?.find((q) => q.id === id);
6314
6310
  const same = (a, b) => a.every((x, k) => Math.abs(x - b[k]) < 1e-3);
@@ -6322,6 +6318,200 @@ function installDestructionBreach(K, _shared) {
6322
6318
  return null;
6323
6319
  }
6324
6320
  const riders = (hostId) => structure.parts.filter((q) => q.id !== hostId && specOf(q.id)?.units && hostOf(q.id) === hostId && !structure.meshesOf(q.id).some((it) => it.mesh)).map((q) => q.id);
6321
+ function shell(it, mask) {
6322
+ const g0 = it.mesh.geometry, g = new THREE.BufferGeometry();
6323
+ const src = it.mesh.userData._p0 || g0.attributes.position.array;
6324
+ for (const [name, attr] of Object.entries(g0.attributes)) {
6325
+ const k = attr.itemSize;
6326
+ g.setAttribute(name, new THREE.BufferAttribute((name === "position" ? src : attr.array).slice(it.v0 * k, it.v1 * k), k));
6327
+ }
6328
+ if (g0.index) {
6329
+ const ix = g0.index.array.slice(it.i0, it.i1);
6330
+ for (let k = 0; k < ix.length; k++) ix[k] -= it.v0;
6331
+ g.setIndex(new THREE.BufferAttribute(ix, 1));
6332
+ }
6333
+ g.computeBoundingBox();
6334
+ g.computeBoundingSphere();
6335
+ const base = it.mesh.material;
6336
+ const material = base.clone();
6337
+ const toHouse = { value: new THREE.Matrix4() };
6338
+ material.onBeforeCompile = (sh) => {
6339
+ base.onBeforeCompile?.(sh);
6340
+ sh.uniforms.uToHouse = toHouse;
6341
+ sh.uniforms.uMask = { value: mask.texture };
6342
+ sh.uniforms.uMaskRect = { value: mask.rect };
6343
+ sh.uniforms.uMaskAlongX = { value: mask.alongU ? 1 : 0 };
6344
+ sh.vertexShader = sh.vertexShader.replace("void main() {", "uniform mat4 uToHouse;\nvarying vec3 vHousePos;\nvoid main() {").replace("#include <begin_vertex>", "#include <begin_vertex>\nvHousePos = (uToHouse * vec4(transformed, 1.0)).xyz;");
6345
+ sh.fragmentShader = sh.fragmentShader.replace("void main() {", "uniform sampler2D uMask;\nuniform vec4 uMaskRect;\nuniform float uMaskAlongX;\nvarying vec3 vHousePos;\nvoid main() {\n vec2 mUv = vec2(((uMaskAlongX > 0.5 ? vHousePos.x : vHousePos.z) - uMaskRect.x) / uMaskRect.z, (vHousePos.y - uMaskRect.y) / uMaskRect.w);\n if (mUv.x >= 0.0 && mUv.x <= 1.0 && mUv.y >= 0.0 && mUv.y <= 1.0 && texture2D(uMask, mUv).r > 0.5) discard;");
6346
+ };
6347
+ material.customProgramCacheKey = () => "breachMask" + (base.customProgramCacheKey?.() ?? "");
6348
+ const mesh = new THREE.Mesh(g, material);
6349
+ mesh.name = "breach-shell";
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
+ };
6356
+ mesh.position.copy(it.mesh.position);
6357
+ mesh.quaternion.copy(it.mesh.quaternion);
6358
+ mesh.scale.copy(it.mesh.scale);
6359
+ mesh.castShadow = it.mesh.castShadow;
6360
+ mesh.receiveShadow = it.mesh.receiveShadow;
6361
+ mesh.renderOrder = it.mesh.renderOrder;
6362
+ mesh.onBeforeRender = () => {
6363
+ house.updateMatrixWorld();
6364
+ toHouse.value.copy(house.matrixWorld).invert().multiply(mesh.matrixWorld);
6365
+ };
6366
+ it.mesh.parent.add(mesh);
6367
+ mesh.updateMatrixWorld(true);
6368
+ toHouse.value.copy(house.matrixWorld).invert().multiply(mesh.matrixWorld);
6369
+ return mesh;
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
+ }
6384
+ function maskOf(hostId, parts) {
6385
+ const host = structure.byId.get(hostId);
6386
+ const [u0, v0, u1, v1] = host.aabb;
6387
+ const alongU = u1 - u0 >= v1 - v0;
6388
+ const y0 = Math.min(...parts.map((id) => structure.byId.get(id).y0)), y1 = Math.max(...parts.map((id) => structure.byId.get(id).y1));
6389
+ const len = alongU ? u1 - u0 : v1 - v0;
6390
+ const w = Math.max(2, Math.min(2048, Math.ceil(len / MASK_TEXEL))), h = Math.max(2, Math.min(1024, Math.ceil((y1 - y0) / MASK_TEXEL)));
6391
+ const data = new Uint8Array(w * h);
6392
+ const texture = new THREE.DataTexture(data, w, h, THREE.RedFormat);
6393
+ texture.magFilter = THREE.LinearFilter;
6394
+ texture.minFilter = THREE.LinearFilter;
6395
+ texture.needsUpdate = true;
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) };
6397
+ }
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
+ }
6412
+ function paint(view) {
6413
+ const { mask, core } = view;
6414
+ const along = (p) => (mask.alongU ? p[0] : p[2]) - mask.start;
6415
+ const holes = [];
6416
+ const pits = [];
6417
+ const discs = [];
6418
+ const flakes = [];
6419
+ const shown = [];
6420
+ for (const id of view.parts) {
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);
6422
+ const all = cells(part, units2);
6423
+ const thick = mask.alongU ? part.aabb[3] - part.aabb[1] : part.aabb[2] - part.aabb[0];
6424
+ const mid = mask.alongU ? (part.aabb[1] + part.aabb[3]) / 2 : (part.aabb[0] + part.aabb[2]) / 2;
6425
+ if (opening) discs.push({ s: along(opening.center), y: opening.center[1], r: 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;
6429
+ const kept = [];
6430
+ for (const c of all) (gone(c) ? holes : kept).push(c);
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
+ }
6436
+ }
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);
6439
+ mask.data.fill(0);
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));
6444
+ for (let j = j0; j <= j1; j++) {
6445
+ const y = m.y0 + (j + 0.5) / m.h * m.height;
6446
+ for (let i = i0; i <= i1; i++) {
6447
+ const s = (i + 0.5) / m.w * m.len;
6448
+ if (inside(s, y)) m.data[j * m.w + i] = 255;
6449
+ }
6450
+ }
6451
+ };
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
+ }
6463
+ mask.texture.needsUpdate = true;
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);
6466
+ const list = [];
6467
+ const tone = (hex, k) => tmp.c.setHex(hex).multiplyScalar(k).getHex();
6468
+ for (const { c, part, units: units2, thick, mid } of shown) {
6469
+ if (!near(c)) continue;
6470
+ const centre = mask.start + (c.a + c.b) / 2, y = (c.y0 + c.y1) / 2, len = c.b - c.a, h = c.y1 - c.y0;
6471
+ const at = (s, dy, d, colour) => list.push({ x: mask.alongU ? centre : mid, y, z: mask.alongU ? mid : centre, sx: mask.alongU ? s : d, sy: dy, sz: mask.alongU ? d : s, colour });
6472
+ const base = fragmentMaterial(part.material).color.getHex();
6473
+ const n = noise2(centre * 3.1, y * 2.7, 0.07);
6474
+ if (units2.mortar === "concrete") {
6475
+ at(len, h, thick - 2 * COVER, tone(base, 0.9 + 0.04 * n));
6476
+ } else {
6477
+ at(len, h, thick - 2 * COVER - 0.012, 10327948);
6478
+ at(len - 0.012, h - 0.012, thick - 2 * COVER, tone(base, 0.8 + 0.35 * n));
6479
+ }
6480
+ }
6481
+ if (view.units) {
6482
+ view.units.removeFromParent();
6483
+ view.units.dispose();
6484
+ }
6485
+ const units = new THREE.InstancedMesh(unitGeometry, view.unitMaterial, Math.max(1, list.length));
6486
+ units.name = "breach:" + view.host;
6487
+ list.forEach((u, i) => {
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));
6489
+ units.setMatrixAt(i, tmp.m);
6490
+ units.setColorAt(i, tmp.c.setHex(u.colour));
6491
+ });
6492
+ units.count = list.length;
6493
+ units.instanceMatrix.needsUpdate = true;
6494
+ if (units.instanceColor) units.instanceColor.needsUpdate = true;
6495
+ units.castShadow = true;
6496
+ units.receiveShadow = true;
6497
+ units.visible = lod === 0;
6498
+ house.add(units);
6499
+ view.units = units;
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 };
6514
+ }
6325
6515
  function draw2(partId, change) {
6326
6516
  if (lod !== 0) return false;
6327
6517
  const hostId = hostOf(partId);
@@ -6331,40 +6521,50 @@ function installDestructionBreach(K, _shared) {
6331
6521
  const items = structure.meshesOf(hostId).filter((it) => it.mesh);
6332
6522
  if (!items.length) return false;
6333
6523
  const parts = [hostId, ...riders(hostId)].filter((id) => structure.byId.get(id) && specOf(id)?.units);
6334
- view = { mesh: null, saved: null, items, paint: wallColour(items), units: 0, opening: null, chips: new Map(parts.map((id) => [id, []])), openings: /* @__PURE__ */ new Map() };
6524
+ if (!parts.includes(partId)) return false;
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
+ };
6335
6543
  }
6336
6544
  if (!view.chips.has(partId)) return false;
6337
6545
  if (change.opening) {
6338
6546
  if (view.openings.has(partId)) return false;
6339
6547
  view.openings.set(partId, { center: local(change.opening.center), radius: change.opening.radius });
6340
- if (partId === hostId) view.opening = view.openings.get(partId);
6341
6548
  }
6342
- if (change.chip) view.chips.get(partId).push(local(change.chip));
6343
- const list = [];
6344
- for (const [id, chips] of view.chips) list.push(...courses(structure.byId.get(id), specOf(id).units, view.openings.get(id) ?? null, chips, view.paint));
6345
- if (view.mesh) {
6346
- view.mesh.removeFromParent();
6347
- view.mesh.dispose();
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
+ }
6555
+ if (!view.saved) {
6556
+ view.shells = view.items.map((it) => shell(it, view.mask));
6557
+ for (const m of view.shells) m.userData.behind = () => view.units;
6558
+ view.saved = view.items.map((it) => ({ it, v0: it.v0, data: collapseRange(it.mesh, it.v0, it.v1) }));
6348
6559
  }
6349
- const mesh = new THREE.InstancedMesh(unitGeometry, unitMaterial, Math.max(1, list.length));
6350
- mesh.name = "breach:" + hostId;
6351
- list.forEach((u, i) => {
6352
- tmp.m.compose(tmp.p.set(u.x, u.y, u.z), tmp.q.identity(), tmp.s.set(u.sx, u.sy, u.sz));
6353
- mesh.setMatrixAt(i, tmp.m);
6354
- mesh.setColorAt(i, tmp.c.setHex(u.colour));
6355
- });
6356
- mesh.count = list.length;
6357
- mesh.instanceMatrix.needsUpdate = true;
6358
- if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
6359
- mesh.castShadow = true;
6360
- mesh.receiveShadow = true;
6361
- house.add(mesh);
6362
- if (!view.saved) view.saved = view.items.map((it) => ({ it, v0: it.v0, data: collapseRange(it.mesh, it.v0, it.v1) }));
6363
- view.mesh = mesh;
6364
- view.units = list.length;
6365
6560
  views.set(hostId, view);
6366
- invalidate();
6367
- invalidateShadow();
6561
+ if (opts.deferPaint && change.spall) dirty.add(view);
6562
+ else {
6563
+ dirty.delete(view);
6564
+ paint(view);
6565
+ invalidate();
6566
+ invalidateShadow();
6567
+ }
6368
6568
  return true;
6369
6569
  }
6370
6570
  function plain(view, shown) {
@@ -6373,30 +6573,88 @@ function installDestructionBreach(K, _shared) {
6373
6573
  else s.data = collapseRange(s.it.mesh, s.v0, s.it.v1);
6374
6574
  }
6375
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
+ }
6588
+ function dispose(view) {
6589
+ dirty.delete(view);
6590
+ if (view.units) {
6591
+ view.units.removeFromParent();
6592
+ view.units.dispose();
6593
+ }
6594
+ view.unitMaterial.dispose();
6595
+ for (const m of view.shells) {
6596
+ m.removeFromParent();
6597
+ m.geometry.dispose();
6598
+ m.material.dispose();
6599
+ }
6600
+ view.mask.texture.dispose();
6601
+ view.core.texture.dispose();
6602
+ }
6376
6603
  return {
6377
- /** Handles a `stage` event of the house's object. Returns true when the wall was redrawn as courses. */
6604
+ /** Handles a `stage` event of the house's object. Returns true when the wall was cut. */
6378
6605
  stage(event) {
6379
6606
  return event.stage === 2 && !!event.opening ? draw2(event.part, { opening: event.opening }) : false;
6380
6607
  },
6381
- /** Handles a `chip` event: the unit that came out leaves a gap in the courses. Returns true when the wall was redrawn. */
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
+ */
6382
6637
  chip(event) {
6383
- 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] } });
6384
6640
  },
6385
- /** The part broke or fell: the courses go (the plain range stays collapsed, the renderer hides the part). */
6641
+ /** The part broke or fell: its damage goes (the plain range stays collapsed, the renderer hides the part). */
6386
6642
  drop(partId) {
6387
6643
  const v = views.get(partId);
6388
6644
  if (!v) return;
6389
- v.mesh.removeFromParent();
6390
- v.mesh.dispose();
6645
+ dispose(v);
6391
6646
  views.delete(partId);
6647
+ if (v.saved) dropped.push(v);
6392
6648
  },
6393
- /** LOD 0 draws the courses; LOD 1 and 2 give the wall its box back. */
6649
+ /** LOD 0 draws the cut wall; LOD 1 and 2 give the wall its box back. */
6394
6650
  setLod(level) {
6651
+ flush(Infinity);
6395
6652
  const was = lod;
6396
6653
  lod = level;
6397
6654
  if (was === 0 === (level === 0)) return;
6398
6655
  for (const v of views.values()) {
6399
- v.mesh.visible = level === 0;
6656
+ v.units.visible = level === 0;
6657
+ for (const m of v.shells) m.visible = level === 0;
6400
6658
  plain(v, level !== 0);
6401
6659
  }
6402
6660
  invalidate();
@@ -6405,23 +6663,33 @@ function installDestructionBreach(K, _shared) {
6405
6663
  /** Everything back to what it was (after `world.reset`). */
6406
6664
  restore() {
6407
6665
  for (const v of views.values()) {
6408
- v.mesh.removeFromParent();
6409
- v.mesh.dispose();
6666
+ dispose(v);
6410
6667
  if (lod === 0) plain(v, true);
6411
6668
  }
6412
6669
  views.clear();
6670
+ for (const v of dropped) plain(v, true);
6671
+ dropped.length = 0;
6413
6672
  },
6414
- /** The wall's opening, if it has courses. */
6673
+ flush,
6674
+ /** The wall has damage drawn. */
6415
6675
  has(partId) {
6416
6676
  return views.has(partId);
6417
6677
  },
6418
- statistics: () => ({ walls: views.size, units: [...views.values()].reduce((n, v) => n + v.units, 0) })
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), {
6680
+ walls: views.size,
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),
6684
+ units: [...views.values()].reduce((n, v) => n + v.units.count, 0)
6685
+ })
6419
6686
  };
6420
6687
  }
6421
6688
  Object.assign(K, { createBreachView });
6422
6689
  }
6423
6690
 
6424
6691
  // src/destruction/world.ts
6692
+ var FOLLOW_FLUSH_FALLBACK_MS = 100;
6425
6693
  function installDestructionWorld(K, shared) {
6426
6694
  const { THREE, cfg, invalidateShadow, invalidate, rngString, FRAGMENT_GEO, fragmentMaterial, createBreachView } = K;
6427
6695
  function createHouseDestruction(root, house, structure, parts, colliders, water) {
@@ -6430,7 +6698,7 @@ function installDestructionWorld(K, shared) {
6430
6698
  const R = cfg.destructionRenderer || null, listeners = /* @__PURE__ */ new Set();
6431
6699
  D.renderer = R;
6432
6700
  D.spec = null;
6433
- 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 } : {} });
6434
6702
  D.breach = breach;
6435
6703
  D.onEvent = (fn) => {
6436
6704
  listeners.add(fn);
@@ -6615,6 +6883,9 @@ function installDestructionWorld(K, shared) {
6615
6883
  case "chip":
6616
6884
  breach.chip(e);
6617
6885
  break;
6886
+ case "spall":
6887
+ breach.spall(e);
6888
+ break;
6618
6889
  case "collapse":
6619
6890
  for (const id of e.parts) {
6620
6891
  breach.drop(id);
@@ -6656,7 +6927,8 @@ function installDestructionWorld(K, shared) {
6656
6927
  D.specOptions = specOptions || null;
6657
6928
  D.spec = structure.spec(specOptions);
6658
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] });
6659
- 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() });
6660
6932
  off = D.world.on("*", handleEvent);
6661
6933
  D.active = true;
6662
6934
  return D;
@@ -6671,6 +6943,8 @@ function installDestructionWorld(K, shared) {
6671
6943
  };
6672
6944
  D.follow = (world, id, spec) => {
6673
6945
  D.spec = spec;
6946
+ breach.claim();
6947
+ let fallback = null;
6674
6948
  const offFollow = world.on("*", (e) => {
6675
6949
  if (e.object !== id) return;
6676
6950
  switch (e.type) {
@@ -6680,6 +6954,9 @@ function installDestructionWorld(K, shared) {
6680
6954
  case "chip":
6681
6955
  breach.chip(e);
6682
6956
  break;
6957
+ case "spall":
6958
+ breach.spall(e);
6959
+ break;
6683
6960
  case "fracture":
6684
6961
  breach.drop(e.part);
6685
6962
  break;
@@ -6689,10 +6966,16 @@ function installDestructionWorld(K, shared) {
6689
6966
  default:
6690
6967
  break;
6691
6968
  }
6969
+ if (!fallback && typeof setTimeout === "function") fallback = setTimeout(() => {
6970
+ fallback = null;
6971
+ breach.flush(Infinity);
6972
+ }, FOLLOW_FLUSH_FALLBACK_MS);
6692
6973
  });
6693
6974
  return { restore: () => breach.restore(), dispose: () => {
6694
6975
  offFollow();
6695
6976
  breach.restore();
6977
+ }, update: () => {
6978
+ breach.flush();
6696
6979
  } };
6697
6980
  };
6698
6981
  D.piece = (id) => D.active ? D.world.part(D.id, id) : null;
@@ -6725,6 +7008,7 @@ function installDestructionWorld(K, shared) {
6725
7008
  return r;
6726
7009
  };
6727
7010
  D.animate = (dt) => {
7011
+ breach.flush();
6728
7012
  if (R) return;
6729
7013
  let moved = false;
6730
7014
  for (const [id, list] of bodies) {
@@ -8191,6 +8475,7 @@ function createFloorplanTestModule(options = {}) {
8191
8475
  houseRenderer = create(THREE, {
8192
8476
  locale,
8193
8477
  lod: { interior: 10, hide: 40 },
8478
+ breachPaintBudgetMs: 4,
8194
8479
  ...options.house,
8195
8480
  quality: settings.quality,
8196
8481
  ...options.world ? { destruction: options.world } : {},
@@ -8346,9 +8631,39 @@ function createFloorplanTestModule(options = {}) {
8346
8631
  host?.log(text.steps.no_door);
8347
8632
  return;
8348
8633
  }
8349
- 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));
8350
8639
  host?.log(fill(text.steps.door, { text: String(target.text) }));
8351
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
+ };
8352
8667
  const strike = () => {
8353
8668
  const world = options.world;
8354
8669
  if (!world || !host) return;