@adatechnology/game-test-room 0.25.2 → 0.27.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,7 +1,8 @@
1
1
  // src/room.ts
2
2
  import { createDestruction, JOINT_DEFAULTS, resolveStrings as resolveDestructionStrings } from "@adatechnology/game-destruction";
3
3
  import { createAudioEngine, createAudioSink, loadKitSamples, loadUserSamples } from "@adatechnology/game-audio";
4
- import { createDestructionRenderer, createDestructionSound, dragSoundKey, impactKey, impactSoundKeys, specFromObject3D } from "@adatechnology/game-destruction-three";
4
+ import { registerFittedSounds } from "@adatechnology/game-audio/fitted";
5
+ import { createDestructionRenderer, createDestructionSound, dragSoundKey, impactKey, impactSoundIds, impactSoundKeys, specFromObject3D } from "@adatechnology/game-destruction-three";
5
6
  import { bodyMass, KNOCK_CHEST, KNOCK_DOWN_SPEED, knockOf, surfaceOfMaterial } from "@adatechnology/game-contracts";
6
7
 
7
8
  // src/body-sounds.ts
@@ -79,7 +80,7 @@ function createStrideCounter(stride = STRIDE_METRES) {
79
80
  }
80
81
 
81
82
  // src/room.ts
82
- import { createArsenal, DEFAULT_LOADOUT } from "@adatechnology/game-weapons";
83
+ import { createArsenal, DEFAULT_LOADOUT, realLoadout, weaponMenu } from "@adatechnology/game-weapons";
83
84
  import { createFilmLcg, createFluids, createItemUse, ITEM_USES, resolveStrings as resolveFluidStrings } from "@adatechnology/game-fluids";
84
85
  import { createFilmBridge, createFilmManager, createFluidRenderer } from "@adatechnology/game-fluids-three";
85
86
  import { SPRITE_KINDS, createVfx, patchSpriteKinds } from "@adatechnology/game-vfx";
@@ -189,7 +190,7 @@ function resolveScenery(scenery) {
189
190
  if (!position) throw new Error(`game-test-room: scenery "${id}" is new, so it needs a position`);
190
191
  merged.set(id, { ...obj, position });
191
192
  }
192
- return [...merged.entries()].map(([id, o]) => ({ id, spec: o.spec, position: o.position, rotationY: o.rotationY ?? 0, colors: o.colors, build: o.build, follow: o.follow, label: o.label, top: o.top, spout: o.spout, free: o.free, pushable: o.pushable }));
193
+ return [...merged.entries()].map(([id, o]) => ({ id, spec: o.spec, position: o.position, rotationY: o.rotationY ?? 0, colors: o.colors, build: o.build, follow: o.follow, warmUpProbes: o.warmUpProbes, label: o.label, top: o.top, spout: o.spout, free: o.free, pushable: o.pushable }));
193
194
  }
