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