@adatechnology/game-test-room 0.18.0 → 0.19.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.
package/dist/index.js CHANGED
@@ -5,9 +5,9 @@ import { createDestructionRenderer, createDestructionSound, dragSoundKey, impact
5
5
  import { createArsenal, DEFAULT_LOADOUT } from "@adatechnology/game-weapons";
6
6
  import { createFilmLcg, createFluids, createItemUse, ITEM_USES, resolveStrings as resolveFluidStrings } from "@adatechnology/game-fluids";
7
7
  import { createFilmBridge, createFilmManager, createFluidRenderer } from "@adatechnology/game-fluids-three";
8
- import { createVfx } from "@adatechnology/game-vfx";
8
+ import { SPRITE_KINDS, createVfx, patchSpriteKinds } from "@adatechnology/game-vfx";
9
9
  import { createVfxRenderer } from "@adatechnology/game-vfx-three";
10
- import { checkAnimation, checkBench, checkDestructible, checkSpec, createEnvironmentBus, shotExtras, useToHit } from "@adatechnology/game-contracts";
10
+ import { checkAnimation, checkBench, checkDestructible, checkSpec, createEnvironmentBus, sealBlock, sealOpener, shotExtras, twistStroke, TWIST_LIFT, useToHit } from "@adatechnology/game-contracts";
11
11
 
12
12
  // src/arena.ts
13
13
  import { markKitOrigin } from "@adatechnology/game-contracts";
@@ -110,7 +110,7 @@ function resolveScenery(scenery) {
110
110
  if (!position) throw new Error(`game-test-room: scenery "${id}" is new, so it needs a position`);
111
111
  merged.set(id, { ...obj, position });
112
112
  }
113
- return [...merged.entries()].map(([id, o]) => ({ id, spec: o.spec, position: o.position, rotationY: o.rotationY ?? 0, colors: o.colors, build: o.build, follow: o.follow, label: o.label, top: o.top, spout: o.spout, free: o.free }));
113
+ return [...merged.entries()].map(([id, o]) => ({ id, spec: o.spec, position: o.position, rotationY: o.rotationY ?? 0, colors: o.colors, build: o.build, follow: o.follow, label: o.label, top: o.top, spout: o.spout, free: o.free, pushable: o.pushable }));
114
114
  }
