@adatechnology/game-test-room 0.20.0 → 0.21.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,87 @@
1
1
  // src/room.ts
2
2
  import { createDestruction, JOINT_DEFAULTS, resolveStrings as resolveDestructionStrings } from "@adatechnology/game-destruction";
3
3
  import { createAudioEngine, createAudioSink } from "@adatechnology/game-audio";
4
+ import { surfaceOfMaterial } from "@adatechnology/game-contracts";
5
+
6
+ // src/body-sounds.ts
7
+ import { BODY_FOLEY_BANK } from "@adatechnology/game-audio";
8
+ import { SWING_LEAD_SECONDS, hitSoundId, landSoundId, swingSoundId } from "@adatechnology/game-contracts";
9
+ var DEFAULT_TOOL_BLOWS = {
10
+ hand: "punch",
11
+ fists: "punch",
12
+ kick: "kick",
13
+ shove: "shove",
14
+ knife: "blade",
15
+ machete: "blade",
16
+ utility_knife: "blade",
17
+ axe: "chop",
18
+ hammer: "blunt",
19
+ claw_hammer: "blunt",
20
+ sledgehammer: "blunt",
21
+ baseball_bat: "blunt",
22
+ crowbar: "blunt",
23
+ wrench: "blunt"
24
+ };
25
+ function blowOfTool(id, category, table = DEFAULT_TOOL_BLOWS) {
26
+ const listed = table[id];
27
+ if (listed) return listed;
28
+ if (category === "melee_blade") return "blade";
29
+ if (category === "melee_blunt" || category === "hand") return id === "kick" ? "kick" : "blunt";
30
+ return null;
31
+ }
32
+ var levelOf = (energyJ) => Math.max(0.5, Math.min(1, 0.5 + energyJ / 800));
33
+ var STRIDE_METRES = { walk: 0.72, run: 1.05 };
34
+ function createBodySounds(audio) {
35
+ audio?.register(BODY_FOLEY_BANK);
36
+ const played = [];
37
+ const play = (id, position, volume, delay = 0) => {
38
+ played.push(id);
39
+ if (played.length > 64) played.shift();
40
+ audio?.play(id, { position, volume, ...delay > 0 ? { delay } : {} });
41
+ };
42
+ return {
43
+ swing(blow, position, delay = 0) {
44
+ const id = swingSoundId(blow);
45
+ if (id) play(id, position, 0.8, delay);
46
+ },
47
+ hit(blow, target, position, energyJ) {
48
+ play(hitSoundId(blow, target), position, levelOf(energyJ));
49
+ },
50
+ jump: (position) => play("jump", position, 0.6),
51
+ land: (surface, speed, position) => play(landSoundId(surface, speed), position, Math.min(1, 0.45 + speed / 8)),
52
+ played
53
+ };
54
+ }
55
+ var swingLead = (blow) => SWING_LEAD_SECONDS[blow];
56
+ function createStrideCounter(stride = STRIDE_METRES) {
57
+ let left = stride.walk / 2;
58
+ let next = "right";
59
+ return {
60
+ advance(metres, gait) {
61
+ left -= metres;
62
+ if (left > 0) return null;
63
+ left += stride[gait];
64
+ const foot = next;
65
+ next = next === "right" ? "left" : "right";
66
+ return foot;
67
+ },
68
+ reset() {
69
+ left = stride.walk / 2;
70
+ next = "right";
71
+ }
72
+ };
73
+ }
74
+
75
+ // src/room.ts
4
76
  import { createDestructionRenderer, createDestructionSound, dragSoundKey, impactKey, specFromObject3D } from "@adatechnology/game-destruction-three";
5
77
  import { createArsenal, DEFAULT_LOADOUT } from "@adatechnology/game-weapons";
6
78
  import { createFilmLcg, createFluids, createItemUse, ITEM_USES, resolveStrings as resolveFluidStrings } from "@adatechnology/game-fluids";
7
79
  import { createFilmBridge, createFilmManager, createFluidRenderer } from "@adatechnology/game-fluids-three";
8
80
  import { SPRITE_KINDS, createVfx, patchSpriteKinds } from "@adatechnology/game-vfx";
9
81
  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";
82
+ import { createFramePacer, createGeometryPool, createLightBudget, createMaterialPool, createProgramWarmup, createStaticBatcher, spriteProbes } from "@adatechnology/game-render-three";
83
+ import { DEFERRED_PRIORITY, DEFERRED_QUEUE_ID, checkAnimation, checkBench, checkDestructible, checkSpec, createEnvironmentBus, sealBlock, sealOpener, shotExtras, twistStroke, TWIST_LIFT, useToHit } from "@adatechnology/game-contracts";
84
+ import { createScheduler, startLoop } from "@adatechnology/game-runtime";
11
85
 
12
86
  // src/arena.ts
13
87
  import { markKitOrigin } from "@adatechnology/game-contracts";
@@ -1187,6 +1261,91 @@ function createLoading(options) {
1187
1261
  };
1188
1262
  }
1189
1263
 
1264
+ // src/graphics.ts
1265
+ import { createGraphicsController, frameLoad, targetFrameMs } from "@adatechnology/game-graphics";
1266
+ import { createFpsOverlay, createGraphicsScreen } from "@adatechnology/game-graphics/screen";
1267
+ import { createGpuFrameTimer, createGraphicsView, gpuName } from "@adatechnology/game-graphics-three";
1268
+ var QUIET_FRAMES = 10;
1269
+ function createRoomGraphics(o) {
1270
+ const view = createGraphicsView({ renderer: o.gl, camera: o.camera, sun: o.sun });
1271
+ const seconds = o.options.calibrationSeconds ?? 3;
1272
+ const controller = createGraphicsController({
1273
+ store: { ...o.options.storage !== void 0 ? { storage: o.options.storage } : {}, ...o.options.key ? { key: o.options.key } : {} },
1274
+ gpu: gpuName(o.gl),
1275
+ benchmark: seconds > 0 ? { durationS: seconds } : false,
1276
+ // the room measures its own frames once it is up, not the loading of its content
1277
+ calibrate: "manual",
1278
+ apply: (e) => view.apply(e)
1279
+ });
1280
+ const timer = createGpuFrameTimer(o.gl);
1281
+ const screen = o.ui ? createGraphicsScreen({ container: o.container, controller, locale: o.locale }) : null;
1282
+ const overlay = o.ui ? createFpsOverlay({ container: o.container, controller, locale: o.locale }) : null;
1283
+ const listeners = /* @__PURE__ */ new Set();
1284
+ let lastHorde = controller.settings.hordeDetail;
1285
+ const unsubscribe = controller.subscribe(() => {
1286
+ if (controller.settings.hordeDetail === lastHorde) return;
1287
+ lastHorde = controller.settings.hordeDetail;
1288
+ for (const l of listeners) l();
1289
+ });
1290
+ let task = null;
1291
+ const syncTask = () => {
1292
+ if (controller.phase === "calibrating") {
1293
+ task ?? (task = o.loading.begin(o.label, { total: 100 }));
1294
+ task.step(`${Math.round(controller.calibrationProgress * 100)}%`, Math.round(controller.calibrationProgress * 100));
1295
+ } else if (task) {
1296
+ task.end();
1297
+ task = null;
1298
+ }
1299
+ };
1300
+ syncTask();
1301
+ let started = 0;
1302
+ let quiet = 0;
1303
+ let lastEnd = 0;
1304
+ return {
1305
+ controller,
1306
+ view,
1307
+ screen,
1308
+ host: {
1309
+ hordeDetail: () => controller.settings.hordeDetail,
1310
+ onChange(listener) {
1311
+ listeners.add(listener);
1312
+ return () => listeners.delete(listener);
1313
+ },
1314
+ report: (key2, value) => controller.report(key2, value)
1315
+ },
1316
+ frameStart() {
1317
+ started = performance.now();
1318
+ },
1319
+ beforeRender(camera) {
1320
+ view.hold();
1321
+ view.follow(camera.position.x, camera.position.z);
1322
+ timer.begin();
1323
+ },
1324
+ afterRender() {
1325
+ timer.end();
1326
+ },
1327
+ frameEnd() {
1328
+ const now = performance.now();
1329
+ const realSeconds = lastEnd > 0 ? (now - lastEnd) / 1e3 : 0;
1330
+ lastEnd = now;
1331
+ if (!(realSeconds > 0)) return;
1332
+ const cpuMs = started > 0 ? now - started : realSeconds * 1e3;
1333
+ controller.frame(realSeconds, frameLoad({ cpuMs, gpuMs: timer.ms, intervalMs: realSeconds * 1e3, targetMs: targetFrameMs(controller.settings.fpsCap) }));
1334
+ quiet = o.loading.active && !task ? 0 : quiet + 1;
1335
+ if (o.options.calibrate !== "manual" && quiet >= QUIET_FRAMES) controller.calibrateIfNeeded();
1336
+ syncTask();
1337
+ },
1338
+ dispose() {
1339
+ task?.end();
1340
+ unsubscribe();
1341
+ listeners.clear();
1342
+ timer.dispose();
1343
+ screen?.dispose();
1344
+ overlay?.dispose();
1345
+ }
1346
+ };
1347
+ }
1348
+
1190
1349
  // src/log.ts
