@adatechnology/game-test-room 0.19.1 → 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
  }
@@ -2954,7 +3167,7 @@ function createRoomDoors(options) {
2954
3167
  // src/room.ts
2955
3168
  import { releaseObject3D as releaseObject3D2 } from "@adatechnology/game-shapes-three";
2956
3169
  import { createTerrain, createTerrainImpl, DEFAULT_PROFILE } from "@adatechnology/game-terrain";
2957
- import { createTerrainRenderer } from "@adatechnology/game-terrain-three";
3170
+ import { createTerrainRenderer, terrainProfileName } from "@adatechnology/game-terrain-three";
2958
3171
 
2959
3172
  // src/avatar.ts
2960
3173
  var DEFAULT_STRIKE_CLIPS = { hand: "punch_right", fists: "punch_right", shove: "shove_two_hands", kick: "front_kick" };
@@ -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",
@@ -3325,6 +3546,8 @@ var en = {
3325
3546
  "ins.damage": "Marks",
3326
3547
  "ins.damage_counts": "{dents}, {holes}, {cracks}",
3327
3548
  "ins.mass": "Mass",
3549
+ "ins.dug": "Dug",
3550
+ "ins.dig_limit": "Ground left to dig",
3328
3551
  "ins.load": "Load",
3329
3552
  "ins.stress": "Stress",
3330
3553
  "ins.joints": "Joints",
@@ -3469,6 +3692,11 @@ var ptBR = {
3469
3692
  "metric.fragments": "Fragmentos",
3470
3693
  "metric.particles": "Part\xEDculas",
3471
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).",
3472
3700
  "act.spawn": "Criou {name} em x {x} \xB7 z {z}",
3473
3701
  "act.clear": "Limpou a sala",
3474
3702
  "act.reset": "Consertou todos os objetos",
@@ -3515,8 +3743,11 @@ var ptBR = {
3515
3743
  "act.throw": "Arremessou {name} a {speed} m/s",
3516
3744
  "ground.name": "Ch\xE3o",
3517
3745
  "act.landing": "{name} caiu a {speed} m/s: {energy} J",
3746
+ "act.player_land": "Voc\xEA aterrissou a {speed} m/s",
3518
3747
  "act.bench_failed": "{name} falhou: {error}",
3519
3748
  "loading.working": "Trabalhando\u2026",
3749
+ "loading.graphics": "Calibrando os gr\xE1ficos\u2026",
3750
+ "ui.graphics": "Gr\xE1ficos",
3520
3751
  "loading.open_area": "Abrindo {area}\u2026",
3521
3752
  "loading.spawn": "Montando {name}\u2026",
3522
3753
  "act.loosened": "{name} \xE9 pesado mas estava solto: a explos\xE3o o moveu",
@@ -3673,6 +3904,8 @@ var ptBR = {
3673
3904
  "ins.damage": "Marcas",
3674
3905
  "ins.damage_counts": "{dents}, {holes}, {cracks}",
3675
3906
  "ins.mass": "Massa",
3907
+ "ins.dug": "Escavado",
3908
+ "ins.dig_limit": "Ch\xE3o at\xE9 o limite",
3676
3909
  "ins.load": "Carga",
3677
3910
  "ins.stress": "Tens\xE3o",
3678
3911
  "ins.joints": "Juntas",
@@ -4106,6 +4339,7 @@ var CSS = `
4106
4339
  .tr-top{position:absolute;top:8px;left:50%;transform:translateX(-50%);text-align:center;max-width:46vw}
4107
4340
  .tr-tool{display:inline-block;background:rgba(10,12,16,.72);padding:4px 12px;border-radius:6px;font-size:14px;font-weight:600}
4108
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)}
4109
4343
  .tr-status{margin-top:4px;min-height:16px;color:#ffd23f;text-shadow:0 1px 2px #000}
4110
4344
  .tr-prog{margin:4px auto 0;width:260px;background:rgba(10,12,16,.72);border-radius:6px;padding:4px 8px;display:none}
4111
4345
  .tr-prog b{display:block;font-weight:500;margin-bottom:3px}
@@ -4224,13 +4458,14 @@ function createUi(o) {
4224
4458
  const top = mk3("div", "tr-top");
4225
4459
  const toolBox = mk3("div", "tr-tool");
4226
4460
  const status = mk3("div", "tr-status");
4461
+ const alertBox = mk3("div", "tr-alert");
4227
4462
  const prog = mk3("div", "tr-prog");
4228
4463
  const progLabel = mk3("b");
4229
4464
  const progBar = mk3("div", "tr-bar");
4230
4465
  const progFill = mk3("i");
4231
4466
  progBar.appendChild(progFill);
4232
4467
  prog.append(progLabel, progBar);
4233
- top.append(toolBox, status, prog);
4468
+ top.append(toolBox, alertBox, status, prog);
4234
4469
  const bottom = mk3("div", "tr-bottom");
4235
4470
  const held = mk3("div", "tr-held");
4236
4471
  const hotbar = mk3("div", "tr-hotbar");
@@ -4457,17 +4692,22 @@ function createUi(o) {
4457
4692
  fullscreenBtn.title = t("ui.fullscreen.hint");
4458
4693
  const blastBtns = ["shelter", "fragmentHits", "confinement"].map((key2) => ({ key: key2, b: ctl(t(`ui.blast.${key2}`), () => o.onBlast(key2)) }));
4459
4694
  for (const { key: key2, b } of blastBtns) b.title = t(`ui.blast.${key2}.hint`);
4460
- const qSel = mk3("select");
4461
- for (const q of ["low", "medium", "high"]) {
4462
- const op = mk3("option", void 0, t(`ui.quality.${q}`));
4463
- op.value = q;
4464
- 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);
4465
4710
  }
4466
- qSel.value = o.quality;
4467
- qSel.onchange = () => o.onQuality(qSel.value);
4468
- const qLabel = mk3("label", void 0, t("ui.quality") + " ");
4469
- qLabel.appendChild(qSel);
4470
- controls.appendChild(qLabel);
4471
4711
  const count = mk3("span");
4472
4712
  controls.appendChild(count);
4473
4713
  cat.appendChild(controls);
@@ -4560,6 +4800,15 @@ function createUi(o) {
4560
4800
  insp.appendChild(mk3("div", void 0, t("ui.aim_at_something")));
4561
4801
  return;
4562
4802
  }
4803
+ if (i.ground) {
4804
+ const g = i.ground;
4805
+ insp.appendChild(mk3("div", "t", `${i.object} \u203A ${g.surface}`));
4806
+ insp.appendChild(row(t("ins.material"), g.material));
4807
+ insp.appendChild(row(t("ins.dug"), `${num(g.dug * 100, 0)} cm`));
4808
+ insp.appendChild(row(t("ins.dig_limit"), `${num(g.remaining, 2)} m`));
4809
+ if (g.scorch > 0.01) insp.appendChild(row(t("ins.char"), `${num(g.scorch * 100, 0)} %`));
4810
+ return;
4811
+ }
4563
4812
  if (i.thumbnail) insp.appendChild(thumb(i.thumbnail, i.thumbnailCredit));
4564
4813
  insp.appendChild(mk3("div", "t", `${i.object} \u203A ${i.part}`));
4565
4814
  insp.appendChild(row(t("ins.material"), i.material));
@@ -4684,6 +4933,10 @@ function createUi(o) {
4684
4933
  renderInspector(s.inspect);
4685
4934
  }
4686
4935
  },
4936
+ alert(text) {
4937
+ alertBox.textContent = text ?? "";
4938
+ alertBox.style.display = text ? "block" : "none";
4939
+ },
4687
4940
  toast(text) {
4688
4941
  status.textContent = text;
4689
4942
  if (toastTimer) clearTimeout(toastTimer);
@@ -4720,7 +4973,14 @@ function createUi(o) {
4720
4973
 
4721
4974
  // src/room.ts
4722
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 };
4723
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/;
4724
4984
  var STEP_UP = 0.45;
4725
4985
  var BODY_HEIGHT2 = 1.75;
4726
4986
  var KICK_MASS = 6;
@@ -4788,6 +5048,17 @@ function createTestRoom(options) {
4788
5048
  const arenaSize = options.arenaSize ?? 14;
4789
5049
  const arena = createArena(THREE, arenaSize, scene, options.scenery, options.tap);
4790
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;
4791
5062
  const ownWorld = !options.world;
4792
5063
  const world = options.world ?? createDestruction({ seed: 1, ...options.worldOptions, materials: [...options.worldOptions?.materials ?? []] });
4793
5064
  const pristineWorld = ownWorld ? world.serialize() : null;
@@ -4833,12 +5104,17 @@ function createTestRoom(options) {
4833
5104
  const ownAudio = options.audio === void 0;
4834
5105
  const audio = options.audio === false ? null : options.audio ?? createAudioEngine({ deferredStart: {} });
4835
5106
  const destructionSound = audio ? createDestructionSound(audio) : null;
5107
+ const bodySounds = createBodySounds(audio);
4836
5108
  const playSound = (key2, point, volume) => {
4837
5109
  destructionSound?.onSound(key2, point, volume);
4838
5110
  options.onSound?.(key2, point, volume);
4839
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 }) };
4840
5115
  const vfx = createVfxRenderer(THREE, {
4841
5116
  scene,
5117
+ lights,
4842
5118
  camera,
4843
5119
  overlay: container,
4844
5120
  quality,
@@ -4992,7 +5268,7 @@ function createTestRoom(options) {
4992
5268
  });
4993
5269
  let pending = [];
4994
5270
  const eventListeners = /* @__PURE__ */ new Set();
4995
- 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() };
4996
5272
  const fire = (type, data) => {
4997
5273
  for (const fn of listeners[type] ?? []) fn(data);
4998
5274
  };
@@ -5129,6 +5405,14 @@ function createTestRoom(options) {
5129
5405
  floorAt: terrain.heightAt,
5130
5406
  start: arena.playerStart,
5131
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
+ },
5132
5416
  hooks: {
5133
5417
  primaryDown: () => {
5134
5418
  useHeld = true;
@@ -5305,12 +5589,23 @@ function createTestRoom(options) {
5305
5589
  for (const p of st?.parts ?? []) if (p.status !== "broken" && p.status !== "melted" && p.status !== "ashes") m += p.mass;
5306
5590
  return m;
5307
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
+ };
5308
5602
  const registerEntry = (objectId, itemId, root, spec, name, parts, item, arenaObject, movable) => {
5309
5603
  specs.set(objectId, spec);
5310
5604
  renderer.bind(objectId, root, parts ? { parts } : void 0);
5311
5605
  const so = { objectId, itemId, root, movable };
5312
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] };
5313
5607
  entries.set(objectId, e);
5608
+ batchObject(objectId, root);
5314
5609
  if (movable) {
5315
5610
  e.piece = renderer.makePiece(objectId, { mass: Math.max(0.2, massOfObject(objectId)) });
5316
5611
  rendererPieces.set(objectId, e.piece);
@@ -5578,6 +5873,8 @@ function createTestRoom(options) {
5578
5873
  dropTree(e.so.root);
5579
5874
  dropTree(inner);
5580
5875
  entries.delete(id);
5876
+ batchers.get(id)?.dispose();
5877
+ batchers.delete(id);
5581
5878
  doors.forget(id);
5582
5879
  rendererPieces.delete(id);
5583
5880
  specs.delete(id);
@@ -5676,7 +5973,7 @@ function createTestRoom(options) {
5676
5973
  lastImpact.clear();
5677
5974
  addArena();
5678
5975
  standFootprints();
5679
- timeScale = 1;
5976
+ scheduler.timeScale = 1;
5680
5977
  fastForward = false;
5681
5978
  walkPlan = null;
5682
5979
  };
@@ -5877,6 +6174,28 @@ function createTestRoom(options) {
5877
6174
  }
5878
6175
  };
5879
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
+ };
5880
6199
  let rng = 12345;
5881
6200
  const rnd = () => {
5882
6201
  rng = rng * 1664525 + 1013904223 >>> 0;
@@ -5995,6 +6314,12 @@ function createTestRoom(options) {
5995
6314
  ui?.toast(msg);
5996
6315
  };
5997
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
+ };
5998
6323
  const hitWhere = (r) => {
5999
6324
  const p = world.part(r.object, r.part);
6000
6325
  return `${nameOf(r.object)} \u203A ${r.part}${p ? ` (${dStrings.materialNames[p.material] ?? p.material})` : ""}`;
@@ -6004,6 +6329,7 @@ function createTestRoom(options) {
6004
6329
  renderer.impulse(objectId, [dir[0] * momentum, dir[1] * momentum, dir[2] * momentum], point);
6005
6330
  };
6006
6331
  const shotRay = new THREE.Raycaster();
6332
+ shotRay.layers.enableAll();
6007
6333
  const LIQUID_SLOWING = 0.25;
6008
6334
  const SHOT_GRIP = 0.3;
6009
6335
  const shotPush = (s, objectId, results, fallback) => {
@@ -6174,6 +6500,8 @@ function createTestRoom(options) {
6174
6500
  return;
6175
6501
  }
6176
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);
6177
6505
  pushPiece(r.object, u.direction, 2 * u.energy / 6, r.point);
6178
6506
  action("melee", t("act.melee.hit", { weapon: w, energy: num(u.energy, 0), where: hitWhere(r), dist: num(r.distance, 1) }), {
6179
6507
  objectId: r.object,
@@ -6214,6 +6542,10 @@ function createTestRoom(options) {
6214
6542
  comboNext = (stepIndex + 1) % combo.steps.length;
6215
6543
  comboAt = simTime;
6216
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();
6217
6549
  if (clip === void 0 || seconds === null) {
6218
6550
  if (leg) {
6219
6551
  legAim();
@@ -6228,6 +6560,7 @@ function createTestRoom(options) {
6228
6560
  strikes.push({
6229
6561
  key: id,
6230
6562
  remaining: seconds,
6563
+ ...swing && seconds > lead ? { swing, lead } : {},
6231
6564
  land: () => {
6232
6565
  if (leg) legAim();
6233
6566
  else aim();
@@ -6237,7 +6570,13 @@ function createTestRoom(options) {
6237
6570
  };
6238
6571
  const stepStrikes = (dt) => {
6239
6572
  if (strikes.length === 0) return;
6240
- 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
+ }
6241
6580
  const landed = strikes.filter((strike) => strike.remaining <= 0);
6242
6581
  strikes = strikes.filter((strike) => strike.remaining > 0);
6243
6582
  for (const strike of landed) strike.land();
@@ -6351,26 +6690,27 @@ function createTestRoom(options) {
6351
6690
  aim();
6352
6691
  const cat = tool.def.category;
6353
6692
  if (cat === "hand") {
6354
- if (strikes.some((strike) => strike.key === "hand" || strike.key === "fists")) return;
6693
+ if (strikes.some((strike) => strike.key === "hand" || strike.key === "fists")) return "cooldown";
6355
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");
6356
- return;
6695
+ return "ok";
6357
6696
  }
6358
- if (strikes.some((strike) => strike.key === toolId)) return;
6697
+ if (strikes.some((strike) => strike.key === toolId)) return "cooldown";
6359
6698
  const res = tool.trigger([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], dt, held, "player");
6360
6699
  flushEvents(toolId, tool, res.recoil);
6361
6700
  if (res.status === "empty") {
6362
6701
  if (!tool.reloading && tool.reserve > 0) startReload();
6363
6702
  say(t(tool.reloading ? "hud.reloading" : "hud.empty"));
6364
- return;
6703
+ return res.status;
6365
6704
  }
6366
6705
  if (res.status !== "ok") {
6367
6706
  if (res.status === "reloading") say(t("hud.reloading"));
6368
6707
  else if (res.status === "no_fuel") say(t("hud.no_fuel"));
6369
6708
  else if (res.status === "fault" && res.fault) say(t("hud.fault", { fault: t(`fault.${res.fault}`) }));
6370
6709
  else if (res.status === "safe") say(t("hud.safe"));
6371
- return;
6710
+ return res.status;
6372
6711
  }
6373
6712
  applyUses(res, dt);
6713
+ return res.status;
6374
6714
  };
6375
6715
  const applyUses = (res, dt) => {
6376
6716
  if (res.shots.length) fireShots(res.shots, res.recoil);
@@ -6472,8 +6812,9 @@ function createTestRoom(options) {
6472
6812
  heldTilt = 0;
6473
6813
  };
6474
6814
  const handUse = () => {
6475
- if (heldPiece) grab();
6476
- else useTool(FIXED_DT, false);
6815
+ if (!heldPiece) return useTool(FIXED_DT, false);
6816
+ grab();
6817
+ return "ok";
6477
6818
  };
6478
6819
  const grab = () => {
6479
6820
  if (push.active) return false;
@@ -6918,9 +7259,36 @@ function createTestRoom(options) {
6918
7259
  if (!leaf) return void 0;
6919
7260
  return t(leaf.locked ? "hud.door_hint_locked" : leaf.open ? "hud.door_hint_close" : "hud.door_hint_open");
6920
7261
  };
7262
+ const groundInspect = (r) => {
7263
+ const onFloor = terrain.raycast(aimO, aimD, r.distance + 3);
7264
+ const [x, , z] = onFloor?.point ?? r.point;
7265
+ const column = terrain.columnAt(x, z);
7266
+ const materialName = S.text[`material.${column.material}`] ?? dStrings.materialNames[column.material] ?? column.material;
7267
+ return {
7268
+ object: t("ground.name"),
7269
+ objectId: GROUND_ID,
7270
+ part: column.profile,
7271
+ material: materialName,
7272
+ status: "",
7273
+ hp: 0,
7274
+ maxHp: 0,
7275
+ temperature: 0,
7276
+ burning: false,
7277
+ char: column.scorch,
7278
+ melt: 0,
7279
+ dents: 0,
7280
+ holes: 0,
7281
+ cracks: 0,
7282
+ mass: 0,
7283
+ joints: [],
7284
+ distance: onFloor?.distance ?? r.distance,
7285
+ ground: { surface: terrainProfileName(column.profile, locale), material: materialName, dug: column.depth, remaining: column.remaining, scorch: column.scorch }
7286
+ };
7287
+ };
6921
7288
  const inspectInfo = () => {
6922
7289
  const r = inspectTarget();
6923
7290
  if (!r) return null;
7291
+ if (r.object === GROUND_ID) return groundInspect(r);
6924
7292
  const st = world.state(r.object);
6925
7293
  const p = world.part(r.object, r.part);
6926
7294
  if (!st || !p) return null;
@@ -6959,13 +7327,64 @@ function createTestRoom(options) {
6959
7327
  distance: r.distance
6960
7328
  };
6961
7329
  };
6962
- let timeScale = 1;
6963
7330
  let fastForward = false;
6964
7331
  let hudAcc = 1;
6965
7332
  let metricsAcc = 0;
6966
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]);
6967
7366
  const roomMetricIds = options.metrics === false ? [] : options.metrics ?? ROOM_METRICS;
6968
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
+ });
6969
7388
  let qualityName = quality;
6970
7389
  const syncContainers = (sdt) => {
6971
7390
  const drinkingHeld = useItemPlayer !== null && (useItemName === "drink" || useItemName === "sip" || useItemName === "chug");
@@ -6991,7 +7410,6 @@ function createTestRoom(options) {
6991
7410
  if (part && part.liters !== void 0 && Math.abs(part.liters - c.liters) > 1e-4) world.setLiters(host2.objectId, e.fluidPart, c.liters);
6992
7411
  }
6993
7412
  };
6994
- let paused = false;
6995
7413
  let walkPlan = null;
6996
7414
  const stepWalk = (dt) => {
6997
7415
  if (!walkPlan) return;
@@ -7000,8 +7418,7 @@ function createTestRoom(options) {
7000
7418
  walkPlan.remaining -= dt;
7001
7419
  if (walkPlan.remaining <= 1e-9) walkPlan = null;
7002
7420
  };
7003
- const stepSim = (dt) => {
7004
- if (paused) return;
7421
+ const simulateBeforeModules = (dt) => {
7005
7422
  simTime += dt;
7006
7423
  stepWalk(dt);
7007
7424
  stepStrikes(dt);
@@ -7033,7 +7450,8 @@ function createTestRoom(options) {
7033
7450
  const look = camera.getWorldDirection(listenerDirection);
7034
7451
  audio.setListener({ position: [camera.position.x, camera.position.y, camera.position.z], yaw: Math.atan2(-look.x, -look.z) });
7035
7452
  }
7036
- for (const update2 of frameListeners) update2(dt);
7453
+ };
7454
+ const simulateAfterModules = (dt) => {
7037
7455
  for (const tr of tracers) {
7038
7456
  if (tr.life > 0) {
7039
7457
  tr.life -= dt;
@@ -7042,7 +7460,7 @@ function createTestRoom(options) {
7042
7460
  }
7043
7461
  flush();
7044
7462
  };
7045
- const update = (dt) => {
7463
+ const updateInput = (dt, isPaused) => {
7046
7464
  if (!ownsLoop) roomMetrics.frame(dt);
7047
7465
  frameStamp++;
7048
7466
  seating.update();
@@ -7051,23 +7469,25 @@ function createTestRoom(options) {
7051
7469
  pushByKey = false;
7052
7470
  push.end();
7053
7471
  }
7054
- push.step(paused ? 0 : dt);
7055
- doors.step(paused ? 0 : dt);
7056
- if (!paused) kickLight(dt);
7057
- 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);
7058
7476
  else player.takeWish();
7059
- stepAvatar(paused ? 0 : dt);
7477
+ stepAvatar(isPaused ? 0 : dt);
7478
+ stepFeet(isPaused ? 0 : dt);
7060
7479
  if (tiltKey || heldTilt > 0) {
7061
7480
  heldTilt = Math.max(0, Math.min(2.4, heldTilt + (tiltKey ? 2.2 : -3) * dt));
7062
7481
  }
7063
- if ((useHeld || useFirst) && !paused && !push.active) {
7482
+ if ((useHeld || useFirst) && !isPaused && !push.active) {
7064
7483
  if (tool.def.category === "hand") {
7065
7484
  if (useFirst) handUse();
7066
7485
  } else if (useHeld) useTool(dt, !useFirst);
7067
7486
  useFirst = false;
7068
7487
  }
7069
- if (!paused) followHeld(dt);
7070
- stepSim(dt * timeScale);
7488
+ if (!isPaused) followHeld(dt);
7489
+ };
7490
+ const updatePresent = (dt) => {
7071
7491
  reachAvatar();
7072
7492
  if (progress && simTime - progress.at > 0.25) progress = null;
7073
7493
  hudAcc += dt;
@@ -7100,7 +7520,7 @@ function createTestRoom(options) {
7100
7520
  inspect: inspectInfo(),
7101
7521
  animations: animationsOf(),
7102
7522
  locked: player.locked,
7103
- timeScale,
7523
+ timeScale: scheduler.timeScale,
7104
7524
  fast: fastForward,
7105
7525
  tap: isTapOn(),
7106
7526
  combo: combo ? comboOn : null,
@@ -7252,6 +7672,8 @@ function createTestRoom(options) {
7252
7672
  const value = sampled[id];
7253
7673
  return value === void 0 ? [] : [{ id, name: t(`metric.${id}`), text: num(value, id === "frame_ms" ? 1 : 0) }];
7254
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 }) });
7255
7677
  const values = moduleMetrics();
7256
7678
  const declared = (activeBench ? benches.get(activeBench)?.metrics : void 0) ?? [];
7257
7679
  const module = declared.map((metric) => {
@@ -7267,7 +7689,6 @@ function createTestRoom(options) {
7267
7689
  ui.metrics(metricRows());
7268
7690
  };
7269
7691
  const hostListeners = /* @__PURE__ */ new Set();
7270
- const frameListeners = /* @__PURE__ */ new Set();
7271
7692
  const listenerDirection = new THREE.Vector3();
7272
7693
  const environment = options.environment ?? createEnvironmentBus();
7273
7694
  const audioSink = audio ? createAudioSink(audio) : void 0;
@@ -7320,10 +7741,16 @@ function createTestRoom(options) {
7320
7741
  scene,
7321
7742
  camera,
7322
7743
  locale,
7744
+ ...graphics ? { graphics: graphics.host } : {},
7323
7745
  ...audioSink ? { audio: audioSink } : {},
7324
7746
  environment,
7325
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,
7326
7750
  effects: vfx,
7751
+ particleLimit: () => options.debris?.particleLimit ?? DEFAULT_PARTICLE_LIMIT,
7752
+ lights,
7753
+ pools,
7327
7754
  loading,
7328
7755
  ...options.blastShove ? { blastShove: options.blastShove } : {},
7329
7756
  tool: () => ({
@@ -7375,9 +7802,9 @@ function createTestRoom(options) {
7375
7802
  hostListeners.add(listener);
7376
7803
  return () => hostListeners.delete(listener);
7377
7804
  },
7378
- onFrame(update2) {
7379
- frameListeners.add(update2);
7380
- 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++}` });
7381
7808
  },
7382
7809
  holdFrame: () => bodyHoldFrame(),
7383
7810
  reachFor(reach) {
@@ -7435,46 +7862,47 @@ function createTestRoom(options) {
7435
7862
  return r.ok ? store : null;
7436
7863
  };
7437
7864
  const LIGHTS_CHECK_MS = 250;
7438
- const pinnedPrograms = /* @__PURE__ */ new WeakSet();
7439
- const pinPrograms = () => {
7440
- for (const program of gl.info?.programs ?? []) {
7441
- if (pinnedPrograms.has(program)) continue;
7442
- pinnedPrograms.add(program);
7443
- program.usedTimes++;
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() };
7444
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
+ };
7445
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;
7446
7903
  const warmUp = () => {
7447
- const probes = [renderer.warmUpProbes(), vfx.warmUpProbes(), fluidView.warmUpProbes()];
7448
- const groups = probes.map((probe) => probe.group);
7449
- const spriteMap = new THREE.DataTexture(new Uint8Array([255, 255, 255, 255]), 1, 1);
7450
- spriteMap.needsUpdate = true;
7451
- const spriteProbes = [true, false].map((fog) => {
7452
- const probe = new THREE.Sprite(new THREE.SpriteMaterial({ map: spriteMap, transparent: true, fog }));
7453
- probe.frustumCulled = false;
7454
- return probe;
7455
- });
7456
- groups.push(...spriteProbes);
7457
- scene.add(...groups);
7458
- gl.render(scene, camera);
7459
- pinPrograms();
7460
- scene.remove(...groups);
7461
- for (const probe of probes) probe.dispose();
7462
- for (const probe of spriteProbes) probe.material.dispose();
7463
- spriteMap.dispose();
7464
- };
7465
- const warmUpEffects = () => {
7466
- const ids = vfx.catalog?.() ?? [];
7467
- const flames = ids.filter((id) => /fire|flame/i.test(id)).slice(0, 1);
7468
- gl.compile(scene, camera);
7469
- 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] }));
7470
- for (let i = 0; i < 3; i++) {
7471
- vfx.update(0.02);
7472
- gl.render(scene, camera);
7473
- }
7474
- for (const h of handles) if (h !== null) vfx.stop(h);
7475
- vfx.world.clear();
7476
- vfx.update(0);
7477
- gl.render(scene, camera);
7904
+ warmup.warmUp(gl, scene, camera, { lights, fogStates: WARMED_FOG_STATES, materials: pools.materials.registeredMaterials?.() ?? [] });
7905
+ warmedUp = true;
7478
7906
  };
