@adatechnology/game-test-room 0.28.2 → 0.29.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
@@ -14,6 +14,9 @@ var DEFAULT_TOOL_BLOWS = {
14
14
  kick: "kick",
15
15
  shove: "shove",
16
16
  knife: "blade",
17
+ kabar: "blade",
18
+ katana: "blade",
19
+ brass_knuckles: "punch",
17
20
  machete: "blade",
18
21
  utility_knife: "blade",
19
22
  axe: "chop",
@@ -80,7 +83,7 @@ function createStrideCounter(stride = STRIDE_METRES) {
80
83
  }
81
84
 
82
85
  // src/room.ts
83
- import { createArsenal, DEFAULT_LOADOUT, realLoadout, weaponMenu } from "@adatechnology/game-weapons";
86
+ import { canDualWield, createArsenal, DEFAULT_LOADOUT, realLoadout, weaponMenu } from "@adatechnology/game-weapons";
84
87
  import { createFilmLcg, createFluids, createItemUse, ITEM_USES, resolveStrings as resolveFluidStrings } from "@adatechnology/game-fluids";
85
88
  import { createFilmBridge, createFilmManager, createFluidRenderer } from "@adatechnology/game-fluids-three";
86
89
  import { SPRITE_KINDS, createVfx, patchSpriteKinds } from "@adatechnology/game-vfx";
@@ -627,6 +630,11 @@ var segmentTouchesBox = (from, to, min, max) => {
627
630
  };
628
631
 
629
632
  // src/explosives.ts
633
+ var SMOKE_PUFFS_PER_SECOND = 36;
634
+ var SMOKE_PUFF_LIFE = 3.2;
635
+ var SMOKE_PUFF_SPEED = 1.7;
636
+ var SMOKE_PUFF_SIZE = 5;
637
+ var SMOKE_PUFF_ALPHA = 1.5;
630
638
  var CONE_BLAST_RADIUS = 0.5;
631
639
  var summarizeBlast = (results) => {
632
640
  const reports = results.flatMap((r) => r.blast ? [r.blast] : []);
@@ -642,6 +650,8 @@ var toArray = (vector) => [vector.x, vector.y, vector.z];
642
650
  function createExplosives(context) {
643
651
  const { THREE, scene, camera, world, renderer, arsenal, translate, format, action } = context;
644
652
  const projectiles = [];
653
+ const smokeSources = [];
654
+ const random = context.random ?? Math.random;
645
655
  const meshes = [];
646
656
  const up = new THREE.Vector3(0, 1, 0);
647
657
  const drop = (objects) => {
@@ -649,6 +659,7 @@ function createExplosives(context) {
649
659
  releaseObject3D(objects, { keep: scene });
650
660
  };
651
661
  const clear = () => {
662
+ smokeSources.length = 0;
652
663
  projectiles.length = 0;
653
664
  drop(meshes);
654
665
  meshes.length = 0;
@@ -815,6 +826,12 @@ function createExplosives(context) {
815
826
  const detonate = (projectile, detonation) => {
816
827
  const weaponName = context.toolName(projectile.weapon);
817
828
  const reach = Math.max(detonation.injuryRadius, detonation.lethalRadius);
829
+ if (detonation.smoke) {
830
+ const [shortest, longest] = detonation.smoke.seconds;
831
+ smokeSources.push({ point: detonation.point, left: shortest + (longest - shortest) * random(), owed: 0, color: detonation.smoke.color });
832
+ action("smoke", translate("act.smoke", { weapon: weaponName, seconds: format(smokeSources[smokeSources.length - 1].left, 0) }), { data: { weapon: projectile.weapon, explosive: detonation.explosive, point: detonation.point } });
833
+ return;
834
+ }
818
835
  const results = detonation.energy <= 0 || detonation.cone ? [] : world.explode(detonation.point, detonation.energy, reach, "player", context.blast());
819
836
  const reached = detonation.energy <= 0 ? 0 : detonation.cone ? coneBlast(detonation) : results.length;
820
837
  if (!detonation.cone) told(results);
@@ -936,7 +953,22 @@ function createExplosives(context) {
936
953
  action("tripwire", translate("act.tripwire", { weapon: context.toolName(projectile.weapon), who: crosser }), { data: { weapon: projectile.weapon, body: crosser } });
937
954
  detonate(projectile, event.detonation);
938
955
  };
956
+ const burnSmoke = (dt) => {
957
+ for (let index = smokeSources.length - 1; index >= 0; index--) {
958
+ const source = smokeSources[index];
959
+ source.left -= dt;
960
+ source.owed += SMOKE_PUFFS_PER_SECOND * dt;
961
+ for (; source.owed >= 1; source.owed -= 1) {
962
+ const angle = random() * Math.PI * 2;
963
+ const speed = SMOKE_PUFF_SPEED * (0.4 + random());
964
+ const velocity = [Math.cos(angle) * speed, 0.5 + random() * 0.8, Math.sin(angle) * speed];
965
+ context.effects.spawn("smoke", [source.point[0], source.point[1] + 0.08, source.point[2]], velocity, { color: source.color, size: SMOKE_PUFF_SIZE, life: SMOKE_PUFF_LIFE, alpha: SMOKE_PUFF_ALPHA });
966
+ }
967
+ if (source.left <= 0) smokeSources.splice(index, 1);
968
+ }
969
+ };
939
970
  const update = (dt) => {
971
+ burnSmoke(dt);
940
972
  for (let index = projectiles.length - 1; index >= 0; index--) {
941
973
  const projectile = projectiles[index];
942
974
  if (!projectile) continue;
@@ -957,7 +989,7 @@ function createExplosives(context) {
957
989
  }
958
990
  }
959
991
  };
960
- return { throwUse, placeUse, backblastUse, detonateCharges, blastAt, update, clear };
992
+ return { throwUse, placeUse, backblastUse, detonateCharges, blastAt, update, clear, smokeLeft: () => smokeSources.map((source) => source.left) };
961
993
  }
962
994
 
963
995
  // src/test-table.ts
@@ -1171,6 +1203,65 @@ var toHostEvent = (weapon, event, options = {}) => {
1171
1203
  }
1172
1204
  };
1173
1205
 
1206
+ // src/dual-reload.ts
1207
+ var otherHand = (hand) => hand === "R" ? "L" : "R";
1208
+ function createDualReload(host, stowSeconds) {
1209
+ let queue = [];
1210
+ let current = null;
1211
+ const next = () => {
1212
+ let hand = queue.shift();
1213
+ while (hand && !host.needs(hand)) hand = queue.shift();
1214
+ if (!hand) {
1215
+ current = null;
1216
+ return;
1217
+ }
1218
+ const seconds = stowSeconds();
1219
+ current = { hand, phase: "stow", left: seconds };
1220
+ host.stow(otherHand(hand), true, seconds);
1221
+ };
1222
+ const draw = (hand) => {
1223
+ const seconds = stowSeconds();
1224
+ current = { hand, phase: "draw", left: seconds };
1225
+ host.stow(otherHand(hand), false, seconds);
1226
+ };
1227
+ return {
1228
+ start(order) {
1229
+ if (current) return;
1230
+ queue = [...order];
1231
+ next();
1232
+ },
1233
+ step(dt) {
1234
+ if (!current) return;
1235
+ if (current.phase === "reload") {
1236
+ if (!host.reloading(current.hand)) draw(current.hand);
1237
+ return;
1238
+ }
1239
+ current.left -= dt;
1240
+ if (current.left > 0) return;
1241
+ if (current.phase === "draw") {
1242
+ next();
1243
+ return;
1244
+ }
1245
+ current.phase = "reload";
1246
+ if (!host.startReload(current.hand)) draw(current.hand);
1247
+ },
1248
+ get busy() {
1249
+ return current !== null;
1250
+ },
1251
+ get current() {
1252
+ return current ? { hand: current.hand, phase: current.phase } : null;
1253
+ },
1254
+ stowed(hand) {
1255
+ return current !== null && current.phase !== "draw" && otherHand(current.hand) === hand;
1256
+ },
1257
+ cancel() {
1258
+ if (current && current.phase !== "draw") host.stow(otherHand(current.hand), false, 0);
1259
+ current = null;
1260
+ queue = [];
1261
+ }
1262
+ };
1263
+ }
1264
+
1174
1265
  // src/loading-view.ts
1175
1266
  var LOADING_CLASS = "tr-loading";
1176
1267
  var LOADING_CSS = [
@@ -2344,6 +2435,10 @@ function createPlayer(THREE, o) {
2344
2435
  o.hooks.primaryDown();
2345
2436
  };
2346
2437
  const onMouseUp = (e) => {
2438
+ if (e.button === 2) {
2439
+ o.hooks.secondaryUp?.();
2440
+ return;
2441
+ }
2347
2442
  if (e.button !== 0) return;
2348
2443
  if (holdTimer) clearTimeout(holdTimer);
2349
2444
  if (dragging) {
@@ -3361,7 +3456,7 @@ import { createFootprints, createTerrain, createTerrainImpl, DEFAULT_PROFILE, wa
3361
3456
  import { createTerrainRenderer, terrainProfileName } from "@adatechnology/game-terrain-three";
3362
3457
 
3363
3458
  // src/avatar.ts
3364
- var DEFAULT_STRIKE_CLIPS = { hand: "punch_right", fists: "punch_right", shove: "shove_two_hands", kick: "front_kick" };
3459
+ var DEFAULT_STRIKE_CLIPS = { hand: "punch_right", fists: "punch_right", brass_knuckles: "punch_right", shove: "shove_two_hands", kick: "front_kick" };
3365
3460
  var DEFAULT_HAND_STANCES = { fists: "fists" };
3366
3461
  var THIRD_PERSON_DEFAULTS = {
3367
3462
  back: 3.8,
@@ -3534,6 +3629,12 @@ var en = {
3534
3629
  "act.clear": "Cleared the room",
3535
3630
  "act.reset": "Repaired every object",
3536
3631
  "act.tool": "Equipped {weapon}",
3632
+ "act.tool_left": "{weapon} in the left hand: left button fires the right one, right button the left one",
3633
+ "act.tool_left_off": "Left hand free: one weapon again",
3634
+ "act.stow": "{weapon} tucked against the body: the {hand} hand is free for the other magazine",
3635
+ "act.unstow": "{weapon} back in the {hand} hand",
3636
+ "hud.no_dual": "{weapon} does not go one in each hand",
3637
+ "hud.left": "L",
3537
3638
  "act.shoot.hit": "{weapon}: shot, {energy} J, hit {where} at {dist} m",
3538
3639
  "act.shoot.miss": "{weapon}: shot, {energy} J, hit nothing",
3539
3640
  "act.shoot.pellets": "{weapon}: shot, {n} projectiles of up to {energy} J, {hits} hit {where} at {dist} m",
@@ -3563,6 +3664,7 @@ var en = {
3563
3664
  "hud.no_charges": "No charge set down waits for the remote detonator",
3564
3665
  "blast.zone.lethal": "lethal",
3565
3666
  "blast.zone.injury": "injury",
3667
+ "act.smoke": "{weapon}: smoke, burning for {seconds} s",
3566
3668
  "act.explode": "{weapon}: exploded, {energy} J within {radius} m, {n} objects in range",
3567
3669
  "act.molotov_fire": "{weapon}: fire on the ground, radius {radius} m for {seconds} s",
3568
3670
  "act.tool.none": "{weapon}: nothing in reach",
@@ -3701,6 +3803,7 @@ var en = {
3701
3803
  "ui.clear_log": "Clear",
3702
3804
  "ui.catalog": "Catalogue",
3703
3805
  "ui.tool_menu": "Weapons",
3806
+ "ui.tool_menu.dual": "Shift+click: one in each hand",
3704
3807
  "ui.tool_menu.search": "Search weapons and tools",
3705
3808
  "ui.tool_menu.close": "Close",
3706
3809
  "ui.tool_menu.results": "{n} found",
@@ -3904,6 +4007,12 @@ var ptBR = {
3904
4007
  "act.clear": "Limpou a sala",
3905
4008
  "act.reset": "Consertou todos os objetos",
3906
4009
  "act.tool": "Equipou {weapon}",
4010
+ "act.tool_left": "{weapon} na m\xE3o esquerda: bot\xE3o esquerdo dispara a da direita, bot\xE3o direito a da esquerda",
4011
+ "act.tool_left_off": "M\xE3o esquerda livre: uma arma de novo",
4012
+ "act.stow": "{weapon} encostada no corpo: a m\xE3o {hand} fica livre para o outro carregador",
4013
+ "act.unstow": "{weapon} de volta na m\xE3o {hand}",
4014
+ "hud.no_dual": "{weapon} n\xE3o vai uma em cada m\xE3o",
4015
+ "hud.left": "E",
3907
4016
  "act.shoot.hit": "{weapon}: disparo, {energy} J, acertou {where} a {dist} m",
3908
4017
  "act.shoot.miss": "{weapon}: disparo, {energy} J, n\xE3o acertou nada",
3909
4018
  "act.shoot.pellets": "{weapon}: disparo, {n} proj\xE9teis de at\xE9 {energy} J, {hits} acertaram {where} a {dist} m",
@@ -3933,6 +4042,7 @@ var ptBR = {
3933
4042
  "hud.no_charges": "Nenhuma carga posta espera o detonador",
3934
4043
  "blast.zone.lethal": "letal",
3935
4044
  "blast.zone.injury": "de ferimento",
4045
+ "act.smoke": "{weapon}: fuma\xE7a, queima por {seconds} s",
3936
4046
  "act.explode": "{weapon}: explodiu, {energy} J em {radius} m, {n} objetos ao alcance",
3937
4047
  "act.molotov_fire": "{weapon}: fogo no ch\xE3o, raio de {radius} m por {seconds} s",
3938
4048
  "act.tool.none": "{weapon}: nada ao alcance",
@@ -4071,6 +4181,7 @@ var ptBR = {
4071
4181
  "ui.clear_log": "Limpar",
4072
4182
  "ui.catalog": "Cat\xE1logo",
4073
4183
  "ui.tool_menu": "Armas",
4184
+ "ui.tool_menu.dual": "Shift+clique: uma em cada m\xE3o",
4074
4185
  "ui.tool_menu.search": "Buscar armas e ferramentas",
4075
4186
  "ui.tool_menu.close": "Fechar",
4076
4187
  "ui.tool_menu.results": "{n} encontradas",
@@ -4279,6 +4390,27 @@ function createBenchPanel(o) {
4279
4390
  return wrap;
4280
4391
  };
4281
4392
  let playhead = null;
4393
+ const sheetImage = (image) => {
4394
+ const figure = mk("figure", "tr-sheet-image");
4395
+ const picture = mk("img");
4396
+ picture.src = image.url;
4397
+ picture.alt = image.alt;
4398
+ picture.loading = "lazy";
4399
+ picture.referrerPolicy = "no-referrer";
4400
+ figure.appendChild(picture);
4401
+ if (image.credit || image.page) {
4402
+ const caption = mk("figcaption", "sub", image.credit ?? "");
4403
+ if (image.page) {
4404
+ const link = mk("a", void 0, image.credit ? " \u2197" : image.page);
4405
+ link.href = image.page;
4406
+ link.target = "_blank";
4407
+ link.rel = "noopener noreferrer";
4408
+ caption.appendChild(link);
4409
+ }
4410
+ figure.appendChild(caption);
4411
+ }
4412
+ return figure;
4413
+ };
4282
4414
  const sheetCard = (sheet) => {
4283
4415
  const card = mk("div", "tr-sheet");
4284
4416
  if (sheet.kicker) card.appendChild(mk("div", "sub", sheet.kicker));
@@ -4330,6 +4462,7 @@ function createBenchPanel(o) {
4330
4462
  playhead = { node: head, metric, end };
4331
4463
  card.appendChild(strip);
4332
4464
  }
4465
+ if (sheet.image) card.appendChild(sheetImage(sheet.image));
4333
4466
  if (sheet.note) card.appendChild(mk("p", "sub", sheet.note));
4334
4467
  return card;
4335
4468
  };
@@ -4563,7 +4696,7 @@ function createToolMenu(o) {
4563
4696
  const close = mk3("button", "tr-btn", "\xD7");
4564
4697
  close.type = "button";
4565
4698
  close.title = o.t("ui.tool_menu.close");
4566
- header.append(mk3("h3", void 0, o.t("ui.tool_menu")), search, close);
4699
+ header.append(mk3("h3", void 0, o.t("ui.tool_menu")), mk3("small", "tr-hint", o.t("ui.tool_menu.dual")), search, close);
4567
4700
  const tabs = mk3("div", "tr-tabs");
4568
4701
  const body = mk3("div", "tr-wmenu-body");
4569
4702
  element.append(header, tabs, body);
@@ -4571,15 +4704,17 @@ function createToolMenu(o) {
4571
4704
  let activeGroup = "";
4572
4705
  let groups = [];
4573
4706
  const toolButton = (tool, group) => {
4574
- const button = mk3("button", tool.id === o.current() ? "tr-btn on" : "tr-btn");
4707
+ const left = o.currentLeft?.() === tool.id;
4708
+ const button = mk3("button", tool.id === o.current() || left ? "tr-btn on" : "tr-btn");
4575
4709
  button.type = "button";
4576
4710
  button.title = tool.name;
4711
+ if (left) button.dataset.left = "";
4577
4712
  button.dataset.tool = tool.id;
4578
4713
  if (tool.thumbnail) button.appendChild(o.thumb(tool.thumbnail, tool.thumbnailCredit));
4579
4714
  button.appendChild(mk3("span", void 0, tool.name));
4580
4715
  if (group) button.appendChild(mk3("small", void 0, group));
4581
- button.onclick = () => {
4582
- o.onPick(tool.id);
4716
+ button.onclick = (event) => {
4717
+ o.onPick(tool.id, { second: event.shiftKey });
4583
4718
  toggle(false);
4584
4719
  };
4585
4720
  return button;
@@ -4675,6 +4810,8 @@ var CSS = `
4675
4810
  .tr-prog b{display:block;font-weight:500;margin-bottom:3px}
4676
4811
  .tr-sheet{margin-top:8px;padding:8px;border:1px solid #2a2e36;border-radius:6px;font-size:12px;line-height:1.4}
4677
4812
  .tr-sheet h4{margin:2px 0 4px;font-size:13px}.tr-sheet p{margin:4px 0}
4813
+ /* the picture of a sheet floats over the scene, top centre, next to what it shows: the bench panel is too short to hold it */
4814
+ .tr-sheet-image{position:fixed;top:52px;left:50%;transform:translateX(-50%);width:min(400px,42vw);z-index:5;margin:0;padding:6px;background:rgba(18,20,24,.92);border:1px solid #2a2e36;border-radius:6px;pointer-events:auto}.tr-sheet-image img{display:block;width:100%;max-height:200px;object-fit:contain;background:#f4f4f2;border-radius:4px}.tr-sheet-image figcaption{margin-top:3px;font-size:11px}.tr-sheet-image a{color:#8ab4f8}
4678
4815
  .tr-facts{display:grid;grid-template-columns:auto 1fr;gap:2px 8px;margin:4px 0}.tr-facts dt{opacity:.7}.tr-facts dd{margin:0}
4679
4816
  .tr-steps{margin:4px 0;padding-left:16px}.tr-swatches{display:flex;flex-wrap:wrap;gap:6px}.tr-swatch i{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:4px}
4680
4817
  .tr-timeline{position:relative;height:22px;margin:8px 0;background:#1b1e24;border-radius:4px;overflow:hidden}
@@ -4802,7 +4939,7 @@ function createUi(o) {
4802
4939
  bottom.append(held, hotbar);
4803
4940
  let currentTool = "";
4804
4941
  const menuOptions = o.toolMenu;
4805
- const toolMenu = menuOptions ? createToolMenu({ t, groups: menuOptions.groups, current: () => currentTool, onPick: menuOptions.onPick, ...menuOptions.onOpen ? { onOpen: menuOptions.onOpen } : {}, thumb }) : null;
4942
+ const toolMenu = menuOptions ? createToolMenu({ t, groups: menuOptions.groups, current: () => currentTool, onPick: menuOptions.onPick, ...menuOptions.onOpen ? { onOpen: menuOptions.onOpen } : {}, ...menuOptions.currentLeft ? { currentLeft: menuOptions.currentLeft } : {}, thumb }) : null;
4806
4943
  if (toolMenu && menuOptions) {
4807
4944
  const menuButton = mk4("button", "tr-slot tr-menu-btn");
4808
4945
  menuButton.type = "button";
@@ -5450,6 +5587,8 @@ function createModuleViews(THREE, gl) {
5450
5587
 
5451
5588
  // src/room.ts
5452
5589
  var FIXED_DT = 1 / 60;
5590
+ var LEFT_HAND_KEY = "#L";
5591
+ var OFF_HAND_SEED = 11;
5453
5592
  var TOOL_MENU_KEY_LABELS = { Backquote: "'" };
5454
5593
  var EXPLOSIVE_EFFECT_IDS = ["m67", "molotov", "pipe_bomb", "m79_40mm", "m72_66mm", "at4_84mm", "dynamite", "c4", "claymore"];
5455
5594
  var DEFAULT_PARTICLE_LIMIT = 900;
@@ -5745,6 +5884,11 @@ function createTestRoom(options) {
5745
5884
  return s;
5746
5885
  };
5747
5886
  let tool = toolState(toolId);
5887
+ let offToolId = null;
5888
+ let offTool = null;
5889
+ let offHeld = false;
5890
+ let offFirst = false;
5891
+ let firedHand = "R";
5748
5892
  const catalogMap = /* @__PURE__ */ new Map();
5749
5893
  for (const i of options.catalog) catalogMap.set(i.id, i);
5750
5894
  const catalog = [...options.catalog];
@@ -5954,9 +6098,19 @@ function createTestRoom(options) {
5954
6098
  releaseTrigger();
5955
6099
  },
5956
6100
  secondary: () => {
6101
+ if (offTool && !heldEntry) {
6102
+ offHeld = true;
6103
+ offFirst = true;
6104
+ return;
6105
+ }
5957
6106
  if (heldEntry) throwHeld();
5958
6107
  else grab();
5959
6108
  },
6109
+ secondaryUp: () => {
6110
+ if (!offHeld) return;
6111
+ offHeld = false;
6112
+ releaseTrigger("L");
6113
+ },
5960
6114
  key: (code, down, e) => onKey(code, down, e),
5961
6115
  wheel: (dir) => cycleTool(dir)
5962
6116
  }
@@ -6495,6 +6649,10 @@ function createTestRoom(options) {
6495
6649
  for (const listener of [...resetListeners]) listener();
6496
6650
  tools.clear();
6497
6651
  tool = toolState(toolId);
6652
+ dualReload.cancel();
6653
+ offTool = null;
6654
+ offToolId = null;
6655
+ firedHand = "R";
6498
6656
  if (pristineWorld) {
6499
6657
  for (const id of world.ids()) world.remove(id);
6500
6658
  world.load(pristineWorld);
@@ -6760,12 +6918,13 @@ function createTestRoom(options) {
6760
6918
  const pocketAt = new THREE.Vector3();
6761
6919
  const aimTurn = new THREE.Quaternion();
6762
6920
  const aimEuler = new THREE.Euler(0, 0, 0, "YXZ");
6763
- const bodyHoldFrame = () => {
6764
- const shoulder = avatar?.shoulder?.("R");
6921
+ const bodyHoldFrame = (hand = "R") => {
6922
+ const shoulder = avatar?.shoulder?.(hand);
6765
6923
  if (!shoulder) return null;
6766
6924
  shoulder.getWorldPosition(pocketAt);
6767
- pocketAt.x -= Math.cos(player.yaw) * SHOULDER_POCKET_IN;
6768
- pocketAt.z += Math.sin(player.yaw) * SHOULDER_POCKET_IN;
6925
+ const inward = hand === "L" ? -SHOULDER_POCKET_IN : SHOULDER_POCKET_IN;
6926
+ pocketAt.x -= Math.cos(player.yaw) * inward;
6927
+ pocketAt.z += Math.sin(player.yaw) * inward;
6769
6928
  aimTurn.setFromEuler(aimEuler.set(player.pitch, player.yaw, 0, "YXZ"));
6770
6929
  return { position: [pocketAt.x, pocketAt.y, pocketAt.z], quaternion: [aimTurn.x, aimTurn.y, aimTurn.z, aimTurn.w] };
6771
6930
  };
@@ -6875,17 +7034,18 @@ function createTestRoom(options) {
6875
7034
  rng = rng * 1664525 + 1013904223 >>> 0;
6876
7035
  return rng / 4294967296;
6877
7036
  };
6878
- const muzzlePose = () => {
6879
- const lent = muzzleSource?.();
6880
- return lent ? { position: [...lent.position], drawsFlash: lent.drawsFlash } : { position: muzzle(), drawsFlash: false };
7037
+ const muzzlePose = (hand = "R") => {
7038
+ const lent = muzzleSource?.(hand);
7039
+ return lent ? { position: [...lent.position], drawsFlash: lent.drawsFlash } : { position: muzzle(hand), drawsFlash: false };
6881
7040
  };
6882
- const muzzle = () => {
7041
+ const muzzle = (hand = "R") => {
6883
7042
  aim();
6884
7043
  const e = camera.matrixWorld.elements;
7044
+ const side = hand === "L" ? -0.16 : 0.16;
6885
7045
  return [
6886
- aimO[0] + aimD[0] * 0.45 + e[0] * 0.16 - e[4] * 0.1,
6887
- aimO[1] + aimD[1] * 0.45 + e[1] * 0.16 - e[5] * 0.1,
6888
- aimO[2] + aimD[2] * 0.45 + e[2] * 0.16 - e[6] * 0.1
7046
+ aimO[0] + aimD[0] * 0.45 + e[0] * side - e[4] * 0.1,
7047
+ aimO[1] + aimD[1] * 0.45 + e[1] * side - e[5] * 0.1,
7048
+ aimO[2] + aimD[2] * 0.45 + e[2] * side - e[6] * 0.1
6889
7049
  ];
6890
7050
  };
6891
7051
  const tracers = [];
@@ -7065,10 +7225,10 @@ function createTestRoom(options) {
7065
7225
  renderer.impulse(objectId, push2.impulse, push2.point);
7066
7226
  }
7067
7227
  };
7068
- const fireShots = (shots, recoil) => {
7228
+ const fireShots = (shots, recoil, id = toolId, hand = "R") => {
7069
7229
  if (shots.length === 0) return;
7070
- const w = toolName();
7071
- const { position: m, drawsFlash } = muzzlePose();
7230
+ const w = toolName(id);
7231
+ const { position: m, drawsFlash } = muzzlePose(hand);
7072
7232
  if (!drawsFlash) {
7073
7233
  renderer.emit("flash", m, [aimD[0], aimD[1], aimD[2]], 1, 0);
7074
7234
  renderer.emit("sparks", m, [aimD[0], aimD[1], aimD[2]], 4, 6);
@@ -7107,7 +7267,7 @@ function createTestRoom(options) {
7107
7267
  objectId: f?.object,
7108
7268
  itemId: f ? entryOf(f.object)?.so.itemId : void 0,
7109
7269
  part: f?.part,
7110
- data: { weapon: toolId, shots: shots.length, hits, energyJ: Math.round(energy), caliberMm: shots[0].caliber, distanceM: f ? Math.round(f.distance * 100) / 100 : null }
7270
+ data: { weapon: id, shots: shots.length, hits, energyJ: Math.round(energy), caliberMm: shots[0].caliber, distanceM: f ? Math.round(f.distance * 100) / 100 : null, ...hand === "L" ? { hand } : {} }
7111
7271
  });
7112
7272
  };
7113
7273
  const fireRays = (u) => {
@@ -7211,7 +7371,7 @@ function createTestRoom(options) {
7211
7371
  const kickLeg = options.kickLeg ?? "alternate";
7212
7372
  let nextLeg = "right";
7213
7373
  const punchHand = options.punchHand ?? "alternate";
7214
- const punchTools = new Set(options.punchTools ?? ["hand", "fists"]);
7374
+ const punchTools = new Set(options.punchTools ?? ["hand", "fists", "brass_knuckles"]);
7215
7375
  let nextHand = "right";
7216
7376
  const combo = options.strikeCombo;
7217
7377
  let comboOn = !!combo && options.strikeComboOn === true;
@@ -7225,16 +7385,16 @@ function createTestRoom(options) {
7225
7385
  action("combo", t(on ? "act.combo.on" : "act.combo.off", { name }), { data: { combo: combo.id, on } });
7226
7386
  say(t(on ? "act.combo.on" : "act.combo.off", { name }));
7227
7387
  };
7228
- const strikeUse = (u, id = toolId) => {
7388
+ const strikeUse = (u, id = toolId, side) => {
7229
7389
  const stepIndex = comboOn && combo?.tools.includes(id) && combo.steps.length > 0 ? simTime - comboAt > combo.reset ? 0 : comboNext : null;
7230
7390
  const step2 = stepIndex === null ? void 0 : combo.steps[stepIndex];
7231
7391
  const clip = step2 ? step2.clip : strikeClips[id];
7232
7392
  const leg = id === "kick" ? kickLeg === "alternate" ? nextLeg : kickLeg : void 0;
7233
- const hand = step2 ? step2.hand : punchTools.has(id) ? punchHand === "alternate" ? nextHand : punchHand : void 0;
7234
- const side = leg ? { leg } : hand ? { hand } : void 0;
7235
- const seconds = clip === void 0 ? null : avatar?.strike?.(clip, side) ?? null;
7393
+ const hand = side ? side.hand : step2 ? step2.hand : punchTools.has(id) ? punchHand === "alternate" ? nextHand : punchHand : void 0;
7394
+ const limb = leg ? { leg } : hand ? { hand } : void 0;
7395
+ const seconds = clip === void 0 ? null : avatar?.strike?.(clip, limb) ?? null;
7236
7396
  if (leg && seconds !== null && kickLeg === "alternate") nextLeg = leg === "right" ? "left" : "right";
7237
- if (!step2 && hand && seconds !== null && punchHand === "alternate") nextHand = hand === "right" ? "left" : "right";
7397
+ if (!step2 && !side && hand && seconds !== null && punchHand === "alternate") nextHand = hand === "right" ? "left" : "right";
7238
7398
  if (step2 && stepIndex !== null) {
7239
7399
  comboNext = (stepIndex + 1) % combo.steps.length;
7240
7400
  comboAt = simTime;
@@ -7255,7 +7415,7 @@ function createTestRoom(options) {
7255
7415
  data: { weapon: id, clip, impactIn: seconds, ...leg ? { leg } : {}, ...hand ? { hand } : {}, ...step2 ? { combo: combo.id, step: stepIndex } : {} }
7256
7416
  });
7257
7417
  strikes.push({
7258
- key: id,
7418
+ key: side?.key ?? id,
7259
7419
  remaining: seconds,
7260
7420
  ...swing && seconds > lead ? { swing, lead } : {},
7261
7421
  land: () => {
@@ -7385,20 +7545,50 @@ function createTestRoom(options) {
7385
7545
  say,
7386
7546
  projectileModel: options.projectileModel
7387
7547
  });
7388
- const useTool = (dt, held) => {
7548
+ const toolOf = (hand) => hand === "L" ? offTool && offToolId ? { id: offToolId, state: offTool } : null : { id: toolId, state: tool };
7549
+ const dualReload = createDualReload(
7550
+ {
7551
+ needs: (hand) => {
7552
+ const state = toolOf(hand)?.state;
7553
+ return state !== void 0 && (state.def.action.kind === "firearm" || state.def.action.kind === "launcher") && state.ammo < state.capacity && state.reserve > 0;
7554
+ },
7555
+ startReload: (hand) => startReload(hand),
7556
+ reloading: (hand) => toolOf(hand)?.state.reloading ?? false,
7557
+ stow: (hand, on, seconds) => {
7558
+ const slot2 = toolOf(hand);
7559
+ if (!slot2) return;
7560
+ if (on) slot2.state.release();
7561
+ emitHost({ type: "stow", weapon: slot2.id, on, duration: seconds, ...hand === "L" ? { hand } : {} });
7562
+ action("stow", t(on ? "act.stow" : "act.unstow", { weapon: toolName(slot2.id), hand: t(hand === "L" ? "hand.left" : "hand.right") }), { data: { weapon: slot2.id, hand, on, seconds } });
7563
+ }
7564
+ },
7565
+ () => arsenal.rules.dualWield.stowSeconds
7566
+ );
7567
+ const strikeKeyOf = (id, hand) => hand === "L" ? `${id}${LEFT_HAND_KEY}` : id;
7568
+ const useTool = (dt, held, hand = "R") => {
7389
7569
  aim();
7390
- const cat = tool.def.category;
7570
+ const slot2 = toolOf(hand);
7571
+ if (!slot2) return "cooldown";
7572
+ const { id, state } = slot2;
7573
+ if (offTool && dualReload.busy) {
7574
+ if (!held) say(t("hud.reloading"));
7575
+ return "reloading";
7576
+ }
7577
+ const cat = state.def.category;
7391
7578
  if (cat === "hand") {
7392
7579
  if (strikes.some((strike) => strike.key === "hand" || strike.key === "fists")) return "cooldown";
7393
7580
  strikeUse({ kind: "melee", damage: "blunt", energy: 25, radius: 0.1, reach: 0.8, origin: [aimO[0], aimO[1], aimO[2]], direction: [aimD[0], aimD[1], aimD[2]] }, "hand");
7394
7581
  return "ok";
7395
7582
  }
7396
- if (strikes.some((strike) => strike.key === toolId)) return "cooldown";
7397
- const res = tool.trigger([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], dt, held, "player");
7398
- flushEvents(toolId, tool, res.recoil);
7583
+ if (strikes.some((strike) => strike.key === strikeKeyOf(id, hand))) return "cooldown";
7584
+ const res = state.trigger([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], dt, held, "player");
7585
+ flushEvents(id, state, res.recoil, hand);
7399
7586
  if (res.status === "empty") {
7400
- if (!tool.reloading && tool.reserve > 0) startReload();
7401
- say(t(tool.reloading ? "hud.reloading" : "hud.empty"));
7587
+ if (!state.reloading && state.reserve > 0) {
7588
+ if (offTool) dualReload.start([hand, hand === "R" ? "L" : "R"]);
7589
+ else startReload(hand);
7590
+ }
7591
+ say(t(state.reloading ? "hud.reloading" : "hud.empty"));
7402
7592
  return res.status;
7403
7593
  }
7404
7594
  if (res.status !== "ok") {
@@ -7408,15 +7598,18 @@ function createTestRoom(options) {
7408
7598
  else if (res.status === "safe") say(t("hud.safe"));
7409
7599
  return res.status;
7410
7600
  }
7411
- applyUses(res, dt);
7601
+ firedHand = hand;
7602
+ applyUses(res, dt, hand);
7412
7603
  return res.status;
7413
7604
  };
7414
- const applyUses = (res, dt) => {
7415
- if (res.shots.length) fireShots(res.shots, res.recoil);
7605
+ const applyUses = (res, dt, hand = "R") => {
7606
+ const id = toolOf(hand)?.id ?? toolId;
7607
+ if (res.shots.length) fireShots(res.shots, res.recoil, id, hand);
7416
7608
  for (const u of res.uses) {
7417
7609
  switch (u.kind) {
7418
7610
  case "melee":
7419
- strikeUse(u);
7611
+ if (hand === "L") strikeUse(u, id, { hand: "left", key: strikeKeyOf(id, hand) });
7612
+ else strikeUse(u);
7420
7613
  break;
7421
7614
  case "flame":
7422
7615
  heatRays(u, "fire");
@@ -7444,15 +7637,17 @@ function createTestRoom(options) {
7444
7637
  }
7445
7638
  }
7446
7639
  };
7447
- const releaseTrigger = () => {
7448
- if (!tool.cooking) {
7449
- tool.release();
7640
+ const releaseTrigger = (hand = "R") => {
7641
+ const slot2 = toolOf(hand);
7642
+ if (!slot2) return;
7643
+ if (!slot2.state.cooking) {
7644
+ slot2.state.release();
7450
7645
  return;
7451
7646
  }
7452
7647
  aim();
7453
- const res = tool.release([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]]);
7454
- flushEvents(toolId, tool);
7455
- if (res) applyUses(res, 0);
7648
+ const res = slot2.state.release([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]]);
7649
+ flushEvents(slot2.id, slot2.state, void 0, hand);
7650
+ if (res) applyUses(res, 0, hand);
7456
7651
  };
7457
7652
  const kick = () => {
7458
7653
  if (!arsenal.weapons.has("kick") || strikes.some((strike) => strike.key === "kick")) return false;
@@ -7481,8 +7676,43 @@ function createTestRoom(options) {
7481
7676
  }));
7482
7677
  };
7483
7678
  const toggleToolMenu = (open) => ui?.toolMenu(open) ?? false;
7679
+ const dropOffTool = () => {
7680
+ if (!offTool) return;
7681
+ releaseTrigger("L");
7682
+ offTool = null;
7683
+ offToolId = null;
7684
+ offHeld = false;
7685
+ offFirst = false;
7686
+ dualReload.cancel();
7687
+ firedHand = "R";
7688
+ tool.setStance({ dualWield: false });
7689
+ action("tool_left", t("act.tool_left_off"), { data: { hand: "L" } });
7690
+ emitHost({ type: "tool", weapon: "", hand: "L" });
7691
+ };
7692
+ const setOffTool = (id) => {
7693
+ if (id === null) {
7694
+ dropOffTool();
7695
+ return true;
7696
+ }
7697
+ if (!arsenal.weapons.has(id)) throw new Error(`game-test-room: unknown weapon "${id}"`);
7698
+ if (!canDualWield(arsenal, id) || !canDualWield(arsenal, toolId)) return false;
7699
+ if (offTool) releaseTrigger("L");
7700
+ const state = arsenal.create(id, { seed: OFF_HAND_SEED, reserve: 999, ...toolSettings });
7701
+ state.setFuseLength(fuseLength);
7702
+ state.refill();
7703
+ state.setStance({ dualWield: true });
7704
+ tool.setStance({ dualWield: true });
7705
+ offTool = state;
7706
+ offToolId = id;
7707
+ dualReload.cancel();
7708
+ action("tool_left", t("act.tool_left", { weapon: toolName(id) }), { data: { weapon: id, hand: "L" } });
7709
+ state.draw();
7710
+ flushEvents(id, state, void 0, "L");
7711
+ return true;
7712
+ };
7484
7713
  const setTool = (id) => {
7485
7714
  if (!arsenal.weapons.has(id)) throw new Error(`game-test-room: unknown weapon "${id}"`);
7715
+ dropOffTool();
7486
7716
  releaseTrigger();
7487
7717
  toolId = id;
7488
7718
  const i = loadout.indexOf(id);
@@ -7975,15 +8205,13 @@ function createTestRoom(options) {
7975
8205
  case "KeyR":
7976
8206
  if (heldPiece) heldYaw += Math.PI / 8;
7977
8207
  else {
7978
- startReload();
8208
+ reloadHands();
7979
8209
  say(t("hud.reloading"));
7980
8210
  }
7981
8211
  break;
7982
- case "KeyQ": {
7983
- tool.cycleMode();
7984
- flushEvents(toolId, tool);
8212
+ case "KeyQ":
8213
+ cycleToolMode();
7985
8214
  break;
7986
- }
7987
8215
  case "KeyX":
7988
8216
  clearToolFault();
7989
8217
  break;
@@ -8040,6 +8268,20 @@ function createTestRoom(options) {
8040
8268
  if (!loadout.includes(weaponId)) loadout.push(weaponId);
8041
8269
  setTool(weaponId);
8042
8270
  };
8271
+ const pickTool = (weaponId, pick = { second: false }) => {
8272
+ if (!arsenal.weapons.has(weaponId)) return;
8273
+ if (!pick.second) {
8274
+ equip(weaponId);
8275
+ return;
8276
+ }
8277
+ if (!canDualWield(arsenal, weaponId)) {
8278
+ equip(weaponId);
8279
+ say(t("hud.no_dual", { weapon: toolName(weaponId) }));
8280
+ return;
8281
+ }
8282
+ if (!offTool || !canDualWield(arsenal, toolId)) equip(weaponId);
8283
+ setOffTool(weaponId);
8284
+ };
8043
8285
  let hudAimHit;
8044
8286
  const inspectTarget = () => {
8045
8287
  if (hudAimHit !== void 0) return hudAimHit;
@@ -8243,6 +8485,11 @@ function createTestRoom(options) {
8243
8485
  s.update(dt);
8244
8486
  flushEvents(id, s);
8245
8487
  }
8488
+ if (offTool && offToolId) {
8489
+ offTool.update(dt);
8490
+ flushEvents(offToolId, offTool, void 0, "L");
8491
+ dualReload.step(dt);
8492
+ }
8246
8493
  if (audio) {
8247
8494
  const look = camera.getWorldDirection(listenerDirection);
8248
8495
  audio.setListener({ position: [camera.position.x, camera.position.y, camera.position.z], yaw: Math.atan2(-look.x, -look.z) });
@@ -8288,6 +8535,10 @@ function createTestRoom(options) {
8288
8535
  } else if (useHeld) useTool(dt, !useFirst);
8289
8536
  useFirst = false;
8290
8537
  }
8538
+ if ((offHeld || offFirst) && offTool && !isPaused && !push.active) {
8539
+ useTool(dt, !offFirst, "L");
8540
+ offFirst = false;
8541
+ }
8291
8542
  if (!isPaused) followHeld(dt);
8292
8543
  };
8293
8544
  const updatePresent = (dt) => {
@@ -8314,12 +8565,18 @@ function createTestRoom(options) {
8314
8565
  hudAimHit = void 0;
8315
8566
  }
8316
8567
  };
8568
+ const ammoText = (state) => {
8569
+ const kind = state.def.action.kind;
8570
+ if (kind === "firearm") return `${state.ammo}/${state.capacity}${state.reserve ? ` \xB7 ${state.reserve}` : ""}`;
8571
+ return kind === "hand" || kind === "melee" || kind === "tool" ? "" : `${num(state.ammo, 0)}`;
8572
+ };
8317
8573
  const readHudState = () => {
8318
8574
  const page = Math.floor(toolIndex / 10);
8319
8575
  const slots = loadout.slice(page * 10, page * 10 + 10).map((id) => arsenal.name(id));
8320
- const ammo = tool.def.action.kind === "firearm" ? `${tool.ammo}/${tool.capacity}${tool.reserve ? ` \xB7 ${tool.reserve}` : ""}` : tool.def.action.kind === "hand" ? "" : tool.def.action.kind === "melee" || tool.def.action.kind === "tool" ? "" : `${num(tool.ammo, 0)}`;
8576
+ const left = offTool ? ammoText(offTool) : "";
8577
+ const ammo = `${ammoText(tool)}${left ? ` | ${t("hud.left")} ${left}` : ""}`;
8321
8578
  return {
8322
- toolName: toolName(),
8579
+ toolName: offToolId ? `${toolName()} + ${toolName(offToolId)}` : toolName(),
8323
8580
  toolId,
8324
8581
  ammo,
8325
8582
  slots,
@@ -8509,26 +8766,53 @@ function createTestRoom(options) {
8509
8766
  if (event.type === "explosion") knockPlayer(event);
8510
8767
  for (const listener of hostListeners) listener(event);
8511
8768
  }
8512
- function flushEvents(id, state, recoil) {
8769
+ function flushEvents(id, state, recoil, hand = "R") {
8513
8770
  const phase = state.phase;
8514
8771
  for (const event of state.drain()) {
8515
8772
  const hostEvent = toHostEvent(id, event, { ...recoil ? { recoil } : {}, phase });
8516
- if (hostEvent) emitHost(hostEvent);
8773
+ if (hostEvent) emitHost(hand === "L" ? { ...hostEvent, hand } : hostEvent);
8517
8774
  }
8518
8775
  }
8519
- function startReload() {
8520
- tool.reload();
8521
- flushEvents(toolId, tool);
8776
+ function startReload(hand = "R") {
8777
+ const slot2 = toolOf(hand);
8778
+ if (!slot2) return false;
8779
+ const started = slot2.state.reload();
8780
+ flushEvents(slot2.id, slot2.state, void 0, hand);
8781
+ return started;
8782
+ }
8783
+ function reloadHands() {
8784
+ if (!offTool) {
8785
+ startReload();
8786
+ return;
8787
+ }
8788
+ dualReload.start(["R", "L"]);
8522
8789
  }
8790
+ const commandHand = () => offTool ? firedHand : "R";
8523
8791
  function clearToolFault() {
8524
- if (!tool.clearFault()) return;
8525
- flushEvents(toolId, tool);
8526
- say(t("hud.clearing"));
8792
+ const order = offTool ? [firedHand, firedHand === "R" ? "L" : "R"] : ["R"];
8793
+ for (const hand of order) {
8794
+ const slot2 = toolOf(hand);
8795
+ if (!slot2?.state.fault || !slot2.state.clearFault()) continue;
8796
+ flushEvents(slot2.id, slot2.state, void 0, hand);
8797
+ say(t("hud.clearing"));
8798
+ return;
8799
+ }
8527
8800
  }
8528
8801
  function toggleSafety() {
8529
- tool.setSafety(!tool.safety);
8530
- flushEvents(toolId, tool);
8531
- say(t(tool.safety ? "hud.safety_on" : "hud.safety_off"));
8802
+ const hand = commandHand();
8803
+ const slot2 = toolOf(hand);
8804
+ if (!slot2) return;
8805
+ slot2.state.setSafety(!slot2.state.safety);
8806
+ flushEvents(slot2.id, slot2.state, void 0, hand);
8807
+ say(`${t(slot2.state.safety ? "hud.safety_on" : "hud.safety_off")}${offTool ? ` (${toolName(slot2.id)}, ${t(hand === "L" ? "hand.left" : "hand.right")})` : ""}`);
8808
+ }
8809
+ function cycleToolMode() {
8810
+ const hand = commandHand();
8811
+ const slot2 = toolOf(hand);
8812
+ if (!slot2) return;
8813
+ slot2.state.cycleMode();
8814
+ flushEvents(slot2.id, slot2.state, void 0, hand);
8815
+ if (offTool) say(`${toolName(slot2.id)} (${t(hand === "L" ? "hand.left" : "hand.right")}): ${slot2.state.mode}`);
8532
8816
  }
8533
8817
  function runToolCommand(request) {
8534
8818
  const work = (done) => done.ok ? { ok: true, seconds: done.seconds } : { ok: false, reason: done.reason };
@@ -8550,6 +8834,24 @@ function createTestRoom(options) {
8550
8834
  return { ok: false, reason: "unknown" };
8551
8835
  }
8552
8836
  }
8837
+ const hostToolOf = (id, state) => ({
8838
+ weapon: id,
8839
+ category: state.def.category,
8840
+ ammo: state.ammo,
8841
+ capacity: state.capacity,
8842
+ reserve: state.reserve,
8843
+ reloading: state.reloading,
8844
+ mode: state.mode,
8845
+ ...state.fault ? { fault: state.fault } : {},
8846
+ safety: state.safety,
8847
+ heat: state.heatLevel,
8848
+ attachments: state.attachments.map((part) => ({ id: part.id, mount: part.mount, ...part.integral ? { integral: true } : {}, ...part.on === void 0 ? {} : { on: part.on } })),
8849
+ stock: state.stock,
8850
+ suppressed: state.suppressed,
8851
+ underbarrelActive: state.underbarrelActive,
8852
+ mass: state.mass,
8853
+ aimSeconds: state.aimSeconds
8854
+ });
8553
8855
  const host = {
8554
8856
  THREE,
8555
8857
  scene,
@@ -8576,22 +8878,9 @@ function createTestRoom(options) {
8576
8878
  ...options.blastShove ? { blastShove: options.blastShove } : {},
8577
8879
  blastReach: (point, target, ignore) => world.blastReach(point, target, ignore ? { ignore } : {}),
8578
8880
  tool: () => ({
8579
- weapon: toolId,
8580
- category: tool.def.category,
8581
- ammo: tool.ammo,
8582
- capacity: tool.capacity,
8583
- reserve: tool.reserve,
8584
- reloading: tool.reloading,
8585
- mode: tool.mode,
8586
- ...tool.fault ? { fault: tool.fault } : {},
8587
- safety: tool.safety,
8588
- heat: tool.heatLevel,
8589
- attachments: tool.attachments.map((part) => ({ id: part.id, mount: part.mount, ...part.integral ? { integral: true } : {}, ...part.on === void 0 ? {} : { on: part.on } })),
8590
- stock: tool.stock,
8591
- suppressed: tool.suppressed,
8592
- underbarrelActive: tool.underbarrelActive,
8593
- mass: tool.mass,
8594
- aimSeconds: tool.aimSeconds
8881
+ ...hostToolOf(toolId, tool),
8882
+ ...dualReload.stowed("R") ? { stowed: true } : {},
8883
+ ...offTool && offToolId ? { offHand: { ...hostToolOf(offToolId, offTool), ...dualReload.stowed("L") ? { stowed: true } : {} } } : {}
8595
8884
  }),
8596
8885
  command(request) {
8597
8886
  const result = runToolCommand(request);
@@ -8628,7 +8917,7 @@ function createTestRoom(options) {
8628
8917
  onFrame(update, frameOptions) {
8629
8918
  return scheduler.add((ctx) => update(ctx.dt), { phase: frameOptions?.phase ?? "lateSimulate", order: frameOptions?.order ?? 0, id: `module.frame#${moduleFrameCount++}` });
8630
8919
  },
8631
- holdFrame: () => bodyHoldFrame(),
8920
+ holdFrame: (hand) => bodyHoldFrame(hand),
8632
8921
  reachFor(reach) {
8633
8922
  if (!avatar) {
8634
8923
  reach.onGrip?.();
@@ -9004,6 +9293,7 @@ function createTestRoom(options) {
9004
9293
  fluids,
9005
9294
  film: filmBridge,
9006
9295
  vfx: vfx.world,
9296
+ effects: vfx,
9007
9297
  fluidRenderer: fluidView,
9008
9298
  audio,
9009
9299
  environment,
@@ -9019,6 +9309,29 @@ function createTestRoom(options) {
9019
9309
  clear,
9020
9310
  resetAll,
9021
9311
  setTool,
9312
+ setOffTool,
9313
+ get offTool() {
9314
+ return offTool;
9315
+ },
9316
+ useOff(hold = 0) {
9317
+ if (!offTool) return "cooldown";
9318
+ const status = useTool(FIXED_DT, false, "L");
9319
+ flush();
9320
+ let t0 = 0;
9321
+ while (t0 < hold) {
9322
+ useTool(FIXED_DT, true, "L");
9323
+ directStep = true;
9324
+ try {
9325
+ runHeadless(() => scheduler.advance(FIXED_DT));
9326
+ } finally {
9327
+ directStep = false;
9328
+ }
9329
+ t0 += FIXED_DT;
9330
+ }
9331
+ releaseTrigger("L");
9332
+ return status;
9333
+ },
9334
+ reload: () => reloadHands(),
9022
9335
  useItem,
9023
9336
  use(hold = 0) {
9024
9337
  if (tool.def.category === "hand") {
@@ -9316,7 +9629,8 @@ function createTestRoom(options) {
9316
9629
  ...options.toolMenu !== false ? {
9317
9630
  toolMenu: {
9318
9631
  groups: toolMenuGroups,
9319
- onPick: equip,
9632
+ onPick: pickTool,
9633
+ currentLeft: () => offToolId,
9320
9634
  // the list takes clicks: the mouse leaves the view while it is open
9321
9635
  onOpen: (open) => {
9322
9636
  if (open && document.pointerLockElement) document.exitPointerLock?.();