@adatechnology/game-test-room 0.20.0 → 0.22.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
@@ -1,13 +1,92 @@
1
1
  // src/room.ts
2
2
  import { createDestruction, JOINT_DEFAULTS, resolveStrings as resolveDestructionStrings } from "@adatechnology/game-destruction";
3
- import { createAudioEngine, createAudioSink } from "@adatechnology/game-audio";
4
- import { createDestructionRenderer, createDestructionSound, dragSoundKey, impactKey, specFromObject3D } from "@adatechnology/game-destruction-three";
3
+ import { createAudioEngine, createAudioSink, loadKitSamples } from "@adatechnology/game-audio";
4
+ import { createDestructionRenderer, createDestructionSound, dragSoundKey, impactKey, impactSoundKeys, specFromObject3D } from "@adatechnology/game-destruction-three";
5
+ import { bodyMass, KNOCK_CHEST, knockOf, surfaceOfMaterial } from "@adatechnology/game-contracts";
6
+
7
+ // src/body-sounds.ts
8
+ import { BODY_FOLEY_BANK } from "@adatechnology/game-audio";
9
+ import { SWING_LEAD_SECONDS, hitSoundId, landSoundId, swingSoundId } from "@adatechnology/game-contracts";
10
+ var DEFAULT_TOOL_BLOWS = {
11
+ hand: "punch",
12
+ fists: "punch",
13
+ kick: "kick",
14
+ shove: "shove",
15
+ knife: "blade",
16
+ machete: "blade",
17
+ utility_knife: "blade",
18
+ axe: "chop",
19
+ hammer: "blunt",
20
+ claw_hammer: "blunt",
21
+ sledgehammer: "blunt",
22
+ baseball_bat: "blunt",
23
+ crowbar: "blunt",
24
+ wrench: "blunt"
25
+ };
26
+ function blowOfTool(id, category, table = DEFAULT_TOOL_BLOWS) {
27
+ const listed = table[id];
28
+ if (listed) return listed;
29
+ if (category === "melee_blade") return "blade";
30
+ if (category === "melee_blunt" || category === "hand") return id === "kick" ? "kick" : "blunt";
31
+ return null;
32
+ }
33
+ var levelOf = (energyJ) => Math.max(0.5, Math.min(1, 0.5 + energyJ / 800));
34
+ var STRIDE_METRES = { walk: 0.72, run: 1.05 };
35
+ var SLAM_HURT_SPEED = 3.5;
36
+ function createBodySounds(audio) {
37
+ audio?.register(BODY_FOLEY_BANK);
38
+ const played = [];
39
+ const play = (id, position, volume, delay = 0) => {
40
+ played.push(id);
41
+ if (played.length > 64) played.shift();
42
+ audio?.play(id, { position, volume, urgent: true, ...delay > 0 ? { delay } : {} });
43
+ };
44
+ return {
45
+ swing(blow, position, delay = 0) {
46
+ const id = swingSoundId(blow);
47
+ if (id) play(id, position, 0.8, delay);
48
+ },
49
+ hit(blow, target, position, energyJ) {
50
+ play(hitSoundId(blow, target), position, levelOf(energyJ));
51
+ },
52
+ jump: (position) => play("jump", position, 0.6),
53
+ land: (surface, speed, position) => play(landSoundId(surface, speed), position, Math.min(1, 0.45 + speed / 8)),
54
+ slam(speed, position) {
55
+ play("body_fall", position, Math.min(1, 0.4 + speed / 8));
56
+ if (speed >= SLAM_HURT_SPEED) play("player_hurt", position, Math.min(1, 0.5 + speed / 12));
57
+ },
58
+ played
59
+ };
60
+ }
61
+ var swingLead = (blow) => SWING_LEAD_SECONDS[blow];
62
+ function createStrideCounter(stride = STRIDE_METRES) {
63
+ let left = stride.walk / 2;
64
+ let next = "right";
65
+ return {
66
+ advance(metres, gait) {
67
+ left -= metres;
68
+ if (left > 0) return null;
69
+ left += stride[gait];
70
+ const foot = next;
71
+ next = next === "right" ? "left" : "right";
72
+ return foot;
73
+ },
74
+ reset() {
75
+ left = stride.walk / 2;
76
+ next = "right";
77
+ }
78
+ };
79
+ }
80
+
81
+ // src/room.ts
5
82
  import { createArsenal, DEFAULT_LOADOUT } from "@adatechnology/game-weapons";
6
83
  import { createFilmLcg, createFluids, createItemUse, ITEM_USES, resolveStrings as resolveFluidStrings } from "@adatechnology/game-fluids";
7
84
  import { createFilmBridge, createFilmManager, createFluidRenderer } from "@adatechnology/game-fluids-three";
8
85
  import { SPRITE_KINDS, createVfx, patchSpriteKinds } from "@adatechnology/game-vfx";
9
86
  import { createVfxRenderer } from "@adatechnology/game-vfx-three";
10
- import { checkAnimation, checkBench, checkDestructible, checkSpec, createEnvironmentBus, sealBlock, sealOpener, shotExtras, twistStroke, TWIST_LIFT, useToHit } from "@adatechnology/game-contracts";
87
+ import { createFramePacer, createGeometryPool, createLightBudget, createMaterialPool, createProgramWarmup, createStaticBatcher, spriteProbes } from "@adatechnology/game-render-three";
88
+ import { DEFERRED_PRIORITY, DEFERRED_QUEUE_ID, checkAnimation, checkBench, checkDestructible, checkSpec, createEnvironmentBus, sealBlock, sealOpener, shotExtras, twistStroke, TWIST_LIFT, useToHit } from "@adatechnology/game-contracts";
89
+ import { createScheduler, startLoop } from "@adatechnology/game-runtime";
11
90
 
12
91
  // src/arena.ts
13
92
  import { markKitOrigin } from "@adatechnology/game-contracts";
@@ -242,6 +321,38 @@ function createArena(THREE, size, scene, scenery, tapOption) {
242
321
  };
243
322
  }
244
323
 
324
+ // src/hand-sounds.ts
325
+ import { LEAF_FOLEY_SETS } from "@adatechnology/game-audio";
326
+ function pickupClassOf(def) {
327
+ if (!def) return "plastic";
328
+ const tags = def.tags ?? [];
329
+ if (def.id === "paper") return "paper";
330
+ if (tags.includes("metal")) return "metal";
331
+ if (tags.includes("glass") || tags.includes("ceramic")) return "glass";
332
+ if (tags.includes("plastic") || tags.includes("rubber")) return "plastic";
333
+ if (tags.includes("wood")) return "wood";
334
+ if (tags.includes("fabric")) return "fabric";
335
+ if (tags.includes("plant")) return "food";
336
+ return "plastic";
337
+ }
338
+ var pickupSoundOf = (sounds, def) => `item_pickup_${sounds?.handling ?? pickupClassOf(def)}`;
339
+ function leafFoleyOf(sounds, part, impactKey2, slides2) {
340
+ const named = sounds?.leaves?.[part] ?? sounds?.leaves?.["*"];
341
+ const set = named ? LEAF_FOLEY_SETS[named] : void 0;
342
+ if (set) return set;
343
+ if (slides2) return LEAF_FOLEY_SETS.drawer;
344
+ return /metal|glass/.test(impactKey2) ? LEAF_FOLEY_SETS.metal_door : LEAF_FOLEY_SETS.wood_door;
345
+ }
346
+ var openSoundOf = (sounds, def) => sounds?.open ?? (pickupClassOf(def) === "metal" ? "can_open" : "bottle_open");
347
+ var USE_SOUNDS = {
348
+ drink: "drink_gulp",
349
+ sip: "drink_gulp",
350
+ chug: "drink_gulp",
351
+ eat: "eat_chew"
352
+ };
353
+ var USE_SOUND_EVERY = 0.55;
354
+ var useSoundOf = (sounds, useId) => sounds?.uses?.[useId] ?? USE_SOUNDS[useId];
355
+
245
356
  // src/bench.ts