115
115
  function createArena(THREE, size, scene, scenery, tapOption) {
116
116
  const group = markKitOrigin(new THREE.Group(), { owner: "game-test-room", kind: "room" });
@@ -435,8 +435,8 @@ function buildFromSpec(THREE, spec, options = {}) {
435
435
  const mats = /* @__PURE__ */ new Map();
436
436
  const materialFor = (p) => {
437
437
  const color = options.colors?.[p.id] ?? options.colors?.[p.material] ?? MATERIAL_COLORS[p.material] ?? 8947848;
438
- const key = `${p.material}:${color}`;
439
- let m = mats.get(key);
438
+ const key2 = `${p.material}:${color}`;
439
+ let m = mats.get(key2);
440
440
  if (!m) {
441
441
  const alpha = TRANSPARENT[p.material];
442
442
  const metal = p.material === "steel" || p.material === "aluminium" || p.material === "copper" || p.material === "cast_iron";
@@ -448,7 +448,7 @@ function buildFromSpec(THREE, spec, options = {}) {
448
448
  opacity: alpha ?? 1,
449
449
  depthWrite: !alpha
450
450
  });
451
- mats.set(key, m);
451
+ mats.set(key2, m);
452
452
  }
453
453
  return m;
454
454
  };
@@ -472,6 +472,7 @@ function buildFromSpec(THREE, spec, options = {}) {
472
472
  }
473
473
 
474
474
  // src/explosives.ts
475
+ import { releaseObject3D } from "@adatechnology/game-shapes-three";
475
476
  import { ExplosiveBody, backblastEffect, blastEffect, createExplosiveBody, tripwireSegment } from "@adatechnology/game-weapons";
476
477
 
477
478
  // src/segment-box.ts
@@ -514,9 +515,13 @@ function createExplosives(context) {
514
515
  const projectiles = [];
515
516
  const meshes = [];
516
517
  const up = new THREE.Vector3(0, 1, 0);
518
+ const drop = (objects) => {
519
+ for (const object of objects) object.removeFromParent();
520
+ releaseObject3D(objects, { keep: scene });
521
+ };
517
522
  const clear = () => {
518
523
  projectiles.length = 0;
519
- for (const mesh of meshes) mesh.removeFromParent();
524
+ drop(meshes);
520
525
  meshes.length = 0;
521
526
  };
522
527
  const addMesh = (geometry, color, metalness, at) => {
@@ -540,13 +545,12 @@ function createExplosives(context) {
540
545
  };
541
546
  const remove = (projectile) => {
542
547
  for (const extra of projectile.attached ?? []) {
543
- extra.removeFromParent();
544
548
  const extraAt = meshes.indexOf(extra);
545
549
  if (extraAt >= 0) meshes.splice(extraAt, 1);
546
550
  }
547
- projectile.mesh.removeFromParent();
548
551
  const meshAt = meshes.indexOf(projectile.mesh);
549
552
  if (meshAt >= 0) meshes.splice(meshAt, 1);
553
+ drop([...projectile.attached ?? [], projectile.mesh]);
550
554
  const at = projectiles.indexOf(projectile);
551
555
  if (at >= 0) projectiles.splice(at, 1);
552
556
  };
@@ -569,8 +573,8 @@ function createExplosives(context) {
569
573
  age: 0,
570
574
  ...use.explosive !== void 0 ? { body: createExplosiveBody(use, arsenal.explosives, arsenal.rules.explosives), aligned: isRocket } : {}
571
575
  });
572
- const key = isRocket ? "act.launch" : isMolotov ? "act.throw.molotov" : "act.throw.grenade";
573
- action(isRocket ? "launch_projectile" : "throw_projectile", translate(key, { weapon: context.toolName(), fuse: format(use.fuse, 1), arm: format(use.armDistance ?? 0, 0) }), {
576
+ const key2 = isRocket ? "act.launch" : isMolotov ? "act.throw.molotov" : "act.throw.grenade";
577
+ action(isRocket ? "launch_projectile" : "throw_projectile", translate(key2, { weapon: context.toolName(), fuse: format(use.fuse, 1), arm: format(use.armDistance ?? 0, 0) }), {
574
578
  data: { weapon: context.toolId(), fuse: use.fuse, explosive: use.explosive }
575
579
  });
576
580
  };
@@ -1229,9 +1233,9 @@ function createLog(options = {}) {
1229
1233
  };
1230
1234
  const log = {
1231
1235
  add,
1232
- coalesce(key, entry, windowS = 1) {
1236
+ coalesce(key2, entry, windowS = 1) {
1233
1237
  const now = entry.t ?? clock();
1234
- const k = keyed.get(key);
1238
+ const k = keyed.get(key2);
1235
1239
  if (k && now - k.last <= windowS && list.includes(k.entry)) {
1236
1240
  const { t: _t, ...rest } = entry;
1237
1241
  k.count++;
@@ -1242,7 +1246,7 @@ function createLog(options = {}) {
1242
1246
  return k.entry;
1243
1247
  }
1244
1248
  const e = add(entry);
1245
- keyed.set(key, { entry: e, last: now, count: 1 });
1249
+ keyed.set(key2, { entry: e, last: now, count: 1 });
1246
1250
  return e;
1247
1251
  },
1248
1252
  setBeforeRead(fn) {
@@ -1378,7 +1382,7 @@ function createNarrator(c) {
1378
1382
  if (p) materialMemo.set(`${objectId}|${partId}`, p.material);
1379
1383
  return p;
1380
1384
  };
1381
- const plural = (key, n) => t(`${key}.${n === 1 ? "one" : "other"}`, { n });
1385
+ const plural = (key2, n) => t(`${key2}.${n === 1 ? "one" : "other"}`, { n });
1382
1386
  const pieces = (n) => plural("count.piece", n);
1383
1387
  const joints = (n) => plural("count.joint", n);
1384
1388
  const xz = (x, z) => t("coords", { x: num(x, 1), z: num(z, 1) });
@@ -1442,18 +1446,18 @@ function createNarrator(c) {
1442
1446
  if (ev.by === "part_lost") {
1443
1447
  const [a, b] = ev.parts;
1444
1448
  const lost = isLost(ev.object, a) && !isLost(ev.object, b) ? a : isLost(ev.object, b) && !isLost(ev.object, a) ? b : isLost(ev.object, a) ? a : b;
1445
- const key = `${ev.object}|${lost}`;
1446
- const g = lostJoints.get(key) ?? { obj: ev.object, part: lost, joints: 0, groups: 0, merged: false };
1449
+ const key2 = `${ev.object}|${lost}`;
1450
+ const g = lostJoints.get(key2) ?? { obj: ev.object, part: lost, joints: 0, groups: 0, merged: false };
1447
1451
  g.joints++;
1448
1452
  g.groups = ev.groups.length;
1449
- lostJoints.set(key, g);
1453
+ lostJoints.set(key2, g);
1450
1454
  } else {
1451
- const key = `${ev.object}|${ev.by}`;
1452
- const g = openJoints.get(key) ?? { obj: ev.object, by: ev.by, list: [], groups: 0, done: false };
1455
+ const key2 = `${ev.object}|${ev.by}`;
1456
+ const g = openJoints.get(key2) ?? { obj: ev.object, by: ev.by, list: [], groups: 0, done: false };
1453
1457
  const [a, b] = ev.parts;
1454
1458
  g.list.push({ joint: ev.joint, a, b });
1455
1459
  g.groups = ev.groups.length;
1456
- openJoints.set(key, g);
1460
+ openJoints.set(key2, g);
1457
1461
  }
1458
1462
  } else if (ev.type === "ignite") {
1459
1463
  const g = fireParts.get(ev.object) ?? { obj: ev.object, parts: [], temp: 0, done: false };
@@ -1466,14 +1470,14 @@ function createNarrator(c) {
1466
1470
  outParts.set(ev.object, g);
1467
1471
  }
1468
1472
  }
1469
- const lostTail = (key) => {
1470
- const g = lostJoints.get(key);
1473
+ const lostTail = (key2) => {
1474
+ const g = lostJoints.get(key2);
1471
1475
  if (!g) return "";
1472
1476
  g.merged = true;
1473
1477
  return t("ev.lost.tail", { joints: joints(g.joints), pieces: pieces(g.groups) });
1474
1478
  };
1475
- const lostData = (key) => {
1476
- const g = lostJoints.get(key);
1479
+ const lostData = (key2) => {
1480
+ const g = lostJoints.get(key2);
1477
1481
  return g ? { jointsOpened: g.joints, pieces: g.groups } : {};
1478
1482
  };
1479
1483
  for (const ev of batch) {
@@ -1597,8 +1601,8 @@ function createNarrator(c) {
1597
1601
  if (ev.by === "part_lost") {
1598
1602
  const [a2, b2] = ev.parts;
1599
1603
  const lost = isLost(ev.object, a2) && !isLost(ev.object, b2) ? a2 : isLost(ev.object, b2) && !isLost(ev.object, a2) ? b2 : isLost(ev.object, a2) ? a2 : b2;
1600
- const key2 = `${ev.object}|${lost}`;
1601
- const g2 = lostJoints.get(key2);
1604
+ const key3 = `${ev.object}|${lost}`;
1605
+ const g2 = lostJoints.get(key3);
1602
1606
  if (!g2 || g2.merged) break;
1603
1607
  const hasOwnLine = batch.some((e) => (e.type === "fracture" || e.type === "burnout") && e.object === ev.object && e.part === lost);
1604
1608
  if (hasOwnLine) break;
@@ -1609,12 +1613,12 @@ function createNarrator(c) {
1609
1613
  ...base(ev, lost),
1610
1614
  type: "detach",
1611
1615
  text: t("ev.lost.alone", { where: where(ev.object, lost), verb, joints: joints(g2.joints), pieces: pieces(g2.groups) }),
1612
- data: { parts: [a2, b2], by: ev.by, ...lostData(key2) }
1616
+ data: { parts: [a2, b2], by: ev.by, ...lostData(key3) }
1613
1617
  });
1614
1618
  break;
1615
1619
  }
1616
- const key = `${ev.object}|${ev.by}`;
1617
- const g = openJoints.get(key);
1620
+ const key2 = `${ev.object}|${ev.by}`;
1621
+ const g = openJoints.get(key2);
1618
1622
  if (!g || g.done) break;
1619
1623
  g.done = true;
1620
1624
  const [a, b] = ev.parts;
@@ -1767,13 +1771,13 @@ function createNarrator(c) {
1767
1771
  };
1768
1772
  const kindMemo = /* @__PURE__ */ new Map();
1769
1773
  const kindOf = (objectId, jointId) => {
1770
- const key = `${objectId}|${jointId}`;
1774
+ const key2 = `${objectId}|${jointId}`;
1771
1775
  const own = c.world.state(objectId)?.joints.find((j) => j.id === jointId)?.kind;
1772
1776
  if (own) {
1773
- kindMemo.set(key, own);
1777
+ kindMemo.set(key2, own);
1774
1778
  return own;
1775
1779
  }
1776
- const known = kindMemo.get(key);
1780
+ const known = kindMemo.get(key2);
1777
1781
  if (known) return known;
1778
1782
  for (const id of c.world.ids()) {
1779
1783
  const k = c.world.state(id)?.joints.find((j) => j.id === jointId)?.kind;
@@ -1809,6 +1813,7 @@ function createNarrator(c) {
1809
1813
  break;
1810
1814
  }
1811
1815
  case "drink": {
1816
+ if (ev.eaten) break;
1812
1817
  const now = c.now();
1813
1818
  let acc = drinkAcc.get(ev.container);
1814
1819
  if (!acc || now - acc.last > 0.5) acc = { liters: 0, thirst: 0, burn: false, effects: /* @__PURE__ */ new Set(), last: now, start: now };
@@ -1842,12 +1847,12 @@ function createNarrator(c) {
1842
1847
  const surface = t(`surface.${ev.surface}`);
1843
1848
  const liquid = c.liquidName(ev.liquid);
1844
1849
  stainInfo.set(ev.stain, { liquid: ev.liquid, surface, x: ev.point[0], z: ev.point[2] });
1845
- const key = `stain:${ev.liquid}:${ev.surface}`;
1846
- const acc = stainAcc.get(key);
1850
+ const key2 = `stain:${ev.liquid}:${ev.surface}`;
1851
+ const acc = stainAcc.get(key2);
1847
1852
  const n = acc && c.now() - acc.last <= 1 ? acc.n + 1 : 1;
1848
- stainAcc.set(key, { n, last: c.now() });
1853
+ stainAcc.set(key2, { n, last: c.now() });
1849
1854
  c.log.coalesce(
1850
- key,
1855
+ key2,
1851
1856
  {
1852
1857
  channel: "event",
1853
1858
  type: f + "stain",
@@ -1860,13 +1865,13 @@ function createNarrator(c) {
1860
1865
  }
1861
1866
  case "dried": {
1862
1867
  const info = stainInfo.get(ev.stain);
1863
- const key = `dried:${info?.liquid ?? "?"}:${ev.mark}`;
1864
- const acc = driedAcc.get(key);
1868
+ const key2 = `dried:${info?.liquid ?? "?"}:${ev.mark}`;
1869
+ const acc = driedAcc.get(key2);
1865
1870
  const n = acc && c.now() - acc.last <= 1.5 ? acc.n + 1 : 1;
1866
- driedAcc.set(key, { n, last: c.now() });
1871
+ driedAcc.set(key2, { n, last: c.now() });
1867
1872
  const vars = { liquid: info ? c.liquidName(info.liquid) : "", surface: info?.surface ?? "", where: info ? xz(info.x, info.z) : "", n };
1868
1873
  const text = !info ? t(ev.mark ? "fl.dried.mark" : "fl.dried") : n > 1 ? t(ev.mark ? "fl.dried.many.mark" : "fl.dried.many", vars) : t(ev.mark ? "fl.dried.known.mark" : "fl.dried.known", vars);
1869
- c.log.coalesce(key, { channel: "event", type: f + "dried", text, data: { stain: ev.stain, mark: ev.mark, count: n } }, 1.5);
1874
+ c.log.coalesce(key2, { channel: "event", type: f + "dried", text, data: { stain: ev.stain, mark: ev.mark, count: n } }, 1.5);
1870
1875
  break;
1871
1876
  }
1872
1877
  case "mix":
@@ -1979,6 +1984,7 @@ function createPlayer(THREE, o) {
1979
1984
  let useStarted = false;
1980
1985
  let holdTimer = null;
1981
1986
  let walkHeld = false;
1987
+ let seated = false;
1982
1988
  let lookLimit = null;
1983
1989
  const look = (dx, dy) => {
1984
1990
  yaw -= dx * LOOK;
@@ -2119,6 +2125,11 @@ function createPlayer(THREE, o) {
2119
2125
  setWalkHeld(held) {
2120
2126
  walkHeld = held;
2121
2127
  },
2128
+ setSeated(value) {
2129
+ seated = value;
2130
+ y = 0;
2131
+ vy = 0;
2132
+ },
2122
2133
  setBend(metres) {
2123
2134
  bendTarget = metres;
2124
2135
  },
@@ -2171,6 +2182,11 @@ function createPlayer(THREE, o) {
2171
2182
  if ((walkX || walkZ) && !walkHeld) move(walkX * speed * dt, walkZ * speed * dt);
2172
2183
  walked = dt > 0 ? Math.hypot(px.x - fromX, px.z - fromZ) / dt : 0;
2173
2184
  running = run && walked > 0;
2185
+ if (seated) {
2186
+ followFloor(dt);
2187
+ apply();
2188
+ return;
2189
+ }
2174
2190
  const floor = Math.max(floorHere(), o.footing?.(px.x, px.z, PLAYER_RADIUS * FOOT, y).floor ?? -Infinity);
2175
2191
  if (y < floor && vy <= 0) y = floor;
2176
2192
  if (y <= floor && keys.has("Space") && !walkHeld) vy = JUMP;
@@ -2318,7 +2334,7 @@ function createRoomPush(options) {
2318
2334
  }
2319
2335
  control.release();
2320
2336
  player.setWalkHeld(false);
2321
- player.setBend(0);
2337
+ if (!options.hasBody?.()) player.setBend(0);
2322
2338
  player.setLookLimit(null);
2323
2339
  options.ring(null);
2324
2340
  event("release", lean);
@@ -2380,7 +2396,7 @@ function createRoomPush(options) {
2380
2396
  control.begin(material === void 0 ? { massKg } : { massKg, material });
2381
2397
  dragged = 0;
2382
2398
  player.setWalkHeld(true);
2383
- if (isLow) player.setBend(Math.min(MAX_BEND, Math.max(0, STANDING_EYE - (max[1] - player.feet + LOW_EYE_OVER_TOP))));
2399
+ if (isLow && !options.hasBody?.()) player.setBend(Math.min(MAX_BEND, Math.max(0, STANDING_EYE - (max[1] - player.feet + LOW_EYE_OVER_TOP))));
2384
2400
  player.setLookLimit({ yaw: Math.atan2(-into[0], -into[1]), range: LOOK_RANGE });
2385
2401
  event("grip", leaning);
2386
2402
  return true;
@@ -2440,7 +2456,503 @@ function createRoomPush(options) {
2440
2456
  };
2441
2457
  }
2442
2458
 
2459
+ // src/seating.ts
2460
+ var SIT_EYE = 0.62;
2461
+ var LIE_EYE = 0.18;
2462
+ var STANDING_EYE2 = 1.7;
2463
+ var LOOK_RANGE2 = 1.6;
2464
+ function createSeating(o) {
2465
+ const { THREE, player } = o;
2466
+ const v = new THREE.Vector3();
2467
+ const q = new THREE.Quaternion();
2468
+ const e = new THREE.Euler();
2469
+ let current = null;
2470
+ let facing = 0;
2471
+ const toWorld = (root, local) => {
2472
+ root.updateWorldMatrix(true, false);
2473
+ v.set(local[0], local[1], local[2]).applyMatrix4(root.matrixWorld);
2474
+ return [v.x, v.y, v.z];
2475
+ };
2476
+ const yawOf = (root) => {
2477
+ root.getWorldQuaternion(q);
2478
+ return e.setFromQuaternion(q, "YXZ").y;
2479
+ };
2480
+ const nearest = (holder) => {
2481
+ let best;
2482
+ let bestDistance = Infinity;
2483
+ for (const seat of holder.seats) {
2484
+ const [x, , z] = toWorld(holder.root, seat.pos);
2485
+ const distance = Math.hypot(x - player.x, z - player.z);
2486
+ if (distance < bestDistance) {
2487
+ best = seat;
2488
+ bestDistance = distance;
2489
+ }
2490
+ }
2491
+ return best;
2492
+ };
2493
+ const hold = () => {
2494
+ player.setSeated(true);
2495
+ player.setWalkHeld(true);
2496
+ player.setLookLimit({ yaw: facing, range: LOOK_RANGE2 });
2497
+ };
2498
+ const release = () => {
2499
+ player.setSeated(false);
2500
+ player.setWalkHeld(false);
2501
+ player.setLookLimit(null);
2502
+ current = null;
2503
+ };
2504
+ const seating = {
2505
+ sit(objectId, seatId) {
2506
+ const holder = objectId ? o.find(objectId) : o.pick();
2507
+ if (!holder) return null;
2508
+ const seat = seatId ? holder.seats.find((s) => s.id === seatId) : nearest(holder);
2509
+ if (!seat) return null;
2510
+ const [x, y, z] = toWorld(holder.root, seat.pos);
2511
+ const eye = o.hasBody?.() ? STANDING_EYE2 : seat.eye ?? (seat.pose === "lie" ? LIE_EYE : SIT_EYE);
2512
+ facing = yawOf(holder.root) + seat.ry + Math.PI;
2513
+ player.setPose([x, y + eye, z], facing, 0);
2514
+ current = { objectId: holder.objectId, seatId: seat.id, pose: seat.pose, hips: [x, y, z] };
2515
+ hold();
2516
+ o.onSit?.(current);
2517
+ return current;
2518
+ },
2519
+ stand() {
2520
+ if (!current) return false;
2521
+ const holder = o.find(current.objectId);
2522
+ const seat = holder?.seats.find((s) => s.id === current?.seatId);
2523
+ if (holder && seat) {
2524
+ const [x, , z] = toWorld(holder.root, seat.exit);
2525
+ if (!player.canStand(x, z)) return false;
2526
+ player.setPose([x, o.groundAt(x, z) + STANDING_EYE2, z], player.yaw, 0);
2527
+ } else player.setPose([player.x, o.groundAt(player.x, player.z) + STANDING_EYE2, player.z], player.yaw, 0);
2528
+ const left = current;
2529
+ release();
2530
+ o.onStand?.(left);
2531
+ return true;
2532
+ },
2533
+ update() {
2534
+ if (!current) return;
2535
+ if (!o.find(current.objectId)) {
2536
+ seating.stand();
2537
+ return;
2538
+ }
2539
+ const [walkX, walkZ] = player.walkIntent;
2540
+ if (walkX || walkZ || player.keys.has("Space")) {
2541
+ seating.stand();
2542
+ return;
2543
+ }
2544
+ hold();
2545
+ },
2546
+ release,
2547
+ get seat() {
2548
+ return current;
2549
+ }
2550
+ };
2551
+ return seating;
2552
+ }
2553
+
2554
+ // src/door-open.ts
2555
+ import { createDoorUse, DOOR_USE_DEFAULTS } from "@adatechnology/game-player";
2556
+ var DOOR_REACH = 2.2;
2557
+ var NEAR_LEAF = 0.25;
2558
+ var NEAR_OPEN_LEAF = 0.9;
2559
+ var BODY_HEIGHT = 1.8;
2560
+ var HAND_REST = [0.22, -0.42, -0.32];
2561
+ var SKIN = 0.01;
2562
+ var HANDLE_INSET = 0.06;
2563
+ var STANDOFF2 = 0.55;
2564
+ var DRAWER_TRAVEL = 0.3;
2565
+ var CLEAR_OF_ARC = 0.3;
2566
+ var OUT_IN_FRONT = 0.42;
2567
+ var ARRIVED = 0.05;
2568
+ var TURN_RATE = 8;
2569
+ var MAX_WALK_SECONDS = 1.5;
2570
+ var APPROACH_SPEED2 = 3;
2571
+ var FLOOR_GAP = 0.03;
2572
+ var key = (objectId, part) => `${objectId}\0${part}`;
2573
+ var slides = (clip, part) => {
2574
+ const last = clip.keys[clip.keys.length - 1]?.parts?.[part];
2575
+ const r = last?.rotation ?? [0, 0, 0];
2576
+ return Math.abs(r[0]) + Math.abs(r[1]) + Math.abs(r[2]) < 1e-3;
2577
+ };
2578
+ function createRoomDoors(options) {
2579
+ const { THREE, player, world, camera, arena } = options;
2580
+ const control = createDoorUse(options.rules);
2581
+ const openings = /* @__PURE__ */ new Map();
2582
+ const box2 = new THREE.Box3();
2583
+ const before = new THREE.Box3();
2584
+ const tmp = new THREE.Vector3();
2585
+ const centre = new THREE.Vector3();
2586
+ let using = null;
2587
+ let reaching = null;
2588
+ const rules = { ...DOOR_USE_DEFAULTS, ...options.rules };
2589
+ const holding = () => {
2590
+ if (reaching) {
2591
+ const h = reaching.reach.handle();
2592
+ return { phase: reaching.phase, progress: reaching.progress, at: new THREE.Vector3(h[0], h[1], h[2]), pull: reaching.reach.pull ?? true };
2593
+ }
2594
+ if (!using || control.phase === "idle") return null;
2595
+ using.obj.updateMatrixWorld(true);
2596
+ return { phase: control.phase, progress: using.progress, at: using.obj.localToWorld(new THREE.Vector3().copy(using.grip)), pull: using.pull };
2597
+ };
2598
+ const standFor = (handle, hinge, slide) => {
2599
+ if (!hinge || slide) {
2600
+ const dx = handle.x - player.x;
2601
+ const dz = handle.z - player.z;
2602
+ const d = Math.hypot(dx, dz) || 1;
2603
+ const off = slide ? STANDOFF2 + DRAWER_TRAVEL : STANDOFF2;
2604
+ return [handle.x - dx / d * off, handle.z - dz / d * off];
2605
+ }
2606
+ let ax = handle.x - hinge.x;
2607
+ let az = handle.z - hinge.z;
2608
+ const width = Math.hypot(ax, az);
2609
+ if (width < 0.05) return standFor(handle, null, false);
2610
+ ax /= width;
2611
+ az /= width;
2612
+ let nx = -az;
2613
+ let nz = ax;
2614
+ if ((player.x - handle.x) * nx + (player.z - handle.z) * nz < 0) {
2615
+ nx = -nx;
2616
+ nz = -nz;
2617
+ }
2618
+ const x = hinge.x + ax * (width + CLEAR_OF_ARC) + nx * OUT_IN_FRONT;
2619
+ const z = hinge.z + az * (width + CLEAR_OF_ARC) + nz * OUT_IN_FRONT;
2620
+ return player.canStand(x, z) ? [x, z] : standFor(handle, null, false);
2621
+ };
2622
+ const face = (handle, dt) => {
2623
+ camera.updateMatrixWorld(true);
2624
+ const eye = camera.getWorldPosition(new THREE.Vector3());
2625
+ const dx = handle.x - player.x;
2626
+ const dz = handle.z - player.z;
2627
+ const flat = Math.hypot(dx, dz);
2628
+ if (flat < 0.05) return;
2629
+ const yaw = Math.atan2(-dx, -dz);
2630
+ const pitch = Math.atan2(handle.y - eye.y, flat);
2631
+ let dYaw = yaw - player.yaw;
2632
+ dYaw = Math.atan2(Math.sin(dYaw), Math.cos(dYaw));
2633
+ const k = Math.min(1, dt * TURN_RATE);
2634
+ player.setPose([player.x, eye.y, player.z], player.yaw + dYaw * k, player.pitch + (pitch - player.pitch) * k);
2635
+ };
2636
+ const approach = (handle, hinge, slide, dt, walked) => {
2637
+ const [x, z] = standFor(handle, hinge, slide);
2638
+ const dx = x - player.x;
2639
+ const dz = z - player.z;
2640
+ const d = Math.hypot(dx, dz);
2641
+ if (d < ARRIVED || walked > MAX_WALK_SECONDS) return true;
2642
+ const go = Math.min(d, APPROACH_SPEED2 * dt);
2643
+ if (go < 1e-4) return false;
2644
+ const x0 = player.x;
2645
+ const z0 = player.z;
2646
+ player.moveBy(dx / d * go, dz / d * go);
2647
+ return Math.hypot(player.x - x0, player.z - z0) < go * 0.2;
2648
+ };
2649
+ const openingOf = (objectId, part) => openings.get(key(objectId, part)) ?? 0;
2650
+ const lastGoals = /* @__PURE__ */ new Map();
2651
+ const leafAt = (objectId, part, point) => {
2652
+ const leaves = options.leaves(objectId);
2653
+ if (leaves.length === 0) return null;
2654
+ const exact = leaves.find((l) => l.part === part);
2655
+ if (exact) return exact;
2656
+ let best = null;
2657
+ let bestD = NEAR_LEAF;
2658
+ let open = null;
2659
+ let openD = NEAR_OPEN_LEAF;
2660
+ for (const l of leaves) {
2661
+ const obj = options.objectOf(objectId, l.part);
2662
+ if (!obj) continue;
2663
+ box2.setFromObject(obj);
2664
+ const d = box2.distanceToPoint(tmp.set(point[0], point[1], point[2]));
2665
+ if (d < bestD) {
2666
+ bestD = d;
2667
+ best = l;
2668
+ }
2669
+ if (openingOf(objectId, l.part) > 0.5 && d < openD) {
2670
+ openD = d;
2671
+ open = l;
2672
+ }
2673
+ }
2674
+ return best ?? open;
2675
+ };
2676
+ const picked = () => {
2677
+ for (const hit of options.pick()) {
2678
+ const leaf = leafAt(hit.objectId, hit.part, hit.point);
2679
+ if (leaf) return { hit, leaf };
2680
+ }
2681
+ return null;
2682
+ };
2683
+ const massOf = (objectId, part) => world.part(objectId, part)?.mass ?? 10;
2684
+ const event = (type, u, by) => options.onEvent({ type, objectId: u.objectId, part: u.leaf.part, name: options.nameOf(u.objectId), opening: openingOf(u.objectId, u.leaf.part), ...by ? { by } : {} });
2685
+ const overlaps2d = (b, c) => b.max.x > c[0] && b.min.x < c[2] && b.max.z > c[1] && b.min.z < c[3];
2686
+ const bodyOverlap = (b) => {
2687
+ if (b.min.y > player.feet + BODY_HEIGHT || b.max.y < player.feet + 0.05) return null;
2688
+ const cx = Math.max(b.min.x, Math.min(b.max.x, player.x));
2689
+ const cz = Math.max(b.min.z, Math.min(b.max.z, player.z));
2690
+ const dx = player.x - cx;
2691
+ const dz = player.z - cz;
2692
+ const d = Math.hypot(dx, dz);
2693
+ if (d >= PLAYER_RADIUS) return null;
2694
+ if (d < 1e-4) {
2695
+ const outs = [
2696
+ [b.max.x - player.x, 1, 0],
2697
+ [player.x - b.min.x, -1, 0],
2698
+ [b.max.z - player.z, 0, 1],
2699
+ [player.z - b.min.z, 0, -1]
2700
+ ];
2701
+ outs.sort((a, c) => a[0] - c[0]);
2702
+ const o = outs[0];
2703
+ return [o[1] * (o[0] + PLAYER_RADIUS), o[2] * (o[0] + PLAYER_RADIUS)];
2704
+ }
2705
+ const push = PLAYER_RADIUS - d + SKIN;
2706
+ return [dx / d * push, dz / d * push];
2707
+ };
2708
+ const isFree = (u, from, to) => {
2709
+ const { objectId, leaf, obj } = u;
2710
+ obj.updateMatrixWorld(true);
2711
+ before.setFromObject(obj);
2712
+ options.pose(objectId, leaf.part, leaf.clip, to);
2713
+ obj.updateMatrixWorld(true);
2714
+ box2.setFromObject(obj);
2715
+ box2.expandByScalar(-SKIN);
2716
+ let by = null;
2717
+ if (Math.max(Math.abs(box2.min.x), Math.abs(box2.max.x), Math.abs(box2.min.z), Math.abs(box2.max.z)) > arena.limit) by = "wall";
2718
+ else
2719
+ for (let i = 0; i < arena.colliders.length; i++) {
2720
+ const c = arena.colliders[i];
2721
+ if (arena.ids[i] === objectId) continue;
2722
+ if (overlaps2d(box2, c) && !overlaps2d(before, c)) {
2723
+ by = arena.ids[i] ?? "wall";
2724
+ break;
2725
+ }
2726
+ }
2727
+ if (!by) {
2728
+ before.getCenter(centre);
2729
+ box2.getCenter(tmp);
2730
+ const dir = [tmp.x - centre.x, tmp.y - centre.y, tmp.z - centre.z];
2731
+ const len = Math.hypot(dir[0], dir[1], dir[2]);
2732
+ if (len > 1e-5) {
2733
+ const hit = world.sweepBox({ min: [before.min.x + SKIN, before.min.y + FLOOR_GAP, before.min.z + SKIN], max: [before.max.x - SKIN, before.max.y - SKIN, before.max.z - SKIN] }, dir, len * 2, { ignore: objectId, spacing: 0.12 });
2734
+ if (hit && hit.normal[1] < 0.7) by = hit.object;
2735
+ }
2736
+ }
2737
+ if (!by) {
2738
+ const out = bodyOverlap(box2);
2739
+ if (out) {
2740
+ const x0 = player.x;
2741
+ const z0 = player.z;
2742
+ player.moveBy(out[0], out[1]);
2743
+ if (Math.hypot(player.x - x0, player.z - z0) < Math.hypot(out[0], out[1]) * 0.5) by = "player";
2744
+ else if (u.phase !== "swing" || from === 0) event("stepped_back", u);
2745
+ }
2746
+ }
2747
+ if (by) {
2748
+ options.pose(objectId, leaf.part, leaf.clip, from);
2749
+ const at = [box2.getCenter(tmp).x, tmp.y, tmp.z];
2750
+ options.sound(options.impactKey(objectId, leaf.part), at, Math.min(0.6, 0.15 + massOf(objectId, leaf.part) * 0.02));
2751
+ event("blocked", u, by);
2752
+ return false;
2753
+ }
2754
+ return true;
2755
+ };
2756
+ const handleOf = (obj, leaf, point) => {
2757
+ obj.updateMatrixWorld(true);
2758
+ box2.setFromObject(obj);
2759
+ const lo = new THREE.Vector3(Infinity, Infinity, Infinity);
2760
+ const hi = new THREE.Vector3(-Infinity, -Infinity, -Infinity);
2761
+ for (let i = 0; i < 8; i++) {
2762
+ tmp.set(i & 1 ? box2.max.x : box2.min.x, i & 2 ? box2.max.y : box2.min.y, i & 4 ? box2.max.z : box2.min.z);
2763
+ obj.worldToLocal(tmp);
2764
+ lo.min(tmp);
2765
+ hi.max(tmp);
2766
+ }
2767
+ const aimed = point ? obj.worldToLocal(new THREE.Vector3(point[0], point[1], point[2])) : lo.clone().add(hi).multiplyScalar(0.5);
2768
+ if (!point) {
2769
+ const local = obj.worldToLocal(obj.getWorldPosition(new THREE.Vector3()).setY(player.feet + 1));
2770
+ aimed.y = local.y;
2771
+ }
2772
+ aimed.clamp(lo, hi);
2773
+ if (slides(leaf.clip, leaf.part)) return aimed;
2774
+ const last = leaf.clip.keys[leaf.clip.keys.length - 1]?.parts?.[leaf.part]?.rotation ?? [0, 1, 0];
2775
+ const axis = Math.abs(last[0]) >= Math.abs(last[1]) && Math.abs(last[0]) >= Math.abs(last[2]) ? 0 : Math.abs(last[1]) >= Math.abs(last[2]) ? 1 : 2;
2776
+ const size = [hi.x - lo.x, hi.y - lo.y, hi.z - lo.z];
2777
+ const reachOf = (k) => Math.max(Math.abs(lo.getComponent(k)), Math.abs(hi.getComponent(k)));
2778
+ let across = -1;
2779
+ for (let k = 0; k < 3; k++) {
2780
+ if (k === axis || size[k] < Math.min(size[0], size[1], size[2]) + 1e-6) continue;
2781
+ if (across < 0 || reachOf(k) > reachOf(across)) across = k;
2782
+ }
2783
+ if (across < 0) return aimed;
2784
+ const far = Math.abs(hi.getComponent(across)) >= Math.abs(lo.getComponent(across)) ? hi.getComponent(across) : lo.getComponent(across);
2785
+ aimed.setComponent(across, far - Math.sign(far) * Math.min(HANDLE_INSET, size[across] * 0.25));
2786
+ return aimed;
2787
+ };
2788
+ const start = (objectId, leaf, point) => {
2789
+ reaching = null;
2790
+ const obj = options.objectOf(objectId, leaf.part);
2791
+ if (!obj) return false;
2792
+ if (using && (using.objectId !== objectId || using.leaf.part !== leaf.part)) control.cancel();
2793
+ obj.updateMatrixWorld(true);
2794
+ const grip = handleOf(obj, leaf, point);
2795
+ const opening = openingOf(objectId, leaf.part);
2796
+ const key2 = `${objectId}|${leaf.part}`;
2797
+ const lastGoal = lastGoals.get(key2);
2798
+ const goal = control.begin({ opening, massKg: massOf(objectId, leaf.part), locked: options.isLocked(objectId, leaf.part), ...lastGoal !== void 0 ? { lastGoal } : {} });
2799
+ if (goal !== null) lastGoals.set(key2, goal);
2800
+ let pull = true;
2801
+ if (goal !== null) {
2802
+ obj.updateMatrixWorld(true);
2803
+ options.pose(objectId, leaf.part, leaf.clip, 1);
2804
+ obj.updateMatrixWorld(true);
2805
+ const openAt = obj.localToWorld(grip.clone());
2806
+ options.pose(objectId, leaf.part, leaf.clip, 0);
2807
+ obj.updateMatrixWorld(true);
2808
+ const shutAt = obj.localToWorld(grip.clone());
2809
+ options.pose(objectId, leaf.part, leaf.clip, opening);
2810
+ const dOpen = Math.hypot(openAt.x - player.x, openAt.z - player.z);
2811
+ const dShut = Math.hypot(shutAt.x - player.x, shutAt.z - player.z);
2812
+ pull = goal === 1 ? dOpen <= dShut : dShut <= dOpen;
2813
+ }
2814
+ using = { objectId, leaf, obj, grip, pull, phase: control.phase, progress: 0, walked: 0, there: goal === null };
2815
+ const at = obj.localToWorld(grip.clone());
2816
+ if (goal === null) {
2817
+ options.sound("leaf_rattle", [at.x, at.y, at.z], 0.6);
2818
+ event("locked", using);
2819
+ } else event("reach", using);
2820
+ return true;
2821
+ };
2822
+ return {
2823
+ get active() {
2824
+ return reaching !== null || using !== null && control.phase !== "idle";
2825
+ },
2826
+ get state() {
2827
+ return using ? { objectId: using.objectId, part: using.leaf.part, phase: control.phase, opening: openingOf(using.objectId, using.leaf.part), goal: control.goal } : null;
2828
+ },
2829
+ get avatarState() {
2830
+ const h = holding();
2831
+ return h ? { phase: h.phase, progress: h.progress, height: h.at.y - player.feet, pull: h.pull } : null;
2832
+ },
2833
+ aimed() {
2834
+ const found = picked();
2835
+ if (!found) return null;
2836
+ const { hit, leaf } = found;
2837
+ return { objectId: hit.objectId, part: leaf.part, open: openingOf(hit.objectId, leaf.part) > 0.5, locked: options.isLocked(hit.objectId, leaf.part) };
2838
+ },
2839
+ use() {
2840
+ const found = picked();
2841
+ return found ? start(found.hit.objectId, found.leaf, found.hit.point) : false;
2842
+ },
2843
+ useLeaf(objectId, part) {
2844
+ const leaves = options.leaves(objectId);
2845
+ const leaf = part === void 0 ? leaves[0] : leaves.find((l) => l.part === part);
2846
+ return leaf ? start(objectId, leaf, null) : false;
2847
+ },
2848
+ opening: openingOf,
2849
+ step(dt) {
2850
+ const r = reaching;
2851
+ if (r) {
2852
+ r.time += dt;
2853
+ if (r.phase === "reach" && !r.there) {
2854
+ const h = r.reach.handle();
2855
+ const hinge = r.reach.hinge?.();
2856
+ r.walked += dt;
2857
+ const g = new THREE.Vector3(h[0], h[1], h[2]);
2858
+ r.there = approach(g, hinge ? new THREE.Vector3(hinge[0], hinge[1], hinge[2]) : null, false, dt, r.walked);
2859
+ face(g, dt);
2860
+ if (r.there) r.time = 0;
2861
+ } else if (r.phase === "reach") {
2862
+ r.progress = Math.min(1, r.time / rules.reachSeconds);
2863
+ if (r.progress >= 1) {
2864
+ r.reach.onGrip?.();
2865
+ r.phase = r.reach.locked ? "rattle" : "swing";
2866
+ r.time = 0;
2867
+ r.progress = 0;
2868
+ }
2869
+ } else if (r.phase === "swing" || r.phase === "rattle") {
2870
+ r.progress = Math.min(1, r.time / (r.phase === "rattle" ? rules.rattleSeconds : Math.max(0.05, r.reach.seconds)));
2871
+ if (r.progress >= 1) {
2872
+ r.phase = "release";
2873
+ r.time = 0;
2874
+ r.progress = 0;
2875
+ }
2876
+ } else {
2877
+ r.progress = Math.min(1, r.time / rules.releaseSeconds);
2878
+ if (r.progress >= 1) reaching = null;
2879
+ }
2880
+ return;
2881
+ }
2882
+ const u = using;
2883
+ if (!u || control.phase === "idle") return;
2884
+ const was = openingOf(u.objectId, u.leaf.part);
2885
+ const wasPhase = control.phase;
2886
+ if (wasPhase === "reach" && !u.there) {
2887
+ u.obj.updateMatrixWorld(true);
2888
+ u.walked += dt;
2889
+ const g = u.obj.localToWorld(new THREE.Vector3().copy(u.grip));
2890
+ u.there = approach(g, u.obj.getWorldPosition(new THREE.Vector3()), slides(u.leaf.clip, u.leaf.part), dt, u.walked);
2891
+ face(g, dt);
2892
+ if (!u.there) return;
2893
+ }
2894
+ const s = control.step(dt, (from, to) => isFree(u, from, to));
2895
+ u.phase = s.phase;
2896
+ u.progress = s.progress;
2897
+ openings.set(key(u.objectId, u.leaf.part), s.opening);
2898
+ u.obj.updateMatrixWorld(true);
2899
+ const at = u.obj.localToWorld(tmp.copy(u.grip));
2900
+ const point = [at.x, at.y, at.z];
2901
+ const slide = slides(u.leaf.clip, u.leaf.part);
2902
+ const shut = options.impactKey(u.objectId, u.leaf.part).replace("impact_", "leaf_shut_");
2903
+ if (wasPhase === "reach" && s.phase !== "reach") {
2904
+ if (slide) options.sound("leaf_slide", point, 0.5);
2905
+ else {
2906
+ if (was < 0.02) options.sound("leaf_latch", point, 0.5);
2907
+ options.sound(/metal|glass/.test(shut) ? "leaf_creak_metal" : "leaf_creak_wood", point, 0.35);
2908
+ }
2909
+ }
2910
+ if (s.arrived) {
2911
+ if (s.opening <= 0) {
2912
+ options.sound(slide ? "leaf_slide" : shut, point, Math.min(1, 0.3 + massOf(u.objectId, u.leaf.part) * 0.03));
2913
+ event("closed", u);
2914
+ } else event("opened", u);
2915
+ }
2916
+ if (s.phase === "idle") using = null;
2917
+ },
2918
+ handTargets() {
2919
+ const h = holding();
2920
+ if (!h || h.phase === "rattle" && h.progress > 0.92) return null;
2921
+ const { at, phase, progress } = h;
2922
+ if (phase === "rattle") at.y += Math.sin(progress * 60) * 0.012;
2923
+ camera.updateMatrixWorld(true);
2924
+ const rest = camera.localToWorld(new THREE.Vector3(HAND_REST[0], HAND_REST[1], HAND_REST[2]));
2925
+ const k = phase === "reach" ? progress : phase === "release" ? 1 - progress : 1;
2926
+ const ease = k * k * (3 - 2 * k);
2927
+ const pos = rest.lerp(at, ease);
2928
+ const way = new THREE.Vector3(at.x - player.x, 0, at.z - player.z);
2929
+ if (way.lengthSq() < 1e-6) way.set(-Math.sin(player.yaw), 0, -Math.cos(player.yaw));
2930
+ way.normalize();
2931
+ const down = new THREE.Vector3(0, -1, 0);
2932
+ const back = new THREE.Vector3().crossVectors(down, way);
2933
+ const q = new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(way, back, down));
2934
+ const grip = phase === "reach" ? 0.15 + 0.75 * progress : phase === "release" ? 0.9 * (1 - progress) : 0.9;
2935
+ return { R: { position: [pos.x, pos.y, pos.z], quaternion: [q.x, q.y, q.z, q.w] }, L: null, grip };
2936
+ },
2937
+ reachFor(reach) {
2938
+ if (using) {
2939
+ control.cancel();
2940
+ using = null;
2941
+ }
2942
+ reaching = { reach, phase: "reach", time: 0, progress: 0, walked: 0, there: false };
2943
+ },
2944
+ forget(objectId) {
2945
+ for (const k of [...openings.keys()]) if (k.startsWith(`${objectId}\0`)) openings.delete(k);
2946
+ if (using?.objectId === objectId) {
2947
+ control.cancel();
2948
+ using = null;
2949
+ }
2950
+ }
2951
+ };
2952
+ }
2953
+
2443
2954
  // src/room.ts
2955
+ import { releaseObject3D as releaseObject3D2 } from "@adatechnology/game-shapes-three";
2444
2956
  import { createTerrain, createTerrainImpl, DEFAULT_PROFILE } from "@adatechnology/game-terrain";
2445
2957
  import { createTerrainRenderer } from "@adatechnology/game-terrain-three";
2446
2958
 
@@ -2660,17 +3172,24 @@ var en = {
2660
3172
  "loading.open_area": "Opening {area}\u2026",
2661
3173
  "loading.spawn": "Building {name}\u2026",
2662
3174
  "act.loosened": "{name} was heavy but free: the blast moved it",
3175
+ "act.came_apart": "{name} came apart: what stayed is loose and falls",
2663
3176
  "act.shoved": "{name} stands free: the shots moved it",
2664
3177
  "act.impact": "{name} hit {where} at {speed} m/s: {energy} J of blunt damage",
2665
3178
  "act.use_item": "{name}: {use}",
3179
+ "act.open_with": "Opened {name} with {tool}",
3180
+ "act.open_by_hand": "Opened {name} by hand",
3181
+ "act.ate": "Ate {grams} g of {name}",
3182
+ "act.ate_all": "Ate all of {name} ({grams} g): the packaging is empty",
2666
3183
  "act.tap.on": "Tap opened",
2667
3184
  "act.tap.off": "Tap closed",
2668
3185
  "use.drink": "drink",
2669
3186
  "use.sip": "sip",
2670
3187
  "use.chug": "chug",
3188
+ "use.eat": "eat",
2671
3189
  "use.pour": "pour",
2672
3190
  "use.pour_out": "pour out",
2673
3191
  "use.open": "open",
3192
+ "use.unscrew": "unscrew",
2674
3193
  "use.close": "close",
2675
3194
  "hud.reloading": "Reloading...",
2676
3195
  "hud.empty": "Empty",
@@ -2687,15 +3206,32 @@ var en = {
2687
3206
  "hud.nothing_to_grab": "Nothing to grab here",
2688
3207
  "hud.cannot_grab": "{name} cannot be picked up",
2689
3208
  "hud.too_heavy": "{name} is too heavy ({kg} kg)",
3209
+ "hud.door_locked": "{name} is locked",
3210
+ "hud.door_blocked": "{name} hits something and stops ({percent}% open)",
3211
+ "hud.door_hint_open": "E: open",
3212
+ "hud.door_hint_close": "E: close",
3213
+ "hud.door_hint_locked": "Locked",
3214
+ "act.door_reach": "reaches for the handle of {name}",
3215
+ "act.door_opened": "opens {name}",
3216
+ "act.door_closed": "closes {name}",
3217
+ "act.door_blocked": "{name} hits something and stops at {percent}%",
3218
+ "act.door_locked": "tries {name}: locked, it only rattles",
3219
+ "act.door_stepped_back": "steps back out of the way of {name}",
2690
3220
  "hud.push_on": "Pushing {name}: W pushes, S pulls, let go of E to stop",
2691
3221
  "hud.push_too_heavy": "{name} does not budge ({kg} kg)",
2692
3222
  "hud.push_blocked": "{name} is stuck: there is something in the way",
2693
3223
  "act.push_grip": "leans on {name} ({kg} kg)",
2694
3224
  "act.push_engaged": "starts pushing {name} ({kg} kg)",
3225
+ "act.sit": "sits on {name}",
3226
+ "act.lie": "lies down on {name}",
3227
+ "act.stand": "gets up from {name}",
2695
3228
  "act.push_too_heavy": "strains against {name}: {kg} kg is too much",
2696
3229
  "act.push_obstacle": "{name} hits something and locks",
2697
3230
  "act.push_release": "lets go of {name}",
2698
3231
  "hud.empty_container": "{name} is empty",
3232
+ "hud.needs_opener": "Opening {name} needs {tools} (put one within reach or in the loadout)",
3233
+ "hud.spoiled_hole": "{name}: the packaging is holed through, what is inside is contaminated; do not eat or drink it",
3234
+ "hud.spoiled_leak": "{name} leaked: what is left is contaminated; do not eat or drink it",
2699
3235
  "hud.refused": "Wrong tool for the {kind}",
2700
3236
  "hud.drop": "drop",
2701
3237
  "hud.rotate": "rotate",
@@ -2984,17 +3520,24 @@ var ptBR = {
2984
3520
  "loading.open_area": "Abrindo {area}\u2026",
2985
3521
  "loading.spawn": "Montando {name}\u2026",
2986
3522
  "act.loosened": "{name} \xE9 pesado mas estava solto: a explos\xE3o o moveu",
3523
+ "act.came_apart": "{name} se desmontou: o que ficou est\xE1 solto e cai",
2987
3524
  "act.shoved": "{name} estava solto no ch\xE3o: os tiros o moveram",
2988
3525
  "act.impact": "{name} bateu em {where} a {speed} m/s: {energy} J de dano de impacto",
2989
3526
  "act.use_item": "{name}: {use}",
3527
+ "act.open_with": "Abriu {name} com {tool}",
3528
+ "act.open_by_hand": "Abriu {name} com a m\xE3o",
3529
+ "act.ate": "Comeu {grams} g de {name}",
3530
+ "act.ate_all": "Comeu todo o conte\xFAdo de {name} ({grams} g): a embalagem ficou vazia",
2990
3531
  "act.tap.on": "Abriu a torneira",
2991
3532
  "act.tap.off": "Fechou a torneira",
2992
3533
  "use.drink": "beber",
2993
3534
  "use.sip": "golinho",
2994
3535
  "use.chug": "virar",
3536
+ "use.eat": "comer",
2995
3537
  "use.pour": "despejar",
2996
3538
  "use.pour_out": "esvaziar",
2997
3539
  "use.open": "abrir",
3540
+ "use.unscrew": "desenroscar",
2998
3541
  "use.close": "fechar",
2999
3542
  "hud.reloading": "Recarregando...",
3000
3543
  "hud.empty": "Sem muni\xE7\xE3o",
@@ -3011,15 +3554,32 @@ var ptBR = {
3011
3554
  "hud.nothing_to_grab": "Nada para pegar aqui",
3012
3555
  "hud.cannot_grab": "{name} n\xE3o d\xE1 para pegar",
3013
3556
  "hud.too_heavy": "{name} \xE9 pesado demais ({kg} kg)",
3557
+ "hud.door_locked": "{name} est\xE1 trancado(a)",
3558
+ "hud.door_blocked": "{name} bateu em algo e parou ({percent}% aberto)",
3559
+ "hud.door_hint_open": "E: abrir",
3560
+ "hud.door_hint_close": "E: fechar",
3561
+ "hud.door_hint_locked": "Trancado",
3562
+ "act.door_reach": "leva a m\xE3o \xE0 ma\xE7aneta de {name}",
3563
+ "act.door_opened": "abre {name}",
3564
+ "act.door_closed": "fecha {name}",
3565
+ "act.door_blocked": "{name} bate em algo e para em {percent}%",
3566
+ "act.door_locked": "tenta abrir {name}: trancado, s\xF3 chacoalha",
3567
+ "act.door_stepped_back": "d\xE1 um passo para tr\xE1s para {name} passar",
3014
3568
  "hud.push_on": "Empurrando {name}: W empurra, S puxa, solte o E para parar",
3015
3569
  "hud.push_too_heavy": "{name} n\xE3o se mexe ({kg} kg)",
3016
3570
  "hud.push_blocked": "{name} travou: tem algo no caminho",
3017
3571
  "act.push_grip": "encosta em {name} ({kg} kg)",
3018
3572
  "act.push_engaged": "come\xE7a a empurrar {name} ({kg} kg)",
3573
+ "act.sit": "senta em {name}",
3574
+ "act.lie": "deita em {name}",
3575
+ "act.stand": "levanta de {name}",
3019
3576
  "act.push_too_heavy": "faz for\xE7a contra {name}: {kg} kg \xE9 demais",
3020
3577
  "act.push_obstacle": "{name} bate em algo e trava",
3021
3578
  "act.push_release": "solta {name}",
3022
3579
  "hud.empty_container": "{name} est\xE1 vazio",
3580
+ "hud.needs_opener": "Para abrir {name} precisa de {tools} (deixe um ao alcance ou no invent\xE1rio)",
3581
+ "hud.spoiled_hole": "{name}: a embalagem furou, o conte\xFAdo est\xE1 contaminado; n\xE3o d\xE1 para comer nem beber",
3582
+ "hud.spoiled_leak": "{name} vazou: o que sobrou est\xE1 contaminado; n\xE3o d\xE1 para comer nem beber",
3023
3583
  "hud.refused": "Ferramenta errada para {kind}",
3024
3584
  "hud.drop": "soltar",
3025
3585
  "hud.rotate": "girar",
@@ -3384,9 +3944,9 @@ function createBenchPanel(o) {
3384
3944
  hasRoom = rows.room.length > 0;
3385
3945
  syncDisplay();
3386
3946
  roomLine.textContent = rows.room.map((row) => `${row.name} ${row.text}`).join(" \xB7 ");
3387
- const key = rows.module.map((row) => `${row.id}:${row.ratio !== void 0}`).join("|");
3388
- if (key !== metricKey) {
3389
- metricKey = key;
3947
+ const key2 = rows.module.map((row) => `${row.id}:${row.ratio !== void 0}`).join("|");
3948
+ if (key2 !== metricKey) {
3949
+ metricKey = key2;
3390
3950
  renderMetrics(rows.module);
3391
3951
  }
3392
3952
  for (const row of rows.module) {
@@ -3740,8 +4300,7 @@ function createUi(o) {
3740
4300
  logBtn(t("ui.download_txt"), () => o.log.download("txt"));
3741
4301
  logBtn(t("ui.clear_log"), () => {
3742
4302
  o.log.clear();
3743
- logBody.textContent = "";
3744
- nodes.clear();
4303
+ rebuildLog();
3745
4304
  });
3746
4305
  logPanel.append(logTitle, logBar, logBody);
3747
4306
  const MAX_LINES = 200;
@@ -3754,29 +4313,59 @@ function createUi(o) {
3754
4313
  const tm = mk3("time", void 0, fmtTime(e.t));
3755
4314
  n.append(tm, document.createTextNode(e.text));
3756
4315
  };
3757
- const addNode = (e) => {
3758
- if (!filters[e.channel]) return;
4316
+ let pendingAdds = [];
4317
+ const pendingUpdates = /* @__PURE__ */ new Map();
4318
+ let isLogStale = false;
4319
+ const isLogVisible = () => !window.matchMedia?.("(max-width:980px)").matches || root.classList.contains("open");
4320
+ const buildNode = (e) => {
3759
4321
  const n = mk3("div");
3760
4322
  fill(n, e);
3761
4323
  nodes.set(e.seq, n);
3762
- logBody.appendChild(n);
3763
- while (logBody.childElementCount > MAX_LINES) {
3764
- const first = logBody.firstElementChild;
3765
- for (const [k, v] of nodes) if (v === first) nodes.delete(k);
3766
- first.remove();
3767
- }
3768
- if (autoscroll) logBody.scrollTop = logBody.scrollHeight;
4324
+ return n;
3769
4325
  };
3770
4326
  function rebuildLog() {
4327
+ pendingAdds = [];
4328
+ pendingUpdates.clear();
4329
+ isLogStale = false;
3771
4330
  logBody.textContent = "";
3772
4331
  nodes.clear();
3773
- for (const e of o.log.entries().slice(-MAX_LINES * 3)) addNode(e);
4332
+ const fragment = document.createDocumentFragment();
4333
+ for (const e of o.log.entries().slice(-MAX_LINES * 3)) if (filters[e.channel]) fragment.appendChild(buildNode(e));
4334
+ logBody.appendChild(fragment);
4335
+ trimLog();
3774
4336
  }
3775
- const offLog = o.log.on((e, updated) => {
3776
- if (updated) {
3777
- const n = nodes.get(e.seq);
4337
+ function trimLog() {
4338
+ while (logBody.childElementCount > MAX_LINES) {
4339
+ const oldest = nodes.keys().next();
4340
+ if (oldest.done) break;
4341
+ nodes.delete(oldest.value);
4342
+ logBody.firstElementChild?.remove();
4343
+ }
4344
+ if (autoscroll) logBody.scrollTop = logBody.scrollHeight;
4345
+ }
4346
+ const flushLog = () => {
4347
+ if (pendingAdds.length === 0 && pendingUpdates.size === 0) return;
4348
+ if (!isLogVisible()) {
4349
+ isLogStale = true;
4350
+ pendingAdds = [];
4351
+ pendingUpdates.clear();
4352
+ return;
4353
+ }
4354
+ if (isLogStale) return rebuildLog();
4355
+ const fragment = document.createDocumentFragment();
4356
+ for (const e of pendingAdds.slice(-MAX_LINES)) if (filters[e.channel]) fragment.appendChild(buildNode(e));
4357
+ pendingAdds = [];
4358
+ logBody.appendChild(fragment);
4359
+ for (const [seq, e] of pendingUpdates) {
4360
+ const n = nodes.get(seq);
3778
4361
  if (n) fill(n, e);
3779
- } else addNode(e);
4362
+ }
4363
+ pendingUpdates.clear();
4364
+ trimLog();
4365
+ };
4366
+ const offLog = o.log.on((e, updated) => {
4367
+ if (updated) pendingUpdates.set(e.seq, e);
4368
+ else pendingAdds.push(e);
3780
4369
  });
3781
4370
  const right = mk3("div", "tr-right");
3782
4371
  const cat = mk3("div", "tr-panel tr-cat");
@@ -3866,8 +4455,8 @@ function createUi(o) {
3866
4455
  if (o.kick) ctl(o.kick.label, () => o.kick.onKick());
3867
4456
  const fullscreenBtn = ctl(t("ui.fullscreen"), () => void toggleFullscreen());
3868
4457
  fullscreenBtn.title = t("ui.fullscreen.hint");
3869
- const blastBtns = ["shelter", "fragmentHits", "confinement"].map((key) => ({ key, b: ctl(t(`ui.blast.${key}`), () => o.onBlast(key)) }));
3870
- for (const { key, b } of blastBtns) b.title = t(`ui.blast.${key}.hint`);
4458
+ const blastBtns = ["shelter", "fragmentHits", "confinement"].map((key2) => ({ key: key2, b: ctl(t(`ui.blast.${key2}`), () => o.onBlast(key2)) }));
4459
+ for (const { key: key2, b } of blastBtns) b.title = t(`ui.blast.${key2}.hint`);
3871
4460
  const qSel = mk3("select");
3872
4461
  for (const q of ["low", "medium", "high"]) {
3873
4462
  const op = mk3("option", void 0, t(`ui.quality.${q}`));
@@ -3943,6 +4532,7 @@ function createUi(o) {
3943
4532
  toggle.type = "button";
3944
4533
  const togglePanels = () => {
3945
4534
  root.classList.toggle("open");
4535
+ if (isLogStale && isLogVisible()) rebuildLog();
3946
4536
  o.onResize();
3947
4537
  };
3948
4538
  toggle.onclick = togglePanels;
@@ -4031,6 +4621,7 @@ function createUi(o) {
4031
4621
  ring.style.display = value === null ? "none" : "block";
4032
4622
  if (value !== null) ringFill.style.strokeDashoffset = String(RING_LENGTH * (1 - value));
4033
4623
  },
4624
+ flushLog,
4034
4625
  hud(s) {
4035
4626
  toolBox.textContent = "";
4036
4627
  toolBox.append(document.createTextNode(s.toolName));
@@ -4061,9 +4652,9 @@ function createUi(o) {
4061
4652
  held.append(mk3("kbd", void 0, k), document.createTextNode(` ${label} `));
4062
4653
  }
4063
4654
  } else held.style.display = "none";
4064
- const key = s.slots.join("|") + s.page;
4065
- if (key !== lastSlotsKey) {
4066
- lastSlotsKey = key;
4655
+ const key2 = s.slots.join("|") + s.page;
4656
+ if (key2 !== lastSlotsKey) {
4657
+ lastSlotsKey = key2;
4067
4658
  hotbar.textContent = "";
4068
4659
  slotsEl.length = 0;
4069
4660
  hudPage = s.page - 1;
@@ -4080,7 +4671,7 @@ function createUi(o) {
4080
4671
  slotsEl.forEach((b, i) => b.classList.toggle("on", i === s.slot));
4081
4672
  tapBtn.classList.toggle("on", s.tap);
4082
4673
  comboBtn?.classList.toggle("on", !!s.combo);
4083
- for (const { key: key2, b } of blastBtns) b.classList.toggle("on", s.blast[key2]);
4674
+ for (const { key: key3, b } of blastBtns) b.classList.toggle("on", s.blast[key3]);
4084
4675
  count.textContent = t("ui.objects", { n: s.objects });
4085
4676
  const ak = JSON.stringify(s.animations);
4086
4677
  if (ak !== lastAnim) {
@@ -4131,7 +4722,7 @@ function createUi(o) {
4131
4722
  var FIXED_DT = 1 / 60;
4132
4723
  var SOLID_SPAN = 3;
4133
4724
  var STEP_UP = 0.45;
4134
- var BODY_HEIGHT = 1.75;
4725
+ var BODY_HEIGHT2 = 1.75;
4135
4726
  var KICK_MASS = 6;
4136
4727
  var KICK_HEIGHT = 0.6;
4137
4728
  var GROUND_ID = "room_ground";
@@ -4143,9 +4734,8 @@ var GROUND_DUST_RADIUS = 0.2;
4143
4734
  var GROUND_FIRE_EVERY = 0.3;
4144
4735
  var SHOVE_SPEED = 0.04;
4145
4736
  var METRICS_INTERVAL_S = 0.25;
4146
- var CARRY_NEAR = 0.62;
4147
4737
  var CARRY_PALM = 0.012;
4148
- var CARRY_GRIP = 0.45;
4738
+ var CAP_RISE = 0.03;
4149
4739
  var MAX_STRIDE_SPEED = 10;
4150
4740
  var v3 = (v) => [v.x, v.y, v.z];
4151
4741
  var dist3 = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
@@ -4155,8 +4745,8 @@ function createTestRoom(options) {
4155
4745
  const S = resolveStrings(locale);
4156
4746
  const dStrings = resolveDestructionStrings(locale);
4157
4747
  const fStrings = resolveFluidStrings(locale);
4158
- const t = (key, vars) => {
4159
- let s = S.text[key] ?? key;
4748
+ const t = (key2, vars) => {
4749
+ let s = S.text[key2] ?? key2;
4160
4750
  if (vars) for (const k of Object.keys(vars)) s = s.split(`{${k}}`).join(String(vars[k]));
4161
4751
  return s;
4162
4752
  };
@@ -4241,11 +4831,11 @@ function createTestRoom(options) {
4241
4831
  const log = createLog({ exportCancelled: () => t("log.export_cancelled") });
4242
4832
  log.setClock(() => simTime);
4243
4833
  const ownAudio = options.audio === void 0;
4244
- const audio = options.audio === false ? null : options.audio ?? createAudioEngine();
4834
+ const audio = options.audio === false ? null : options.audio ?? createAudioEngine({ deferredStart: {} });
4245
4835
  const destructionSound = audio ? createDestructionSound(audio) : null;
4246
- const playSound = (key, point, volume) => {
4247
- destructionSound?.onSound(key, point, volume);
4248
- options.onSound?.(key, point, volume);
4836
+ const playSound = (key2, point, volume) => {
4837
+ destructionSound?.onSound(key2, point, volume);
4838
+ options.onSound?.(key2, point, volume);
4249
4839
  };
4250
4840
  const vfx = createVfxRenderer(THREE, {
4251
4841
  scene,
@@ -4255,6 +4845,7 @@ function createTestRoom(options) {
4255
4845
  pointScale,
4256
4846
  vfx: createVfx({
4257
4847
  profile: quality,
4848
+ ...options.debris?.particles ? { sprites: { kinds: patchSpriteKinds(SPRITE_KINDS, options.debris.particles) } } : {},
4258
4849
  groundAt
4259
4850
  })
4260
4851
  });
@@ -4265,6 +4856,9 @@ function createTestRoom(options) {
4265
4856
  quality,
4266
4857
  groundAt,
4267
4858
  pointScale,
4859
+ ...options.debris?.fragmentLife !== void 0 ? { fragmentLife: options.debris.fragmentLife } : {},
4860
+ ...options.debris?.maxFragments !== void 0 ? { maxFragments: options.debris.maxFragments } : {},
4861
+ eventBudgetMs: options.debris?.eventBudgetMs ?? 8,
4268
4862
  onSound: playSound,
4269
4863
  ...options.blastShove ? { blastShove: options.blastShove } : {},
4270
4864
  // a blast moves what is heavy but not nailed down: the renderer makes it a piece the moment the shove reaches it
@@ -4272,13 +4866,13 @@ function createTestRoom(options) {
4272
4866
  const e = entries.get(objectId);
4273
4867
  return e?.free === true && !e.piece;
4274
4868
  },
4275
- onLoosened: (objectId, piece) => {
4869
+ onLoosened: (objectId, piece, cause) => {
4276
4870
  const e = entries.get(objectId);
4277
4871
  if (!e) return;
4278
4872
  e.piece = piece;
4279
4873
  leaveFootprint(objectId);
4280
4874
  rendererPieces.set(objectId, piece);
4281
- action("loosened", t("act.loosened", { name: e.name }), { objectId, itemId: e.so.itemId });
4875
+ action("loosened", t(cause === "blast" ? "act.loosened" : "act.came_apart", { name: e.name }), { objectId, itemId: e.so.itemId });
4282
4876
  },
4283
4877
  onPiece: (piece) => {
4284
4878
  rendererPieces.set(piece.objectId, piece);
@@ -4494,7 +5088,7 @@ function createTestRoom(options) {
4494
5088
  const dz = Math.max(s.min[2] - z, 0, z - s.max[2]);
4495
5089
  if (dx * dx + dz * dz > radius * radius) continue;
4496
5090
  if (s.max[1] <= feet + STEP_UP) floor = Math.max(floor, s.max[1]);
4497
- else if (s.min[1] < feet + BODY_HEIGHT) blocked = true;
5091
+ else if (s.min[1] < feet + BODY_HEIGHT2) blocked = true;
4498
5092
  }
4499
5093
  return { floor, blocked };
4500
5094
  };
@@ -4568,7 +5162,7 @@ function createTestRoom(options) {
4568
5162
  let best = null;
4569
5163
  let bestDistance = PUSH_REACH;
4570
5164
  for (const e of entries.values()) {
4571
- if (e.arena || !e.piece && !e.free) continue;
5165
+ if (!e.pushable || !e.piece && !e.free) continue;
4572
5166
  const b = bodyBox(e);
4573
5167
  if (!b) continue;
4574
5168
  const dx = Math.max(b.minX, Math.min(b.maxX, player.x)) - player.x;
@@ -4592,7 +5186,7 @@ function createTestRoom(options) {
4592
5186
  aim();
4593
5187
  const r = rayWorld([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], 3.2);
4594
5188
  const aimed = r ? rendererPieces.get(r.object)?.root ?? (entries.get(r.object)?.free ? entries.get(r.object)?.so.root : void 0) : void 0;
4595
- if (r && aimed) return { objectId: r.object, root: aimed, name: nameOf(r.object), piece: rendererPieces.get(r.object) ?? null };
5189
+ if (r && aimed) return entries.get(r.object)?.pushable ? { objectId: r.object, root: aimed, name: nameOf(r.object), piece: rendererPieces.get(r.object) ?? null } : null;
4596
5190
  return leanedOn();
4597
5191
  },
4598
5192
  massOf: (objectId) => massOfObject(objectId),
@@ -4615,9 +5209,96 @@ function createTestRoom(options) {
4615
5209
  else if (ev.type === "too_heavy") say(t("hud.push_too_heavy", vars));
4616
5210
  else if (ev.type === "obstacle") say(t("hud.push_blocked", vars));
4617
5211
  },
4618
- ring: (value) => ui?.holdRing(value)
5212
+ ring: (value) => ui?.holdRing(value),
5213
+ hasBody: () => !!avatar?.eye
5214
+ });
5215
+ const seatHolder = (objectId) => {
5216
+ const e = entries.get(objectId);
5217
+ const seats = e?.item?.seats;
5218
+ if (!e || !seats?.length || !world.has(objectId)) return null;
5219
+ return { objectId, root: rendererPieces.get(objectId)?.root ?? e.so.root, seats };
5220
+ };
5221
+ const seating = createSeating({
5222
+ THREE,
5223
+ player,
5224
+ pick: () => {
5225
+ aim();
5226
+ const r = rayWorld([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], 3.2);
5227
+ return r ? seatHolder(r.object) : null;
5228
+ },
5229
+ find: seatHolder,
5230
+ hasBody: () => !!avatar?.eye,
5231
+ groundAt: (x, z) => groundAt(x, z),
5232
+ onSit: (seat) => action("sit", t(seat.pose === "lie" ? "act.lie" : "act.sit", { name: nameOf(seat.objectId) }), { objectId: seat.objectId, itemId: entryOf(seat.objectId)?.so.itemId, data: { seat: seat.seatId, pose: seat.pose } }),
5233
+ onStand: (seat) => action("stand", t("act.stand", { name: nameOf(seat.objectId) }), { objectId: seat.objectId, itemId: entryOf(seat.objectId)?.so.itemId, data: { seat: seat.seatId } })
4619
5234
  });
4620
5235
  let pushByKey = false;
5236
+ const leafCache = /* @__PURE__ */ new WeakMap();
5237
+ const leavesOf = (objectId) => {
5238
+ const item = entries.get(objectId)?.item;
5239
+ if (!item?.animations?.length) return [];
5240
+ const cached = leafCache.get(item);
5241
+ if (cached) return cached;
5242
+ const byPart = /* @__PURE__ */ new Map();
5243
+ for (const clip of item.animations) {
5244
+ if (!clip.id.startsWith("open_")) continue;
5245
+ const parts = /* @__PURE__ */ new Set();
5246
+ for (const k of clip.keys) for (const id of Object.keys(k.parts ?? {})) parts.add(id);
5247
+ for (const part of parts) {
5248
+ const had = byPart.get(part);
5249
+ if (!had || parts.size < had.n) byPart.set(part, { clip, n: parts.size });
5250
+ }
5251
+ }
5252
+ const leaves = [...byPart].map(([part, v]) => ({ part, clip: v.clip }));
5253
+ leafCache.set(item, leaves);
5254
+ return leaves;
5255
+ };
5256
+ const lockedLeaves = /* @__PURE__ */ new Set();
5257
+ const doors = createRoomDoors({
5258
+ THREE,
5259
+ player,
5260
+ world,
5261
+ camera,
5262
+ arena: { limit: arenaSize, colliders: standingColliders, ids: standingIds },
5263
+ ...options.doors ? { rules: options.doors } : {},
5264
+ pick: () => {
5265
+ if (heldPiece) return [];
5266
+ aim();
5267
+ const hits = [];
5268
+ const r = rayWorld([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], DOOR_REACH);
5269
+ if (r) hits.push({ objectId: r.object, part: r.part, point: r.point });
5270
+ if (thirdActive()) {
5271
+ camera.getWorldDirection(tmpV2);
5272
+ const e = rayWorld([aimO[0], aimO[1], aimO[2]], [tmpV2.x, tmpV2.y, tmpV2.z], DOOR_REACH);
5273
+ if (e) hits.push({ objectId: e.object, part: e.part, point: e.point });
5274
+ }
5275
+ return hits;
5276
+ },
5277
+ leaves: leavesOf,
5278
+ objectOf: (objectId, part) => entries.get(objectId)?.so.root.getObjectByName(part) ?? null,
5279
+ pose: (objectId, part, clip, fraction) => {
5280
+ const e = entries.get(objectId);
5281
+ const obj = e?.so.root.getObjectByName(part);
5282
+ if (!e || !obj) return;
5283
+ if (!restPose.has(obj)) restPose.set(obj, { p: [obj.position.x, obj.position.y, obj.position.z], r: [obj.rotation.x, obj.rotation.y, obj.rotation.z] });
5284
+ const rest = restPose.get(obj);
5285
+ const pose = poseAt(clip, part, Math.max(0, Math.min(1, fraction)) * clip.duration);
5286
+ obj.position.set(rest.p[0] + pose.position[0], rest.p[1] + pose.position[1], rest.p[2] + pose.position[2]);
5287
+ obj.rotation.set(rest.r[0] + pose.rotation[0], rest.r[1] + pose.rotation[1], rest.r[2] + pose.rotation[2]);
5288
+ followPart(e, part, obj, rest);
5289
+ },
5290
+ isLocked: (objectId, part) => lockedLeaves.has(`${objectId}\0*`) || lockedLeaves.has(`${objectId}\0${part}`),
5291
+ nameOf,
5292
+ impactKey: (objectId, part) => impactKey(world.materials.get(world.part(objectId, part)?.material ?? heaviestMaterial(objectId) ?? "")),
5293
+ sound: playSound,
5294
+ onEvent: (ev) => {
5295
+ const vars = { name: ev.name, percent: Math.round(ev.opening * 100) };
5296
+ action(`door_${ev.type}`, t(`act.door_${ev.type}`, vars), { objectId: ev.objectId, itemId: entryOf(ev.objectId)?.so.itemId, part: ev.part, data: { opening: Math.round(ev.opening * 1e3) / 1e3, ...ev.by ? { by: ev.by } : {} } });
5297
+ fire("door", ev);
5298
+ if (ev.type === "locked") say(t("hud.door_locked", vars));
5299
+ else if (ev.type === "blocked") say(t("hud.door_blocked", vars));
5300
+ }
5301
+ });
4621
5302
  const massOfObject = (id) => {
4622
5303
  const st = world.state(id);
4623
5304
  let m = 0;
@@ -4643,6 +5324,8 @@ function createTestRoom(options) {
4643
5324
  const q = root.getWorldQuaternion(tmpQ);
4644
5325
  const p = tmpV.set(e.fluidOffset[0], e.fluidOffset[1], e.fluidOffset[2]).applyMatrix4(root.matrixWorld);
4645
5326
  e.containerId = fluids.addContainer(f.container, { id: `${objectId}_fluid`, position: [p.x, p.y, p.z], rotation: [q.x, q.y, q.z, q.w] });
5327
+ e.fluidStart = f.container.liters;
5328
+ e.fluidUsed = 0;
4646
5329
  fluidView.bindContainer(e.containerId, root, { innerOffset: e.fluidOffset });
4647
5330
  }
4648
5331
  return e;
@@ -4663,6 +5346,7 @@ function createTestRoom(options) {
4663
5346
  world.add(structuredCloneSpec(o.spec), { id: o.id, position: o.position, ...o.rotationY ? { rotation: [0, Math.sin(o.rotationY / 2), 0, Math.cos(o.rotationY / 2)] } : {} });
4664
5347
  const e = registerEntry(o.id, o.id, root, o.spec, o.label ?? S.text[`arena.${o.id}`] ?? o.id, void 0, void 0, true, false);
4665
5348
  if (o.free) e.free = true;
5349
+ if (o.pushable) e.pushable = true;
4666
5350
  e.follower = o.follow?.(root, world, o.id) || null;
4667
5351
  }
4668
5352
  };
@@ -4762,7 +5446,7 @@ function createTestRoom(options) {
4762
5446
  if (movable) {
4763
5447
  const box2 = new THREE.Box3().setFromObject(root);
4764
5448
  const base = Math.max(pos[1], groundAt(pos[0], pos[2]));
4765
- pos = [pos[0], pos[1] + (base - box2.min.y), pos[2]];
5449
+ if (!box2.isEmpty()) pos = [pos[0], pos[1] + (base - box2.min.y), pos[2]];
4766
5450
  root.position.set(pos[0], pos[1], pos[2]);
4767
5451
  root.updateMatrixWorld(true);
4768
5452
  }
@@ -4771,6 +5455,7 @@ function createTestRoom(options) {
4771
5455
  else world.add(spec, { id: objectId, position: pos, rotation: [q.x, q.y, q.z, q.w] });
4772
5456
  const e = registerEntry(objectId, item.id, root, spec, item.name, parts, item, false, movable);
4773
5457
  if (!own && !movable && item.movable === void 0) e.free = true;
5458
+ if (item.pushable) e.pushable = true;
4774
5459
  action("spawn", t("act.spawn", { name: item.name, x: num(pos[0], 1), z: num(pos[2], 1) }), { objectId, itemId: item.id, data: { position: pos, movable, massKg: Math.round(massOfObject(objectId) * 10) / 10 } });
4775
5460
  fire("spawn", e.so);
4776
5461
  return e.so;
@@ -4855,6 +5540,23 @@ function createTestRoom(options) {
4855
5540
  }
4856
5541
  return m;
4857
5542
  };
5543
+ let releaseBatch = null;
5544
+ const dropTree = (root) => {
5545
+ if (!root) return;
5546
+ if (releaseBatch) releaseBatch.push(root);
5547
+ else releaseObject3D2(root, { keep: scene });
5548
+ };
5549
+ const releasingTogether = (fn) => {
5550
+ if (releaseBatch) return fn();
5551
+ releaseBatch = [];
5552
+ try {
5553
+ fn();
5554
+ } finally {
5555
+ const roots = releaseBatch;
5556
+ releaseBatch = null;
5557
+ releaseObject3D2(roots, { keep: scene });
5558
+ }
5559
+ };
4858
5560
  const removeEntry = (id) => {
4859
5561
  const e = entries.get(id);
4860
5562
  if (!e) return;
@@ -4871,8 +5573,12 @@ function createTestRoom(options) {
4871
5573
  e.follower?.dispose?.();
4872
5574
  if (world.has(id)) world.remove(id);
4873
5575
  e.so.root.removeFromParent();
5576
+ const inner = wrapped.get(e.so.root);
4874
5577
  letGo(e.so.root);
5578
+ dropTree(e.so.root);
5579
+ dropTree(inner);
4875
5580
  entries.delete(id);
5581
+ doors.forget(id);
4876
5582
  rendererPieces.delete(id);
4877
5583
  specs.delete(id);
4878
5584
  };
@@ -4886,12 +5592,14 @@ function createTestRoom(options) {
4886
5592
  }
4887
5593
  if (world.has(id)) world.remove(id);
4888
5594
  p?.root.removeFromParent();
5595
+ dropTree(p?.root);
4889
5596
  rendererPieces.delete(id);
4890
5597
  splitOf.delete(id);
4891
5598
  }
4892
5599
  loose.length = 0;
4893
5600
  };
4894
- const clearEverything = () => {
5601
+ const clearEverything = () => releasingTogether(clearEverythingNow);
5602
+ const clearEverythingNow = () => {
4895
5603
  if (heldEntry || heldPiece) releaseHeld();
4896
5604
  useItemPlayer = null;
4897
5605
  playing.length = 0;
@@ -4914,6 +5622,7 @@ function createTestRoom(options) {
4914
5622
  fluids.setTap(TAP_ID, false);
4915
5623
  };
4916
5624
  const clearListeners = /* @__PURE__ */ new Set();
5625
+ const resetListeners = /* @__PURE__ */ new Set();
4917
5626
  const clear = () => {
4918
5627
  clearEverything();
4919
5628
  for (const listener of [...clearListeners]) listener();
@@ -4936,10 +5645,15 @@ function createTestRoom(options) {
4936
5645
  action("reset", t("act.reset"));
4937
5646
  };
4938
5647
  const hardReset = () => {
5648
+ seating.release();
4939
5649
  pushByKey = false;
4940
5650
  push.end();
4941
- clearEverything();
4942
- for (const id of [...entries.keys()]) removeEntry(id);
5651
+ releasingTogether(() => {
5652
+ clearEverythingNow();
5653
+ for (const id of [...entries.keys()]) removeEntry(id);
5654
+ });
5655
+ renderer.reset();
5656
+ for (const listener of [...resetListeners]) listener();
4943
5657
  tools.clear();
4944
5658
  tool = toolState(toolId);
4945
5659
  if (pristineWorld) {
@@ -4954,6 +5668,8 @@ function createTestRoom(options) {
4954
5668
  pending = [];
4955
5669
  strikes = [];
4956
5670
  rng = 12345;
5671
+ filmRandom.seed = 1;
5672
+ groundFireClock = 0;
4957
5673
  counter = 0;
4958
5674
  slot = 0;
4959
5675
  prevVy.clear();
@@ -4987,8 +5703,8 @@ function createTestRoom(options) {
4987
5703
  const thirdActive = () => cameraMode === "third" && avatar !== null;
4988
5704
  const syncAvatarVisible = () => {
4989
5705
  if (!avatar) return;
4990
- avatar.root.visible = cameraMode === "third";
4991
- if (avatar.firstPerson) avatar.firstPerson.root.visible = cameraMode === "first";
5706
+ avatar.root.visible = cameraMode === "third" || !!avatar.eye;
5707
+ avatar.setView?.(cameraMode, camera);
4992
5708
  };
4993
5709
  const setCameraMode = (mode) => {
4994
5710
  if (mode !== "first" && mode !== "third") return;
@@ -5001,13 +5717,13 @@ function createTestRoom(options) {
5001
5717
  };
5002
5718
  const setAvatar = (next) => {
5003
5719
  if (avatar === next) return;
5720
+ avatar?.setView?.("third", null);
5004
5721
  avatar?.root.removeFromParent();
5005
- avatar?.firstPerson?.root.removeFromParent();
5006
5722
  avatar = next;
5007
5723
  feetPrev = null;
5008
5724
  if (avatar) {
5725
+ avatar.root.userData.viewmodel = true;
5009
5726
  scene.add(avatar.root);
5010
- if (avatar.firstPerson) scene.add(avatar.firstPerson.root);
5011
5727
  syncAvatarVisible();
5012
5728
  stepAvatar(0);
5013
5729
  }
@@ -5029,38 +5745,73 @@ function createTestRoom(options) {
5029
5745
  avatarState.pitch = player.pitch;
5030
5746
  avatarState.holding = heldPiece !== null;
5031
5747
  avatarState.push = push.avatarState;
5032
- avatarState.itemUse = useItemPlayer ? { id: useItemName, ...useItemPlayer.pose() } : heldPiece && heldTilt > 0.01 ? { id: "tilt", position: [0.2, -0.1, -0.38], rotation: [-heldTilt, 0, 0] } : null;
5748
+ const seat = seating.seat;
5749
+ avatarState.seat = seat ? { pose: seat.pose, hip: seat.hips[1] - player.feet } : null;
5750
+ avatarState.door = doors.avatarState;
5751
+ avatarState.itemUse = useItemPlayer ? { id: useItemName, ...useItemPlayer.pose(), ...twistHand?.gripL !== void 0 ? { gripL: twistHand.gripL } : {} } : heldPiece && heldTilt > 0.01 ? { id: "tilt", position: [0.2, -0.1, -0.38], rotation: [-heldTilt, 0, 0] } : null;
5033
5752
  avatar.root.position.set(x, player.feet, z);
5034
5753
  avatar.root.rotation.set(0, player.yaw + Math.PI, 0);
5035
5754
  avatar.update(dt, avatarState);
5036
5755
  avatar.root.updateMatrixWorld(true);
5756
+ placeEye();
5757
+ };
5758
+ let scriptedAt = null;
5759
+ const lookAt = (eye, target) => {
5760
+ player.look(eye, target);
5761
+ scriptedAt = [player.x, player.z];
5762
+ };
5763
+ const EYE_CLEARANCE = 0.1;
5764
+ const eyeAt = new THREE.Vector3();
5765
+ const placeEye = () => {
5766
+ if (!avatar?.eye) return;
5767
+ if (scriptedAt) {
5768
+ const [walkX, walkZ] = player.walkIntent;
5769
+ if (!walkX && !walkZ && Math.hypot(player.x - scriptedAt[0], player.z - scriptedAt[1]) < 1e-6) return;
5770
+ scriptedAt = null;
5771
+ }
5772
+ avatar.eye(eyeAt);
5773
+ const dx = eyeAt.x - player.x;
5774
+ const dz = eyeAt.z - player.z;
5775
+ const ahead = Math.hypot(dx, dz);
5776
+ if (ahead > 1e-4) {
5777
+ const dir = [dx / ahead, 0, dz / ahead];
5778
+ const hit = rayWorld([player.x, eyeAt.y, player.z], dir, ahead + EYE_CLEARANCE);
5779
+ if (hit) {
5780
+ const keep = Math.max(0, hit.distance - EYE_CLEARANCE);
5781
+ eyeAt.set(player.x + dir[0] * keep, eyeAt.y, player.z + dir[2] * keep);
5782
+ }
5783
+ }
5784
+ camera.position.copy(eyeAt);
5785
+ camera.updateMatrixWorld(true);
5037
5786
  };
5038
5787
  let handSource = null;
5039
- let carriedHand = null;
5040
5788
  let debugView = null;
5041
5789
  let lastHand = { stance: "relaxed", triggerFinger: null, targets: null };
5042
5790
  const strikeClips = options.strikeClips ?? DEFAULT_STRIKE_CLIPS;
5043
5791
  const handStances = options.handStances ?? DEFAULT_HAND_STANCES;
5044
- const stepFirstPerson = (dt) => {
5045
- const arms = avatar?.firstPerson;
5046
- if (!arms) return;
5047
- const targets = push.handTargets() ?? handSource?.() ?? carriedHand;
5048
- const state = {
5049
- camera,
5050
- targets,
5051
- stance: push.active ? "hold" : handStances[toolId] ?? (targets ? "hold" : "relaxed"),
5052
- speed: avatarState.speed,
5053
- running: avatarState.running,
5054
- crouch: avatarState.crouch,
5055
- contact: (origin, direction, maxDistance) => rayWorld(origin, direction, maxDistance)?.distance ?? null,
5056
- triggerFinger: targets?.fingers?.index ?? null
5057
- };
5058
- lastHand = { stance: state.stance, triggerFinger: state.triggerFinger, targets };
5059
- arms.update(dt, state);
5792
+ const SHOULDER_POCKET_IN = 0.1;
5793
+ const pocketAt = new THREE.Vector3();
5794
+ const aimTurn = new THREE.Quaternion();
5795
+ const aimEuler = new THREE.Euler(0, 0, 0, "YXZ");
5796
+ const bodyHoldFrame = () => {
5797
+ const shoulder = avatar?.shoulder?.("R");
5798
+ if (!shoulder) return null;
5799
+ shoulder.getWorldPosition(pocketAt);
5800
+ pocketAt.x -= Math.cos(player.yaw) * SHOULDER_POCKET_IN;
5801
+ pocketAt.z += Math.sin(player.yaw) * SHOULDER_POCKET_IN;
5802
+ aimTurn.setFromEuler(aimEuler.set(player.pitch, player.yaw, 0, "YXZ"));
5803
+ return { position: [pocketAt.x, pocketAt.y, pocketAt.z], quaternion: [aimTurn.x, aimTurn.y, aimTurn.z, aimTurn.w] };
5060
5804
  };
5061
5805
  const reachAvatar = () => {
5062
- if (!avatar?.reach || !thirdActive()) return;
5063
- avatar.reach(push.active || heldPiece ? null : handSource?.() ?? null);
5806
+ const doorHand = doors.handTargets();
5807
+ const targets = push.active ? null : doorHand ?? (heldPiece ? twistHand : handSource?.() ?? null);
5808
+ lastHand = {
5809
+ stance: push.active || doorHand ? "hold" : handStances[toolId] ?? (targets ? "hold" : "relaxed"),
5810
+ triggerFinger: targets?.fingers?.index ?? null,
5811
+ targets: push.handTargets() ?? targets
5812
+ };
5813
+ if (!avatar?.reach) return;
5814
+ avatar.reach(targets, { camera, keepOutside: { contact: (origin, direction, maxDistance) => rayWorld(origin, direction, maxDistance)?.distance ?? null } });
5064
5815
  };
5065
5816
  const applyThirdView = () => {
5066
5817
  if (!thirdActive()) return () => void 0;
@@ -5566,9 +6317,9 @@ function createTestRoom(options) {
5566
6317
  }
5567
6318
  let blastOptions = { ...options.blast };
5568
6319
  let lastBlast;
5569
- const blastOn = (key) => Boolean(blastOptions[key]);
5570
- const toggleBlast = (key) => {
5571
- blastOptions = { ...blastOptions, [key]: blastOn(key) ? false : true };
6320
+ const blastOn = (key2) => Boolean(blastOptions[key2]);
6321
+ const toggleBlast = (key2) => {
6322
+ blastOptions = { ...blastOptions, [key2]: blastOn(key2) ? false : true };
5572
6323
  };
5573
6324
  const explosives = createExplosives({
5574
6325
  THREE,
@@ -5699,10 +6450,12 @@ function createTestRoom(options) {
5699
6450
  let tiltKey = 0;
5700
6451
  const heldLocal = new THREE.Vector3();
5701
6452
  const heldSize = new THREE.Vector3(0.2, 0.2, 0.2);
5702
- let heldRadius = 0.2;
5703
6453
  const heldVel = [0, 0, 0];
5704
6454
  let useItemPlayer = null;
5705
6455
  let useItemName = "";
6456
+ let afterOpening = null;
6457
+ let twisting = null;
6458
+ let twistHand = null;
5706
6459
  const releaseHeld = () => {
5707
6460
  if (heldPiece) {
5708
6461
  heldPiece.held = false;
@@ -5711,6 +6464,9 @@ function createTestRoom(options) {
5711
6464
  }
5712
6465
  useItemPlayer?.cancel();
5713
6466
  useItemPlayer = null;
6467
+ afterOpening = null;
6468
+ twisting = null;
6469
+ twistHand = null;
5714
6470
  heldEntry = null;
5715
6471
  heldPiece = null;
5716
6472
  heldTilt = 0;
@@ -5757,7 +6513,6 @@ function createTestRoom(options) {
5757
6513
  box2.getSize(heldSize);
5758
6514
  piece.root.quaternion.copy(savedQ);
5759
6515
  piece.root.updateMatrixWorld(true);
5760
- heldRadius = heldSize.length() / 3;
5761
6516
  heldYaw = 0;
5762
6517
  heldTilt = 0;
5763
6518
  heldVel[0] = heldVel[1] = heldVel[2] = 0;
@@ -5778,52 +6533,28 @@ function createTestRoom(options) {
5778
6533
  fire("throw", { objectId: id, speed });
5779
6534
  return true;
5780
6535
  };
5781
- const carriedTargets = (root, gripQ) => {
5782
- const width2 = Math.max(heldSize.x, heldSize.z);
5783
- const sideGrip = heldSize.y > width2 * 1.04 && width2 <= 0.22;
5784
- if (sideGrip) {
5785
- const center2 = heldLocal.clone().applyQuaternion(root.quaternion).add(root.position);
5786
- const radius = Math.min(heldSize.x, heldSize.z) * 0.5;
5787
- const handX = new THREE.Vector3(-0.35, 0, -0.94).normalize().applyQuaternion(gripQ);
5788
- const handY = new THREE.Vector3(0.94, 0, -0.35).normalize().applyQuaternion(gripQ);
5789
- const handZ = new THREE.Vector3().crossVectors(handX, handY).normalize();
5790
- const q2 = new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(handX, handY, handZ));
5791
- const up = new THREE.Vector3(0, 1, 0).applyQuaternion(gripQ);
5792
- const palm = center2.clone().addScaledVector(handY, radius + CARRY_PALM).addScaledVector(handX, -0.022).addScaledVector(up, heldSize.y > 0.16 ? -heldSize.y * 0.08 : -5e-3);
5793
- if (useItemName === "open" || useItemName === "close") {
5794
- const leftX = new THREE.Vector3(0.35, 0, -0.94).normalize().applyQuaternion(gripQ);
5795
- const leftY = new THREE.Vector3(-0.94, 0, -0.35).normalize().applyQuaternion(gripQ);
5796
- const leftZ = new THREE.Vector3().crossVectors(leftX, leftY).normalize();
5797
- const lq = new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(leftX, leftY, leftZ));
5798
- const leftPalm = center2.clone().addScaledVector(up, heldSize.y * 0.45).addScaledVector(leftY, radius * 0.65 + CARRY_PALM).addScaledVector(leftX, -0.018);
5799
- return {
5800
- R: { position: [palm.x, palm.y, palm.z], quaternion: [q2.x, q2.y, q2.z, q2.w] },
5801
- L: { position: [leftPalm.x, leftPalm.y, leftPalm.z], quaternion: [lq.x, lq.y, lq.z, lq.w] },
5802
- grip: 0.85,
5803
- gripL: 0.8
5804
- };
5805
- }
5806
- return { R: { position: [palm.x, palm.y, palm.z], quaternion: [q2.x, q2.y, q2.z, q2.w] }, grip: 0.85 };
5807
- }
5808
- const box2 = new THREE.Box3().setFromObject(root);
5809
- const center = box2.getCenter(new THREE.Vector3());
5810
- const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(gripQ).normalize();
5811
- const back = new THREE.Vector3(0, -1, 0).applyQuaternion(gripQ).normalize();
5812
- const z = new THREE.Vector3().crossVectors(forward, back).normalize();
5813
- const y = new THREE.Vector3().crossVectors(z, forward).normalize();
5814
- const q = new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(forward, y, z));
5815
- return { R: { position: [center.x, box2.min.y - CARRY_PALM, center.z], quaternion: [q.x, q.y, q.z, q.w] }, grip: CARRY_GRIP };
6536
+ const twistTargets = (root, gripQ) => {
6537
+ if (!twisting) return null;
6538
+ const { stroke, lift } = twisting;
6539
+ const up = new THREE.Vector3(0, 1, 0).applyQuaternion(root.quaternion);
6540
+ const top = heldLocal.clone().applyQuaternion(root.quaternion).add(root.position).addScaledVector(up, heldSize.y * 0.5 + lift * CAP_RISE + stroke.lift * TWIST_LIFT + CARRY_PALM);
6541
+ const knuckles = new THREE.Vector3(0.85, 0, -0.5).normalize().applyQuaternion(gripQ);
6542
+ knuckles.addScaledVector(up, -knuckles.dot(up)).normalize().applyAxisAngle(up, stroke.angle);
6543
+ const side = new THREE.Vector3().crossVectors(knuckles, up).normalize();
6544
+ const palm = top.addScaledVector(knuckles, -0.025);
6545
+ const q = new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(knuckles, up, side));
6546
+ return { L: { position: [palm.x, palm.y, palm.z], quaternion: [q.x, q.y, q.z, q.w] }, gripL: 0.35 + 0.5 * stroke.grip };
5816
6547
  };
5817
6548
  const gripQuat = new THREE.Quaternion();
5818
6549
  const followHeld = (dt) => {
5819
6550
  const piece = heldPiece;
5820
- carriedHand = null;
6551
+ twistHand = null;
5821
6552
  if (!piece) return;
5822
6553
  camera.updateMatrixWorld(true);
5823
6554
  const root = piece.root;
5824
6555
  let pos;
5825
6556
  const targetQ = tmpQ;
5826
- const hand = thirdActive() ? avatar?.hand?.("R") : void 0;
6557
+ const hand = avatar?.hand?.("R");
5827
6558
  const isDrink = useItemName === "drink" || useItemName === "sip" || useItemName === "chug";
5828
6559
  if (hand) {
5829
6560
  const handWorld = hand.getWorldPosition(tmpHand);
@@ -5839,7 +6570,7 @@ function createTestRoom(options) {
5839
6570
  pos = tmpV.copy(handWorld).add(gripOffset);
5840
6571
  } else if (useItemPlayer) {
5841
6572
  const pose = useItemPlayer.pose();
5842
- const frameQ = avatar?.firstPerson ? camera.quaternion : new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), player.yaw);
6573
+ const frameQ = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), player.yaw);
5843
6574
  const raise = isDrink ? Math.max(0, Math.min(1, (pose.position[1] + 0.2) / 0.22)) : 0;
5844
6575
  const px2 = pose.position[0] + (isDrink ? 0.02 * raise : 0);
5845
6576
  const py2 = pose.position[1] - (isDrink ? 0.08 * raise : 0);
@@ -5852,19 +6583,10 @@ function createTestRoom(options) {
5852
6583
  targetQ.copy(frameQ).multiply(eq);
5853
6584
  targetQ.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), heldYaw));
5854
6585
  } else {
5855
- const small = heldRadius < 0.22;
5856
- const holdX = avatar?.firstPerson ? small ? 0.22 : 0.08 : 0.05;
5857
- const holdY = avatar?.firstPerson ? small ? -0.2 : -0.26 : -0.28;
5858
- const holdZ = avatar?.firstPerson ? small ? -0.42 : -Math.min(1.15, CARRY_NEAR + heldRadius) : -1.15;
5859
- pos = tmpV.set(holdX, holdY, holdZ).applyMatrix4(camera.matrixWorld);
5860
- const baseQ = avatar?.firstPerson && small ? camera.quaternion : new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), player.yaw);
5861
- gripQuat.copy(baseQ).multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.max(-1.35, -heldTilt)));
6586
+ pos = tmpV.set(0.05, -0.28, -1.15).applyMatrix4(camera.matrixWorld);
6587
+ gripQuat.setFromAxisAngle(new THREE.Vector3(0, 1, 0), player.yaw).multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.max(-1.35, -heldTilt)));
5862
6588
  targetQ.setFromAxisAngle(new THREE.Vector3(0, 1, 0), player.yaw + heldYaw);
5863
- if (avatar?.firstPerson && small && heldTilt <= 1e-4) {
5864
- targetQ.copy(camera.quaternion).multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), heldYaw));
5865
- } else {
5866
- targetQ.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), -heldTilt));
5867
- }
6589
+ targetQ.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), -heldTilt));
5868
6590
  }
5869
6591
  const k = Math.min(1, (useItemPlayer ? 30 : 16) * dt);
5870
6592
  const px = root.position.x;
@@ -5879,8 +6601,50 @@ function createTestRoom(options) {
5879
6601
  heldVel[2] = (root.position.z - pz) / dt;
5880
6602
  }
5881
6603
  root.updateMatrixWorld(true);
5882
- if (avatar?.firstPerson && cameraMode === "first") carriedHand = carriedTargets(root, gripQuat);
6604
+ twistHand = twistTargets(root, gripQuat);
6605
+ };
6606
+ const TOOL_REACH = 1.6;
6607
+ const toolsAtHand = () => {
6608
+ const here = player.position;
6609
+ const near = [...loadout];
6610
+ for (const other of entries.values()) {
6611
+ if (other === heldEntry || other.arena) continue;
6612
+ const p = other.so.root.position;
6613
+ if (Math.hypot(p.x - here[0], p.z - here[2]) <= TOOL_REACH) near.push(other.so.itemId);
6614
+ }
6615
+ return near;
6616
+ };
6617
+ const sealLoss = (e) => {
6618
+ if (e.sealLoss !== void 0) return e.sealLoss;
6619
+ const c = e.containerId ? fluids.container(e.containerId) : void 0;
6620
+ return c && e.fluidStart !== void 0 ? Math.max(0, e.fluidStart - c.liters - (e.fluidUsed ?? 0)) : 0;
6621
+ };
6622
+ const sealState = (e) => {
6623
+ const breached = world.state(e.so.objectId)?.parts.some((p) => p.holes.some((h) => h.through && !h.vent)) ?? false;
6624
+ const leaked = e.fluidStart !== void 0 && sealLoss(e) > Math.max(2e-3, e.fluidStart * 0.02);
6625
+ return { opened: !!e.opened, breached, leaked };
6626
+ };
6627
+ const openSeal = (e) => {
6628
+ const seal = e.item?.seal;
6629
+ if (!seal || e.opened) return true;
6630
+ const opener = sealOpener(seal, toolsAtHand());
6631
+ if (!opener.ok) {
6632
+ const listed = (seal.openWith ?? []).map((id) => catalogMap.get(id)?.name).filter((n) => !!n);
6633
+ const names = [...new Set(listed.length ? listed : (seal.openWith ?? []).filter((id) => arsenal.weapons.has(id)).map((id) => toolName(id)))];
6634
+ const why = t("hud.needs_opener", { name: e.name, tools: names.join(", ") });
6635
+ say(why);
6636
+ action("sealed", why, { objectId: e.so.objectId, itemId: e.so.itemId, data: { openWith: [...seal.openWith ?? []] } });
6637
+ return false;
6638
+ }
6639
+ e.sealLoss = sealLoss(e);
6640
+ e.opened = true;
6641
+ if (e.containerId) fluids.open(e.containerId);
6642
+ seal.open?.(wrapped.get(e.so.root) ?? e.so.root);
6643
+ const tool2 = opener.tool ? catalogMap.get(opener.tool)?.name ?? (arsenal.weapons.has(opener.tool) ? toolName(opener.tool) : opener.tool) : void 0;
6644
+ action("open_seal", tool2 ? t("act.open_with", { name: e.name, tool: tool2 }) : t("act.open_by_hand", { name: e.name }), { objectId: e.so.objectId, itemId: e.so.itemId, data: { tool: opener.tool ?? "hand" } });
6645
+ return true;
5883
6646
  };
6647
+ const crouchOnC = (options.crouch?.keys ?? ["KeyC"]).includes("KeyC");
5884
6648
  const useItem = (useId) => {
5885
6649
  const e = heldEntry;
5886
6650
  if (!e?.containerId) return false;
@@ -5889,25 +6653,90 @@ function createTestRoom(options) {
5889
6653
  const c = fluids.container(e.containerId);
5890
6654
  if (!c) return false;
5891
6655
  if (def.liquids && def.liquids.length > 0 && (!c.liquid || !def.liquids.includes(c.liquid))) return false;
5892
- const consuming = useId === "drink" || useId === "sip" || useId === "chug" || useId === "pour" || useId === "pour_out";
6656
+ const eating = useId === "drink" || useId === "sip" || useId === "chug" || useId === "eat";
6657
+ const consuming = eating || useId === "pour" || useId === "pour_out";
6658
+ if (useItemPlayer && !useItemPlayer.done) return false;
6659
+ const seal = e.item?.seal;
6660
+ if (seal && (consuming || useId === "open")) {
6661
+ const state = sealState(e);
6662
+ const spoiled = sealBlock(seal, { ...state, opened: true });
6663
+ if (eating && spoiled) {
6664
+ say(t(spoiled === "leaked" ? "hud.spoiled_leak" : "hud.spoiled_hole", { name: e.name }));
6665
+ action("spoiled", t(spoiled === "leaked" ? "hud.spoiled_leak" : "hud.spoiled_hole", { name: e.name }), { objectId: e.so.objectId, itemId: e.so.itemId, data: { reason: spoiled } });
6666
+ return false;
6667
+ }
6668
+ if (!e.opened) {
6669
+ const opening = seal.byHand && seal.openUse ? itemUseDefs.find((u) => u.id === seal.openUse) : void 0;
6670
+ if (opening) {
6671
+ useItemPlayer = createItemUse(fluids, e.containerId, opening);
6672
+ useItemName = opening.id;
6673
+ afterOpening = useId === "open" ? null : useId;
6674
+ return true;
6675
+ }
6676
+ if (!openSeal(e)) return false;
6677
+ if (!seal.byHand || useId === "open") {
6678
+ useItemPlayer = createItemUse(fluids, e.containerId, itemUseDefs.find((u) => u.id === "open") ?? def);
6679
+ useItemName = "open";
6680
+ return true;
6681
+ }
6682
+ } else if (useId === "open") return false;
6683
+ }
5893
6684
  if (consuming && c.liters <= 1e-6) {
5894
6685
  say(t("hud.empty_container", { name: e.name }));
5895
6686
  return false;
5896
6687
  }
5897
- if (useItemPlayer && !useItemPlayer.done) return false;
5898
6688
  if (consuming && c.closed) fluids.open(e.containerId);
5899
6689
  useItemPlayer = createItemUse(fluids, e.containerId, def);
5900
6690
  useItemName = def.id;
6691
+ if (eating) useLiters = c.liters;
5901
6692
  action("use_item", t("act.use_item", { name: e.name, use: S.text[`use.${def.id}`] ?? def.id }), { objectId: e.so.objectId, itemId: e.so.itemId, data: { use: def.id } });
5902
6693
  return true;
5903
6694
  };
6695
+ let useLiters = null;
5904
6696
  const updateItemUse = (dt) => {
5905
6697
  if (!useItemPlayer) return;
6698
+ const e = heldEntry;
6699
+ const c = e?.containerId ? fluids.container(e.containerId) : void 0;
6700
+ const before = c?.liters;
5906
6701
  useItemPlayer.update(dt);
6702
+ const seal = e?.item?.seal;
6703
+ twisting = null;
6704
+ if (e && seal?.openUse === useItemName && !e.opened) {
6705
+ twisting = openingProgress(useItemPlayer.def, useItemPlayer.time);
6706
+ seal.turn?.(wrapped.get(e.so.root) ?? e.so.root, { turned: twisting.stroke.turned, lift: twisting.lift });
6707
+ if (twisting.lift >= 1 || useItemPlayer.done) {
6708
+ openSeal(e);
6709
+ twisting = null;
6710
+ }
6711
+ }
6712
+ if (e && useLiters !== null && before !== void 0) {
6713
+ const after = (e.containerId ? fluids.container(e.containerId)?.liters : void 0) ?? before;
6714
+ e.fluidUsed = (e.fluidUsed ?? 0) + Math.max(0, before - after);
6715
+ if (e.opened && e.fluidStart) e.item?.seal?.contents?.(wrapped.get(e.so.root) ?? e.so.root, after / e.fluidStart);
6716
+ }
5907
6717
  if (useItemPlayer.done) {
6718
+ if (e && useLiters !== null && useItemName === "eat") {
6719
+ const left = (e.containerId ? fluids.container(e.containerId)?.liters : void 0) ?? 0;
6720
+ action("ate", t(left <= 1e-4 ? "act.ate_all" : "act.ate", { name: e.name, grams: Math.round((useLiters - left) * 1e3) }), { objectId: e.so.objectId, itemId: e.so.itemId, data: { liters: Math.round((useLiters - left) * 1e3) / 1e3 } });
6721
+ }
6722
+ useLiters = null;
5908
6723
  useItemPlayer = null;
5909
6724
  useItemName = "";
5910
- }
6725
+ const next = afterOpening;
6726
+ afterOpening = null;
6727
+ if (next && e?.opened) useItem(next);
6728
+ }
6729
+ };
6730
+ const openingProgress = (def, time) => {
6731
+ const keys = [...def.keys].sort((a, b) => a.t - b.t);
6732
+ const turning = keys.findIndex((k) => k.action === "turn");
6733
+ const lastTurn = keys.map((k) => k.action).lastIndexOf("turn");
6734
+ const start = turning >= 0 ? keys[turning].t : 0;
6735
+ const end = lastTurn >= 0 ? keys[lastTurn + 1]?.t ?? def.duration : start;
6736
+ const openAt = keys.find((k) => k.action === "open")?.t ?? def.duration;
6737
+ const share = end > start ? (time - start) / (end - start) : 1;
6738
+ const lift = time <= end ? 0 : openAt > end ? Math.min(1, (time - end) / (openAt - end)) : 1;
6739
+ return { stroke: twistStroke(share), lift };
5911
6740
  };
5912
6741
  const TAP_ID = "room_tap";
5913
6742
  const TAP_RATE = 0.25;
@@ -5986,7 +6815,7 @@ function createTestRoom(options) {
5986
6815
  if (code === "KeyZ") tiltKey = 0;
5987
6816
  if (code === "KeyE" && pushByKey) {
5988
6817
  pushByKey = false;
5989
- if (push.end()) grab();
6818
+ if (push.end() && !doors.use()) grab();
5990
6819
  }
5991
6820
  return;
5992
6821
  }
@@ -6006,7 +6835,7 @@ function createTestRoom(options) {
6006
6835
  case "KeyE":
6007
6836
  if (e.repeat) break;
6008
6837
  pushByKey = !heldPiece && push.begin();
6009
- if (!pushByKey) grab();
6838
+ if (!pushByKey && (heldPiece || !doors.use())) grab();
6010
6839
  break;
6011
6840
  case "KeyG":
6012
6841
  throwHeld();
@@ -6032,11 +6861,13 @@ function createTestRoom(options) {
6032
6861
  case "KeyZ":
6033
6862
  tiltKey = 1;
6034
6863
  break;
6035
- case "KeyF":
6036
- if (heldEntry?.uses[0]) useItem(heldEntry.uses[0]);
6864
+ case "KeyF": {
6865
+ const use = heldEntry?.uses[e.shiftKey && crouchOnC ? 1 : 0];
6866
+ if (use) useItem(use);
6037
6867
  break;
6868
+ }
6038
6869
  case "KeyC":
6039
- if (heldEntry?.uses[1] && !(options.crouch?.keys ?? ["KeyC"]).includes("KeyC")) useItem(heldEntry.uses[1]);
6870
+ if (heldEntry?.uses[1] && !crouchOnC) useItem(heldEntry.uses[1]);
6040
6871
  break;
6041
6872
  case "KeyV":
6042
6873
  if (heldEntry?.uses[2]) useItem(heldEntry.uses[2]);
@@ -6081,6 +6912,12 @@ function createTestRoom(options) {
6081
6912
  aim();
6082
6913
  return rayWorld(aimO, aimD, 14);
6083
6914
  };
6915
+ const leafNote = (distance) => {
6916
+ if (distance > DOOR_REACH) return void 0;
6917
+ const leaf = doors.aimed();
6918
+ if (!leaf) return void 0;
6919
+ return t(leaf.locked ? "hud.door_hint_locked" : leaf.open ? "hud.door_hint_close" : "hud.door_hint_open");
6920
+ };
6084
6921
  const inspectInfo = () => {
6085
6922
  const r = inspectTarget();
6086
6923
  if (!r) return null;
@@ -6116,7 +6953,7 @@ function createTestRoom(options) {
6116
6953
  load: ext.load,
6117
6954
  stress: ext.stress,
6118
6955
  joints,
6119
- note: e?.item?.note,
6956
+ note: leafNote(r.distance) ?? e?.item?.note,
6120
6957
  thumbnail: e?.item?.thumbnail,
6121
6958
  thumbnailCredit: e?.item?.thumbnailCredit,
6122
6959
  distance: r.distance
@@ -6180,6 +7017,7 @@ function createTestRoom(options) {
6180
7017
  }
6181
7018
  syncLiters();
6182
7019
  renderer.update(dt);
7020
+ for (const e of entries.values()) e.follower?.update?.();
6183
7021
  terrainView.sync(camera.position);
6184
7022
  showGroundFire(dt);
6185
7023
  vfx.update(dt);
@@ -6207,12 +7045,14 @@ function createTestRoom(options) {
6207
7045
  const update = (dt) => {
6208
7046
  if (!ownsLoop) roomMetrics.frame(dt);
6209
7047
  frameStamp++;
7048
+ seating.update();
6210
7049
  player.update(dt);
6211
7050
  if (pushByKey && !player.keys.has("KeyE")) {
6212
7051
  pushByKey = false;
6213
7052
  push.end();
6214
7053
  }
6215
7054
  push.step(paused ? 0 : dt);
7055
+ doors.step(paused ? 0 : dt);
6216
7056
  if (!paused) kickLight(dt);
6217
7057
  if (!paused) pushByPlayer(dt);
6218
7058
  else player.takeWish();
@@ -6228,7 +7068,6 @@ function createTestRoom(options) {
6228
7068
  }
6229
7069
  if (!paused) followHeld(dt);
6230
7070
  stepSim(dt * timeScale);
6231
- stepFirstPerson(paused ? 0 : dt);
6232
7071
  reachAvatar();
6233
7072
  if (progress && simTime - progress.at > 0.25) progress = null;
6234
7073
  hudAcc += dt;
@@ -6236,6 +7075,7 @@ function createTestRoom(options) {
6236
7075
  hudAcc = 0;
6237
7076
  ui.hud(hudState());
6238
7077
  }
7078
+ ui?.flushLog();
6239
7079
  metricsAcc += dt;
6240
7080
  if (metricsAcc >= METRICS_INTERVAL_S && ui) {
6241
7081
  metricsAcc = 0;
@@ -6256,7 +7096,7 @@ function createTestRoom(options) {
6256
7096
  pages: Math.max(1, Math.ceil(loadout.length / 10)),
6257
7097
  status: simTime - statusAt < 2 ? statusLine : "",
6258
7098
  progress: progress ? { label: progress.label, value: progress.value } : null,
6259
- held: heldEntry || heldPiece ? { name: heldName, uses: (heldEntry?.uses ?? []).map((u, i) => ({ key: ["F", "C", "V"][i] ?? "", label: S.text[`use.${u}`] ?? u })), weapon: heldEntry?.item?.weapon ? arsenal.name(heldEntry.item.weapon) : void 0, using: useItemName } : null,
7099
+ held: heldEntry || heldPiece ? { name: heldName, uses: (heldEntry?.uses ?? []).map((u, i) => ({ key: ["F", crouchOnC ? "Shift+F" : "C", "V"][i] ?? "", label: S.text[`use.${u}`] ?? u })), weapon: heldEntry?.item?.weapon ? arsenal.name(heldEntry.item.weapon) : void 0, using: useItemName } : null,
6260
7100
  inspect: inspectInfo(),
6261
7101
  animations: animationsOf(),
6262
7102
  locked: player.locked,
@@ -6297,17 +7137,33 @@ function createTestRoom(options) {
6297
7137
  const pose = poseAt(pl.clip, id === "" ? null : id, pl.t);
6298
7138
  obj.position.set(rest.p[0] + pose.position[0], rest.p[1] + pose.position[1], rest.p[2] + pose.position[2]);
6299
7139
  obj.rotation.set(rest.r[0] + pose.rotation[0], rest.r[1] + pose.rotation[1], rest.r[2] + pose.rotation[2]);
6300
- const ps = id === "" ? void 0 : specs.get(pl.entry.so.objectId)?.parts.find((p) => p.id === id);
6301
- if (ps) {
6302
- const r0 = ps.rotation ?? ZERO;
6303
- world.setPartPose(
6304
- pl.entry.so.objectId,
6305
- id,
6306
- [ps.position[0] + pose.position[0], ps.position[1] + pose.position[1], ps.position[2] + pose.position[2]],
6307
- [r0[0] + pose.rotation[0], r0[1] + pose.rotation[1], r0[2] + pose.rotation[2]]
6308
- );
6309
- }
6310
- }
7140
+ if (id !== "") followPart(pl.entry, id, obj, rest);
7141
+ }
7142
+ };
7143
+ const fpRest = new THREE.Matrix4();
7144
+ const fpNow = new THREE.Matrix4();
7145
+ const fpRoot = new THREE.Matrix4();
7146
+ const fpPart = new THREE.Matrix4();
7147
+ const fpPos = new THREE.Vector3();
7148
+ const fpQuat = new THREE.Quaternion();
7149
+ const fpScale = new THREE.Vector3();
7150
+ const fpEuler = new THREE.Euler();
7151
+ const followPart = (e, id, obj, rest) => {
7152
+ const ps = specs.get(e.so.objectId)?.parts.find((p) => p.id === id);
7153
+ if (!ps || !obj.parent) return;
7154
+ const root = e.so.root;
7155
+ root.updateMatrixWorld(true);
7156
+ fpRoot.copy(root.matrixWorld).invert();
7157
+ fpNow.multiplyMatrices(fpRoot, obj.matrixWorld);
7158
+ fpEuler.set(rest.r[0], rest.r[1], rest.r[2]);
7159
+ fpRest.compose(fpPos.set(rest.p[0], rest.p[1], rest.p[2]), fpQuat.setFromEuler(fpEuler), obj.scale);
7160
+ fpRest.premultiply(obj.parent.matrixWorld).premultiply(fpRoot);
7161
+ fpNow.multiply(fpRest.invert());
7162
+ const r0 = ps.rotation ?? ZERO;
7163
+ fpPart.compose(fpPos.set(ps.position[0], ps.position[1], ps.position[2]), fpQuat.setFromEuler(fpEuler.set(r0[0], r0[1], r0[2])), fpScale.set(1, 1, 1));
7164
+ fpPart.premultiply(fpNow).decompose(fpPos, fpQuat, fpScale);
7165
+ fpEuler.setFromQuaternion(fpQuat);
7166
+ world.setPartPose(e.so.objectId, id, [fpPos.x, fpPos.y, fpPos.z], [fpEuler.x, fpEuler.y, fpEuler.z]);
6311
7167
  };
6312
7168
  const updateAnimations = (dt) => {
6313
7169
  for (let i = playing.length - 1; i >= 0; i--) {
@@ -6318,8 +7174,8 @@ function createTestRoom(options) {
6318
7174
  while (pl.fired < evs.length && evs[pl.fired].t <= pl.t) {
6319
7175
  const ev = evs[pl.fired++];
6320
7176
  action("animation_event", t("act.anim_event", { name: pl.entry.name, clip: pl.clip.id, type: ev.type }), { objectId: pl.entry.so.objectId, itemId: pl.entry.so.itemId, data: { clip: pl.clip.id, type: ev.type, at: ev.t, ...ev.data ?? {} } });
6321
- const key = ev.type === "sound" ? ev.data?.key : void 0;
6322
- if (typeof key === "string") playSound(key, v3(pl.entry.so.root.getWorldPosition(tmpV)), 1);
7177
+ const key2 = ev.type === "sound" ? ev.data?.key : void 0;
7178
+ if (typeof key2 === "string") playSound(key2, v3(pl.entry.so.root.getWorldPosition(tmpV)), 1);
6323
7179
  }
6324
7180
  if (pl.t >= pl.clip.duration) {
6325
7181
  playing.splice(i, 1);
@@ -6341,10 +7197,10 @@ function createTestRoom(options) {
6341
7197
  const targets = /* @__PURE__ */ new Map();
6342
7198
  const ids = /* @__PURE__ */ new Set();
6343
7199
  for (const k of clip.keys) for (const id of Object.keys(k.parts ?? {})) ids.add(id);
6344
- const grab2 = (key, obj) => {
7200
+ const grab2 = (key2, obj) => {
6345
7201
  if (!obj) return;
6346
7202
  if (!restPose.has(obj)) restPose.set(obj, { p: [obj.position.x, obj.position.y, obj.position.z], r: [obj.rotation.x, obj.rotation.y, obj.rotation.z] });
6347
- targets.set(key, obj);
7203
+ targets.set(key2, obj);
6348
7204
  };
6349
7205
  if (clip.keys.some((k) => k.position || k.rotation)) grab2("", e.so.root);
6350
7206
  for (const id of ids) grab2(id, e.so.root.getObjectByName(id) ?? void 0);
@@ -6371,9 +7227,9 @@ function createTestRoom(options) {
6371
7227
  const METRIC_UNAVAILABLE = "n/a";
6372
7228
  const metricText = (value, digits, unit) => typeof value === "number" ? `${num(value, digits)}${unit ? ` ${unit}` : ""}` : value ?? METRIC_UNAVAILABLE;
6373
7229
  const reportedFailures = /* @__PURE__ */ new Set();
6374
- const reportOnce = (key, text) => {
6375
- if (reportedFailures.has(key)) return;
6376
- reportedFailures.add(key);
7230
+ const reportOnce = (key2, text) => {
7231
+ if (reportedFailures.has(key2)) return;
7232
+ reportedFailures.add(key2);
6377
7233
  host.log(text);
6378
7234
  };
6379
7235
  const failureText = (error) => error instanceof Error ? error.message : String(error);
@@ -6523,6 +7379,14 @@ function createTestRoom(options) {
6523
7379
  frameListeners.add(update2);
6524
7380
  return () => frameListeners.delete(update2);
6525
7381
  },
7382
+ holdFrame: () => bodyHoldFrame(),
7383
+ reachFor(reach) {
7384
+ if (!avatar) {
7385
+ reach.onGrip?.();
7386
+ return;
7387
+ }
7388
+ doors.reachFor(reach);
7389
+ },
6526
7390
  setHandTargets(source) {
6527
7391
  handSource = source;
6528
7392
  return () => {
@@ -6540,6 +7404,10 @@ function createTestRoom(options) {
6540
7404
  clearListeners.add(listener);
6541
7405
  return () => clearListeners.delete(listener);
6542
7406
  },
7407
+ onReset(listener) {
7408
+ resetListeners.add(listener);
7409
+ return () => resetListeners.delete(listener);
7410
+ },
6543
7411
  addObject(itemId, place) {
6544
7412
  return addScenery(itemId, place.position, place.rotationY);
6545
7413
  }
@@ -6567,13 +7435,32 @@ function createTestRoom(options) {
6567
7435
  return r.ok ? store : null;
6568
7436
  };
6569
7437
  const LIGHTS_CHECK_MS = 250;
7438
+ const pinnedPrograms = /* @__PURE__ */ new WeakSet();
7439
+ const pinPrograms = () => {
7440
+ for (const program of gl.info?.programs ?? []) {
7441
+ if (pinnedPrograms.has(program)) continue;
7442
+ pinnedPrograms.add(program);
7443
+ program.usedTimes++;
7444
+ }
7445
+ };
6570
7446
  const warmUp = () => {
6571
7447
  const probes = [renderer.warmUpProbes(), vfx.warmUpProbes(), fluidView.warmUpProbes()];
6572
7448
  const groups = probes.map((probe) => probe.group);
7449
+ const spriteMap = new THREE.DataTexture(new Uint8Array([255, 255, 255, 255]), 1, 1);
7450
+ spriteMap.needsUpdate = true;
7451
+ const spriteProbes = [true, false].map((fog) => {
7452
+ const probe = new THREE.Sprite(new THREE.SpriteMaterial({ map: spriteMap, transparent: true, fog }));
7453
+ probe.frustumCulled = false;
7454
+ return probe;
7455
+ });
7456
+ groups.push(...spriteProbes);
6573
7457
  scene.add(...groups);
6574
7458
  gl.render(scene, camera);
7459
+ pinPrograms();
6575
7460
  scene.remove(...groups);
6576
7461
  for (const probe of probes) probe.dispose();
7462
+ for (const probe of spriteProbes) probe.material.dispose();
7463
+ spriteMap.dispose();
6577
7464
  };
6578
7465
  const warmUpEffects = () => {
6579
7466
  const ids = vfx.catalog?.() ?? [];
@@ -6604,11 +7491,14 @@ function createTestRoom(options) {
6604
7491
  build: (T) => ti.build(T),
6605
7492
  spec: ti.spec,
6606
7493
  movable: ti.movable,
7494
+ pushable: ti.pushable,
6607
7495
  slow: ti.slow,
6608
7496
  fluid: ti.fluid,
7497
+ seal: ti.seal,
6609
7498
  weapon: ti.weapon,
6610
7499
  impl: ti.impl,
6611
7500
  animations: ti.animations,
7501
+ seats: ti.seats,
6612
7502
  thumbnail: ti.thumbnail,
6613
7503
  thumbnailCredit: ti.thumbnailCredit
6614
7504
  };
@@ -6665,15 +7555,20 @@ function createTestRoom(options) {
6665
7555
  const kept = previous?.encode();
6666
7556
  previous?.dispose();
6667
7557
  benches.delete(mod.id);
6668
- for (const key of [...reportedFailures]) if (key.startsWith(`${mod.id}:`)) reportedFailures.delete(key);
7558
+ for (const key2 of [...reportedFailures]) if (key2.startsWith(`${mod.id}:`)) reportedFailures.delete(key2);
6669
7559
  modules.set(mod.id, mod);
6670
7560
  if (bench) {
6671
7561
  const seed = options.bench?.state?.[mod.id];
6672
7562
  const restored = kept ?? (typeof seed === "string" ? seed : void 0);
6673
7563
  if (restored !== void 0) bench.restore(restored);
6674
7564
  benches.set(mod.id, bench);
7565
+ let reportedState = bench.encode();
6675
7566
  bench.watch(() => {
6676
- options.bench?.onChange?.(mod.id, bench.encode());
7567
+ const encoded = bench.encode();
7568
+ if (encoded !== reportedState) {
7569
+ reportedState = encoded;
7570
+ options.bench?.onChange?.(mod.id, encoded);
7571
+ }
6677
7572
  if (activeBench === mod.id) ui?.bench(benchView());
6678
7573
  });
6679
7574
  if (!activeBench || !benches.has(activeBench)) activeBench = mod.id;
@@ -6695,8 +7590,10 @@ function createTestRoom(options) {
6695
7590
  const dt = seconds > 6 ? 1 / 30 : FIXED_DT;
6696
7591
  const n = Math.max(1, Math.round(seconds / dt));
6697
7592
  for (let i = 0; i < n; i++) {
7593
+ seating.update();
6698
7594
  player.update(0);
6699
7595
  push.step(dt);
7596
+ doors.step(dt);
6700
7597
  followHeld(dt);
6701
7598
  stepSim(dt);
6702
7599
  }
@@ -6831,7 +7728,7 @@ function createTestRoom(options) {
6831
7728
  room.waitUntilReady();
6832
7729
  return true;
6833
7730
  },
6834
- look: (eye, target) => player.look(eye, target),
7731
+ look: (eye, target) => lookAt(eye, target),
6835
7732
  use: (hold) => room.use(hold),
6836
7733
  blastAt: (weapon, point) => {
6837
7734
  const def = arsenal.weapons.get(weapon);
@@ -6887,7 +7784,7 @@ function createTestRoom(options) {
6887
7784
  grab,
6888
7785
  throwHeld,
6889
7786
  look(position, target) {
6890
- player.look(position, target);
7787
+ lookAt(position, target);
6891
7788
  },
6892
7789
  inspect() {
6893
7790
  const r = inspectTarget();
@@ -6954,11 +7851,23 @@ function createTestRoom(options) {
6954
7851
  busy: (label, work, busyOptions) => loading.busy(label, work, busyOptions),
6955
7852
  loadModule,
6956
7853
  warmUp: () => {
7854
+ audio?.warmUp();
6957
7855
  warmUp();
6958
7856
  warmUpEffects();
6959
7857
  },
6960
7858
  animations: animationsOf,
6961
7859
  playAnimation,
7860
+ useLeaf: (objectId, part) => doors.useLeaf(objectId, part),
7861
+ leafOpening: (objectId, part) => doors.opening(objectId, part ?? leavesOf(objectId)[0]?.part ?? ""),
7862
+ leaves: (objectId) => leavesOf(objectId).map((l) => l.part),
7863
+ lockLeaf(objectId, locked = true, part) {
7864
+ const k = `${objectId}\0${part ?? "*"}`;
7865
+ if (locked) lockedLeaves.add(k);
7866
+ else lockedLeaves.delete(k);
7867
+ },
7868
+ get door() {
7869
+ return doors.state;
7870
+ },
6962
7871
  bench(moduleId) {
6963
7872
  const id = moduleId ?? activeBench;
6964
7873
  return (id ? benches.get(id) : void 0) ?? null;
@@ -7001,6 +7910,11 @@ function createTestRoom(options) {
7001
7910
  get playerPosition() {
7002
7911
  return player.position;
7003
7912
  },
7913
+ sit: (objectId, seatId) => seating.sit(objectId, seatId),
7914
+ stand: () => seating.stand(),
7915
+ get seat() {
7916
+ return seating.seat;
7917
+ },
7004
7918
  push: { begin: () => push.begin(), end: () => push.end(), setWalk: (walk) => push.setWalk(walk), get state() {
7005
7919
  return push.state;
7006
7920
  } },
@@ -7103,8 +8017,8 @@ function createTestRoom(options) {
7103
8017
  },
7104
8018
  onTap: () => toggleTap(),
7105
8019
  ...combo ? { combo: { label: combo.name?.[locale] ?? combo.id, onToggle: () => setStrikeCombo(!comboOn) } } : {},
7106
- ...arsenal.weapons.has("kick") ? { kick: { label: `${t("ui.kick")} (${kickKeys.map((key) => key.replace(/^Key/, "")).join("/")})`, onKick: () => void kick() } } : {},
7107
- onBlast: (key) => toggleBlast(key),
8020
+ ...arsenal.weapons.has("kick") ? { kick: { label: `${t("ui.kick")} (${kickKeys.map((key2) => key2.replace(/^Key/, "")).join("/")})`, onKick: () => void kick() } } : {},
8021
+ onBlast: (key2) => toggleBlast(key2),
7108
8022
  onPlay: (objectId, clipId) => {
7109
8023
  playAnimation(clipId, objectId);
7110
8024
  },
@@ -7130,19 +8044,19 @@ function createTestRoom(options) {
7130
8044
  let nextLightsCheck = 0;
7131
8045
  let warmedLights = "";
7132
8046
  const lightsKey = () => {
7133
- let key = "";
8047
+ let key2 = "";
7134
8048
  scene.traverseVisible((object) => {
7135
- if (object.isLight) key += `${object.type[0]}${object.castShadow ? "s" : ""}`;
8049
+ if (object.isLight) key2 += `${object.type[0]}${object.castShadow ? "s" : ""}`;
7136
8050
  });
7137
- return key;
8051
+ return key2;
7138
8052
  };
7139
8053
  const loop = (now) => {
7140
8054
  raf = requestAnimationFrame(loop);
7141
8055
  if (now >= nextLightsCheck) {
7142
8056
  nextLightsCheck = now + LIGHTS_CHECK_MS;
7143
- const key = lightsKey();
7144
- if (key !== warmedLights) {
7145
- warmedLights = key;
8057
+ const key2 = lightsKey();
8058
+ if (key2 !== warmedLights) {
8059
+ warmedLights = key2;
7146
8060
  warmUp();
7147
8061
  }
7148
8062
  }
@@ -7153,6 +8067,7 @@ function createTestRoom(options) {
7153
8067
  if (!scenarioRunning) update(dt);
7154
8068
  const restoreCamera = room.applyCamera();
7155
8069
  gl.render(scene, debugView ?? camera);
8070
+ pinPrograms();
7156
8071
  restoreCamera();
7157
8072
  roomMetrics.rendered();
7158
8073
  };