1191
1350
  var MAX_ENTRIES = 5e3;
1192
1351
  var pad = (n, w = 2) => String(n).padStart(w, "0");
@@ -1316,8 +1475,12 @@ function createLog(options = {}) {
1316
1475
  }
1317
1476
 
1318
1477
  // src/metrics.ts
1319
- var ROOM_METRICS = ["fps", "frame_ms", "draw_calls", "triangles", "objects", "fragments", "particles", "decals", "blast_shelter", "blast_confinement", "blast_fragments"];
1478
+ var ROOM_METRICS = ["fps", "frame_ms", "draw_calls", "triangles", "objects", "fragments", "particles", "decals", "blast_shelter", "blast_confinement", "blast_fragments", "geometries", "textures", "heap_mb"];
1320
1479
  var WINDOW_S = 0.5;
1480
+ function heapMB() {
1481
+ const memory = globalThis.performance?.memory;
1482
+ return typeof memory?.usedJSHeapSize === "number" ? memory.usedJSHeapSize / 1048576 : void 0;
1483
+ }
1321
1484
  function createRoomMetrics(sources) {
1322
1485
  const wanted = new Set(sources.ids);
1323
1486
  const gl = sources.gl;
@@ -1353,6 +1516,9 @@ function createRoomMetrics(sources) {
1353
1516
  const render = ownsRender ? { calls, triangles } : gl.info?.render;
1354
1517
  put("draw_calls", render?.calls);
1355
1518
  put("triangles", render?.triangles);
1519
+ put("geometries", gl.info?.memory?.geometries);
1520
+ put("textures", gl.info?.memory?.textures);
1521
+ put("heap_mb", heapMB());
1356
1522
  const blast = sources.blast?.();
1357
1523
  if (blast) {
1358
1524
  put("blast_shelter", blast.shelter * 100);
@@ -1372,6 +1538,43 @@ function createRoomMetrics(sources) {
1372
1538
  };
1373
1539
  }
1374
1540
 
1541
+ // src/memory-watch.ts
1542
+ function createMemoryWatch(options) {
1543
+ const clears = Math.max(2, options.clears ?? 4);
1544
+ const budget = { geometries: options.budget?.geometries ?? 40, textures: options.budget?.textures ?? 4 };
1545
+ const histories = /* @__PURE__ */ new Map();
1546
+ let last;
1547
+ return {
1548
+ cleared(where) {
1549
+ const now = options.read();
1550
+ if (!now) return void 0;
1551
+ const key2 = where ?? "";
1552
+ const history = histories.get(key2) ?? [];
1553
+ histories.set(key2, history);
1554
+ history.push(now);
1555
+ if (history.length > clears + 1) history.shift();
1556
+ const first = history[0];
1557
+ const grew = { geometries: now.geometries - first.geometries, textures: now.textures - first.textures };
1558
+ let growing = history.length === clears + 1;
1559
+ for (let i = 1; growing && i < history.length; i++) {
1560
+ const before = history[i - 1];
1561
+ const after = history[i];
1562
+ growing = after.geometries > before.geometries || after.textures > before.textures;
1563
+ }
1564
+ const leaking = growing && (grew.geometries > budget.geometries || grew.textures > budget.textures);
1565
+ last = { after: now, grew, leaking };
1566
+ return last;
1567
+ },
1568
+ get last() {
1569
+ return last;
1570
+ },
1571
+ reset() {
1572
+ histories.clear();
1573
+ last = void 0;
1574
+ }
1575
+ };
1576
+ }
1577
+
1375
1578
  // src/narrate.ts
1376
1579
  var SKIP_DAMAGE = /* @__PURE__ */ new Set(["fire", "heat"]);
1377
1580
  function createNarrator(c) {
@@ -1885,6 +2088,7 @@ function createNarrator(c) {
1885
2088
  }
1886
2089
 
1887
2090
  // src/player.ts
2091
+ var LANDING_MIN_SPEED = 1;
1888
2092
  var EYE = 1.7;
1889
2093
  var PLAYER_RADIUS = 0.3;
1890
2094
  var FOOT = 0.5;
@@ -1911,6 +2115,7 @@ function createPlayer(THREE, o) {
1911
2115
  let bend = 0;
1912
2116
  let bendTarget = 0;
1913
2117
  let vy = 0;
2118
+ let floorUnder = 0;
1914
2119
  let walked = 0;
1915
2120
  let running = false;
1916
2121
  const px = { x: o.start[0], z: o.start[2] };
@@ -2163,6 +2368,9 @@ function createPlayer(THREE, o) {
2163
2368
  get running() {
2164
2369
  return running;
2165
2370
  },
2371
+ get grounded() {
2372
+ return y <= floorUnder + 1e-3 && vy <= 0;
2373
+ },
2166
2374
  get crouching() {
2167
2375
  return isCrouched();
2168
2376
  },
@@ -2188,13 +2396,18 @@ function createPlayer(THREE, o) {
2188
2396
  return;
2189
2397
  }
2190
2398
  const floor = Math.max(floorHere(), o.footing?.(px.x, px.z, PLAYER_RADIUS * FOOT, y).floor ?? -Infinity);
2399
+ floorUnder = floor;
2191
2400
  if (y < floor && vy <= 0) y = floor;
2192
- if (y <= floor && keys.has("Space") && !walkHeld) vy = JUMP;
2401
+ if (y <= floor && keys.has("Space") && !walkHeld) {
2402
+ vy = JUMP;
2403
+ o.onFeet?.({ type: "jump" });
2404
+ }
2193
2405
  if (y > floor || vy > 0) {
2194
2406
  vy -= GRAVITY * dt;
2195
2407
  y += vy * dt;
2196
2408
  if (y <= floor) {
2197
2409
  y = floor;
2410
+ if (-vy >= LANDING_MIN_SPEED) o.onFeet?.({ type: "land", speed: -vy });
2198
2411
  vy = 0;
2199
2412
  }
2200
2413
  }
@@ -3121,6 +3334,11 @@ var en = {
3121
3334
  "metric.fragments": "Fragments",
3122
3335
  "metric.particles": "Particles",
3123
3336
  "metric.decals": "Decals",
3337
+ "metric.geometries": "Geometries",
3338
+ "metric.textures": "Textures",
3339
+ "metric.heap_mb": "Heap MB",
3340
+ "memory.leak_short": "memory leak: +{geometries} geometries, +{textures} textures per {clears} clears",
3341
+ "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
3342
  "act.spawn": "Spawned {name} at x {x} \xB7 z {z}",
3125
3343
  "act.clear": "Cleared the room",
3126
3344
  "act.reset": "Repaired every object",
@@ -3167,8 +3385,11 @@ var en = {
3167
3385
  "act.throw": "Threw {name} at {speed} m/s",
3168
3386
  "ground.name": "Ground",
3169
3387
  "act.landing": "{name} landed at {speed} m/s: {energy} J",
3388
+ "act.player_land": "You landed at {speed} m/s",
3170
3389
  "act.bench_failed": "{name} failed: {error}",
3171
3390
  "loading.working": "Working\u2026",
3391
+ "loading.graphics": "Calibrating graphics\u2026",
3392
+ "ui.graphics": "Graphics",
3172
3393
  "loading.open_area": "Opening {area}\u2026",
3173
3394
  "loading.spawn": "Building {name}\u2026",
3174
3395
  "act.loosened": "{name} was heavy but free: the blast moved it",
@@ -3471,6 +3692,11 @@ var ptBR = {
3471
3692
  "metric.fragments": "Fragmentos",
3472
3693
  "metric.particles": "Part\xEDculas",
3473
3694
  "metric.decals": "Decalques",
3695
+ "metric.geometries": "Geometrias",
3696
+ "metric.textures": "Texturas",
3697
+ "metric.heap_mb": "Heap MB",
3698
+ "memory.leak_short": "vazamento de mem\xF3ria: +{geometries} geometrias, +{textures} texturas em {clears} limpezas",
3699
+ "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
3700
  "act.spawn": "Criou {name} em x {x} \xB7 z {z}",
3475
3701
  "act.clear": "Limpou a sala",
3476
3702
  "act.reset": "Consertou todos os objetos",
@@ -3517,8 +3743,11 @@ var ptBR = {
3517
3743
  "act.throw": "Arremessou {name} a {speed} m/s",
3518
3744
  "ground.name": "Ch\xE3o",
3519
3745
  "act.landing": "{name} caiu a {speed} m/s: {energy} J",
3746
+ "act.player_land": "Voc\xEA aterrissou a {speed} m/s",
3520
3747
  "act.bench_failed": "{name} falhou: {error}",
3521
3748
  "loading.working": "Trabalhando\u2026",
3749
+ "loading.graphics": "Calibrando os gr\xE1ficos\u2026",
3750
+ "ui.graphics": "Gr\xE1ficos",
3522
3751
  "loading.open_area": "Abrindo {area}\u2026",
3523
3752
  "loading.spawn": "Montando {name}\u2026",
3524
3753
  "act.loosened": "{name} \xE9 pesado mas estava solto: a explos\xE3o o moveu",
@@ -4110,6 +4339,7 @@ var CSS = `
4110
4339
  .tr-top{position:absolute;top:8px;left:50%;transform:translateX(-50%);text-align:center;max-width:46vw}
4111
4340
  .tr-tool{display:inline-block;background:rgba(10,12,16,.72);padding:4px 12px;border-radius:6px;font-size:14px;font-weight:600}
4112
4341
  .tr-tool small{font-weight:400;opacity:.8;margin-left:8px}
4342
+ .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
4343
  .tr-status{margin-top:4px;min-height:16px;color:#ffd23f;text-shadow:0 1px 2px #000}
4114
4344
  .tr-prog{margin:4px auto 0;width:260px;background:rgba(10,12,16,.72);border-radius:6px;padding:4px 8px;display:none}
4115
4345
  .tr-prog b{display:block;font-weight:500;margin-bottom:3px}
@@ -4228,13 +4458,14 @@ function createUi(o) {
4228
4458
  const top = mk3("div", "tr-top");
4229
4459
  const toolBox = mk3("div", "tr-tool");
4230
4460
  const status = mk3("div", "tr-status");
4461
+ const alertBox = mk3("div", "tr-alert");
4231
4462
  const prog = mk3("div", "tr-prog");
4232
4463
  const progLabel = mk3("b");
4233
4464
  const progBar = mk3("div", "tr-bar");
4234
4465
  const progFill = mk3("i");
4235
4466
  progBar.appendChild(progFill);
4236
4467
  prog.append(progLabel, progBar);
4237
- top.append(toolBox, status, prog);
4468
+ top.append(toolBox, alertBox, status, prog);
4238
4469
  const bottom = mk3("div", "tr-bottom");
4239
4470
  const held = mk3("div", "tr-held");
4240
4471
  const hotbar = mk3("div", "tr-hotbar");
@@ -4461,17 +4692,22 @@ function createUi(o) {
4461
4692
  fullscreenBtn.title = t("ui.fullscreen.hint");
4462
4693
  const blastBtns = ["shelter", "fragmentHits", "confinement"].map((key2) => ({ key: key2, b: ctl(t(`ui.blast.${key2}`), () => o.onBlast(key2)) }));
4463
4694
  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);
4695
+ if (o.onGraphics) {
4696
+ const g = ctl(t("ui.graphics"), () => o.onGraphics());
4697
+ g.dataset.testid = "graphics-button";
4698
+ } else {
4699
+ const qSel = mk3("select");
4700
+ for (const q of ["low", "medium", "high"]) {
4701
+ const op = mk3("option", void 0, t(`ui.quality.${q}`));
4702
+ op.value = q;
4703
+ qSel.appendChild(op);
4704
+ }
4705
+ qSel.value = o.quality;
4706
+ qSel.onchange = () => o.onQuality(qSel.value);
4707
+ const qLabel = mk3("label", void 0, t("ui.quality") + " ");
4708
+ qLabel.appendChild(qSel);
4709
+ controls.appendChild(qLabel);
4469
4710
  }
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
4711
  const count = mk3("span");
4476
4712
  controls.appendChild(count);
4477
4713
  cat.appendChild(controls);
@@ -4697,6 +4933,10 @@ function createUi(o) {
4697
4933
  renderInspector(s.inspect);
4698
4934
  }
4699
4935
  },
4936
+ alert(text) {
4937
+ alertBox.textContent = text ?? "";
4938
+ alertBox.style.display = text ? "block" : "none";
4939
+ },
4700
4940
  toast(text) {
4701
4941
  status.textContent = text;
4702
4942
  if (toastTimer) clearTimeout(toastTimer);
@@ -4733,7 +4973,14 @@ function createUi(o) {
4733
4973
 
4734
4974
  // src/room.ts
4735
4975
  var FIXED_DT = 1 / 60;
4976
+ var EXPLOSIVE_EFFECT_IDS = ["m67", "molotov", "pipe_bomb", "m79_40mm", "m72_66mm", "at4_84mm", "dynamite", "c4", "claymore"];
4977
+ var DEFAULT_PARTICLE_LIMIT = 900;
4978
+ var WARMED_FOG_STATES = ["none", "linear", "exp2"];
4979
+ var ROOM_LIGHT_SIZES = { point: 16, spot: 4, pointShadows: 0, hemisphere: 1 };
4736
4980
  var SOLID_SPAN = 3;
4981
+ var BATCH_MIN_MESHES = 8;
4982
+ var BATCH_BUDGET_MS = 1;
4983
+ var UNBATCHED_OWNERS = /vehicles|characters|animals|crowd/;
4737
4984
  var STEP_UP = 0.45;
4738
4985
  var BODY_HEIGHT2 = 1.75;
4739
4986
  var KICK_MASS = 6;
@@ -4801,6 +5048,17 @@ function createTestRoom(options) {
4801
5048
  const arenaSize = options.arenaSize ?? 14;
4802
5049
  const arena = createArena(THREE, arenaSize, scene, options.scenery, options.tap);
4803
5050
  scene.add(arena.group);
5051
+ const graphics = ownRenderer && options.graphics ? createRoomGraphics({
5052
+ gl,
5053
+ camera,
5054
+ sun: arena.sun,
5055
+ container,
5056
+ locale,
5057
+ loading,
5058
+ label: t("loading.graphics"),
5059
+ ui: options.ui !== false,
5060
+ options: options.graphics === true ? {} : options.graphics
5061
+ }) : null;
4804
5062
  const ownWorld = !options.world;
4805
5063
  const world = options.world ?? createDestruction({ seed: 1, ...options.worldOptions, materials: [...options.worldOptions?.materials ?? []] });
4806
5064
  const pristineWorld = ownWorld ? world.serialize() : null;
@@ -4846,12 +5104,17 @@ function createTestRoom(options) {
4846
5104
  const ownAudio = options.audio === void 0;
4847
5105
  const audio = options.audio === false ? null : options.audio ?? createAudioEngine({ deferredStart: {} });
4848
5106
  const destructionSound = audio ? createDestructionSound(audio) : null;
5107
+ const bodySounds = createBodySounds(audio);
4849
5108
  const playSound = (key2, point, volume) => {
4850
5109
  destructionSound?.onSound(key2, point, volume);
4851
5110
  options.onSound?.(key2, point, volume);
4852
5111
  };
5112
+ const lights = createLightBudget(THREE, scene, { ...ROOM_LIGHT_SIZES, ...options.lightSizes });
5113
+ const warmup = createProgramWarmup(THREE);
5114
+ const pools = { geometries: createGeometryPool(), materials: createMaterialPool({ anchors: warmup.anchors, renderer: gl }) };
4853
5115
  const vfx = createVfxRenderer(THREE, {
4854
5116
  scene,
5117
+ lights,
4855
5118
  camera,
4856
5119
  overlay: container,
4857
5120
  quality,
@@ -5005,7 +5268,7 @@ function createTestRoom(options) {
5005
5268
  });
5006
5269
  let pending = [];
5007
5270
  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() };
5271
+ 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
5272
  const fire = (type, data) => {
5010
5273
  for (const fn of listeners[type] ?? []) fn(data);
5011
5274
  };
@@ -5142,6 +5405,14 @@ function createTestRoom(options) {
5142
5405
  floorAt: terrain.heightAt,
5143
5406
  start: arena.playerStart,
5144
5407
  crouchKeys: options.crouch?.keys,
5408
+ onFeet: (event) => {
5409
+ const at = player.position;
5410
+ if (event.type === "jump") bodySounds.jump(at);
5411
+ else {
5412
+ bodySounds.land(surfaceUnderFeet(), event.speed, at);
5413
+ action("landing_player", t("act.player_land", { speed: num(event.speed, 1) }), { data: { speedMs: Math.round(event.speed * 100) / 100, surface: surfaceUnderFeet() } });
5414
+ }
5415
+ },
5145
5416
  hooks: {
5146
5417
  primaryDown: () => {
5147
5418
  useHeld = true;
@@ -5318,12 +5589,23 @@ function createTestRoom(options) {
5318
5589
  for (const p of st?.parts ?? []) if (p.status !== "broken" && p.status !== "melted" && p.status !== "ashes") m += p.mass;
5319
5590
  return m;
5320
5591
  };
5592
+ const batchers = /* @__PURE__ */ new Map();
5593
+ const batchObject = (objectId, root) => {
5594
+ let meshes = 0;
5595
+ let unbatched = false;
5596
+ root.traverse((object) => {
5597
+ if (object.isMesh) meshes++;
5598
+ if (UNBATCHED_OWNERS.test(object.userData.kitOrigin?.owner ?? "")) unbatched = true;
5599
+ });
5600
+ if (!unbatched && meshes >= BATCH_MIN_MESHES) batchers.set(objectId, createStaticBatcher(THREE, root, { budgetMs: BATCH_BUDGET_MS }));
5601
+ };
5321
5602
  const registerEntry = (objectId, itemId, root, spec, name, parts, item, arenaObject, movable) => {
5322
5603
  specs.set(objectId, spec);
5323
5604
  renderer.bind(objectId, root, parts ? { parts } : void 0);
5324
5605
  const so = { objectId, itemId, root, movable };
5325
5606
  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
5607
  entries.set(objectId, e);
5608
+ batchObject(objectId, root);
5327
5609
  if (movable) {
5328
5610
  e.piece = renderer.makePiece(objectId, { mass: Math.max(0.2, massOfObject(objectId)) });
5329
5611
  rendererPieces.set(objectId, e.piece);
@@ -5591,6 +5873,8 @@ function createTestRoom(options) {
5591
5873
  dropTree(e.so.root);
5592
5874
  dropTree(inner);
5593
5875
  entries.delete(id);
5876
+ batchers.get(id)?.dispose();
5877
+ batchers.delete(id);
5594
5878
  doors.forget(id);
5595
5879
  rendererPieces.delete(id);
5596
5880
  specs.delete(id);
@@ -5689,7 +5973,7 @@ function createTestRoom(options) {
5689
5973
  lastImpact.clear();
5690
5974
  addArena();
5691
5975
  standFootprints();
5692
- timeScale = 1;
5976
+ scheduler.timeScale = 1;
5693
5977
  fastForward = false;
5694
5978
  walkPlan = null;
5695
5979
  };
@@ -5890,6 +6174,28 @@ function createTestRoom(options) {
5890
6174
  }
5891
6175
  };
5892
6176
  const rayWorld = (o, d, max) => withoutHeld(() => world.raycast(o, d, max));
6177
+ const surfaceUnderFeet = () => {
6178
+ const [x, feet, z] = player.position;
6179
+ const under = rayWorld([x, feet + 0.15, z], [0, -1, 0], 0.5);
6180
+ if (under && under.object !== GROUND_ID) {
6181
+ const material = world.state(under.object)?.parts.find((p) => p.id === under.part)?.material;
6182
+ if (material) return surfaceOfMaterial(material);
6183
+ }
6184
+ return surfaceOfMaterial(terrain.columnAt(x, z).material);
6185
+ };
6186
+ const strides = createStrideCounter();
6187
+ let feetAt = null;
6188
+ const stepFeet = (dt) => {
6189
+ const [x, , z] = player.position;
6190
+ const moved = feetAt && dt > 0 ? Math.hypot(x - feetAt[0], z - feetAt[1]) : 0;
6191
+ feetAt = [x, z];
6192
+ if (dt <= 0 || seating.seat) return;
6193
+ const gait = player.running ? "run" : "walk";
6194
+ const foot = avatar?.footfall ? avatar.footfall() : moved / dt > MAX_STRIDE_SPEED ? null : strides.advance(moved, gait);
6195
+ if (!foot || !player.grounded) return;
6196
+ emitHost({ type: "footfall", foot, surface: surfaceUnderFeet(), gait, position: player.position });
6197
+ fire("footfall", { foot, gait, time: simTime });
6198
+ };
5893
6199
  let rng = 12345;
5894
6200
  const rnd = () => {
5895
6201
  rng = rng * 1664525 + 1013904223 >>> 0;
@@ -6008,6 +6314,12 @@ function createTestRoom(options) {
6008
6314
  ui?.toast(msg);
6009
6315
  };
6010
6316
  const toolName = (id = toolId) => arsenal.name(id);
6317
+ const toolDef = (id) => arsenal.weapons.get(id);
6318
+ const blowTarget = (r) => {
6319
+ const part = world.state(r.object)?.parts.find((p) => p.id === r.part);
6320
+ if (!part || !(world.materials.get(part.material)?.tags ?? []).includes("body")) return "object";
6321
+ return /head|face|jaw|skull|neck/.test(r.part) ? "head" : "body";
6322
+ };
6011
6323
  const hitWhere = (r) => {
6012
6324
  const p = world.part(r.object, r.part);
6013
6325
  return `${nameOf(r.object)} \u203A ${r.part}${p ? ` (${dStrings.materialNames[p.material] ?? p.material})` : ""}`;
@@ -6017,6 +6329,7 @@ function createTestRoom(options) {
6017
6329
  renderer.impulse(objectId, [dir[0] * momentum, dir[1] * momentum, dir[2] * momentum], point);
6018
6330
  };
6019
6331
  const shotRay = new THREE.Raycaster();
6332
+ shotRay.layers.enableAll();
6020
6333
  const LIQUID_SLOWING = 0.25;
6021
6334
  const SHOT_GRIP = 0.3;
6022
6335
  const shotPush = (s, objectId, results, fallback) => {
@@ -6187,6 +6500,8 @@ function createTestRoom(options) {
6187
6500
  return;
6188
6501
  }
6189
6502
  world.hitPart(r.object, r.part, { ...useToHit(u, r.point, r.normal), source: "player" });
6503
+ const blow = blowOfTool(id, toolDef(id)?.category ?? "");
6504
+ if (blow) bodySounds.hit(blow, blowTarget(r), r.point, u.energy);
6190
6505
  pushPiece(r.object, u.direction, 2 * u.energy / 6, r.point);
6191
6506
  action("melee", t("act.melee.hit", { weapon: w, energy: num(u.energy, 0), where: hitWhere(r), dist: num(r.distance, 1) }), {
6192
6507
  objectId: r.object,
@@ -6227,6 +6542,10 @@ function createTestRoom(options) {
6227
6542
  comboNext = (stepIndex + 1) % combo.steps.length;
6228
6543
  comboAt = simTime;
6229
6544
  }
6545
+ const blow = blowOfTool(id, toolDef(id)?.category ?? "");
6546
+ const lead = blow ? swingLead(blow) : 0;
6547
+ const swing = blow ? () => bodySounds.swing(blow, player.position) : void 0;
6548
+ if (swing && (seconds === null || seconds <= lead)) swing();
6230
6549
  if (clip === void 0 || seconds === null) {
6231
6550
  if (leg) {
6232
6551
  legAim();
@@ -6241,6 +6560,7 @@ function createTestRoom(options) {
6241
6560
  strikes.push({
6242
6561
  key: id,
6243
6562
  remaining: seconds,
6563
+ ...swing && seconds > lead ? { swing, lead } : {},
6244
6564
  land: () => {
6245
6565
  if (leg) legAim();
6246
6566
  else aim();
@@ -6250,7 +6570,13 @@ function createTestRoom(options) {
6250
6570
  };
6251
6571
  const stepStrikes = (dt) => {
6252
6572
  if (strikes.length === 0) return;
6253
- for (const strike of strikes) strike.remaining -= dt;
6573
+ for (const strike of strikes) {
6574
+ strike.remaining -= dt;
6575
+ if (strike.swing && strike.remaining <= (strike.lead ?? 0)) {
6576
+ strike.swing();
6577
+ delete strike.swing;
6578
+ }
6579
+ }
6254
6580
  const landed = strikes.filter((strike) => strike.remaining <= 0);
6255
6581
  strikes = strikes.filter((strike) => strike.remaining > 0);
6256
6582
  for (const strike of landed) strike.land();
@@ -6364,26 +6690,27 @@ function createTestRoom(options) {
6364
6690
  aim();
6365
6691
  const cat = tool.def.category;
6366
6692
  if (cat === "hand") {
6367
- if (strikes.some((strike) => strike.key === "hand" || strike.key === "fists")) return;
6693
+ if (strikes.some((strike) => strike.key === "hand" || strike.key === "fists")) return "cooldown";
6368
6694
  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;
6695
+ return "ok";
6370
6696
  }
6371
- if (strikes.some((strike) => strike.key === toolId)) return;
6697
+ if (strikes.some((strike) => strike.key === toolId)) return "cooldown";
6372
6698
  const res = tool.trigger([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], dt, held, "player");
6373
6699
  flushEvents(toolId, tool, res.recoil);
6374
6700
  if (res.status === "empty") {
6375
6701
  if (!tool.reloading && tool.reserve > 0) startReload();
6376
6702
  say(t(tool.reloading ? "hud.reloading" : "hud.empty"));
6377
- return;
6703
+ return res.status;
6378
6704
  }
6379
6705
  if (res.status !== "ok") {
6380
6706
  if (res.status === "reloading") say(t("hud.reloading"));
6381
6707
  else if (res.status === "no_fuel") say(t("hud.no_fuel"));
6382
6708
  else if (res.status === "fault" && res.fault) say(t("hud.fault", { fault: t(`fault.${res.fault}`) }));
6383
6709
  else if (res.status === "safe") say(t("hud.safe"));
6384
- return;
6710
+ return res.status;
6385
6711
  }
6386
6712
  applyUses(res, dt);
6713
+ return res.status;
6387
6714
  };
6388
6715
  const applyUses = (res, dt) => {
6389
6716
  if (res.shots.length) fireShots(res.shots, res.recoil);
@@ -6485,8 +6812,9 @@ function createTestRoom(options) {
6485
6812
  heldTilt = 0;
6486
6813
  };
6487
6814
  const handUse = () => {
6488
- if (heldPiece) grab();
6489
- else useTool(FIXED_DT, false);
6815
+ if (!heldPiece) return useTool(FIXED_DT, false);
6816
+ grab();
6817
+ return "ok";
6490
6818
  };
6491
6819
  const grab = () => {
6492
6820
  if (push.active) return false;
@@ -6999,13 +7327,64 @@ function createTestRoom(options) {
6999
7327
  distance: r.distance
7000
7328
  };
7001
7329
  };
7002
- let timeScale = 1;
7003
7330
  let fastForward = false;
7004
7331
  let hudAcc = 1;
7005
7332
  let metricsAcc = 0;
7006
7333
  let ownsLoop = false;
7334
+ let moduleFrameCount = 0;
7335
+ const headlessErrors = /* @__PURE__ */ new Map();
7336
+ const loggedErrors = /* @__PURE__ */ new Set();
7337
+ let headlessDepth = 0;
7338
+ const reportFrameError = (error, system) => {
7339
+ if (headlessDepth > 0) {
7340
+ if (!headlessErrors.has(system.id)) headlessErrors.set(system.id, error);
7341
+ return;
7342
+ }
7343
+ if (loggedErrors.has(system.id)) return;
7344
+ loggedErrors.add(system.id);
7345
+ console.error(`test-room: ${system.id} (${system.phase}) threw`, error);
7346
+ };
7347
+ const runHeadless = (run) => {
7348
+ headlessDepth++;
7349
+ try {
7350
+ run();
7351
+ } catch (error) {
7352
+ headlessErrors.clear();
7353
+ throw error;
7354
+ } finally {
7355
+ headlessDepth--;
7356
+ }
7357
+ if (headlessDepth > 0 || headlessErrors.size === 0) return;
7358
+ const errors = [...headlessErrors.values()];
7359
+ headlessErrors.clear();
7360
+ throw errors.length === 1 ? errors[0] : new AggregateError(errors, `${errors.length} systems threw in one frame`);
7361
+ };
7362
+ const scheduler = createScheduler({ maxFrameSeconds: 0.05, budgetMs: options.frameBudgetMs ?? 8, onError: reportFrameError });
7363
+ const audioQueue = audio?.deferredQueue?.();
7364
+ if (audioQueue) scheduler.budget.register(audioQueue, DEFERRED_PRIORITY[DEFERRED_QUEUE_ID.AUDIO_VOICES]);
7365
+ scheduler.budget.register(renderer.deferredQueue(), DEFERRED_PRIORITY[DEFERRED_QUEUE_ID.DESTRUCTION_EVENTS]);
7007
7366
  const roomMetricIds = options.metrics === false ? [] : options.metrics ?? ROOM_METRICS;
7008
7367
  const roomMetrics = createRoomMetrics({ gl, destruction: renderer, fluids: fluidView, effects: vfx.world, ids: roomMetricIds, blast: () => lastBlast });
7368
+ const MEMORY_CLEARS = 4;
7369
+ const memoryWatch = createMemoryWatch({
7370
+ clears: MEMORY_CLEARS,
7371
+ read: () => {
7372
+ const memory = gl.info?.memory;
7373
+ const heap = heapMB();
7374
+ return memory ? { geometries: memory.geometries, textures: memory.textures, ...heap === void 0 ? {} : { heapMB: heap } } : void 0;
7375
+ }
7376
+ });
7377
+ clearListeners.add(() => {
7378
+ const verdict = memoryWatch.cleared(activeBench ?? void 0);
7379
+ if (!verdict) return;
7380
+ if (!verdict.leaking) {
7381
+ ui?.alert(null);
7382
+ return;
7383
+ }
7384
+ const text = t("memory.leak", { geometries: verdict.grew.geometries, textures: verdict.grew.textures, clears: MEMORY_CLEARS });
7385
+ log.add({ channel: "event", type: "memory_leak", text, data: { ...verdict.after, grew: verdict.grew } });
7386
+ ui?.alert(`\u26A0 ${text}`);
7387
+ });
7009
7388
  let qualityName = quality;
7010
7389
  const syncContainers = (sdt) => {
7011
7390
  const drinkingHeld = useItemPlayer !== null && (useItemName === "drink" || useItemName === "sip" || useItemName === "chug");
@@ -7031,7 +7410,6 @@ function createTestRoom(options) {
7031
7410
  if (part && part.liters !== void 0 && Math.abs(part.liters - c.liters) > 1e-4) world.setLiters(host2.objectId, e.fluidPart, c.liters);
7032
7411
  }
7033
7412
  };
7034
- let paused = false;
7035
7413
  let walkPlan = null;
7036
7414
  const stepWalk = (dt) => {
7037
7415
  if (!walkPlan) return;
@@ -7040,8 +7418,7 @@ function createTestRoom(options) {
7040
7418
  walkPlan.remaining -= dt;
7041
7419
  if (walkPlan.remaining <= 1e-9) walkPlan = null;
7042
7420
  };
7043
- const stepSim = (dt) => {
7044
- if (paused) return;
7421
+ const simulateBeforeModules = (dt) => {
7045
7422
  simTime += dt;
7046
7423
  stepWalk(dt);
7047
7424
  stepStrikes(dt);
@@ -7073,7 +7450,8 @@ function createTestRoom(options) {
7073
7450
  const look = camera.getWorldDirection(listenerDirection);
7074
7451
  audio.setListener({ position: [camera.position.x, camera.position.y, camera.position.z], yaw: Math.atan2(-look.x, -look.z) });
7075
7452
  }
7076
- for (const update2 of frameListeners) update2(dt);
7453
+ };
7454
+ const simulateAfterModules = (dt) => {
7077
7455
  for (const tr of tracers) {
7078
7456
  if (tr.life > 0) {
7079
7457
  tr.life -= dt;
@@ -7082,7 +7460,7 @@ function createTestRoom(options) {
7082
7460
  }
7083
7461
  flush();
7084
7462
  };
7085
- const update = (dt) => {
7463
+ const updateInput = (dt, isPaused) => {
7086
7464
  if (!ownsLoop) roomMetrics.frame(dt);
7087
7465
  frameStamp++;
7088
7466
  seating.update();
@@ -7091,23 +7469,25 @@ function createTestRoom(options) {
7091
7469
  pushByKey = false;
7092
7470
  push.end();
7093
7471
  }
7094
- push.step(paused ? 0 : dt);
7095
- doors.step(paused ? 0 : dt);
7096
- if (!paused) kickLight(dt);
7097
- if (!paused) pushByPlayer(dt);
7472
+ push.step(isPaused ? 0 : dt);
7473
+ doors.step(isPaused ? 0 : dt);
7474
+ if (!isPaused) kickLight(dt);
7475
+ if (!isPaused) pushByPlayer(dt);
7098
7476
  else player.takeWish();
7099
- stepAvatar(paused ? 0 : dt);
7477
+ stepAvatar(isPaused ? 0 : dt);
7478
+ stepFeet(isPaused ? 0 : dt);
7100
7479
  if (tiltKey || heldTilt > 0) {
7101
7480
  heldTilt = Math.max(0, Math.min(2.4, heldTilt + (tiltKey ? 2.2 : -3) * dt));
7102
7481
  }
7103
- if ((useHeld || useFirst) && !paused && !push.active) {
7482
+ if ((useHeld || useFirst) && !isPaused && !push.active) {
7104
7483
  if (tool.def.category === "hand") {
7105
7484
  if (useFirst) handUse();
7106
7485
  } else if (useHeld) useTool(dt, !useFirst);
7107
7486
  useFirst = false;
7108
7487
  }
7109
- if (!paused) followHeld(dt);
7110
- stepSim(dt * timeScale);
7488
+ if (!isPaused) followHeld(dt);
7489
+ };
7490
+ const updatePresent = (dt) => {
7111
7491
  reachAvatar();
7112
7492
  if (progress && simTime - progress.at > 0.25) progress = null;
7113
7493
  hudAcc += dt;
@@ -7140,7 +7520,7 @@ function createTestRoom(options) {
7140
7520
  inspect: inspectInfo(),
7141
7521
  animations: animationsOf(),
7142
7522
  locked: player.locked,
7143
- timeScale,
7523
+ timeScale: scheduler.timeScale,
7144
7524
  fast: fastForward,
7145
7525
  tap: isTapOn(),
7146
7526
  combo: combo ? comboOn : null,
@@ -7292,6 +7672,8 @@ function createTestRoom(options) {
7292
7672
  const value = sampled[id];
7293
7673
  return value === void 0 ? [] : [{ id, name: t(`metric.${id}`), text: num(value, id === "frame_ms" ? 1 : 0) }];
7294
7674
  });
7675
+ const leak = memoryWatch.last;
7676
+ 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
7677
  const values = moduleMetrics();
7296
7678
  const declared = (activeBench ? benches.get(activeBench)?.metrics : void 0) ?? [];
7297
7679
  const module = declared.map((metric) => {
@@ -7307,7 +7689,6 @@ function createTestRoom(options) {
7307
7689
  ui.metrics(metricRows());
7308
7690
  };
7309
7691
  const hostListeners = /* @__PURE__ */ new Set();
7310
- const frameListeners = /* @__PURE__ */ new Set();
7311
7692
  const listenerDirection = new THREE.Vector3();
7312
7693
  const environment = options.environment ?? createEnvironmentBus();
7313
7694
  const audioSink = audio ? createAudioSink(audio) : void 0;
@@ -7360,10 +7741,16 @@ function createTestRoom(options) {
7360
7741
  scene,
7361
7742
  camera,
7362
7743
  locale,
7744
+ ...graphics ? { graphics: graphics.host } : {},
7363
7745
  ...audioSink ? { audio: audioSink } : {},
7364
7746
  environment,
7365
7747
  groundAt,
7748
+ // the terrain's material where the floor is the top; a table or a counter answers undefined (the module's default)
7749
+ surfaceAt: (x, z) => Math.max(sceneryTopAt(x, z), restingTopAt(x, z)) > terrain.heightAt(x, z) ? void 0 : terrain.columnAt(x, z).material,
7366
7750
  effects: vfx,
7751
+ particleLimit: () => options.debris?.particleLimit ?? DEFAULT_PARTICLE_LIMIT,
7752
+ lights,
7753
+ pools,
7367
7754
  loading,
7368
7755
  ...options.blastShove ? { blastShove: options.blastShove } : {},
7369
7756
  tool: () => ({
@@ -7415,9 +7802,9 @@ function createTestRoom(options) {
7415
7802
  hostListeners.add(listener);
7416
7803
  return () => hostListeners.delete(listener);
7417
7804
  },
7418
- onFrame(update2) {
7419
- frameListeners.add(update2);
7420
- return () => frameListeners.delete(update2);
7805
+ defer: (queue, queueOptions) => scheduler.budget.register(queue, queueOptions),
7806
+ onFrame(update, frameOptions) {
7807
+ return scheduler.add((ctx) => update(ctx.dt), { phase: frameOptions?.phase ?? "lateSimulate", order: frameOptions?.order ?? 0, id: `module.frame#${moduleFrameCount++}` });
7421
7808
  },
7422
7809
  holdFrame: () => bodyHoldFrame(),
7423
7810
  reachFor(reach) {
@@ -7475,46 +7862,47 @@ function createTestRoom(options) {
7475
7862
  return r.ok ? store : null;
7476
7863
  };
7477
7864
  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++;
7865
+ const materialsOf = (group) => {
7866
+ const found = /* @__PURE__ */ new Set();
7867
+ group.traverse((object) => {
7868
+ const material = object.material;
7869
+ for (const each of Array.isArray(material) ? material : material ? [material] : []) found.add(each);
7870
+ });
7871
+ return [...found];
7872
+ };
7873
+ const probeOf = (id, adapter) => ({
7874
+ id,
7875
+ build() {
7876
+ const probe = adapter.warmUpProbes();
7877
+ const group = probe.group;
7878
+ return { group, materials: materialsOf(group), dispose: () => probe.dispose() };
7484
7879
  }
7880
+ });
7881
+ warmup.register(probeOf("destruction", renderer));
7882
+ warmup.register(probeOf("vfx", vfx));
7883
+ warmup.register(probeOf("fluids", fluidView));
7884
+ warmup.register(spriteProbes(THREE));
7885
+ const warmedEffect = (id) => {
7886
+ let handle = null;
7887
+ return {
7888
+ id: `vfx:${id}`,
7889
+ play() {
7890
+ handle = vfx.play({ effect: id, point: [0, 0.1, -4], direction: [0, 1, 0], normal: [0, 1, 0] });
7891
+ },
7892
+ step: (dt) => vfx.update(dt),
7893
+ stop() {
7894
+ if (handle !== null) vfx.stop(handle);
7895
+ vfx.world.clear();
7896
+ vfx.update(0);
7897
+ }
7898
+ };
7485
7899
  };
7900
+ const catalogIds = vfx.catalog?.() ?? [];
7901
+ 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));
7902
+ let warmedUp = false;
7486
7903
  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);
7904
+ warmup.warmUp(gl, scene, camera, { lights, fogStates: WARMED_FOG_STATES, materials: pools.materials.registeredMaterials?.() ?? [] });
7905
+ warmedUp = true;
7518
7906
  };
7519
7907
  const loadModule = (mod) => {
7520
7908
  const report = { module: mod.id, items: [], scenarios: [], ok: true, problems: [] };
@@ -7626,24 +8014,13 @@ function createTestRoom(options) {
7626
8014
  };
7627
8015
  const allScenarios = () => [...extraScenarios];
7628
8016
  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
- };
8017
+ const step = (seconds) => runHeadless(() => scheduler.advance(seconds, { dt: seconds > 6 ? 1 / 30 : FIXED_DT }));
7641
8018
  const runScenarios = async (list) => {
7642
8019
  const all = list ?? allScenarios();
7643
8020
  const report = { passed: 0, failed: 0, results: [] };
7644
8021
  scenarioRunning = true;
7645
- const wasPaused = paused;
7646
- paused = false;
8022
+ const wasPaused = scheduler.paused;
8023
+ scheduler.resume();
7647
8024
  try {
7648
8025
  for (const sc of all) {
7649
8026
  hardReset();
@@ -7675,7 +8052,7 @@ function createTestRoom(options) {
7675
8052
  }
7676
8053
  } finally {
7677
8054
  scenarioRunning = false;
7678
- paused = wasPaused;
8055
+ if (wasPaused) scheduler.pause();
7679
8056
  restoreScenery();
7680
8057
  }
7681
8058
  return report;
@@ -7807,19 +8184,25 @@ function createTestRoom(options) {
7807
8184
  useItem,
7808
8185
  use(hold = 0) {
7809
8186
  if (tool.def.category === "hand") {
7810
- handUse();
8187
+ const status2 = handUse();
7811
8188
  flush();
7812
- return;
8189
+ return status2;
7813
8190
  }
7814
- useTool(FIXED_DT, false);
8191
+ const status = useTool(FIXED_DT, false);
7815
8192
  flush();
7816
8193
  let t0 = 0;
7817
8194
  while (t0 < hold) {
7818
8195
  useTool(FIXED_DT, true);
7819
- stepSim(FIXED_DT);
8196
+ directStep = true;
8197
+ try {
8198
+ runHeadless(() => scheduler.advance(FIXED_DT));
8199
+ } finally {
8200
+ directStep = false;
8201
+ }
7820
8202
  t0 += FIXED_DT;
7821
8203
  }
7822
8204
  releaseTrigger();
8205
+ return status;
7823
8206
  },
7824
8207
  grab,
7825
8208
  throwHeld,
@@ -7835,14 +8218,14 @@ function createTestRoom(options) {
7835
8218
  spawned() {
7836
8219
  return [...entries.values()].filter((e) => !e.arena).map((e) => e.so);
7837
8220
  },
7838
- update,
8221
+ update: (dt) => runHeadless(() => scheduler.frame(dt, { render: false, exact: true })),
7839
8222
  waitUntilReady(maxSeconds = 5) {
7840
8223
  let waited = 0;
7841
8224
  while (tool.phase && waited < maxSeconds) {
7842
- update(FIXED_DT);
8225
+ room.update(FIXED_DT);
7843
8226
  waited += FIXED_DT;
7844
8227
  }
7845
- update(FIXED_DT);
8228
+ room.update(FIXED_DT);
7846
8229
  return waited + FIXED_DT;
7847
8230
  },
7848
8231
  on(type, fn) {
@@ -7859,20 +8242,20 @@ function createTestRoom(options) {
7859
8242
  blastOptions = { ...blastOptions, ...next };
7860
8243
  },
7861
8244
  pause() {
7862
- paused = true;
8245
+ scheduler.pause();
7863
8246
  },
7864
8247
  resume() {
7865
- paused = false;
8248
+ scheduler.resume();
7866
8249
  },
7867
8250
  get paused() {
7868
- return paused;
8251
+ return scheduler.paused;
7869
8252
  },
7870
8253
  walk(to, seconds) {
7871
8254
  walkPlan = seconds > 0 ? { x: to[0], z: to[2], remaining: seconds } : null;
7872
8255
  if (!walkPlan) player.moveBy(to[0] - camera.position.x, to[2] - camera.position.z);
7873
8256
  },
7874
8257
  setSlowMotion(on) {
7875
- timeScale = on ? 0.25 : 1;
8258
+ scheduler.timeScale = on ? 0.25 : 1;
7876
8259
  },
7877
8260
  setFastForward(on) {
7878
8261
  fastForward = on;
@@ -7888,12 +8271,13 @@ function createTestRoom(options) {
7888
8271
  modules: () => [...modules.values()].map((m) => ({ id: m.id, name: m.name, group: m.group, hasArea: typeof m.area === "function" })),
7889
8272
  openAreaAsync: (area) => loading.busy(t("loading.open_area", { area: areaLabel(area) }), () => openArea(area)),
7890
8273
  loading,
8274
+ graphics: graphics?.controller ?? null,
8275
+ graphicsScreen: graphics?.screen ?? null,
7891
8276
  busy: (label, work, busyOptions) => loading.busy(label, work, busyOptions),
7892
8277
  loadModule,
7893
8278
  warmUp: () => {
7894
8279
  audio?.warmUp();
7895
8280
  warmUp();
7896
- warmUpEffects();
7897
8281
  },
7898
8282
  animations: animationsOf,
7899
8283
  playAnimation,
@@ -7965,6 +8349,9 @@ function createTestRoom(options) {
7965
8349
  metrics() {
7966
8350
  return { room: roomMetrics.sample(), module: moduleMetrics() };
7967
8351
  },
8352
+ frameStats: () => scheduler.stats(),
8353
+ lights,
8354
+ webgl: gl,
7968
8355
  dispose() {
7969
8356
  destructionSound?.dispose();
7970
8357
  if (ownAudio) audio?.dispose();
@@ -7972,13 +8359,19 @@ function createTestRoom(options) {
7972
8359
  detachers.clear();
7973
8360
  for (const store of benches.values()) store.dispose();
7974
8361
  benches.clear();
7975
- cancelAnimationFrame?.(raf);
8362
+ stopLoop?.();
8363
+ disposePacer?.();
8364
+ scheduler.dispose();
7976
8365
  window.removeEventListener("resize", onResize);
7977
8366
  player.dispose();
7978
8367
  ui?.dispose();
8368
+ graphics?.dispose();
7979
8369
  loading.dispose();
7980
8370
  renderer.dispose();
7981
8371
  vfx.dispose();
8372
+ lights.dispose();
8373
+ pools.materials.dispose();
8374
+ pools.geometries.dispose();
7982
8375
  fluidView.dispose();
7983
8376
  filmBridge.dispose();
7984
8377
  film.dispose();
@@ -8044,6 +8437,7 @@ function createTestRoom(options) {
8044
8437
  }
8045
8438
  },
8046
8439
  quality: qualityName,
8440
+ ...graphics?.screen ? { onGraphics: () => graphics.screen?.open() } : {},
8047
8441
  scenarios: allScenarios,
8048
8442
  onRun: async (sc) => {
8049
8443
  await runScenarios([sc]);
@@ -8077,41 +8471,76 @@ function createTestRoom(options) {
8077
8471
  ui.hud(hudState());
8078
8472
  showBench();
8079
8473
  }
8080
- let raf = 0;
8081
- if (ownRenderer && typeof requestAnimationFrame === "function") {
8082
- let last = performance.now();
8083
- ownsLoop = true;
8474
+ let directStep = false;
8475
+ scheduler.add(
8476
+ (ctx) => {
8477
+ if (directStep) return;
8478
+ if (!ctx.scripted) return updateInput(ctx.dt, ctx.paused);
8479
+ seating.update();
8480
+ player.update(0);
8481
+ push.step(ctx.dt);
8482
+ doors.step(ctx.dt);
8483
+ followHeld(ctx.dt);
8484
+ },
8485
+ { phase: "input", id: "room.input" }
8486
+ );
8487
+ scheduler.add((ctx) => simulateBeforeModules(ctx.dt), { phase: "lateSimulate", order: -1e3, id: "room.simulate" });
8488
+ scheduler.add((ctx) => simulateAfterModules(ctx.dt), { phase: "lateSimulate", order: 1e3, id: "room.simulate-end" });
8489
+ scheduler.add(
8490
+ (ctx) => {
8491
+ if (ctx.simulated && !ctx.scripted) updatePresent(ctx.realDt);
8492
+ },
8493
+ { phase: "present", id: "room.present" }
8494
+ );
8495
+ scheduler.add((ctx) => lights.present([camera.position.x, camera.position.y, camera.position.z], ctx.realDt), { phase: "present", order: 10, id: "room.lights" });
8496
+ let stopLoop;
8497
+ let disposePacer;
8498
+ if (ownRenderer) {
8084
8499
  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) {
8500
+ let warnedState = "";
8501
+ scheduler.add(
8502
+ (ctx) => {
8503
+ if (ctx.scripted) return;
8504
+ const now = performance.now();
8505
+ if (now < nextLightsCheck) return;
8096
8506
  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);
8507
+ const state = lights.check(gl, scene);
8508
+ if (warmedUp && (state.ok || state.actual === warnedState)) return;
8509
+ warnedState = state.actual;
8510
+ warmUp();
8511
+ },
8512
+ { phase: "render", order: -1e3, id: "room.lights-check" }
8513
+ );
8514
+ const pacer = createFramePacer(typeof gl.getContext === "function" ? gl.getContext() : null, options.framesInFlight === void 0 ? {} : { framesInFlight: options.framesInFlight });
8515
+ disposePacer = () => pacer.dispose();
8516
+ scheduler.add(
8517
+ (ctx) => {
8518
+ if (!ctx.scripted && !pacer.ready()) return;
8519
+ for (const batcher of batchers.values()) batcher.update();
8520
+ const restoreCamera = room.applyCamera();
8521
+ graphics?.beforeRender(camera);
8522
+ gl.render(scene, debugView ?? camera);
8523
+ graphics?.afterRender();
8524
+ restoreCamera();
8525
+ pacer.submitted();
8526
+ roomMetrics.rendered();
8527
+ if (!ctx.scripted) graphics?.frameEnd();
8528
+ },
8529
+ { phase: "render", id: "room.render" }
8530
+ );
8531
+ try {
8532
+ stopLoop = startLoop(scheduler, {
8533
+ onFrame: (realSeconds) => {
8534
+ graphics?.frameStart();
8535
+ roomMetrics.frame(realSeconds);
8536
+ },
8537
+ skipSimulation: () => scenarioRunning,
8538
+ // the player's frame cap (Gráficos → Limite de FPS)
8539
+ ...graphics ? { minFrameMs: () => graphics.controller.minFrameMs() } : {}
8540
+ });
8541
+ ownsLoop = true;
8542
+ } catch {
8543
+ }
8115
8544
  }
8116
8545
  if (options.initial) {
8117
8546
  for (const [i, id] of options.initial.entries()) spawn(id, [(i - (options.initial.length - 1) / 2) * 1.3, 0, 0.5]);