@adatechnology/game-test-room 0.1.0 → 0.1.2

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.
@@ -126,6 +126,82 @@ var GameTestRoom = (() => {
126
126
  }
127
127
  return { ok: problems.length === 0, problems };
128
128
  };
129
+ var STRUCTURE_ROLES = ["foundation", "column", "beam", "slab", "wall", "partition", "roof", "stair", "none"];
130
+ var BEARS = [true, false, "a_on_b", "b_on_a"];
131
+ var finite = (n) => typeof n === "number" && Number.isFinite(n);
132
+ var positive = (n) => finite(n) && n > 0;
133
+ var vec3 = (v) => Array.isArray(v) && v.length === 3 && v.every(finite);
134
+ var nonNegative = (n) => n === void 0 || finite(n) && n >= 0;
135
+ var checkSpec = (spec, options = {}) => {
136
+ const problems = [];
137
+ const warnings = [];
138
+ const materials = options.materials ? new Set(options.materials) : null;
139
+ const parts = Array.isArray(spec?.parts) ? spec.parts : [];
140
+ if (!Array.isArray(spec?.parts) || parts.length === 0) problems.push("the spec needs at least one part");
141
+ const ids = /* @__PURE__ */ new Set();
142
+ for (const p of parts) {
143
+ const id = String(p?.id ?? "");
144
+ const label = `part "${id}"`;
145
+ if (id === "") problems.push("a part has no id");
146
+ else if (ids.has(id)) problems.push(`duplicate part id "${id}"`);
147
+ ids.add(id);
148
+ if (materials && !materials.has(p?.material)) problems.push(`${label} has unknown material "${String(p?.material)}"`);
149
+ else if (typeof p?.material !== "string" || p.material === "") problems.push(`${label} needs a material`);
150
+ const s = p?.shape;
151
+ if (s?.kind === "box") {
152
+ if (!Array.isArray(s.size) || s.size.length !== 3 || !s.size.every(positive)) problems.push(`${label} box size must be three finite positive numbers`);
153
+ } else if (s?.kind === "cylinder") {
154
+ if (!positive(s.radius) || !positive(s.height)) problems.push(`${label} cylinder radius and height must be finite and positive`);
155
+ } else if (s?.kind === "sphere") {
156
+ if (!positive(s.radius)) problems.push(`${label} sphere radius must be finite and positive`);
157
+ } else problems.push(`${label} has an unknown shape`);
158
+ if (!vec3(p?.position)) problems.push(`${label} needs a finite position [x, y, z]`);
159
+ if (p?.rotation !== void 0 && !vec3(p.rotation)) problems.push(`${label} has an invalid rotation`);
160
+ for (const f of ["mass", "hp", "thickness"]) if (!nonNegative(p?.[f])) problems.push(`${label} ${f} must be a finite number >= 0`);
161
+ if (p?.fill !== void 0 && !(finite(p.fill) && p.fill >= 0 && p.fill <= 1)) problems.push(`${label} fill must be between 0 and 1`);
162
+ if (p?.structure) {
163
+ if (!STRUCTURE_ROLES.includes(p.structure.role)) problems.push(`${label} has an invalid structure role "${String(p.structure.role)}"`);
164
+ if (!nonNegative(p.structure.capacity)) problems.push(`${label} structure capacity must be a finite number >= 0`);
165
+ }
166
+ if (p?.contains && !nonNegative(p.contains.liters)) problems.push(`${label} contains.liters must be a finite number >= 0`);
167
+ }
168
+ const jointIds = /* @__PURE__ */ new Set();
169
+ const edges = [];
170
+ for (const j of spec?.joints ?? []) {
171
+ const id = j.id ?? `${j.a}~${j.b}`;
172
+ const label = `joint "${id}"`;
173
+ if (jointIds.has(id)) problems.push(`duplicate joint id "${id}"`);
174
+ jointIds.add(id);
175
+ if (!ids.has(j.a)) problems.push(`${label} references unknown part "${String(j.a)}"`);
176
+ if (!ids.has(j.b)) problems.push(`${label} references unknown part "${String(j.b)}"`);
177
+ if (j.a === j.b) problems.push(`${label} joins part "${String(j.a)}" to itself`);
178
+ if (j.bears !== void 0 && !BEARS.includes(j.bears)) problems.push(`${label} has an invalid bears "${String(j.bears)}"`);
179
+ if (!nonNegative(j.strength)) problems.push(`${label} strength must be a finite number >= 0`);
180
+ if (!nonNegative(j.seconds)) problems.push(`${label} seconds must be a finite number >= 0`);
181
+ if (j.bears !== false && ids.has(j.a) && ids.has(j.b) && j.a !== j.b) edges.push([j.a, j.b]);
182
+ }
183
+ const structural = parts.filter((p) => p?.structure && p.structure.role !== "none");
184
+ if (structural.length && spec.joints) {
185
+ const anchored = parts.filter((p) => p.anchored || p.structure?.role === "foundation").map((p) => p.id);
186
+ const adj = /* @__PURE__ */ new Map();
187
+ for (const [a, b] of edges) {
188
+ adj.set(a, [...adj.get(a) ?? [], b]);
189
+ adj.set(b, [...adj.get(b) ?? [], a]);
190
+ }
191
+ const seen = new Set(anchored);
192
+ const queue = [...anchored];
193
+ while (queue.length) {
194
+ for (const n of adj.get(queue.pop()) ?? []) {
195
+ if (seen.has(n)) continue;
196
+ seen.add(n);
197
+ queue.push(n);
198
+ }
199
+ }
200
+ const lost = structural.filter((p) => !seen.has(p.id)).map((p) => p.id);
201
+ if (lost.length) warnings.push(`structural part(s) ${lost.map((i) => `"${i}"`).join(", ")} cannot reach an anchored part, so they will fall`);
202
+ }
203
+ return { ok: problems.length === 0, problems, warnings };
204
+ };
129
205
  var finite3 = (v) => Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === "number" && Number.isFinite(n));