7479
7907
  const loadModule = (mod) => {
7480
7908
  const report = { module: mod.id, items: [], scenarios: [], ok: true, problems: [] };
@@ -7586,24 +8014,13 @@ function createTestRoom(options) {
7586
8014
  };
7587
8015
  const allScenarios = () => [...extraScenarios];
7588
8016
  let scenarioRunning = false;
7589
- const step = (seconds) => {
7590
- const dt = seconds > 6 ? 1 / 30 : FIXED_DT;
7591
- const n = Math.max(1, Math.round(seconds / dt));
7592
- for (let i = 0; i < n; i++) {
7593
- seating.update();
7594
- player.update(0);
7595
- push.step(dt);
7596
- doors.step(dt);
7597
- followHeld(dt);
7598
- stepSim(dt);
7599
- }
7600
- };
8017
+ const step = (seconds) => runHeadless(() => scheduler.advance(seconds, { dt: seconds > 6 ? 1 / 30 : FIXED_DT }));
7601
8018
  const runScenarios = async (list) => {
7602
8019
  const all = list ?? allScenarios();
7603
8020
  const report = { passed: 0, failed: 0, results: [] };
7604
8021
  scenarioRunning = true;
7605
- const wasPaused = paused;
7606
- paused = false;
8022
+ const wasPaused = scheduler.paused;
8023
+ scheduler.resume();
7607
8024
  try {
7608
8025
  for (const sc of all) {
7609
8026
  hardReset();
@@ -7635,7 +8052,7 @@ function createTestRoom(options) {
7635
8052
  }
7636
8053
  } finally {
7637
8054
  scenarioRunning = false;
7638
- paused = wasPaused;
8055
+ if (wasPaused) scheduler.pause();
7639
8056
  restoreScenery();
7640
8057
  }
7641
8058
  return report;
@@ -7767,19 +8184,25 @@ function createTestRoom(options) {
7767
8184
  useItem,
7768
8185
  use(hold = 0) {
7769
8186
  if (tool.def.category === "hand") {
7770
- handUse();
8187
+ const status2 = handUse();
7771
8188
  flush();
7772
- return;
8189
+ return status2;
7773
8190
  }
7774
- useTool(FIXED_DT, false);
8191
+ const status = useTool(FIXED_DT, false);
7775
8192
  flush();
7776
8193
  let t0 = 0;
7777
8194
  while (t0 < hold) {
7778
8195
  useTool(FIXED_DT, true);
7779
- stepSim(FIXED_DT);
8196
+ directStep = true;
8197
+ try {
8198
+ runHeadless(() => scheduler.advance(FIXED_DT));
8199
+ } finally {
8200
+ directStep = false;
8201
+ }
7780
8202
  t0 += FIXED_DT;
7781
8203
  }
7782
8204
  releaseTrigger();
8205
+ return status;
7783
8206
  },
7784
8207
  grab,
7785
8208
  throwHeld,
@@ -7795,14 +8218,14 @@ function createTestRoom(options) {
7795
8218
  spawned() {
7796
8219
  return [...entries.values()].filter((e) => !e.arena).map((e) => e.so);
7797
8220
  },
7798
- update,
8221
+ update: (dt) => runHeadless(() => scheduler.frame(dt, { render: false, exact: true })),
7799
8222
  waitUntilReady(maxSeconds = 5) {
7800
8223
  let waited = 0;
7801
8224
  while (tool.phase && waited < maxSeconds) {
7802
- update(FIXED_DT);
8225
+ room.update(FIXED_DT);
7803
8226
  waited += FIXED_DT;
7804
8227
  }
7805
- update(FIXED_DT);
8228
+ room.update(FIXED_DT);
7806
8229
  return waited + FIXED_DT;
7807
8230
  },
7808
8231
  on(type, fn) {
@@ -7819,20 +8242,20 @@ function createTestRoom(options) {
7819
8242
  blastOptions = { ...blastOptions, ...next };
7820
8243
  },
7821
8244
  pause() {
7822
- paused = true;
8245
+ scheduler.pause();
7823
8246
  },
7824
8247
  resume() {
7825
- paused = false;
8248
+ scheduler.resume();
7826
8249
  },
7827
8250
  get paused() {
7828
- return paused;
8251
+ return scheduler.paused;
7829
8252
  },
7830
8253
  walk(to, seconds) {
7831
8254
  walkPlan = seconds > 0 ? { x: to[0], z: to[2], remaining: seconds } : null;
7832
8255
  if (!walkPlan) player.moveBy(to[0] - camera.position.x, to[2] - camera.position.z);
7833
8256
  },
7834
8257
  setSlowMotion(on) {
7835
- timeScale = on ? 0.25 : 1;
8258
+ scheduler.timeScale = on ? 0.25 : 1;
7836
8259
  },
7837
8260
  setFastForward(on) {
7838
8261
  fastForward = on;
@@ -7848,12 +8271,13 @@ function createTestRoom(options) {
7848
8271
  modules: () => [...modules.values()].map((m) => ({ id: m.id, name: m.name, group: m.group, hasArea: typeof m.area === "function" })),
7849
8272
  openAreaAsync: (area) => loading.busy(t("loading.open_area", { area: areaLabel(area) }), () => openArea(area)),
7850
8273
  loading,
8274
+ graphics: graphics?.controller ?? null,
8275
+ graphicsScreen: graphics?.screen ?? null,
7851
8276
  busy: (label, work, busyOptions) => loading.busy(label, work, busyOptions),
7852
8277
  loadModule,
7853
8278
  warmUp: () => {
7854
8279
  audio?.warmUp();
7855
8280
  warmUp();
7856
- warmUpEffects();
7857
8281
  },
7858
8282
  animations: animationsOf,
7859
8283
  playAnimation,
@@ -7925,6 +8349,9 @@ function createTestRoom(options) {
7925
8349
  metrics() {
7926
8350
  return { room: roomMetrics.sample(), module: moduleMetrics() };
7927
8351
  },
8352
+ frameStats: () => scheduler.stats(),
8353
+ lights,
8354
+ webgl: gl,
7928
8355
  dispose() {
7929
8356
  destructionSound?.dispose();
7930
8357
  if (ownAudio) audio?.dispose();
@@ -7932,13 +8359,19 @@ function createTestRoom(options) {
7932
8359
  detachers.clear();
7933
8360
  for (const store of benches.values()) store.dispose();
7934
8361
  benches.clear();
7935
- cancelAnimationFrame?.(raf);
8362
+ stopLoop?.();
8363
+ disposePacer?.();
8364
+ scheduler.dispose();
7936
8365
  window.removeEventListener("resize", onResize);
7937
8366
  player.dispose();
7938
8367
  ui?.dispose();
8368
+ graphics?.dispose();
7939
8369
  loading.dispose();
7940
8370
  renderer.dispose();
7941
8371
  vfx.dispose();
8372
+ lights.dispose();
8373
+ pools.materials.dispose();
8374
+ pools.geometries.dispose();
7942
8375
  fluidView.dispose();
7943
8376
  filmBridge.dispose();
7944
8377
  film.dispose();
@@ -8004,6 +8437,7 @@ function createTestRoom(options) {
8004
8437
  }
8005
8438
  },
8006
8439
  quality: qualityName,
8440
+ ...graphics?.screen ? { onGraphics: () => graphics.screen?.open() } : {},
8007
8441
  scenarios: allScenarios,
8008
8442
  onRun: async (sc) => {
8009
8443
  await runScenarios([sc]);
@@ -8037,41 +8471,76 @@ function createTestRoom(options) {
8037
8471
  ui.hud(hudState());
8038
8472
  showBench();
8039
8473
  }
8040
- let raf = 0;
8041
- if (ownRenderer && typeof requestAnimationFrame === "function") {
8042
- let last = performance.now();
8043
- 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) {
8044
8499
  let nextLightsCheck = 0;
8045
- let warmedLights = "";
8046
- const lightsKey = () => {
8047
- let key2 = "";
8048
- scene.traverseVisible((object) => {
8049
- if (object.isLight) key2 += `${object.type[0]}${object.castShadow ? "s" : ""}`;
8050
- });
8051
- return key2;
8052
- };
8053
- const loop = (now) => {
8054
- raf = requestAnimationFrame(loop);
8055
- 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;
8056
8506
  nextLightsCheck = now + LIGHTS_CHECK_MS;
8057
- const key2 = lightsKey();
8058
- if (key2 !== warmedLights) {
8059
- warmedLights = key2;
8060
- warmUp();
8061
- }
8062
- }
8063
- const realDelta = (now - last) / 1e3;
8064
- roomMetrics.frame(realDelta);
8065
- const dt = Math.min(0.05, realDelta);
8066
- last = now;
8067
- if (!scenarioRunning) update(dt);
8068
- const restoreCamera = room.applyCamera();
8069
- gl.render(scene, debugView ?? camera);
8070
- pinPrograms();
8071
- restoreCamera();
8072
- roomMetrics.rendered();
8073
- };
8074
- 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
+ }
8075
8544
  }
8076
8545
  if (options.initial) {
8077
8546
  for (const [i, id] of options.initial.entries()) spawn(id, [(i - (options.initial.length - 1) / 2) * 1.3, 0, 0.5]);