194
195
  function createArena(THREE, size, scene, scenery, tapOption) {
195
196
  const group = markKitOrigin(new THREE.Group(), { owner: "game-test-room", kind: "room" });
@@ -352,6 +353,23 @@ var USE_SOUNDS = {
352
353
  };
353
354
  var USE_SOUND_EVERY = 0.55;
354
355
  var useSoundOf = (sounds, useId) => sounds?.uses?.[useId] ?? USE_SOUNDS[useId];
356
+ var HAND_RECORDED_IDS = [
357
+ "door_wood_open",
358
+ "door_creak",
359
+ "door_wood_close",
360
+ "door_wood_slam",
361
+ "door_locked_rattle",
362
+ "item_pickup_metal",
363
+ "item_pickup_plastic",
364
+ "item_pickup_glass",
365
+ "item_pickup_wood",
366
+ "item_pickup_fabric",
367
+ "drink_gulp",
368
+ // the old leaf keys still played by a leaf without a set (a vehicle's, whose bank falls back to them)
369
+ "latch",
370
+ "shut_wood",
371
+ "rattle"
372
+ ];
355
373
 
356
374
  // src/bench.ts
357
375
  var snap = (control, value) => {
@@ -2223,7 +2241,7 @@ function createPlayer(THREE, o) {
2223
2241
  if (o.blockers) {
2224
2242
  for (const isBlocked of o.blockers) if (isBlocked(x, z, radius)) return true;
2225
2243
  }
2226
- return o.footing ? o.footing(x, z, radius, y).blocked : false;
2244
+ return o.footing ? o.footing(x, z, radius, floorLevel + y).blocked : false;
2227
2245
  };
2228
2246
  const clearRadius = () => {
2229
2247
  for (const share of CLEAR_SHARES) if (!blocked(px.x, px.z, PLAYER_RADIUS * share)) return PLAYER_RADIUS * share;
@@ -2522,7 +2540,7 @@ function createPlayer(THREE, o) {
2522
2540
  const run = !crouched && !walkHeld && (keys.has("ShiftLeft") || keys.has("ShiftRight"));
2523
2541
  const fromX = px.x;
2524
2542
  const fromZ = px.z;
2525
- const speed = crouched ? CROUCH_WALK : run ? RUN : WALK;
2543
+ const speed = (crouched ? CROUCH_WALK : run ? RUN : WALK) * (o.speedAt?.(px.x, px.z) ?? 1);
2526
2544
  const [walkX, walkZ] = walkKeys();
2527
2545
  if ((walkX || walkZ || keys.has("Space")) && eye !== EYE) eye = EYE;
2528
2546
  if ((walkX || walkZ) && !walkHeld) move(walkX * speed * dt, walkZ * speed * dt);
@@ -3318,7 +3336,7 @@ function createRoomDoors(options) {
3318
3336
 
3319
3337
  // src/room.ts
3320
3338
  import { releaseObject3D as releaseObject3D2 } from "@adatechnology/game-shapes-three";
3321
- import { createTerrain, createTerrainImpl, DEFAULT_PROFILE } from "@adatechnology/game-terrain";
3339
+ import { createFootprints, createTerrain, createTerrainImpl, DEFAULT_PROFILE, waterLift } from "@adatechnology/game-terrain";
3322
3340
  import { createTerrainRenderer, terrainProfileName } from "@adatechnology/game-terrain-three";
3323
3341
 
3324
3342
  // src/avatar.ts
@@ -3618,7 +3636,7 @@ var en = {
3618
3636
  "hud.throw": "throw",
3619
3637
  "hud.tilt": "tilt (hold)",
3620
3638
  "hud.equip": "equip {weapon}",
3621
- "ui.help": "Click: look \xB7 WASD: walk \xB7 Shift: run \xB7 C: crouch (tap stays, hold while pressed) \xB7 Space: jump \xB7 1-0 / wheel: tool \xB7 LMB: use (bare hands: punch) \xB7 E: grab/drop (hold against furniture: push) \xB7 G or RMB: throw \xB7 R: reload \xB7 Q: fire mode \xB7 X: clear a jam \xB7 B: safety \xB7 Y: detonate charges set down \xB7 K: kick \xB7 N: strike combo on/off \xB7 Tab: panels \xB7 Esc: free the mouse",
3639
+ "ui.help": "Click: look \xB7 WASD: walk \xB7 Shift: run \xB7 C: crouch (tap stays, hold while pressed) \xB7 Space: jump \xB7 1-0 / wheel: tool \xB7 \u2019 (key left of 1): every weapon by category \xB7 LMB: use (bare hands: punch) \xB7 E: grab/drop (hold against furniture: push) \xB7 G or RMB: throw \xB7 R: reload \xB7 Q: fire mode \xB7 X: clear a jam \xB7 B: safety \xB7 Y: detonate charges set down \xB7 K: kick \xB7 N: strike combo on/off \xB7 Tab: panels \xB7 Esc: free the mouse",
3622
3640
  "ui.help.camera": "V: 1st/3rd person (Shift+V: kick)",
3623
3641
  "camera.first": "1st person",
3624
3642
  "camera.third": "3rd person",
@@ -3660,6 +3678,10 @@ var en = {
3660
3678
  "arena.test_table": "Test table",
3661
3679
  "ui.clear_log": "Clear",
3662
3680
  "ui.catalog": "Catalogue",
3681
+ "ui.tool_menu": "Weapons",
3682
+ "ui.tool_menu.search": "Search weapons and tools",
3683
+ "ui.tool_menu.close": "Close",
3684
+ "ui.tool_menu.results": "{n} found",
3663
3685
  "ui.clear": "Clear",
3664
3686
  "ui.reset_all": "Reset all",
3665
3687
  "ui.slow": "Slow motion 0.25\xD7",
@@ -3983,7 +4005,7 @@ var ptBR = {
3983
4005
  "hud.throw": "arremessar",
3984
4006
  "hud.tilt": "inclinar (segurar)",
3985
4007
  "hud.equip": "equipar {weapon}",
3986
- "ui.help": "Clique: olhar \xB7 WASD: andar \xB7 Shift: correr \xB7 C: agachar (toque fixa, segurar s\xF3 enquanto aperta) \xB7 Espa\xE7o: pular \xB7 1-0 / roda: ferramenta \xB7 bot\xE3o esquerdo: usar (m\xE3o livre: soco) \xB7 E: pegar/soltar (segurar encostado no m\xF3vel: empurrar) \xB7 G ou bot\xE3o direito: arremessar \xB7 R: recarregar \xB7 Q: modo de tiro \xB7 X: destravar \xB7 B: trava \xB7 Y: detonar cargas postas \xB7 K: chute \xB7 N: liga/desliga a sequ\xEAncia de golpes \xB7 Tab: pain\xE9is \xB7 Esc: soltar o mouse",
4008
+ "ui.help": "Clique: olhar \xB7 WASD: andar \xB7 Shift: correr \xB7 C: agachar (toque fixa, segurar s\xF3 enquanto aperta) \xB7 Espa\xE7o: pular \xB7 1-0 / roda: ferramenta \xB7 \u2019 (tecla \xE0 esquerda do 1): todas as armas por categoria \xB7 bot\xE3o esquerdo: usar (m\xE3o livre: soco) \xB7 E: pegar/soltar (segurar encostado no m\xF3vel: empurrar) \xB7 G ou bot\xE3o direito: arremessar \xB7 R: recarregar \xB7 Q: modo de tiro \xB7 X: destravar \xB7 B: trava \xB7 Y: detonar cargas postas \xB7 K: chute \xB7 N: liga/desliga a sequ\xEAncia de golpes \xB7 Tab: pain\xE9is \xB7 Esc: soltar o mouse",
3987
4009
  "ui.help.camera": "V: 1\xAA/3\xAA pessoa (Shift+V: chute)",
3988
4010
  "camera.first": "1\xAA pessoa",
3989
4011
  "camera.third": "3\xAA pessoa",
@@ -4025,6 +4047,10 @@ var ptBR = {
4025
4047
  "arena.test_table": "Mesa de testes",
4026
4048
  "ui.clear_log": "Limpar",
4027
4049
  "ui.catalog": "Cat\xE1logo",
4050
+ "ui.tool_menu": "Armas",
4051
+ "ui.tool_menu.search": "Buscar armas e ferramentas",
4052
+ "ui.tool_menu.close": "Fechar",
4053
+ "ui.tool_menu.results": "{n} encontradas",
4028
4054
  "ui.clear": "Limpar",
4029
4055
  "ui.reset_all": "Consertar tudo",
4030
4056
  "ui.slow": "C\xE2mera lenta 0,25\xD7",
@@ -4446,12 +4472,12 @@ function createTestTablePanel(table, t) {
4446
4472
  list.appendChild(b);
4447
4473
  }
4448
4474
  };
4449
- const fold2 = (text) => text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
4475
+ const fold3 = (text) => text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
4450
4476
  search.oninput = () => {
4451
4477
  found.textContent = "";
4452
- const term = fold2(search.value.trim());
4478
+ const term = fold3(search.value.trim());
4453
4479
  if (!term) return;
4454
- const hits = table.items().filter((item) => fold2(`${item.name} ${item.id}`).includes(term)).slice(0, 8);
4480
+ const hits = table.items().filter((item) => fold3(`${item.name} ${item.id}`).includes(term)).slice(0, 8);
4455
4481
  for (const item of hits) {
4456
4482
  const b = mk2("button", "tr-btn", item.name);
4457
4483
  b.type = "button";
@@ -4483,6 +4509,121 @@ function createTestTablePanel(table, t) {
4483
4509
  };
4484
4510
  }
4485
4511
 
4512
+ // src/tool-menu.ts
4513
+ var TOOL_MENU_CSS = `
4514
+ .tr-wmenu{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:min(880px,92vw);max-height:78vh;display:none;flex-direction:column;z-index:30;pointer-events:auto}
4515
+ .tr-wmenu.open{display:flex}
4516
+ .tr-wmenu header{display:flex;gap:6px;align-items:center;margin-bottom:6px}.tr-wmenu header h3{margin:0;flex:1}
4517
+ .tr-wmenu .tr-search{margin:0;width:220px}
4518
+ .tr-wmenu-body{overflow:auto;flex:1}
4519
+ .tr-wmenu-sec h4{margin:8px 0 4px;font-size:11px;color:#9aa3b2;text-transform:uppercase;letter-spacing:.04em;font-weight:600}
4520
+ .tr-wmenu-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(128px,1fr));gap:4px}
4521
+ .tr-wmenu-grid .tr-btn{display:flex;flex-direction:column;gap:3px;min-width:0;text-align:left}
4522
+ .tr-wmenu-grid .tr-btn span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
4523
+ .tr-wmenu-grid .tr-thumb{height:22px}
4524
+ .tr-menu-btn{margin-left:6px;max-width:none}
4525
+ `;
4526
+ var mk3 = (tag, cls, text) => {
4527
+ const e = document.createElement(tag);
4528
+ if (cls) e.className = cls;
4529
+ if (text !== void 0) e.textContent = text;
4530
+ return e;
4531
+ };
4532
+ var fold = (text) => text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
4533
+ function createToolMenu(o) {
4534
+ const element = mk3("div", "tr-panel tr-wmenu");
4535
+ element.dataset.toolMenu = "";
4536
+ const header = mk3("header");
4537
+ const search = mk3("input", "tr-search");
4538
+ search.type = "search";
4539
+ search.placeholder = o.t("ui.tool_menu.search");
4540
+ const close = mk3("button", "tr-btn", "\xD7");
4541
+ close.type = "button";
4542
+ close.title = o.t("ui.tool_menu.close");
4543
+ header.append(mk3("h3", void 0, o.t("ui.tool_menu")), search, close);
4544
+ const tabs = mk3("div", "tr-tabs");
4545
+ const body = mk3("div", "tr-wmenu-body");
4546
+ element.append(header, tabs, body);
4547
+ let open = false;
4548
+ let activeGroup = "";
4549
+ let groups = [];
4550
+ const toolButton = (tool, group) => {
4551
+ const button = mk3("button", tool.id === o.current() ? "tr-btn on" : "tr-btn");
4552
+ button.type = "button";
4553
+ button.title = tool.name;
4554
+ button.dataset.tool = tool.id;
4555
+ if (tool.thumbnail) button.appendChild(o.thumb(tool.thumbnail, tool.thumbnailCredit));
4556
+ button.appendChild(mk3("span", void 0, tool.name));
4557
+ if (group) button.appendChild(mk3("small", void 0, group));
4558
+ button.onclick = () => {
4559
+ o.onPick(tool.id);
4560
+ toggle(false);
4561
+ };
4562
+ return button;
4563
+ };
4564
+ const section = (name, buttons) => {
4565
+ const wrap = mk3("div", "tr-wmenu-sec");
4566
+ const grid = mk3("div", "tr-wmenu-grid");
4567
+ grid.append(...buttons);
4568
+ wrap.append(mk3("h4", void 0, name), grid);
4569
+ return wrap;
4570
+ };
4571
+ const renderBody = () => {
4572
+ body.textContent = "";
4573
+ const term = fold(search.value.trim());
4574
+ if (term) {
4575
+ const found = groups.flatMap((group2) => group2.sections.flatMap((s) => s.tools.filter((tool) => fold(`${tool.name} ${tool.id} ${tool.keywords ?? ""} ${s.name}`).includes(term)).map((tool) => toolButton(tool, s.name))));
4576
+ body.appendChild(found.length ? section(o.t("ui.tool_menu.results", { n: found.length }), found) : mk3("div", "tr-empty", o.t("ui.no_results")));
4577
+ return;
4578
+ }
4579
+ const group = groups.find((g) => g.id === activeGroup);
4580
+ for (const s of group?.sections ?? []) body.appendChild(section(`${s.name} (${s.tools.length})`, s.tools.map((tool) => toolButton(tool))));
4581
+ };
4582
+ const renderTabs = () => {
4583
+ tabs.textContent = "";
4584
+ if (!groups.some((g) => g.id === activeGroup)) activeGroup = groups.find((g) => g.sections.some((s) => s.tools.some((tool) => tool.id === o.current())))?.id ?? groups[0]?.id ?? "";
4585
+ for (const group of groups) {
4586
+ const count = group.sections.reduce((sum, s) => sum + s.tools.length, 0);
4587
+ const button = mk3("button", group.id === activeGroup ? "tr-btn on" : "tr-btn", `${group.name} (${count})`);
4588
+ button.type = "button";
4589
+ button.dataset.group = group.id;
4590
+ button.onclick = () => {
4591
+ activeGroup = group.id;
4592
+ search.value = "";
4593
+ renderTabs();
4594
+ };
4595
+ tabs.appendChild(button);
4596
+ }
4597
+ renderBody();
4598
+ };
4599
+ search.oninput = renderBody;
4600
+ close.onclick = () => toggle(false);
4601
+ element.addEventListener("keydown", (e) => {
4602
+ if (e.code === "Escape") toggle(false);
4603
+ });
4604
+ function toggle(next) {
4605
+ const want = next ?? !open;
4606
+ if (want === open) return open;
4607
+ open = want;
4608
+ element.classList.toggle("open", open);
4609
+ if (open) {
4610
+ groups = o.groups();
4611
+ activeGroup = "";
4612
+ search.value = "";
4613
+ renderTabs();
4614
+ }
4615
+ o.onOpen?.(open);
4616
+ return open;
4617
+ }
4618
+ return {
4619
+ element,
4620
+ toggle,
4621
+ get open() {
4622
+ return open;
4623
+ }
4624
+ };
4625
+ }
4626
+
4486
4627
  // src/ui.ts
4487
4628
  var CSS = `
4488
4629
  .tr-root{position:absolute;inset:0;pointer-events:none;font:12px/1.35 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;color:#e8eaee;overflow:hidden}
@@ -4563,8 +4704,8 @@ var CSS = `
4563
4704
  .tr-summary{margin-top:6px;color:#9aa3b2}
4564
4705
  @media (max-width:980px){.tr-left,.tr-right{display:none}.tr-root.open .tr-left,.tr-root.open .tr-right{display:flex}.tr-toggle{display:block;pointer-events:auto}.tr-root.open .tr-toggle{right:min(310px,34vw)}.tr-left{width:min(400px,46vw)}.tr-help{display:none}}
4565
4706
  `;
4566
- var fold = (text) => text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
4567
- var mk3 = (tag, cls, text) => {
4707
+ var fold2 = (text) => text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
4708
+ var mk4 = (tag, cls, text) => {
4568
4709
  const e = document.createElement(tag);
4569
4710
  if (cls) e.className = cls;
4570
4711
  if (text !== void 0) e.textContent = text;
@@ -4575,7 +4716,7 @@ var thumbSource = (thumbnail) => {
4575
4716
  return source.replace(/["\\\n\r]/g, (char) => encodeURIComponent(char));
4576
4717
  };
4577
4718
  var thumb = (thumbnail, credit) => {
4578
- const e = mk3("span", "tr-thumb");
4719
+ const e = mk4("span", "tr-thumb");
4579
4720
  if (credit) e.title = credit;
4580
4721
  else e.setAttribute("aria-hidden", "true");
4581
4722
  e.style.setProperty("--tr-thumb", `url("${thumbSource(thumbnail)}")`);
@@ -4596,15 +4737,15 @@ async function toggleFullscreen() {
4596
4737
  }
4597
4738
  function createUi(o) {
4598
4739
  const { t } = o;
4599
- const style = mk3("style");
4600
- style.textContent = CSS + (o.testTable ? TEST_TABLE_CSS : "");
4601
- const root = mk3("div", "tr-root");
4740
+ const style = mk4("style");
4741
+ style.textContent = CSS + (o.testTable ? TEST_TABLE_CSS : "") + (o.toolMenu ? TOOL_MENU_CSS : "");
4742
+ const root = mk4("div", "tr-root");
4602
4743
  const pos = getComputedStyle?.(o.container)?.position;
4603
4744
  if (!pos || pos === "static") o.container.style.position = "relative";
4604
4745
  root.appendChild(style);
4605
4746
  o.container.appendChild(root);
4606
- const cross = mk3("div", "tr-cross");
4607
- cross.appendChild(mk3("i"));
4747
+ const cross = mk4("div", "tr-cross");
4748
+ cross.appendChild(mk4("i"));
4608
4749
  const ring = document.createElementNS("http://www.w3.org/2000/svg", "svg");
4609
4750
  ring.setAttribute("class", "tr-ring");
4610
4751
  ring.setAttribute("viewBox", "0 0 64 64");
@@ -4621,35 +4762,45 @@ function createUi(o) {
4621
4762
  const ringFill = ringCircle("tr-ring-fill");
4622
4763
  const RING_LENGTH = 2 * Math.PI * 27;
4623
4764
  ringFill.style.strokeDasharray = String(RING_LENGTH);
4624
- const top = mk3("div", "tr-top");
4625
- const toolBox = mk3("div", "tr-tool");
4626
- const status = mk3("div", "tr-status");
4627
- const alertBox = mk3("div", "tr-alert");
4628
- const prog = mk3("div", "tr-prog");
4629
- const progLabel = mk3("b");
4630
- const progBar = mk3("div", "tr-bar");
4631
- const progFill = mk3("i");
4765
+ const top = mk4("div", "tr-top");
4766
+ const toolBox = mk4("div", "tr-tool");
4767
+ const status = mk4("div", "tr-status");
4768
+ const alertBox = mk4("div", "tr-alert");
4769
+ const prog = mk4("div", "tr-prog");
4770
+ const progLabel = mk4("b");
4771
+ const progBar = mk4("div", "tr-bar");
4772
+ const progFill = mk4("i");
4632
4773
  progBar.appendChild(progFill);
4633
4774
  prog.append(progLabel, progBar);
4634
4775
  top.append(toolBox, alertBox, status, prog);
4635
- const bottom = mk3("div", "tr-bottom");
4636
- const held = mk3("div", "tr-held");
4637
- const hotbar = mk3("div", "tr-hotbar");
4776
+ const bottom = mk4("div", "tr-bottom");
4777
+ const held = mk4("div", "tr-held");
4778
+ const hotbar = mk4("div", "tr-hotbar");
4638
4779
  bottom.append(held, hotbar);
4639
- const help = mk3("div", "tr-help");
4780
+ let currentTool = "";
4781
+ const menuOptions = o.toolMenu;
4782
+ const toolMenu = menuOptions ? createToolMenu({ t, groups: menuOptions.groups, current: () => currentTool, onPick: menuOptions.onPick, ...menuOptions.onOpen ? { onOpen: menuOptions.onOpen } : {}, thumb }) : null;
4783
+ if (toolMenu && menuOptions) {
4784
+ const menuButton = mk4("button", "tr-slot tr-menu-btn");
4785
+ menuButton.type = "button";
4786
+ menuButton.append(mk4("b", void 0, menuOptions.keyLabel), document.createTextNode(t("ui.tool_menu")));
4787
+ menuButton.onclick = () => toolMenu.toggle();
4788
+ bottom.appendChild(menuButton);
4789
+ }
4790
+ const help = mk4("div", "tr-help");
4640
4791
  help.textContent = t("ui.help");
4641
- const left = mk3("div", "tr-left");
4642
- const insp = mk3("div", "tr-panel tr-insp");
4643
- const logPanel = mk3("div", "tr-panel tr-log");
4792
+ const left = mk4("div", "tr-left");
4793
+ const insp = mk4("div", "tr-panel tr-insp");
4794
+ const logPanel = mk4("div", "tr-panel tr-log");
4644
4795
  const tablePanel = o.testTable ? createTestTablePanel(o.testTable, t) : null;
4645
4796
  left.append(insp, ...tablePanel ? [tablePanel.element] : [], logPanel);
4646
- const logTitle = mk3("h3", void 0, t("ui.log"));
4647
- const logBar = mk3("div", "tr-logbar");
4648
- const logBody = mk3("div", "tr-logbody");
4797
+ const logTitle = mk4("h3", void 0, t("ui.log"));
4798
+ const logBar = mk4("div", "tr-logbar");
4799
+ const logBody = mk4("div", "tr-logbody");
4649
4800
  const filters = { action: true, event: true, check: true };
4650
4801
  const filterBtns = [];
4651
4802
  for (const ch of ["action", "event", "check"]) {
4652
- const b = mk3("button", "tr-btn on", t(`ui.filter.${ch}`));
4803
+ const b = mk4("button", "tr-btn on", t(`ui.filter.${ch}`));
4653
4804
  b.type = "button";
4654
4805
  b.onclick = () => {
4655
4806
  filters[ch] = !filters[ch];
@@ -4660,8 +4811,8 @@ function createUi(o) {
4660
4811
  logBar.appendChild(b);
4661
4812
  }
4662
4813
  let autoscroll = true;
4663
- const asLabel = mk3("label");
4664
- const asBox = mk3("input");
4814
+ const asLabel = mk4("label");
4815
+ const asBox = mk4("input");
4665
4816
  asBox.type = "checkbox";
4666
4817
  asBox.checked = true;
4667
4818
  asBox.onchange = () => {
@@ -4670,7 +4821,7 @@ function createUi(o) {
4670
4821
  asLabel.append(asBox, document.createTextNode(" " + t("ui.autoscroll")));
4671
4822
  logBar.appendChild(asLabel);
4672
4823
  const logBtn = (label, fn) => {
4673
- const b = mk3("button", "tr-btn", label);
4824
+ const b = mk4("button", "tr-btn", label);
4674
4825
  b.type = "button";
4675
4826
  b.onclick = fn;
4676
4827
  logBar.appendChild(b);
@@ -4678,7 +4829,7 @@ function createUi(o) {
4678
4829
  logBtn(t("ui.copy_json"), () => {
4679
4830
  const text = o.log.toJSON();
4680
4831
  const select = () => {
4681
- const ta = mk3("textarea");
4832
+ const ta = mk4("textarea");
4682
4833
  ta.value = text;
4683
4834
  ta.setAttribute("readonly", "");
4684
4835
  ta.style.cssText = "position:fixed;left:8px;bottom:8px;width:min(480px,80vw);height:120px;z-index:99999";
@@ -4711,7 +4862,7 @@ function createUi(o) {
4711
4862
  const fill = (n, e) => {
4712
4863
  n.className = lineClass(e);
4713
4864
  n.textContent = "";
4714
- const tm = mk3("time", void 0, fmtTime(e.t));
4865
+ const tm = mk4("time", void 0, fmtTime(e.t));
4715
4866
  n.append(tm, document.createTextNode(e.text));
4716
4867
  };
4717
4868
  let pendingAdds = [];
@@ -4719,7 +4870,7 @@ function createUi(o) {
4719
4870
  let isLogStale = false;
4720
4871
  const isLogVisible = () => !window.matchMedia?.("(max-width:980px)").matches || root.classList.contains("open");
4721
4872
  const buildNode = (e) => {
4722
- const n = mk3("div");
4873
+ const n = mk4("div");
4723
4874
  fill(n, e);
4724
4875
  nodes.set(e.seq, n);
4725
4876
  return n;
@@ -4775,41 +4926,41 @@ function createUi(o) {
4775
4926
  if (updated) pendingUpdates.set(e.seq, e);
4776
4927
  else pendingAdds.push(e);
4777
4928
  });
4778
- const right = mk3("div", "tr-right");
4779
- const cat = mk3("div", "tr-panel tr-cat");
4780
- const anim = mk3("div", "tr-panel tr-anim");
4781
- const tests = mk3("div", "tr-panel tr-tests");
4929
+ const right = mk4("div", "tr-right");
4930
+ const cat = mk4("div", "tr-panel tr-cat");
4931
+ const anim = mk4("div", "tr-panel tr-anim");
4932
+ const tests = mk4("div", "tr-panel tr-tests");
4782
4933
  const benchPanel = createBenchPanel({ t, num: (n, d) => num(n, d), onControl: o.onControl, onLayer: o.onLayer, onAction: o.onAction });
4783
4934
  right.append(cat, anim, benchPanel.element, tests);
4784
- cat.appendChild(mk3("h3", void 0, t("ui.catalog")));
4785
- const itemSearch = mk3("input", "tr-search");
4935
+ cat.appendChild(mk4("h3", void 0, t("ui.catalog")));
4936
+ const itemSearch = mk4("input", "tr-search");
4786
4937
  itemSearch.type = "search";
4787
4938
  itemSearch.placeholder = t("ui.search_items");
4788
- const tabs = mk3("div", "tr-tabs");
4789
- const items = mk3("div", "tr-items");
4939
+ const tabs = mk4("div", "tr-tabs");
4940
+ const items = mk4("div", "tr-items");
4790
4941
  cat.append(itemSearch, tabs, items);
4791
4942
  let activeGroup = "";
4792
4943
  const visibleItems = () => {
4793
- const term = fold(itemSearch.value.trim());
4944
+ const term = fold2(itemSearch.value.trim());
4794
4945
  if (!term) return (o.groups().get(activeGroup) ?? []).map((item) => ({ item, group: activeGroup }));
4795
- return [...o.groups()].flatMap(([group, list2]) => list2.filter((item) => fold(`${item.name} ${item.id} ${group}`).includes(term)).map((item) => ({ item, group })));
4946
+ return [...o.groups()].flatMap(([group, list2]) => list2.filter((item) => fold2(`${item.name} ${item.id} ${group}`).includes(term)).map((item) => ({ item, group })));
4796
4947
  };
4797
4948
  const renderItems = () => {
4798
4949
  items.textContent = "";
4799
4950
  const searching = itemSearch.value.trim() !== "";
4800
4951
  const found = visibleItems();
4801
- if (!found.length) items.appendChild(mk3("div", "tr-empty", t("ui.no_results")));
4952
+ if (!found.length) items.appendChild(mk4("div", "tr-empty", t("ui.no_results")));
4802
4953
  for (const { item: it, group } of found) {
4803
- const wrap = mk3("div", "tr-item");
4804
- const b = mk3("button", it.thumbnail ? "tr-btn has-thumb" : "tr-btn", it.thumbnail ? void 0 : it.name);
4805
- if (it.thumbnail) b.append(thumb(it.thumbnail), mk3("span", "tr-name", it.name));
4806
- if (searching) b.appendChild(mk3("small", void 0, group));
4954
+ const wrap = mk4("div", "tr-item");
4955
+ const b = mk4("button", it.thumbnail ? "tr-btn has-thumb" : "tr-btn", it.thumbnail ? void 0 : it.name);
4956
+ if (it.thumbnail) b.append(thumb(it.thumbnail), mk4("span", "tr-name", it.name));
4957
+ if (searching) b.appendChild(mk4("small", void 0, group));
4807
4958
  b.type = "button";
4808
4959
  b.title = it.note ?? it.name;
4809
4960
  b.onclick = () => o.onSpawn(it.id);
4810
4961
  wrap.appendChild(b);
4811
4962
  if (it.weapon) {
4812
- const w = mk3("button", "tr-btn", "\u2694");
4963
+ const w = mk4("button", "tr-btn", "\u2694");
4813
4964
  w.type = "button";
4814
4965
  w.title = t("ui.equip");
4815
4966
  w.onclick = () => o.onEquip(it.id);
@@ -4823,7 +4974,7 @@ function createUi(o) {
4823
4974
  const groupNames = [...o.groups().keys()];
4824
4975
  if (!groupNames.includes(activeGroup)) activeGroup = groupNames[0] ?? "";
4825
4976
  for (const g of groupNames) {
4826
- const b = mk3("button", g === activeGroup ? "tr-btn on" : "tr-btn", g);
4977
+ const b = mk4("button", g === activeGroup ? "tr-btn on" : "tr-btn", g);
4827
4978
  b.type = "button";
4828
4979
  b.onclick = () => {
4829
4980
  activeGroup = g;
@@ -4836,9 +4987,9 @@ function createUi(o) {
4836
4987
  renderTabs();
4837
4988
  itemSearch.oninput = renderItems;
4838
4989
  for (const box2 of [itemSearch]) box2.addEventListener("keydown", (e) => e.stopPropagation());
4839
- const controls = mk3("div", "tr-row");
4990
+ const controls = mk4("div", "tr-row");
4840
4991
  const ctl = (label, fn) => {
4841
- const b = mk3("button", "tr-btn", label);
4992
+ const b = mk4("button", "tr-btn", label);
4842
4993
  b.type = "button";
4843
4994
  b.onclick = () => fn(b);
4844
4995
  controls.appendChild(b);
@@ -4869,34 +5020,34 @@ function createUi(o) {
4869
5020
  const g = ctl(t("ui.graphics"), () => o.onGraphics());
4870
5021
  g.dataset.testid = "graphics-button";
4871
5022
  } else {
4872
- const qSel = mk3("select");
5023
+ const qSel = mk4("select");
4873
5024
  for (const q of ["low", "medium", "high"]) {
4874
- const op = mk3("option", void 0, t(`ui.quality.${q}`));
5025
+ const op = mk4("option", void 0, t(`ui.quality.${q}`));
4875
5026
  op.value = q;
4876
5027
  qSel.appendChild(op);
4877
5028
  }
4878
5029
  qSel.value = o.quality;
4879
5030
  qSel.onchange = () => o.onQuality(qSel.value);
4880
- const qLabel = mk3("label", void 0, t("ui.quality") + " ");
5031
+ const qLabel = mk4("label", void 0, t("ui.quality") + " ");
4881
5032
  qLabel.appendChild(qSel);
4882
5033
  controls.appendChild(qLabel);
4883
5034
  }
4884
- const count = mk3("span");
5035
+ const count = mk4("span");
4885
5036
  controls.appendChild(count);
4886
5037
  cat.appendChild(controls);
4887
- tests.appendChild(mk3("h3", void 0, t("ui.tests")));
4888
- const runAll = mk3("button", "tr-btn", t("ui.run_all"));
5038
+ tests.appendChild(mk4("h3", void 0, t("ui.tests")));
5039
+ const runAll = mk4("button", "tr-btn", t("ui.run_all"));
4889
5040
  runAll.type = "button";
4890
- const summary = mk3("div", "tr-summary");
4891
- const testSearch = mk3("input", "tr-search");
5041
+ const summary = mk4("div", "tr-summary");
5042
+ const testSearch = mk4("input", "tr-search");
4892
5043
  testSearch.type = "search";
4893
5044
  testSearch.placeholder = t("ui.search_tests");
4894
5045
  testSearch.addEventListener("keydown", (e) => e.stopPropagation());
4895
- const list = mk3("div", "list");
5046
+ const list = mk4("div", "list");
4896
5047
  tests.append(runAll, summary, testSearch, list);
4897
5048
  testSearch.oninput = () => {
4898
- const term = fold(testSearch.value.trim());
4899
- for (const b of scBtns.values()) b.style.display = !term || fold(b.textContent ?? "").includes(term) ? "" : "none";
5049
+ const term = fold2(testSearch.value.trim());
5050
+ for (const b of scBtns.values()) b.style.display = !term || fold2(b.textContent ?? "").includes(term) ? "" : "none";
4900
5051
  };
4901
5052
  const scBtns = /* @__PURE__ */ new Map();
4902
5053
  let busy = false;
@@ -4915,7 +5066,7 @@ function createUi(o) {
4915
5066
  list.textContent = "";
4916
5067
  scBtns.clear();
4917
5068
  for (const s of o.scenarios()) {
4918
- const b = mk3("button", "tr-btn", s.name);
5069
+ const b = mk4("button", "tr-btn", s.name);
4919
5070
  b.type = "button";
4920
5071
  b.onclick = async () => {
4921
5072
  if (busy) return;
@@ -4941,7 +5092,7 @@ function createUi(o) {
4941
5092
  busy = false;
4942
5093
  }
4943
5094
  };
4944
- const toggle = mk3("button", "tr-btn tr-toggle", t("ui.panel"));
5095
+ const toggle = mk4("button", "tr-btn tr-toggle", t("ui.panel"));
4945
5096
  toggle.type = "button";
4946
5097
  const togglePanels = () => {
4947
5098
  root.classList.toggle("open");
@@ -4949,17 +5100,17 @@ function createUi(o) {
4949
5100
  o.onResize();
4950
5101
  };
4951
5102
  toggle.onclick = togglePanels;
4952
- root.append(cross, ring, top, bottom, help, left, right, toggle);
5103
+ root.append(cross, ring, top, bottom, help, left, right, toggle, ...toolMenu ? [toolMenu.element] : []);
4953
5104
  const bar = (value, max, cls) => {
4954
- const b = mk3("div", `tr-bar ${cls}`);
4955
- const i = mk3("i");
5105
+ const b = mk4("div", `tr-bar ${cls}`);
5106
+ const i = mk4("i");
4956
5107
  i.style.width = `${Math.max(0, Math.min(100, value / Math.max(1e-9, max) * 100))}%`;
4957
5108
  b.appendChild(i);
4958
5109
  return b;
4959
5110
  };
4960
5111
  const row = (k, v) => {
4961
- const r = mk3("div", "r");
4962
- r.append(mk3("span", void 0, k), mk3("span", void 0, v));
5112
+ const r = mk4("div", "r");
5113
+ r.append(mk4("span", void 0, k), mk4("span", void 0, v));
4963
5114
  return r;
4964
5115
  };
4965
5116
  const num = (n, d = 1) => {
@@ -4968,14 +5119,14 @@ function createUi(o) {
4968
5119
  };
4969
5120
  const renderInspector = (i) => {
4970
5121
  insp.textContent = "";
4971
- insp.appendChild(mk3("h3", void 0, t("ui.inspector")));
5122
+ insp.appendChild(mk4("h3", void 0, t("ui.inspector")));
4972
5123
  if (!i) {
4973
- insp.appendChild(mk3("div", void 0, t("ui.aim_at_something")));
5124
+ insp.appendChild(mk4("div", void 0, t("ui.aim_at_something")));
4974
5125
  return;
4975
5126
  }
4976
5127
  if (i.ground) {
4977
5128
  const g = i.ground;
4978
- insp.appendChild(mk3("div", "t", `${i.object} \u203A ${g.surface}`));
5129
+ insp.appendChild(mk4("div", "t", `${i.object} \u203A ${g.surface}`));
4979
5130
  insp.appendChild(row(t("ins.material"), g.material));
4980
5131
  insp.appendChild(row(t("ins.dug"), `${num(g.dug * 100, 0)} cm`));
4981
5132
  insp.appendChild(row(t("ins.dig_limit"), `${num(g.remaining, 2)} m`));
@@ -4983,7 +5134,7 @@ function createUi(o) {
4983
5134
  return;
4984
5135
  }
4985
5136
  if (i.thumbnail) insp.appendChild(thumb(i.thumbnail, i.thumbnailCredit));
4986
- insp.appendChild(mk3("div", "t", `${i.object} \u203A ${i.part}`));
5137
+ insp.appendChild(mk4("div", "t", `${i.object} \u203A ${i.part}`));
4987
5138
  insp.appendChild(row(t("ins.material"), i.material));
4988
5139
  insp.appendChild(row(t("ins.status"), i.status + (i.burning ? ` \xB7 ${t("ins.burning")}` : "")));
4989
5140
  insp.appendChild(row(t("ins.hp"), `${num(i.hp, 0)} / ${num(i.maxHp, 0)}`));
@@ -5002,30 +5153,30 @@ function createUi(o) {
5002
5153
  insp.appendChild(bar(Math.min(i.stress, 1.2), 1.2, i.stress > 1 ? "bad" : i.stress > 0.7 ? "warn" : "hp"));
5003
5154
  }
5004
5155
  if (i.joints.length) {
5005
- insp.appendChild(mk3("div", void 0, t("ins.joints") + ":"));
5156
+ insp.appendChild(mk4("div", void 0, t("ins.joints") + ":"));
5006
5157
  for (const j of i.joints) {
5007
5158
  const tools = j.tools.length ? j.tools.join(", ") : t("tool.none");
5008
- insp.appendChild(mk3("div", void 0, `\u2022 ${j.kind} (${j.id})${j.open ? ` \xB7 ${t("ins.open")}` : ` \u2192 ${tools}`}`));
5159
+ insp.appendChild(mk4("div", void 0, `\u2022 ${j.kind} (${j.id})${j.open ? ` \xB7 ${t("ins.open")}` : ` \u2192 ${tools}`}`));
5009
5160
  }
5010
5161
  }
5011
- if (i.note) insp.appendChild(mk3("div", void 0, i.note));
5162
+ if (i.note) insp.appendChild(mk4("div", void 0, i.note));
5012
5163
  };
5013
5164
  let lastAnim = "";
5014
5165
  const renderAnimations = (a) => {
5015
5166
  anim.textContent = "";
5016
- anim.appendChild(mk3("h3", void 0, t("ui.animations")));
5167
+ anim.appendChild(mk4("h3", void 0, t("ui.animations")));
5017
5168
  if (!a) {
5018
- anim.appendChild(mk3("div", "sub", t("ui.animations_aim")));
5169
+ anim.appendChild(mk4("div", "sub", t("ui.animations_aim")));
5019
5170
  return;
5020
5171
  }
5021
- anim.appendChild(mk3("div", "sub", a.name));
5172
+ anim.appendChild(mk4("div", "sub", a.name));
5022
5173
  if (a.clips.length === 0) {
5023
- anim.appendChild(mk3("div", "sub", t("ui.animations_none")));
5174
+ anim.appendChild(mk4("div", "sub", t("ui.animations_none")));
5024
5175
  return;
5025
5176
  }
5026
- const l = mk3("div", "list");
5177
+ const l = mk4("div", "list");
5027
5178
  for (const c of a.clips) {
5028
- const b = mk3("button", a.playing === c.id ? "tr-btn on" : "tr-btn", `\u25B6 ${t(`anim.${c.id}`) === `anim.${c.id}` ? c.id : t(`anim.${c.id}`)} (${num(c.duration, 1)} s)`);
5179
+ const b = mk4("button", a.playing === c.id ? "tr-btn on" : "tr-btn", `\u25B6 ${t(`anim.${c.id}`) === `anim.${c.id}` ? c.id : t(`anim.${c.id}`)} (${num(c.duration, 1)} s)`);
5029
5180
  b.type = "button";
5030
5181
  b.onclick = () => o.onPlay(a.objectId, c.id);
5031
5182
  l.appendChild(b);
@@ -5045,10 +5196,11 @@ function createUi(o) {
5045
5196
  },
5046
5197
  flushLog,
5047
5198
  hud(s) {
5199
+ currentTool = s.toolId;
5048
5200
  toolBox.textContent = "";
5049
5201
  toolBox.append(document.createTextNode(s.toolName));
5050
- if (s.ammo) toolBox.append(mk3("small", void 0, s.ammo));
5051
- if (s.camera) toolBox.append(mk3("small", void 0, t(`camera.${s.camera}`)));
5202
+ if (s.ammo) toolBox.append(mk4("small", void 0, s.ammo));
5203
+ if (s.camera) toolBox.append(mk4("small", void 0, t(`camera.${s.camera}`)));
5052
5204
  const helpText = s.camera ? `${t("ui.help")} \xB7 ${t("ui.help.camera")}` : t("ui.help");
5053
5205
  if (help.textContent !== helpText) help.textContent = helpText;
5054
5206
  if (s.status) status.textContent = s.status;
@@ -5071,7 +5223,7 @@ function createUi(o) {
5071
5223
  ];
5072
5224
  if (s.held.weapon) keys.push(["H", t("hud.equip", { weapon: s.held.weapon })]);
5073
5225
  for (const [k, label] of keys) {
5074
- held.append(mk3("kbd", void 0, k), document.createTextNode(` ${label} `));
5226
+ held.append(mk4("kbd", void 0, k), document.createTextNode(` ${label} `));
5075
5227
  }
5076
5228
  } else held.style.display = "none";
5077
5229
  const key2 = s.slots.join("|") + s.page;
@@ -5081,9 +5233,9 @@ function createUi(o) {
5081
5233
  slotsEl.length = 0;
5082
5234
  hudPage = s.page - 1;
5083
5235
  s.slots.forEach((name, i) => {
5084
- const b = mk3("button", "tr-slot");
5236
+ const b = mk4("button", "tr-slot");
5085
5237
  b.type = "button";
5086
- b.append(mk3("b", void 0, String((i + 1) % 10)), document.createTextNode(name));
5238
+ b.append(mk4("b", void 0, String((i + 1) % 10)), document.createTextNode(name));
5087
5239
  b.title = name;
5088
5240
  b.onclick = () => o.onTool(hudPage * 10 + i);
5089
5241
  hotbar.appendChild(b);
@@ -5128,6 +5280,7 @@ function createUi(o) {
5128
5280
  renderTabs();
5129
5281
  },
5130
5282
  togglePanels,
5283
+ toolMenu: (open) => toolMenu?.toggle(open) ?? false,
5131
5284
  bench: benchPanel.show,
5132
5285
  metrics: benchPanel.metrics,
5133
5286
  get benchVisible() {
@@ -5274,6 +5427,7 @@ function createModuleViews(THREE, gl) {
5274
5427
 
5275
5428
  // src/room.ts
5276
5429
  var FIXED_DT = 1 / 60;
5430
+ var TOOL_MENU_KEY_LABELS = { Backquote: "'" };
5277
5431
  var EXPLOSIVE_EFFECT_IDS = ["m67", "molotov", "pipe_bomb", "m79_40mm", "m72_66mm", "at4_84mm", "dynamite", "c4", "claymore"];
5278
5432
  var DEFAULT_PARTICLE_LIMIT = 900;
5279
5433
  var WARMED_FOG_STATES = ["none", "linear", "exp2"];
@@ -5288,6 +5442,8 @@ var KICK_MASS = 6;
5288
5442
  var KICK_HEIGHT = 0.6;
5289
5443
  var GROUND_ID = "room_ground";
5290
5444
  var TERRAIN_CELL = 0.1;
5445
+ var GROUND_SPLASH_MAX_LITRES = 3;
5446
+ var WATER_ENTRY_SPEED = 1.5;
5291
5447
  var TERRAIN_MAX_CELLS = 1e3;
5292
5448
  var GROUND_BURST_MAX = 28;
5293
5449
  var GROUND_BURST_PER_METRE = 30;
@@ -5379,7 +5535,7 @@ function createTestRoom(options) {
5379
5535
  const restingTops = [];
5380
5536
  const restingTopAt = (x, z) => {
5381
5537
  let top = 0;
5382
- for (const c of restingTops) if (x > c.minX && x < c.maxX && z > c.minZ && z < c.maxZ && world.has(c.objectId)) top = Math.max(top, c.top);
5538
+ for (const c of restingTops) if (x > c.minX && x < c.maxX && z > c.minZ && z < c.maxZ && world.has(c.objectId) && !renderer.piece(c.objectId)) top = Math.max(top, c.top);
5383
5539
  return top;
5384
5540
  };
5385
5541
  const sceneryTopAt = (x, z) => {
@@ -5422,13 +5578,14 @@ function createTestRoom(options) {
5422
5578
  const preload = () => {
5423
5579
  window.removeEventListener("keydown", preload, true);
5424
5580
  window.removeEventListener("pointerdown", preload, true);
5425
- void audio.loadSamples(["latch", "creak_wood", "shut_wood", "rattle", "grab_hard", "grab_soft"]).catch(() => void 0);
5581
+ void audio.loadSamples([...HAND_RECORDED_IDS, ...impactSoundIds()]).catch(() => void 0);
5426
5582
  };
5427
5583
  window.addEventListener("keydown", preload, true);
5428
5584
  window.addEventListener("pointerdown", preload, true);
5429
5585
  });
5430
5586
  }
5431
5587
  const bodySounds = createBodySounds(audio);
5588
+ if (audio && options.fittedSounds !== false) registerFittedSounds(audio);
5432
5589
  const playSound = (key2, point, volume, detail) => {
5433
5590
  destructionSound?.onSound(key2, point, volume, detail);
5434
5591
  if (detail) options.onSound?.(key2, point, volume, detail);
@@ -5447,7 +5604,8 @@ function createTestRoom(options) {
5447
5604
  vfx: createVfx({
5448
5605
  profile: quality,
5449
5606
  ...options.debris?.particles ? { sprites: { kinds: patchSpriteKinds(SPRITE_KINDS, options.debris.particles) } } : {},
5450
- groundAt
5607
+ groundAt,
5608
+ marklessAt: (x, z) => terrain.waterAt(x, z) !== null
5451
5609
  })
5452
5610
  });
5453
5611
  const renderer = createDestructionRenderer(THREE, {
@@ -5500,8 +5658,11 @@ function createTestRoom(options) {
5500
5658
  const material = terrain.material(result.removed[0]?.material ?? result.exposed);
5501
5659
  const def = world.materials.get(material.id);
5502
5660
  const size = Math.min(GROUND_BURST_MAX, Math.ceil(result.radius * GROUND_BURST_PER_METRE));
5503
- renderer.emit(material.debris === "clods" ? "dust" : material.debris, result.point, [0, 1, 0], Math.max(2, size), Math.min(4, 1 + result.radius * 4));
5661
+ renderer.emit(material.debris === "clods" ? "dust" : material.debris === "blocks" ? "chips" : material.debris, result.point, [0, 1, 0], Math.max(2, size), Math.min(4, 1 + result.radius * 4));
5504
5662
  if (result.radius > GROUND_DUST_RADIUS) renderer.emit("dust", result.point, [0, 1, 0], size, 1.4);
5663
+ const litres = Math.min(GROUND_SPLASH_MAX_LITRES, 0.1 + result.radius * 2);
5664
+ if (result.water) renderer.emit("foam", [result.point[0], result.water.level + 0.05, result.point[2]], [0, 1, 0], Math.max(6, size * 2), 2 + result.radius * 4);
5665
+ else if (material.splash) fluids.splash(material.splash, [result.point[0], result.point[1] + 0.05, result.point[2]], litres, [0, 1.5 + result.radius * 3, 0]);
5505
5666
  playSound(impactKey(def), result.point, Math.min(1, 0.25 + result.radius), impactSoundKeys(material.id, def));
5506
5667
  };
5507
5668
  terrain.onDeform((region) => renderer.wakeRegion(region.x, region.z, region.radius));
@@ -5535,7 +5696,7 @@ function createTestRoom(options) {
5535
5696
  const specs = /* @__PURE__ */ new Map();
5536
5697
  const loose = [];
5537
5698
  const arsenal = options.arsenal ?? createArsenal({ locale, weapons: options.weapons, seed: 1 });
5538
- const loadout = (options.loadout ?? DEFAULT_LOADOUT).filter((id) => arsenal.weapons.has(id));
5699
+ const loadout = (options.loadout ?? realLoadout(arsenal, DEFAULT_LOADOUT)).filter((id) => arsenal.weapons.has(id));
5539
5700
  const tools = /* @__PURE__ */ new Map();
5540
5701
  let toolIndex = Math.max(0, loadout.indexOf("hand"));
5541
5702
  let toolId = loadout[toolIndex] ?? "hand";
@@ -5739,7 +5900,8 @@ function createTestRoom(options) {
5739
5900
  footing: (x, z, radius, feet) => footing(x, z, radius, feet),
5740
5901
  blockers,
5741
5902
  limit: arenaSize,
5742
- floorAt: terrain.heightAt,
5903
+ floorAt: terrain.footAt,
5904
+ speedAt: terrain.walkFactorAt,
5743
5905
  start: arena.playerStart,
5744
5906
  crouchKeys: options.crouch?.keys,
5745
5907
  onSlam: (event) => slamPlayer(event),
@@ -6336,13 +6498,50 @@ function createTestRoom(options) {
6336
6498
  const playerMass = options.playerMass ?? bodyMass(1.75);
6337
6499
  const SLAM_REACH = PLAYER_RADIUS + 0.35;
6338
6500
  const SLAM_SHOULDER = 1.3;
6501
+ const SLAM_PROBE = PLAYER_RADIUS / 3 + 0.03;
6502
+ const SLAM_INSET = 0.05;
6503
+ const SLAM_SWEEP = 0.2;
6504
+ const slamHit = (feet, direction) => {
6505
+ const [x, y, z] = feet;
6506
+ const [dx, , dz] = direction;
6507
+ const height2 = y + ((player.knocked?.lying ?? 0) > 0.5 ? 0.3 : SLAM_SHOULDER);
6508
+ let nearest = null;
6509
+ for (const e of entries.values()) {
6510
+ const box3 = inTheWay(e, x + dx * SLAM_PROBE, z + dz * SLAM_PROBE, PLAYER_RADIUS);
6511
+ if (!box3 || box3.top - box3.base < STEP_OVER) continue;
6512
+ const [enterX, exitX] = slab(x, dx, box3.minX, box3.maxX);
6513
+ const [enterZ, exitZ] = slab(z, dz, box3.minZ, box3.maxZ);
6514
+ const entry = Math.max(0, enterX, enterZ);
6515
+ const exit2 = Math.min(exitX, exitZ);
6516
+ if (exit2 < entry || nearest && entry >= nearest.entry) continue;
6517
+ nearest = { box: box3, exit: exit2, entry };
6518
+ }
6519
+ if (!nearest) return rayWorld([x, height2, z], direction, SLAM_REACH);
6520
+ const { box: box2, exit } = nearest;
6521
+ const reach = Math.max(SLAM_REACH, exit);
6522
+ const low = box2.base + SLAM_INSET;
6523
+ const high = Math.max(low, box2.top - SLAM_INSET);
6524
+ const first = Math.min(Math.max(height2, low), high);
6525
+ const hit = rayWorld([x, first, z], direction, reach);
6526
+ if (hit) return hit;
6527
+ for (let h = low; h <= high; h += SLAM_SWEEP) {
6528
+ const swept = rayWorld([x, h, z], direction, reach);
6529
+ if (swept) return swept;
6530
+ }
6531
+ return null;
6532
+ };
6533
+ const slab = (origin, step2, min, max) => {
6534
+ if (Math.abs(step2) < 1e-9) return origin > min && origin < max ? [-Infinity, Infinity] : [Infinity, -Infinity];
6535
+ const a = (min - origin) / step2;
6536
+ const b = (max - origin) / step2;
6537
+ return a < b ? [a, b] : [b, a];
6538
+ };
6339
6539
  const slamPlayer = (event) => {
6340
6540
  const [x, feet, z] = player.position;
6341
6541
  const energy = 0.5 * playerMass * event.speed * event.speed;
6342
6542
  let hit = null;
6343
6543
  if (event.against === "obstacle") {
6344
- const from = [x, feet + ((player.knocked?.lying ?? 0) > 0.5 ? 0.3 : SLAM_SHOULDER), z];
6345
- hit = rayWorld(from, event.direction, SLAM_REACH);
6544
+ hit = slamHit([x, feet, z], event.direction);
6346
6545
  if (hit) world.hitPart(hit.object, hit.part, { type: "blunt", point: hit.point, direction: event.direction, energy, radius: 0.2, ...hit.normal ? { normal: hit.normal } : {} });
6347
6546
  }
6348
6547
  bodySounds.slam(event.speed, [x, feet + 0.5, z]);
@@ -6477,6 +6676,7 @@ function createTestRoom(options) {
6477
6676
  camera.updateMatrixWorld(true);
6478
6677
  };
6479
6678
  let handSource = null;
6679
+ let muzzleSource = null;
6480
6680
  let debugView = null;
6481
6681
  let lastHand = { stance: "relaxed", triggerFinger: null, targets: null };
6482
6682
  const strikeClips = options.strikeClips ?? DEFAULT_STRIKE_CLIPS;
@@ -6600,6 +6800,10 @@ function createTestRoom(options) {
6600
6800
  rng = rng * 1664525 + 1013904223 >>> 0;
6601
6801
  return rng / 4294967296;
6602
6802
  };
6803
+ const muzzlePose = () => {
6804
+ const lent = muzzleSource?.();
6805
+ return lent ? { position: [...lent.position], drawsFlash: lent.drawsFlash } : { position: muzzle(), drawsFlash: false };
6806
+ };
6603
6807
  const muzzle = () => {
6604
6808
  aim();
6605
6809
  const e = camera.matrixWorld.elements;
@@ -6788,9 +6992,11 @@ function createTestRoom(options) {
6788
6992
  const fireShots = (shots, recoil) => {
6789
6993
  if (shots.length === 0) return;
6790
6994
  const w = toolName();
6791
- const m = muzzle();
6792
- renderer.emit("flash", m, [aimD[0], aimD[1], aimD[2]], 1, 0);
6793
- renderer.emit("sparks", m, [aimD[0], aimD[1], aimD[2]], 4, 6);
6995
+ const { position: m, drawsFlash } = muzzlePose();
6996
+ if (!drawsFlash) {
6997
+ renderer.emit("flash", m, [aimD[0], aimD[1], aimD[2]], 1, 0);
6998
+ renderer.emit("sparks", m, [aimD[0], aimD[1], aimD[2]], 4, 6);
6999
+ }
6794
7000
  let hits = 0;
6795
7001
  let first = null;
6796
7002
  let energy = 0;
@@ -6854,7 +7060,7 @@ function createTestRoom(options) {
6854
7060
  const cone = u.kind === "flame" ? u.cone : false;
6855
7061
  const rays = fireRays({ origin: u.origin, direction: u.direction, range: u.range, cone, radius: u.radius });
6856
7062
  let target = null;
6857
- const m = muzzle();
7063
+ const m = muzzlePose().position;
6858
7064
  withoutHeld(() => {
6859
7065
  for (const d of rays) {
6860
7066
  const r = world.raycast(u.origin, d, u.range);
@@ -7036,7 +7242,7 @@ function createTestRoom(options) {
7036
7242
  const extinguishUse = (u) => {
7037
7243
  const w = toolName();
7038
7244
  const rays = fireRays({ origin: u.origin, direction: u.direction, range: u.range, cone: true, radius: 0.6 });
7039
- const m = muzzle();
7245
+ const m = muzzlePose().position;
7040
7246
  renderer.emit("foam", m, [u.direction[0], u.direction[1], u.direction[2]], 8, u.range * 1.2);
7041
7247
  const hit = /* @__PURE__ */ new Set();
7042
7248
  withoutHeld(() => {
@@ -7181,6 +7387,24 @@ function createTestRoom(options) {
7181
7387
  return true;
7182
7388
  };
7183
7389
  const kickKeys = options.kick?.keys ?? ["KeyK"];
7390
+ const toolMenuKeys = options.toolMenu === false ? [] : options.toolMenu?.keys ?? ["Backquote"];
7391
+ const toolMenuGroups = () => {
7392
+ const thumbnails = /* @__PURE__ */ new Map();
7393
+ for (const item of catalog) if (item.weapon && item.thumbnail && !thumbnails.has(item.weapon)) thumbnails.set(item.weapon, item);
7394
+ return weaponMenu(arsenal, { locale }).map((group) => ({
7395
+ id: group.id,
7396
+ name: group.name,
7397
+ sections: group.sections.map((section) => ({
7398
+ id: section.id,
7399
+ name: section.name,
7400
+ tools: section.weapons.map((weapon) => {
7401
+ const item = thumbnails.get(weapon.id);
7402
+ return { id: weapon.id, name: weapon.name, ...weapon.maker || weapon.tags ? { keywords: [weapon.maker, ...weapon.tags ?? []].filter(Boolean).join(" ") } : {}, ...item?.thumbnail ? { thumbnail: item.thumbnail } : {}, ...item?.thumbnailCredit ? { thumbnailCredit: item.thumbnailCredit } : {} };
7403
+ })
7404
+ }))
7405
+ }));
7406
+ };
7407
+ const toggleToolMenu = (open) => ui?.toolMenu(open) ?? false;
7184
7408
  const setTool = (id) => {
7185
7409
  if (!arsenal.weapons.has(id)) throw new Error(`game-test-room: unknown weapon "${id}"`);
7186
7410
  releaseTrigger();
@@ -7276,7 +7500,6 @@ function createTestRoom(options) {
7276
7500
  heldVel[0] = heldVel[1] = heldVel[2] = 0;
7277
7501
  playSound(pickupSoundOf(heldEntry?.item?.sounds, world.materials.get(heaviestMaterial(r.object) ?? "")), r.point, Math.min(1, 0.45 + kg * 0.1));
7278
7502
  action("grab", t("act.grab", { name: heldName, kg: num(kg, 1) }), { objectId: r.object, itemId: entryOf(r.object)?.so.itemId, data: { massKg: Math.round(kg * 10) / 10 } });
7279
- playSound(impactKey(world.materials.get(heaviestMaterial(r.object) ?? "")).replace("impact_", "grab_"), v3(piece.root.getWorldPosition(tmpV)), 0.8);
7280
7503
  fire("grab", { held: true, objectId: r.object });
7281
7504
  return true;
7282
7505
  };
@@ -7581,6 +7804,38 @@ function createTestRoom(options) {
7581
7804
  land(p);
7582
7805
  }
7583
7806
  };
7807
+ const inWater = /* @__PURE__ */ new Set();
7808
+ const floatPiece = (piece, dt) => {
7809
+ if (piece.held) return;
7810
+ const box2 = tmpBox.setFromObject(piece.root);
7811
+ const centre = box2.getCenter(tmpV);
7812
+ const here = terrain.waterAt(centre.x, centre.z);
7813
+ if (!here || box2.min.y >= here.level) {
7814
+ inWater.delete(piece.objectId);
7815
+ return;
7816
+ }
7817
+ const size = box2.getSize(tmpV2);
7818
+ const volume = piece.halfExtents[0] * piece.halfExtents[1] * piece.halfExtents[2] * 8;
7819
+ if (!inWater.has(piece.objectId)) {
7820
+ inWater.add(piece.objectId);
7821
+ const speed = -piece.velocity[1];
7822
+ if (speed > WATER_ENTRY_SPEED) {
7823
+ renderer.emit("foam", [centre.x, here.level + 0.05, centre.z], [0, 1, 0], Math.min(GROUND_BURST_MAX, Math.ceil(piece.mass)), speed * 0.5);
7824
+ terrain.ripple(centre.x, centre.z, Math.max(size.x, size.z));
7825
+ }
7826
+ }
7827
+ const lift = waterLift({ mass: piece.mass, volume, submerged: (here.level - box2.min.y) / Math.max(1e-3, size.y) });
7828
+ if (piece.sleeping && !(lift.floats && lift.acceleration > 9.81 * 1.1)) return;
7829
+ piece.sleeping = false;
7830
+ const keep = Math.exp(-lift.drag * dt);
7831
+ piece.velocity = [piece.velocity[0] * keep, (piece.velocity[1] + lift.acceleration * dt) * keep, piece.velocity[2] * keep];
7832
+ piece.angularVelocity = [piece.angularVelocity[0] * keep, piece.angularVelocity[1] * keep, piece.angularVelocity[2] * keep];
7833
+ };
7834
+ const updateWater = (dt) => {
7835
+ for (const e of entries.values()) if (e.piece) floatPiece(e.piece, dt);
7836
+ for (const p of loose) floatPiece(p, dt);
7837
+ };
7838
+ const footprints = createFootprints(terrain);
7584
7839
  const prevVy = /* @__PURE__ */ new Map();
7585
7840
  const tmpBox = new THREE.Box3();
7586
7841
  const onKey = (code, down, e) => {
@@ -7600,6 +7855,10 @@ function createTestRoom(options) {
7600
7855
  if (idx < loadout.length) setTool(loadout[idx]);
7601
7856
  return;
7602
7857
  }
7858
+ if (toolMenuKeys.includes(code) && !e.ctrlKey && !e.metaKey && !e.repeat) {
7859
+ toggleToolMenu();
7860
+ return;
7861
+ }
7603
7862
  if (kickKeys.includes(code) && !e.ctrlKey && !e.metaKey) {
7604
7863
  kick();
7605
7864
  return;
@@ -7874,6 +8133,8 @@ function createTestRoom(options) {
7874
8133
  showGroundFire(dt);
7875
8134
  vfx.update(dt);
7876
8135
  updateImpacts();
8136
+ updateWater(dt);
8137
+ if (player.position[1] <= terrain.footAt(player.position[0], player.position[2]) + 0.15) footprints.update(player.position[0], player.position[2]);
7877
8138
  fluidView.update(dt);
7878
8139
  filmBridge.update(dt);
7879
8140
  film.update(dt);
@@ -8236,7 +8497,7 @@ function createTestRoom(options) {
8236
8497
  crouching: false,
8237
8498
  speed: player.speed,
8238
8499
  running: player.running,
8239
- cameraMode: "first"
8500
+ cameraMode
8240
8501
  }),
8241
8502
  configureTool(settings) {
8242
8503
  const condition = settings[SETTING_IDS.condition];
@@ -8273,6 +8534,12 @@ function createTestRoom(options) {
8273
8534
  if (handSource === source) handSource = null;
8274
8535
  };
8275
8536
  },
8537
+ setMuzzleSource(source) {
8538
+ muzzleSource = source;
8539
+ return () => {
8540
+ if (muzzleSource === source) muzzleSource = null;
8541
+ };
8542
+ },
8276
8543
  log(text, data, type) {
8277
8544
  action(type ?? "module_note", text, { data });
8278
8545
  },
@@ -8335,6 +8602,10 @@ function createTestRoom(options) {
8335
8602
  warmup.register(probeOf("vfx", vfx));
8336
8603
  warmup.register(probeOf("fluids", fluidView));
8337
8604
  warmup.register(spriteProbes(THREE));
8605
+ for (const object of arena.objects) {
8606
+ const probes = object.warmUpProbes;
8607
+ if (probes) warmup.register(probeOf(`scenery:${object.id}`, { warmUpProbes: () => probes(THREE) }));
8608
+ }
8338
8609
  const warmedEffect = (id) => {
8339
8610
  let handle = null;
8340
8611
  return {
@@ -8543,7 +8814,7 @@ function createTestRoom(options) {
8543
8814
  const openArea = (area) => {
8544
8815
  clear();
8545
8816
  const mod = [...modules.values()].find((m) => m.id === area || m.group === area);
8546
- if (mod?.ground) terrain.setLayout(mod.ground);
8817
+ terrain.setLayout(mod?.ground ?? { surface: DEFAULT_PROFILE });
8547
8818
  const label = mod?.group ?? S.groups[area] ?? area;
8548
8819
  ui?.showGroup(label);
8549
8820
  activeBench = mod && benches.has(mod.id) ? mod.id : lastBench();
@@ -8722,6 +8993,7 @@ function createTestRoom(options) {
8722
8993
  return combo ? comboOn : null;
8723
8994
  },
8724
8995
  equip,
8996
+ toggleToolMenu,
8725
8997
  terrain,
8726
8998
  openArea,
8727
8999
  modules: () => [...modules.values()].map((m) => ({ id: m.id, name: m.name, group: m.group, hasArea: typeof m.area === "function" })),
@@ -8929,7 +9201,18 @@ function createTestRoom(options) {
8929
9201
  onAction: (id) => {
8930
9202
  if (activeBench) slowly(activeBench, id, () => benches.get(activeBench)?.act(id));
8931
9203
  },
8932
- ...options.testTable !== false && arena.tops.some((x) => x.id === tableScenery) ? { testTable } : {}
9204
+ ...options.testTable !== false && arena.tops.some((x) => x.id === tableScenery) ? { testTable } : {},
9205
+ ...options.toolMenu !== false ? {
9206
+ toolMenu: {
9207
+ groups: toolMenuGroups,
9208
+ onPick: equip,
9209
+ // the list takes clicks: the mouse leaves the view while it is open
9210
+ onOpen: (open) => {
9211
+ if (open && document.pointerLockElement) document.exitPointerLock?.();
9212
+ },
9213
+ keyLabel: toolMenuKeys.map((key2) => TOOL_MENU_KEY_LABELS[key2] ?? key2.replace(/^Key/, "")).join("/")
9214
+ }
9215
+ } : {}
8933
9216
  });
8934
9217
  ui.hud(hudState());
8935
9218
  showBench();