@adatechnology/game-test-room 0.1.0 → 0.1.1

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",
@@ -1567,6 +1644,15 @@ var GameTestRoom = (() => {
1567
1644
  var clamp2 = (v, a, b) => Math.max(a, Math.min(b, v));
1568
1645
  var isLost = (p) => p.st.status === "broken" || p.st.status === "melted" || p.st.status === "ashes";
1569
1646
  var clone = (v) => structuredClone(v);
1647
+ var updateBound = (o) => {
1648
+ if (!o.parts.length) return;
1649
+ const c = [0, 0, 0];
1650
+ for (const p of o.parts) for (let k = 0; k < 3; k++) c[k] += p.pos[k] / o.parts.length;
1651
+ let r = 0;
1652
+ for (const p of o.parts) r = Math.max(r, vec.distance(c, p.pos) + p.bound);
1653
+ o.bc = c;
1654
+ o.br = r;
1655
+ };
1570
1656
  var AXIS = { x: 0, y: 1, z: 2 };
1571
1657
  var createDestruction = (options = {}) => {
1572
1658
  const materials = /* @__PURE__ */ new Map();
@@ -1702,6 +1788,7 @@ var GameTestRoom = (() => {
1702
1788
  mat,
1703
1789
  st: pst,
1704
1790
  q: quatFromEuler(...ps.rotation ?? [0, 0, 0]),
1791
+ pos: [ps.position[0], ps.position[1], ps.position[2]],
1705
1792
  mass0,
1706
1793
  fuel0,
1707
1794
  area: shapeArea(ps.shape),
@@ -1740,14 +1827,8 @@ var GameTestRoom = (() => {
1740
1827
  o.byPart.get(r.b).push(j);
1741
1828
  o.st.joints.push(jst);
1742
1829
  }
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
- }
1830
+ for (const [jid, c] of carry?.joints ?? []) if (c.st.open && !o.jointMap.has(jid)) o.st.joints.push(c.st);
1831
+ updateBound(o);
1751
1832
  o.anyAnchor = o.parts.some((p) => p.anchored);
1752
1833
  o.unsup = o.parts.map(() => false);
1753
1834
  solveStructure(o);
@@ -1887,7 +1968,7 @@ var GameTestRoom = (() => {
1887
1968
  if (!p) throw new Error(`unknown part "${id}" in object "${o.id}"`);
1888
1969
  return p;
1889
1970
  };
1890
- const partXf = (o, p) => ({ c: toWorld(o.st.position, o.st.rotation, p.spec.position), q: quat.mul(o.st.rotation, p.q) });
1971
+ const partXf = (o, p) => ({ c: toWorld(o.st.position, o.st.rotation, p.pos), q: quat.mul(o.st.rotation, p.q) });
1891
1972
  const objLocal = (o, w) => toLocal(o.st.position, o.st.rotation, w);
1892
1973
  const objLocalDir = (o, w) => rotateVec(quat.conjugate(o.st.rotation), w);
1893
1974
  const jointWorld = (o, j) => toWorld(o.st.position, o.st.rotation, j.pos);
@@ -1899,14 +1980,14 @@ var GameTestRoom = (() => {
1899
1980
  const { c, q } = partXf(o, p);
1900
1981
  return aabbOf(p.spec.shape, c, q);
1901
1982
  };
1902
- const rayPartRaw = (pos, rot, spec, pq, origin, d) => {
1903
- const c = toWorld(pos, rot, spec.position);
1983
+ const rayPartRaw = (pos, rot, spec, pq, origin, d, lp = spec.position) => {
1984
+ const c = toWorld(pos, rot, lp);
1904
1985
  const q = quat.mul(rot, pq);
1905
1986
  const qi = quat.conjugate(q);
1906
1987
  const r = rayShape(spec.shape, rotateVec(qi, vec.sub(origin, c)), rotateVec(qi, d));
1907
1988
  return r ? { t0: r.t0, t1: r.t1, n: rotateVec(q, r.n0) } : null;
1908
1989
  };
1909
- const rayPart = (o, p, origin, d) => rayPartRaw(o.st.position, o.st.rotation, p.spec, p.q, origin, d);
1990
+ const rayPart = (o, p, origin, d) => rayPartRaw(o.st.position, o.st.rotation, p.spec, p.q, origin, d, p.pos);
1910
1991
  const raySphereMiss = (origin, d, c, r, maxDist) => {
1911
1992
  const oc = vec.sub(origin, c);
1912
1993
  const b = vec.dot(oc, d);
@@ -1931,7 +2012,8 @@ var GameTestRoom = (() => {
1931
2012
  for (const im of impls.values()) {
1932
2013
  for (const ps of im.impl.describe().parts) {
1933
2014
  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);
2015
+ const ov = im.poses.get(ps.id);
2016
+ const r = rayPartRaw(im.position, im.rotation, ps, ov?.q ?? quatFromEuler(...ps.rotation ?? [0, 0, 0]), origin, d, ov?.pos);
1935
2017
  if (!r || r.t0 < 0 || r.t0 > maxDist) continue;
1936
2018
  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
2019
  }
@@ -2066,11 +2148,29 @@ var GameTestRoom = (() => {
2066
2148
  collapseCheck(o, src);
2067
2149
  checkDestroyed(o, src);
2068
2150
  };
2069
- const wearJoints = (o, p, amount, near, radius = 0.06, src) => {
2151
+ const jointReach = (o, j, point) => {
2152
+ const A = partWorldAabb(o, o.parts[j.ia]), B = partWorldAabb(o, o.parts[j.ib]);
2153
+ let d2 = 0;
2154
+ for (let k = 0; k < 3; k++) {
2155
+ const lo = Math.min(Math.max(A.min[k], B.min[k]), Math.min(A.max[k], B.max[k]));
2156
+ const hi = Math.max(Math.max(A.min[k], B.min[k]), Math.min(A.max[k], B.max[k]));
2157
+ const d = point[k] < lo ? lo - point[k] : point[k] > hi ? point[k] - hi : 0;
2158
+ d2 += d * d;
2159
+ }
2160
+ return Math.sqrt(d2);
2161
+ };
2162
+ const jointReachLimit = (o, j) => {
2163
+ const pa = o.parts[j.ia], pb = o.parts[j.ib];
2164
+ const small = pa.dims[0] * pa.dims[1] * pa.dims[2] <= pb.dims[0] * pb.dims[1] * pb.dims[2] ? pa : pb;
2165
+ return Math.max(0.06, 0.5 * Math.min(small.dims[0], small.dims[1], small.dims[2]));
2166
+ };
2167
+ const wearJoints = (o, p, amount, near, radius = 0.06, src, split = false) => {
2070
2168
  if (amount <= 0) return;
2071
- for (const j of o.byPart.get(p.spec.id) ?? []) {
2169
+ const mine = (o.byPart.get(p.spec.id) ?? []).filter((j) => !j.st.open);
2170
+ if (split && mine.length) amount /= mine.length;
2171
+ for (const j of mine) {
2072
2172
  if (j.st.open) continue;
2073
- if (near && vec.distance(jointWorld(o, j), near) > radius) continue;
2173
+ if (near && jointReach(o, j, near) > Math.max(radius, jointReachLimit(o, j))) continue;
2074
2174
  j.st.hp -= amount;
2075
2175
  if (j.st.hp <= 0) {
2076
2176
  j.st.hp = 0;
@@ -2189,7 +2289,7 @@ var GameTestRoom = (() => {
2189
2289
  if (mat.fracture !== "shatter") addHole(o, p, entry, n, caliber / 1e3, false, void 0, src);
2190
2290
  } else addHole(o, p, entry, n, caliber / 1e3, false, void 0, src);
2191
2291
  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);
2292
+ wearJoints(o, p, (penetrated ? thr : E) * 0.02, void 0, 0, src, true);
2193
2293
  const dmg = applyDamage(o, p, E * DMG_PER_J * scale2, "ballistic", entry, d, E * 0.1, 12, src);
2194
2294
  return { penetrated, exitEnergy: penetrated ? E - thr : 0, exitPoint, damage: dmg };
2195
2295
  };
@@ -2264,8 +2364,9 @@ var GameTestRoom = (() => {
2264
2364
  }
2265
2365
  for (const im of [...impls.values()]) {
2266
2366
  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]));
2367
+ const ov = im.poses.get(ps.id);
2368
+ const c = toWorld(im.position, im.rotation, ov?.pos ?? ps.position);
2369
+ const q = quat.mul(im.rotation, ov?.q ?? quatFromEuler(...ps.rotation ?? [0, 0, 0]));
2269
2370
  return distanceToShape(ps.shape, toLocal(c, q, point)) < radius;
2270
2371
  });
2271
2372
  if (inRange) out.push(implHit(im, { type: "explosive", point, energy, radius, ...src !== void 0 ? { source: src } : {} }));
@@ -2517,7 +2618,7 @@ var GameTestRoom = (() => {
2517
2618
  const spread = (sources, dt) => {
2518
2619
  for (const src of sources) {
2519
2620
  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);
2621
+ const sc = toWorld(src.o.st.position, src.o.st.rotation, src.p.pos);
2521
2622
  const A = partWorldAabb(src.o, src.p);
2522
2623
  for (const o of objects.values()) {
2523
2624
  if (o === src.o) continue;
@@ -2563,9 +2664,23 @@ var GameTestRoom = (() => {
2563
2664
  register(impl, opts = {}) {
2564
2665
  const id = opts.id ?? impl.id;
2565
2666
  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] });
2667
+ impls.set(id, { impl, position: opts.position ?? [0, 0, 0], rotation: opts.rotation ?? [0, 0, 0, 1], poses: /* @__PURE__ */ new Map() });
2567
2668
  return id;
2568
2669
  },
2670
+ setPartPose(objectId, partId, position, rotation) {
2671
+ const q = quatFromEuler(...rotation ?? [0, 0, 0]);
2672
+ const im = impls.get(objectId);
2673
+ if (im) {
2674
+ im.impl.setPartPose?.(partId, position, rotation);
2675
+ im.poses.set(partId, { pos: [position[0], position[1], position[2]], q });
2676
+ return;
2677
+ }
2678
+ const o = getObj(objectId);
2679
+ const p = getPart(o, partId);
2680
+ p.pos = [position[0], position[1], position[2]];
2681
+ p.q = q;
2682
+ updateBound(o);
2683
+ },
2569
2684
  remove(id) {
2570
2685
  objects.delete(id);
2571
2686
  impls.delete(id);
@@ -2592,9 +2707,12 @@ var GameTestRoom = (() => {
2592
2707
  const moved = new Set(names);
2593
2708
  const nid = newId ?? nextId();
2594
2709
  if (objects.has(nid)) throw new Error(`object "${nid}" already exists`);
2595
- const carryFor = (keep) => ({
2710
+ const carryFor = (keep, keepOpen = () => false) => ({
2596
2711
  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 }])),
2712
+ joints: new Map([
2713
+ ...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 }]),
2714
+ ...o.st.joints.filter((st) => st.open && !o.jointMap.has(st.id) && keepOpen(st.a, st.b)).map((st) => [st.id, { st, step: 0 }])
2715
+ ]),
2598
2716
  collapsed: [...o.collapsed].filter(keep)
2599
2717
  });
2600
2718
  const sub3 = (keep) => {
@@ -2606,8 +2724,8 @@ var GameTestRoom = (() => {
2606
2724
  const inNew = (pid) => moved.has(pid);
2607
2725
  const inOld = (pid) => !moved.has(pid);
2608
2726
  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));
2727
+ const fresh = build(sub3(inNew), nid, pos, rot, carryFor(inNew, (a, b) => inNew(a) && inNew(b)));
2728
+ const rest = build(sub3(inOld), id, pos, rot, carryFor(inOld, (a, b) => !(inNew(a) && inNew(b))));
2611
2729
  objects.set(id, rest);
2612
2730
  objects.set(nid, fresh);
2613
2731
  return nid;
@@ -2705,34 +2823,37 @@ var GameTestRoom = (() => {
2705
2823
  }
2706
2824
  return getObj(objectId).joints.filter((j) => !j.st.open && j.spec.tools.includes(tool)).map((j) => j.st);
2707
2825
  },
2708
- nearestJoint(objectId, point, maxDistance = 0.3) {
2826
+ nearestJoint(objectId, point, maxDistance = 0.3, tool) {
2709
2827
  const im = impls.get(objectId);
2828
+ const cands = [];
2710
2829
  if (im) {
2711
- let best2 = null;
2712
- let bd2 = maxDistance;
2830
+ const spec = im.impl.describe();
2713
2831
  for (const js of im.impl.state().joints) {
2714
2832
  if (js.open) continue;
2715
2833
  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
- }
2834
+ const j = (spec.joints ?? []).find((x) => (x.id ?? `${x.a}~${x.b}`) === js.id);
2835
+ cands.push({ st: js, d: pos ? vec.distance(pos, point) : Infinity, ok: !!j && (j.tools ?? JOINT_DEFAULTS[j.kind].tools).includes(tool) });
2721
2836
  }
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;
2837
+ } else {
2838
+ const o = getObj(objectId);
2839
+ for (const j of o.joints) {
2840
+ if (j.st.open) continue;
2841
+ cands.push({ st: j.st, d: vec.distance(jointWorld(o, j), point), ok: tool ? j.spec.tools.includes(tool) : true });
2733
2842
  }
2734
2843
  }
2735
- return best;
2844
+ const pick = (only) => {
2845
+ let best = null;
2846
+ let bd = maxDistance;
2847
+ for (const c of cands) {
2848
+ if (only && !c.ok) continue;
2849
+ if (c.d <= bd) {
2850
+ bd = c.d;
2851
+ best = c.st;
2852
+ }
2853
+ }
2854
+ return best;
2855
+ };
2856
+ return (tool ? pick(true) : null) ?? pick(false);
2736
2857
  },
2737
2858
  extinguish(objectId, partId, coolC = 200) {
2738
2859
  const im = impls.get(objectId);
@@ -2775,6 +2896,10 @@ var GameTestRoom = (() => {
2775
2896
  }).events;
2776
2897
  },
2777
2898
  state: (id) => objects.get(id)?.st ?? impls.get(id)?.impl.state() ?? null,
2899
+ snapshot: (id) => {
2900
+ const st = objects.get(id)?.st ?? impls.get(id)?.impl.state();
2901
+ return st ? clone(st) : null;
2902
+ },
2778
2903
  part: (id, partId) => objects.get(id)?.byId.get(partId)?.st ?? impls.get(id)?.impl.state().parts.find((p) => p.id === partId) ?? null,
2779
2904
  reset(id) {
2780
2905
  const im = impls.get(id);
@@ -3786,6 +3911,8 @@ void main() {
3786
3911
  var SLEEP_AFTER = 0.5;
3787
3912
  var HARD_LANDING = 3;
3788
3913
  var SLOP = 2e-3;
3914
+ var NARROW = 0.3;
3915
+ var TOPPLE = 3;
3789
3916
  var PieceSystem = class {
3790
3917
  constructor(THREE, o) {
3791
3918
  __publicField(this, "THREE", THREE);
@@ -3917,6 +4044,7 @@ void main() {
3917
4044
  let landPoint = [pos[0], pos[1], pos[2]];
3918
4045
  let contactedLast = false;
3919
4046
  let touched = false;
4047
+ let unstable = false;
3920
4048
  for (let s = 0; s < steps; s++) {
3921
4049
  v[1] -= this.o.gravity * h;
3922
4050
  pos[0] += v[0] * h;
@@ -4005,6 +4133,25 @@ void main() {
4005
4133
  }
4006
4134
  }
4007
4135
  }
4136
+ let x0 = Infinity, x1 = -Infinity, z0 = Infinity, z1 = -Infinity, low = 0;
4137
+ for (const r of rs) {
4138
+ x0 = Math.min(x0, r[0]);
4139
+ x1 = Math.max(x1, r[0]);
4140
+ z0 = Math.min(z0, r[2]);
4141
+ z1 = Math.max(z1, r[2]);
4142
+ low = Math.min(low, r[1]);
4143
+ }
4144
+ const height = -low;
4145
+ const wx = x1 - x0;
4146
+ const wz = z1 - z0;
4147
+ unstable = Math.min(wx, wz) < NARROW * height || x0 > 0 || x1 < 0 || z0 > 0 || z1 < 0;
4148
+ if (unstable) {
4149
+ const dirX = x0 + x1 > 0 ? -1 : 1;
4150
+ const dirZ = z0 + z1 > 0 ? -1 : 1;
4151
+ const k = TOPPLE * Math.min(height, 1) * h;
4152
+ if (wx <= wz) w[2] -= dirX * k;
4153
+ else w[0] += dirZ * k;
4154
+ }
4008
4155
  const rd = Math.exp((rs.length >= 3 ? -3 : -1.5) * h);
4009
4156
  w[0] *= rd;
4010
4157
  w[1] *= rd;
@@ -4022,7 +4169,7 @@ void main() {
4022
4169
  b.lastHit = this.time;
4023
4170
  this.o.onHardLanding(b, landPoint, landing);
4024
4171
  }
4025
- const still = touched && Math.hypot(v[0], v[1], v[2]) < 0.12 && Math.hypot(w[0], w[1], w[2]) < 0.45;
4172
+ 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
4173
  if (still) {
4027
4174
  b.still += dt;
4028
4175
  if (b.still >= SLEEP_AFTER) {
@@ -4081,6 +4228,163 @@ void main() {
4081
4228
  }
4082
4229
  }
4083
4230
  };
4231
+ function partHintOf(value) {
4232
+ if (typeof value === "string") return value ? { id: value } : null;
4233
+ if (!value || typeof value !== "object") return null;
4234
+ const v = value;
4235
+ if (typeof v.id !== "string" || !v.id) return null;
4236
+ const str = (x) => typeof x === "string" && x ? x : void 0;
4237
+ return { id: v.id, material: str(v.material), parent: str(v.parent), kind: str(v.kind) };
4238
+ }
4239
+ var METAL = /steel|iron|metal|alumin|copper|brass|bronze|tin|titan|chrome|zinc|lead/i;
4240
+ function sanitizeId(name) {
4241
+ let id = name.trim().replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
4242
+ if (id && /^[0-9]/.test(id)) id = `p_${id}`;
4243
+ return id;
4244
+ }
4245
+ function isMesh(o) {
4246
+ return o.isMesh === true;
4247
+ }
4248
+ function sizeOf(box2) {
4249
+ return [box2.max.x - box2.min.x, box2.max.y - box2.min.y, box2.max.z - box2.min.z];
4250
+ }
4251
+ function specFromObject3D(THREE, root, options = {}) {
4252
+ const fallback = options.fallback ?? "plastic";
4253
+ const minSize = options.minSize ?? 0.015;
4254
+ root.updateMatrixWorld(true);
4255
+ const inv = root.matrixWorld.clone().invert();
4256
+ const entries = [];
4257
+ const byKey = /* @__PURE__ */ new Map();
4258
+ const visit = (o) => {
4259
+ if (!o.visible || o.userData?.destructionIgnore) return;
4260
+ if (isMesh(o) && o.geometry) {
4261
+ const geom = o.geometry;
4262
+ if (!geom.boundingBox) geom.computeBoundingBox();
4263
+ if (geom.boundingBox && !geom.boundingBox.isEmpty()) {
4264
+ const box2 = geom.boundingBox.clone().applyMatrix4(new THREE.Matrix4().multiplyMatrices(inv, o.matrixWorld));
4265
+ const mats = Array.isArray(o.material) ? o.material : [o.material];
4266
+ const matName = mats[0]?.name ?? "";
4267
+ const ud = o.userData ?? {};
4268
+ const hint = partHintOf(ud.destructionPart);
4269
+ 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;
4270
+ const explicit = hint?.id ?? null;
4271
+ const hollow = ud.hollow === true || /hollow/i.test(o.name);
4272
+ const fill = options.fillOf?.(o, material);
4273
+ const tags = Array.isArray(ud.destructionTags) ? ud.destructionTags : void 0;
4274
+ const existing = explicit ? byKey.get(explicit) : void 0;
4275
+ if (existing) {
4276
+ const before = sizeOf(existing.box);
4277
+ const bv = before[0] * before[1] * before[2];
4278
+ const ns = sizeOf(box2);
4279
+ existing.box.union(box2);
4280
+ existing.meshes.push(o);
4281
+ if (ns[0] * ns[1] * ns[2] > bv) existing.material = material;
4282
+ existing.hollow = existing.hollow || hollow;
4283
+ if (fill !== void 0) existing.fill = fill;
4284
+ } else {
4285
+ 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 };
4286
+ entries.push(e);
4287
+ if (explicit) byKey.set(explicit, e);
4288
+ }
4289
+ }
4290
+ }
4291
+ for (const c of o.children) visit(c);
4292
+ };
4293
+ visit(root);
4294
+ const eps = 5e-3;
4295
+ const overlapVolume = (a, b) => {
4296
+ const x = Math.min(a.max.x, b.max.x) - Math.max(a.min.x, b.min.x) + eps;
4297
+ const y = Math.min(a.max.y, b.max.y) - Math.max(a.min.y, b.min.y) + eps;
4298
+ const z = Math.min(a.max.z, b.max.z) - Math.max(a.min.z, b.min.z) + eps;
4299
+ return x > 0 && y > 0 && z > 0 ? x * y * z : 0;
4300
+ };
4301
+ const largest = (e) => Math.max(...sizeOf(e.box));
4302
+ const kept = [];
4303
+ const tiny = [];
4304
+ for (const e of entries) (!e.fixedId && largest(e) < minSize ? tiny : kept).push(e);
4305
+ for (const t of tiny) {
4306
+ let best = null;
4307
+ let bestV = 0;
4308
+ for (const k of kept) {
4309
+ const v = overlapVolume(t.box, k.box);
4310
+ if (v > bestV) {
4311
+ bestV = v;
4312
+ best = k;
4313
+ }
4314
+ }
4315
+ if (!best) {
4316
+ let bd = 0.05;
4317
+ for (const k of kept) {
4318
+ const d = k.box.distanceToPoint(t.box.getCenter(new THREE.Vector3()));
4319
+ if (d < bd) {
4320
+ bd = d;
4321
+ best = k;
4322
+ }
4323
+ }
4324
+ }
4325
+ if (best) {
4326
+ best.meshes.push(...t.meshes);
4327
+ } else {
4328
+ kept.push(t);
4329
+ }
4330
+ }
4331
+ const used = /* @__PURE__ */ new Set();
4332
+ const parts = [];
4333
+ const meshesOf = {};
4334
+ let counter = 0;
4335
+ for (const e of kept) {
4336
+ counter++;
4337
+ let base = (e.explicitId ? sanitizeId(e.explicitId) : "") || sanitizeId(e.name) || `part_${counter}`;
4338
+ let id = base;
4339
+ for (let n = 2; used.has(id); n++) id = `${base}_${n}`;
4340
+ used.add(id);
4341
+ base = id;
4342
+ const size = sizeOf(e.box);
4343
+ const center = e.box.getCenter(new THREE.Vector3());
4344
+ const part = {
4345
+ id,
4346
+ material: e.material,
4347
+ shape: { kind: "box", size: [Math.max(size[0], 2e-3), Math.max(size[1], 2e-3), Math.max(size[2], 2e-3)] },
4348
+ position: [center.x, center.y, center.z]
4349
+ };
4350
+ const minSide = Math.min(...size);
4351
+ if (e.fill !== void 0) part.fill = e.fill;
4352
+ else if (e.hollow) part.fill = 0.05;
4353
+ if (minSide < 0.01 && METAL.test(e.material)) {
4354
+ part.fill = e.fill ?? 1;
4355
+ part.thickness = Math.max(minSide, 5e-4);
4356
+ }
4357
+ if (e.tags) part.tags = [...e.tags];
4358
+ const ao = options.anchorBelow ?? -1;
4359
+ if (ao >= 0 && e.box.min.y <= ao) part.anchored = true;
4360
+ parts.push(part);
4361
+ meshesOf[id] = e.meshes;
4362
+ }
4363
+ const spec = { parts };
4364
+ if (options.name !== void 0) spec.name = options.name;
4365
+ else if (root.name) spec.name = root.name;
4366
+ if (options.tags) spec.tags = [...options.tags];
4367
+ spec.joints = options.joints === false ? [] : inferJoints(parts);
4368
+ if (options.joints !== false) {
4369
+ const finalId = /* @__PURE__ */ new Map();
4370
+ kept.forEach((e, i) => finalId.set(e, parts[i].id));
4371
+ const idOf = /* @__PURE__ */ new Map();
4372
+ for (const e of kept) if (e.explicitId) idOf.set(e.explicitId, finalId.get(e));
4373
+ const joints = spec.joints;
4374
+ for (const e of kept) {
4375
+ if (!e.parent) continue;
4376
+ const id = finalId.get(e);
4377
+ const parent = idOf.get(e.parent) ?? (used.has(sanitizeId(e.parent)) ? sanitizeId(e.parent) : null);
4378
+ if (!parent || parent === id) continue;
4379
+ const at = joints.findIndex((j) => j.a === parent && j.b === id || j.a === id && j.b === parent);
4380
+ const kind = e.jointKind ?? (at >= 0 ? joints[at].kind : "resting");
4381
+ const joint2 = { id: `${parent}~${id}`, a: parent, b: id, kind };
4382
+ if (at >= 0) joints[at] = joint2;
4383
+ else joints.push(joint2);
4384
+ }
4385
+ }
4386
+ return { spec, parts: meshesOf };
4387
+ }
4084
4388
  var STOPS = [
4085
4389
  [450, 0],
4086
4390
  [500, 2753536],
@@ -4166,6 +4470,7 @@ void main() {
4166
4470
  __publicField(this, "m4");
4167
4471
  __publicField(this, "box");
4168
4472
  __publicField(this, "decalCount", 0);
4473
+ __publicField(this, "tints", /* @__PURE__ */ new WeakMap());
4169
4474
  this.kit = new DecalKit(THREE);
4170
4475
  this.a = tmpV(THREE);
4171
4476
  this.b = tmpV(THREE);
@@ -4196,10 +4501,33 @@ void main() {
4196
4501
  vertexCount(mv) {
4197
4502
  return mv.mesh.geometry.getAttribute(POS_ATTR)?.count ?? 0;
4198
4503
  }
4199
- /** Colour of the original material (before any char), `0xRRGGBB`, or -1. */
4504
+ /**
4505
+ * Average vertex colour of a mesh whose material uses `vertexColors` (computed once per mesh, from its original geometry), else null. Fragments
4506
+ * and debris of such a mesh multiply it by `material.color`; without it they come out white.
4507
+ */
4508
+ vertexTint(mv) {
4509
+ if (this.tints.has(mv.mesh)) return this.tints.get(mv.mesh) ?? null;
4510
+ let tint = null;
4511
+ const used = materialsOf(mv.origMaterial).some((m) => m.vertexColors === true);
4512
+ const attr = used ? mv.origGeometry.getAttribute("color") : void 0;
4513
+ if (attr && attr.count > 0 && attr.itemSize >= 3) {
4514
+ let r = 0, g = 0, b = 0;
4515
+ for (let i = 0; i < attr.count; i++) {
4516
+ r += attr.getX(i);
4517
+ g += attr.getY(i);
4518
+ b += attr.getZ(i);
4519
+ }
4520
+ tint = new this.THREE.Color(r / attr.count, g / attr.count, b / attr.count);
4521
+ }
4522
+ this.tints.set(mv.mesh, tint);
4523
+ return tint;
4524
+ }
4525
+ /** Colour of the original material (before any char) times the vertex tint, `0xRRGGBB`, or -1. */
4200
4526
  baseColorHex(mv) {
4201
4527
  const c = mv.baseColors?.[0] ?? colorOf(materialsOf(mv.origMaterial)[0]);
4202
- return c ? c.getHex() : -1;
4528
+ if (!c) return -1;
4529
+ const tint = this.vertexTint(mv);
4530
+ return tint ? c.clone().multiply(tint).getHex() : c.getHex();
4203
4531
  }
4204
4532
  /** Is the original material transparent? */
4205
4533
  isTransparent(mv) {
@@ -4668,8 +4996,8 @@ void main() {
4668
4996
  } else {
4669
4997
  const leftovers = [];
4670
4998
  for (const m of meshesIn(root)) {
4671
- const ud = m.userData?.destructionPart;
4672
- if (typeof ud === "string" && partIds.has(ud)) add2(ud, m);
4999
+ const ud = partHintOf(m.userData?.destructionPart)?.id;
5000
+ if (ud !== void 0 && partIds.has(ud)) add2(ud, m);
4673
5001
  else if (partIds.has(m.name)) add2(m.name, m);
4674
5002
  else leftovers.push(m);
4675
5003
  }
@@ -4979,7 +5307,10 @@ void main() {
4979
5307
  if (!mv) return null;
4980
5308
  const mat = materialsOf2(mv.mesh.material)[0];
4981
5309
  const c = mat?.color;
4982
- if (c && c.isColor) return c.getHex();
5310
+ if (c && c.isColor) {
5311
+ const tint = this.visuals.vertexTint(mv);
5312
+ return tint ? c.clone().multiply(tint).getHex() : c.getHex();
5313
+ }
4983
5314
  return def ? fallbackColor(def.id, def) : null;
4984
5315
  }
4985
5316
  debrisOptions(kind, objectId, partId) {
@@ -5423,136 +5754,6 @@ void main() {
5423
5754
  this.disposed = true;
5424
5755
  }
5425
5756
  };
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
5757
 
5557
5758
  // ../game-weapons/dist/index.js
5558
5759
  var __defProp3 = Object.defineProperty;
@@ -5739,6 +5940,36 @@ void main() {
5739
5940
  "grab"
5740
5941
  ]
5741
5942
  },
5943
+ {
5944
+ id: "kick",
5945
+ name: "Kick",
5946
+ category: "melee_blunt",
5947
+ action: {
5948
+ kind: "melee",
5949
+ damage: "blunt",
5950
+ energy: 250,
5951
+ radius: 0.08,
5952
+ reach: 1.1,
5953
+ rpm: 60,
5954
+ noise: 8
5955
+ },
5956
+ mass: 0
5957
+ },
5958
+ {
5959
+ id: "shove",
5960
+ name: "Shove",
5961
+ category: "melee_blunt",
5962
+ action: {
5963
+ kind: "melee",
5964
+ damage: "blunt",
5965
+ energy: 80,
5966
+ radius: 0.1,
5967
+ reach: 1.5,
5968
+ rpm: 90,
5969
+ noise: 5
5970
+ },
5971
+ mass: 0
5972
+ },
5742
5973
  {
5743
5974
  id: "pistol_9mm",
5744
5975
  name: "9 mm service pistol",
@@ -6616,6 +6847,8 @@ void main() {
6616
6847
  en: {
6617
6848
  weapons: {
6618
6849
  hand: "Hand",
6850
+ kick: "Kick",
6851
+ shove: "Shove",
6619
6852
  pistol_9mm: "9 mm service pistol",
6620
6853
  pistol_45: ".45 ACP pistol",
6621
6854
  pistol_22: ".22 LR pistol",
@@ -6662,6 +6895,8 @@ void main() {
6662
6895
  "pt-BR": {
6663
6896
  weapons: {
6664
6897
  hand: "m\xE3o",
6898
+ kick: "chute",
6899
+ shove: "empurr\xE3o",
6665
6900
  pistol_9mm: "pistola 9 mm",
6666
6901
  pistol_45: "pistola .45",
6667
6902
  pistol_22: "pistola .22",
@@ -7058,6 +7293,8 @@ void main() {
7058
7293
  var WEAPONS = weapons_default;
7059
7294
  var DEFAULT_LOADOUT = [
7060
7295
  "hand",
7296
+ "kick",
7297
+ "shove",
7061
7298
  "pistol_9mm",
7062
7299
  "revolver_357",
7063
7300
  "shotgun_pump_12",
@@ -7065,6 +7302,8 @@ void main() {
7065
7302
  "rifle_bolt_308",
7066
7303
  "hammer",
7067
7304
  "axe",
7305
+ "machete",
7306
+ "utility_knife",
7068
7307
  "crowbar",
7069
7308
  "lighter",
7070
7309
  "flamethrower",
@@ -10529,6 +10768,9 @@ void main() {
10529
10768
  "mod.destructible_ok": "{name}: destructible contract ok ({parts} parts, hit and state well-formed)",
10530
10769
  "mod.destructible_fail": "{name}: destructible contract broken: {problems}",
10531
10770
  "mod.animation_ok": '{name}: animation "{clip}" ok ({seconds} s)',
10771
+ "mod.spec_ok": "{name}: spec ok ({parts} parts)",
10772
+ "mod.spec_fail": "{name}: spec broken: {problems}",
10773
+ "mod.spec_warn": "{name}: spec warning: {warning}",
10532
10774
  "mod.animation_fail": '{name}: animation "{clip}" broken: {problems}',
10533
10775
  "ui.animations": "Animations",
10534
10776
  "ui.animations_aim": "Aim at an item or hold it to see its animations.",
@@ -10589,6 +10831,7 @@ void main() {
10589
10831
  "ui.filter.event": "events",
10590
10832
  "ui.filter.check": "checks",
10591
10833
  "ui.autoscroll": "autoscroll",
10834
+ "log.export_cancelled": "export cancelled",
10592
10835
  "ui.copy_json": "Copy JSON",
10593
10836
  "ui.download_ndjson": "Download NDJSON",
10594
10837
  "ui.download_txt": "Download TXT",
@@ -10645,6 +10888,8 @@ void main() {
10645
10888
  "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
10889
  "sc.kick_chair_leg": "A kick takes a chair leg off",
10647
10890
  "sc.kick_chair_leg.check": "Chair: {kicks} kick(s), {groups} group(s), loose pieces {before} \u2192 {after}, {leg} (expected: a loose piece appears)",
10891
+ "sc.kick_detaches_leg_via_tool": "The kick tool (hotbar) takes a chair leg off",
10892
+ "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
10893
  "sc.screwdriver_shelf_board": "Screwdriver takes a screwed shelf board off in about 2.5 s",
10649
10894
  "sc.screwdriver_shelf_board.check": "Screwdriver on the shelf board: took {secs} s, expected about {want} s, the shelf is now in {pieces}",
10650
10895
  "sc.wrong_tool_refused": "The wrong tool is refused",
@@ -10812,6 +11057,9 @@ void main() {
10812
11057
  "mod.destructible_ok": "{name}: contrato de destrui\xE7\xE3o ok ({parts} pe\xE7as, golpe e estado bem formados)",
10813
11058
  "mod.destructible_fail": "{name}: contrato de destrui\xE7\xE3o quebrado: {problems}",
10814
11059
  "mod.animation_ok": '{name}: anima\xE7\xE3o "{clip}" ok ({seconds} s)',
11060
+ "mod.spec_ok": "{name}: spec ok ({parts} pe\xE7as)",
11061
+ "mod.spec_fail": "{name}: spec quebrada: {problems}",
11062
+ "mod.spec_warn": "{name}: aviso na spec: {warning}",
10815
11063
  "mod.animation_fail": '{name}: anima\xE7\xE3o "{clip}" quebrada: {problems}',
10816
11064
  "ui.animations": "Anima\xE7\xF5es",
10817
11065
  "ui.animations_aim": "Mire num item ou segure-o para ver as anima\xE7\xF5es.",
@@ -10872,6 +11120,7 @@ void main() {
10872
11120
  "ui.filter.event": "eventos",
10873
11121
  "ui.filter.check": "testes",
10874
11122
  "ui.autoscroll": "rolar sozinho",
11123
+ "log.export_cancelled": "exporta\xE7\xE3o cancelada",
10875
11124
  "ui.copy_json": "Copiar JSON",
10876
11125
  "ui.download_ndjson": "Baixar NDJSON",
10877
11126
  "ui.download_txt": "Baixar TXT",
@@ -10928,6 +11177,8 @@ void main() {
10928
11177
  "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
11178
  "sc.kick_chair_leg": "Um chute solta a perna da cadeira",
10930
11179
  "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)",
11180
+ "sc.kick_detaches_leg_via_tool": "A ferramenta chute (barra de atalhos) solta a perna da cadeira",
11181
+ "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
11182
  "sc.screwdriver_shelf_board": "Chave de fenda solta a prateleira parafusada em cerca de 2,5 s",
10932
11183
  "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
11184
  "sc.wrong_tool_refused": "A ferramenta errada \xE9 recusada",
@@ -11429,7 +11680,16 @@ void main() {
11429
11680
  const s = t - m * 60;
11430
11681
  return `${pad(m)}:${s.toFixed(2).padStart(5, "0")}`;
11431
11682
  };
11432
- function createLog() {
11683
+ function createLog(options = {}) {
11684
+ const host = globalThis.claude;
11685
+ let downloadsP = null;
11686
+ if (host && typeof host.use === "function") {
11687
+ try {
11688
+ downloadsP = Promise.resolve(host.use("downloads")).then((d) => d ?? null, () => null);
11689
+ } catch {
11690
+ downloadsP = null;
11691
+ }
11692
+ }
11433
11693
  let list = [];
11434
11694
  let seq = 0;
11435
11695
  let clock = () => 0;
@@ -11495,9 +11755,20 @@ void main() {
11495
11755
  toJSON: () => JSON.stringify(read(), null, 2),
11496
11756
  toNDJSON: () => read().map((e) => JSON.stringify(e)).join("\n") + (list.length ? "\n" : ""),
11497
11757
  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;
11758
+ async download(format = "ndjson") {
11500
11759
  const body = format === "json" ? log.toJSON() : format === "txt" ? log.toText() : log.toNDJSON();
11760
+ const downloads = downloadsP ? await downloadsP : null;
11761
+ if (downloads) {
11762
+ const filename = format === "ndjson" ? "test-room-log.ndjson.txt" : `test-room-log.${format}`;
11763
+ try {
11764
+ await downloads.save({ filename, data: body });
11765
+ } catch (err) {
11766
+ const code = err?.code;
11767
+ log.add({ channel: "action", type: "export_cancelled", text: options.exportCancelled?.() ?? "export cancelled", data: { format, ...typeof code === "string" ? { code } : {} } });
11768
+ }
11769
+ return;
11770
+ }
11771
+ if (typeof document === "undefined" || typeof Blob === "undefined" || typeof URL === "undefined" || !URL.createObjectURL) return;
11501
11772
  const mime = format === "json" ? "application/json" : "text/plain";
11502
11773
  const url = URL.createObjectURL(new Blob([body], { type: mime }));
11503
11774
  const a = document.createElement("a");
@@ -12242,6 +12513,10 @@ void main() {
12242
12513
  yaw += yawDeg * Math.PI / 180;
12243
12514
  apply();
12244
12515
  },
12516
+ moveBy(dx, dz) {
12517
+ move(dx, dz);
12518
+ apply();
12519
+ },
12245
12520
  walk(forward, right) {
12246
12521
  move(-Math.sin(yaw) * forward + Math.cos(yaw) * right, -Math.cos(yaw) * forward - Math.sin(yaw) * right);
12247
12522
  apply();
@@ -12373,6 +12648,23 @@ void main() {
12373
12648
  { kicks, before, after }
12374
12649
  );
12375
12650
  }),
12651
+ sc("kick_detaches_leg_via_tool", ({ room, step, expect }) => {
12652
+ const id = at(room, "chair", 0, -4);
12653
+ const before = room.renderer.pieces().length;
12654
+ room.setTool("kick");
12655
+ let kicks = 0;
12656
+ for (; kicks < 8 && room.renderer.pieces().length <= before && part(room, id, "leg_1"); kicks++) {
12657
+ const st = room.world.state(id);
12658
+ const leg = toWorld(st.position, st.rotation, [-0.18, 0.2, -0.18]);
12659
+ const side = rotateVec(st.rotation, [-0.8, 0, 0]);
12660
+ look(room, [leg[0] + side[0], leg[1] + side[1], leg[2] + side[2]], leg);
12661
+ room.use();
12662
+ step(1.05);
12663
+ }
12664
+ step(0.5);
12665
+ const after = room.renderer.pieces().length;
12666
+ 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 });
12667
+ }),
12376
12668
  sc("screwdriver_shelf_board", ({ room, step, expect }) => {
12377
12669
  const id = at(room, "shelf", 0, -4);
12378
12670
  room.setTool("screwdriver");
@@ -12703,16 +12995,25 @@ void main() {
12703
12995
  };
12704
12996
  logBtn(t("ui.copy_json"), () => {
12705
12997
  const text = o.log.toJSON();
12706
- try {
12707
- void navigator.clipboard.writeText(text);
12708
- } catch {
12998
+ const select = () => {
12709
12999
  const ta = mk("textarea");
12710
13000
  ta.value = text;
13001
+ ta.setAttribute("readonly", "");
13002
+ ta.style.cssText = "position:fixed;left:8px;bottom:8px;width:min(480px,80vw);height:120px;z-index:99999";
12711
13003
  document.body.appendChild(ta);
13004
+ ta.focus();
12712
13005
  ta.select();
12713
- document.execCommand?.("copy");
12714
- ta.remove();
12715
- }
13006
+ let copied = false;
13007
+ try {
13008
+ copied = !!document.execCommand?.("copy");
13009
+ } catch {
13010
+ copied = false;
13011
+ }
13012
+ if (copied) ta.remove();
13013
+ else ta.onblur = () => ta.remove();
13014
+ };
13015
+ if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) navigator.clipboard.writeText(text).catch(select);
13016
+ else select();
12716
13017
  });
12717
13018
  logBtn(t("ui.download_ndjson"), () => o.log.download("ndjson"));
12718
13019
  logBtn(t("ui.download_txt"), () => o.log.download("txt"));
@@ -13124,7 +13425,7 @@ void main() {
13124
13425
  const entries = /* @__PURE__ */ new Map();
13125
13426
  const splitOf = /* @__PURE__ */ new Map();
13126
13427
  let simTime = 0;
13127
- const log = createLog();
13428
+ const log = createLog({ exportCancelled: () => t("log.export_cancelled") });
13128
13429
  log.setClock(() => simTime);
13129
13430
  const renderer = createDestructionRenderer(THREE, {
13130
13431
  scene,
@@ -13498,6 +13799,7 @@ void main() {
13498
13799
  addArena();
13499
13800
  timeScale = 1;
13500
13801
  fastForward = false;
13802
+ walkPlan = null;
13501
13803
  };
13502
13804
  const aimO = [0, 0, 0];
13503
13805
  const aimD = [0, 0, -1];
@@ -13749,11 +14051,11 @@ void main() {
13749
14051
  1
13750
14052
  );
13751
14053
  };
13752
- const meleeUse = (u) => {
13753
- const w = toolName();
14054
+ const meleeUse = (u, id = toolId) => {
14055
+ const w = toolName(id);
13754
14056
  const r = rayWorld(u.origin, u.direction, u.reach);
13755
14057
  if (!r) {
13756
- action("melee", t("act.melee.miss", { weapon: w }), { data: { weapon: toolId } });
14058
+ action("melee", t("act.melee.miss", { weapon: w }), { data: { weapon: id } });
13757
14059
  return;
13758
14060
  }
13759
14061
  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 +14064,7 @@ void main() {
13762
14064
  objectId: r.object,
13763
14065
  itemId: entryOf(r.object)?.so.itemId,
13764
14066
  part: r.part,
13765
- data: { weapon: toolId, damage: u.damage, energyJ: u.energy, distanceM: Math.round(r.distance * 100) / 100 }
14067
+ data: { weapon: id, damage: u.damage, energyJ: u.energy, distanceM: Math.round(r.distance * 100) / 100 }
13766
14068
  });
13767
14069
  };
13768
14070
  const toolWork = (u, dt) => {
@@ -13773,7 +14075,7 @@ void main() {
13773
14075
  log.coalesce(`use:${toolId}`, { channel: "action", type: "tool", text: t("act.tool.none", { weapon: w }), data: { weapon: toolId } }, 1);
13774
14076
  return;
13775
14077
  }
13776
- const j = world.nearestJoint(r.object, r.point, 0.45);
14078
+ const j = world.nearestJoint(r.object, r.point, 0.45, u.tool);
13777
14079
  if (!j) {
13778
14080
  progress = null;
13779
14081
  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 +14254,13 @@ void main() {
13952
14254
  }
13953
14255
  }
13954
14256
  };
14257
+ const kick = () => {
14258
+ if (!arsenal.weapons.has("kick")) return;
14259
+ aim();
14260
+ const res = toolState("kick").trigger([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], 0, false, "player");
14261
+ if (res.status !== "ok") return;
14262
+ for (const u of res.uses) if (u.kind === "melee") meleeUse(u, "kick");
14263
+ };
13955
14264
  const setTool = (id) => {
13956
14265
  if (!arsenal.weapons.has(id)) throw new Error(`game-test-room: unknown weapon "${id}"`);
13957
14266
  tool.release();
@@ -14207,7 +14516,7 @@ void main() {
14207
14516
  break;
14208
14517
  case "KeyV":
14209
14518
  if (heldEntry?.uses[2]) useItem(heldEntry.uses[2]);
14210
- else toggleTap();
14519
+ else kick();
14211
14520
  break;
14212
14521
  case "KeyT":
14213
14522
  toggleTap();
@@ -14306,8 +14615,19 @@ void main() {
14306
14615
  if (part && part.liters !== void 0 && Math.abs(part.liters - c.liters) > 1e-4) world.setLiters(host.objectId, e.fluidPart, c.liters);
14307
14616
  }
14308
14617
  };
14618
+ let paused = false;
14619
+ let walkPlan = null;
14620
+ const stepWalk = (dt) => {
14621
+ if (!walkPlan) return;
14622
+ const f = walkPlan.remaining <= dt ? 1 : dt / walkPlan.remaining;
14623
+ player.moveBy((walkPlan.x - camera.position.x) * f, (walkPlan.z - camera.position.z) * f);
14624
+ walkPlan.remaining -= dt;
14625
+ if (walkPlan.remaining <= 1e-9) walkPlan = null;
14626
+ };
14309
14627
  const stepSim = (dt) => {
14628
+ if (paused) return;
14310
14629
  simTime += dt;
14630
+ stepWalk(dt);
14311
14631
  const reps = fastForward ? 10 : 1;
14312
14632
  updateAnimations(dt);
14313
14633
  updateItemUse(dt);
@@ -14336,13 +14656,13 @@ void main() {
14336
14656
  if (tiltKey || heldTilt > 0) {
14337
14657
  heldTilt = Math.max(0, Math.min(2.4, heldTilt + (tiltKey ? 2.2 : -3) * dt));
14338
14658
  }
14339
- if (useHeld) {
14659
+ if (useHeld && !paused) {
14340
14660
  if (tool.def.category === "hand") {
14341
14661
  if (useFirst) grab();
14342
14662
  } else useTool(dt, !useFirst);
14343
14663
  useFirst = false;
14344
14664
  }
14345
- followHeld(dt);
14665
+ if (!paused) followHeld(dt);
14346
14666
  stepSim(dt * timeScale);
14347
14667
  if (progress && simTime - progress.at > 0.25) progress = null;
14348
14668
  hudAcc += dt;
@@ -14403,6 +14723,16 @@ void main() {
14403
14723
  const pose = poseAt(pl.clip, id === "" ? null : id, pl.t);
14404
14724
  obj.position.set(rest.p[0] + pose.position[0], rest.p[1] + pose.position[1], rest.p[2] + pose.position[2]);
14405
14725
  obj.rotation.set(rest.r[0] + pose.rotation[0], rest.r[1] + pose.rotation[1], rest.r[2] + pose.rotation[2]);
14726
+ const ps = id === "" ? void 0 : specs.get(pl.entry.so.objectId)?.parts.find((p) => p.id === id);
14727
+ if (ps) {
14728
+ const r0 = ps.rotation ?? ZERO;
14729
+ world.setPartPose(
14730
+ pl.entry.so.objectId,
14731
+ id,
14732
+ [ps.position[0] + pose.position[0], ps.position[1] + pose.position[1], ps.position[2] + pose.position[2]],
14733
+ [r0[0] + pose.rotation[0], r0[1] + pose.rotation[1], r0[2] + pose.rotation[2]]
14734
+ );
14735
+ }
14406
14736
  }
14407
14737
  };
14408
14738
  const updateAnimations = (dt) => {
@@ -14490,8 +14820,14 @@ void main() {
14490
14820
  partIds = probe.describe().parts.map((p) => p.id);
14491
14821
  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
14822
  for (const p of r.problems) fail(`${item.id}: ${p}`);
14493
- } else {
14494
- partIds = resolveSpec(item, root).spec.parts.map((p) => p.id);
14823
+ }
14824
+ if (!item.impl || item.spec) {
14825
+ const spec = resolveSpec(item, root).spec;
14826
+ if (!item.impl) partIds = spec.parts.map((p) => p.id);
14827
+ const r = checkSpec(spec, { materials: world.materials.keys() });
14828
+ 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);
14829
+ for (const p of r.problems) fail(`${item.id}: ${p}`);
14830
+ 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
14831
  }
14496
14832
  } catch (err) {
14497
14833
  const msg = err instanceof Error ? err.message : String(err);
@@ -14530,6 +14866,8 @@ void main() {
14530
14866
  const all = list ?? allScenarios();
14531
14867
  const report = { passed: 0, failed: 0, results: [] };
14532
14868
  scenarioRunning = true;
14869
+ const wasPaused = paused;
14870
+ paused = false;
14533
14871
  try {
14534
14872
  for (const sc of all) {
14535
14873
  hardReset();
@@ -14561,6 +14899,7 @@ void main() {
14561
14899
  }
14562
14900
  } finally {
14563
14901
  scenarioRunning = false;
14902
+ paused = wasPaused;
14564
14903
  }
14565
14904
  return report;
14566
14905
  };
@@ -14623,6 +14962,19 @@ void main() {
14623
14962
  heldTilt = rad;
14624
14963
  },
14625
14964
  toggleTap,
14965
+ pause() {
14966
+ paused = true;
14967
+ },
14968
+ resume() {
14969
+ paused = false;
14970
+ },
14971
+ get paused() {
14972
+ return paused;
14973
+ },
14974
+ walk(to, seconds) {
14975
+ walkPlan = seconds > 0 ? { x: to[0], z: to[2], remaining: seconds } : null;
14976
+ if (!walkPlan) player.moveBy(to[0] - camera.position.x, to[2] - camera.position.z);
14977
+ },
14626
14978
  setSlowMotion(on) {
14627
14979
  timeScale = on ? 0.25 : 1;
14628
14980
  },