246
357
  var snap = (control, value) => {
247
358
  const clamped = Math.min(control.max, Math.max(control.min, value));
@@ -691,7 +802,7 @@ function createExplosives(context) {
691
802
  if (!detonation.cone) told(results);
692
803
  if (detonation.energy > 0) {
693
804
  const isPlayed = context.effects.explosion(detonation.explosive, detonation.point, { direction: detonation.direction }) !== null;
694
- renderer.explode(detonation.point, detonation.energy, reach, { visual: !isPlayed, report: results });
805
+ renderer.explode(detonation.point, detonation.energy, reach, { visual: !isPlayed, report: results, effect: detonation.explosive });
695
806
  }
696
807
  if (detonation.stunSeconds !== void 0) renderer.emit("flash", detonation.point, [0, 1, 0], 1, 1);
697
808
  const onPlayer = blastEffect(detonation, toArray(camera.position));
@@ -1187,6 +1298,91 @@ function createLoading(options) {
1187
1298
  };
1188
1299
  }
1189
1300
 
1301
+ // src/graphics.ts
1302
+ import { createGraphicsController, frameLoad, targetFrameMs } from "@adatechnology/game-graphics";
1303
+ import { createFpsOverlay, createGraphicsScreen } from "@adatechnology/game-graphics/screen";
1304
+ import { createGpuFrameTimer, createGraphicsView, gpuName } from "@adatechnology/game-graphics-three";
1305
+ var QUIET_FRAMES = 10;
1306
+ function createRoomGraphics(o) {
1307
+ const view = createGraphicsView({ renderer: o.gl, camera: o.camera, sun: o.sun });
1308
+ const seconds = o.options.calibrationSeconds ?? 3;
1309
+ const controller = createGraphicsController({
1310
+ store: { ...o.options.storage !== void 0 ? { storage: o.options.storage } : {}, ...o.options.key ? { key: o.options.key } : {} },
1311
+ gpu: gpuName(o.gl),
1312
+ benchmark: seconds > 0 ? { durationS: seconds } : false,
1313
+ // the room measures its own frames once it is up, not the loading of its content
1314
+ calibrate: "manual",
1315
+ apply: (e) => view.apply(e)
1316
+ });
1317
+ const timer = createGpuFrameTimer(o.gl);
1318
+ const screen = o.ui ? createGraphicsScreen({ container: o.container, controller, locale: o.locale }) : null;
1319
+ const overlay = o.ui ? createFpsOverlay({ container: o.container, controller, locale: o.locale }) : null;
1320
+ const listeners = /* @__PURE__ */ new Set();
1321
+ let lastHorde = controller.settings.hordeDetail;
1322
+ const unsubscribe = controller.subscribe(() => {
1323
+ if (controller.settings.hordeDetail === lastHorde) return;
1324
+ lastHorde = controller.settings.hordeDetail;
1325
+ for (const l of listeners) l();
1326
+ });
1327
+ let task = null;
1328
+ const syncTask = () => {
1329
+ if (controller.phase === "calibrating") {
1330
+ task ?? (task = o.loading.begin(o.label, { total: 100 }));
1331
+ task.step(`${Math.round(controller.calibrationProgress * 100)}%`, Math.round(controller.calibrationProgress * 100));
1332
+ } else if (task) {
1333
+ task.end();
1334
+ task = null;
1335
+ }
1336
+ };
1337
+ syncTask();
1338
+ let started = 0;
1339
+ let quiet = 0;
1340
+ let lastEnd = 0;
1341
+ return {
1342
+ controller,
1343
+ view,
1344
+ screen,
1345
+ host: {
1346
+ hordeDetail: () => controller.settings.hordeDetail,
1347
+ onChange(listener) {
1348
+ listeners.add(listener);
1349
+ return () => listeners.delete(listener);
1350
+ },
1351
+ report: (key2, value) => controller.report(key2, value)
1352
+ },
1353
+ frameStart() {
1354
+ started = performance.now();
1355
+ },
1356
+ beforeRender(camera) {
1357
+ view.hold();
1358
+ view.follow(camera.position.x, camera.position.z);
1359
+ timer.begin();
1360
+ },
1361
+ afterRender() {
1362
+ timer.end();
1363
+ },
1364
+ frameEnd() {
1365
+ const now = performance.now();
1366
+ const realSeconds = lastEnd > 0 ? (now - lastEnd) / 1e3 : 0;
1367
+ lastEnd = now;
1368
+ if (!(realSeconds > 0)) return;
1369
+ const cpuMs = started > 0 ? now - started : realSeconds * 1e3;
1370
+ controller.frame(realSeconds, frameLoad({ cpuMs, gpuMs: timer.ms, intervalMs: realSeconds * 1e3, targetMs: targetFrameMs(controller.settings.fpsCap) }));
1371
+ quiet = o.loading.active && !task ? 0 : quiet + 1;
1372
+ if (o.options.calibrate !== "manual" && quiet >= QUIET_FRAMES) controller.calibrateIfNeeded();
1373
+ syncTask();
1374
+ },
1375
+ dispose() {
1376
+ task?.end();
1377
+ unsubscribe();
1378
+ listeners.clear();
1379
+ timer.dispose();
1380
+ screen?.dispose();
1381
+ overlay?.dispose();
1382
+ }
1383
+ };
1384
+ }
1385
+
1190
1386
  // src/log.ts
1191
1387
  var MAX_ENTRIES = 5e3;
1192
1388
  var pad = (n, w = 2) => String(n).padStart(w, "0");
@@ -1316,8 +1512,12 @@ function createLog(options = {}) {
1316
1512
  }
1317
1513
 
1318
1514
  // src/metrics.ts
1319
- var ROOM_METRICS = ["fps", "frame_ms", "draw_calls", "triangles", "objects", "fragments", "particles", "decals", "blast_shelter", "blast_confinement", "blast_fragments"];
1515
+ var ROOM_METRICS = ["fps", "frame_ms", "draw_calls", "triangles", "objects", "fragments", "particles", "decals", "blast_shelter", "blast_confinement", "blast_fragments", "geometries", "textures", "heap_mb"];
1320
1516
  var WINDOW_S = 0.5;
1517
+ function heapMB() {
1518
+ const memory = globalThis.performance?.memory;
1519
+ return typeof memory?.usedJSHeapSize === "number" ? memory.usedJSHeapSize / 1048576 : void 0;
1520
+ }
1321
1521
  function createRoomMetrics(sources) {
1322
1522
  const wanted = new Set(sources.ids);
1323
1523
  const gl = sources.gl;
@@ -1353,6 +1553,9 @@ function createRoomMetrics(sources) {
1353
1553
  const render = ownsRender ? { calls, triangles } : gl.info?.render;
1354
1554
  put("draw_calls", render?.calls);
1355
1555
  put("triangles", render?.triangles);
1556
+ put("geometries", gl.info?.memory?.geometries);
1557
+ put("textures", gl.info?.memory?.textures);
1558
+ put("heap_mb", heapMB());
1356
1559
  const blast = sources.blast?.();
1357
1560
  if (blast) {
1358
1561
  put("blast_shelter", blast.shelter * 100);
@@ -1372,6 +1575,43 @@ function createRoomMetrics(sources) {
1372
1575
  };
1373
1576
  }
1374
1577
 
1578
+ // src/memory-watch.ts
1579
+ function createMemoryWatch(options) {
1580
+ const clears = Math.max(2, options.clears ?? 4);
1581
+ const budget = { geometries: options.budget?.geometries ?? 40, textures: options.budget?.textures ?? 4 };
1582
+ const histories = /* @__PURE__ */ new Map();
1583
+ let last;
1584
+ return {
1585
+ cleared(where) {
1586
+ const now = options.read();
1587
+ if (!now) return void 0;
1588
+ const key2 = where ?? "";
1589
+ const history = histories.get(key2) ?? [];
1590
+ histories.set(key2, history);
1591
+ history.push(now);
1592
+ if (history.length > clears + 1) history.shift();
1593
+ const first = history[0];
1594
+ const grew = { geometries: now.geometries - first.geometries, textures: now.textures - first.textures };
1595
+ let growing = history.length === clears + 1;
1596
+ for (let i = 1; growing && i < history.length; i++) {
1597
+ const before = history[i - 1];
1598
+ const after = history[i];
1599
+ growing = after.geometries > before.geometries || after.textures > before.textures;
1600
+ }
1601
+ const leaking = growing && (grew.geometries > budget.geometries || grew.textures > budget.textures);
1602
+ last = { after: now, grew, leaking };
1603
+ return last;
1604
+ },
1605
+ get last() {
1606
+ return last;
1607
+ },
1608
+ reset() {
1609
+ histories.clear();
1610
+ last = void 0;
1611
+ }
1612
+ };
1613
+ }
1614
+
1375
1615
  // src/narrate.ts
1376
1616
  var SKIP_DAMAGE = /* @__PURE__ */ new Set(["fire", "heat"]);
1377
1617
  function createNarrator(c) {
@@ -1885,6 +2125,9 @@ function createNarrator(c) {
1885
2125
  }
1886
2126
 
1887
2127
  // src/player.ts
2128
+ import { stepKnock } from "@adatechnology/game-contracts";
2129
+ var SLAM_MIN_SPEED = 1.5;
2130
+ var LANDING_MIN_SPEED = 1;
1888
2131
  var EYE = 1.7;
1889
2132
  var PLAYER_RADIUS = 0.3;
1890
2133
  var FOOT = 0.5;
@@ -1901,6 +2144,9 @@ var GRAVITY = 13;
1901
2144
  var FLOOR_CLIMB_SPEED = 3;
1902
2145
  var FLOOR_FALL_SPEED = 6;
1903
2146
  var LOOK = 22e-4;
2147
+ var LYING_EYE = 0.3;
2148
+ var LYING_PITCH = 1.05;
2149
+ var LYING_ROLL = 0.35;
1904
2150
  function createPlayer(THREE, o) {
1905
2151
  const cam = o.camera;
1906
2152
  let yaw = 0;
@@ -1911,6 +2157,7 @@ function createPlayer(THREE, o) {
1911
2157
  let bend = 0;
1912
2158
  let bendTarget = 0;
1913
2159
  let vy = 0;
2160
+ let floorUnder = 0;
1914
2161
  let walked = 0;
1915
2162
  let running = false;
1916
2163
  const px = { x: o.start[0], z: o.start[2] };
@@ -1937,10 +2184,15 @@ function createPlayer(THREE, o) {
1937
2184
  const reach = (gap > 0 ? FLOOR_CLIMB_SPEED : FLOOR_FALL_SPEED) * dt;
1938
2185
  floorLevel += Math.max(-reach, Math.min(reach, gap));
1939
2186
  };
2187
+ let knocked = null;
2188
+ let rollSide = 1;
1940
2189
  const apply = () => {
1941
- euler.set(pitch, yaw, 0, "YXZ");
2190
+ const lying = knocked?.lying ?? 0;
2191
+ const fallen = lying * lying * (3 - 2 * lying);
2192
+ euler.set(pitch + (LYING_PITCH - pitch) * fallen, yaw, LYING_ROLL * rollSide * fallen, "YXZ");
1942
2193
  cam.quaternion.setFromEuler(euler);
1943
- cam.position.set(px.x, eye + floorLevel + y - CROUCH_DROP * crouch - bend, px.z);
2194
+ const standing = eye - CROUCH_DROP * crouch - bend;
2195
+ cam.position.set(px.x, floorLevel + y + standing + (LYING_EYE - standing) * fallen, px.z);
1944
2196
  cam.updateMatrixWorld(true);
1945
2197
  };
1946
2198
  apply();
@@ -2115,6 +2367,41 @@ function createPlayer(THREE, o) {
2115
2367
  window.addEventListener("keyup", onKeyUp);
2116
2368
  window.addEventListener("blur", onBlur);
2117
2369
  window.addEventListener("beforeunload", onBeforeUnload);
2370
+ const stepKnocked = (dt) => {
2371
+ const k = knocked;
2372
+ if (!k) return;
2373
+ const floor = Math.max(floorHere(), o.footing?.(px.x, px.z, PLAYER_RADIUS * FOOT, y).floor ?? -Infinity);
2374
+ floorUnder = floor;
2375
+ const at = { x: px.x, y, z: px.z };
2376
+ const fromY = y;
2377
+ const fallSpeed = Math.max(0, -k.velocity[1]);
2378
+ const done = stepKnock(k, at, dt, () => floor, true);
2379
+ const wantX = at.x - px.x;
2380
+ const wantZ = at.z - px.z;
2381
+ const fromX = px.x;
2382
+ const fromZ = px.z;
2383
+ const vx = k.velocity[0];
2384
+ const vz = k.velocity[2];
2385
+ move(wantX, wantZ);
2386
+ const stoppedX = Math.abs(wantX) > 1e-6 && Math.abs(px.x - fromX) < Math.abs(wantX) * 0.5;
2387
+ const stoppedZ = Math.abs(wantZ) > 1e-6 && Math.abs(px.z - fromZ) < Math.abs(wantZ) * 0.5;
2388
+ if (stoppedX) k.velocity[0] = 0;
2389
+ if (stoppedZ) k.velocity[2] = 0;
2390
+ const into = Math.hypot(stoppedX ? vx : 0, stoppedZ ? vz : 0);
2391
+ if (into >= SLAM_MIN_SPEED) o.onSlam?.({ against: "obstacle", speed: into, direction: [stoppedX ? vx / into : 0, 0, stoppedZ ? vz / into : 0] });
2392
+ y = at.y;
2393
+ if (fromY > floor && y <= floor) {
2394
+ const speed = Math.max(fallSpeed, (fromY - y) / Math.max(dt, 1e-6));
2395
+ if (k.down && speed >= SLAM_MIN_SPEED * 0.5) o.onSlam?.({ against: "ground", speed: Math.hypot(speed, Math.hypot(vx, vz) * 0.5), direction: [0, -1, 0] });
2396
+ else if (speed >= LANDING_MIN_SPEED) o.onFeet?.({ type: "land", speed });
2397
+ }
2398
+ vy = 0;
2399
+ walked = dt > 0 ? Math.hypot(px.x - fromX, px.z - fromZ) / dt : 0;
2400
+ running = false;
2401
+ if (done) knocked = null;
2402
+ followFloor(dt);
2403
+ apply();
2404
+ };
2118
2405
  return {
2119
2406
  get walkIntent() {
2120
2407
  return walkKeys();
@@ -2163,11 +2450,35 @@ function createPlayer(THREE, o) {
2163
2450
  get running() {
2164
2451
  return running;
2165
2452
  },
2453
+ get grounded() {
2454
+ return y <= floorUnder + 1e-3 && vy <= 0;
2455
+ },
2166
2456
  get crouching() {
2167
2457
  return isCrouched();
2168
2458
  },
2169
2459
  keys,
2460
+ knock(next) {
2461
+ if (seated) return;
2462
+ if (knocked) {
2463
+ knocked.velocity = [knocked.velocity[0] + next.velocity[0], knocked.velocity[1] + next.velocity[1], knocked.velocity[2] + next.velocity[2]];
2464
+ knocked.airborne || (knocked.airborne = next.airborne);
2465
+ knocked.down || (knocked.down = next.down);
2466
+ } else knocked = { ...next, velocity: [...next.velocity] };
2467
+ if (next.down) {
2468
+ const right = Math.cos(yaw) * next.velocity[0] - Math.sin(yaw) * next.velocity[2];
2469
+ rollSide = right >= 0 ? -1 : 1;
2470
+ crouchLatched = false;
2471
+ }
2472
+ if (knocked.airborne) vy = Math.max(vy, knocked.velocity[1]);
2473
+ },
2474
+ get knocked() {
2475
+ return knocked;
2476
+ },
2170
2477
  update(dt) {
2478
+ if (knocked) {
2479
+ stepKnocked(dt);
2480
+ return;
2481
+ }
2171
2482
  if (crouchPress) crouchPress.held += dt;
2172
2483
  const crouched = isCrouched();
2173
2484
  crouch += ((crouched ? 1 : 0) - crouch) * Math.min(1, dt * CROUCH_RATE);
@@ -2188,13 +2499,18 @@ function createPlayer(THREE, o) {
2188
2499
  return;
2189
2500
  }
2190
2501
  const floor = Math.max(floorHere(), o.footing?.(px.x, px.z, PLAYER_RADIUS * FOOT, y).floor ?? -Infinity);
2502
+ floorUnder = floor;
2191
2503
  if (y < floor && vy <= 0) y = floor;
2192
- if (y <= floor && keys.has("Space") && !walkHeld) vy = JUMP;
2504
+ if (y <= floor && keys.has("Space") && !walkHeld) {
2505
+ vy = JUMP;
2506
+ o.onFeet?.({ type: "jump" });
2507
+ }
2193
2508
  if (y > floor || vy > 0) {
2194
2509
  vy -= GRAVITY * dt;
2195
2510
  y += vy * dt;
2196
2511
  if (y <= floor) {
2197
2512
  y = floor;
2513
+ if (-vy >= LANDING_MIN_SPEED) o.onFeet?.({ type: "land", speed: -vy });
2198
2514
  vy = 0;
2199
2515
  }
2200
2516
  }
@@ -2202,6 +2518,7 @@ function createPlayer(THREE, o) {
2202
2518
  apply();
2203
2519
  },
2204
2520
  setPose(position, yw, pt) {
2521
+ knocked = null;
2205
2522
  px.x = position[0];
2206
2523
  px.z = position[2];
2207
2524
  floorLevel = floorHere();
@@ -2213,6 +2530,7 @@ function createPlayer(THREE, o) {
2213
2530
  apply();
2214
2531
  },
2215
2532
  look(position, target) {
2533
+ knocked = null;
2216
2534
  px.x = position[0];
2217
2535
  px.z = position[2];
2218
2536
  floorLevel = floorHere();
@@ -2569,6 +2887,8 @@ var TURN_RATE = 8;
2569
2887
  var MAX_WALK_SECONDS = 1.5;
2570
2888
  var APPROACH_SPEED2 = 3;
2571
2889
  var FLOOR_GAP = 0.03;
2890
+ var LEAF_SLAM_VOLUME = 0.85;
2891
+ var shutVolume = (massKg) => Math.min(1, 0.3 + massKg * 0.03);
2572
2892
  var key = (objectId, part) => `${objectId}\0${part}`;
2573
2893
  var slides = (clip, part) => {
2574
2894
  const last = clip.keys[clip.keys.length - 1]?.parts?.[part];
@@ -2814,7 +3134,7 @@ function createRoomDoors(options) {
2814
3134
  using = { objectId, leaf, obj, grip, pull, phase: control.phase, progress: 0, walked: 0, there: goal === null };
2815
3135
  const at = obj.localToWorld(grip.clone());
2816
3136
  if (goal === null) {
2817
- options.sound("leaf_rattle", [at.x, at.y, at.z], 0.6);
3137
+ options.sound(options.leafFoley?.(objectId, leaf.part, slides(leaf.clip, leaf.part))?.locked ?? "leaf_rattle", [at.x, at.y, at.z], 0.6);
2818
3138
  event("locked", using);
2819
3139
  } else event("reach", using);
2820
3140
  return true;
@@ -2899,17 +3219,27 @@ function createRoomDoors(options) {
2899
3219
  const at = u.obj.localToWorld(tmp.copy(u.grip));
2900
3220
  const point = [at.x, at.y, at.z];
2901
3221
  const slide = slides(u.leaf.clip, u.leaf.part);
2902
- const shut = options.impactKey(u.objectId, u.leaf.part).replace("impact_", "leaf_shut_");
3222
+ const foley = options.leafFoley?.(u.objectId, u.leaf.part, slide) ?? null;
3223
+ const own = (moment) => options.leafSound?.(u.objectId, u.leaf.part, moment);
3224
+ const shut = own("shut") ?? options.impactKey(u.objectId, u.leaf.part).replace("impact_", "leaf_shut_");
2903
3225
  if (wasPhase === "reach" && s.phase !== "reach") {
2904
- if (slide) options.sound("leaf_slide", point, 0.5);
2905
- else {
3226
+ const opening = foley ? void 0 : own("open");
3227
+ if (foley) {
3228
+ if (was < 0.02) options.sound(foley.open, point, 0.5);
3229
+ if (foley.turn) options.sound(foley.turn, point, 0.35);
3230
+ } else if (slide) options.sound("leaf_slide", point, 0.5);
3231
+ else if (opening) {
3232
+ if (was < 0.02) options.sound(opening, point, 0.5);
3233
+ } else {
2906
3234
  if (was < 0.02) options.sound("leaf_latch", point, 0.5);
2907
3235
  options.sound(/metal|glass/.test(shut) ? "leaf_creak_metal" : "leaf_creak_wood", point, 0.35);
2908
3236
  }
2909
3237
  }
2910
3238
  if (s.arrived) {
2911
3239
  if (s.opening <= 0) {
2912
- options.sound(slide ? "leaf_slide" : shut, point, Math.min(1, 0.3 + massOf(u.objectId, u.leaf.part) * 0.03));
3240
+ const volume = shutVolume(massOf(u.objectId, u.leaf.part));
3241
+ if (foley) options.sound(foley.slam && volume >= LEAF_SLAM_VOLUME ? foley.slam : foley.close, point, volume);
3242
+ else options.sound(slide ? "leaf_slide" : shut, point, volume);
2913
3243
  event("closed", u);
2914
3244
  } else event("opened", u);
2915
3245
  }
@@ -3121,6 +3451,11 @@ var en = {
3121
3451
  "metric.fragments": "Fragments",
3122
3452
  "metric.particles": "Particles",
3123
3453
  "metric.decals": "Decals",
3454
+ "metric.geometries": "Geometries",
3455
+ "metric.textures": "Textures",
3456
+ "metric.heap_mb": "Heap MB",
3457
+ "memory.leak_short": "memory leak: +{geometries} geometries, +{textures} textures per {clears} clears",
3458
+ "memory.leak": "Memory keeps growing after each clear: +{geometries} geometries and +{textures} textures over the last {clears} clears. Something removed from the scene was not released (discardObject3D).",
3124
3459
  "act.spawn": "Spawned {name} at x {x} \xB7 z {z}",
3125
3460
  "act.clear": "Cleared the room",
3126
3461
  "act.reset": "Repaired every object",
@@ -3167,11 +3502,18 @@ var en = {
3167
3502
  "act.throw": "Threw {name} at {speed} m/s",
3168
3503
  "ground.name": "Ground",
3169
3504
  "act.landing": "{name} landed at {speed} m/s: {energy} J",
3505
+ "act.player_land": "You landed at {speed} m/s",
3170
3506
  "act.bench_failed": "{name} failed: {error}",
3171
3507
  "loading.working": "Working\u2026",
3508
+ "loading.graphics": "Calibrating graphics\u2026",
3509
+ "ui.graphics": "Graphics",
3172
3510
  "loading.open_area": "Opening {area}\u2026",
3173
3511
  "loading.spawn": "Building {name}\u2026",
3174
3512
  "act.loosened": "{name} was heavy but free: the blast moved it",
3513
+ "act.knocked_down": "The blast threw the player off their feet at {speed} m/s ({reach}% got through the cover)",
3514
+ "act.player_slammed": "The player was thrown into {what} at {speed} m/s",
3515
+ "act.player_fell": "The player hit the floor at {speed} m/s",
3516
+ "act.player_shoved": "The blast pushed the player at {speed} m/s ({reach}% got through the cover)",
3175
3517
  "act.came_apart": "{name} came apart: what stayed is loose and falls",
3176
3518
  "act.shoved": "{name} stands free: the shots moved it",
3177
3519
  "act.impact": "{name} hit {where} at {speed} m/s: {energy} J of blunt damage",
@@ -3471,6 +3813,11 @@ var ptBR = {
3471
3813
  "metric.fragments": "Fragmentos",
3472
3814
  "metric.particles": "Part\xEDculas",
3473
3815
  "metric.decals": "Decalques",
3816
+ "metric.geometries": "Geometrias",
3817
+ "metric.textures": "Texturas",
3818
+ "metric.heap_mb": "Heap MB",
3819
+ "memory.leak_short": "vazamento de mem\xF3ria: +{geometries} geometrias, +{textures} texturas em {clears} limpezas",
3820
+ "memory.leak": "A mem\xF3ria continua subindo a cada limpeza: +{geometries} geometrias e +{textures} texturas nas \xFAltimas {clears} limpezas. Algo tirado da cena n\xE3o foi liberado (discardObject3D).",
3474
3821
  "act.spawn": "Criou {name} em x {x} \xB7 z {z}",
3475
3822
  "act.clear": "Limpou a sala",
3476
3823
  "act.reset": "Consertou todos os objetos",
@@ -3517,11 +3864,18 @@ var ptBR = {
3517
3864
  "act.throw": "Arremessou {name} a {speed} m/s",
3518
3865
  "ground.name": "Ch\xE3o",
3519
3866
  "act.landing": "{name} caiu a {speed} m/s: {energy} J",
3867
+ "act.player_land": "Voc\xEA aterrissou a {speed} m/s",
3520
3868
  "act.bench_failed": "{name} falhou: {error}",
3521
3869
  "loading.working": "Trabalhando\u2026",
3870
+ "loading.graphics": "Calibrando os gr\xE1ficos\u2026",
3871
+ "ui.graphics": "Gr\xE1ficos",
3522
3872
  "loading.open_area": "Abrindo {area}\u2026",
3523
3873
  "loading.spawn": "Montando {name}\u2026",
3524
3874
  "act.loosened": "{name} \xE9 pesado mas estava solto: a explos\xE3o o moveu",
3875
+ "act.knocked_down": "A explos\xE3o derrubou o jogador a {speed} m/s ({reach}% passou pela cobertura)",
3876
+ "act.player_slammed": "O jogador foi arremessado contra {what} a {speed} m/s",
3877
+ "act.player_fell": "O jogador bateu no ch\xE3o a {speed} m/s",
3878
+ "act.player_shoved": "A explos\xE3o empurrou o jogador a {speed} m/s ({reach}% passou pela cobertura)",
3525
3879
  "act.came_apart": "{name} se desmontou: o que ficou est\xE1 solto e cai",
3526
3880
  "act.shoved": "{name} estava solto no ch\xE3o: os tiros o moveram",
3527
3881
  "act.impact": "{name} bateu em {where} a {speed} m/s: {energy} J de dano de impacto",
@@ -4110,6 +4464,7 @@ var CSS = `
4110
4464
  .tr-top{position:absolute;top:8px;left:50%;transform:translateX(-50%);text-align:center;max-width:46vw}
4111
4465
  .tr-tool{display:inline-block;background:rgba(10,12,16,.72);padding:4px 12px;border-radius:6px;font-size:14px;font-weight:600}
4112
4466
  .tr-tool small{font-weight:400;opacity:.8;margin-left:8px}
4467
+ .tr-alert{display:none;position:relative;z-index:20;margin-top:6px;background:rgba(183,28,28,.92);color:#fff;padding:6px 12px;border-radius:6px;font-size:13px;font-weight:600;box-shadow:0 2px 8px rgba(0,0,0,.4)}
4113
4468
  .tr-status{margin-top:4px;min-height:16px;color:#ffd23f;text-shadow:0 1px 2px #000}
4114
4469
  .tr-prog{margin:4px auto 0;width:260px;background:rgba(10,12,16,.72);border-radius:6px;padding:4px 8px;display:none}
4115
4470
  .tr-prog b{display:block;font-weight:500;margin-bottom:3px}
@@ -4228,13 +4583,14 @@ function createUi(o) {
4228
4583
  const top = mk3("div", "tr-top");
4229
4584
  const toolBox = mk3("div", "tr-tool");
4230
4585
  const status = mk3("div", "tr-status");
4586
+ const alertBox = mk3("div", "tr-alert");
4231
4587
  const prog = mk3("div", "tr-prog");
4232
4588
  const progLabel = mk3("b");
4233
4589
  const progBar = mk3("div", "tr-bar");
4234
4590
  const progFill = mk3("i");
4235
4591
  progBar.appendChild(progFill);
4236
4592
  prog.append(progLabel, progBar);
4237
- top.append(toolBox, status, prog);
4593
+ top.append(toolBox, alertBox, status, prog);
4238
4594
  const bottom = mk3("div", "tr-bottom");
4239
4595
  const held = mk3("div", "tr-held");
4240
4596
  const hotbar = mk3("div", "tr-hotbar");
@@ -4345,9 +4701,16 @@ function createUi(o) {
4345
4701
  nodes.delete(oldest.value);
4346
4702
  logBody.firstElementChild?.remove();
4347
4703
  }
4348
- if (autoscroll) logBody.scrollTop = logBody.scrollHeight;
4704
+ if (autoscroll) isScrollDue = true;
4349
4705
  }
4706
+ let isScrollDue = false;
4707
+ const scrollToEnd = () => {
4708
+ if (!isScrollDue) return;
4709
+ isScrollDue = false;
4710
+ logBody.scrollTop = logBody.scrollHeight;
4711
+ };
4350
4712
  const flushLog = () => {
4713
+ scrollToEnd();
4351
4714
  if (pendingAdds.length === 0 && pendingUpdates.size === 0) return;
4352
4715
  if (!isLogVisible()) {
4353
4716
  isLogStale = true;
@@ -4461,17 +4824,22 @@ function createUi(o) {
4461
4824
  fullscreenBtn.title = t("ui.fullscreen.hint");
4462
4825
  const blastBtns = ["shelter", "fragmentHits", "confinement"].map((key2) => ({ key: key2, b: ctl(t(`ui.blast.${key2}`), () => o.onBlast(key2)) }));
4463
4826
  for (const { key: key2, b } of blastBtns) b.title = t(`ui.blast.${key2}.hint`);
4464
- const qSel = mk3("select");
4465
- for (const q of ["low", "medium", "high"]) {
4466
- const op = mk3("option", void 0, t(`ui.quality.${q}`));
4467
- op.value = q;
4468
- qSel.appendChild(op);
4827
+ if (o.onGraphics) {
4828
+ const g = ctl(t("ui.graphics"), () => o.onGraphics());
4829
+ g.dataset.testid = "graphics-button";
4830
+ } else {
4831
+ const qSel = mk3("select");
4832
+ for (const q of ["low", "medium", "high"]) {
4833
+ const op = mk3("option", void 0, t(`ui.quality.${q}`));
4834
+ op.value = q;
4835
+ qSel.appendChild(op);
4836
+ }
4837
+ qSel.value = o.quality;
4838
+ qSel.onchange = () => o.onQuality(qSel.value);
4839
+ const qLabel = mk3("label", void 0, t("ui.quality") + " ");
4840
+ qLabel.appendChild(qSel);
4841
+ controls.appendChild(qLabel);
4469
4842
  }
4470
- qSel.value = o.quality;
4471
- qSel.onchange = () => o.onQuality(qSel.value);
4472
- const qLabel = mk3("label", void 0, t("ui.quality") + " ");
4473
- qLabel.appendChild(qSel);
4474
- controls.appendChild(qLabel);
4475
4843
  const count = mk3("span");
4476
4844
  controls.appendChild(count);
4477
4845
  cat.appendChild(controls);
@@ -4697,6 +5065,10 @@ function createUi(o) {
4697
5065
  renderInspector(s.inspect);
4698
5066
  }
4699
5067
  },
5068
+ alert(text) {
5069
+ alertBox.textContent = text ?? "";
5070
+ alertBox.style.display = text ? "block" : "none";
5071
+ },
4700
5072
  toast(text) {
4701
5073
  status.textContent = text;
4702
5074
  if (toastTimer) clearTimeout(toastTimer);
@@ -4731,9 +5103,144 @@ function createUi(o) {
4731
5103
  return api;
4732
5104
  }
4733
5105
 
5106
+ // src/module-views.ts
5107
+ var FRAME_PX = 3;
5108
+ function createModuleViews(THREE, gl) {
5109
+ const entries = [];
5110
+ let mainCamera = null;
5111
+ const overlay = new THREE.Scene();
5112
+ const ortho = new THREE.OrthographicCamera(-0.5, 0.5, 0.5, -0.5, 0, 2);
5113
+ ortho.position.z = 1;
5114
+ const plane = new THREE.PlaneGeometry(1, 1);
5115
+ const roundedMask = (rounded, aspect) => {
5116
+ if (!rounded || typeof document === "undefined") return null;
5117
+ const canvas = document.createElement("canvas");
5118
+ canvas.width = 256;
5119
+ canvas.height = Math.max(16, Math.round(256 / aspect));
5120
+ const g = canvas.getContext("2d");
5121
+ if (!g) return null;
5122
+ const r = Math.min(canvas.width, canvas.height) * Math.min(0.5, rounded);
5123
+ g.fillStyle = "#000";
5124
+ g.fillRect(0, 0, canvas.width, canvas.height);
5125
+ g.fillStyle = "#fff";
5126
+ g.beginPath();
5127
+ g.roundRect(0, 0, canvas.width, canvas.height, r);
5128
+ g.fill();
5129
+ const texture = new THREE.CanvasTexture(canvas);
5130
+ texture.colorSpace = THREE.NoColorSpace;
5131
+ return texture;
5132
+ };
5133
+ const size = () => {
5134
+ if (typeof gl.getSize !== "function") return { w: 960, h: 600 };
5135
+ const v = new THREE.Vector2();
5136
+ gl.getSize(v);
5137
+ return { w: v.x, h: v.y };
5138
+ };
5139
+ const take = (entry) => {
5140
+ const i = entries.indexOf(entry);
5141
+ if (i < 0) return;
5142
+ entries.splice(i, 1);
5143
+ overlay.remove(entry.quad, entry.frame);
5144
+ entry.target.dispose();
5145
+ entry.quad.material.dispose();
5146
+ entry.frame.material.dispose();
5147
+ entry.alpha?.dispose();
5148
+ };
5149
+ return {
5150
+ add(view) {
5151
+ const { w, h } = size();
5152
+ const scale = view.resolution ?? 0.5;
5153
+ const pr = typeof gl.getPixelRatio === "function" ? gl.getPixelRatio() : 1;
5154
+ const target = new THREE.WebGLRenderTarget(Math.max(16, Math.round(w * view.rect.width * pr * scale)), Math.max(16, Math.round(h * view.rect.height * pr * scale)), { samples: 2 });
5155
+ target.texture.colorSpace = THREE.LinearSRGBColorSpace;
5156
+ const alpha = roundedMask(view.rounded ?? 0, w * view.rect.width / Math.max(1, h * view.rect.height));
5157
+ const quad = new THREE.Mesh(plane, new THREE.MeshBasicMaterial({ map: target.texture, side: THREE.DoubleSide, depthTest: false, depthWrite: false, ...alpha ? { alphaMap: alpha, transparent: true } : {} }));
5158
+ const frame = new THREE.Mesh(plane, new THREE.MeshBasicMaterial({ color: 723982, side: THREE.DoubleSide, depthTest: false, depthWrite: false, ...alpha ? { alphaMap: alpha, transparent: true } : {} }));
5159
+ quad.renderOrder = 1;
5160
+ frame.visible = quad.visible = false;
5161
+ overlay.add(frame, quad);
5162
+ const entry = { view, target, quad, frame, alpha };
5163
+ entries.push(entry);
5164
+ return () => take(entry);
5165
+ },
5166
+ setMain(camera) {
5167
+ mainCamera = camera;
5168
+ },
5169
+ main(fallback) {
5170
+ if (!mainCamera) return fallback;
5171
+ const persp = mainCamera;
5172
+ const back = fallback;
5173
+ if (persp.isPerspectiveCamera && back.isPerspectiveCamera && persp.aspect !== back.aspect) {
5174
+ persp.aspect = back.aspect;
5175
+ persp.updateProjectionMatrix();
5176
+ }
5177
+ return mainCamera;
5178
+ },
5179
+ render(scene) {
5180
+ if (!entries.length) return;
5181
+ const { w, h } = size();
5182
+ const autoClear = gl.autoClear;
5183
+ const shadows = gl.shadowMap.autoUpdate;
5184
+ gl.shadowMap.autoUpdate = false;
5185
+ for (const entry of entries) {
5186
+ const cam = entry.view.camera;
5187
+ const aspect = w * entry.view.rect.width / Math.max(1, h * entry.view.rect.height);
5188
+ if (cam.isPerspectiveCamera && Math.abs(cam.aspect - aspect) > 1e-3) {
5189
+ cam.aspect = aspect;
5190
+ cam.updateProjectionMatrix();
5191
+ }
5192
+ gl.setRenderTarget(entry.target);
5193
+ gl.clear();
5194
+ gl.render(scene, cam);
5195
+ }
5196
+ gl.setRenderTarget(null);
5197
+ gl.autoClear = false;
5198
+ gl.setScissorTest(true);
5199
+ for (const entry of entries) {
5200
+ const r = entry.view.rect;
5201
+ const x = r.x * w;
5202
+ const y = (1 - r.y - r.height) * h;
5203
+ const vw = r.width * w;
5204
+ const vh = r.height * h;
5205
+ for (const mesh of [entry.frame, entry.quad]) mesh.visible = false;
5206
+ entry.frame.visible = true;
5207
+ gl.setViewport(x - FRAME_PX, y - FRAME_PX, vw + 2 * FRAME_PX, vh + 2 * FRAME_PX);
5208
+ gl.setScissor(x - FRAME_PX, y - FRAME_PX, vw + 2 * FRAME_PX, vh + 2 * FRAME_PX);
5209
+ gl.render(overlay, ortho);
5210
+ entry.frame.visible = false;
5211
+ entry.quad.visible = true;
5212
+ entry.quad.scale.x = entry.view.mirror ? -1 : 1;
5213
+ gl.setViewport(x, y, vw, vh);
5214
+ gl.setScissor(x, y, vw, vh);
5215
+ gl.render(overlay, ortho);
5216
+ entry.quad.visible = false;
5217
+ }
5218
+ gl.setScissorTest(false);
5219
+ gl.setViewport(0, 0, w, h);
5220
+ gl.autoClear = autoClear;
5221
+ gl.shadowMap.autoUpdate = shadows;
5222
+ },
5223
+ get count() {
5224
+ return entries.length;
5225
+ },
5226
+ dispose() {
5227
+ for (const entry of [...entries]) take(entry);
5228
+ plane.dispose();
5229
+ mainCamera = null;
5230
+ }
5231
+ };
5232
+ }
5233
+
4734
5234
  // src/room.ts
4735
5235
  var FIXED_DT = 1 / 60;
5236
+ var EXPLOSIVE_EFFECT_IDS = ["m67", "molotov", "pipe_bomb", "m79_40mm", "m72_66mm", "at4_84mm", "dynamite", "c4", "claymore"];
5237
+ var DEFAULT_PARTICLE_LIMIT = 900;
5238
+ var WARMED_FOG_STATES = ["none", "linear", "exp2"];
5239
+ var ROOM_LIGHT_SIZES = { point: 16, spot: 4, pointShadows: 0, hemisphere: 1 };
4736
5240
  var SOLID_SPAN = 3;
5241
+ var BATCH_MIN_MESHES = 8;
5242
+ var BATCH_BUDGET_MS = 1;
5243
+ var UNBATCHED_OWNERS = /vehicles|characters|animals|crowd/;
4737
5244
  var STEP_UP = 0.45;
4738
5245
  var BODY_HEIGHT2 = 1.75;
4739
5246
  var KICK_MASS = 6;
@@ -4752,6 +5259,12 @@ var CAP_RISE = 0.03;
4752
5259
  var MAX_STRIDE_SPEED = 10;
4753
5260
  var v3 = (v) => [v.x, v.y, v.z];
4754
5261
  var dist3 = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
5262
+ var vehicleLeafKey = (spec, part, moment) => {
5263
+ const tags = spec.parts.find((p) => p.id === part)?.tags ?? [];
5264
+ const leaf = tags.includes("hood") ? "hood" : tags.includes("lid") || tags.includes("trunk") ? "trunk" : "door";
5265
+ return `vehicle_${leaf}_${moment}`;
5266
+ };
5267
+ var MARK_EVENTS = /* @__PURE__ */ new Set(["dent", "hole", "crack", "chip", "stage", "fracture"]);
4755
5268
  function createTestRoom(options) {
4756
5269
  const THREE = options.THREE;
4757
5270
  const locale = options.locale ?? "en";
@@ -4798,9 +5311,21 @@ function createTestRoom(options) {
4798
5311
  };
4799
5312
  const onResize = () => resize();
4800
5313
  window.addEventListener("resize", onResize);
5314
+ const moduleViews = createModuleViews(THREE, gl);
4801
5315
  const arenaSize = options.arenaSize ?? 14;
4802
5316
  const arena = createArena(THREE, arenaSize, scene, options.scenery, options.tap);
4803
5317
  scene.add(arena.group);
5318
+ const graphics = ownRenderer && options.graphics ? createRoomGraphics({
5319
+ gl,
5320
+ camera,
5321
+ sun: arena.sun,
5322
+ container,
5323
+ locale,
5324
+ loading,
5325
+ label: t("loading.graphics"),
5326
+ ui: options.ui !== false,
5327
+ options: options.graphics === true ? {} : options.graphics
5328
+ }) : null;
4804
5329
  const ownWorld = !options.world;
4805
5330
  const world = options.world ?? createDestruction({ seed: 1, ...options.worldOptions, materials: [...options.worldOptions?.materials ?? []] });
4806
5331
  const pristineWorld = ownWorld ? world.serialize() : null;
@@ -4846,12 +5371,30 @@ function createTestRoom(options) {
4846
5371
  const ownAudio = options.audio === void 0;
4847
5372
  const audio = options.audio === false ? null : options.audio ?? createAudioEngine({ deferredStart: {} });
4848
5373
  const destructionSound = audio ? createDestructionSound(audio) : null;
4849
- const playSound = (key2, point, volume) => {
4850
- destructionSound?.onSound(key2, point, volume);
4851
- options.onSound?.(key2, point, volume);
4852
- };
5374
+ if (audio && options.samplesBaseUrl !== null) {
5375
+ void loadKitSamples(audio, { baseUrl: options.samplesBaseUrl ?? "./sfx/" }).then((bank) => {
5376
+ if (!bank || typeof window === "undefined") return;
5377
+ const preload = () => {
5378
+ window.removeEventListener("keydown", preload, true);
5379
+ window.removeEventListener("pointerdown", preload, true);
5380
+ void audio.loadSamples(["latch", "creak_wood", "shut_wood", "rattle", "grab_hard", "grab_soft"]).catch(() => void 0);
5381
+ };
5382
+ window.addEventListener("keydown", preload, true);
5383
+ window.addEventListener("pointerdown", preload, true);
5384
+ });
5385
+ }
5386
+ const bodySounds = createBodySounds(audio);
5387
+ const playSound = (key2, point, volume, detail) => {
5388
+ destructionSound?.onSound(key2, point, volume, detail);
5389
+ if (detail) options.onSound?.(key2, point, volume, detail);
5390
+ else options.onSound?.(key2, point, volume);
5391
+ };
5392
+ const lights = createLightBudget(THREE, scene, { ...ROOM_LIGHT_SIZES, ...options.lightSizes });
5393
+ const warmup = createProgramWarmup(THREE);
5394
+ const pools = { geometries: createGeometryPool(), materials: createMaterialPool({ anchors: warmup.anchors, renderer: gl }) };
4853
5395
  const vfx = createVfxRenderer(THREE, {
4854
5396
  scene,
5397
+ lights,
4855
5398
  camera,
4856
5399
  overlay: container,
4857
5400
  quality,
@@ -4914,7 +5457,7 @@ function createTestRoom(options) {
4914
5457
  const size = Math.min(GROUND_BURST_MAX, Math.ceil(result.radius * GROUND_BURST_PER_METRE));
4915
5458
  renderer.emit(material.debris === "clods" ? "dust" : material.debris, result.point, [0, 1, 0], Math.max(2, size), Math.min(4, 1 + result.radius * 4));
4916
5459
  if (result.radius > GROUND_DUST_RADIUS) renderer.emit("dust", result.point, [0, 1, 0], size, 1.4);
4917
- playSound(impactKey(def), result.point, Math.min(1, 0.25 + result.radius));
5460
+ playSound(impactKey(def), result.point, Math.min(1, 0.25 + result.radius), impactSoundKeys(material.id, def));
4918
5461
  };
4919
5462
  terrain.onDeform((region) => renderer.wakeRegion(region.x, region.z, region.radius));
4920
5463
  const groundImpl = createTerrainImpl(terrain, { id: GROUND_ID, name: t("ground.name"), onBlow: showGroundBlow });
@@ -5005,7 +5548,7 @@ function createTestRoom(options) {
5005
5548
  });
5006
5549
  let pending = [];
5007
5550
  const eventListeners = /* @__PURE__ */ new Set();
5008
- const listeners = { spawn: /* @__PURE__ */ new Set(), tool: /* @__PURE__ */ new Set(), grab: /* @__PURE__ */ new Set(), throw: /* @__PURE__ */ new Set(), event: eventListeners, camera: /* @__PURE__ */ new Set() };
5551
+ const listeners = { spawn: /* @__PURE__ */ new Set(), tool: /* @__PURE__ */ new Set(), grab: /* @__PURE__ */ new Set(), throw: /* @__PURE__ */ new Set(), event: eventListeners, camera: /* @__PURE__ */ new Set(), footfall: /* @__PURE__ */ new Set() };
5009
5552
  const fire = (type, data) => {
5010
5553
  for (const fn of listeners[type] ?? []) fn(data);
5011
5554
  };
@@ -5142,6 +5685,15 @@ function createTestRoom(options) {
5142
5685
  floorAt: terrain.heightAt,
5143
5686
  start: arena.playerStart,
5144
5687
  crouchKeys: options.crouch?.keys,
5688
+ onSlam: (event) => slamPlayer(event),
5689
+ onFeet: (event) => {
5690
+ const at = player.position;
5691
+ if (event.type === "jump") bodySounds.jump(at);
5692
+ else {
5693
+ bodySounds.land(surfaceUnderFeet(), event.speed, at);
5694
+ action("landing_player", t("act.player_land", { speed: num(event.speed, 1) }), { data: { speedMs: Math.round(event.speed * 100) / 100, surface: surfaceUnderFeet() } });
5695
+ }
5696
+ },
5145
5697
  hooks: {
5146
5698
  primaryDown: () => {
5147
5699
  useHeld = true;
@@ -5213,7 +5765,11 @@ function createTestRoom(options) {
5213
5765
  return piece;
5214
5766
  },
5215
5767
  dragSound: (objectId, point, volume) => playSound(dragSoundKey(world.materials.get(heaviestMaterial(objectId) ?? "")), point, volume),
5216
- hitSound: (objectId, point) => playSound(impactKey(world.materials.get(heaviestMaterial(objectId) ?? "")), point, 0.5),
5768
+ hitSound: (objectId, point) => {
5769
+ const material = heaviestMaterial(objectId) ?? "";
5770
+ const def = world.materials.get(material);
5771
+ playSound(impactKey(def), point, 0.5, impactSoundKeys(material, def, "blunt"));
5772
+ },
5217
5773
  onEvent: (ev) => {
5218
5774
  const vars = { name: ev.name, kg: num(ev.massKg, 0) };
5219
5775
  action(`push_${ev.type}`, t(`act.push_${ev.type}`, vars), { objectId: ev.objectId, itemId: entryOf(ev.objectId)?.so.itemId, data: { massKg: Math.round(ev.massKg * 10) / 10 } });
@@ -5303,7 +5859,13 @@ function createTestRoom(options) {
5303
5859
  isLocked: (objectId, part) => lockedLeaves.has(`${objectId}\0*`) || lockedLeaves.has(`${objectId}\0${part}`),
5304
5860
  nameOf,
5305
5861
  impactKey: (objectId, part) => impactKey(world.materials.get(world.part(objectId, part)?.material ?? heaviestMaterial(objectId) ?? "")),
5862
+ leafSound: (objectId, part, moment) => {
5863
+ const spec = specs.get(objectId);
5864
+ return spec?.tags?.includes("vehicle") ? vehicleLeafKey(spec, part, moment) : void 0;
5865
+ },
5306
5866
  sound: playSound,
5867
+ // a vehicle's doors, hood and lid have their own sounds in the vehicle bank (game-vehicles-three), not those of the house
5868
+ leafFoley: (objectId, part, slides2) => specs.get(objectId)?.tags?.includes("vehicle") ? null : leafFoleyOf(entryOf(objectId)?.item?.sounds, part, impactKey(world.materials.get(world.part(objectId, part)?.material ?? heaviestMaterial(objectId) ?? "")), slides2),
5307
5869
  onEvent: (ev) => {
5308
5870
  const vars = { name: ev.name, percent: Math.round(ev.opening * 100) };
5309
5871
  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 } : {} } });
@@ -5318,12 +5880,23 @@ function createTestRoom(options) {
5318
5880
  for (const p of st?.parts ?? []) if (p.status !== "broken" && p.status !== "melted" && p.status !== "ashes") m += p.mass;
5319
5881
  return m;
5320
5882
  };
5883
+ const batchers = /* @__PURE__ */ new Map();
5884
+ const batchObject = (objectId, root) => {
5885
+ let meshes = 0;
5886
+ let unbatched = false;
5887
+ root.traverse((object) => {
5888
+ if (object.isMesh) meshes++;
5889
+ if (UNBATCHED_OWNERS.test(object.userData.kitOrigin?.owner ?? "")) unbatched = true;
5890
+ });
5891
+ if (!unbatched && meshes >= BATCH_MIN_MESHES) batchers.set(objectId, createStaticBatcher(THREE, root, { budgetMs: BATCH_BUDGET_MS }));
5892
+ };
5321
5893
  const registerEntry = (objectId, itemId, root, spec, name, parts, item, arenaObject, movable) => {
5322
5894
  specs.set(objectId, spec);
5323
5895
  renderer.bind(objectId, root, parts ? { parts } : void 0);
5324
5896
  const so = { objectId, itemId, root, movable };
5325
5897
  const e = { so, item, name, arena: arenaObject, fluidOffset: [0, 0, 0], uses: [], piece: null, last: [root.position.x, root.position.y, root.position.z], velocity: [0, 0, 0] };
5326
5898
  entries.set(objectId, e);
5899
+ batchObject(objectId, root);
5327
5900
  if (movable) {
5328
5901
  e.piece = renderer.makePiece(objectId, { mass: Math.max(0.2, massOfObject(objectId)) });
5329
5902
  rendererPieces.set(objectId, e.piece);
@@ -5591,6 +6164,8 @@ function createTestRoom(options) {
5591
6164
  dropTree(e.so.root);
5592
6165
  dropTree(inner);
5593
6166
  entries.delete(id);
6167
+ batchers.get(id)?.dispose();
6168
+ batchers.delete(id);
5594
6169
  doors.forget(id);
5595
6170
  rendererPieces.delete(id);
5596
6171
  specs.delete(id);
@@ -5689,7 +6264,7 @@ function createTestRoom(options) {
5689
6264
  lastImpact.clear();
5690
6265
  addArena();
5691
6266
  standFootprints();
5692
- timeScale = 1;
6267
+ scheduler.timeScale = 1;
5693
6268
  fastForward = false;
5694
6269
  walkPlan = null;
5695
6270
  };
@@ -5699,6 +6274,37 @@ function createTestRoom(options) {
5699
6274
  let avatar = null;
5700
6275
  let cameraMode = options.cameraMode ?? "first";
5701
6276
  let crouchForced = false;
6277
+ let downForced = false;
6278
+ const playerMass = options.playerMass ?? bodyMass(1.75);
6279
+ const SLAM_REACH = PLAYER_RADIUS + 0.35;
6280
+ const SLAM_SHOULDER = 1.3;
6281
+ const slamPlayer = (event) => {
6282
+ const [x, feet, z] = player.position;
6283
+ const energy = 0.5 * playerMass * event.speed * event.speed;
6284
+ let hit = null;
6285
+ if (event.against === "obstacle") {
6286
+ const from = [x, feet + ((player.knocked?.lying ?? 0) > 0.5 ? 0.3 : SLAM_SHOULDER), z];
6287
+ hit = rayWorld(from, event.direction, SLAM_REACH);
6288
+ if (hit) world.hitPart(hit.object, hit.part, { type: "blunt", point: hit.point, direction: event.direction, energy, radius: 0.2, ...hit.normal ? { normal: hit.normal } : {} });
6289
+ }
6290
+ bodySounds.slam(event.speed, [x, feet + 0.5, z]);
6291
+ action("player_slammed", t(event.against === "obstacle" ? "act.player_slammed" : "act.player_fell", { speed: num(event.speed, 1), what: hit ? nameOf(hit.object) : "" }), {
6292
+ ...hit ? { objectId: hit.object, part: hit.part } : {},
6293
+ data: { against: event.against, speed: event.speed, energy, ...hit ? { object: hit.object, part: hit.part } : {} }
6294
+ });
6295
+ };
6296
+ const knockPlayer = (event) => {
6297
+ if (seating.seat) return;
6298
+ const feet = [player.x, player.feet, player.z];
6299
+ const reach = world.blastReach(event.point, [feet[0], feet[1] + KNOCK_CHEST, feet[2]], event.source ? { ignore: [event.source] } : {});
6300
+ const knock = knockOf({ point: event.point, energy: event.energy, radius: event.radius, reach }, feet, playerMass, options.blastShove);
6301
+ if (!knock) return;
6302
+ player.knock(knock);
6303
+ const speed = Math.hypot(knock.velocity[0], knock.velocity[1], knock.velocity[2]);
6304
+ action(knock.down ? "player_knocked_down" : "player_shoved", t(knock.down ? "act.knocked_down" : "act.player_shoved", { speed: num(speed, 1), reach: num(reach * 100, 0) }), {
6305
+ data: { speed, reach, down: knock.down, source: event.source }
6306
+ });
6307
+ };
5702
6308
  const avatarState = { speed: 0, running: false, crouch: false, down: false, armed: false, pitch: 0 };
5703
6309
  let feetPrev = null;
5704
6310
  let armedForced = null;
@@ -5754,6 +6360,7 @@ function createTestRoom(options) {
5754
6360
  feetPrev = [x, z];
5755
6361
  avatarState.running = !player.crouching && (player.keys.has("ShiftLeft") || player.keys.has("ShiftRight"));
5756
6362
  avatarState.crouch = crouchForced || player.crouching || push.isBent;
6363
+ avatarState.down = downForced || Boolean(player.knocked?.down || (player.knocked?.lying ?? 0) > 0.5);
5757
6364
  avatarState.armed = armedForced ?? tool.def.action.kind === "firearm";
5758
6365
  avatarState.pitch = player.pitch;
5759
6366
  avatarState.holding = heldPiece !== null;
@@ -5826,14 +6433,18 @@ function createTestRoom(options) {
5826
6433
  if (!avatar?.reach) return;
5827
6434
  avatar.reach(targets, { camera, keepOutside: { contact: (origin, direction, maxDistance) => rayWorld(origin, direction, maxDistance)?.distance ?? null } });
5828
6435
  };
6436
+ const thirdEuler = new THREE.Euler(0, 0, 0, "YXZ");
5829
6437
  const applyThirdView = () => {
5830
6438
  if (!thirdActive()) return () => void 0;
5831
6439
  const home = camera.position.clone();
6440
+ const turn = camera.quaternion.clone();
5832
6441
  const pose = thirdPose(0);
5833
6442
  camera.position.set(pose.position[0], pose.position[1], pose.position[2]);
6443
+ if (player.knocked) camera.quaternion.setFromEuler(thirdEuler.set(player.pitch, player.yaw, 0, "YXZ"));
5834
6444
  camera.updateMatrixWorld(true);
5835
6445
  return () => {
5836
6446
  camera.position.copy(home);
6447
+ camera.quaternion.copy(turn);
5837
6448
  camera.updateMatrixWorld(true);
5838
6449
  };
5839
6450
  };
@@ -5890,6 +6501,28 @@ function createTestRoom(options) {
5890
6501
  }
5891
6502
  };
5892
6503
  const rayWorld = (o, d, max) => withoutHeld(() => world.raycast(o, d, max));
6504
+ const surfaceUnderFeet = () => {
6505
+ const [x, feet, z] = player.position;
6506
+ const under = rayWorld([x, feet + 0.15, z], [0, -1, 0], 0.5);
6507
+ if (under && under.object !== GROUND_ID) {
6508
+ const material = world.state(under.object)?.parts.find((p) => p.id === under.part)?.material;
6509
+ if (material) return surfaceOfMaterial(material);
6510
+ }
6511
+ return surfaceOfMaterial(terrain.columnAt(x, z).material);
6512
+ };
6513
+ const strides = createStrideCounter();
6514
+ let feetAt = null;
6515
+ const stepFeet = (dt) => {
6516
+ const [x, , z] = player.position;
6517
+ const moved = feetAt && dt > 0 ? Math.hypot(x - feetAt[0], z - feetAt[1]) : 0;
6518
+ feetAt = [x, z];
6519
+ if (dt <= 0 || seating.seat) return;
6520
+ const gait = player.running ? "run" : "walk";
6521
+ const foot = avatar?.footfall ? avatar.footfall() : moved / dt > MAX_STRIDE_SPEED ? null : strides.advance(moved, gait);
6522
+ if (!foot || !player.grounded) return;
6523
+ emitHost({ type: "footfall", foot, surface: surfaceUnderFeet(), gait, position: player.position });
6524
+ fire("footfall", { foot, gait, time: simTime });
6525
+ };
5893
6526
  let rng = 12345;
5894
6527
  const rnd = () => {
5895
6528
  rng = rng * 1664525 + 1013904223 >>> 0;
@@ -6008,6 +6641,12 @@ function createTestRoom(options) {
6008
6641
  ui?.toast(msg);
6009
6642
  };
6010
6643
  const toolName = (id = toolId) => arsenal.name(id);
6644
+ const toolDef = (id) => arsenal.weapons.get(id);
6645
+ const blowTarget = (r) => {
6646
+ const part = world.state(r.object)?.parts.find((p) => p.id === r.part);
6647
+ if (!part || !(world.materials.get(part.material)?.tags ?? []).includes("body")) return "object";
6648
+ return /head|face|jaw|skull|neck/.test(r.part) ? "head" : "body";
6649
+ };
6011
6650
  const hitWhere = (r) => {
6012
6651
  const p = world.part(r.object, r.part);
6013
6652
  return `${nameOf(r.object)} \u203A ${r.part}${p ? ` (${dStrings.materialNames[p.material] ?? p.material})` : ""}`;
@@ -6017,6 +6656,7 @@ function createTestRoom(options) {
6017
6656
  renderer.impulse(objectId, [dir[0] * momentum, dir[1] * momentum, dir[2] * momentum], point);
6018
6657
  };
6019
6658
  const shotRay = new THREE.Raycaster();
6659
+ shotRay.layers.enableAll();
6020
6660
  const LIQUID_SLOWING = 0.25;
6021
6661
  const SHOT_GRIP = 0.3;
6022
6662
  const shotPush = (s, objectId, results, fallback) => {
@@ -6186,7 +6826,15 @@ function createTestRoom(options) {
6186
6826
  action("melee", t("act.melee.miss", { weapon: w }), { data: { weapon: id } });
6187
6827
  return;
6188
6828
  }
6189
- world.hitPart(r.object, r.part, { ...useToHit(u, r.point, r.normal), source: "player" });
6829
+ const hit = useToHit(u, r.point, r.normal);
6830
+ const result = world.hitPart(r.object, r.part, { ...hit, source: "player" });
6831
+ if (!result.events.some((e) => MARK_EVENTS.has(e.type))) {
6832
+ const material = world.part(r.object, r.part)?.material ?? heaviestMaterial(r.object) ?? "";
6833
+ const def = world.materials.get(material);
6834
+ playSound(impactKey(def), r.point, Math.min(0.8, 0.25 + u.energy / 400), impactSoundKeys(material, def, hit.type));
6835
+ }
6836
+ const blow = blowOfTool(id, toolDef(id)?.category ?? "");
6837
+ if (blow) bodySounds.hit(blow, blowTarget(r), r.point, u.energy);
6190
6838
  pushPiece(r.object, u.direction, 2 * u.energy / 6, r.point);
6191
6839
  action("melee", t("act.melee.hit", { weapon: w, energy: num(u.energy, 0), where: hitWhere(r), dist: num(r.distance, 1) }), {
6192
6840
  objectId: r.object,
@@ -6227,6 +6875,10 @@ function createTestRoom(options) {
6227
6875
  comboNext = (stepIndex + 1) % combo.steps.length;
6228
6876
  comboAt = simTime;
6229
6877
  }
6878
+ const blow = blowOfTool(id, toolDef(id)?.category ?? "");
6879
+ const lead = blow ? swingLead(blow) : 0;
6880
+ const swing = blow ? () => bodySounds.swing(blow, player.position) : void 0;
6881
+ if (swing && (seconds === null || seconds <= lead)) swing();
6230
6882
  if (clip === void 0 || seconds === null) {
6231
6883
  if (leg) {
6232
6884
  legAim();
@@ -6241,6 +6893,7 @@ function createTestRoom(options) {
6241
6893
  strikes.push({
6242
6894
  key: id,
6243
6895
  remaining: seconds,
6896
+ ...swing && seconds > lead ? { swing, lead } : {},
6244
6897
  land: () => {
6245
6898
  if (leg) legAim();
6246
6899
  else aim();
@@ -6250,7 +6903,13 @@ function createTestRoom(options) {
6250
6903
  };
6251
6904
  const stepStrikes = (dt) => {
6252
6905
  if (strikes.length === 0) return;
6253
- for (const strike of strikes) strike.remaining -= dt;
6906
+ for (const strike of strikes) {
6907
+ strike.remaining -= dt;
6908
+ if (strike.swing && strike.remaining <= (strike.lead ?? 0)) {
6909
+ strike.swing();
6910
+ delete strike.swing;
6911
+ }
6912
+ }
6254
6913
  const landed = strikes.filter((strike) => strike.remaining <= 0);
6255
6914
  strikes = strikes.filter((strike) => strike.remaining > 0);
6256
6915
  for (const strike of landed) strike.land();
@@ -6364,26 +7023,27 @@ function createTestRoom(options) {
6364
7023
  aim();
6365
7024
  const cat = tool.def.category;
6366
7025
  if (cat === "hand") {
6367
- if (strikes.some((strike) => strike.key === "hand" || strike.key === "fists")) return;
7026
+ if (strikes.some((strike) => strike.key === "hand" || strike.key === "fists")) return "cooldown";
6368
7027
  strikeUse({ kind: "melee", damage: "blunt", energy: 25, radius: 0.1, reach: 0.8, origin: [aimO[0], aimO[1], aimO[2]], direction: [aimD[0], aimD[1], aimD[2]] }, "hand");
6369
- return;
7028
+ return "ok";
6370
7029
  }
6371
- if (strikes.some((strike) => strike.key === toolId)) return;
7030
+ if (strikes.some((strike) => strike.key === toolId)) return "cooldown";
6372
7031
  const res = tool.trigger([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], dt, held, "player");
6373
7032
  flushEvents(toolId, tool, res.recoil);
6374
7033
  if (res.status === "empty") {
6375
7034
  if (!tool.reloading && tool.reserve > 0) startReload();
6376
7035
  say(t(tool.reloading ? "hud.reloading" : "hud.empty"));
6377
- return;
7036
+ return res.status;
6378
7037
  }
6379
7038
  if (res.status !== "ok") {
6380
7039
  if (res.status === "reloading") say(t("hud.reloading"));
6381
7040
  else if (res.status === "no_fuel") say(t("hud.no_fuel"));
6382
7041
  else if (res.status === "fault" && res.fault) say(t("hud.fault", { fault: t(`fault.${res.fault}`) }));
6383
7042
  else if (res.status === "safe") say(t("hud.safe"));
6384
- return;
7043
+ return res.status;
6385
7044
  }
6386
7045
  applyUses(res, dt);
7046
+ return res.status;
6387
7047
  };
6388
7048
  const applyUses = (res, dt) => {
6389
7049
  if (res.shots.length) fireShots(res.shots, res.recoil);
@@ -6485,8 +7145,9 @@ function createTestRoom(options) {
6485
7145
  heldTilt = 0;
6486
7146
  };
6487
7147
  const handUse = () => {
6488
- if (heldPiece) grab();
6489
- else useTool(FIXED_DT, false);
7148
+ if (!heldPiece) return useTool(FIXED_DT, false);
7149
+ grab();
7150
+ return "ok";
6490
7151
  };
6491
7152
  const grab = () => {
6492
7153
  if (push.active) return false;
@@ -6529,7 +7190,9 @@ function createTestRoom(options) {
6529
7190
  heldYaw = 0;
6530
7191
  heldTilt = 0;
6531
7192
  heldVel[0] = heldVel[1] = heldVel[2] = 0;
7193
+ playSound(pickupSoundOf(heldEntry?.item?.sounds, world.materials.get(heaviestMaterial(r.object) ?? "")), r.point, Math.min(1, 0.45 + kg * 0.1));
6532
7194
  action("grab", t("act.grab", { name: heldName, kg: num(kg, 1) }), { objectId: r.object, itemId: entryOf(r.object)?.so.itemId, data: { massKg: Math.round(kg * 10) / 10 } });
7195
+ playSound(impactKey(world.materials.get(heaviestMaterial(r.object) ?? "")).replace("impact_", "grab_"), v3(piece.root.getWorldPosition(tmpV)), 0.8);
6533
7196
  fire("grab", { held: true, objectId: r.object });
6534
7197
  return true;
6535
7198
  };
@@ -6542,6 +7205,7 @@ function createTestRoom(options) {
6542
7205
  releaseHeld();
6543
7206
  piece.velocity = [0, 0, 0];
6544
7207
  renderer.impulse(id, [aimD[0] * speed * piece.mass, (aimD[1] * speed + 1.2) * piece.mass, aimD[2] * speed * piece.mass]);
7208
+ playSound("item_throw", [piece.root.position.x, piece.root.position.y, piece.root.position.z], Math.min(1, 0.3 + speed * 0.04));
6545
7209
  action("throw", t("act.throw", { name, speed: num(speed, 1) }), { objectId: id, itemId: entryOf(id)?.so.itemId, data: { speedMs: speed, massKg: piece.mass } });
6546
7210
  fire("throw", { objectId: id, speed });
6547
7211
  return true;
@@ -6653,6 +7317,7 @@ function createTestRoom(options) {
6653
7317
  e.opened = true;
6654
7318
  if (e.containerId) fluids.open(e.containerId);
6655
7319
  seal.open?.(wrapped.get(e.so.root) ?? e.so.root);
7320
+ playSound(openSoundOf(e.item?.sounds, world.materials.get(heaviestMaterial(e.so.objectId) ?? "")), v3(e.so.root.getWorldPosition(tmpV)), 0.7);
6656
7321
  const tool2 = opener.tool ? catalogMap.get(opener.tool)?.name ?? (arsenal.weapons.has(opener.tool) ? toolName(opener.tool) : opener.tool) : void 0;
6657
7322
  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" } });
6658
7323
  return true;
@@ -6701,17 +7366,28 @@ function createTestRoom(options) {
6701
7366
  if (consuming && c.closed) fluids.open(e.containerId);
6702
7367
  useItemPlayer = createItemUse(fluids, e.containerId, def);
6703
7368
  useItemName = def.id;
7369
+ useSoundIn = 0;
6704
7370
  if (eating) useLiters = c.liters;
6705
7371
  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 } });
6706
7372
  return true;
6707
7373
  };
6708
7374
  let useLiters = null;
7375
+ let useSoundIn = 0;
6709
7376
  const updateItemUse = (dt) => {
6710
7377
  if (!useItemPlayer) return;
6711
7378
  const e = heldEntry;
6712
7379
  const c = e?.containerId ? fluids.container(e.containerId) : void 0;
6713
7380
  const before = c?.liters;
6714
7381
  useItemPlayer.update(dt);
7382
+ const useSound = e && useLiters !== null ? useSoundOf(e.item?.sounds, useItemName) : void 0;
7383
+ if (useSound && e) {
7384
+ useSoundIn -= dt;
7385
+ const now = e.containerId ? fluids.container(e.containerId)?.liters : void 0;
7386
+ if (useSoundIn <= 0 && before !== void 0 && now !== void 0 && now < before) {
7387
+ playSound(useSound, v3(e.so.root.getWorldPosition(tmpV)), 0.6);
7388
+ useSoundIn = USE_SOUND_EVERY;
7389
+ }
7390
+ }
6715
7391
  const seal = e?.item?.seal;
6716
7392
  twisting = null;
6717
7393
  if (e && seal?.openUse === useItemName && !e.opened) {
@@ -6921,7 +7597,9 @@ function createTestRoom(options) {
6921
7597
  if (!loadout.includes(weaponId)) loadout.push(weaponId);
6922
7598
  setTool(weaponId);
6923
7599
  };
7600
+ let hudAimHit;
6924
7601
  const inspectTarget = () => {
7602
+ if (hudAimHit !== void 0) return hudAimHit;
6925
7603
  aim();
6926
7604
  return rayWorld(aimO, aimD, 14);
6927
7605
  };
@@ -6999,13 +7677,64 @@ function createTestRoom(options) {
6999
7677
  distance: r.distance
7000
7678
  };
7001
7679
  };
7002
- let timeScale = 1;
7003
7680
  let fastForward = false;
7004
7681
  let hudAcc = 1;
7005
7682
  let metricsAcc = 0;
7006
7683
  let ownsLoop = false;
7684
+ let moduleFrameCount = 0;
7685
+ const headlessErrors = /* @__PURE__ */ new Map();
7686
+ const loggedErrors = /* @__PURE__ */ new Set();
7687
+ let headlessDepth = 0;
7688
+ const reportFrameError = (error, system) => {
7689
+ if (headlessDepth > 0) {
7690
+ if (!headlessErrors.has(system.id)) headlessErrors.set(system.id, error);
7691
+ return;
7692
+ }
7693
+ if (loggedErrors.has(system.id)) return;
7694
+ loggedErrors.add(system.id);
7695
+ console.error(`test-room: ${system.id} (${system.phase}) threw`, error);
7696
+ };
7697
+ const runHeadless = (run) => {
7698
+ headlessDepth++;
7699
+ try {
7700
+ run();
7701
+ } catch (error) {
7702
+ headlessErrors.clear();
7703
+ throw error;
7704
+ } finally {
7705
+ headlessDepth--;
7706
+ }
7707
+ if (headlessDepth > 0 || headlessErrors.size === 0) return;
7708
+ const errors = [...headlessErrors.values()];
7709
+ headlessErrors.clear();
7710
+ throw errors.length === 1 ? errors[0] : new AggregateError(errors, `${errors.length} systems threw in one frame`);
7711
+ };
7712
+ const scheduler = createScheduler({ maxFrameSeconds: 0.05, budgetMs: options.frameBudgetMs ?? 8, onError: reportFrameError });
7713
+ const audioQueue = audio?.deferredQueue?.();
7714
+ if (audioQueue) scheduler.budget.register(audioQueue, DEFERRED_PRIORITY[DEFERRED_QUEUE_ID.AUDIO_VOICES]);
7715
+ scheduler.budget.register(renderer.deferredQueue(), DEFERRED_PRIORITY[DEFERRED_QUEUE_ID.DESTRUCTION_EVENTS]);
7007
7716
  const roomMetricIds = options.metrics === false ? [] : options.metrics ?? ROOM_METRICS;
7008
7717
  const roomMetrics = createRoomMetrics({ gl, destruction: renderer, fluids: fluidView, effects: vfx.world, ids: roomMetricIds, blast: () => lastBlast });
7718
+ const MEMORY_CLEARS = 4;
7719
+ const memoryWatch = createMemoryWatch({
7720
+ clears: MEMORY_CLEARS,
7721
+ read: () => {
7722
+ const memory = gl.info?.memory;
7723
+ const heap = heapMB();
7724
+ return memory ? { geometries: memory.geometries, textures: memory.textures, ...heap === void 0 ? {} : { heapMB: heap } } : void 0;
7725
+ }
7726
+ });
7727
+ clearListeners.add(() => {
7728
+ const verdict = memoryWatch.cleared(activeBench ?? void 0);
7729
+ if (!verdict) return;
7730
+ if (!verdict.leaking) {
7731
+ ui?.alert(null);
7732
+ return;
7733
+ }
7734
+ const text = t("memory.leak", { geometries: verdict.grew.geometries, textures: verdict.grew.textures, clears: MEMORY_CLEARS });
7735
+ log.add({ channel: "event", type: "memory_leak", text, data: { ...verdict.after, grew: verdict.grew } });
7736
+ ui?.alert(`\u26A0 ${text}`);
7737
+ });
7009
7738
  let qualityName = quality;
7010
7739
  const syncContainers = (sdt) => {
7011
7740
  const drinkingHeld = useItemPlayer !== null && (useItemName === "drink" || useItemName === "sip" || useItemName === "chug");
@@ -7031,7 +7760,6 @@ function createTestRoom(options) {
7031
7760
  if (part && part.liters !== void 0 && Math.abs(part.liters - c.liters) > 1e-4) world.setLiters(host2.objectId, e.fluidPart, c.liters);
7032
7761
  }
7033
7762
  };
7034
- let paused = false;
7035
7763
  let walkPlan = null;
7036
7764
  const stepWalk = (dt) => {
7037
7765
  if (!walkPlan) return;
@@ -7040,8 +7768,7 @@ function createTestRoom(options) {
7040
7768
  walkPlan.remaining -= dt;
7041
7769
  if (walkPlan.remaining <= 1e-9) walkPlan = null;
7042
7770
  };
7043
- const stepSim = (dt) => {
7044
- if (paused) return;
7771
+ const simulateBeforeModules = (dt) => {
7045
7772
  simTime += dt;
7046
7773
  stepWalk(dt);
7047
7774
  stepStrikes(dt);
@@ -7057,6 +7784,7 @@ function createTestRoom(options) {
7057
7784
  }
7058
7785
  syncLiters();
7059
7786
  renderer.update(dt);
7787
+ destructionSound?.update(dt);
7060
7788
  for (const e of entries.values()) e.follower?.update?.();
7061
7789
  terrainView.sync(camera.position);
7062
7790
  showGroundFire(dt);
@@ -7073,7 +7801,8 @@ function createTestRoom(options) {
7073
7801
  const look = camera.getWorldDirection(listenerDirection);
7074
7802
  audio.setListener({ position: [camera.position.x, camera.position.y, camera.position.z], yaw: Math.atan2(-look.x, -look.z) });
7075
7803
  }
7076
- for (const update2 of frameListeners) update2(dt);
7804
+ };
7805
+ const simulateAfterModules = (dt) => {
7077
7806
  for (const tr of tracers) {
7078
7807
  if (tr.life > 0) {
7079
7808
  tr.life -= dt;
@@ -7082,7 +7811,7 @@ function createTestRoom(options) {
7082
7811
  }
7083
7812
  flush();
7084
7813
  };
7085
- const update = (dt) => {
7814
+ const updateInput = (dt, isPaused) => {
7086
7815
  if (!ownsLoop) roomMetrics.frame(dt);
7087
7816
  frameStamp++;
7088
7817
  seating.update();
@@ -7091,31 +7820,33 @@ function createTestRoom(options) {
7091
7820
  pushByKey = false;
7092
7821
  push.end();
7093
7822
  }
7094
- push.step(paused ? 0 : dt);
7095
- doors.step(paused ? 0 : dt);
7096
- if (!paused) kickLight(dt);
7097
- if (!paused) pushByPlayer(dt);
7823
+ push.step(isPaused ? 0 : dt);
7824
+ doors.step(isPaused ? 0 : dt);
7825
+ if (!isPaused) kickLight(dt);
7826
+ if (!isPaused) pushByPlayer(dt);
7098
7827
  else player.takeWish();
7099
- stepAvatar(paused ? 0 : dt);
7828
+ stepAvatar(isPaused ? 0 : dt);
7829
+ stepFeet(isPaused ? 0 : dt);
7100
7830
  if (tiltKey || heldTilt > 0) {
7101
7831
  heldTilt = Math.max(0, Math.min(2.4, heldTilt + (tiltKey ? 2.2 : -3) * dt));
7102
7832
  }
7103
- if ((useHeld || useFirst) && !paused && !push.active) {
7833
+ if ((useHeld || useFirst) && !isPaused && !push.active) {
7104
7834
  if (tool.def.category === "hand") {
7105
7835
  if (useFirst) handUse();
7106
7836
  } else if (useHeld) useTool(dt, !useFirst);
7107
7837
  useFirst = false;
7108
7838
  }
7109
- if (!paused) followHeld(dt);
7110
- stepSim(dt * timeScale);
7839
+ if (!isPaused) followHeld(dt);
7840
+ };
7841
+ const updatePresent = (dt) => {
7111
7842
  reachAvatar();
7112
7843
  if (progress && simTime - progress.at > 0.25) progress = null;
7844
+ ui?.flushLog();
7113
7845
  hudAcc += dt;
7114
7846
  if (hudAcc >= 0.1 && ui) {
7115
7847
  hudAcc = 0;
7116
7848
  ui.hud(hudState());
7117
7849
  }
7118
- ui?.flushLog();
7119
7850
  metricsAcc += dt;
7120
7851
  if (metricsAcc >= METRICS_INTERVAL_S && ui) {
7121
7852
  metricsAcc = 0;
@@ -7123,6 +7854,14 @@ function createTestRoom(options) {
7123
7854
  }
7124
7855
  };
7125
7856
  const hudState = () => {
7857
+ hudAimHit = inspectTarget();
7858
+ try {
7859
+ return readHudState();
7860
+ } finally {
7861
+ hudAimHit = void 0;
7862
+ }
7863
+ };
7864
+ const readHudState = () => {
7126
7865
  const page = Math.floor(toolIndex / 10);
7127
7866
  const slots = loadout.slice(page * 10, page * 10 + 10).map((id) => arsenal.name(id));
7128
7867
  const ammo = tool.def.action.kind === "firearm" ? `${tool.ammo}/${tool.capacity}${tool.reserve ? ` \xB7 ${tool.reserve}` : ""}` : tool.def.action.kind === "hand" ? "" : tool.def.action.kind === "melee" || tool.def.action.kind === "tool" ? "" : `${num(tool.ammo, 0)}`;
@@ -7140,7 +7879,7 @@ function createTestRoom(options) {
7140
7879
  inspect: inspectInfo(),
7141
7880
  animations: animationsOf(),
7142
7881
  locked: player.locked,
7143
- timeScale,
7882
+ timeScale: scheduler.timeScale,
7144
7883
  fast: fastForward,
7145
7884
  tap: isTapOn(),
7146
7885
  combo: combo ? comboOn : null,
@@ -7292,6 +8031,8 @@ function createTestRoom(options) {
7292
8031
  const value = sampled[id];
7293
8032
  return value === void 0 ? [] : [{ id, name: t(`metric.${id}`), text: num(value, id === "frame_ms" ? 1 : 0) }];
7294
8033
  });
8034
+ const leak = memoryWatch.last;
8035
+ if (leak?.leaking) room2.push({ id: "memory_leak", name: "\u26A0", text: t("memory.leak_short", { geometries: leak.grew.geometries, textures: leak.grew.textures, clears: MEMORY_CLEARS }) });
7295
8036
  const values = moduleMetrics();
7296
8037
  const declared = (activeBench ? benches.get(activeBench)?.metrics : void 0) ?? [];
7297
8038
  const module = declared.map((metric) => {
@@ -7307,11 +8048,11 @@ function createTestRoom(options) {
7307
8048
  ui.metrics(metricRows());
7308
8049
  };
7309
8050
  const hostListeners = /* @__PURE__ */ new Set();
7310
- const frameListeners = /* @__PURE__ */ new Set();
7311
8051
  const listenerDirection = new THREE.Vector3();
7312
8052
  const environment = options.environment ?? createEnvironmentBus();
7313
8053
  const audioSink = audio ? createAudioSink(audio) : void 0;
7314
8054
  function emitHost(event) {
8055
+ if (event.type === "explosion") knockPlayer(event);
7315
8056
  for (const listener of hostListeners) listener(event);
7316
8057
  }
7317
8058
  function flushEvents(id, state, recoil) {
@@ -7360,12 +8101,21 @@ function createTestRoom(options) {
7360
8101
  scene,
7361
8102
  camera,
7362
8103
  locale,
8104
+ ...graphics ? { graphics: graphics.host } : {},
7363
8105
  ...audioSink ? { audio: audioSink } : {},
7364
8106
  environment,
7365
8107
  groundAt,
8108
+ // the terrain's material where the floor is the top; a table or a counter answers undefined (the module's default)
8109
+ surfaceAt: (x, z) => Math.max(sceneryTopAt(x, z), restingTopAt(x, z)) > terrain.heightAt(x, z) ? void 0 : terrain.columnAt(x, z).material,
7366
8110
  effects: vfx,
8111
+ particleLimit: () => options.debris?.particleLimit ?? DEFAULT_PARTICLE_LIMIT,
8112
+ lights,
8113
+ pools,
7367
8114
  loading,
8115
+ addView: (view) => moduleViews.add(view),
8116
+ setViewCamera: (view) => moduleViews.setMain(view ?? null),
7368
8117
  ...options.blastShove ? { blastShove: options.blastShove } : {},
8118
+ blastReach: (point, target, ignore) => world.blastReach(point, target, ignore ? { ignore } : {}),
7369
8119
  tool: () => ({
7370
8120
  weapon: toolId,
7371
8121
  category: tool.def.category,
@@ -7415,9 +8165,9 @@ function createTestRoom(options) {
7415
8165
  hostListeners.add(listener);
7416
8166
  return () => hostListeners.delete(listener);
7417
8167
  },
7418
- onFrame(update2) {
7419
- frameListeners.add(update2);
7420
- return () => frameListeners.delete(update2);
8168
+ defer: (queue, queueOptions) => scheduler.budget.register(queue, queueOptions),
8169
+ onFrame(update, frameOptions) {
8170
+ return scheduler.add((ctx) => update(ctx.dt), { phase: frameOptions?.phase ?? "lateSimulate", order: frameOptions?.order ?? 0, id: `module.frame#${moduleFrameCount++}` });
7421
8171
  },
7422
8172
  holdFrame: () => bodyHoldFrame(),
7423
8173
  reachFor(reach) {
@@ -7475,46 +8225,47 @@ function createTestRoom(options) {
7475
8225
  return r.ok ? store : null;
7476
8226
  };
7477
8227
  const LIGHTS_CHECK_MS = 250;
7478
- const pinnedPrograms = /* @__PURE__ */ new WeakSet();
7479
- const pinPrograms = () => {
7480
- for (const program of gl.info?.programs ?? []) {
7481
- if (pinnedPrograms.has(program)) continue;
7482
- pinnedPrograms.add(program);
7483
- program.usedTimes++;
8228
+ const materialsOf = (group) => {
8229
+ const found = /* @__PURE__ */ new Set();
8230
+ group.traverse((object) => {
8231
+ const material = object.material;
8232
+ for (const each of Array.isArray(material) ? material : material ? [material] : []) found.add(each);
8233
+ });
8234
+ return [...found];
8235
+ };
8236
+ const probeOf = (id, adapter) => ({
8237
+ id,
8238
+ build() {
8239
+ const probe = adapter.warmUpProbes();
8240
+ const group = probe.group;
8241
+ return { group, materials: materialsOf(group), dispose: () => probe.dispose() };
7484
8242
  }
8243
+ });
8244
+ warmup.register(probeOf("destruction", renderer));
8245
+ warmup.register(probeOf("vfx", vfx));
8246
+ warmup.register(probeOf("fluids", fluidView));
8247
+ warmup.register(spriteProbes(THREE));
8248
+ const warmedEffect = (id) => {
8249
+ let handle = null;
8250
+ return {
8251
+ id: `vfx:${id}`,
8252
+ play() {
8253
+ handle = vfx.play({ effect: id, point: [0, 0.1, -4], direction: [0, 1, 0], normal: [0, 1, 0] });
8254
+ },
8255
+ step: (dt) => vfx.update(dt),
8256
+ stop() {
8257
+ if (handle !== null) vfx.stop(handle);
8258
+ vfx.world.clear();
8259
+ vfx.update(0);
8260
+ }
8261
+ };
7485
8262
  };
8263
+ const catalogIds = vfx.catalog?.() ?? [];
8264
+ for (const id of [...EXPLOSIVE_EFFECT_IDS, ...catalogIds.filter((id2) => /fire|flame/i.test(id2)).slice(0, 1)]) if (catalogIds.includes(id)) warmup.registerEffect(warmedEffect(id));
8265
+ let warmedUp = false;
7486
8266
  const warmUp = () => {
7487
- const probes = [renderer.warmUpProbes(), vfx.warmUpProbes(), fluidView.warmUpProbes()];
7488
- const groups = probes.map((probe) => probe.group);
7489
- const spriteMap = new THREE.DataTexture(new Uint8Array([255, 255, 255, 255]), 1, 1);
7490
- spriteMap.needsUpdate = true;
7491
- const spriteProbes = [true, false].map((fog) => {
7492
- const probe = new THREE.Sprite(new THREE.SpriteMaterial({ map: spriteMap, transparent: true, fog }));
7493
- probe.frustumCulled = false;
7494
- return probe;
7495
- });
7496
- groups.push(...spriteProbes);
7497
- scene.add(...groups);
7498
- gl.render(scene, camera);
7499
- pinPrograms();
7500
- scene.remove(...groups);
7501
- for (const probe of probes) probe.dispose();
7502
- for (const probe of spriteProbes) probe.material.dispose();
7503
- spriteMap.dispose();
7504
- };
7505
- const warmUpEffects = () => {
7506
- const ids = vfx.catalog?.() ?? [];
7507
- const flames = ids.filter((id) => /fire|flame/i.test(id)).slice(0, 1);
7508
- gl.compile(scene, camera);
7509
- const handles = ["m67", ...flames].filter((id) => ids.includes(id)).map((effect) => vfx.play({ effect, point: [0, 0.1, -4], direction: [0, 1, 0], normal: [0, 1, 0] }));
7510
- for (let i = 0; i < 3; i++) {
7511
- vfx.update(0.02);
7512
- gl.render(scene, camera);
7513
- }
7514
- for (const h of handles) if (h !== null) vfx.stop(h);
7515
- vfx.world.clear();
7516
- vfx.update(0);
7517
- gl.render(scene, camera);
8267
+ warmup.warmUp(gl, scene, camera, { lights, fogStates: WARMED_FOG_STATES, materials: pools.materials.registeredMaterials?.() ?? [] });
8268
+ warmedUp = true;
7518
8269
  };
7519
8270
  const loadModule = (mod) => {
7520
8271
  const report = { module: mod.id, items: [], scenarios: [], ok: true, problems: [] };
@@ -7539,6 +8290,7 @@ function createTestRoom(options) {
7539
8290
  impl: ti.impl,
7540
8291
  animations: ti.animations,
7541
8292
  seats: ti.seats,
8293
+ sounds: ti.sounds,
7542
8294
  thumbnail: ti.thumbnail,
7543
8295
  thumbnailCredit: ti.thumbnailCredit
7544
8296
  };
@@ -7626,24 +8378,13 @@ function createTestRoom(options) {
7626
8378
  };
7627
8379
  const allScenarios = () => [...extraScenarios];
7628
8380
  let scenarioRunning = false;
7629
- const step = (seconds) => {
7630
- const dt = seconds > 6 ? 1 / 30 : FIXED_DT;
7631
- const n = Math.max(1, Math.round(seconds / dt));
7632
- for (let i = 0; i < n; i++) {
7633
- seating.update();
7634
- player.update(0);
7635
- push.step(dt);
7636
- doors.step(dt);
7637
- followHeld(dt);
7638
- stepSim(dt);
7639
- }
7640
- };
8381
+ const step = (seconds) => runHeadless(() => scheduler.advance(seconds, { dt: seconds > 6 ? 1 / 30 : FIXED_DT }));
7641
8382
  const runScenarios = async (list) => {
7642
8383
  const all = list ?? allScenarios();
7643
8384
  const report = { passed: 0, failed: 0, results: [] };
7644
8385
  scenarioRunning = true;
7645
- const wasPaused = paused;
7646
- paused = false;
8386
+ const wasPaused = scheduler.paused;
8387
+ scheduler.resume();
7647
8388
  try {
7648
8389
  for (const sc of all) {
7649
8390
  hardReset();
@@ -7675,7 +8416,7 @@ function createTestRoom(options) {
7675
8416
  }
7676
8417
  } finally {
7677
8418
  scenarioRunning = false;
7678
- paused = wasPaused;
8419
+ if (wasPaused) scheduler.pause();
7679
8420
  restoreScenery();
7680
8421
  }
7681
8422
  return report;
@@ -7807,19 +8548,25 @@ function createTestRoom(options) {
7807
8548
  useItem,
7808
8549
  use(hold = 0) {
7809
8550
  if (tool.def.category === "hand") {
7810
- handUse();
8551
+ const status2 = handUse();
7811
8552
  flush();
7812
- return;
8553
+ return status2;
7813
8554
  }
7814
- useTool(FIXED_DT, false);
8555
+ const status = useTool(FIXED_DT, false);
7815
8556
  flush();
7816
8557
  let t0 = 0;
7817
8558
  while (t0 < hold) {
7818
8559
  useTool(FIXED_DT, true);
7819
- stepSim(FIXED_DT);
8560
+ directStep = true;
8561
+ try {
8562
+ runHeadless(() => scheduler.advance(FIXED_DT));
8563
+ } finally {
8564
+ directStep = false;
8565
+ }
7820
8566
  t0 += FIXED_DT;
7821
8567
  }
7822
8568
  releaseTrigger();
8569
+ return status;
7823
8570
  },
7824
8571
  grab,
7825
8572
  throwHeld,
@@ -7835,14 +8582,14 @@ function createTestRoom(options) {
7835
8582
  spawned() {
7836
8583
  return [...entries.values()].filter((e) => !e.arena).map((e) => e.so);
7837
8584
  },
7838
- update,
8585
+ update: (dt) => runHeadless(() => scheduler.frame(dt, { render: false, exact: true })),
7839
8586
  waitUntilReady(maxSeconds = 5) {
7840
8587
  let waited = 0;
7841
8588
  while (tool.phase && waited < maxSeconds) {
7842
- update(FIXED_DT);
8589
+ room.update(FIXED_DT);
7843
8590
  waited += FIXED_DT;
7844
8591
  }
7845
- update(FIXED_DT);
8592
+ room.update(FIXED_DT);
7846
8593
  return waited + FIXED_DT;
7847
8594
  },
7848
8595
  on(type, fn) {
@@ -7859,20 +8606,20 @@ function createTestRoom(options) {
7859
8606
  blastOptions = { ...blastOptions, ...next };
7860
8607
  },
7861
8608
  pause() {
7862
- paused = true;
8609
+ scheduler.pause();
7863
8610
  },
7864
8611
  resume() {
7865
- paused = false;
8612
+ scheduler.resume();
7866
8613
  },
7867
8614
  get paused() {
7868
- return paused;
8615
+ return scheduler.paused;
7869
8616
  },
7870
8617
  walk(to, seconds) {
7871
8618
  walkPlan = seconds > 0 ? { x: to[0], z: to[2], remaining: seconds } : null;
7872
8619
  if (!walkPlan) player.moveBy(to[0] - camera.position.x, to[2] - camera.position.z);
7873
8620
  },
7874
8621
  setSlowMotion(on) {
7875
- timeScale = on ? 0.25 : 1;
8622
+ scheduler.timeScale = on ? 0.25 : 1;
7876
8623
  },
7877
8624
  setFastForward(on) {
7878
8625
  fastForward = on;
@@ -7888,12 +8635,13 @@ function createTestRoom(options) {
7888
8635
  modules: () => [...modules.values()].map((m) => ({ id: m.id, name: m.name, group: m.group, hasArea: typeof m.area === "function" })),
7889
8636
  openAreaAsync: (area) => loading.busy(t("loading.open_area", { area: areaLabel(area) }), () => openArea(area)),
7890
8637
  loading,
8638
+ graphics: graphics?.controller ?? null,
8639
+ graphicsScreen: graphics?.screen ?? null,
7891
8640
  busy: (label, work, busyOptions) => loading.busy(label, work, busyOptions),
7892
8641
  loadModule,
7893
8642
  warmUp: () => {
7894
8643
  audio?.warmUp();
7895
8644
  warmUp();
7896
- warmUpEffects();
7897
8645
  },
7898
8646
  animations: animationsOf,
7899
8647
  playAnimation,
@@ -7929,7 +8677,10 @@ function createTestRoom(options) {
7929
8677
  crouchForced = state.crouch;
7930
8678
  avatarState.crouch = state.crouch || player.crouching;
7931
8679
  }
7932
- if (state.down !== void 0) avatarState.down = state.down;
8680
+ if (state.down !== void 0) {
8681
+ downForced = state.down;
8682
+ avatarState.down = state.down || Boolean(player.knocked?.down);
8683
+ }
7933
8684
  if (state.armed !== void 0) armedForced = state.armed;
7934
8685
  },
7935
8686
  avatarState: () => ({ ...avatarState }),
@@ -7950,6 +8701,9 @@ function createTestRoom(options) {
7950
8701
  get playerPosition() {
7951
8702
  return player.position;
7952
8703
  },
8704
+ get playerKnock() {
8705
+ return player.knocked;
8706
+ },
7953
8707
  sit: (objectId, seatId) => seating.sit(objectId, seatId),
7954
8708
  stand: () => seating.stand(),
7955
8709
  get seat() {
@@ -7965,20 +8719,30 @@ function createTestRoom(options) {
7965
8719
  metrics() {
7966
8720
  return { room: roomMetrics.sample(), module: moduleMetrics() };
7967
8721
  },
8722
+ frameStats: () => scheduler.stats(),
8723
+ lights,
8724
+ webgl: gl,
7968
8725
  dispose() {
7969
8726
  destructionSound?.dispose();
7970
8727
  if (ownAudio) audio?.dispose();
7971
8728
  for (const detach of detachers.values()) detach();
7972
8729
  detachers.clear();
8730
+ moduleViews.dispose();
7973
8731
  for (const store of benches.values()) store.dispose();
7974
8732
  benches.clear();
7975
- cancelAnimationFrame?.(raf);
8733
+ stopLoop?.();
8734
+ disposePacer?.();
8735
+ scheduler.dispose();
7976
8736
  window.removeEventListener("resize", onResize);
7977
8737
  player.dispose();
7978
8738
  ui?.dispose();
8739
+ graphics?.dispose();
7979
8740
  loading.dispose();
7980
8741
  renderer.dispose();
7981
8742
  vfx.dispose();
8743
+ lights.dispose();
8744
+ pools.materials.dispose();
8745
+ pools.geometries.dispose();
7982
8746
  fluidView.dispose();
7983
8747
  filmBridge.dispose();
7984
8748
  film.dispose();
@@ -8044,6 +8808,7 @@ function createTestRoom(options) {
8044
8808
  }
8045
8809
  },
8046
8810
  quality: qualityName,
8811
+ ...graphics?.screen ? { onGraphics: () => graphics.screen?.open() } : {},
8047
8812
  scenarios: allScenarios,
8048
8813
  onRun: async (sc) => {
8049
8814
  await runScenarios([sc]);
@@ -8077,41 +8842,77 @@ function createTestRoom(options) {
8077
8842
  ui.hud(hudState());
8078
8843
  showBench();
8079
8844
  }
8080
- let raf = 0;
8081
- if (ownRenderer && typeof requestAnimationFrame === "function") {
8082
- let last = performance.now();
8083
- ownsLoop = true;
8845
+ let directStep = false;
8846
+ scheduler.add(
8847
+ (ctx) => {
8848
+ if (directStep) return;
8849
+ if (!ctx.scripted) return updateInput(ctx.dt, ctx.paused);
8850
+ seating.update();
8851
+ player.update(0);
8852
+ push.step(ctx.dt);
8853
+ doors.step(ctx.dt);
8854
+ followHeld(ctx.dt);
8855
+ },
8856
+ { phase: "input", id: "room.input" }
8857
+ );
8858
+ scheduler.add((ctx) => simulateBeforeModules(ctx.dt), { phase: "lateSimulate", order: -1e3, id: "room.simulate" });
8859
+ scheduler.add((ctx) => simulateAfterModules(ctx.dt), { phase: "lateSimulate", order: 1e3, id: "room.simulate-end" });
8860
+ scheduler.add(
8861
+ (ctx) => {
8862
+ if (ctx.simulated && !ctx.scripted) updatePresent(ctx.realDt);
8863
+ },
8864
+ { phase: "present", id: "room.present" }
8865
+ );
8866
+ scheduler.add((ctx) => lights.present([camera.position.x, camera.position.y, camera.position.z], ctx.realDt), { phase: "present", order: 10, id: "room.lights" });
8867
+ let stopLoop;
8868
+ let disposePacer;
8869
+ if (ownRenderer) {
8084
8870
  let nextLightsCheck = 0;
8085
- let warmedLights = "";
8086
- const lightsKey = () => {
8087
- let key2 = "";
8088
- scene.traverseVisible((object) => {
8089
- if (object.isLight) key2 += `${object.type[0]}${object.castShadow ? "s" : ""}`;
8090
- });
8091
- return key2;
8092
- };
8093
- const loop = (now) => {
8094
- raf = requestAnimationFrame(loop);
8095
- if (now >= nextLightsCheck) {
8871
+ let warnedState = "";
8872
+ scheduler.add(
8873
+ (ctx) => {
8874
+ if (ctx.scripted) return;
8875
+ const now = performance.now();
8876
+ if (now < nextLightsCheck) return;
8096
8877
  nextLightsCheck = now + LIGHTS_CHECK_MS;
8097
- const key2 = lightsKey();
8098
- if (key2 !== warmedLights) {
8099
- warmedLights = key2;
8100
- warmUp();
8101
- }
8102
- }
8103
- const realDelta = (now - last) / 1e3;
8104
- roomMetrics.frame(realDelta);
8105
- const dt = Math.min(0.05, realDelta);
8106
- last = now;
8107
- if (!scenarioRunning) update(dt);
8108
- const restoreCamera = room.applyCamera();
8109
- gl.render(scene, debugView ?? camera);
8110
- pinPrograms();
8111
- restoreCamera();
8112
- roomMetrics.rendered();
8113
- };
8114
- raf = requestAnimationFrame(loop);
8878
+ const state = lights.check(gl, scene);
8879
+ if (warmedUp && (state.ok || state.actual === warnedState)) return;
8880
+ warnedState = state.actual;
8881
+ warmUp();
8882
+ },
8883
+ { phase: "render", order: -1e3, id: "room.lights-check" }
8884
+ );
8885
+ const pacer = createFramePacer(typeof gl.getContext === "function" ? gl.getContext() : null, options.framesInFlight === void 0 ? {} : { framesInFlight: options.framesInFlight });
8886
+ disposePacer = () => pacer.dispose();
8887
+ scheduler.add(
8888
+ (ctx) => {
8889
+ if (!ctx.scripted && !pacer.ready()) return;
8890
+ for (const batcher of batchers.values()) batcher.update();
8891
+ const restoreCamera = room.applyCamera();
8892
+ graphics?.beforeRender(camera);
8893
+ gl.render(scene, debugView ?? moduleViews.main(camera));
8894
+ moduleViews.render(scene);
8895
+ graphics?.afterRender();
8896
+ restoreCamera();
8897
+ pacer.submitted();
8898
+ roomMetrics.rendered();
8899
+ if (!ctx.scripted) graphics?.frameEnd();
8900
+ },
8901
+ { phase: "render", id: "room.render" }
8902
+ );
8903
+ try {
8904
+ stopLoop = startLoop(scheduler, {
8905
+ onFrame: (realSeconds) => {
8906
+ graphics?.frameStart();
8907
+ roomMetrics.frame(realSeconds);
8908
+ },
8909
+ skipSimulation: () => scenarioRunning,
8910
+ // the player's frame cap (Gráficos → Limite de FPS)
8911
+ ...graphics ? { minFrameMs: () => graphics.controller.minFrameMs() } : {}
8912
+ });
8913
+ ownsLoop = true;
8914
+ } catch {
8915
+ }
8115
8916
  }
8116
8917
  if (options.initial) {
8117
8918
  for (const [i, id] of options.initial.entries()) spawn(id, [(i - (options.initial.length - 1) / 2) * 1.3, 0, 0.5]);