@adatechnology/game-test-room 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -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
  };
@@ -569,8 +569,8 @@ function createExplosives(context) {
569
569
  age: 0,
570
570
  ...use.explosive !== void 0 ? { body: createExplosiveBody(use, arsenal.explosives, arsenal.rules.explosives), aligned: isRocket } : {}
571
571
  });
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) }), {
572
+ const key2 = isRocket ? "act.launch" : isMolotov ? "act.throw.molotov" : "act.throw.grenade";
573
+ action(isRocket ? "launch_projectile" : "throw_projectile", translate(key2, { weapon: context.toolName(), fuse: format(use.fuse, 1), arm: format(use.armDistance ?? 0, 0) }), {
574
574
  data: { weapon: context.toolId(), fuse: use.fuse, explosive: use.explosive }
575
575
  });
576
576
  };
@@ -1229,9 +1229,9 @@ function createLog(options = {}) {
1229
1229
  };
1230
1230
  const log = {
1231
1231
  add,
1232
- coalesce(key, entry, windowS = 1) {
1232
+ coalesce(key2, entry, windowS = 1) {
1233
1233
  const now = entry.t ?? clock();
1234
- const k = keyed.get(key);
1234
+ const k = keyed.get(key2);
1235
1235
  if (k && now - k.last <= windowS && list.includes(k.entry)) {
1236
1236
  const { t: _t, ...rest } = entry;
1237
1237
  k.count++;
@@ -1242,7 +1242,7 @@ function createLog(options = {}) {
1242
1242
  return k.entry;
1243
1243
  }
1244
1244
  const e = add(entry);
1245
- keyed.set(key, { entry: e, last: now, count: 1 });
1245
+ keyed.set(key2, { entry: e, last: now, count: 1 });
1246
1246
  return e;
1247
1247
  },
1248
1248
  setBeforeRead(fn) {
@@ -1378,7 +1378,7 @@ function createNarrator(c) {
1378
1378
  if (p) materialMemo.set(`${objectId}|${partId}`, p.material);
1379
1379
  return p;
1380
1380
  };
1381
- const plural = (key, n) => t(`${key}.${n === 1 ? "one" : "other"}`, { n });
1381
+ const plural = (key2, n) => t(`${key2}.${n === 1 ? "one" : "other"}`, { n });
1382
1382
  const pieces = (n) => plural("count.piece", n);
1383
1383
  const joints = (n) => plural("count.joint", n);
1384
1384
  const xz = (x, z) => t("coords", { x: num(x, 1), z: num(z, 1) });
@@ -1442,18 +1442,18 @@ function createNarrator(c) {
1442
1442
  if (ev.by === "part_lost") {
1443
1443
  const [a, b] = ev.parts;
1444
1444
  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 };
1445
+ const key2 = `${ev.object}|${lost}`;
1446
+ const g = lostJoints.get(key2) ?? { obj: ev.object, part: lost, joints: 0, groups: 0, merged: false };
1447
1447
  g.joints++;
1448
1448
  g.groups = ev.groups.length;
1449
- lostJoints.set(key, g);
1449
+ lostJoints.set(key2, g);
1450
1450
  } 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 };
1451
+ const key2 = `${ev.object}|${ev.by}`;
1452
+ const g = openJoints.get(key2) ?? { obj: ev.object, by: ev.by, list: [], groups: 0, done: false };
1453
1453
  const [a, b] = ev.parts;
1454
1454
  g.list.push({ joint: ev.joint, a, b });
1455
1455
  g.groups = ev.groups.length;
1456
- openJoints.set(key, g);
1456
+ openJoints.set(key2, g);
1457
1457
  }
1458
1458
  } else if (ev.type === "ignite") {
1459
1459
  const g = fireParts.get(ev.object) ?? { obj: ev.object, parts: [], temp: 0, done: false };
@@ -1466,14 +1466,14 @@ function createNarrator(c) {
1466
1466
  outParts.set(ev.object, g);
1467
1467
  }
1468
1468
  }
1469
- const lostTail = (key) => {
1470
- const g = lostJoints.get(key);
1469
+ const lostTail = (key2) => {
1470
+ const g = lostJoints.get(key2);
1471
1471
  if (!g) return "";
1472
1472
  g.merged = true;
1473
1473
  return t("ev.lost.tail", { joints: joints(g.joints), pieces: pieces(g.groups) });
1474
1474
  };
1475
- const lostData = (key) => {
1476
- const g = lostJoints.get(key);
1475
+ const lostData = (key2) => {
1476
+ const g = lostJoints.get(key2);
1477
1477
  return g ? { jointsOpened: g.joints, pieces: g.groups } : {};
1478
1478
  };
1479
1479
  for (const ev of batch) {
@@ -1597,8 +1597,8 @@ function createNarrator(c) {
1597
1597
  if (ev.by === "part_lost") {
1598
1598
  const [a2, b2] = ev.parts;
1599
1599
  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);
1600
+ const key3 = `${ev.object}|${lost}`;
1601
+ const g2 = lostJoints.get(key3);
1602
1602
  if (!g2 || g2.merged) break;
1603
1603
  const hasOwnLine = batch.some((e) => (e.type === "fracture" || e.type === "burnout") && e.object === ev.object && e.part === lost);
1604
1604
  if (hasOwnLine) break;
@@ -1609,12 +1609,12 @@ function createNarrator(c) {
1609
1609
  ...base(ev, lost),
1610
1610
  type: "detach",
1611
1611
  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) }
1612
+ data: { parts: [a2, b2], by: ev.by, ...lostData(key3) }
1613
1613
  });
1614
1614
  break;
1615
1615
  }
1616
- const key = `${ev.object}|${ev.by}`;
1617
- const g = openJoints.get(key);
1616
+ const key2 = `${ev.object}|${ev.by}`;
1617
+ const g = openJoints.get(key2);
1618
1618
  if (!g || g.done) break;
1619
1619
  g.done = true;
1620
1620
  const [a, b] = ev.parts;
@@ -1767,13 +1767,13 @@ function createNarrator(c) {
1767
1767
  };
1768
1768
  const kindMemo = /* @__PURE__ */ new Map();
1769
1769
  const kindOf = (objectId, jointId) => {
1770
- const key = `${objectId}|${jointId}`;
1770
+ const key2 = `${objectId}|${jointId}`;
1771
1771
  const own = c.world.state(objectId)?.joints.find((j) => j.id === jointId)?.kind;
1772
1772
  if (own) {
1773
- kindMemo.set(key, own);
1773
+ kindMemo.set(key2, own);
1774
1774
  return own;
1775
1775
  }
1776
- const known = kindMemo.get(key);
1776
+ const known = kindMemo.get(key2);
1777
1777
  if (known) return known;
1778
1778
  for (const id of c.world.ids()) {
1779
1779
  const k = c.world.state(id)?.joints.find((j) => j.id === jointId)?.kind;
@@ -1809,6 +1809,7 @@ function createNarrator(c) {
1809
1809
  break;
1810
1810
  }
1811
1811
  case "drink": {
1812
+ if (ev.eaten) break;
1812
1813
  const now = c.now();
1813
1814
  let acc = drinkAcc.get(ev.container);
1814
1815
  if (!acc || now - acc.last > 0.5) acc = { liters: 0, thirst: 0, burn: false, effects: /* @__PURE__ */ new Set(), last: now, start: now };
@@ -1842,12 +1843,12 @@ function createNarrator(c) {
1842
1843
  const surface = t(`surface.${ev.surface}`);
1843
1844
  const liquid = c.liquidName(ev.liquid);
1844
1845
  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);
1846
+ const key2 = `stain:${ev.liquid}:${ev.surface}`;
1847
+ const acc = stainAcc.get(key2);
1847
1848
  const n = acc && c.now() - acc.last <= 1 ? acc.n + 1 : 1;
1848
- stainAcc.set(key, { n, last: c.now() });
1849
+ stainAcc.set(key2, { n, last: c.now() });
1849
1850
  c.log.coalesce(
1850
- key,
1851
+ key2,
1851
1852
  {
1852
1853
  channel: "event",
1853
1854
  type: f + "stain",
@@ -1860,13 +1861,13 @@ function createNarrator(c) {
1860
1861
  }
1861
1862
  case "dried": {
1862
1863
  const info = stainInfo.get(ev.stain);
1863
- const key = `dried:${info?.liquid ?? "?"}:${ev.mark}`;
1864
- const acc = driedAcc.get(key);
1864
+ const key2 = `dried:${info?.liquid ?? "?"}:${ev.mark}`;
1865
+ const acc = driedAcc.get(key2);
1865
1866
  const n = acc && c.now() - acc.last <= 1.5 ? acc.n + 1 : 1;
1866
- driedAcc.set(key, { n, last: c.now() });
1867
+ driedAcc.set(key2, { n, last: c.now() });
1867
1868
  const vars = { liquid: info ? c.liquidName(info.liquid) : "", surface: info?.surface ?? "", where: info ? xz(info.x, info.z) : "", n };
1868
1869
  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);
1870
+ c.log.coalesce(key2, { channel: "event", type: f + "dried", text, data: { stain: ev.stain, mark: ev.mark, count: n } }, 1.5);
1870
1871
  break;
1871
1872
  }
1872
1873
  case "mix":
@@ -1979,6 +1980,7 @@ function createPlayer(THREE, o) {
1979
1980
  let useStarted = false;
1980
1981
  let holdTimer = null;
1981
1982
  let walkHeld = false;
1983
+ let seated = false;
1982
1984
  let lookLimit = null;
1983
1985
  const look = (dx, dy) => {
1984
1986
  yaw -= dx * LOOK;
@@ -2119,6 +2121,11 @@ function createPlayer(THREE, o) {
2119
2121
  setWalkHeld(held) {
2120
2122
  walkHeld = held;
2121
2123
  },
2124
+ setSeated(value) {
2125
+ seated = value;
2126
+ y = 0;
2127
+ vy = 0;
2128
+ },
2122
2129
  setBend(metres) {
2123
2130
  bendTarget = metres;
2124
2131
  },
@@ -2171,6 +2178,11 @@ function createPlayer(THREE, o) {
2171
2178
  if ((walkX || walkZ) && !walkHeld) move(walkX * speed * dt, walkZ * speed * dt);
2172
2179
  walked = dt > 0 ? Math.hypot(px.x - fromX, px.z - fromZ) / dt : 0;
2173
2180
  running = run && walked > 0;
2181
+ if (seated) {
2182
+ followFloor(dt);
2183
+ apply();
2184
+ return;
2185
+ }
2174
2186
  const floor = Math.max(floorHere(), o.footing?.(px.x, px.z, PLAYER_RADIUS * FOOT, y).floor ?? -Infinity);
2175
2187
  if (y < floor && vy <= 0) y = floor;
2176
2188
  if (y <= floor && keys.has("Space") && !walkHeld) vy = JUMP;
@@ -2318,7 +2330,7 @@ function createRoomPush(options) {
2318
2330
  }
2319
2331
  control.release();
2320
2332
  player.setWalkHeld(false);
2321
- player.setBend(0);
2333
+ if (!options.hasBody?.()) player.setBend(0);
2322
2334
  player.setLookLimit(null);
2323
2335
  options.ring(null);
2324
2336
  event("release", lean);
@@ -2380,7 +2392,7 @@ function createRoomPush(options) {
2380
2392
  control.begin(material === void 0 ? { massKg } : { massKg, material });
2381
2393
  dragged = 0;
2382
2394
  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))));
2395
+ if (isLow && !options.hasBody?.()) player.setBend(Math.min(MAX_BEND, Math.max(0, STANDING_EYE - (max[1] - player.feet + LOW_EYE_OVER_TOP))));
2384
2396
  player.setLookLimit({ yaw: Math.atan2(-into[0], -into[1]), range: LOOK_RANGE });
2385
2397
  event("grip", leaning);
2386
2398
  return true;
@@ -2440,6 +2452,501 @@ function createRoomPush(options) {
2440
2452
  };
2441
2453
  }
2442
2454
 
2455
+ // src/seating.ts
2456
+ var SIT_EYE = 0.62;
2457
+ var LIE_EYE = 0.18;
2458
+ var STANDING_EYE2 = 1.7;
2459
+ var LOOK_RANGE2 = 1.6;
2460
+ function createSeating(o) {
2461
+ const { THREE, player } = o;
2462
+ const v = new THREE.Vector3();
2463
+ const q = new THREE.Quaternion();
2464
+ const e = new THREE.Euler();
2465
+ let current = null;
2466
+ let facing = 0;
2467
+ const toWorld = (root, local) => {
2468
+ root.updateWorldMatrix(true, false);
2469
+ v.set(local[0], local[1], local[2]).applyMatrix4(root.matrixWorld);
2470
+ return [v.x, v.y, v.z];
2471
+ };
2472
+ const yawOf = (root) => {
2473
+ root.getWorldQuaternion(q);
2474
+ return e.setFromQuaternion(q, "YXZ").y;
2475
+ };
2476
+ const nearest = (holder) => {
2477
+ let best;
2478
+ let bestDistance = Infinity;
2479
+ for (const seat of holder.seats) {
2480
+ const [x, , z] = toWorld(holder.root, seat.pos);
2481
+ const distance = Math.hypot(x - player.x, z - player.z);
2482
+ if (distance < bestDistance) {
2483
+ best = seat;
2484
+ bestDistance = distance;
2485
+ }
2486
+ }
2487
+ return best;
2488
+ };
2489
+ const hold = () => {
2490
+ player.setSeated(true);
2491
+ player.setWalkHeld(true);
2492
+ player.setLookLimit({ yaw: facing, range: LOOK_RANGE2 });
2493
+ };
2494
+ const release = () => {
2495
+ player.setSeated(false);
2496
+ player.setWalkHeld(false);
2497
+ player.setLookLimit(null);
2498
+ current = null;
2499
+ };
2500
+ const seating = {
2501
+ sit(objectId, seatId) {
2502
+ const holder = objectId ? o.find(objectId) : o.pick();
2503
+ if (!holder) return null;
2504
+ const seat = seatId ? holder.seats.find((s) => s.id === seatId) : nearest(holder);
2505
+ if (!seat) return null;
2506
+ const [x, y, z] = toWorld(holder.root, seat.pos);
2507
+ const eye = o.hasBody?.() ? STANDING_EYE2 : seat.eye ?? (seat.pose === "lie" ? LIE_EYE : SIT_EYE);
2508
+ facing = yawOf(holder.root) + seat.ry + Math.PI;
2509
+ player.setPose([x, y + eye, z], facing, 0);
2510
+ current = { objectId: holder.objectId, seatId: seat.id, pose: seat.pose, hips: [x, y, z] };
2511
+ hold();
2512
+ o.onSit?.(current);
2513
+ return current;
2514
+ },
2515
+ stand() {
2516
+ if (!current) return false;
2517
+ const holder = o.find(current.objectId);
2518
+ const seat = holder?.seats.find((s) => s.id === current?.seatId);
2519
+ if (holder && seat) {
2520
+ const [x, , z] = toWorld(holder.root, seat.exit);
2521
+ if (!player.canStand(x, z)) return false;
2522
+ player.setPose([x, o.groundAt(x, z) + STANDING_EYE2, z], player.yaw, 0);
2523
+ } else player.setPose([player.x, o.groundAt(player.x, player.z) + STANDING_EYE2, player.z], player.yaw, 0);
2524
+ const left = current;
2525
+ release();
2526
+ o.onStand?.(left);
2527
+ return true;
2528
+ },
2529
+ update() {
2530
+ if (!current) return;
2531
+ if (!o.find(current.objectId)) {
2532
+ seating.stand();
2533
+ return;
2534
+ }
2535
+ const [walkX, walkZ] = player.walkIntent;
2536
+ if (walkX || walkZ || player.keys.has("Space")) {
2537
+ seating.stand();
2538
+ return;
2539
+ }
2540
+ hold();
2541
+ },
2542
+ release,
2543
+ get seat() {
2544
+ return current;
2545
+ }
2546
+ };
2547
+ return seating;
2548
+ }
2549
+
2550
+ // src/door-open.ts
2551
+ import { createDoorUse, DOOR_USE_DEFAULTS } from "@adatechnology/game-player";
2552
+ var DOOR_REACH = 2.2;
2553
+ var NEAR_LEAF = 0.25;
2554
+ var NEAR_OPEN_LEAF = 0.9;
2555
+ var BODY_HEIGHT = 1.8;
2556
+ var HAND_REST = [0.22, -0.42, -0.32];
2557
+ var SKIN = 0.01;
2558
+ var HANDLE_INSET = 0.06;
2559
+ var STANDOFF2 = 0.55;
2560
+ var DRAWER_TRAVEL = 0.3;
2561
+ var CLEAR_OF_ARC = 0.3;
2562
+ var OUT_IN_FRONT = 0.42;
2563
+ var ARRIVED = 0.05;
2564
+ var TURN_RATE = 8;
2565
+ var MAX_WALK_SECONDS = 1.5;
2566
+ var APPROACH_SPEED2 = 3;
2567
+ var FLOOR_GAP = 0.03;
2568
+ var key = (objectId, part) => `${objectId}\0${part}`;
2569
+ var slides = (clip, part) => {
2570
+ const last = clip.keys[clip.keys.length - 1]?.parts?.[part];
2571
+ const r = last?.rotation ?? [0, 0, 0];
2572
+ return Math.abs(r[0]) + Math.abs(r[1]) + Math.abs(r[2]) < 1e-3;
2573
+ };
2574
+ function createRoomDoors(options) {
2575
+ const { THREE, player, world, camera, arena } = options;
2576
+ const control = createDoorUse(options.rules);
2577
+ const openings = /* @__PURE__ */ new Map();
2578
+ const box2 = new THREE.Box3();
2579
+ const before = new THREE.Box3();
2580
+ const tmp = new THREE.Vector3();
2581
+ const centre = new THREE.Vector3();
2582
+ let using = null;
2583
+ let reaching = null;
2584
+ const rules = { ...DOOR_USE_DEFAULTS, ...options.rules };
2585
+ const holding = () => {
2586
+ if (reaching) {
2587
+ const h = reaching.reach.handle();
2588
+ return { phase: reaching.phase, progress: reaching.progress, at: new THREE.Vector3(h[0], h[1], h[2]), pull: reaching.reach.pull ?? true };
2589
+ }
2590
+ if (!using || control.phase === "idle") return null;
2591
+ using.obj.updateMatrixWorld(true);
2592
+ return { phase: control.phase, progress: using.progress, at: using.obj.localToWorld(new THREE.Vector3().copy(using.grip)), pull: using.pull };
2593
+ };
2594
+ const standFor = (handle, hinge, slide) => {
2595
+ if (!hinge || slide) {
2596
+ const dx = handle.x - player.x;
2597
+ const dz = handle.z - player.z;
2598
+ const d = Math.hypot(dx, dz) || 1;
2599
+ const off = slide ? STANDOFF2 + DRAWER_TRAVEL : STANDOFF2;
2600
+ return [handle.x - dx / d * off, handle.z - dz / d * off];
2601
+ }
2602
+ let ax = handle.x - hinge.x;
2603
+ let az = handle.z - hinge.z;
2604
+ const width = Math.hypot(ax, az);
2605
+ if (width < 0.05) return standFor(handle, null, false);
2606
+ ax /= width;
2607
+ az /= width;
2608
+ let nx = -az;
2609
+ let nz = ax;
2610
+ if ((player.x - handle.x) * nx + (player.z - handle.z) * nz < 0) {
2611
+ nx = -nx;
2612
+ nz = -nz;
2613
+ }
2614
+ const x = hinge.x + ax * (width + CLEAR_OF_ARC) + nx * OUT_IN_FRONT;
2615
+ const z = hinge.z + az * (width + CLEAR_OF_ARC) + nz * OUT_IN_FRONT;
2616
+ return player.canStand(x, z) ? [x, z] : standFor(handle, null, false);
2617
+ };
2618
+ const face = (handle, dt) => {
2619
+ camera.updateMatrixWorld(true);
2620
+ const eye = camera.getWorldPosition(new THREE.Vector3());
2621
+ const dx = handle.x - player.x;
2622
+ const dz = handle.z - player.z;
2623
+ const flat = Math.hypot(dx, dz);
2624
+ if (flat < 0.05) return;
2625
+ const yaw = Math.atan2(-dx, -dz);
2626
+ const pitch = Math.atan2(handle.y - eye.y, flat);
2627
+ let dYaw = yaw - player.yaw;
2628
+ dYaw = Math.atan2(Math.sin(dYaw), Math.cos(dYaw));
2629
+ const k = Math.min(1, dt * TURN_RATE);
2630
+ player.setPose([player.x, eye.y, player.z], player.yaw + dYaw * k, player.pitch + (pitch - player.pitch) * k);
2631
+ };
2632
+ const approach = (handle, hinge, slide, dt, walked) => {
2633
+ const [x, z] = standFor(handle, hinge, slide);
2634
+ const dx = x - player.x;
2635
+ const dz = z - player.z;
2636
+ const d = Math.hypot(dx, dz);
2637
+ if (d < ARRIVED || walked > MAX_WALK_SECONDS) return true;
2638
+ const go = Math.min(d, APPROACH_SPEED2 * dt);
2639
+ if (go < 1e-4) return false;
2640
+ const x0 = player.x;
2641
+ const z0 = player.z;
2642
+ player.moveBy(dx / d * go, dz / d * go);
2643
+ return Math.hypot(player.x - x0, player.z - z0) < go * 0.2;
2644
+ };
2645
+ const openingOf = (objectId, part) => openings.get(key(objectId, part)) ?? 0;
2646
+ const lastGoals = /* @__PURE__ */ new Map();
2647
+ const leafAt = (objectId, part, point) => {
2648
+ const leaves = options.leaves(objectId);
2649
+ if (leaves.length === 0) return null;
2650
+ const exact = leaves.find((l) => l.part === part);
2651
+ if (exact) return exact;
2652
+ let best = null;
2653
+ let bestD = NEAR_LEAF;
2654
+ let open = null;
2655
+ let openD = NEAR_OPEN_LEAF;
2656
+ for (const l of leaves) {
2657
+ const obj = options.objectOf(objectId, l.part);
2658
+ if (!obj) continue;
2659
+ box2.setFromObject(obj);
2660
+ const d = box2.distanceToPoint(tmp.set(point[0], point[1], point[2]));
2661
+ if (d < bestD) {
2662
+ bestD = d;
2663
+ best = l;
2664
+ }
2665
+ if (openingOf(objectId, l.part) > 0.5 && d < openD) {
2666
+ openD = d;
2667
+ open = l;
2668
+ }
2669
+ }
2670
+ return best ?? open;
2671
+ };
2672
+ const picked = () => {
2673
+ for (const hit of options.pick()) {
2674
+ const leaf = leafAt(hit.objectId, hit.part, hit.point);
2675
+ if (leaf) return { hit, leaf };
2676
+ }
2677
+ return null;
2678
+ };
2679
+ const massOf = (objectId, part) => world.part(objectId, part)?.mass ?? 10;
2680
+ 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 } : {} });
2681
+ const overlaps2d = (b, c) => b.max.x > c[0] && b.min.x < c[2] && b.max.z > c[1] && b.min.z < c[3];
2682
+ const bodyOverlap = (b) => {
2683
+ if (b.min.y > player.feet + BODY_HEIGHT || b.max.y < player.feet + 0.05) return null;
2684
+ const cx = Math.max(b.min.x, Math.min(b.max.x, player.x));
2685
+ const cz = Math.max(b.min.z, Math.min(b.max.z, player.z));
2686
+ const dx = player.x - cx;
2687
+ const dz = player.z - cz;
2688
+ const d = Math.hypot(dx, dz);
2689
+ if (d >= PLAYER_RADIUS) return null;
2690
+ if (d < 1e-4) {
2691
+ const outs = [
2692
+ [b.max.x - player.x, 1, 0],
2693
+ [player.x - b.min.x, -1, 0],
2694
+ [b.max.z - player.z, 0, 1],
2695
+ [player.z - b.min.z, 0, -1]
2696
+ ];
2697
+ outs.sort((a, c) => a[0] - c[0]);
2698
+ const o = outs[0];
2699
+ return [o[1] * (o[0] + PLAYER_RADIUS), o[2] * (o[0] + PLAYER_RADIUS)];
2700
+ }
2701
+ const push = PLAYER_RADIUS - d + SKIN;
2702
+ return [dx / d * push, dz / d * push];
2703
+ };
2704
+ const isFree = (u, from, to) => {
2705
+ const { objectId, leaf, obj } = u;
2706
+ obj.updateMatrixWorld(true);
2707
+ before.setFromObject(obj);
2708
+ options.pose(objectId, leaf.part, leaf.clip, to);
2709
+ obj.updateMatrixWorld(true);
2710
+ box2.setFromObject(obj);
2711
+ box2.expandByScalar(-SKIN);
2712
+ let by = null;
2713
+ 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";
2714
+ else
2715
+ for (let i = 0; i < arena.colliders.length; i++) {
2716
+ const c = arena.colliders[i];
2717
+ if (arena.ids[i] === objectId) continue;
2718
+ if (overlaps2d(box2, c) && !overlaps2d(before, c)) {
2719
+ by = arena.ids[i] ?? "wall";
2720
+ break;
2721
+ }
2722
+ }
2723
+ if (!by) {
2724
+ before.getCenter(centre);
2725
+ box2.getCenter(tmp);
2726
+ const dir = [tmp.x - centre.x, tmp.y - centre.y, tmp.z - centre.z];
2727
+ const len = Math.hypot(dir[0], dir[1], dir[2]);
2728
+ if (len > 1e-5) {
2729
+ 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 });
2730
+ if (hit && hit.normal[1] < 0.7) by = hit.object;
2731
+ }
2732
+ }
2733
+ if (!by) {
2734
+ const out = bodyOverlap(box2);
2735
+ if (out) {
2736
+ const x0 = player.x;
2737
+ const z0 = player.z;
2738
+ player.moveBy(out[0], out[1]);
2739
+ if (Math.hypot(player.x - x0, player.z - z0) < Math.hypot(out[0], out[1]) * 0.5) by = "player";
2740
+ else if (u.phase !== "swing" || from === 0) event("stepped_back", u);
2741
+ }
2742
+ }
2743
+ if (by) {
2744
+ options.pose(objectId, leaf.part, leaf.clip, from);
2745
+ const at = [box2.getCenter(tmp).x, tmp.y, tmp.z];
2746
+ options.sound(options.impactKey(objectId, leaf.part), at, Math.min(0.6, 0.15 + massOf(objectId, leaf.part) * 0.02));
2747
+ event("blocked", u, by);
2748
+ return false;
2749
+ }
2750
+ return true;
2751
+ };
2752
+ const handleOf = (obj, leaf, point) => {
2753
+ obj.updateMatrixWorld(true);
2754
+ box2.setFromObject(obj);
2755
+ const lo = new THREE.Vector3(Infinity, Infinity, Infinity);
2756
+ const hi = new THREE.Vector3(-Infinity, -Infinity, -Infinity);
2757
+ for (let i = 0; i < 8; i++) {
2758
+ 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);
2759
+ obj.worldToLocal(tmp);
2760
+ lo.min(tmp);
2761
+ hi.max(tmp);
2762
+ }
2763
+ const aimed = point ? obj.worldToLocal(new THREE.Vector3(point[0], point[1], point[2])) : lo.clone().add(hi).multiplyScalar(0.5);
2764
+ if (!point) {
2765
+ const local = obj.worldToLocal(obj.getWorldPosition(new THREE.Vector3()).setY(player.feet + 1));
2766
+ aimed.y = local.y;
2767
+ }
2768
+ aimed.clamp(lo, hi);
2769
+ if (slides(leaf.clip, leaf.part)) return aimed;
2770
+ const last = leaf.clip.keys[leaf.clip.keys.length - 1]?.parts?.[leaf.part]?.rotation ?? [0, 1, 0];
2771
+ 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;
2772
+ const size = [hi.x - lo.x, hi.y - lo.y, hi.z - lo.z];
2773
+ const reachOf = (k) => Math.max(Math.abs(lo.getComponent(k)), Math.abs(hi.getComponent(k)));
2774
+ let across = -1;
2775
+ for (let k = 0; k < 3; k++) {
2776
+ if (k === axis || size[k] < Math.min(size[0], size[1], size[2]) + 1e-6) continue;
2777
+ if (across < 0 || reachOf(k) > reachOf(across)) across = k;
2778
+ }
2779
+ if (across < 0) return aimed;
2780
+ const far = Math.abs(hi.getComponent(across)) >= Math.abs(lo.getComponent(across)) ? hi.getComponent(across) : lo.getComponent(across);
2781
+ aimed.setComponent(across, far - Math.sign(far) * Math.min(HANDLE_INSET, size[across] * 0.25));
2782
+ return aimed;
2783
+ };
2784
+ const start = (objectId, leaf, point) => {
2785
+ reaching = null;
2786
+ const obj = options.objectOf(objectId, leaf.part);
2787
+ if (!obj) return false;
2788
+ if (using && (using.objectId !== objectId || using.leaf.part !== leaf.part)) control.cancel();
2789
+ obj.updateMatrixWorld(true);
2790
+ const grip = handleOf(obj, leaf, point);
2791
+ const opening = openingOf(objectId, leaf.part);
2792
+ const key2 = `${objectId}|${leaf.part}`;
2793
+ const lastGoal = lastGoals.get(key2);
2794
+ const goal = control.begin({ opening, massKg: massOf(objectId, leaf.part), locked: options.isLocked(objectId, leaf.part), ...lastGoal !== void 0 ? { lastGoal } : {} });
2795
+ if (goal !== null) lastGoals.set(key2, goal);
2796
+ let pull = true;
2797
+ if (goal !== null) {
2798
+ obj.updateMatrixWorld(true);
2799
+ options.pose(objectId, leaf.part, leaf.clip, 1);
2800
+ obj.updateMatrixWorld(true);
2801
+ const openAt = obj.localToWorld(grip.clone());
2802
+ options.pose(objectId, leaf.part, leaf.clip, 0);
2803
+ obj.updateMatrixWorld(true);
2804
+ const shutAt = obj.localToWorld(grip.clone());
2805
+ options.pose(objectId, leaf.part, leaf.clip, opening);
2806
+ const dOpen = Math.hypot(openAt.x - player.x, openAt.z - player.z);
2807
+ const dShut = Math.hypot(shutAt.x - player.x, shutAt.z - player.z);
2808
+ pull = goal === 1 ? dOpen <= dShut : dShut <= dOpen;
2809
+ }
2810
+ using = { objectId, leaf, obj, grip, pull, phase: control.phase, progress: 0, walked: 0, there: goal === null };
2811
+ const at = obj.localToWorld(grip.clone());
2812
+ if (goal === null) {
2813
+ options.sound("leaf_rattle", [at.x, at.y, at.z], 0.6);
2814
+ event("locked", using);
2815
+ } else event("reach", using);
2816
+ return true;
2817
+ };
2818
+ return {
2819
+ get active() {
2820
+ return reaching !== null || using !== null && control.phase !== "idle";
2821
+ },
2822
+ get state() {
2823
+ return using ? { objectId: using.objectId, part: using.leaf.part, phase: control.phase, opening: openingOf(using.objectId, using.leaf.part), goal: control.goal } : null;
2824
+ },
2825
+ get avatarState() {
2826
+ const h = holding();
2827
+ return h ? { phase: h.phase, progress: h.progress, height: h.at.y - player.feet, pull: h.pull } : null;
2828
+ },
2829
+ aimed() {
2830
+ const found = picked();
2831
+ if (!found) return null;
2832
+ const { hit, leaf } = found;
2833
+ return { objectId: hit.objectId, part: leaf.part, open: openingOf(hit.objectId, leaf.part) > 0.5, locked: options.isLocked(hit.objectId, leaf.part) };
2834
+ },
2835
+ use() {
2836
+ const found = picked();
2837
+ return found ? start(found.hit.objectId, found.leaf, found.hit.point) : false;
2838
+ },
2839
+ useLeaf(objectId, part) {
2840
+ const leaves = options.leaves(objectId);
2841
+ const leaf = part === void 0 ? leaves[0] : leaves.find((l) => l.part === part);
2842
+ return leaf ? start(objectId, leaf, null) : false;
2843
+ },
2844
+ opening: openingOf,
2845
+ step(dt) {
2846
+ const r = reaching;
2847
+ if (r) {
2848
+ r.time += dt;
2849
+ if (r.phase === "reach" && !r.there) {
2850
+ const h = r.reach.handle();
2851
+ const hinge = r.reach.hinge?.();
2852
+ r.walked += dt;
2853
+ const g = new THREE.Vector3(h[0], h[1], h[2]);
2854
+ r.there = approach(g, hinge ? new THREE.Vector3(hinge[0], hinge[1], hinge[2]) : null, false, dt, r.walked);
2855
+ face(g, dt);
2856
+ if (r.there) r.time = 0;
2857
+ } else if (r.phase === "reach") {
2858
+ r.progress = Math.min(1, r.time / rules.reachSeconds);
2859
+ if (r.progress >= 1) {
2860
+ r.reach.onGrip?.();
2861
+ r.phase = r.reach.locked ? "rattle" : "swing";
2862
+ r.time = 0;
2863
+ r.progress = 0;
2864
+ }
2865
+ } else if (r.phase === "swing" || r.phase === "rattle") {
2866
+ r.progress = Math.min(1, r.time / (r.phase === "rattle" ? rules.rattleSeconds : Math.max(0.05, r.reach.seconds)));
2867
+ if (r.progress >= 1) {
2868
+ r.phase = "release";
2869
+ r.time = 0;
2870
+ r.progress = 0;
2871
+ }
2872
+ } else {
2873
+ r.progress = Math.min(1, r.time / rules.releaseSeconds);
2874
+ if (r.progress >= 1) reaching = null;
2875
+ }
2876
+ return;
2877
+ }
2878
+ const u = using;
2879
+ if (!u || control.phase === "idle") return;
2880
+ const was = openingOf(u.objectId, u.leaf.part);
2881
+ const wasPhase = control.phase;
2882
+ if (wasPhase === "reach" && !u.there) {
2883
+ u.obj.updateMatrixWorld(true);
2884
+ u.walked += dt;
2885
+ const g = u.obj.localToWorld(new THREE.Vector3().copy(u.grip));
2886
+ u.there = approach(g, u.obj.getWorldPosition(new THREE.Vector3()), slides(u.leaf.clip, u.leaf.part), dt, u.walked);
2887
+ face(g, dt);
2888
+ if (!u.there) return;
2889
+ }
2890
+ const s = control.step(dt, (from, to) => isFree(u, from, to));
2891
+ u.phase = s.phase;
2892
+ u.progress = s.progress;
2893
+ openings.set(key(u.objectId, u.leaf.part), s.opening);
2894
+ u.obj.updateMatrixWorld(true);
2895
+ const at = u.obj.localToWorld(tmp.copy(u.grip));
2896
+ const point = [at.x, at.y, at.z];
2897
+ const slide = slides(u.leaf.clip, u.leaf.part);
2898
+ const shut = options.impactKey(u.objectId, u.leaf.part).replace("impact_", "leaf_shut_");
2899
+ if (wasPhase === "reach" && s.phase !== "reach") {
2900
+ if (slide) options.sound("leaf_slide", point, 0.5);
2901
+ else {
2902
+ if (was < 0.02) options.sound("leaf_latch", point, 0.5);
2903
+ options.sound(/metal|glass/.test(shut) ? "leaf_creak_metal" : "leaf_creak_wood", point, 0.35);
2904
+ }
2905
+ }
2906
+ if (s.arrived) {
2907
+ if (s.opening <= 0) {
2908
+ options.sound(slide ? "leaf_slide" : shut, point, Math.min(1, 0.3 + massOf(u.objectId, u.leaf.part) * 0.03));
2909
+ event("closed", u);
2910
+ } else event("opened", u);
2911
+ }
2912
+ if (s.phase === "idle") using = null;
2913
+ },
2914
+ handTargets() {
2915
+ const h = holding();
2916
+ if (!h || h.phase === "rattle" && h.progress > 0.92) return null;
2917
+ const { at, phase, progress } = h;
2918
+ if (phase === "rattle") at.y += Math.sin(progress * 60) * 0.012;
2919
+ camera.updateMatrixWorld(true);
2920
+ const rest = camera.localToWorld(new THREE.Vector3(HAND_REST[0], HAND_REST[1], HAND_REST[2]));
2921
+ const k = phase === "reach" ? progress : phase === "release" ? 1 - progress : 1;
2922
+ const ease = k * k * (3 - 2 * k);
2923
+ const pos = rest.lerp(at, ease);
2924
+ const way = new THREE.Vector3(at.x - player.x, 0, at.z - player.z);
2925
+ if (way.lengthSq() < 1e-6) way.set(-Math.sin(player.yaw), 0, -Math.cos(player.yaw));
2926
+ way.normalize();
2927
+ const down = new THREE.Vector3(0, -1, 0);
2928
+ const back = new THREE.Vector3().crossVectors(down, way);
2929
+ const q = new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(way, back, down));
2930
+ const grip = phase === "reach" ? 0.15 + 0.75 * progress : phase === "release" ? 0.9 * (1 - progress) : 0.9;
2931
+ return { R: { position: [pos.x, pos.y, pos.z], quaternion: [q.x, q.y, q.z, q.w] }, L: null, grip };
2932
+ },
2933
+ reachFor(reach) {
2934
+ if (using) {
2935
+ control.cancel();
2936
+ using = null;
2937
+ }
2938
+ reaching = { reach, phase: "reach", time: 0, progress: 0, walked: 0, there: false };
2939
+ },
2940
+ forget(objectId) {
2941
+ for (const k of [...openings.keys()]) if (k.startsWith(`${objectId}\0`)) openings.delete(k);
2942
+ if (using?.objectId === objectId) {
2943
+ control.cancel();
2944
+ using = null;
2945
+ }
2946
+ }
2947
+ };
2948
+ }
2949
+
2443
2950
  // src/room.ts