130
206
  var checkAnimation = (clip, partIds) => {
131
207
  const problems = [];
@@ -1251,7 +1327,7 @@ var GameTestRoom = (() => {
1251
1327
  {
1252
1328
  id: "green_wood",
1253
1329
  density: 650,
1254
- hpPerKg: 120,
1330
+ hpPerKg: 5,
1255
1331
  penetration: 10,
1256
1332
  ductility: 0.5,
1257
1333
  fracture: "splinter",
@@ -1260,7 +1336,8 @@ var GameTestRoom = (() => {
1260
1336
  0.2
1261
1337
  ],
1262
1338
  damageScale: {
1263
- slash: 1.3
1339
+ slash: 1.3,
1340
+ ballistic: 0.1
1264
1341
  },
1265
1342
  hitDebris: "splinters",
1266
1343
  breakDebris: "leaves",
@@ -1564,9 +1641,20 @@ var GameTestRoom = (() => {
1564
1641
  var DMG_PER_J = 0.02;
1565
1642
  var BLAST_COUPLING = 0.1;
1566
1643
  var FLAMMABLE_FLUIDS = ["gas", "fuel"];
1644
+ var CUT_SLICE = 1;
1645
+ var cutFactorOf = (shape) => clamp2(Math.max(...shapeDims(shape)) / CUT_SLICE, 1, 50);
1567
1646
  var clamp2 = (v, a, b) => Math.max(a, Math.min(b, v));
1568
1647
  var isLost = (p) => p.st.status === "broken" || p.st.status === "melted" || p.st.status === "ashes";
1569
1648
  var clone = (v) => structuredClone(v);
1649
+ var updateBound = (o) => {
1650
+ if (!o.parts.length) return;
1651
+ const c = [0, 0, 0];
1652
+ for (const p of o.parts) for (let k = 0; k < 3; k++) c[k] += p.pos[k] / o.parts.length;
1653
+ let r = 0;
1654
+ for (const p of o.parts) r = Math.max(r, vec.distance(c, p.pos) + p.bound);
1655
+ o.bc = c;
1656
+ o.br = r;
1657
+ };
1570
1658
  var AXIS = { x: 0, y: 1, z: 2 };
1571
1659
  var createDestruction = (options = {}) => {
1572
1660
  const materials = /* @__PURE__ */ new Map();
@@ -1702,10 +1790,12 @@ var GameTestRoom = (() => {
1702
1790
  mat,
1703
1791
  st: pst,
1704
1792
  q: quatFromEuler(...ps.rotation ?? [0, 0, 0]),
1793
+ pos: [ps.position[0], ps.position[1], ps.position[2]],
1705
1794
  mass0,
1706
1795
  fuel0,
1707
1796
  area: shapeArea(ps.shape),
1708
1797
  thickness: partThickness(ps),
1798
+ cutFactor: cutFactorOf(ps.shape),
1709
1799
  dims: shapeDims(ps.shape),
1710
1800
  bound: shapeBound(ps.shape),
1711
1801
  rt: c?.rt ?? { ...freshRuntime(), prevTemp: pst.temperature },
@@ -1740,14 +1830,8 @@ var GameTestRoom = (() => {
1740
1830
  o.byPart.get(r.b).push(j);
1741
1831
  o.st.joints.push(jst);
1742
1832
  }
1743
- if (o.parts.length) {
1744
- const c = [0, 0, 0];
1745
- for (const p of o.parts) for (let k = 0; k < 3; k++) c[k] += p.spec.position[k] / o.parts.length;
1746
- let r = 0;
1747
- for (const p of o.parts) r = Math.max(r, vec.distance(c, p.spec.position) + p.bound);
1748
- o.bc = c;
1749
- o.br = r;
1750
- }
1833
+ for (const [jid, c] of carry?.joints ?? []) if (c.st.open && !o.jointMap.has(jid)) o.st.joints.push(c.st);
1834
+ updateBound(o);
1751
1835
  o.anyAnchor = o.parts.some((p) => p.anchored);
1752
1836
  o.unsup = o.parts.map(() => false);
1753
1837
  solveStructure(o);
@@ -1887,7 +1971,7 @@ var GameTestRoom = (() => {
1887
1971
  if (!p) throw new Error(`unknown part "${id}" in object "${o.id}"`);
1888
1972
  return p;
1889
1973
  };
1890
- const partXf = (o, p) => ({ c: toWorld(o.st.position, o.st.rotation, p.spec.position), q: quat.mul(o.st.rotation, p.q) });
1974
+ const partXf = (o, p) => ({ c: toWorld(o.st.position, o.st.rotation, p.pos), q: quat.mul(o.st.rotation, p.q) });
1891
1975
  const objLocal = (o, w) => toLocal(o.st.position, o.st.rotation, w);
1892
1976
  const objLocalDir = (o, w) => rotateVec(quat.conjugate(o.st.rotation), w);
1893
1977
  const jointWorld = (o, j) => toWorld(o.st.position, o.st.rotation, j.pos);
@@ -1899,14 +1983,14 @@ var GameTestRoom = (() => {
1899
1983
  const { c, q } = partXf(o, p);
1900
1984
  return aabbOf(p.spec.shape, c, q);
1901
1985
  };
1902
- const rayPartRaw = (pos, rot, spec, pq, origin, d) => {
1903
- const c = toWorld(pos, rot, spec.position);
1986
+ const rayPartRaw = (pos, rot, spec, pq, origin, d, lp = spec.position) => {
1987
+ const c = toWorld(pos, rot, lp);
1904
1988
  const q = quat.mul(rot, pq);
1905
1989
  const qi = quat.conjugate(q);
1906
1990
  const r = rayShape(spec.shape, rotateVec(qi, vec.sub(origin, c)), rotateVec(qi, d));
1907
1991
  return r ? { t0: r.t0, t1: r.t1, n: rotateVec(q, r.n0) } : null;
1908
1992
  };
1909
- const rayPart = (o, p, origin, d) => rayPartRaw(o.st.position, o.st.rotation, p.spec, p.q, origin, d);
1993
+ const rayPart = (o, p, origin, d) => rayPartRaw(o.st.position, o.st.rotation, p.spec, p.q, origin, d, p.pos);
1910
1994
  const raySphereMiss = (origin, d, c, r, maxDist) => {
1911
1995
  const oc = vec.sub(origin, c);
1912
1996
  const b = vec.dot(oc, d);
@@ -1931,7 +2015,8 @@ var GameTestRoom = (() => {
1931
2015
  for (const im of impls.values()) {
1932
2016
  for (const ps of im.impl.describe().parts) {
1933
2017
  if (ignore?.has(`${im.impl.id}\0${ps.id}`)) continue;
1934
- const r = rayPartRaw(im.position, im.rotation, ps, quatFromEuler(...ps.rotation ?? [0, 0, 0]), origin, d);
2018
+ const ov = im.poses.get(ps.id);
2019
+ const r = rayPartRaw(im.position, im.rotation, ps, ov?.q ?? quatFromEuler(...ps.rotation ?? [0, 0, 0]), origin, d, ov?.pos);
1935
2020
  if (!r || r.t0 < 0 || r.t0 > maxDist) continue;
1936
2021
  if (!best || r.t0 < best.distance) best = { object: im.impl.id, part: ps.id, point: vec.madd(origin, d, r.t0), normal: r.n, distance: r.t0, pathLen: r.t1 - r.t0 };
1937
2022
  }
@@ -2066,11 +2151,29 @@ var GameTestRoom = (() => {
2066
2151
  collapseCheck(o, src);
2067
2152
  checkDestroyed(o, src);
2068
2153
  };
2069
- const wearJoints = (o, p, amount, near, radius = 0.06, src) => {
2154
+ const jointReach = (o, j, point) => {
2155
+ const A = partWorldAabb(o, o.parts[j.ia]), B = partWorldAabb(o, o.parts[j.ib]);
2156
+ let d2 = 0;
2157
+ for (let k = 0; k < 3; k++) {
2158
+ const lo = Math.min(Math.max(A.min[k], B.min[k]), Math.min(A.max[k], B.max[k]));
2159
+ const hi = Math.max(Math.max(A.min[k], B.min[k]), Math.min(A.max[k], B.max[k]));
2160
+ const d = point[k] < lo ? lo - point[k] : point[k] > hi ? point[k] - hi : 0;
2161
+ d2 += d * d;
2162
+ }
2163
+ return Math.sqrt(d2);
2164
+ };
2165
+ const jointReachLimit = (o, j) => {
2166
+ const pa = o.parts[j.ia], pb = o.parts[j.ib];
2167
+ const small = pa.dims[0] * pa.dims[1] * pa.dims[2] <= pb.dims[0] * pb.dims[1] * pb.dims[2] ? pa : pb;
2168
+ return Math.max(0.06, 0.5 * Math.min(small.dims[0], small.dims[1], small.dims[2]));
2169
+ };
2170
+ const wearJoints = (o, p, amount, near, radius = 0.06, src, split = false) => {
2070
2171
  if (amount <= 0) return;
2071
- for (const j of o.byPart.get(p.spec.id) ?? []) {
2172
+ const mine = (o.byPart.get(p.spec.id) ?? []).filter((j) => !j.st.open);
2173
+ if (split && mine.length) amount /= mine.length;
2174
+ for (const j of mine) {
2072
2175
  if (j.st.open) continue;
2073
- if (near && vec.distance(jointWorld(o, j), near) > radius) continue;
2176
+ if (near && jointReach(o, j, near) > Math.max(radius, jointReachLimit(o, j))) continue;
2074
2177
  j.st.hp -= amount;
2075
2178
  if (j.st.hp <= 0) {
2076
2179
  j.st.hp = 0;
@@ -2189,7 +2292,7 @@ var GameTestRoom = (() => {
2189
2292
  if (mat.fracture !== "shatter") addHole(o, p, entry, n, caliber / 1e3, false, void 0, src);
2190
2293
  } else addHole(o, p, entry, n, caliber / 1e3, false, void 0, src);
2191
2294
  debris(o, p, mat.hitDebris, entry, n, clamp2(Math.round(E / 60) + 2, 2, 14), clamp2(Math.sqrt(E) / 6, 1, 8), src);
2192
- wearJoints(o, p, E * 0.2, entry, 0.06, src);
2295
+ wearJoints(o, p, (penetrated ? thr : E) * 0.02, void 0, 0, src, true);
2193
2296
  const dmg = applyDamage(o, p, E * DMG_PER_J * scale2, "ballistic", entry, d, E * 0.1, 12, src);
2194
2297
  return { penetrated, exitEnergy: penetrated ? E - thr : 0, exitPoint, damage: dmg };
2195
2298
  };
@@ -2214,7 +2317,7 @@ var GameTestRoom = (() => {
2214
2317
  if (kind === "blunt") wearJoints(o, p, E * 0.3, void 0, 0, src);
2215
2318
  else wearJoints(o, p, E * 0.3, point, 0.06, src);
2216
2319
  const scale2 = mat.damageScale?.[kind] ?? 1;
2217
- return applyDamage(o, p, Ec * DMG_PER_J * scale2, kind, point, d, E * 0.1, 12, src);
2320
+ return applyDamage(o, p, Ec * DMG_PER_J * scale2 * (kind === "slash" ? p.cutFactor : 1), kind, point, d, E * 0.1, 12, src);
2218
2321
  };
2219
2322
  const blastObject = (o, point, energy, radius, src) => {
2220
2323
  const start = cur.length;
@@ -2264,8 +2367,9 @@ var GameTestRoom = (() => {
2264
2367
  }
2265
2368
  for (const im of [...impls.values()]) {
2266
2369
  const inRange = im.impl.describe().parts.some((ps) => {
2267
- const c = toWorld(im.position, im.rotation, ps.position);
2268
- const q = quat.mul(im.rotation, quatFromEuler(...ps.rotation ?? [0, 0, 0]));
2370
+ const ov = im.poses.get(ps.id);
2371
+ const c = toWorld(im.position, im.rotation, ov?.pos ?? ps.position);
2372
+ const q = quat.mul(im.rotation, ov?.q ?? quatFromEuler(...ps.rotation ?? [0, 0, 0]));
2269
2373
  return distanceToShape(ps.shape, toLocal(c, q, point)) < radius;
2270
2374
  });
2271
2375
  if (inRange) out.push(implHit(im, { type: "explosive", point, energy, radius, ...src !== void 0 ? { source: src } : {} }));
@@ -2517,7 +2621,7 @@ var GameTestRoom = (() => {
2517
2621
  const spread = (sources, dt) => {
2518
2622
  for (const src of sources) {
2519
2623
  if (!src.p.st.burning && !src.p.rt.jet) continue;
2520
- const sc = toWorld(src.o.st.position, src.o.st.rotation, src.p.spec.position);
2624
+ const sc = toWorld(src.o.st.position, src.o.st.rotation, src.p.pos);
2521
2625
  const A = partWorldAabb(src.o, src.p);
2522
2626
  for (const o of objects.values()) {
2523
2627
  if (o === src.o) continue;
@@ -2563,9 +2667,23 @@ var GameTestRoom = (() => {
2563
2667
  register(impl, opts = {}) {
2564
2668
  const id = opts.id ?? impl.id;
2565
2669
  if (taken(id)) throw new Error(`object "${id}" already exists`);
2566
- impls.set(id, { impl, position: opts.position ?? [0, 0, 0], rotation: opts.rotation ?? [0, 0, 0, 1] });
2670
+ impls.set(id, { impl, position: opts.position ?? [0, 0, 0], rotation: opts.rotation ?? [0, 0, 0, 1], poses: /* @__PURE__ */ new Map() });
2567
2671
  return id;
2568
2672
  },
2673
+ setPartPose(objectId, partId, position, rotation) {
2674
+ const q = quatFromEuler(...rotation ?? [0, 0, 0]);
2675
+ const im = impls.get(objectId);
2676
+ if (im) {
2677
+ im.impl.setPartPose?.(partId, position, rotation);
2678
+ im.poses.set(partId, { pos: [position[0], position[1], position[2]], q });
2679
+ return;
2680
+ }
2681
+ const o = getObj(objectId);
2682
+ const p = getPart(o, partId);
2683
+ p.pos = [position[0], position[1], position[2]];
2684
+ p.q = q;
2685
+ updateBound(o);
2686
+ },
2569
2687
  remove(id) {
2570
2688
  objects.delete(id);
2571
2689
  impls.delete(id);
@@ -2592,9 +2710,12 @@ var GameTestRoom = (() => {
2592
2710
  const moved = new Set(names);
2593
2711
  const nid = newId ?? nextId();
2594
2712
  if (objects.has(nid)) throw new Error(`object "${nid}" already exists`);
2595
- const carryFor = (keep) => ({
2713
+ const carryFor = (keep, keepOpen = () => false) => ({
2596
2714
  parts: new Map(o.parts.filter((p) => keep(p.spec.id)).map((p) => [p.spec.id, { st: p.st, rt: p.rt }])),
2597
- joints: new Map(o.joints.filter((j) => keep(j.spec.a) && keep(j.spec.b)).map((j) => [j.spec.id, { st: j.st, step: j.step }])),
2715
+ joints: new Map([
2716
+ ...o.joints.filter((j) => keep(j.spec.a) && keep(j.spec.b) || j.st.open && keepOpen(j.spec.a, j.spec.b)).map((j) => [j.spec.id, { st: j.st, step: j.step }]),
2717
+ ...o.st.joints.filter((st) => st.open && !o.jointMap.has(st.id) && keepOpen(st.a, st.b)).map((st) => [st.id, { st, step: 0 }])
2718
+ ]),
2598
2719
  collapsed: [...o.collapsed].filter(keep)
2599
2720
  });
2600
2721
  const sub3 = (keep) => {
@@ -2606,8 +2727,8 @@ var GameTestRoom = (() => {
2606
2727
  const inNew = (pid) => moved.has(pid);
2607
2728
  const inOld = (pid) => !moved.has(pid);
2608
2729
  const pos = o.st.position, rot = o.st.rotation;
2609
- const fresh = build(sub3(inNew), nid, pos, rot, carryFor(inNew));
2610
- const rest = build(sub3(inOld), id, pos, rot, carryFor(inOld));
2730
+ const fresh = build(sub3(inNew), nid, pos, rot, carryFor(inNew, (a, b) => inNew(a) && inNew(b)));
2731
+ const rest = build(sub3(inOld), id, pos, rot, carryFor(inOld, (a, b) => !(inNew(a) && inNew(b))));
2611
2732
  objects.set(id, rest);
2612
2733
  objects.set(nid, fresh);
2613
2734
  return nid;
@@ -2705,34 +2826,37 @@ var GameTestRoom = (() => {
2705
2826
  }
2706
2827
  return getObj(objectId).joints.filter((j) => !j.st.open && j.spec.tools.includes(tool)).map((j) => j.st);
2707
2828
  },
2708
- nearestJoint(objectId, point, maxDistance = 0.3) {
2829
+ nearestJoint(objectId, point, maxDistance = 0.3, tool) {
2709
2830
  const im = impls.get(objectId);
2831
+ const cands = [];
2710
2832
  if (im) {
2711
- let best2 = null;
2712
- let bd2 = maxDistance;
2833
+ const spec = im.impl.describe();
2713
2834
  for (const js of im.impl.state().joints) {
2714
2835
  if (js.open) continue;
2715
2836
  const pos = implJointPos(im, js.a, js.b);
2716
- const d = pos ? vec.distance(pos, point) : Infinity;
2717
- if (d <= bd2) {
2718
- bd2 = d;
2719
- best2 = js;
2720
- }
2837
+ const j = (spec.joints ?? []).find((x) => (x.id ?? `${x.a}~${x.b}`) === js.id);
2838
+ cands.push({ st: js, d: pos ? vec.distance(pos, point) : Infinity, ok: !!j && (j.tools ?? JOINT_DEFAULTS[j.kind].tools).includes(tool) });
2721
2839
  }
2722
- return best2;
2723
- }
2724
- const o = getObj(objectId);
2725
- let best = null;
2726
- let bd = maxDistance;
2727
- for (const j of o.joints) {
2728
- if (j.st.open) continue;
2729
- const d = vec.distance(jointWorld(o, j), point);
2730
- if (d <= bd) {
2731
- bd = d;
2732
- best = j.st;
2840
+ } else {
2841
+ const o = getObj(objectId);
2842
+ for (const j of o.joints) {
2843
+ if (j.st.open) continue;
2844
+ cands.push({ st: j.st, d: vec.distance(jointWorld(o, j), point), ok: tool ? j.spec.tools.includes(tool) : true });
2733
2845
  }
2734
2846
  }
2735
- return best;
2847
+ const pick = (only) => {
2848
+ let best = null;
2849
+ let bd = maxDistance;
2850
+ for (const c of cands) {
2851
+ if (only && !c.ok) continue;
2852
+ if (c.d <= bd) {
2853
+ bd = c.d;
2854
+ best = c.st;
2855
+ }
2856
+ }
2857
+ return best;
2858
+ };
2859
+ return (tool ? pick(true) : null) ?? pick(false);
2736
2860
  },
2737
2861
  extinguish(objectId, partId, coolC = 200) {
2738
2862
  const im = impls.get(objectId);
@@ -2775,6 +2899,10 @@ var GameTestRoom = (() => {
2775
2899
  }).events;
2776
2900
  },
2777
2901
  state: (id) => objects.get(id)?.st ?? impls.get(id)?.impl.state() ?? null,
2902
+ snapshot: (id) => {
2903
+ const st = objects.get(id)?.st ?? impls.get(id)?.impl.state();
2904
+ return st ? clone(st) : null;
2905
+ },
2778
2906
  part: (id, partId) => objects.get(id)?.byId.get(partId)?.st ?? impls.get(id)?.impl.state().parts.find((p) => p.id === partId) ?? null,
2779
2907
  reset(id) {
2780
2908
  const im = impls.get(id);
@@ -3786,6 +3914,8 @@ void main() {
3786
3914
  var SLEEP_AFTER = 0.5;
3787
3915
  var HARD_LANDING = 3;
3788
3916
  var SLOP = 2e-3;
3917
+ var NARROW = 0.3;
3918
+ var TOPPLE = 3;
3789
3919
  var PieceSystem = class {
3790
3920
  constructor(THREE, o) {
3791
3921
  __publicField(this, "THREE", THREE);
@@ -3917,6 +4047,7 @@ void main() {
3917
4047
  let landPoint = [pos[0], pos[1], pos[2]];
3918
4048
  let contactedLast = false;
3919
4049
  let touched = false;
4050
+ let unstable = false;
3920
4051
  for (let s = 0; s < steps; s++) {
3921
4052
  v[1] -= this.o.gravity * h;
3922
4053
  pos[0] += v[0] * h;
@@ -4005,6 +4136,25 @@ void main() {
4005
4136
  }
4006
4137
  }
4007
4138
  }
4139
+ let x0 = Infinity, x1 = -Infinity, z0 = Infinity, z1 = -Infinity, low = 0;
4140
+ for (const r of rs) {
4141
+ x0 = Math.min(x0, r[0]);
4142
+ x1 = Math.max(x1, r[0]);
4143
+ z0 = Math.min(z0, r[2]);
4144
+ z1 = Math.max(z1, r[2]);
4145
+ low = Math.min(low, r[1]);
4146
+ }
4147
+ const height = -low;
4148
+ const wx = x1 - x0;
4149
+ const wz = z1 - z0;
4150
+ unstable = Math.min(wx, wz) < NARROW * height || x0 > 0 || x1 < 0 || z0 > 0 || z1 < 0;
4151
+ if (unstable) {
4152
+ const dirX = x0 + x1 > 0 ? -1 : 1;
4153
+ const dirZ = z0 + z1 > 0 ? -1 : 1;
4154
+ const k = TOPPLE * Math.min(height, 1) * h;
4155
+ if (wx <= wz) w[2] -= dirX * k;
4156
+ else w[0] += dirZ * k;
4157
+ }
4008
4158
  const rd = Math.exp((rs.length >= 3 ? -3 : -1.5) * h);
4009
4159
  w[0] *= rd;
4010
4160
  w[1] *= rd;
@@ -4022,7 +4172,7 @@ void main() {
4022
4172
  b.lastHit = this.time;
4023
4173
  this.o.onHardLanding(b, landPoint, landing);
4024
4174
  }
4025
- const still = touched && Math.hypot(v[0], v[1], v[2]) < 0.12 && Math.hypot(w[0], w[1], w[2]) < 0.45;
4175
+ const still = touched && !unstable && Math.hypot(v[0], v[1], v[2]) < 0.12 && Math.hypot(w[0], w[1], w[2]) < 0.45;
4026
4176
  if (still) {
4027
4177
  b.still += dt;
4028
4178
  if (b.still >= SLEEP_AFTER) {
@@ -4081,6 +4231,163 @@ void main() {
4081
4231
  }
4082
4232
  }
4083
4233
  };
4234
+ function partHintOf(value) {
4235
+ if (typeof value === "string") return value ? { id: value } : null;
4236
+ if (!value || typeof value !== "object") return null;
4237
+ const v = value;
4238
+ if (typeof v.id !== "string" || !v.id) return null;
4239
+ const str = (x) => typeof x === "string" && x ? x : void 0;
4240
+ return { id: v.id, material: str(v.material), parent: str(v.parent), kind: str(v.kind) };
4241
+ }
4242
+ var METAL = /steel|iron|metal|alumin|copper|brass|bronze|tin|titan|chrome|zinc|lead/i;
4243
+ function sanitizeId(name) {
4244
+ let id = name.trim().replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
4245
+ if (id && /^[0-9]/.test(id)) id = `p_${id}`;
4246
+ return id;
4247
+ }
4248
+ function isMesh(o) {
4249
+ return o.isMesh === true;
4250
+ }
4251
+ function sizeOf(box2) {
4252
+ return [box2.max.x - box2.min.x, box2.max.y - box2.min.y, box2.max.z - box2.min.z];
4253
+ }
4254
+ function specFromObject3D(THREE, root, options = {}) {
4255
+ const fallback = options.fallback ?? "plastic";
4256
+ const minSize = options.minSize ?? 0.015;
4257
+ root.updateMatrixWorld(true);
4258
+ const inv = root.matrixWorld.clone().invert();
4259
+ const entries = [];
4260
+ const byKey = /* @__PURE__ */ new Map();
4261
+ const visit = (o) => {
4262
+ if (!o.visible || o.userData?.destructionIgnore) return;
4263
+ if (isMesh(o) && o.geometry) {
4264
+ const geom = o.geometry;
4265
+ if (!geom.boundingBox) geom.computeBoundingBox();
4266
+ if (geom.boundingBox && !geom.boundingBox.isEmpty()) {
4267
+ const box2 = geom.boundingBox.clone().applyMatrix4(new THREE.Matrix4().multiplyMatrices(inv, o.matrixWorld));
4268
+ const mats = Array.isArray(o.material) ? o.material : [o.material];
4269
+ const matName = mats[0]?.name ?? "";
4270
+ const ud = o.userData ?? {};
4271
+ const hint = partHintOf(ud.destructionPart);
4272
+ const material = options.materialOf?.(o) ?? (typeof ud.destructionMaterial === "string" ? ud.destructionMaterial : void 0) ?? hint?.material ?? guessMaterial(`${o.name} ${matName} ${typeof ud.material === "string" ? ud.material : ""}`) ?? fallback;
4273
+ const explicit = hint?.id ?? null;
4274
+ const hollow = ud.hollow === true || /hollow/i.test(o.name);
4275
+ const fill = options.fillOf?.(o, material);
4276
+ const tags = Array.isArray(ud.destructionTags) ? ud.destructionTags : void 0;
4277
+ const existing = explicit ? byKey.get(explicit) : void 0;
4278
+ if (existing) {
4279
+ const before = sizeOf(existing.box);
4280
+ const bv = before[0] * before[1] * before[2];
4281
+ const ns = sizeOf(box2);
4282
+ existing.box.union(box2);
4283
+ existing.meshes.push(o);
4284
+ if (ns[0] * ns[1] * ns[2] > bv) existing.material = material;
4285
+ existing.hollow = existing.hollow || hollow;
4286
+ if (fill !== void 0) existing.fill = fill;
4287
+ } else {
4288
+ const e = { key: explicit ?? `m${entries.length}`, meshes: [o], box: box2, material, explicitId: explicit, fill, hollow, tags, fixedId: !!explicit, name: o.name, parent: hint?.parent, jointKind: hint?.kind };
4289
+ entries.push(e);
4290
+ if (explicit) byKey.set(explicit, e);
4291
+ }
4292
+ }
4293
+ }
4294
+ for (const c of o.children) visit(c);
4295
+ };
4296
+ visit(root);
4297
+ const eps = 5e-3;
4298
+ const overlapVolume = (a, b) => {
4299
+ const x = Math.min(a.max.x, b.max.x) - Math.max(a.min.x, b.min.x) + eps;
4300
+ const y = Math.min(a.max.y, b.max.y) - Math.max(a.min.y, b.min.y) + eps;
4301
+ const z = Math.min(a.max.z, b.max.z) - Math.max(a.min.z, b.min.z) + eps;
4302
+ return x > 0 && y > 0 && z > 0 ? x * y * z : 0;
4303
+ };
4304
+ const largest = (e) => Math.max(...sizeOf(e.box));
4305
+ const kept = [];
4306
+ const tiny = [];
4307
+ for (const e of entries) (!e.fixedId && largest(e) < minSize ? tiny : kept).push(e);
4308
+ for (const t of tiny) {
4309
+ let best = null;
4310
+ let bestV = 0;
4311
+ for (const k of kept) {
4312
+ const v = overlapVolume(t.box, k.box);
4313
+ if (v > bestV) {
4314
+ bestV = v;
4315
+ best = k;
4316
+ }
4317
+ }
4318
+ if (!best) {
4319
+ let bd = 0.05;
4320
+ for (const k of kept) {
4321
+ const d = k.box.distanceToPoint(t.box.getCenter(new THREE.Vector3()));
4322
+ if (d < bd) {
4323
+ bd = d;
4324
+ best = k;
4325
+ }
4326
+ }
4327
+ }
4328
+ if (best) {
4329
+ best.meshes.push(...t.meshes);
4330
+ } else {
4331
+ kept.push(t);
4332
+ }
4333
+ }
4334
+ const used = /* @__PURE__ */ new Set();
4335
+ const parts = [];
4336
+ const meshesOf = {};
4337
+ let counter = 0;
4338
+ for (const e of kept) {
4339
+ counter++;
4340
+ let base = (e.explicitId ? sanitizeId(e.explicitId) : "") || sanitizeId(e.name) || `part_${counter}`;
4341
+ let id = base;
4342
+ for (let n = 2; used.has(id); n++) id = `${base}_${n}`;
4343
+ used.add(id);
4344
+ base = id;
4345
+ const size = sizeOf(e.box);
4346
+ const center = e.box.getCenter(new THREE.Vector3());
4347
+ const part = {
4348
+ id,
4349
+ material: e.material,
4350
+ shape: { kind: "box", size: [Math.max(size[0], 2e-3), Math.max(size[1], 2e-3), Math.max(size[2], 2e-3)] },
4351
+ position: [center.x, center.y, center.z]
4352
+ };
4353
+ const minSide = Math.min(...size);
4354
+ if (e.fill !== void 0) part.fill = e.fill;
4355
+ else if (e.hollow) part.fill = 0.05;
4356
+ if (minSide < 0.01 && METAL.test(e.material)) {
4357
+ part.fill = e.fill ?? 1;
4358
+ part.thickness = Math.max(minSide, 5e-4);
4359
+ }
4360
+ if (e.tags) part.tags = [...e.tags];
4361
+ const ao = options.anchorBelow ?? -1;
4362
+ if (ao >= 0 && e.box.min.y <= ao) part.anchored = true;
4363
+ parts.push(part);
4364
+ meshesOf[id] = e.meshes;
4365
+ }
4366
+ const spec = { parts };
4367
+ if (options.name !== void 0) spec.name = options.name;
4368
+ else if (root.name) spec.name = root.name;
4369
+ if (options.tags) spec.tags = [...options.tags];
4370
+ spec.joints = options.joints === false ? [] : inferJoints(parts);
4371
+ if (options.joints !== false) {
4372
+ const finalId = /* @__PURE__ */ new Map();
4373
+ kept.forEach((e, i) => finalId.set(e, parts[i].id));
4374
+ const idOf = /* @__PURE__ */ new Map();
4375
+ for (const e of kept) if (e.explicitId) idOf.set(e.explicitId, finalId.get(e));
4376
+ const joints = spec.joints;
4377
+ for (const e of kept) {
4378
+ if (!e.parent) continue;
4379
+ const id = finalId.get(e);
4380
+ const parent = idOf.get(e.parent) ?? (used.has(sanitizeId(e.parent)) ? sanitizeId(e.parent) : null);
4381
+ if (!parent || parent === id) continue;
4382
+ const at = joints.findIndex((j) => j.a === parent && j.b === id || j.a === id && j.b === parent);
4383
+ const kind = e.jointKind ?? (at >= 0 ? joints[at].kind : "resting");
4384
+ const joint2 = { id: `${parent}~${id}`, a: parent, b: id, kind };
4385
+ if (at >= 0) joints[at] = joint2;
4386
+ else joints.push(joint2);
4387
+ }
4388
+ }
4389
+ return { spec, parts: meshesOf };
4390
+ }
4084
4391
  var STOPS = [
4085
4392
  [450, 0],
4086
4393
  [500, 2753536],
@@ -4166,6 +4473,7 @@ void main() {
4166
4473
  __publicField(this, "m4");
4167
4474
  __publicField(this, "box");
4168
4475
  __publicField(this, "decalCount", 0);
4476
+ __publicField(this, "tints", /* @__PURE__ */ new WeakMap());
4169
4477
  this.kit = new DecalKit(THREE);
4170
4478
  this.a = tmpV(THREE);
4171
4479
  this.b = tmpV(THREE);
@@ -4196,10 +4504,33 @@ void main() {
4196
4504
  vertexCount(mv) {
4197
4505
  return mv.mesh.geometry.getAttribute(POS_ATTR)?.count ?? 0;
4198
4506
  }
4199
- /** Colour of the original material (before any char), `0xRRGGBB`, or -1. */
4507
+ /**
4508
+ * Average vertex colour of a mesh whose material uses `vertexColors` (computed once per mesh, from its original geometry), else null. Fragments
4509
+ * and debris of such a mesh multiply it by `material.color`; without it they come out white.
4510
+ */
4511
+ vertexTint(mv) {
4512
+ if (this.tints.has(mv.mesh)) return this.tints.get(mv.mesh) ?? null;
4513
+ let tint = null;
4514
+ const used = materialsOf(mv.origMaterial).some((m) => m.vertexColors === true);
4515
+ const attr = used ? mv.origGeometry.getAttribute("color") : void 0;
4516
+ if (attr && attr.count > 0 && attr.itemSize >= 3) {
4517
+ let r = 0, g = 0, b = 0;
4518
+ for (let i = 0; i < attr.count; i++) {
4519
+ r += attr.getX(i);
4520
+ g += attr.getY(i);
4521
+ b += attr.getZ(i);
4522
+ }
4523
+ tint = new this.THREE.Color(r / attr.count, g / attr.count, b / attr.count);
4524
+ }
4525
+ this.tints.set(mv.mesh, tint);
4526
+ return tint;
4527
+ }
4528
+ /** Colour of the original material (before any char) times the vertex tint, `0xRRGGBB`, or -1. */
4200
4529
  baseColorHex(mv) {
4201
4530
  const c = mv.baseColors?.[0] ?? colorOf(materialsOf(mv.origMaterial)[0]);
4202
- return c ? c.getHex() : -1;
4531
+ if (!c) return -1;
4532
+ const tint = this.vertexTint(mv);
4533
+ return tint ? c.clone().multiply(tint).getHex() : c.getHex();
4203
4534
  }
4204
4535
  /** Is the original material transparent? */
4205
4536
  isTransparent(mv) {
@@ -4668,8 +4999,8 @@ void main() {
4668
4999
  } else {
4669
5000
  const leftovers = [];
4670
5001
  for (const m of meshesIn(root)) {
4671
- const ud = m.userData?.destructionPart;
4672
- if (typeof ud === "string" && partIds.has(ud)) add2(ud, m);
5002
+ const ud = partHintOf(m.userData?.destructionPart)?.id;
5003
+ if (ud !== void 0 && partIds.has(ud)) add2(ud, m);
4673
5004
  else if (partIds.has(m.name)) add2(m.name, m);
4674
5005
  else leftovers.push(m);
4675
5006
  }
@@ -4979,7 +5310,10 @@ void main() {
4979
5310
  if (!mv) return null;
4980
5311
  const mat = materialsOf2(mv.mesh.material)[0];
4981
5312
  const c = mat?.color;
4982
- if (c && c.isColor) return c.getHex();
5313
+ if (c && c.isColor) {
5314
+ const tint = this.visuals.vertexTint(mv);
5315
+ return tint ? c.clone().multiply(tint).getHex() : c.getHex();
5316
+ }
4983
5317
  return def ? fallbackColor(def.id, def) : null;
4984
5318
  }
4985
5319
  debrisOptions(kind, objectId, partId) {
@@ -5423,136 +5757,6 @@ void main() {
5423
5757
  this.disposed = true;
5424
5758
  }
5425
5759
  };
5426
- var METAL = /steel|iron|metal|alumin|copper|brass|bronze|tin|titan|chrome|zinc|lead/i;
5427
- function sanitizeId(name) {
5428
- let id = name.trim().replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
5429
- if (id && /^[0-9]/.test(id)) id = `p_${id}`;
5430
- return id;
5431
- }
5432
- function isMesh(o) {
5433
- return o.isMesh === true;
5434
- }
5435
- function sizeOf(box2) {
5436
- return [box2.max.x - box2.min.x, box2.max.y - box2.min.y, box2.max.z - box2.min.z];
5437
- }
5438
- function specFromObject3D(THREE, root, options = {}) {
5439
- const fallback = options.fallback ?? "plastic";
5440
- const minSize = options.minSize ?? 0.015;
5441
- root.updateMatrixWorld(true);
5442
- const inv = root.matrixWorld.clone().invert();
5443
- const entries = [];
5444
- const byKey = /* @__PURE__ */ new Map();
5445
- const visit = (o) => {
5446
- if (!o.visible || o.userData?.destructionIgnore) return;
5447
- if (isMesh(o) && o.geometry) {
5448
- const geom = o.geometry;
5449
- if (!geom.boundingBox) geom.computeBoundingBox();
5450
- if (geom.boundingBox && !geom.boundingBox.isEmpty()) {
5451
- const box2 = geom.boundingBox.clone().applyMatrix4(new THREE.Matrix4().multiplyMatrices(inv, o.matrixWorld));
5452
- const mats = Array.isArray(o.material) ? o.material : [o.material];
5453
- const matName = mats[0]?.name ?? "";
5454
- const ud = o.userData ?? {};
5455
- const material = options.materialOf?.(o) ?? (typeof ud.destructionMaterial === "string" ? ud.destructionMaterial : void 0) ?? guessMaterial(`${o.name} ${matName} ${typeof ud.material === "string" ? ud.material : ""}`) ?? fallback;
5456
- const explicit = typeof ud.destructionPart === "string" && ud.destructionPart ? ud.destructionPart : null;
5457
- const hollow = ud.hollow === true || /hollow/i.test(o.name);
5458
- const fill = options.fillOf?.(o, material);
5459
- const tags = Array.isArray(ud.destructionTags) ? ud.destructionTags : void 0;
5460
- const existing = explicit ? byKey.get(explicit) : void 0;
5461
- if (existing) {
5462
- const before = sizeOf(existing.box);
5463
- const bv = before[0] * before[1] * before[2];
5464
- const ns = sizeOf(box2);
5465
- existing.box.union(box2);
5466
- existing.meshes.push(o);
5467
- if (ns[0] * ns[1] * ns[2] > bv) existing.material = material;
5468
- existing.hollow = existing.hollow || hollow;
5469
- if (fill !== void 0) existing.fill = fill;
5470
- } else {
5471
- const e = { key: explicit ?? `m${entries.length}`, meshes: [o], box: box2, material, explicitId: explicit, fill, hollow, tags, fixedId: !!explicit, name: o.name };
5472
- entries.push(e);
5473
- if (explicit) byKey.set(explicit, e);
5474
- }
5475
- }
5476
- }
5477
- for (const c of o.children) visit(c);
5478
- };
5479
- visit(root);
5480
- const eps = 5e-3;
5481
- const overlapVolume = (a, b) => {
5482
- const x = Math.min(a.max.x, b.max.x) - Math.max(a.min.x, b.min.x) + eps;
5483
- const y = Math.min(a.max.y, b.max.y) - Math.max(a.min.y, b.min.y) + eps;
5484
- const z = Math.min(a.max.z, b.max.z) - Math.max(a.min.z, b.min.z) + eps;
5485
- return x > 0 && y > 0 && z > 0 ? x * y * z : 0;
5486
- };
5487
- const largest = (e) => Math.max(...sizeOf(e.box));
5488
- const kept = [];
5489
- const tiny = [];
5490
- for (const e of entries) (!e.fixedId && largest(e) < minSize ? tiny : kept).push(e);
5491
- for (const t of tiny) {
5492
- let best = null;
5493
- let bestV = 0;
5494
- for (const k of kept) {
5495
- const v = overlapVolume(t.box, k.box);
5496
- if (v > bestV) {
5497
- bestV = v;
5498
- best = k;
5499
- }
5500
- }
5501
- if (!best) {
5502
- let bd = 0.05;
5503
- for (const k of kept) {
5504
- const d = k.box.distanceToPoint(t.box.getCenter(new THREE.Vector3()));
5505
- if (d < bd) {
5506
- bd = d;
5507
- best = k;
5508
- }
5509
- }
5510
- }
5511
- if (best) {
5512
- best.meshes.push(...t.meshes);
5513
- } else {
5514
- kept.push(t);
5515
- }
5516
- }
5517
- const used = /* @__PURE__ */ new Set();
5518
- const parts = [];
5519
- const meshesOf = {};
5520
- let counter = 0;
5521
- for (const e of kept) {
5522
- counter++;
5523
- let base = (e.explicitId ? sanitizeId(e.explicitId) : "") || sanitizeId(e.name) || `part_${counter}`;
5524
- let id = base;
5525
- for (let n = 2; used.has(id); n++) id = `${base}_${n}`;
5526
- used.add(id);
5527
- base = id;
5528
- const size = sizeOf(e.box);
5529
- const center = e.box.getCenter(new THREE.Vector3());
5530
- const part = {
5531
- id,
5532
- material: e.material,
5533
- shape: { kind: "box", size: [Math.max(size[0], 2e-3), Math.max(size[1], 2e-3), Math.max(size[2], 2e-3)] },
5534
- position: [center.x, center.y, center.z]
5535
- };
5536
- const minSide = Math.min(...size);
5537
- if (e.fill !== void 0) part.fill = e.fill;
5538
- else if (e.hollow) part.fill = 0.05;
5539
- if (minSide < 0.01 && METAL.test(e.material)) {
5540
- part.fill = e.fill ?? 1;
5541
- part.thickness = Math.max(minSide, 5e-4);
5542
- }
5543
- if (e.tags) part.tags = [...e.tags];
5544
- const ao = options.anchorBelow ?? -1;
5545
- if (ao >= 0 && e.box.min.y <= ao) part.anchored = true;
5546
- parts.push(part);
5547
- meshesOf[id] = e.meshes;
5548
- }
5549
- const spec = { parts };
5550
- if (options.name !== void 0) spec.name = options.name;
5551
- else if (root.name) spec.name = root.name;
5552
- if (options.tags) spec.tags = [...options.tags];
5553
- spec.joints = options.joints === false ? [] : inferJoints(parts);
5554
- return { spec, parts: meshesOf };
5555
- }
5556
5760
 
5557
5761
  // ../game-weapons/dist/index.js
5558
5762
  var __defProp3 = Object.defineProperty;
@@ -5739,6 +5943,36 @@ void main() {
5739
5943
  "grab"
5740
5944
  ]
5741
5945
  },
5946
+ {
5947
+ id: "kick",
5948
+ name: "Kick",
5949
+ category: "melee_blunt",
5950
+ action: {
5951
+ kind: "melee",
5952
+ damage: "blunt",
5953
+ energy: 250,
5954
+ radius: 0.08,
5955
+ reach: 1.1,
5956
+ rpm: 60,
5957
+ noise: 8
5958
+ },
5959
+ mass: 0
5960
+ },
5961
+ {
5962
+ id: "shove",
5963
+ name: "Shove",
5964
+ category: "melee_blunt",
5965
+ action: {
5966
+ kind: "melee",
5967
+ damage: "blunt",
5968
+ energy: 80,
5969
+ radius: 0.1,
5970
+ reach: 1.5,
5971
+ rpm: 90,
5972
+ noise: 5
5973
+ },
5974
+ mass: 0
5975
+ },
5742
5976
  {
5743
5977
  id: "pistol_9mm",
5744
5978
  name: "9 mm service pistol",
@@ -6616,6 +6850,8 @@ void main() {
6616
6850
  en: {
6617
6851
  weapons: {
6618
6852
  hand: "Hand",
6853
+ kick: "Kick",
6854
+ shove: "Shove",
6619
6855
  pistol_9mm: "9 mm service pistol",
6620
6856
  pistol_45: ".45 ACP pistol",
6621
6857
  pistol_22: ".22 LR pistol",
@@ -6662,6 +6898,8 @@ void main() {
6662
6898
  "pt-BR": {
6663
6899
  weapons: {
6664
6900
  hand: "m\xE3o",
6901
+ kick: "chute",
6902
+ shove: "empurr\xE3o",
6665
6903
  pistol_9mm: "pistola 9 mm",
6666
6904
  pistol_45: "pistola .45",
6667
6905
  pistol_22: "pistola .22",
@@ -7058,6 +7296,8 @@ void main() {
7058
7296
  var WEAPONS = weapons_default;
7059
7297
  var DEFAULT_LOADOUT = [
7060
7298
  "hand",
7299
+ "kick",
7300
+ "shove",
7061
7301
  "pistol_9mm",
7062
7302
  "revolver_357",
7063
7303
  "shotgun_pump_12",
@@ -7065,6 +7305,8 @@ void main() {
7065
7305
  "rifle_bolt_308",
7066
7306
  "hammer",
7067
7307
  "axe",
7308
+ "machete",
7309
+ "utility_knife",
7068
7310
  "crowbar",
7069
7311
  "lighter",
7070
7312
  "flamethrower",
@@ -10529,6 +10771,9 @@ void main() {
10529
10771
  "mod.destructible_ok": "{name}: destructible contract ok ({parts} parts, hit and state well-formed)",
10530
10772
  "mod.destructible_fail": "{name}: destructible contract broken: {problems}",
10531
10773
  "mod.animation_ok": '{name}: animation "{clip}" ok ({seconds} s)',
10774
+ "mod.spec_ok": "{name}: spec ok ({parts} parts)",
10775
+ "mod.spec_fail": "{name}: spec broken: {problems}",
10776
+ "mod.spec_warn": "{name}: spec warning: {warning}",
10532
10777
  "mod.animation_fail": '{name}: animation "{clip}" broken: {problems}',
10533
10778
  "ui.animations": "Animations",
10534
10779
  "ui.animations_aim": "Aim at an item or hold it to see its animations.",
@@ -10589,6 +10834,7 @@ void main() {
10589
10834
  "ui.filter.event": "events",
10590
10835
  "ui.filter.check": "checks",
10591
10836
  "ui.autoscroll": "autoscroll",
10837
+ "log.export_cancelled": "export cancelled",
10592
10838
  "ui.copy_json": "Copy JSON",
10593
10839
  "ui.download_ndjson": "Download NDJSON",
10594
10840
  "ui.download_txt": "Download TXT",
@@ -10645,6 +10891,8 @@ void main() {
10645
10891
  "sc.shotgun_splinters_crate.check": "12 ga on the crate: {shots} shot(s), {splinters} board(s) splintered, {holes} holes (expected: at least one splintered)",
10646
10892
  "sc.kick_chair_leg": "A kick takes a chair leg off",
10647
10893
  "sc.kick_chair_leg.check": "Chair: {kicks} kick(s), {groups} group(s), loose pieces {before} \u2192 {after}, {leg} (expected: a loose piece appears)",
10894
+ "sc.kick_detaches_leg_via_tool": "The kick tool (hotbar) takes a chair leg off",
10895
+ "sc.kick_detaches_leg_via_tool.check": "Chair, kick tool: {kicks} kick(s), loose pieces {before} \u2192 {after}, {leg} (expected: a loose piece appears)",
10648
10896
  "sc.screwdriver_shelf_board": "Screwdriver takes a screwed shelf board off in about 2.5 s",
10649
10897
  "sc.screwdriver_shelf_board.check": "Screwdriver on the shelf board: took {secs} s, expected about {want} s, the shelf is now in {pieces}",
10650
10898
  "sc.wrong_tool_refused": "The wrong tool is refused",
@@ -10812,6 +11060,9 @@ void main() {
10812
11060
  "mod.destructible_ok": "{name}: contrato de destrui\xE7\xE3o ok ({parts} pe\xE7as, golpe e estado bem formados)",
10813
11061
  "mod.destructible_fail": "{name}: contrato de destrui\xE7\xE3o quebrado: {problems}",
10814
11062
  "mod.animation_ok": '{name}: anima\xE7\xE3o "{clip}" ok ({seconds} s)',
11063
+ "mod.spec_ok": "{name}: spec ok ({parts} pe\xE7as)",
11064
+ "mod.spec_fail": "{name}: spec quebrada: {problems}",
11065
+ "mod.spec_warn": "{name}: aviso na spec: {warning}",
10815
11066
  "mod.animation_fail": '{name}: anima\xE7\xE3o "{clip}" quebrada: {problems}',
10816
11067
  "ui.animations": "Anima\xE7\xF5es",
10817
11068
  "ui.animations_aim": "Mire num item ou segure-o para ver as anima\xE7\xF5es.",
@@ -10872,6 +11123,7 @@ void main() {
10872
11123
  "ui.filter.event": "eventos",
10873
11124
  "ui.filter.check": "testes",
10874
11125
  "ui.autoscroll": "rolar sozinho",
11126
+ "log.export_cancelled": "exporta\xE7\xE3o cancelada",
10875
11127
  "ui.copy_json": "Copiar JSON",
10876
11128
  "ui.download_ndjson": "Baixar NDJSON",
10877
11129
  "ui.download_txt": "Baixar TXT",
@@ -10928,6 +11180,8 @@ void main() {
10928
11180
  "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)",
10929
11181
  "sc.kick_chair_leg": "Um chute solta a perna da cadeira",
10930
11182
  "sc.kick_chair_leg.check": "Cadeira: {kicks} chute(s), {groups} grupo(s), pe\xE7as soltas {before} \u2192 {after}, {leg} (esperado: aparece uma pe\xE7a solta)",
11183
+ "sc.kick_detaches_leg_via_tool": "A ferramenta chute (barra de atalhos) solta a perna da cadeira",
11184
+ "sc.kick_detaches_leg_via_tool.check": "Cadeira, ferramenta chute: {kicks} chute(s), pe\xE7as soltas {before} \u2192 {after}, {leg} (esperado: aparece uma pe\xE7a solta)",
10931
11185
  "sc.screwdriver_shelf_board": "Chave de fenda solta a prateleira parafusada em cerca de 2,5 s",
10932
11186
  "sc.screwdriver_shelf_board.check": "Chave de fenda na prateleira: levou {secs} s, esperado cerca de {want} s, a estante agora est\xE1 em {pieces}",
10933
11187
  "sc.wrong_tool_refused": "A ferramenta errada \xE9 recusada",
@@ -11429,7 +11683,16 @@ void main() {
11429
11683
  const s = t - m * 60;
11430
11684
  return `${pad(m)}:${s.toFixed(2).padStart(5, "0")}`;
11431
11685
  };
11432
- function createLog() {
11686
+ function createLog(options = {}) {
11687
+ const host = globalThis.claude;
11688
+ let downloadsP = null;
11689
+ if (host && typeof host.use === "function") {
11690
+ try {
11691
+ downloadsP = Promise.resolve(host.use("downloads")).then((d) => d ?? null, () => null);
11692
+ } catch {
11693
+ downloadsP = null;
11694
+ }
11695
+ }
11433
11696
  let list = [];
11434
11697
  let seq = 0;
11435
11698
  let clock = () => 0;
@@ -11495,9 +11758,20 @@ void main() {
11495
11758
  toJSON: () => JSON.stringify(read(), null, 2),
11496
11759
  toNDJSON: () => read().map((e) => JSON.stringify(e)).join("\n") + (list.length ? "\n" : ""),
11497
11760
  toText: () => read().map((e) => `[${formatTime(e.t)}] ${e.channel.padEnd(6)} ${e.text}`).join("\n"),
11498
- download(format = "ndjson") {
11499
- if (typeof document === "undefined" || typeof Blob === "undefined" || typeof URL === "undefined" || !URL.createObjectURL) return;
11761
+ async download(format = "ndjson") {
11500
11762
  const body = format === "json" ? log.toJSON() : format === "txt" ? log.toText() : log.toNDJSON();
11763
+ const downloads = downloadsP ? await downloadsP : null;
11764
+ if (downloads) {
11765
+ const filename = format === "ndjson" ? "test-room-log.ndjson.txt" : `test-room-log.${format}`;
11766
+ try {
11767
+ await downloads.save({ filename, data: body });
11768
+ } catch (err) {
11769
+ const code = err?.code;
11770
+ log.add({ channel: "action", type: "export_cancelled", text: options.exportCancelled?.() ?? "export cancelled", data: { format, ...typeof code === "string" ? { code } : {} } });
11771
+ }
11772
+ return;
11773
+ }
11774
+ if (typeof document === "undefined" || typeof Blob === "undefined" || typeof URL === "undefined" || !URL.createObjectURL) return;
11501
11775
  const mime = format === "json" ? "application/json" : "text/plain";
11502
11776
  const url = URL.createObjectURL(new Blob([body], { type: mime }));
11503
11777
  const a = document.createElement("a");
@@ -12242,6 +12516,10 @@ void main() {
12242
12516
  yaw += yawDeg * Math.PI / 180;
12243
12517
  apply();
12244
12518
  },
12519
+ moveBy(dx, dz) {
12520
+ move(dx, dz);
12521
+ apply();
12522
+ },
12245
12523
  walk(forward, right) {
12246
12524
  move(-Math.sin(yaw) * forward + Math.cos(yaw) * right, -Math.cos(yaw) * forward - Math.sin(yaw) * right);
12247
12525
  apply();
@@ -12373,6 +12651,23 @@ void main() {
12373
12651
  { kicks, before, after }
12374
12652
  );
12375
12653
  }),
12654
+ sc("kick_detaches_leg_via_tool", ({ room, step, expect }) => {
12655
+ const id = at(room, "chair", 0, -4);
12656
+ const before = room.renderer.pieces().length;
12657
+ room.setTool("kick");
12658
+ let kicks = 0;
12659
+ for (; kicks < 8 && room.renderer.pieces().length <= before && part(room, id, "leg_1"); kicks++) {
12660
+ const st = room.world.state(id);
12661
+ const leg = toWorld(st.position, st.rotation, [-0.18, 0.2, -0.18]);
12662
+ const side = rotateVec(st.rotation, [-0.8, 0, 0]);
12663
+ look(room, [leg[0] + side[0], leg[1] + side[1], leg[2] + side[2]], leg);
12664
+ room.use();
12665
+ step(1.05);
12666
+ }
12667
+ step(0.5);
12668
+ const after = room.renderer.pieces().length;
12669
+ expect(after > before, t("sc.kick_detaches_leg_via_tool.check", { kicks, before, after, leg: part(room, id, "leg_1") ? t("leg.still") : t("leg.gone") }), { kicks, before, after });
12670
+ }),
12376
12671
  sc("screwdriver_shelf_board", ({ room, step, expect }) => {
12377
12672
  const id = at(room, "shelf", 0, -4);
12378
12673
  room.setTool("screwdriver");
@@ -12703,16 +12998,25 @@ void main() {
12703
12998
  };
12704
12999
  logBtn(t("ui.copy_json"), () => {
12705
13000
  const text = o.log.toJSON();
12706
- try {
12707
- void navigator.clipboard.writeText(text);
12708
- } catch {
13001
+ const select = () => {
12709
13002
  const ta = mk("textarea");
12710
13003
  ta.value = text;
13004
+ ta.setAttribute("readonly", "");
13005
+ ta.style.cssText = "position:fixed;left:8px;bottom:8px;width:min(480px,80vw);height:120px;z-index:99999";
12711
13006
  document.body.appendChild(ta);
13007
+ ta.focus();
12712
13008
  ta.select();
12713
- document.execCommand?.("copy");
12714
- ta.remove();
12715
- }
13009
+ let copied = false;
13010
+ try {
13011
+ copied = !!document.execCommand?.("copy");
13012
+ } catch {
13013
+ copied = false;
13014
+ }
13015
+ if (copied) ta.remove();
13016
+ else ta.onblur = () => ta.remove();
13017
+ };
13018
+ if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) navigator.clipboard.writeText(text).catch(select);
13019
+ else select();
12716
13020
  });
12717
13021
  logBtn(t("ui.download_ndjson"), () => o.log.download("ndjson"));
12718
13022
  logBtn(t("ui.download_txt"), () => o.log.download("txt"));
@@ -13124,7 +13428,7 @@ void main() {
13124
13428
  const entries = /* @__PURE__ */ new Map();
13125
13429
  const splitOf = /* @__PURE__ */ new Map();
13126
13430
  let simTime = 0;
13127
- const log = createLog();
13431
+ const log = createLog({ exportCancelled: () => t("log.export_cancelled") });
13128
13432
  log.setClock(() => simTime);
13129
13433
  const renderer = createDestructionRenderer(THREE, {
13130
13434
  scene,
@@ -13498,6 +13802,7 @@ void main() {
13498
13802
  addArena();
13499
13803
  timeScale = 1;
13500
13804
  fastForward = false;
13805
+ walkPlan = null;
13501
13806
  };
13502
13807
  const aimO = [0, 0, 0];
13503
13808
  const aimD = [0, 0, -1];
@@ -13749,11 +14054,11 @@ void main() {
13749
14054
  1
13750
14055
  );
13751
14056
  };
13752
- const meleeUse = (u) => {
13753
- const w = toolName();
14057
+ const meleeUse = (u, id = toolId) => {
14058
+ const w = toolName(id);
13754
14059
  const r = rayWorld(u.origin, u.direction, u.reach);
13755
14060
  if (!r) {
13756
- action("melee", t("act.melee.miss", { weapon: w }), { data: { weapon: toolId } });
14061
+ action("melee", t("act.melee.miss", { weapon: w }), { data: { weapon: id } });
13757
14062
  return;
13758
14063
  }
13759
14064
  world.hitPart(r.object, r.part, { type: u.damage, point: r.point, direction: u.direction, energy: u.energy, radius: u.radius, source: "player" });
@@ -13762,7 +14067,7 @@ void main() {
13762
14067
  objectId: r.object,
13763
14068
  itemId: entryOf(r.object)?.so.itemId,
13764
14069
  part: r.part,
13765
- data: { weapon: toolId, damage: u.damage, energyJ: u.energy, distanceM: Math.round(r.distance * 100) / 100 }
14070
+ data: { weapon: id, damage: u.damage, energyJ: u.energy, distanceM: Math.round(r.distance * 100) / 100 }
13766
14071
  });
13767
14072
  };
13768
14073
  const toolWork = (u, dt) => {
@@ -13773,7 +14078,7 @@ void main() {
13773
14078
  log.coalesce(`use:${toolId}`, { channel: "action", type: "tool", text: t("act.tool.none", { weapon: w }), data: { weapon: toolId } }, 1);
13774
14079
  return;
13775
14080
  }
13776
- const j = world.nearestJoint(r.object, r.point, 0.45);
14081
+ const j = world.nearestJoint(r.object, r.point, 0.45, u.tool);
13777
14082
  if (!j) {
13778
14083
  progress = null;
13779
14084
  log.coalesce(`use:${toolId}`, { channel: "action", type: "tool", text: t("act.tool.nojoint", { weapon: w, where: hitWhere(r) }), objectId: r.object, part: r.part }, 1);
@@ -13952,6 +14257,13 @@ void main() {
13952
14257
  }
13953
14258
  }
13954
14259
  };
14260
+ const kick = () => {
14261
+ if (!arsenal.weapons.has("kick")) return;
14262
+ aim();
14263
+ const res = toolState("kick").trigger([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], 0, false, "player");
14264
+ if (res.status !== "ok") return;
14265
+ for (const u of res.uses) if (u.kind === "melee") meleeUse(u, "kick");
14266
+ };
13955
14267
  const setTool = (id) => {
13956
14268
  if (!arsenal.weapons.has(id)) throw new Error(`game-test-room: unknown weapon "${id}"`);
13957
14269
  tool.release();
@@ -14207,7 +14519,7 @@ void main() {
14207
14519
  break;
14208
14520
  case "KeyV":
14209
14521
  if (heldEntry?.uses[2]) useItem(heldEntry.uses[2]);
14210
- else toggleTap();
14522
+ else kick();
14211
14523
  break;
14212
14524
  case "KeyT":
14213
14525
  toggleTap();
@@ -14306,8 +14618,19 @@ void main() {
14306
14618
  if (part && part.liters !== void 0 && Math.abs(part.liters - c.liters) > 1e-4) world.setLiters(host.objectId, e.fluidPart, c.liters);
14307
14619
  }
14308
14620
  };
14621
+ let paused = false;
14622
+ let walkPlan = null;
14623
+ const stepWalk = (dt) => {
14624
+ if (!walkPlan) return;
14625
+ const f = walkPlan.remaining <= dt ? 1 : dt / walkPlan.remaining;
14626
+ player.moveBy((walkPlan.x - camera.position.x) * f, (walkPlan.z - camera.position.z) * f);
14627
+ walkPlan.remaining -= dt;
14628
+ if (walkPlan.remaining <= 1e-9) walkPlan = null;
14629
+ };
14309
14630
  const stepSim = (dt) => {
14631
+ if (paused) return;
14310
14632
  simTime += dt;
14633
+ stepWalk(dt);
14311
14634
  const reps = fastForward ? 10 : 1;
14312
14635
  updateAnimations(dt);
14313
14636
  updateItemUse(dt);
@@ -14336,13 +14659,13 @@ void main() {
14336
14659
  if (tiltKey || heldTilt > 0) {
14337
14660
  heldTilt = Math.max(0, Math.min(2.4, heldTilt + (tiltKey ? 2.2 : -3) * dt));
14338
14661
  }
14339
- if (useHeld) {
14662
+ if (useHeld && !paused) {
14340
14663
  if (tool.def.category === "hand") {
14341
14664
  if (useFirst) grab();
14342
14665
  } else useTool(dt, !useFirst);
14343
14666
  useFirst = false;
14344
14667
  }
14345
- followHeld(dt);
14668
+ if (!paused) followHeld(dt);
14346
14669
  stepSim(dt * timeScale);
14347
14670
  if (progress && simTime - progress.at > 0.25) progress = null;
14348
14671
  hudAcc += dt;
@@ -14403,6 +14726,16 @@ void main() {
14403
14726
  const pose = poseAt(pl.clip, id === "" ? null : id, pl.t);
14404
14727
  obj.position.set(rest.p[0] + pose.position[0], rest.p[1] + pose.position[1], rest.p[2] + pose.position[2]);
14405
14728
  obj.rotation.set(rest.r[0] + pose.rotation[0], rest.r[1] + pose.rotation[1], rest.r[2] + pose.rotation[2]);
14729
+ const ps = id === "" ? void 0 : specs.get(pl.entry.so.objectId)?.parts.find((p) => p.id === id);
14730
+ if (ps) {
14731
+ const r0 = ps.rotation ?? ZERO;
14732
+ world.setPartPose(
14733
+ pl.entry.so.objectId,
14734
+ id,
14735
+ [ps.position[0] + pose.position[0], ps.position[1] + pose.position[1], ps.position[2] + pose.position[2]],
14736
+ [r0[0] + pose.rotation[0], r0[1] + pose.rotation[1], r0[2] + pose.rotation[2]]
14737
+ );
14738
+ }
14406
14739
  }
14407
14740
  };
14408
14741
  const updateAnimations = (dt) => {
@@ -14490,8 +14823,14 @@ void main() {
14490
14823
  partIds = probe.describe().parts.map((p) => p.id);
14491
14824
  moduleCheck(r.ok, r.ok ? t("mod.destructible_ok", { name: item.name, parts: partIds.length }) : t("mod.destructible_fail", { name: item.name, problems: r.problems.join("; ") }), { ...base, check: "destructible", problems: r.problems }, item.id);
14492
14825
  for (const p of r.problems) fail(`${item.id}: ${p}`);
14493
- } else {
14494
- partIds = resolveSpec(item, root).spec.parts.map((p) => p.id);
14826
+ }
14827
+ if (!item.impl || item.spec) {
14828
+ const spec = resolveSpec(item, root).spec;
14829
+ if (!item.impl) partIds = spec.parts.map((p) => p.id);
14830
+ const r = checkSpec(spec, { materials: world.materials.keys() });
14831
+ moduleCheck(r.ok, r.ok ? t("mod.spec_ok", { name: item.name, parts: spec.parts.length }) : t("mod.spec_fail", { name: item.name, problems: r.problems.join("; ") }), { ...base, check: "spec", problems: r.problems, warnings: r.warnings }, item.id);
14832
+ for (const p of r.problems) fail(`${item.id}: ${p}`);
14833
+ for (const w of r.warnings) log.add({ channel: "check", type: "warn", text: `\u26A0 ${t("mod.spec_warn", { name: item.name, warning: w })}`, itemId: item.id, data: { ok: true, ...base, check: "spec", warning: w } });
14495
14834
  }
14496
14835
  } catch (err) {
14497
14836
  const msg = err instanceof Error ? err.message : String(err);
@@ -14530,6 +14869,8 @@ void main() {
14530
14869
  const all = list ?? allScenarios();
14531
14870
  const report = { passed: 0, failed: 0, results: [] };
14532
14871
  scenarioRunning = true;
14872
+ const wasPaused = paused;
14873
+ paused = false;
14533
14874
  try {
14534
14875
  for (const sc of all) {
14535
14876
  hardReset();
@@ -14561,6 +14902,7 @@ void main() {
14561
14902
  }
14562
14903
  } finally {
14563
14904
  scenarioRunning = false;
14905
+ paused = wasPaused;
14564
14906
  }
14565
14907
  return report;
14566
14908
  };
@@ -14623,6 +14965,19 @@ void main() {
14623
14965
  heldTilt = rad;
14624
14966
  },
14625
14967
  toggleTap,
14968
+ pause() {
14969
+ paused = true;
14970
+ },
14971
+ resume() {
14972
+ paused = false;
14973
+ },
14974
+ get paused() {
14975
+ return paused;
14976
+ },
14977
+ walk(to, seconds) {
14978
+ walkPlan = seconds > 0 ? { x: to[0], z: to[2], remaining: seconds } : null;
14979
+ if (!walkPlan) player.moveBy(to[0] - camera.position.x, to[2] - camera.position.z);
14980
+ },
14626
14981
  setSlowMotion(on) {
14627
14982
  timeScale = on ? 0.25 : 1;
14628
14983
  },