2444
2951
  import { createTerrain, createTerrainImpl, DEFAULT_PROFILE } from "@adatechnology/game-terrain";
2445
2952
  import { createTerrainRenderer } from "@adatechnology/game-terrain-three";
@@ -2660,17 +3167,24 @@ var en = {
2660
3167
  "loading.open_area": "Opening {area}\u2026",
2661
3168
  "loading.spawn": "Building {name}\u2026",
2662
3169
  "act.loosened": "{name} was heavy but free: the blast moved it",
3170
+ "act.came_apart": "{name} came apart: what stayed is loose and falls",
2663
3171
  "act.shoved": "{name} stands free: the shots moved it",
2664
3172
  "act.impact": "{name} hit {where} at {speed} m/s: {energy} J of blunt damage",
2665
3173
  "act.use_item": "{name}: {use}",
3174
+ "act.open_with": "Opened {name} with {tool}",
3175
+ "act.open_by_hand": "Opened {name} by hand",
3176
+ "act.ate": "Ate {grams} g of {name}",
3177
+ "act.ate_all": "Ate all of {name} ({grams} g): the packaging is empty",
2666
3178
  "act.tap.on": "Tap opened",
2667
3179
  "act.tap.off": "Tap closed",
2668
3180
  "use.drink": "drink",
2669
3181
  "use.sip": "sip",
2670
3182
  "use.chug": "chug",
3183
+ "use.eat": "eat",
2671
3184
  "use.pour": "pour",
2672
3185
  "use.pour_out": "pour out",
2673
3186
  "use.open": "open",
3187
+ "use.unscrew": "unscrew",
2674
3188
  "use.close": "close",
2675
3189
  "hud.reloading": "Reloading...",
2676
3190
  "hud.empty": "Empty",
@@ -2687,15 +3201,32 @@ var en = {
2687
3201
  "hud.nothing_to_grab": "Nothing to grab here",
2688
3202
  "hud.cannot_grab": "{name} cannot be picked up",
2689
3203
  "hud.too_heavy": "{name} is too heavy ({kg} kg)",
3204
+ "hud.door_locked": "{name} is locked",
3205
+ "hud.door_blocked": "{name} hits something and stops ({percent}% open)",
3206
+ "hud.door_hint_open": "E: open",
3207
+ "hud.door_hint_close": "E: close",
3208
+ "hud.door_hint_locked": "Locked",
3209
+ "act.door_reach": "reaches for the handle of {name}",
3210
+ "act.door_opened": "opens {name}",
3211
+ "act.door_closed": "closes {name}",
3212
+ "act.door_blocked": "{name} hits something and stops at {percent}%",
3213
+ "act.door_locked": "tries {name}: locked, it only rattles",
3214
+ "act.door_stepped_back": "steps back out of the way of {name}",
2690
3215
  "hud.push_on": "Pushing {name}: W pushes, S pulls, let go of E to stop",
2691
3216
  "hud.push_too_heavy": "{name} does not budge ({kg} kg)",
2692
3217
  "hud.push_blocked": "{name} is stuck: there is something in the way",
2693
3218
  "act.push_grip": "leans on {name} ({kg} kg)",
2694
3219
  "act.push_engaged": "starts pushing {name} ({kg} kg)",
3220
+ "act.sit": "sits on {name}",
3221
+ "act.lie": "lies down on {name}",
3222
+ "act.stand": "gets up from {name}",
2695
3223
  "act.push_too_heavy": "strains against {name}: {kg} kg is too much",
2696
3224
  "act.push_obstacle": "{name} hits something and locks",
2697
3225
  "act.push_release": "lets go of {name}",
2698
3226
  "hud.empty_container": "{name} is empty",
3227
+ "hud.needs_opener": "Opening {name} needs {tools} (put one within reach or in the loadout)",
3228
+ "hud.spoiled_hole": "{name}: the packaging is holed through, what is inside is contaminated; do not eat or drink it",
3229
+ "hud.spoiled_leak": "{name} leaked: what is left is contaminated; do not eat or drink it",
2699
3230
  "hud.refused": "Wrong tool for the {kind}",
2700
3231
  "hud.drop": "drop",
2701
3232
  "hud.rotate": "rotate",
@@ -2984,17 +3515,24 @@ var ptBR = {
2984
3515
  "loading.open_area": "Abrindo {area}\u2026",
2985
3516
  "loading.spawn": "Montando {name}\u2026",
2986
3517
  "act.loosened": "{name} \xE9 pesado mas estava solto: a explos\xE3o o moveu",
3518
+ "act.came_apart": "{name} se desmontou: o que ficou est\xE1 solto e cai",
2987
3519
  "act.shoved": "{name} estava solto no ch\xE3o: os tiros o moveram",
2988
3520
  "act.impact": "{name} bateu em {where} a {speed} m/s: {energy} J de dano de impacto",
2989
3521
  "act.use_item": "{name}: {use}",
3522
+ "act.open_with": "Abriu {name} com {tool}",
3523
+ "act.open_by_hand": "Abriu {name} com a m\xE3o",
3524
+ "act.ate": "Comeu {grams} g de {name}",
3525
+ "act.ate_all": "Comeu todo o conte\xFAdo de {name} ({grams} g): a embalagem ficou vazia",
2990
3526
  "act.tap.on": "Abriu a torneira",
2991
3527
  "act.tap.off": "Fechou a torneira",
2992
3528
  "use.drink": "beber",
2993
3529
  "use.sip": "golinho",
2994
3530
  "use.chug": "virar",
3531
+ "use.eat": "comer",
2995
3532
  "use.pour": "despejar",
2996
3533
  "use.pour_out": "esvaziar",
2997
3534
  "use.open": "abrir",
3535
+ "use.unscrew": "desenroscar",
2998
3536
  "use.close": "fechar",
2999
3537
  "hud.reloading": "Recarregando...",
3000
3538
  "hud.empty": "Sem muni\xE7\xE3o",
@@ -3011,15 +3549,32 @@ var ptBR = {
3011
3549
  "hud.nothing_to_grab": "Nada para pegar aqui",
3012
3550
  "hud.cannot_grab": "{name} n\xE3o d\xE1 para pegar",
3013
3551
  "hud.too_heavy": "{name} \xE9 pesado demais ({kg} kg)",
3552
+ "hud.door_locked": "{name} est\xE1 trancado(a)",
3553
+ "hud.door_blocked": "{name} bateu em algo e parou ({percent}% aberto)",
3554
+ "hud.door_hint_open": "E: abrir",
3555
+ "hud.door_hint_close": "E: fechar",
3556
+ "hud.door_hint_locked": "Trancado",
3557
+ "act.door_reach": "leva a m\xE3o \xE0 ma\xE7aneta de {name}",
3558
+ "act.door_opened": "abre {name}",
3559
+ "act.door_closed": "fecha {name}",
3560
+ "act.door_blocked": "{name} bate em algo e para em {percent}%",
3561
+ "act.door_locked": "tenta abrir {name}: trancado, s\xF3 chacoalha",
3562
+ "act.door_stepped_back": "d\xE1 um passo para tr\xE1s para {name} passar",
3014
3563
  "hud.push_on": "Empurrando {name}: W empurra, S puxa, solte o E para parar",
3015
3564
  "hud.push_too_heavy": "{name} n\xE3o se mexe ({kg} kg)",
3016
3565
  "hud.push_blocked": "{name} travou: tem algo no caminho",
3017
3566
  "act.push_grip": "encosta em {name} ({kg} kg)",
3018
3567
  "act.push_engaged": "come\xE7a a empurrar {name} ({kg} kg)",
3568
+ "act.sit": "senta em {name}",
3569
+ "act.lie": "deita em {name}",
3570
+ "act.stand": "levanta de {name}",
3019
3571
  "act.push_too_heavy": "faz for\xE7a contra {name}: {kg} kg \xE9 demais",
3020
3572
  "act.push_obstacle": "{name} bate em algo e trava",
3021
3573
  "act.push_release": "solta {name}",
3022
3574
  "hud.empty_container": "{name} est\xE1 vazio",
3575
+ "hud.needs_opener": "Para abrir {name} precisa de {tools} (deixe um ao alcance ou no invent\xE1rio)",
3576
+ "hud.spoiled_hole": "{name}: a embalagem furou, o conte\xFAdo est\xE1 contaminado; n\xE3o d\xE1 para comer nem beber",
3577
+ "hud.spoiled_leak": "{name} vazou: o que sobrou est\xE1 contaminado; n\xE3o d\xE1 para comer nem beber",
3023
3578
  "hud.refused": "Ferramenta errada para {kind}",
3024
3579
  "hud.drop": "soltar",
3025
3580
  "hud.rotate": "girar",
@@ -3384,9 +3939,9 @@ function createBenchPanel(o) {
3384
3939
  hasRoom = rows.room.length > 0;
3385
3940
  syncDisplay();
3386
3941
  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;
3942
+ const key2 = rows.module.map((row) => `${row.id}:${row.ratio !== void 0}`).join("|");
3943
+ if (key2 !== metricKey) {
3944
+ metricKey = key2;
3390
3945
  renderMetrics(rows.module);
3391
3946
  }
3392
3947
  for (const row of rows.module) {
@@ -3740,8 +4295,7 @@ function createUi(o) {
3740
4295
  logBtn(t("ui.download_txt"), () => o.log.download("txt"));
3741
4296
  logBtn(t("ui.clear_log"), () => {
3742
4297
  o.log.clear();
3743
- logBody.textContent = "";
3744
- nodes.clear();
4298
+ rebuildLog();
3745
4299
  });
3746
4300
  logPanel.append(logTitle, logBar, logBody);
3747
4301
  const MAX_LINES = 200;
@@ -3754,29 +4308,59 @@ function createUi(o) {
3754
4308
  const tm = mk3("time", void 0, fmtTime(e.t));
3755
4309
  n.append(tm, document.createTextNode(e.text));
3756
4310
  };
3757
- const addNode = (e) => {
3758
- if (!filters[e.channel]) return;
4311
+ let pendingAdds = [];
4312
+ const pendingUpdates = /* @__PURE__ */ new Map();
4313
+ let isLogStale = false;
4314
+ const isLogVisible = () => !window.matchMedia?.("(max-width:980px)").matches || root.classList.contains("open");
4315
+ const buildNode = (e) => {
3759
4316
  const n = mk3("div");
3760
4317
  fill(n, e);
3761
4318
  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;
4319
+ return n;
3769
4320
  };
3770
4321
  function rebuildLog() {
4322
+ pendingAdds = [];
4323
+ pendingUpdates.clear();
4324
+ isLogStale = false;
3771
4325
  logBody.textContent = "";
3772
4326
  nodes.clear();
3773
- for (const e of o.log.entries().slice(-MAX_LINES * 3)) addNode(e);
4327
+ const fragment = document.createDocumentFragment();
4328
+ for (const e of o.log.entries().slice(-MAX_LINES * 3)) if (filters[e.channel]) fragment.appendChild(buildNode(e));
4329
+ logBody.appendChild(fragment);
4330
+ trimLog();
3774
4331
  }
3775
- const offLog = o.log.on((e, updated) => {
3776
- if (updated) {
3777
- const n = nodes.get(e.seq);
4332
+ function trimLog() {
4333
+ while (logBody.childElementCount > MAX_LINES) {
4334
+ const oldest = nodes.keys().next();
4335
+ if (oldest.done) break;
4336
+ nodes.delete(oldest.value);
4337
+ logBody.firstElementChild?.remove();
4338
+ }
4339
+ if (autoscroll) logBody.scrollTop = logBody.scrollHeight;
4340
+ }
4341
+ const flushLog = () => {
4342
+ if (pendingAdds.length === 0 && pendingUpdates.size === 0) return;
4343
+ if (!isLogVisible()) {
4344
+ isLogStale = true;
4345
+ pendingAdds = [];
4346
+ pendingUpdates.clear();
4347
+ return;
4348
+ }
4349
+ if (isLogStale) return rebuildLog();
4350
+ const fragment = document.createDocumentFragment();
4351
+ for (const e of pendingAdds.slice(-MAX_LINES)) if (filters[e.channel]) fragment.appendChild(buildNode(e));
4352
+ pendingAdds = [];
4353
+ logBody.appendChild(fragment);
4354
+ for (const [seq, e] of pendingUpdates) {
4355
+ const n = nodes.get(seq);
3778
4356
  if (n) fill(n, e);
3779
- } else addNode(e);
4357
+ }
4358
+ pendingUpdates.clear();
4359
+ trimLog();
4360
+ };
4361
+ const offLog = o.log.on((e, updated) => {
4362
+ if (updated) pendingUpdates.set(e.seq, e);
4363
+ else pendingAdds.push(e);
3780
4364
  });
3781
4365
  const right = mk3("div", "tr-right");
3782
4366
  const cat = mk3("div", "tr-panel tr-cat");
@@ -3866,8 +4450,8 @@ function createUi(o) {
3866
4450
  if (o.kick) ctl(o.kick.label, () => o.kick.onKick());
3867
4451
  const fullscreenBtn = ctl(t("ui.fullscreen"), () => void toggleFullscreen());
3868
4452
  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`);
4453
+ const blastBtns = ["shelter", "fragmentHits", "confinement"].map((key2) => ({ key: key2, b: ctl(t(`ui.blast.${key2}`), () => o.onBlast(key2)) }));
4454
+ for (const { key: key2, b } of blastBtns) b.title = t(`ui.blast.${key2}.hint`);
3871
4455
  const qSel = mk3("select");
3872
4456
  for (const q of ["low", "medium", "high"]) {
3873
4457
  const op = mk3("option", void 0, t(`ui.quality.${q}`));
@@ -3943,6 +4527,7 @@ function createUi(o) {
3943
4527
  toggle.type = "button";
3944
4528
  const togglePanels = () => {
3945
4529
  root.classList.toggle("open");
4530
+ if (isLogStale && isLogVisible()) rebuildLog();
3946
4531
  o.onResize();
3947
4532
  };
3948
4533
  toggle.onclick = togglePanels;
@@ -4031,6 +4616,7 @@ function createUi(o) {
4031
4616
  ring.style.display = value === null ? "none" : "block";
4032
4617
  if (value !== null) ringFill.style.strokeDashoffset = String(RING_LENGTH * (1 - value));
4033
4618
  },
4619
+ flushLog,
4034
4620
  hud(s) {
4035
4621
  toolBox.textContent = "";
4036
4622
  toolBox.append(document.createTextNode(s.toolName));
@@ -4061,9 +4647,9 @@ function createUi(o) {
4061
4647
  held.append(mk3("kbd", void 0, k), document.createTextNode(` ${label} `));
4062
4648
  }
4063
4649
  } else held.style.display = "none";
4064
- const key = s.slots.join("|") + s.page;
4065
- if (key !== lastSlotsKey) {
4066
- lastSlotsKey = key;
4650
+ const key2 = s.slots.join("|") + s.page;
4651
+ if (key2 !== lastSlotsKey) {
4652
+ lastSlotsKey = key2;
4067
4653
  hotbar.textContent = "";
4068
4654
  slotsEl.length = 0;
4069
4655
  hudPage = s.page - 1;
@@ -4080,7 +4666,7 @@ function createUi(o) {
4080
4666
  slotsEl.forEach((b, i) => b.classList.toggle("on", i === s.slot));
4081
4667
  tapBtn.classList.toggle("on", s.tap);
4082
4668
  comboBtn?.classList.toggle("on", !!s.combo);
4083
- for (const { key: key2, b } of blastBtns) b.classList.toggle("on", s.blast[key2]);
4669
+ for (const { key: key3, b } of blastBtns) b.classList.toggle("on", s.blast[key3]);
4084
4670
  count.textContent = t("ui.objects", { n: s.objects });
4085
4671
  const ak = JSON.stringify(s.animations);
4086
4672
  if (ak !== lastAnim) {
@@ -4131,7 +4717,7 @@ function createUi(o) {
4131
4717
  var FIXED_DT = 1 / 60;
4132
4718
  var SOLID_SPAN = 3;
4133
4719
  var STEP_UP = 0.45;
4134
- var BODY_HEIGHT = 1.75;
4720
+ var BODY_HEIGHT2 = 1.75;
4135
4721
  var KICK_MASS = 6;
4136
4722
  var KICK_HEIGHT = 0.6;
4137
4723
  var GROUND_ID = "room_ground";
@@ -4143,9 +4729,8 @@ var GROUND_DUST_RADIUS = 0.2;
4143
4729
  var GROUND_FIRE_EVERY = 0.3;
4144
4730
  var SHOVE_SPEED = 0.04;
4145
4731
  var METRICS_INTERVAL_S = 0.25;
4146
- var CARRY_NEAR = 0.62;
4147
4732
  var CARRY_PALM = 0.012;
4148
- var CARRY_GRIP = 0.45;
4733
+ var CAP_RISE = 0.03;
4149
4734
  var MAX_STRIDE_SPEED = 10;
4150
4735
  var v3 = (v) => [v.x, v.y, v.z];
4151
4736
  var dist3 = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
@@ -4155,8 +4740,8 @@ function createTestRoom(options) {
4155
4740
  const S = resolveStrings(locale);
4156
4741
  const dStrings = resolveDestructionStrings(locale);
4157
4742
  const fStrings = resolveFluidStrings(locale);
4158
- const t = (key, vars) => {
4159
- let s = S.text[key] ?? key;
4743
+ const t = (key2, vars) => {
4744
+ let s = S.text[key2] ?? key2;
4160
4745
  if (vars) for (const k of Object.keys(vars)) s = s.split(`{${k}}`).join(String(vars[k]));
4161
4746
  return s;
4162
4747
  };
@@ -4241,11 +4826,11 @@ function createTestRoom(options) {
4241
4826
  const log = createLog({ exportCancelled: () => t("log.export_cancelled") });
4242
4827
  log.setClock(() => simTime);
4243
4828
  const ownAudio = options.audio === void 0;
4244
- const audio = options.audio === false ? null : options.audio ?? createAudioEngine();
4829
+ const audio = options.audio === false ? null : options.audio ?? createAudioEngine({ deferredStart: {} });
4245
4830
  const destructionSound = audio ? createDestructionSound(audio) : null;
4246
- const playSound = (key, point, volume) => {
4247
- destructionSound?.onSound(key, point, volume);
4248
- options.onSound?.(key, point, volume);
4831
+ const playSound = (key2, point, volume) => {
4832
+ destructionSound?.onSound(key2, point, volume);
4833
+ options.onSound?.(key2, point, volume);
4249
4834
  };
4250
4835
  const vfx = createVfxRenderer(THREE, {
4251
4836
  scene,
@@ -4255,6 +4840,7 @@ function createTestRoom(options) {
4255
4840
  pointScale,
4256
4841
  vfx: createVfx({
4257
4842
  profile: quality,
4843
+ ...options.debris?.particles ? { sprites: { kinds: patchSpriteKinds(SPRITE_KINDS, options.debris.particles) } } : {},
4258
4844
  groundAt
4259
4845
  })
4260
4846
  });
@@ -4265,6 +4851,9 @@ function createTestRoom(options) {
4265
4851
  quality,
4266
4852
  groundAt,
4267
4853
  pointScale,
4854
+ ...options.debris?.fragmentLife !== void 0 ? { fragmentLife: options.debris.fragmentLife } : {},
4855
+ ...options.debris?.maxFragments !== void 0 ? { maxFragments: options.debris.maxFragments } : {},
4856
+ eventBudgetMs: options.debris?.eventBudgetMs ?? 8,
4268
4857
  onSound: playSound,
4269
4858
  ...options.blastShove ? { blastShove: options.blastShove } : {},
4270
4859
  // a blast moves what is heavy but not nailed down: the renderer makes it a piece the moment the shove reaches it
@@ -4272,13 +4861,13 @@ function createTestRoom(options) {
4272
4861
  const e = entries.get(objectId);
4273
4862
  return e?.free === true && !e.piece;
4274
4863
  },
4275
- onLoosened: (objectId, piece) => {
4864
+ onLoosened: (objectId, piece, cause) => {
4276
4865
  const e = entries.get(objectId);
4277
4866
  if (!e) return;
4278
4867
  e.piece = piece;
4279
4868
  leaveFootprint(objectId);
4280
4869
  rendererPieces.set(objectId, piece);
4281
- action("loosened", t("act.loosened", { name: e.name }), { objectId, itemId: e.so.itemId });
4870
+ action("loosened", t(cause === "blast" ? "act.loosened" : "act.came_apart", { name: e.name }), { objectId, itemId: e.so.itemId });
4282
4871
  },
4283
4872
  onPiece: (piece) => {
4284
4873
  rendererPieces.set(piece.objectId, piece);
@@ -4494,7 +5083,7 @@ function createTestRoom(options) {
4494
5083
  const dz = Math.max(s.min[2] - z, 0, z - s.max[2]);
4495
5084
  if (dx * dx + dz * dz > radius * radius) continue;
4496
5085
  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;
5086
+ else if (s.min[1] < feet + BODY_HEIGHT2) blocked = true;
4498
5087
  }
4499
5088
  return { floor, blocked };
4500
5089
  };
@@ -4568,7 +5157,7 @@ function createTestRoom(options) {
4568
5157
  let best = null;
4569
5158
  let bestDistance = PUSH_REACH;
4570
5159
  for (const e of entries.values()) {
4571
- if (e.arena || !e.piece && !e.free) continue;
5160
+ if (!e.pushable || !e.piece && !e.free) continue;
4572
5161
  const b = bodyBox(e);
4573
5162
  if (!b) continue;
4574
5163
  const dx = Math.max(b.minX, Math.min(b.maxX, player.x)) - player.x;
@@ -4592,7 +5181,7 @@ function createTestRoom(options) {
4592
5181
  aim();
4593
5182
  const r = rayWorld([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], 3.2);
4594
5183
  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 };
5184
+ 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
5185
  return leanedOn();
4597
5186
  },
4598
5187
  massOf: (objectId) => massOfObject(objectId),
@@ -4615,9 +5204,96 @@ function createTestRoom(options) {
4615
5204
  else if (ev.type === "too_heavy") say(t("hud.push_too_heavy", vars));
4616
5205
  else if (ev.type === "obstacle") say(t("hud.push_blocked", vars));
4617
5206
  },
4618
- ring: (value) => ui?.holdRing(value)
5207
+ ring: (value) => ui?.holdRing(value),
5208
+ hasBody: () => !!avatar?.eye
5209
+ });
5210
+ const seatHolder = (objectId) => {
5211
+ const e = entries.get(objectId);
5212
+ const seats = e?.item?.seats;
5213
+ if (!e || !seats?.length || !world.has(objectId)) return null;
5214
+ return { objectId, root: rendererPieces.get(objectId)?.root ?? e.so.root, seats };
5215
+ };
5216
+ const seating = createSeating({
5217
+ THREE,
5218
+ player,
5219
+ pick: () => {
5220
+ aim();
5221
+ const r = rayWorld([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], 3.2);
5222
+ return r ? seatHolder(r.object) : null;
5223
+ },
5224
+ find: seatHolder,
5225
+ hasBody: () => !!avatar?.eye,
5226
+ groundAt: (x, z) => groundAt(x, z),
5227
+ 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 } }),
5228
+ 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
5229
  });
4620
5230
  let pushByKey = false;
5231
+ const leafCache = /* @__PURE__ */ new WeakMap();
5232
+ const leavesOf = (objectId) => {
5233
+ const item = entries.get(objectId)?.item;
5234
+ if (!item?.animations?.length) return [];
5235
+ const cached = leafCache.get(item);
5236
+ if (cached) return cached;
5237
+ const byPart = /* @__PURE__ */ new Map();
5238
+ for (const clip of item.animations) {
5239
+ if (!clip.id.startsWith("open_")) continue;
5240
+ const parts = /* @__PURE__ */ new Set();
5241
+ for (const k of clip.keys) for (const id of Object.keys(k.parts ?? {})) parts.add(id);
5242
+ for (const part of parts) {
5243
+ const had = byPart.get(part);
5244
+ if (!had || parts.size < had.n) byPart.set(part, { clip, n: parts.size });
5245
+ }
5246
+ }
5247
+ const leaves = [...byPart].map(([part, v]) => ({ part, clip: v.clip }));
5248
+ leafCache.set(item, leaves);
5249
+ return leaves;
5250
+ };
5251
+ const lockedLeaves = /* @__PURE__ */ new Set();
5252
+ const doors = createRoomDoors({
5253
+ THREE,
5254
+ player,
5255
+ world,
5256
+ camera,
5257
+ arena: { limit: arenaSize, colliders: standingColliders, ids: standingIds },
5258
+ ...options.doors ? { rules: options.doors } : {},
5259
+ pick: () => {
5260
+ if (heldPiece) return [];
5261
+ aim();
5262
+ const hits = [];
5263
+ const r = rayWorld([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], DOOR_REACH);
5264
+ if (r) hits.push({ objectId: r.object, part: r.part, point: r.point });
5265
+ if (thirdActive()) {
5266
+ camera.getWorldDirection(tmpV2);
5267
+ const e = rayWorld([aimO[0], aimO[1], aimO[2]], [tmpV2.x, tmpV2.y, tmpV2.z], DOOR_REACH);
5268
+ if (e) hits.push({ objectId: e.object, part: e.part, point: e.point });
5269
+ }
5270
+ return hits;
5271
+ },
5272
+ leaves: leavesOf,
5273
+ objectOf: (objectId, part) => entries.get(objectId)?.so.root.getObjectByName(part) ?? null,
5274
+ pose: (objectId, part, clip, fraction) => {
5275
+ const e = entries.get(objectId);
5276
+ const obj = e?.so.root.getObjectByName(part);
5277
+ if (!e || !obj) return;
5278
+ 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] });
5279
+ const rest = restPose.get(obj);
5280
+ const pose = poseAt(clip, part, Math.max(0, Math.min(1, fraction)) * clip.duration);
5281
+ obj.position.set(rest.p[0] + pose.position[0], rest.p[1] + pose.position[1], rest.p[2] + pose.position[2]);
5282
+ obj.rotation.set(rest.r[0] + pose.rotation[0], rest.r[1] + pose.rotation[1], rest.r[2] + pose.rotation[2]);
5283
+ followPart(e, part, obj, rest);
5284
+ },
5285
+ isLocked: (objectId, part) => lockedLeaves.has(`${objectId}\0*`) || lockedLeaves.has(`${objectId}\0${part}`),
5286
+ nameOf,
5287
+ impactKey: (objectId, part) => impactKey(world.materials.get(world.part(objectId, part)?.material ?? heaviestMaterial(objectId) ?? "")),
5288
+ sound: playSound,
5289
+ onEvent: (ev) => {
5290
+ const vars = { name: ev.name, percent: Math.round(ev.opening * 100) };
5291
+ 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 } : {} } });
5292
+ fire("door", ev);
5293
+ if (ev.type === "locked") say(t("hud.door_locked", vars));
5294
+ else if (ev.type === "blocked") say(t("hud.door_blocked", vars));
5295
+ }
5296
+ });
4621
5297
  const massOfObject = (id) => {
4622
5298
  const st = world.state(id);
4623
5299
  let m = 0;
@@ -4643,6 +5319,8 @@ function createTestRoom(options) {
4643
5319
  const q = root.getWorldQuaternion(tmpQ);
4644
5320
  const p = tmpV.set(e.fluidOffset[0], e.fluidOffset[1], e.fluidOffset[2]).applyMatrix4(root.matrixWorld);
4645
5321
  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] });
5322
+ e.fluidStart = f.container.liters;
5323
+ e.fluidUsed = 0;
4646
5324
  fluidView.bindContainer(e.containerId, root, { innerOffset: e.fluidOffset });
4647
5325
  }
4648
5326
  return e;
@@ -4663,6 +5341,7 @@ function createTestRoom(options) {
4663
5341
  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
5342
  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
5343
  if (o.free) e.free = true;
5344
+ if (o.pushable) e.pushable = true;
4666
5345
  e.follower = o.follow?.(root, world, o.id) || null;
4667
5346
  }
4668
5347
  };
@@ -4762,7 +5441,7 @@ function createTestRoom(options) {
4762
5441
  if (movable) {
4763
5442
  const box2 = new THREE.Box3().setFromObject(root);
4764
5443
  const base = Math.max(pos[1], groundAt(pos[0], pos[2]));
4765
- pos = [pos[0], pos[1] + (base - box2.min.y), pos[2]];
5444
+ if (!box2.isEmpty()) pos = [pos[0], pos[1] + (base - box2.min.y), pos[2]];
4766
5445
  root.position.set(pos[0], pos[1], pos[2]);
4767
5446
  root.updateMatrixWorld(true);
4768
5447
  }
@@ -4771,6 +5450,7 @@ function createTestRoom(options) {
4771
5450
  else world.add(spec, { id: objectId, position: pos, rotation: [q.x, q.y, q.z, q.w] });
4772
5451
  const e = registerEntry(objectId, item.id, root, spec, item.name, parts, item, false, movable);
4773
5452
  if (!own && !movable && item.movable === void 0) e.free = true;
5453
+ if (item.pushable) e.pushable = true;
4774
5454
  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
5455
  fire("spawn", e.so);
4776
5456
  return e.so;
@@ -4873,6 +5553,7 @@ function createTestRoom(options) {
4873
5553
  e.so.root.removeFromParent();
4874
5554
  letGo(e.so.root);
4875
5555
  entries.delete(id);
5556
+ doors.forget(id);
4876
5557
  rendererPieces.delete(id);
4877
5558
  specs.delete(id);
4878
5559
  };
@@ -4914,6 +5595,7 @@ function createTestRoom(options) {
4914
5595
  fluids.setTap(TAP_ID, false);
4915
5596
  };
4916
5597
  const clearListeners = /* @__PURE__ */ new Set();
5598
+ const resetListeners = /* @__PURE__ */ new Set();
4917
5599
  const clear = () => {
4918
5600
  clearEverything();
4919
5601
  for (const listener of [...clearListeners]) listener();
@@ -4936,10 +5618,13 @@ function createTestRoom(options) {
4936
5618
  action("reset", t("act.reset"));
4937
5619
  };
4938
5620
  const hardReset = () => {
5621
+ seating.release();
4939
5622
  pushByKey = false;
4940
5623
  push.end();
4941
5624
  clearEverything();
4942
5625
  for (const id of [...entries.keys()]) removeEntry(id);
5626
+ renderer.reset();
5627
+ for (const listener of [...resetListeners]) listener();
4943
5628
  tools.clear();
4944
5629
  tool = toolState(toolId);
4945
5630
  if (pristineWorld) {
@@ -4954,6 +5639,8 @@ function createTestRoom(options) {
4954
5639
  pending = [];
4955
5640
  strikes = [];
4956
5641
  rng = 12345;
5642
+ filmRandom.seed = 1;
5643
+ groundFireClock = 0;
4957
5644
  counter = 0;
4958
5645
  slot = 0;
4959
5646
  prevVy.clear();
@@ -4987,8 +5674,8 @@ function createTestRoom(options) {
4987
5674
  const thirdActive = () => cameraMode === "third" && avatar !== null;
4988
5675
  const syncAvatarVisible = () => {
4989
5676
  if (!avatar) return;
4990
- avatar.root.visible = cameraMode === "third";
4991
- if (avatar.firstPerson) avatar.firstPerson.root.visible = cameraMode === "first";
5677
+ avatar.root.visible = cameraMode === "third" || !!avatar.eye;
5678
+ avatar.setView?.(cameraMode, camera);
4992
5679
  };
4993
5680
  const setCameraMode = (mode) => {
4994
5681
  if (mode !== "first" && mode !== "third") return;
@@ -5001,13 +5688,13 @@ function createTestRoom(options) {
5001
5688
  };
5002
5689
  const setAvatar = (next) => {
5003
5690
  if (avatar === next) return;
5691
+ avatar?.setView?.("third", null);
5004
5692
  avatar?.root.removeFromParent();
5005
- avatar?.firstPerson?.root.removeFromParent();
5006
5693
  avatar = next;
5007
5694
  feetPrev = null;
5008
5695
  if (avatar) {
5696
+ avatar.root.userData.viewmodel = true;
5009
5697
  scene.add(avatar.root);
5010
- if (avatar.firstPerson) scene.add(avatar.firstPerson.root);
5011
5698
  syncAvatarVisible();
5012
5699
  stepAvatar(0);
5013
5700
  }
@@ -5029,38 +5716,73 @@ function createTestRoom(options) {
5029
5716
  avatarState.pitch = player.pitch;
5030
5717
  avatarState.holding = heldPiece !== null;
5031
5718
  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;
5719
+ const seat = seating.seat;
5720
+ avatarState.seat = seat ? { pose: seat.pose, hip: seat.hips[1] - player.feet } : null;
5721
+ avatarState.door = doors.avatarState;
5722
+ 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
5723
  avatar.root.position.set(x, player.feet, z);
5034
5724
  avatar.root.rotation.set(0, player.yaw + Math.PI, 0);
5035
5725
  avatar.update(dt, avatarState);
5036
5726
  avatar.root.updateMatrixWorld(true);
5727
+ placeEye();
5728
+ };
5729
+ let scriptedAt = null;
5730
+ const lookAt = (eye, target) => {
5731
+ player.look(eye, target);
5732
+ scriptedAt = [player.x, player.z];
5733
+ };
5734
+ const EYE_CLEARANCE = 0.1;
5735
+ const eyeAt = new THREE.Vector3();
5736
+ const placeEye = () => {
5737
+ if (!avatar?.eye) return;
5738
+ if (scriptedAt) {
5739
+ const [walkX, walkZ] = player.walkIntent;
5740
+ if (!walkX && !walkZ && Math.hypot(player.x - scriptedAt[0], player.z - scriptedAt[1]) < 1e-6) return;
5741
+ scriptedAt = null;
5742
+ }
5743
+ avatar.eye(eyeAt);
5744
+ const dx = eyeAt.x - player.x;
5745
+ const dz = eyeAt.z - player.z;
5746
+ const ahead = Math.hypot(dx, dz);
5747
+ if (ahead > 1e-4) {
5748
+ const dir = [dx / ahead, 0, dz / ahead];
5749
+ const hit = rayWorld([player.x, eyeAt.y, player.z], dir, ahead + EYE_CLEARANCE);
5750
+ if (hit) {
5751
+ const keep = Math.max(0, hit.distance - EYE_CLEARANCE);
5752
+ eyeAt.set(player.x + dir[0] * keep, eyeAt.y, player.z + dir[2] * keep);
5753
+ }
5754
+ }
5755
+ camera.position.copy(eyeAt);
5756
+ camera.updateMatrixWorld(true);
5037
5757
  };
5038
5758
  let handSource = null;
5039
- let carriedHand = null;
5040
5759
  let debugView = null;
5041
5760
  let lastHand = { stance: "relaxed", triggerFinger: null, targets: null };
5042
5761
  const strikeClips = options.strikeClips ?? DEFAULT_STRIKE_CLIPS;
5043
5762
  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);
5763
+ const SHOULDER_POCKET_IN = 0.1;
5764
+ const pocketAt = new THREE.Vector3();
5765
+ const aimTurn = new THREE.Quaternion();
5766
+ const aimEuler = new THREE.Euler(0, 0, 0, "YXZ");
5767
+ const bodyHoldFrame = () => {
5768
+ const shoulder = avatar?.shoulder?.("R");
5769
+ if (!shoulder) return null;
5770
+ shoulder.getWorldPosition(pocketAt);
5771
+ pocketAt.x -= Math.cos(player.yaw) * SHOULDER_POCKET_IN;
5772
+ pocketAt.z += Math.sin(player.yaw) * SHOULDER_POCKET_IN;
5773
+ aimTurn.setFromEuler(aimEuler.set(player.pitch, player.yaw, 0, "YXZ"));
5774
+ return { position: [pocketAt.x, pocketAt.y, pocketAt.z], quaternion: [aimTurn.x, aimTurn.y, aimTurn.z, aimTurn.w] };
5060
5775
  };
5061
5776
  const reachAvatar = () => {
5062
- if (!avatar?.reach || !thirdActive()) return;
5063
- avatar.reach(push.active || heldPiece ? null : handSource?.() ?? null);
5777
+ const doorHand = doors.handTargets();
5778
+ const targets = push.active ? null : heldPiece ? twistHand : handSource?.() ?? null;
5779
+ lastHand = {
5780
+ stance: push.active || doorHand ? "hold" : handStances[toolId] ?? (targets ? "hold" : "relaxed"),
5781
+ triggerFinger: targets?.fingers?.index ?? null,
5782
+ targets: push.handTargets() ?? doorHand ?? targets
5783
+ };
5784
+ if (!avatar?.reach) return;
5785
+ avatar.reach(targets, { camera, keepOutside: { contact: (origin, direction, maxDistance) => rayWorld(origin, direction, maxDistance)?.distance ?? null } });
5064
5786
  };
5065
5787
  const applyThirdView = () => {
5066
5788
  if (!thirdActive()) return () => void 0;
@@ -5566,9 +6288,9 @@ function createTestRoom(options) {
5566
6288
  }
5567
6289
  let blastOptions = { ...options.blast };
5568
6290
  let lastBlast;
5569
- const blastOn = (key) => Boolean(blastOptions[key]);
5570
- const toggleBlast = (key) => {
5571
- blastOptions = { ...blastOptions, [key]: blastOn(key) ? false : true };
6291
+ const blastOn = (key2) => Boolean(blastOptions[key2]);
6292
+ const toggleBlast = (key2) => {
6293
+ blastOptions = { ...blastOptions, [key2]: blastOn(key2) ? false : true };
5572
6294
  };
5573
6295
  const explosives = createExplosives({
5574
6296
  THREE,
@@ -5699,10 +6421,12 @@ function createTestRoom(options) {
5699
6421
  let tiltKey = 0;
5700
6422
  const heldLocal = new THREE.Vector3();
5701
6423
  const heldSize = new THREE.Vector3(0.2, 0.2, 0.2);
5702
- let heldRadius = 0.2;
5703
6424
  const heldVel = [0, 0, 0];
5704
6425
  let useItemPlayer = null;
5705
6426
  let useItemName = "";
6427
+ let afterOpening = null;
6428
+ let twisting = null;
6429
+ let twistHand = null;
5706
6430
  const releaseHeld = () => {
5707
6431
  if (heldPiece) {
5708
6432
  heldPiece.held = false;
@@ -5711,6 +6435,9 @@ function createTestRoom(options) {
5711
6435
  }
5712
6436
  useItemPlayer?.cancel();
5713
6437
  useItemPlayer = null;
6438
+ afterOpening = null;
6439
+ twisting = null;
6440
+ twistHand = null;
5714
6441
  heldEntry = null;
5715
6442
  heldPiece = null;
5716
6443
  heldTilt = 0;
@@ -5757,7 +6484,6 @@ function createTestRoom(options) {
5757
6484
  box2.getSize(heldSize);
5758
6485
  piece.root.quaternion.copy(savedQ);
5759
6486
  piece.root.updateMatrixWorld(true);
5760
- heldRadius = heldSize.length() / 3;
5761
6487
  heldYaw = 0;
5762
6488
  heldTilt = 0;
5763
6489
  heldVel[0] = heldVel[1] = heldVel[2] = 0;
@@ -5778,52 +6504,28 @@ function createTestRoom(options) {
5778
6504
  fire("throw", { objectId: id, speed });
5779
6505
  return true;
5780
6506
  };
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 };
6507
+ const twistTargets = (root, gripQ) => {
6508
+ if (!twisting) return null;
6509
+ const { stroke, lift } = twisting;
6510
+ const up = new THREE.Vector3(0, 1, 0).applyQuaternion(root.quaternion);
6511
+ 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);
6512
+ const knuckles = new THREE.Vector3(0.85, 0, -0.5).normalize().applyQuaternion(gripQ);
6513
+ knuckles.addScaledVector(up, -knuckles.dot(up)).normalize().applyAxisAngle(up, stroke.angle);
6514
+ const side = new THREE.Vector3().crossVectors(knuckles, up).normalize();
6515
+ const palm = top.addScaledVector(knuckles, -0.025);
6516
+ const q = new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(knuckles, up, side));
6517
+ 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
6518
  };
5817
6519
  const gripQuat = new THREE.Quaternion();
5818
6520
  const followHeld = (dt) => {
5819
6521
  const piece = heldPiece;
5820
- carriedHand = null;
6522
+ twistHand = null;
5821
6523
  if (!piece) return;
5822
6524
  camera.updateMatrixWorld(true);
5823
6525
  const root = piece.root;
5824
6526
  let pos;
5825
6527
  const targetQ = tmpQ;
5826
- const hand = thirdActive() ? avatar?.hand?.("R") : void 0;
6528
+ const hand = avatar?.hand?.("R");
5827
6529
  const isDrink = useItemName === "drink" || useItemName === "sip" || useItemName === "chug";
5828
6530
  if (hand) {
5829
6531
  const handWorld = hand.getWorldPosition(tmpHand);
@@ -5839,7 +6541,7 @@ function createTestRoom(options) {
5839
6541
  pos = tmpV.copy(handWorld).add(gripOffset);
5840
6542
  } else if (useItemPlayer) {
5841
6543
  const pose = useItemPlayer.pose();
5842
- const frameQ = avatar?.firstPerson ? camera.quaternion : new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), player.yaw);
6544
+ const frameQ = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), player.yaw);
5843
6545
  const raise = isDrink ? Math.max(0, Math.min(1, (pose.position[1] + 0.2) / 0.22)) : 0;
5844
6546
  const px2 = pose.position[0] + (isDrink ? 0.02 * raise : 0);
5845
6547
  const py2 = pose.position[1] - (isDrink ? 0.08 * raise : 0);
@@ -5852,19 +6554,10 @@ function createTestRoom(options) {
5852
6554
  targetQ.copy(frameQ).multiply(eq);
5853
6555
  targetQ.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), heldYaw));
5854
6556
  } 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)));
6557
+ pos = tmpV.set(0.05, -0.28, -1.15).applyMatrix4(camera.matrixWorld);
6558
+ 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
6559
  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
- }
6560
+ targetQ.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), -heldTilt));
5868
6561
  }
5869
6562
  const k = Math.min(1, (useItemPlayer ? 30 : 16) * dt);
5870
6563
  const px = root.position.x;
@@ -5879,8 +6572,50 @@ function createTestRoom(options) {
5879
6572
  heldVel[2] = (root.position.z - pz) / dt;
5880
6573
  }
5881
6574
  root.updateMatrixWorld(true);
5882
- if (avatar?.firstPerson && cameraMode === "first") carriedHand = carriedTargets(root, gripQuat);
6575
+ twistHand = twistTargets(root, gripQuat);
6576
+ };
6577
+ const TOOL_REACH = 1.6;
6578
+ const toolsAtHand = () => {
6579
+ const here = player.position;
6580
+ const near = [...loadout];
6581
+ for (const other of entries.values()) {
6582
+ if (other === heldEntry || other.arena) continue;
6583
+ const p = other.so.root.position;
6584
+ if (Math.hypot(p.x - here[0], p.z - here[2]) <= TOOL_REACH) near.push(other.so.itemId);
6585
+ }
6586
+ return near;
6587
+ };
6588
+ const sealLoss = (e) => {
6589
+ if (e.sealLoss !== void 0) return e.sealLoss;
6590
+ const c = e.containerId ? fluids.container(e.containerId) : void 0;
6591
+ return c && e.fluidStart !== void 0 ? Math.max(0, e.fluidStart - c.liters - (e.fluidUsed ?? 0)) : 0;
6592
+ };
6593
+ const sealState = (e) => {
6594
+ const breached = world.state(e.so.objectId)?.parts.some((p) => p.holes.some((h) => h.through && !h.vent)) ?? false;
6595
+ const leaked = e.fluidStart !== void 0 && sealLoss(e) > Math.max(2e-3, e.fluidStart * 0.02);
6596
+ return { opened: !!e.opened, breached, leaked };
6597
+ };
6598
+ const openSeal = (e) => {
6599
+ const seal = e.item?.seal;
6600
+ if (!seal || e.opened) return true;
6601
+ const opener = sealOpener(seal, toolsAtHand());
6602
+ if (!opener.ok) {
6603
+ const listed = (seal.openWith ?? []).map((id) => catalogMap.get(id)?.name).filter((n) => !!n);
6604
+ const names = [...new Set(listed.length ? listed : (seal.openWith ?? []).filter((id) => arsenal.weapons.has(id)).map((id) => toolName(id)))];
6605
+ const why = t("hud.needs_opener", { name: e.name, tools: names.join(", ") });
6606
+ say(why);
6607
+ action("sealed", why, { objectId: e.so.objectId, itemId: e.so.itemId, data: { openWith: [...seal.openWith ?? []] } });
6608
+ return false;
6609
+ }
6610
+ e.sealLoss = sealLoss(e);
6611
+ e.opened = true;
6612
+ if (e.containerId) fluids.open(e.containerId);
6613
+ seal.open?.(wrapped.get(e.so.root) ?? e.so.root);
6614
+ const tool2 = opener.tool ? catalogMap.get(opener.tool)?.name ?? (arsenal.weapons.has(opener.tool) ? toolName(opener.tool) : opener.tool) : void 0;
6615
+ 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" } });
6616
+ return true;
5883
6617
  };
6618
+ const crouchOnC = (options.crouch?.keys ?? ["KeyC"]).includes("KeyC");
5884
6619
  const useItem = (useId) => {
5885
6620
  const e = heldEntry;
5886
6621
  if (!e?.containerId) return false;
@@ -5889,25 +6624,90 @@ function createTestRoom(options) {
5889
6624
  const c = fluids.container(e.containerId);
5890
6625
  if (!c) return false;
5891
6626
  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";
6627
+ const eating = useId === "drink" || useId === "sip" || useId === "chug" || useId === "eat";
6628
+ const consuming = eating || useId === "pour" || useId === "pour_out";
6629
+ if (useItemPlayer && !useItemPlayer.done) return false;
6630
+ const seal = e.item?.seal;
6631
+ if (seal && (consuming || useId === "open")) {
6632
+ const state = sealState(e);
6633
+ const spoiled = sealBlock(seal, { ...state, opened: true });
6634
+ if (eating && spoiled) {
6635
+ say(t(spoiled === "leaked" ? "hud.spoiled_leak" : "hud.spoiled_hole", { name: e.name }));
6636
+ 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 } });
6637
+ return false;
6638
+ }
6639
+ if (!e.opened) {
6640
+ const opening = seal.byHand && seal.openUse ? itemUseDefs.find((u) => u.id === seal.openUse) : void 0;
6641
+ if (opening) {
6642
+ useItemPlayer = createItemUse(fluids, e.containerId, opening);
6643
+ useItemName = opening.id;
6644
+ afterOpening = useId === "open" ? null : useId;
6645
+ return true;
6646
+ }
6647
+ if (!openSeal(e)) return false;
6648
+ if (!seal.byHand || useId === "open") {
6649
+ useItemPlayer = createItemUse(fluids, e.containerId, itemUseDefs.find((u) => u.id === "open") ?? def);
6650
+ useItemName = "open";
6651
+ return true;
6652
+ }
6653
+ } else if (useId === "open") return false;
6654
+ }
5893
6655
  if (consuming && c.liters <= 1e-6) {
5894
6656
  say(t("hud.empty_container", { name: e.name }));
5895
6657
  return false;
5896
6658
  }
5897
- if (useItemPlayer && !useItemPlayer.done) return false;
5898
6659
  if (consuming && c.closed) fluids.open(e.containerId);
5899
6660
  useItemPlayer = createItemUse(fluids, e.containerId, def);
5900
6661
  useItemName = def.id;
6662
+ if (eating) useLiters = c.liters;
5901
6663
  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
6664
  return true;
5903
6665
  };
6666
+ let useLiters = null;
5904
6667
  const updateItemUse = (dt) => {
5905
6668
  if (!useItemPlayer) return;
6669
+ const e = heldEntry;
6670
+ const c = e?.containerId ? fluids.container(e.containerId) : void 0;
6671
+ const before = c?.liters;
5906
6672
  useItemPlayer.update(dt);
6673
+ const seal = e?.item?.seal;
6674
+ twisting = null;
6675
+ if (e && seal?.openUse === useItemName && !e.opened) {
6676
+ twisting = openingProgress(useItemPlayer.def, useItemPlayer.time);
6677
+ seal.turn?.(wrapped.get(e.so.root) ?? e.so.root, { turned: twisting.stroke.turned, lift: twisting.lift });
6678
+ if (twisting.lift >= 1 || useItemPlayer.done) {
6679
+ openSeal(e);
6680
+ twisting = null;
6681
+ }
6682
+ }
6683
+ if (e && useLiters !== null && before !== void 0) {
6684
+ const after = (e.containerId ? fluids.container(e.containerId)?.liters : void 0) ?? before;
6685
+ e.fluidUsed = (e.fluidUsed ?? 0) + Math.max(0, before - after);
6686
+ if (e.opened && e.fluidStart) e.item?.seal?.contents?.(wrapped.get(e.so.root) ?? e.so.root, after / e.fluidStart);
6687
+ }
5907
6688
  if (useItemPlayer.done) {
6689
+ if (e && useLiters !== null && useItemName === "eat") {
6690
+ const left = (e.containerId ? fluids.container(e.containerId)?.liters : void 0) ?? 0;
6691
+ 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 } });
6692
+ }
6693
+ useLiters = null;
5908
6694
  useItemPlayer = null;
5909
6695
  useItemName = "";
5910
- }
6696
+ const next = afterOpening;
6697
+ afterOpening = null;
6698
+ if (next && e?.opened) useItem(next);
6699
+ }
6700
+ };
6701
+ const openingProgress = (def, time) => {
6702
+ const keys = [...def.keys].sort((a, b) => a.t - b.t);
6703
+ const turning = keys.findIndex((k) => k.action === "turn");
6704
+ const lastTurn = keys.map((k) => k.action).lastIndexOf("turn");
6705
+ const start = turning >= 0 ? keys[turning].t : 0;
6706
+ const end = lastTurn >= 0 ? keys[lastTurn + 1]?.t ?? def.duration : start;
6707
+ const openAt = keys.find((k) => k.action === "open")?.t ?? def.duration;
6708
+ const share = end > start ? (time - start) / (end - start) : 1;
6709
+ const lift = time <= end ? 0 : openAt > end ? Math.min(1, (time - end) / (openAt - end)) : 1;
6710
+ return { stroke: twistStroke(share), lift };
5911
6711
  };
5912
6712
  const TAP_ID = "room_tap";
5913
6713
  const TAP_RATE = 0.25;
@@ -5986,7 +6786,7 @@ function createTestRoom(options) {
5986
6786
  if (code === "KeyZ") tiltKey = 0;
5987
6787
  if (code === "KeyE" && pushByKey) {
5988
6788
  pushByKey = false;
5989
- if (push.end()) grab();
6789
+ if (push.end() && !doors.use()) grab();
5990
6790
  }
5991
6791
  return;
5992
6792
  }
@@ -6006,7 +6806,7 @@ function createTestRoom(options) {
6006
6806
  case "KeyE":
6007
6807
  if (e.repeat) break;
6008
6808
  pushByKey = !heldPiece && push.begin();
6009
- if (!pushByKey) grab();
6809
+ if (!pushByKey && (heldPiece || !doors.use())) grab();
6010
6810
  break;
6011
6811
  case "KeyG":
6012
6812
  throwHeld();
@@ -6032,11 +6832,13 @@ function createTestRoom(options) {
6032
6832
  case "KeyZ":
6033
6833
  tiltKey = 1;
6034
6834
  break;
6035
- case "KeyF":
6036
- if (heldEntry?.uses[0]) useItem(heldEntry.uses[0]);
6835
+ case "KeyF": {
6836
+ const use = heldEntry?.uses[e.shiftKey && crouchOnC ? 1 : 0];
6837
+ if (use) useItem(use);
6037
6838
  break;
6839
+ }
6038
6840
  case "KeyC":
6039
- if (heldEntry?.uses[1] && !(options.crouch?.keys ?? ["KeyC"]).includes("KeyC")) useItem(heldEntry.uses[1]);
6841
+ if (heldEntry?.uses[1] && !crouchOnC) useItem(heldEntry.uses[1]);
6040
6842
  break;
6041
6843
  case "KeyV":
6042
6844
  if (heldEntry?.uses[2]) useItem(heldEntry.uses[2]);
@@ -6081,6 +6883,12 @@ function createTestRoom(options) {
6081
6883
  aim();
6082
6884
  return rayWorld(aimO, aimD, 14);
6083
6885
  };
6886
+ const leafNote = (distance) => {
6887
+ if (distance > DOOR_REACH) return void 0;
6888
+ const leaf = doors.aimed();
6889
+ if (!leaf) return void 0;
6890
+ return t(leaf.locked ? "hud.door_hint_locked" : leaf.open ? "hud.door_hint_close" : "hud.door_hint_open");
6891
+ };
6084
6892
  const inspectInfo = () => {
6085
6893
  const r = inspectTarget();
6086
6894
  if (!r) return null;
@@ -6116,7 +6924,7 @@ function createTestRoom(options) {
6116
6924
  load: ext.load,
6117
6925
  stress: ext.stress,
6118
6926
  joints,
6119
- note: e?.item?.note,
6927
+ note: leafNote(r.distance) ?? e?.item?.note,
6120
6928
  thumbnail: e?.item?.thumbnail,
6121
6929
  thumbnailCredit: e?.item?.thumbnailCredit,
6122
6930
  distance: r.distance
@@ -6180,6 +6988,7 @@ function createTestRoom(options) {
6180
6988
  }
6181
6989
  syncLiters();
6182
6990
  renderer.update(dt);
6991
+ for (const e of entries.values()) e.follower?.update?.();
6183
6992
  terrainView.sync(camera.position);
6184
6993
  showGroundFire(dt);
6185
6994
  vfx.update(dt);
@@ -6207,12 +7016,14 @@ function createTestRoom(options) {
6207
7016
  const update = (dt) => {
6208
7017
  if (!ownsLoop) roomMetrics.frame(dt);
6209
7018
  frameStamp++;
7019
+ seating.update();
6210
7020
  player.update(dt);
6211
7021
  if (pushByKey && !player.keys.has("KeyE")) {
6212
7022
  pushByKey = false;
6213
7023
  push.end();
6214
7024
  }
6215
7025
  push.step(paused ? 0 : dt);
7026
+ doors.step(paused ? 0 : dt);
6216
7027
  if (!paused) kickLight(dt);
6217
7028
  if (!paused) pushByPlayer(dt);
6218
7029
  else player.takeWish();
@@ -6228,7 +7039,6 @@ function createTestRoom(options) {
6228
7039
  }
6229
7040
  if (!paused) followHeld(dt);
6230
7041
  stepSim(dt * timeScale);
6231
- stepFirstPerson(paused ? 0 : dt);
6232
7042
  reachAvatar();
6233
7043
  if (progress && simTime - progress.at > 0.25) progress = null;
6234
7044
  hudAcc += dt;
@@ -6236,6 +7046,7 @@ function createTestRoom(options) {
6236
7046
  hudAcc = 0;
6237
7047
  ui.hud(hudState());
6238
7048
  }
7049
+ ui?.flushLog();
6239
7050
  metricsAcc += dt;
6240
7051
  if (metricsAcc >= METRICS_INTERVAL_S && ui) {
6241
7052
  metricsAcc = 0;
@@ -6256,7 +7067,7 @@ function createTestRoom(options) {
6256
7067
  pages: Math.max(1, Math.ceil(loadout.length / 10)),
6257
7068
  status: simTime - statusAt < 2 ? statusLine : "",
6258
7069
  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,
7070
+ 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
7071
  inspect: inspectInfo(),
6261
7072
  animations: animationsOf(),
6262
7073
  locked: player.locked,
@@ -6297,17 +7108,33 @@ function createTestRoom(options) {
6297
7108
  const pose = poseAt(pl.clip, id === "" ? null : id, pl.t);
6298
7109
  obj.position.set(rest.p[0] + pose.position[0], rest.p[1] + pose.position[1], rest.p[2] + pose.position[2]);
6299
7110
  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
- }
7111
+ if (id !== "") followPart(pl.entry, id, obj, rest);
7112
+ }
7113
+ };
7114
+ const fpRest = new THREE.Matrix4();
7115
+ const fpNow = new THREE.Matrix4();
7116
+ const fpRoot = new THREE.Matrix4();
7117
+ const fpPart = new THREE.Matrix4();
7118
+ const fpPos = new THREE.Vector3();
7119
+ const fpQuat = new THREE.Quaternion();
7120
+ const fpScale = new THREE.Vector3();
7121
+ const fpEuler = new THREE.Euler();
7122
+ const followPart = (e, id, obj, rest) => {
7123
+ const ps = specs.get(e.so.objectId)?.parts.find((p) => p.id === id);
7124
+ if (!ps || !obj.parent) return;
7125
+ const root = e.so.root;
7126
+ root.updateMatrixWorld(true);
7127
+ fpRoot.copy(root.matrixWorld).invert();
7128
+ fpNow.multiplyMatrices(fpRoot, obj.matrixWorld);
7129
+ fpEuler.set(rest.r[0], rest.r[1], rest.r[2]);
7130
+ fpRest.compose(fpPos.set(rest.p[0], rest.p[1], rest.p[2]), fpQuat.setFromEuler(fpEuler), obj.scale);
7131
+ fpRest.premultiply(obj.parent.matrixWorld).premultiply(fpRoot);
7132
+ fpNow.multiply(fpRest.invert());
7133
+ const r0 = ps.rotation ?? ZERO;
7134
+ 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));
7135
+ fpPart.premultiply(fpNow).decompose(fpPos, fpQuat, fpScale);
7136
+ fpEuler.setFromQuaternion(fpQuat);
7137
+ world.setPartPose(e.so.objectId, id, [fpPos.x, fpPos.y, fpPos.z], [fpEuler.x, fpEuler.y, fpEuler.z]);
6311
7138
  };
6312
7139
  const updateAnimations = (dt) => {
6313
7140
  for (let i = playing.length - 1; i >= 0; i--) {
@@ -6318,8 +7145,8 @@ function createTestRoom(options) {
6318
7145
  while (pl.fired < evs.length && evs[pl.fired].t <= pl.t) {
6319
7146
  const ev = evs[pl.fired++];
6320
7147
  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);
7148
+ const key2 = ev.type === "sound" ? ev.data?.key : void 0;
7149
+ if (typeof key2 === "string") playSound(key2, v3(pl.entry.so.root.getWorldPosition(tmpV)), 1);
6323
7150
  }
6324
7151
  if (pl.t >= pl.clip.duration) {
6325
7152
  playing.splice(i, 1);
@@ -6341,10 +7168,10 @@ function createTestRoom(options) {
6341
7168
  const targets = /* @__PURE__ */ new Map();
6342
7169
  const ids = /* @__PURE__ */ new Set();
6343
7170
  for (const k of clip.keys) for (const id of Object.keys(k.parts ?? {})) ids.add(id);
6344
- const grab2 = (key, obj) => {
7171
+ const grab2 = (key2, obj) => {
6345
7172
  if (!obj) return;
6346
7173
  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);
7174
+ targets.set(key2, obj);
6348
7175
  };
6349
7176
  if (clip.keys.some((k) => k.position || k.rotation)) grab2("", e.so.root);
6350
7177
  for (const id of ids) grab2(id, e.so.root.getObjectByName(id) ?? void 0);
@@ -6371,9 +7198,9 @@ function createTestRoom(options) {
6371
7198
  const METRIC_UNAVAILABLE = "n/a";
6372
7199
  const metricText = (value, digits, unit) => typeof value === "number" ? `${num(value, digits)}${unit ? ` ${unit}` : ""}` : value ?? METRIC_UNAVAILABLE;
6373
7200
  const reportedFailures = /* @__PURE__ */ new Set();
6374
- const reportOnce = (key, text) => {
6375
- if (reportedFailures.has(key)) return;
6376
- reportedFailures.add(key);
7201
+ const reportOnce = (key2, text) => {
7202
+ if (reportedFailures.has(key2)) return;
7203
+ reportedFailures.add(key2);
6377
7204
  host.log(text);
6378
7205
  };
6379
7206
  const failureText = (error) => error instanceof Error ? error.message : String(error);
@@ -6523,6 +7350,14 @@ function createTestRoom(options) {
6523
7350
  frameListeners.add(update2);
6524
7351
  return () => frameListeners.delete(update2);
6525
7352
  },
7353
+ holdFrame: () => bodyHoldFrame(),
7354
+ reachFor(reach) {
7355
+ if (!avatar) {
7356
+ reach.onGrip?.();
7357
+ return;
7358
+ }
7359
+ doors.reachFor(reach);
7360
+ },
6526
7361
  setHandTargets(source) {
6527
7362
  handSource = source;
6528
7363
  return () => {
@@ -6540,6 +7375,10 @@ function createTestRoom(options) {
6540
7375
  clearListeners.add(listener);
6541
7376
  return () => clearListeners.delete(listener);
6542
7377
  },
7378
+ onReset(listener) {
7379
+ resetListeners.add(listener);
7380
+ return () => resetListeners.delete(listener);
7381
+ },
6543
7382
  addObject(itemId, place) {
6544
7383
  return addScenery(itemId, place.position, place.rotationY);
6545
7384
  }
@@ -6567,13 +7406,32 @@ function createTestRoom(options) {
6567
7406
  return r.ok ? store : null;
6568
7407
  };
6569
7408
  const LIGHTS_CHECK_MS = 250;
7409
+ const pinnedPrograms = /* @__PURE__ */ new WeakSet();
7410
+ const pinPrograms = () => {
7411
+ for (const program of gl.info?.programs ?? []) {
7412
+ if (pinnedPrograms.has(program)) continue;
7413
+ pinnedPrograms.add(program);
7414
+ program.usedTimes++;
7415
+ }
7416
+ };
6570
7417
  const warmUp = () => {
6571
7418
  const probes = [renderer.warmUpProbes(), vfx.warmUpProbes(), fluidView.warmUpProbes()];
6572
7419
  const groups = probes.map((probe) => probe.group);
7420
+ const spriteMap = new THREE.DataTexture(new Uint8Array([255, 255, 255, 255]), 1, 1);
7421
+ spriteMap.needsUpdate = true;
7422
+ const spriteProbes = [true, false].map((fog) => {
7423
+ const probe = new THREE.Sprite(new THREE.SpriteMaterial({ map: spriteMap, transparent: true, fog }));
7424
+ probe.frustumCulled = false;
7425
+ return probe;
7426
+ });
7427
+ groups.push(...spriteProbes);
6573
7428
  scene.add(...groups);
6574
7429
  gl.render(scene, camera);
7430
+ pinPrograms();
6575
7431
  scene.remove(...groups);
6576
7432
  for (const probe of probes) probe.dispose();
7433
+ for (const probe of spriteProbes) probe.material.dispose();
7434
+ spriteMap.dispose();
6577
7435
  };
6578
7436
  const warmUpEffects = () => {
6579
7437
  const ids = vfx.catalog?.() ?? [];
@@ -6604,11 +7462,14 @@ function createTestRoom(options) {
6604
7462
  build: (T) => ti.build(T),
6605
7463
  spec: ti.spec,
6606
7464
  movable: ti.movable,
7465
+ pushable: ti.pushable,
6607
7466
  slow: ti.slow,
6608
7467
  fluid: ti.fluid,
7468
+ seal: ti.seal,
6609
7469
  weapon: ti.weapon,
6610
7470
  impl: ti.impl,
6611
7471
  animations: ti.animations,
7472
+ seats: ti.seats,
6612
7473
  thumbnail: ti.thumbnail,
6613
7474
  thumbnailCredit: ti.thumbnailCredit
6614
7475
  };
@@ -6665,15 +7526,20 @@ function createTestRoom(options) {
6665
7526
  const kept = previous?.encode();
6666
7527
  previous?.dispose();
6667
7528
  benches.delete(mod.id);
6668
- for (const key of [...reportedFailures]) if (key.startsWith(`${mod.id}:`)) reportedFailures.delete(key);
7529
+ for (const key2 of [...reportedFailures]) if (key2.startsWith(`${mod.id}:`)) reportedFailures.delete(key2);
6669
7530
  modules.set(mod.id, mod);
6670
7531
  if (bench) {
6671
7532
  const seed = options.bench?.state?.[mod.id];
6672
7533
  const restored = kept ?? (typeof seed === "string" ? seed : void 0);
6673
7534
  if (restored !== void 0) bench.restore(restored);
6674
7535
  benches.set(mod.id, bench);
7536
+ let reportedState = bench.encode();
6675
7537
  bench.watch(() => {
6676
- options.bench?.onChange?.(mod.id, bench.encode());
7538
+ const encoded = bench.encode();
7539
+ if (encoded !== reportedState) {
7540
+ reportedState = encoded;
7541
+ options.bench?.onChange?.(mod.id, encoded);
7542
+ }
6677
7543
  if (activeBench === mod.id) ui?.bench(benchView());
6678
7544
  });
6679
7545
  if (!activeBench || !benches.has(activeBench)) activeBench = mod.id;
@@ -6695,8 +7561,10 @@ function createTestRoom(options) {
6695
7561
  const dt = seconds > 6 ? 1 / 30 : FIXED_DT;
6696
7562
  const n = Math.max(1, Math.round(seconds / dt));
6697
7563
  for (let i = 0; i < n; i++) {
7564
+ seating.update();
6698
7565
  player.update(0);
6699
7566
  push.step(dt);
7567
+ doors.step(dt);
6700
7568
  followHeld(dt);
6701
7569
  stepSim(dt);
6702
7570
  }
@@ -6831,7 +7699,7 @@ function createTestRoom(options) {
6831
7699
  room.waitUntilReady();
6832
7700
  return true;
6833
7701
  },
6834
- look: (eye, target) => player.look(eye, target),
7702
+ look: (eye, target) => lookAt(eye, target),
6835
7703
  use: (hold) => room.use(hold),
6836
7704
  blastAt: (weapon, point) => {
6837
7705
  const def = arsenal.weapons.get(weapon);
@@ -6887,7 +7755,7 @@ function createTestRoom(options) {
6887
7755
  grab,
6888
7756
  throwHeld,
6889
7757
  look(position, target) {
6890
- player.look(position, target);
7758
+ lookAt(position, target);
6891
7759
  },
6892
7760
  inspect() {
6893
7761
  const r = inspectTarget();
@@ -6954,11 +7822,23 @@ function createTestRoom(options) {
6954
7822
  busy: (label, work, busyOptions) => loading.busy(label, work, busyOptions),
6955
7823
  loadModule,
6956
7824
  warmUp: () => {
7825
+ audio?.warmUp();
6957
7826
  warmUp();
6958
7827
  warmUpEffects();
6959
7828
  },
6960
7829
  animations: animationsOf,
6961
7830
  playAnimation,
7831
+ useLeaf: (objectId, part) => doors.useLeaf(objectId, part),
7832
+ leafOpening: (objectId, part) => doors.opening(objectId, part ?? leavesOf(objectId)[0]?.part ?? ""),
7833
+ leaves: (objectId) => leavesOf(objectId).map((l) => l.part),
7834
+ lockLeaf(objectId, locked = true, part) {
7835
+ const k = `${objectId}\0${part ?? "*"}`;
7836
+ if (locked) lockedLeaves.add(k);
7837
+ else lockedLeaves.delete(k);
7838
+ },
7839
+ get door() {
7840
+ return doors.state;
7841
+ },
6962
7842
  bench(moduleId) {
6963
7843
  const id = moduleId ?? activeBench;
6964
7844
  return (id ? benches.get(id) : void 0) ?? null;
@@ -7001,6 +7881,11 @@ function createTestRoom(options) {
7001
7881
  get playerPosition() {
7002
7882
  return player.position;
7003
7883
  },
7884
+ sit: (objectId, seatId) => seating.sit(objectId, seatId),
7885
+ stand: () => seating.stand(),
7886
+ get seat() {
7887
+ return seating.seat;
7888
+ },
7004
7889
  push: { begin: () => push.begin(), end: () => push.end(), setWalk: (walk) => push.setWalk(walk), get state() {
7005
7890
  return push.state;
7006
7891
  } },
@@ -7103,8 +7988,8 @@ function createTestRoom(options) {
7103
7988
  },
7104
7989
  onTap: () => toggleTap(),
7105
7990
  ...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),
7991
+ ...arsenal.weapons.has("kick") ? { kick: { label: `${t("ui.kick")} (${kickKeys.map((key2) => key2.replace(/^Key/, "")).join("/")})`, onKick: () => void kick() } } : {},
7992
+ onBlast: (key2) => toggleBlast(key2),
7108
7993
  onPlay: (objectId, clipId) => {
7109
7994
  playAnimation(clipId, objectId);
7110
7995
  },
@@ -7130,19 +8015,19 @@ function createTestRoom(options) {
7130
8015
  let nextLightsCheck = 0;
7131
8016
  let warmedLights = "";
7132
8017
  const lightsKey = () => {
7133
- let key = "";
8018
+ let key2 = "";
7134
8019
  scene.traverseVisible((object) => {
7135
- if (object.isLight) key += `${object.type[0]}${object.castShadow ? "s" : ""}`;
8020
+ if (object.isLight) key2 += `${object.type[0]}${object.castShadow ? "s" : ""}`;
7136
8021
  });
7137
- return key;
8022
+ return key2;
7138
8023
  };
7139
8024
  const loop = (now) => {
7140
8025
  raf = requestAnimationFrame(loop);
7141
8026
  if (now >= nextLightsCheck) {
7142
8027
  nextLightsCheck = now + LIGHTS_CHECK_MS;
7143
- const key = lightsKey();
7144
- if (key !== warmedLights) {
7145
- warmedLights = key;
8028
+ const key2 = lightsKey();
8029
+ if (key2 !== warmedLights) {
8030
+ warmedLights = key2;
7146
8031
  warmUp();
7147
8032
  }
7148
8033
  }
@@ -7153,6 +8038,7 @@ function createTestRoom(options) {
7153
8038
  if (!scenarioRunning) update(dt);
7154
8039
  const restoreCamera = room.applyCamera();
7155
8040
  gl.render(scene, debugView ?? camera);
8041
+ pinPrograms();
7156
8042
  restoreCamera();
7157
8043
  roomMetrics.rendered();
7158
8044
  };