@adatechnology/game-test-room 0.28.2 → 0.29.1

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,13 @@ 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;
638
+ var SMOKE_HISS = { loop: "smoke_grenade_hiss", volume: 0.7, halfAt: 6, max: 1 };
639
+ var GRENADE_PIN_SOUND = "grenade_pin_pull";
630
640
  var CONE_BLAST_RADIUS = 0.5;
631
641
  var summarizeBlast = (results) => {
632
642
  const reports = results.flatMap((r) => r.blast ? [r.blast] : []);
@@ -642,13 +652,35 @@ var toArray = (vector) => [vector.x, vector.y, vector.z];
642
652
  function createExplosives(context) {
643
653
  const { THREE, scene, camera, world, renderer, arsenal, translate, format, action } = context;
644
654
  const projectiles = [];
655
+ const smokeSources = [];
656
+ const random = context.random ?? Math.random;
645
657
  const meshes = [];
646
658
  const up = new THREE.Vector3(0, 1, 0);
659
+ let smokeHiss = null;
660
+ const hissSmoke = () => {
661
+ if (!context.audio) return;
662
+ if (smokeSources.length === 0) {
663
+ smokeHiss?.stop();
664
+ smokeHiss = null;
665
+ return;
666
+ }
667
+ const at = camera.position;
668
+ let level = 0;
669
+ for (const source of smokeSources) {
670
+ const distance = Math.hypot(source.point[0] - at.x, source.point[1] - at.y, source.point[2] - at.z);
671
+ level += SMOKE_HISS.volume / (1 + (distance / SMOKE_HISS.halfAt) ** 2);
672
+ }
673
+ smokeHiss ?? (smokeHiss = context.audio.loop(SMOKE_HISS.loop));
674
+ smokeHiss.set({ volume: Math.min(SMOKE_HISS.max, level) });
675
+ };
647
676
  const drop = (objects) => {
648
677
  for (const object of objects) object.removeFromParent();
649
678
  releaseObject3D(objects, { keep: scene });
650
679
  };
651
680
  const clear = () => {
681
+ smokeSources.length = 0;
682
+ smokeHiss?.stop();
683
+ smokeHiss = null;
652
684
  projectiles.length = 0;
653
685
  drop(meshes);
654
686
  meshes.length = 0;
@@ -702,6 +734,7 @@ function createExplosives(context) {
702
734
  age: 0,
703
735
  ...use.explosive !== void 0 ? { body: createExplosiveBody(use, arsenal.explosives, arsenal.rules.explosives), aligned: isRocket } : {}
704
736
  });
737
+ if (!isRocket && !isMolotov && use.explosive !== void 0) context.audio?.play(GRENADE_PIN_SOUND, { position: use.origin });
705
738
  const key2 = isRocket ? "act.launch" : isMolotov ? "act.throw.molotov" : "act.throw.grenade";
706
739
  action(isRocket ? "launch_projectile" : "throw_projectile", translate(key2, { weapon: context.toolName(), fuse: format(use.fuse, 1), arm: format(use.armDistance ?? 0, 0) }), {
707
740
  data: { weapon: context.toolId(), fuse: use.fuse, explosive: use.explosive }
@@ -815,6 +848,12 @@ function createExplosives(context) {
815
848
  const detonate = (projectile, detonation) => {
816
849
  const weaponName = context.toolName(projectile.weapon);
817
850
  const reach = Math.max(detonation.injuryRadius, detonation.lethalRadius);
851
+ if (detonation.smoke) {
852
+ const [shortest, longest] = detonation.smoke.seconds;
853
+ smokeSources.push({ point: detonation.point, left: shortest + (longest - shortest) * random(), owed: 0, color: detonation.smoke.color });
854
+ 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 } });
855
+ return;
856
+ }
818
857
  const results = detonation.energy <= 0 || detonation.cone ? [] : world.explode(detonation.point, detonation.energy, reach, "player", context.blast());
819
858
  const reached = detonation.energy <= 0 ? 0 : detonation.cone ? coneBlast(detonation) : results.length;
820
859
  if (!detonation.cone) told(results);
@@ -936,7 +975,23 @@ function createExplosives(context) {
936
975
  action("tripwire", translate("act.tripwire", { weapon: context.toolName(projectile.weapon), who: crosser }), { data: { weapon: projectile.weapon, body: crosser } });
937
976
  detonate(projectile, event.detonation);
938
977
  };
978
+ const burnSmoke = (dt) => {
979
+ for (let index = smokeSources.length - 1; index >= 0; index--) {
980
+ const source = smokeSources[index];
981
+ source.left -= dt;
982
+ source.owed += SMOKE_PUFFS_PER_SECOND * dt;
983
+ for (; source.owed >= 1; source.owed -= 1) {
984
+ const angle = random() * Math.PI * 2;
985
+ const speed = SMOKE_PUFF_SPEED * (0.4 + random());
986
+ const velocity = [Math.cos(angle) * speed, 0.5 + random() * 0.8, Math.sin(angle) * speed];
987
+ 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 });
988
+ }
989
+ if (source.left <= 0) smokeSources.splice(index, 1);
990
+ }
991
+ hissSmoke();
992
+ };
939
993
  const update = (dt) => {
994
+ burnSmoke(dt);
940
995
  for (let index = projectiles.length - 1; index >= 0; index--) {
941
996
  const projectile = projectiles[index];
942
997
  if (!projectile) continue;
@@ -957,7 +1012,7 @@ function createExplosives(context) {
957
1012
  }
958
1013
  }
959
1014
  };
960
- return { throwUse, placeUse, backblastUse, detonateCharges, blastAt, update, clear };
1015
+ return { throwUse, placeUse, backblastUse, detonateCharges, blastAt, update, clear, smokeLeft: () => smokeSources.map((source) => source.left) };
961
1016
  }
962
1017
 
963
1018
  // src/test-table.ts
@@ -1171,6 +1226,65 @@ var toHostEvent = (weapon, event, options = {}) => {
1171
1226
  }
1172
1227
  };
1173
1228
 
1229
+ // src/dual-reload.ts
1230
+ var otherHand = (hand) => hand === "R" ? "L" : "R";
1231
+ function createDualReload(host, stowSeconds) {
1232
+ let queue = [];
1233
+ let current = null;
1234
+ const next = () => {
1235
+ let hand = queue.shift();
1236
+ while (hand && !host.needs(hand)) hand = queue.shift();
1237
+ if (!hand) {
1238
+ current = null;
1239
+ return;
1240
+ }
1241
+ const seconds = stowSeconds();
1242
+ current = { hand, phase: "stow", left: seconds };
1243
+ host.stow(otherHand(hand), true, seconds);
1244
+ };
1245
+ const draw = (hand) => {
1246
+ const seconds = stowSeconds();
1247
+ current = { hand, phase: "draw", left: seconds };
1248
+ host.stow(otherHand(hand), false, seconds);
1249
+ };
1250
+ return {
1251
+ start(order) {
1252
+ if (current) return;
1253
+ queue = [...order];
1254
+ next();
1255
+ },
1256
+ step(dt) {
1257
+ if (!current) return;
1258
+ if (current.phase === "reload") {
1259
+ if (!host.reloading(current.hand)) draw(current.hand);
1260
+ return;
1261
+ }
1262
+ current.left -= dt;
1263
+ if (current.left > 0) return;
1264
+ if (current.phase === "draw") {
1265
+ next();
1266
+ return;
1267
+ }
1268
+ current.phase = "reload";
1269
+ if (!host.startReload(current.hand)) draw(current.hand);
1270
+ },
1271
+ get busy() {
1272
+ return current !== null;
1273
+ },
1274
+ get current() {
1275
+ return current ? { hand: current.hand, phase: current.phase } : null;
1276
+ },
1277
+ stowed(hand) {
1278
+ return current !== null && current.phase !== "draw" && otherHand(current.hand) === hand;
1279
+ },
1280
+ cancel() {
1281
+ if (current && current.phase !== "draw") host.stow(otherHand(current.hand), false, 0);
1282
+ current = null;
1283
+ queue = [];
1284
+ }
1285
+ };
1286
+ }
1287
+
1174
1288
  // src/loading-view.ts
1175
1289
  var LOADING_CLASS = "tr-loading";
1176
1290
  var LOADING_CSS = [
@@ -2344,6 +2458,10 @@ function createPlayer(THREE, o) {
2344
2458
  o.hooks.primaryDown();
2345
2459
  };
2346
2460
  const onMouseUp = (e) => {
2461
+ if (e.button === 2) {
2462
+ o.hooks.secondaryUp?.();
2463
+ return;
2464
+ }
2347
2465
  if (e.button !== 0) return;
2348
2466
  if (holdTimer) clearTimeout(holdTimer);
2349
2467
  if (dragging) {
@@ -3361,7 +3479,7 @@ import { createFootprints, createTerrain, createTerrainImpl, DEFAULT_PROFILE, wa
3361
3479
  import { createTerrainRenderer, terrainProfileName } from "@adatechnology/game-terrain-three";
3362
3480
 
3363
3481
  // src/avatar.ts
3364
- var DEFAULT_STRIKE_CLIPS = { hand: "punch_right", fists: "punch_right", shove: "shove_two_hands", kick: "front_kick" };
3482
+ var DEFAULT_STRIKE_CLIPS = { hand: "punch_right", fists: "punch_right", brass_knuckles: "punch_right", shove: "shove_two_hands", kick: "front_kick" };
3365
3483
  var DEFAULT_HAND_STANCES = { fists: "fists" };
3366
3484
  var THIRD_PERSON_DEFAULTS = {
3367
3485
  back: 3.8,
@@ -3534,6 +3652,12 @@ var en = {
3534
3652
  "act.clear": "Cleared the room",
3535
3653
  "act.reset": "Repaired every object",
3536
3654
  "act.tool": "Equipped {weapon}",
3655
+ "act.tool_left": "{weapon} in the left hand: left button fires the right one, right button the left one",
3656
+ "act.tool_left_off": "Left hand free: one weapon again",
3657
+ "act.stow": "{weapon} tucked against the body: the {hand} hand is free for the other magazine",
3658
+ "act.unstow": "{weapon} back in the {hand} hand",
3659
+ "hud.no_dual": "{weapon} does not go one in each hand",
3660
+ "hud.left": "L",
3537
3661
  "act.shoot.hit": "{weapon}: shot, {energy} J, hit {where} at {dist} m",
3538
3662
  "act.shoot.miss": "{weapon}: shot, {energy} J, hit nothing",
3539
3663
  "act.shoot.pellets": "{weapon}: shot, {n} projectiles of up to {energy} J, {hits} hit {where} at {dist} m",
@@ -3563,6 +3687,7 @@ var en = {
3563
3687
  "hud.no_charges": "No charge set down waits for the remote detonator",
3564
3688
  "blast.zone.lethal": "lethal",
3565
3689
  "blast.zone.injury": "injury",
3690
+ "act.smoke": "{weapon}: smoke, burning for {seconds} s",
3566
3691
  "act.explode": "{weapon}: exploded, {energy} J within {radius} m, {n} objects in range",
3567
3692
  "act.molotov_fire": "{weapon}: fire on the ground, radius {radius} m for {seconds} s",
3568
3693
  "act.tool.none": "{weapon}: nothing in reach",
@@ -3593,6 +3718,7 @@ var en = {
3593
3718
  "act.player_thrown_wounded": "The blast threw the player at {speed} m/s, {distance} m from it: badly wounded, stunned for {stun} s ({reach}% got through the cover)",
3594
3719
  "act.player_stunned": "The blast stunned the player for {stun} s, {distance} m from it: dizzy, ears ringing",
3595
3720
  "act.came_apart": "{name} came apart: what stayed is loose and falls",
3721
+ "check.broken_floating": "ERROR: what was left of {name} stood on nothing after the break; it was let go to fall",
3596
3722
  "act.shoved": "{name} stands free: the shots moved it",
3597
3723
  "act.impact": "{name} hit {where} at {speed} m/s: {energy} J of blunt damage",
3598
3724
  "act.use_item": "{name}: {use}",
@@ -3701,6 +3827,7 @@ var en = {
3701
3827
  "ui.clear_log": "Clear",
3702
3828
  "ui.catalog": "Catalogue",
3703
3829
  "ui.tool_menu": "Weapons",
3830
+ "ui.tool_menu.dual": "Shift+click: one in each hand",
3704
3831
  "ui.tool_menu.search": "Search weapons and tools",
3705
3832
  "ui.tool_menu.close": "Close",
3706
3833
  "ui.tool_menu.results": "{n} found",
@@ -3904,6 +4031,12 @@ var ptBR = {
3904
4031
  "act.clear": "Limpou a sala",
3905
4032
  "act.reset": "Consertou todos os objetos",
3906
4033
  "act.tool": "Equipou {weapon}",
4034
+ "act.tool_left": "{weapon} na m\xE3o esquerda: bot\xE3o esquerdo dispara a da direita, bot\xE3o direito a da esquerda",
4035
+ "act.tool_left_off": "M\xE3o esquerda livre: uma arma de novo",
4036
+ "act.stow": "{weapon} encostada no corpo: a m\xE3o {hand} fica livre para o outro carregador",
4037
+ "act.unstow": "{weapon} de volta na m\xE3o {hand}",
4038
+ "hud.no_dual": "{weapon} n\xE3o vai uma em cada m\xE3o",
4039
+ "hud.left": "E",
3907
4040
  "act.shoot.hit": "{weapon}: disparo, {energy} J, acertou {where} a {dist} m",
3908
4041
  "act.shoot.miss": "{weapon}: disparo, {energy} J, n\xE3o acertou nada",
3909
4042
  "act.shoot.pellets": "{weapon}: disparo, {n} proj\xE9teis de at\xE9 {energy} J, {hits} acertaram {where} a {dist} m",
@@ -3933,6 +4066,7 @@ var ptBR = {
3933
4066
  "hud.no_charges": "Nenhuma carga posta espera o detonador",
3934
4067
  "blast.zone.lethal": "letal",
3935
4068
  "blast.zone.injury": "de ferimento",
4069
+ "act.smoke": "{weapon}: fuma\xE7a, queima por {seconds} s",
3936
4070
  "act.explode": "{weapon}: explodiu, {energy} J em {radius} m, {n} objetos ao alcance",
3937
4071
  "act.molotov_fire": "{weapon}: fogo no ch\xE3o, raio de {radius} m por {seconds} s",
3938
4072
  "act.tool.none": "{weapon}: nada ao alcance",
@@ -3963,6 +4097,7 @@ var ptBR = {
3963
4097
  "act.player_thrown_wounded": "A explos\xE3o arremessou o jogador a {speed} m/s, a {distance} m dela: ferido gravemente, atordoado por {stun} s ({reach}% passou pela cobertura)",
3964
4098
  "act.player_stunned": "A explos\xE3o atordoou o jogador por {stun} s, a {distance} m dela: tonto, com os ouvidos zumbindo",
3965
4099
  "act.came_apart": "{name} se desmontou: o que ficou est\xE1 solto e cai",
4100
+ "check.broken_floating": "ERRO: o que sobrou de {name} ficou sem apoio depois de quebrar; foi solto para cair",
3966
4101
  "act.shoved": "{name} estava solto no ch\xE3o: os tiros o moveram",
3967
4102
  "act.impact": "{name} bateu em {where} a {speed} m/s: {energy} J de dano de impacto",
3968
4103
  "act.use_item": "{name}: {use}",
@@ -4071,6 +4206,7 @@ var ptBR = {
4071
4206
  "ui.clear_log": "Limpar",
4072
4207
  "ui.catalog": "Cat\xE1logo",
4073
4208
  "ui.tool_menu": "Armas",
4209
+ "ui.tool_menu.dual": "Shift+clique: uma em cada m\xE3o",
4074
4210
  "ui.tool_menu.search": "Buscar armas e ferramentas",
4075
4211
  "ui.tool_menu.close": "Fechar",
4076
4212
  "ui.tool_menu.results": "{n} encontradas",
@@ -4279,6 +4415,27 @@ function createBenchPanel(o) {
4279
4415
  return wrap;
4280
4416
  };
4281
4417
  let playhead = null;
4418
+ const sheetImage = (image) => {
4419
+ const figure = mk("figure", "tr-sheet-image");
4420
+ const picture = mk("img");
4421
+ picture.src = image.url;
4422
+ picture.alt = image.alt;
4423
+ picture.loading = "lazy";
4424
+ picture.referrerPolicy = "no-referrer";
4425
+ figure.appendChild(picture);
4426
+ if (image.credit || image.page) {
4427
+ const caption = mk("figcaption", "sub", image.credit ?? "");
4428
+ if (image.page) {
4429
+ const link = mk("a", void 0, image.credit ? " \u2197" : image.page);
4430
+ link.href = image.page;
4431
+ link.target = "_blank";
4432
+ link.rel = "noopener noreferrer";
4433
+ caption.appendChild(link);
4434
+ }
4435
+ figure.appendChild(caption);
4436
+ }
4437
+ return figure;
4438
+ };
4282
4439
  const sheetCard = (sheet) => {
4283
4440
  const card = mk("div", "tr-sheet");
4284
4441
  if (sheet.kicker) card.appendChild(mk("div", "sub", sheet.kicker));
@@ -4330,6 +4487,7 @@ function createBenchPanel(o) {
4330
4487
  playhead = { node: head, metric, end };
4331
4488
  card.appendChild(strip);
4332
4489
  }
4490
+ if (sheet.image) card.appendChild(sheetImage(sheet.image));
4333
4491
  if (sheet.note) card.appendChild(mk("p", "sub", sheet.note));
4334
4492
  return card;
4335
4493
  };
@@ -4563,7 +4721,7 @@ function createToolMenu(o) {
4563
4721
  const close = mk3("button", "tr-btn", "\xD7");
4564
4722
  close.type = "button";
4565
4723
  close.title = o.t("ui.tool_menu.close");
4566
- header.append(mk3("h3", void 0, o.t("ui.tool_menu")), search, close);
4724
+ header.append(mk3("h3", void 0, o.t("ui.tool_menu")), mk3("small", "tr-hint", o.t("ui.tool_menu.dual")), search, close);
4567
4725
  const tabs = mk3("div", "tr-tabs");
4568
4726
  const body = mk3("div", "tr-wmenu-body");
4569
4727
  element.append(header, tabs, body);
@@ -4571,15 +4729,17 @@ function createToolMenu(o) {
4571
4729
  let activeGroup = "";
4572
4730
  let groups = [];
4573
4731
  const toolButton = (tool, group) => {
4574
- const button = mk3("button", tool.id === o.current() ? "tr-btn on" : "tr-btn");
4732
+ const left = o.currentLeft?.() === tool.id;
4733
+ const button = mk3("button", tool.id === o.current() || left ? "tr-btn on" : "tr-btn");
4575
4734
  button.type = "button";
4576
4735
  button.title = tool.name;
4736
+ if (left) button.dataset.left = "";
4577
4737
  button.dataset.tool = tool.id;
4578
4738
  if (tool.thumbnail) button.appendChild(o.thumb(tool.thumbnail, tool.thumbnailCredit));
4579
4739
  button.appendChild(mk3("span", void 0, tool.name));
4580
4740
  if (group) button.appendChild(mk3("small", void 0, group));
4581
- button.onclick = () => {
4582
- o.onPick(tool.id);
4741
+ button.onclick = (event) => {
4742
+ o.onPick(tool.id, { second: event.shiftKey });
4583
4743
  toggle(false);
4584
4744
  };
4585
4745
  return button;
@@ -4675,6 +4835,8 @@ var CSS = `
4675
4835
  .tr-prog b{display:block;font-weight:500;margin-bottom:3px}
4676
4836
  .tr-sheet{margin-top:8px;padding:8px;border:1px solid #2a2e36;border-radius:6px;font-size:12px;line-height:1.4}
4677
4837
  .tr-sheet h4{margin:2px 0 4px;font-size:13px}.tr-sheet p{margin:4px 0}
4838
+ /* 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 */
4839
+ .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
4840
  .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
4841
  .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
4842
  .tr-timeline{position:relative;height:22px;margin:8px 0;background:#1b1e24;border-radius:4px;overflow:hidden}
@@ -4802,7 +4964,7 @@ function createUi(o) {
4802
4964
  bottom.append(held, hotbar);
4803
4965
  let currentTool = "";
4804
4966
  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;
4967
+ 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
4968
  if (toolMenu && menuOptions) {
4807
4969
  const menuButton = mk4("button", "tr-slot tr-menu-btn");
4808
4970
  menuButton.type = "button";
@@ -5450,6 +5612,8 @@ function createModuleViews(THREE, gl) {
5450
5612
 
5451
5613
  // src/room.ts
5452
5614
  var FIXED_DT = 1 / 60;
5615
+ var LEFT_HAND_KEY = "#L";
5616
+ var OFF_HAND_SEED = 11;
5453
5617
  var TOOL_MENU_KEY_LABELS = { Backquote: "'" };
5454
5618
  var EXPLOSIVE_EFFECT_IDS = ["m67", "molotov", "pipe_bomb", "m79_40mm", "m72_66mm", "at4_84mm", "dynamite", "c4", "claymore"];
5455
5619
  var DEFAULT_PARTICLE_LIMIT = 900;
@@ -5664,6 +5828,14 @@ function createTestRoom(options) {
5664
5828
  rendererPieces.set(objectId, piece);
5665
5829
  action("loosened", t(cause === "blast" ? "act.loosened" : "act.came_apart", { name: e.name }), { objectId, itemId: e.so.itemId });
5666
5830
  },
5831
+ // after a break the collision of the object is measured again from what is still drawn; what stood on nothing was let go, which is a bug the room says aloud
5832
+ onBrokenChecked: (objectId, released) => {
5833
+ bodyBoxes.delete(objectId);
5834
+ if (standingIds.includes(objectId)) leaveFootprint(objectId);
5835
+ if (!released) return;
5836
+ const e = entries.get(objectId);
5837
+ log.add({ channel: "check", type: "fail", text: t("check.broken_floating", { name: e?.name ?? objectId }), objectId, ...e ? { itemId: e.so.itemId } : {}, data: { invariant: "broken_floating" } });
5838
+ },
5667
5839
  onPiece: (piece) => {
5668
5840
  rendererPieces.set(piece.objectId, piece);
5669
5841
  loose.push(piece);
@@ -5745,6 +5917,11 @@ function createTestRoom(options) {
5745
5917
  return s;
5746
5918
  };
5747
5919
  let tool = toolState(toolId);
5920
+ let offToolId = null;
5921
+ let offTool = null;
5922
+ let offHeld = false;
5923
+ let offFirst = false;
5924
+ let firedHand = "R";
5748
5925
  const catalogMap = /* @__PURE__ */ new Map();
5749
5926
  for (const i of options.catalog) catalogMap.set(i.id, i);
5750
5927
  const catalog = [...options.catalog];
@@ -5954,9 +6131,19 @@ function createTestRoom(options) {
5954
6131
  releaseTrigger();
5955
6132
  },
5956
6133
  secondary: () => {
6134
+ if (offTool && !heldEntry) {
6135
+ offHeld = true;
6136
+ offFirst = true;
6137
+ return;
6138
+ }
5957
6139
  if (heldEntry) throwHeld();
5958
6140
  else grab();
5959
6141
  },
6142
+ secondaryUp: () => {
6143
+ if (!offHeld) return;
6144
+ offHeld = false;
6145
+ releaseTrigger("L");
6146
+ },
5960
6147
  key: (code, down, e) => onKey(code, down, e),
5961
6148
  wheel: (dir) => cycleTool(dir)
5962
6149
  }
@@ -6312,9 +6499,14 @@ function createTestRoom(options) {
6312
6499
  bodyBoxes.delete(id);
6313
6500
  return null;
6314
6501
  }
6315
- bodyBounds.setFromObject(e.so.root);
6316
- if (bodyBounds.isEmpty()) return null;
6317
- const box2 = { minX: bodyBounds.min.x, maxX: bodyBounds.max.x, minZ: bodyBounds.min.z, maxZ: bodyBounds.max.z, base: bodyBounds.min.y, top: bodyBounds.max.y, stamp: frameStamp };
6502
+ const parts = renderer.shownBoxes(id);
6503
+ if (parts.length === 0) {
6504
+ bodyBoxes.delete(id);
6505
+ return null;
6506
+ }
6507
+ bodyBounds.makeEmpty();
6508
+ for (const p of parts) bodyBounds.expandByPoint(tmpV.set(p[0], p[1], p[2])).expandByPoint(tmpV.set(p[3], p[4], p[5]));
6509
+ const box2 = { minX: bodyBounds.min.x, maxX: bodyBounds.max.x, minZ: bodyBounds.min.z, maxZ: bodyBounds.max.z, base: bodyBounds.min.y, top: bodyBounds.max.y, stamp: frameStamp, parts };
6318
6510
  bodyBoxes.set(id, box2);
6319
6511
  return box2;
6320
6512
  };
@@ -6331,7 +6523,15 @@ function createTestRoom(options) {
6331
6523
  const reach = c.radius + r;
6332
6524
  return (x - footPos.x) ** 2 + (z - footPos.z) ** 2 < reach * reach ? { ...b, top: Math.min(b.top, footPos.y + (c.height ?? b.top - footPos.y)) } : null;
6333
6525
  }
6334
- return x > b.minX - r && x < b.maxX + r && z > b.minZ - r && z < b.maxZ + r ? b : null;
6526
+ if (!(x > b.minX - r && x < b.maxX + r && z > b.minZ - r && z < b.maxZ + r)) return null;
6527
+ let base = Infinity;
6528
+ let top = -Infinity;
6529
+ for (const p of b.parts) {
6530
+ if (!(x > p[0] - r && x < p[3] + r && z > p[2] - r && z < p[5] + r)) continue;
6531
+ base = Math.min(base, p[1]);
6532
+ top = Math.max(top, p[4]);
6533
+ }
6534
+ return top > base ? { ...b, base, top } : null;
6335
6535
  };
6336
6536
  blockers.add((x, z, r) => {
6337
6537
  for (const e of entries.values()) {
@@ -6495,6 +6695,10 @@ function createTestRoom(options) {
6495
6695
  for (const listener of [...resetListeners]) listener();
6496
6696
  tools.clear();
6497
6697
  tool = toolState(toolId);
6698
+ dualReload.cancel();
6699
+ offTool = null;
6700
+ offToolId = null;
6701
+ firedHand = "R";
6498
6702
  if (pristineWorld) {
6499
6703
  for (const id of world.ids()) world.remove(id);
6500
6704
  world.load(pristineWorld);
@@ -6760,12 +6964,13 @@ function createTestRoom(options) {
6760
6964
  const pocketAt = new THREE.Vector3();
6761
6965
  const aimTurn = new THREE.Quaternion();
6762
6966
  const aimEuler = new THREE.Euler(0, 0, 0, "YXZ");
6763
- const bodyHoldFrame = () => {
6764
- const shoulder = avatar?.shoulder?.("R");
6967
+ const bodyHoldFrame = (hand = "R") => {
6968
+ const shoulder = avatar?.shoulder?.(hand);
6765
6969
  if (!shoulder) return null;
6766
6970
  shoulder.getWorldPosition(pocketAt);
6767
- pocketAt.x -= Math.cos(player.yaw) * SHOULDER_POCKET_IN;
6768
- pocketAt.z += Math.sin(player.yaw) * SHOULDER_POCKET_IN;
6971
+ const inward = hand === "L" ? -SHOULDER_POCKET_IN : SHOULDER_POCKET_IN;
6972
+ pocketAt.x -= Math.cos(player.yaw) * inward;
6973
+ pocketAt.z += Math.sin(player.yaw) * inward;
6769
6974
  aimTurn.setFromEuler(aimEuler.set(player.pitch, player.yaw, 0, "YXZ"));
6770
6975
  return { position: [pocketAt.x, pocketAt.y, pocketAt.z], quaternion: [aimTurn.x, aimTurn.y, aimTurn.z, aimTurn.w] };
6771
6976
  };
@@ -6875,17 +7080,18 @@ function createTestRoom(options) {
6875
7080
  rng = rng * 1664525 + 1013904223 >>> 0;
6876
7081
  return rng / 4294967296;
6877
7082
  };
6878
- const muzzlePose = () => {
6879
- const lent = muzzleSource?.();
6880
- return lent ? { position: [...lent.position], drawsFlash: lent.drawsFlash } : { position: muzzle(), drawsFlash: false };
7083
+ const muzzlePose = (hand = "R") => {
7084
+ const lent = muzzleSource?.(hand);
7085
+ return lent ? { position: [...lent.position], drawsFlash: lent.drawsFlash } : { position: muzzle(hand), drawsFlash: false };
6881
7086
  };
6882
- const muzzle = () => {
7087
+ const muzzle = (hand = "R") => {
6883
7088
  aim();
6884
7089
  const e = camera.matrixWorld.elements;
7090
+ const side = hand === "L" ? -0.16 : 0.16;
6885
7091
  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
7092
+ aimO[0] + aimD[0] * 0.45 + e[0] * side - e[4] * 0.1,
7093
+ aimO[1] + aimD[1] * 0.45 + e[1] * side - e[5] * 0.1,
7094
+ aimO[2] + aimD[2] * 0.45 + e[2] * side - e[6] * 0.1
6889
7095
  ];
6890
7096
  };
6891
7097
  const tracers = [];
@@ -7065,10 +7271,10 @@ function createTestRoom(options) {
7065
7271
  renderer.impulse(objectId, push2.impulse, push2.point);
7066
7272
  }
7067
7273
  };
7068
- const fireShots = (shots, recoil) => {
7274
+ const fireShots = (shots, recoil, id = toolId, hand = "R") => {
7069
7275
  if (shots.length === 0) return;
7070
- const w = toolName();
7071
- const { position: m, drawsFlash } = muzzlePose();
7276
+ const w = toolName(id);
7277
+ const { position: m, drawsFlash } = muzzlePose(hand);
7072
7278
  if (!drawsFlash) {
7073
7279
  renderer.emit("flash", m, [aimD[0], aimD[1], aimD[2]], 1, 0);
7074
7280
  renderer.emit("sparks", m, [aimD[0], aimD[1], aimD[2]], 4, 6);
@@ -7107,7 +7313,7 @@ function createTestRoom(options) {
7107
7313
  objectId: f?.object,
7108
7314
  itemId: f ? entryOf(f.object)?.so.itemId : void 0,
7109
7315
  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 }
7316
+ 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
7317
  });
7112
7318
  };
7113
7319
  const fireRays = (u) => {
@@ -7211,7 +7417,7 @@ function createTestRoom(options) {
7211
7417
  const kickLeg = options.kickLeg ?? "alternate";
7212
7418
  let nextLeg = "right";
7213
7419
  const punchHand = options.punchHand ?? "alternate";
7214
- const punchTools = new Set(options.punchTools ?? ["hand", "fists"]);
7420
+ const punchTools = new Set(options.punchTools ?? ["hand", "fists", "brass_knuckles"]);
7215
7421
  let nextHand = "right";
7216
7422
  const combo = options.strikeCombo;
7217
7423
  let comboOn = !!combo && options.strikeComboOn === true;
@@ -7225,16 +7431,16 @@ function createTestRoom(options) {
7225
7431
  action("combo", t(on ? "act.combo.on" : "act.combo.off", { name }), { data: { combo: combo.id, on } });
7226
7432
  say(t(on ? "act.combo.on" : "act.combo.off", { name }));
7227
7433
  };
7228
- const strikeUse = (u, id = toolId) => {
7434
+ const strikeUse = (u, id = toolId, side) => {
7229
7435
  const stepIndex = comboOn && combo?.tools.includes(id) && combo.steps.length > 0 ? simTime - comboAt > combo.reset ? 0 : comboNext : null;
7230
7436
  const step2 = stepIndex === null ? void 0 : combo.steps[stepIndex];
7231
7437
  const clip = step2 ? step2.clip : strikeClips[id];
7232
7438
  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;
7439
+ const hand = side ? side.hand : step2 ? step2.hand : punchTools.has(id) ? punchHand === "alternate" ? nextHand : punchHand : void 0;
7440
+ const limb = leg ? { leg } : hand ? { hand } : void 0;
7441
+ const seconds = clip === void 0 ? null : avatar?.strike?.(clip, limb) ?? null;
7236
7442
  if (leg && seconds !== null && kickLeg === "alternate") nextLeg = leg === "right" ? "left" : "right";
7237
- if (!step2 && hand && seconds !== null && punchHand === "alternate") nextHand = hand === "right" ? "left" : "right";
7443
+ if (!step2 && !side && hand && seconds !== null && punchHand === "alternate") nextHand = hand === "right" ? "left" : "right";
7238
7444
  if (step2 && stepIndex !== null) {
7239
7445
  comboNext = (stepIndex + 1) % combo.steps.length;
7240
7446
  comboAt = simTime;
@@ -7255,7 +7461,7 @@ function createTestRoom(options) {
7255
7461
  data: { weapon: id, clip, impactIn: seconds, ...leg ? { leg } : {}, ...hand ? { hand } : {}, ...step2 ? { combo: combo.id, step: stepIndex } : {} }
7256
7462
  });
7257
7463
  strikes.push({
7258
- key: id,
7464
+ key: side?.key ?? id,
7259
7465
  remaining: seconds,
7260
7466
  ...swing && seconds > lead ? { swing, lead } : {},
7261
7467
  land: () => {
@@ -7383,22 +7589,53 @@ function createTestRoom(options) {
7383
7589
  emitHost,
7384
7590
  startFire,
7385
7591
  say,
7386
- projectileModel: options.projectileModel
7592
+ projectileModel: options.projectileModel,
7593
+ audio
7387
7594
  });
7388
- const useTool = (dt, held) => {
7595
+ const toolOf = (hand) => hand === "L" ? offTool && offToolId ? { id: offToolId, state: offTool } : null : { id: toolId, state: tool };
7596
+ const dualReload = createDualReload(
7597
+ {
7598
+ needs: (hand) => {
7599
+ const state = toolOf(hand)?.state;
7600
+ return state !== void 0 && (state.def.action.kind === "firearm" || state.def.action.kind === "launcher") && state.ammo < state.capacity && state.reserve > 0;
7601
+ },
7602
+ startReload: (hand) => startReload(hand),
7603
+ reloading: (hand) => toolOf(hand)?.state.reloading ?? false,
7604
+ stow: (hand, on, seconds) => {
7605
+ const slot2 = toolOf(hand);
7606
+ if (!slot2) return;
7607
+ if (on) slot2.state.release();
7608
+ emitHost({ type: "stow", weapon: slot2.id, on, duration: seconds, ...hand === "L" ? { hand } : {} });
7609
+ 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 } });
7610
+ }
7611
+ },
7612
+ () => arsenal.rules.dualWield.stowSeconds
7613
+ );
7614
+ const strikeKeyOf = (id, hand) => hand === "L" ? `${id}${LEFT_HAND_KEY}` : id;
7615
+ const useTool = (dt, held, hand = "R") => {
7389
7616
  aim();
7390
- const cat = tool.def.category;
7617
+ const slot2 = toolOf(hand);
7618
+ if (!slot2) return "cooldown";
7619
+ const { id, state } = slot2;
7620
+ if (offTool && dualReload.busy) {
7621
+ if (!held) say(t("hud.reloading"));
7622
+ return "reloading";
7623
+ }
7624
+ const cat = state.def.category;
7391
7625
  if (cat === "hand") {
7392
7626
  if (strikes.some((strike) => strike.key === "hand" || strike.key === "fists")) return "cooldown";
7393
7627
  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
7628
  return "ok";
7395
7629
  }
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);
7630
+ if (strikes.some((strike) => strike.key === strikeKeyOf(id, hand))) return "cooldown";
7631
+ const res = state.trigger([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], dt, held, "player");
7632
+ flushEvents(id, state, res.recoil, hand);
7399
7633
  if (res.status === "empty") {
7400
- if (!tool.reloading && tool.reserve > 0) startReload();
7401
- say(t(tool.reloading ? "hud.reloading" : "hud.empty"));
7634
+ if (!state.reloading && state.reserve > 0) {
7635
+ if (offTool) dualReload.start([hand, hand === "R" ? "L" : "R"]);
7636
+ else startReload(hand);
7637
+ }
7638
+ say(t(state.reloading ? "hud.reloading" : "hud.empty"));
7402
7639
  return res.status;
7403
7640
  }
7404
7641
  if (res.status !== "ok") {
@@ -7408,15 +7645,18 @@ function createTestRoom(options) {
7408
7645
  else if (res.status === "safe") say(t("hud.safe"));
7409
7646
  return res.status;
7410
7647
  }
7411
- applyUses(res, dt);
7648
+ firedHand = hand;
7649
+ applyUses(res, dt, hand);
7412
7650
  return res.status;
7413
7651
  };
7414
- const applyUses = (res, dt) => {
7415
- if (res.shots.length) fireShots(res.shots, res.recoil);
7652
+ const applyUses = (res, dt, hand = "R") => {
7653
+ const id = toolOf(hand)?.id ?? toolId;
7654
+ if (res.shots.length) fireShots(res.shots, res.recoil, id, hand);
7416
7655
  for (const u of res.uses) {
7417
7656
  switch (u.kind) {
7418
7657
  case "melee":
7419
- strikeUse(u);
7658
+ if (hand === "L") strikeUse(u, id, { hand: "left", key: strikeKeyOf(id, hand) });
7659
+ else strikeUse(u);
7420
7660
  break;
7421
7661
  case "flame":
7422
7662
  heatRays(u, "fire");
@@ -7444,15 +7684,17 @@ function createTestRoom(options) {
7444
7684
  }
7445
7685
  }
7446
7686
  };
7447
- const releaseTrigger = () => {
7448
- if (!tool.cooking) {
7449
- tool.release();
7687
+ const releaseTrigger = (hand = "R") => {
7688
+ const slot2 = toolOf(hand);
7689
+ if (!slot2) return;
7690
+ if (!slot2.state.cooking) {
7691
+ slot2.state.release();
7450
7692
  return;
7451
7693
  }
7452
7694
  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);
7695
+ const res = slot2.state.release([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]]);
7696
+ flushEvents(slot2.id, slot2.state, void 0, hand);
7697
+ if (res) applyUses(res, 0, hand);
7456
7698
  };
7457
7699
  const kick = () => {
7458
7700
  if (!arsenal.weapons.has("kick") || strikes.some((strike) => strike.key === "kick")) return false;
@@ -7481,8 +7723,43 @@ function createTestRoom(options) {
7481
7723
  }));
7482
7724
  };
7483
7725
  const toggleToolMenu = (open) => ui?.toolMenu(open) ?? false;
7726
+ const dropOffTool = () => {
7727
+ if (!offTool) return;
7728
+ releaseTrigger("L");
7729
+ offTool = null;
7730
+ offToolId = null;
7731
+ offHeld = false;
7732
+ offFirst = false;
7733
+ dualReload.cancel();
7734
+ firedHand = "R";
7735
+ tool.setStance({ dualWield: false });
7736
+ action("tool_left", t("act.tool_left_off"), { data: { hand: "L" } });
7737
+ emitHost({ type: "tool", weapon: "", hand: "L" });
7738
+ };
7739
+ const setOffTool = (id) => {
7740
+ if (id === null) {
7741
+ dropOffTool();
7742
+ return true;
7743
+ }
7744
+ if (!arsenal.weapons.has(id)) throw new Error(`game-test-room: unknown weapon "${id}"`);
7745
+ if (!canDualWield(arsenal, id) || !canDualWield(arsenal, toolId)) return false;
7746
+ if (offTool) releaseTrigger("L");
7747
+ const state = arsenal.create(id, { seed: OFF_HAND_SEED, reserve: 999, ...toolSettings });
7748
+ state.setFuseLength(fuseLength);
7749
+ state.refill();
7750
+ state.setStance({ dualWield: true });
7751
+ tool.setStance({ dualWield: true });
7752
+ offTool = state;
7753
+ offToolId = id;
7754
+ dualReload.cancel();
7755
+ action("tool_left", t("act.tool_left", { weapon: toolName(id) }), { data: { weapon: id, hand: "L" } });
7756
+ state.draw();
7757
+ flushEvents(id, state, void 0, "L");
7758
+ return true;
7759
+ };
7484
7760
  const setTool = (id) => {
7485
7761
  if (!arsenal.weapons.has(id)) throw new Error(`game-test-room: unknown weapon "${id}"`);
7762
+ dropOffTool();
7486
7763
  releaseTrigger();
7487
7764
  toolId = id;
7488
7765
  const i = loadout.indexOf(id);
@@ -7975,15 +8252,13 @@ function createTestRoom(options) {
7975
8252
  case "KeyR":
7976
8253
  if (heldPiece) heldYaw += Math.PI / 8;
7977
8254
  else {
7978
- startReload();
8255
+ reloadHands();
7979
8256
  say(t("hud.reloading"));
7980
8257
  }
7981
8258
  break;
7982
- case "KeyQ": {
7983
- tool.cycleMode();
7984
- flushEvents(toolId, tool);
8259
+ case "KeyQ":
8260
+ cycleToolMode();
7985
8261
  break;
7986
- }
7987
8262
  case "KeyX":
7988
8263
  clearToolFault();
7989
8264
  break;
@@ -8040,6 +8315,20 @@ function createTestRoom(options) {
8040
8315
  if (!loadout.includes(weaponId)) loadout.push(weaponId);
8041
8316
  setTool(weaponId);
8042
8317
  };
8318
+ const pickTool = (weaponId, pick = { second: false }) => {
8319
+ if (!arsenal.weapons.has(weaponId)) return;
8320
+ if (!pick.second) {
8321
+ equip(weaponId);
8322
+ return;
8323
+ }
8324
+ if (!canDualWield(arsenal, weaponId)) {
8325
+ equip(weaponId);
8326
+ say(t("hud.no_dual", { weapon: toolName(weaponId) }));
8327
+ return;
8328
+ }
8329
+ if (!offTool || !canDualWield(arsenal, toolId)) equip(weaponId);
8330
+ setOffTool(weaponId);
8331
+ };
8043
8332
  let hudAimHit;
8044
8333
  const inspectTarget = () => {
8045
8334
  if (hudAimHit !== void 0) return hudAimHit;
@@ -8243,6 +8532,11 @@ function createTestRoom(options) {
8243
8532
  s.update(dt);
8244
8533
  flushEvents(id, s);
8245
8534
  }
8535
+ if (offTool && offToolId) {
8536
+ offTool.update(dt);
8537
+ flushEvents(offToolId, offTool, void 0, "L");
8538
+ dualReload.step(dt);
8539
+ }
8246
8540
  if (audio) {
8247
8541
  const look = camera.getWorldDirection(listenerDirection);
8248
8542
  audio.setListener({ position: [camera.position.x, camera.position.y, camera.position.z], yaw: Math.atan2(-look.x, -look.z) });
@@ -8288,6 +8582,10 @@ function createTestRoom(options) {
8288
8582
  } else if (useHeld) useTool(dt, !useFirst);
8289
8583
  useFirst = false;
8290
8584
  }
8585
+ if ((offHeld || offFirst) && offTool && !isPaused && !push.active) {
8586
+ useTool(dt, !offFirst, "L");
8587
+ offFirst = false;
8588
+ }
8291
8589
  if (!isPaused) followHeld(dt);
8292
8590
  };
8293
8591
  const updatePresent = (dt) => {
@@ -8314,12 +8612,18 @@ function createTestRoom(options) {
8314
8612
  hudAimHit = void 0;
8315
8613
  }
8316
8614
  };
8615
+ const ammoText = (state) => {
8616
+ const kind = state.def.action.kind;
8617
+ if (kind === "firearm") return `${state.ammo}/${state.capacity}${state.reserve ? ` \xB7 ${state.reserve}` : ""}`;
8618
+ return kind === "hand" || kind === "melee" || kind === "tool" ? "" : `${num(state.ammo, 0)}`;
8619
+ };
8317
8620
  const readHudState = () => {
8318
8621
  const page = Math.floor(toolIndex / 10);
8319
8622
  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)}`;
8623
+ const left = offTool ? ammoText(offTool) : "";
8624
+ const ammo = `${ammoText(tool)}${left ? ` | ${t("hud.left")} ${left}` : ""}`;
8321
8625
  return {
8322
- toolName: toolName(),
8626
+ toolName: offToolId ? `${toolName()} + ${toolName(offToolId)}` : toolName(),
8323
8627
  toolId,
8324
8628
  ammo,
8325
8629
  slots,
@@ -8509,26 +8813,53 @@ function createTestRoom(options) {
8509
8813
  if (event.type === "explosion") knockPlayer(event);
8510
8814
  for (const listener of hostListeners) listener(event);
8511
8815
  }
8512
- function flushEvents(id, state, recoil) {
8816
+ function flushEvents(id, state, recoil, hand = "R") {
8513
8817
  const phase = state.phase;
8514
8818
  for (const event of state.drain()) {
8515
8819
  const hostEvent = toHostEvent(id, event, { ...recoil ? { recoil } : {}, phase });
8516
- if (hostEvent) emitHost(hostEvent);
8820
+ if (hostEvent) emitHost(hand === "L" ? { ...hostEvent, hand } : hostEvent);
8517
8821
  }
8518
8822
  }
8519
- function startReload() {
8520
- tool.reload();
8521
- flushEvents(toolId, tool);
8823
+ function startReload(hand = "R") {
8824
+ const slot2 = toolOf(hand);
8825
+ if (!slot2) return false;
8826
+ const started = slot2.state.reload();
8827
+ flushEvents(slot2.id, slot2.state, void 0, hand);
8828
+ return started;
8522
8829
  }
8830
+ function reloadHands() {
8831
+ if (!offTool) {
8832
+ startReload();
8833
+ return;
8834
+ }
8835
+ dualReload.start(["R", "L"]);
8836
+ }
8837
+ const commandHand = () => offTool ? firedHand : "R";
8523
8838
  function clearToolFault() {
8524
- if (!tool.clearFault()) return;
8525
- flushEvents(toolId, tool);
8526
- say(t("hud.clearing"));
8839
+ const order = offTool ? [firedHand, firedHand === "R" ? "L" : "R"] : ["R"];
8840
+ for (const hand of order) {
8841
+ const slot2 = toolOf(hand);
8842
+ if (!slot2?.state.fault || !slot2.state.clearFault()) continue;
8843
+ flushEvents(slot2.id, slot2.state, void 0, hand);
8844
+ say(t("hud.clearing"));
8845
+ return;
8846
+ }
8527
8847
  }
8528
8848
  function toggleSafety() {
8529
- tool.setSafety(!tool.safety);
8530
- flushEvents(toolId, tool);
8531
- say(t(tool.safety ? "hud.safety_on" : "hud.safety_off"));
8849
+ const hand = commandHand();
8850
+ const slot2 = toolOf(hand);
8851
+ if (!slot2) return;
8852
+ slot2.state.setSafety(!slot2.state.safety);
8853
+ flushEvents(slot2.id, slot2.state, void 0, hand);
8854
+ say(`${t(slot2.state.safety ? "hud.safety_on" : "hud.safety_off")}${offTool ? ` (${toolName(slot2.id)}, ${t(hand === "L" ? "hand.left" : "hand.right")})` : ""}`);
8855
+ }
8856
+ function cycleToolMode() {
8857
+ const hand = commandHand();
8858
+ const slot2 = toolOf(hand);
8859
+ if (!slot2) return;
8860
+ slot2.state.cycleMode();
8861
+ flushEvents(slot2.id, slot2.state, void 0, hand);
8862
+ if (offTool) say(`${toolName(slot2.id)} (${t(hand === "L" ? "hand.left" : "hand.right")}): ${slot2.state.mode}`);
8532
8863
  }
8533
8864
  function runToolCommand(request) {
8534
8865
  const work = (done) => done.ok ? { ok: true, seconds: done.seconds } : { ok: false, reason: done.reason };
@@ -8550,6 +8881,24 @@ function createTestRoom(options) {
8550
8881
  return { ok: false, reason: "unknown" };
8551
8882
  }
8552
8883
  }
8884
+ const hostToolOf = (id, state) => ({
8885
+ weapon: id,
8886
+ category: state.def.category,
8887
+ ammo: state.ammo,
8888
+ capacity: state.capacity,
8889
+ reserve: state.reserve,
8890
+ reloading: state.reloading,
8891
+ mode: state.mode,
8892
+ ...state.fault ? { fault: state.fault } : {},
8893
+ safety: state.safety,
8894
+ heat: state.heatLevel,
8895
+ attachments: state.attachments.map((part) => ({ id: part.id, mount: part.mount, ...part.integral ? { integral: true } : {}, ...part.on === void 0 ? {} : { on: part.on } })),
8896
+ stock: state.stock,
8897
+ suppressed: state.suppressed,
8898
+ underbarrelActive: state.underbarrelActive,
8899
+ mass: state.mass,
8900
+ aimSeconds: state.aimSeconds
8901
+ });
8553
8902
  const host = {
8554
8903
  THREE,
8555
8904
  scene,
@@ -8576,22 +8925,9 @@ function createTestRoom(options) {
8576
8925
  ...options.blastShove ? { blastShove: options.blastShove } : {},
8577
8926
  blastReach: (point, target, ignore) => world.blastReach(point, target, ignore ? { ignore } : {}),
8578
8927
  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
8928
+ ...hostToolOf(toolId, tool),
8929
+ ...dualReload.stowed("R") ? { stowed: true } : {},
8930
+ ...offTool && offToolId ? { offHand: { ...hostToolOf(offToolId, offTool), ...dualReload.stowed("L") ? { stowed: true } : {} } } : {}
8595
8931
  }),
8596
8932
  command(request) {
8597
8933
  const result = runToolCommand(request);
@@ -8628,7 +8964,7 @@ function createTestRoom(options) {
8628
8964
  onFrame(update, frameOptions) {
8629
8965
  return scheduler.add((ctx) => update(ctx.dt), { phase: frameOptions?.phase ?? "lateSimulate", order: frameOptions?.order ?? 0, id: `module.frame#${moduleFrameCount++}` });
8630
8966
  },
8631
- holdFrame: () => bodyHoldFrame(),
8967
+ holdFrame: (hand) => bodyHoldFrame(hand),
8632
8968
  reachFor(reach) {
8633
8969
  if (!avatar) {
8634
8970
  reach.onGrip?.();
@@ -9004,6 +9340,7 @@ function createTestRoom(options) {
9004
9340
  fluids,
9005
9341
  film: filmBridge,
9006
9342
  vfx: vfx.world,
9343
+ effects: vfx,
9007
9344
  fluidRenderer: fluidView,
9008
9345
  audio,
9009
9346
  environment,
@@ -9019,6 +9356,29 @@ function createTestRoom(options) {
9019
9356
  clear,
9020
9357
  resetAll,
9021
9358
  setTool,
9359
+ setOffTool,
9360
+ get offTool() {
9361
+ return offTool;
9362
+ },
9363
+ useOff(hold = 0) {
9364
+ if (!offTool) return "cooldown";
9365
+ const status = useTool(FIXED_DT, false, "L");
9366
+ flush();
9367
+ let t0 = 0;
9368
+ while (t0 < hold) {
9369
+ useTool(FIXED_DT, true, "L");
9370
+ directStep = true;
9371
+ try {
9372
+ runHeadless(() => scheduler.advance(FIXED_DT));
9373
+ } finally {
9374
+ directStep = false;
9375
+ }
9376
+ t0 += FIXED_DT;
9377
+ }
9378
+ releaseTrigger("L");
9379
+ return status;
9380
+ },
9381
+ reload: () => reloadHands(),
9022
9382
  useItem,
9023
9383
  use(hold = 0) {
9024
9384
  if (tool.def.category === "hand") {
@@ -9316,7 +9676,8 @@ function createTestRoom(options) {
9316
9676
  ...options.toolMenu !== false ? {
9317
9677
  toolMenu: {
9318
9678
  groups: toolMenuGroups,
9319
- onPick: equip,
9679
+ onPick: pickTool,
9680
+ currentLeft: () => offToolId,
9320
9681
  // the list takes clicks: the mouse leaves the view while it is open
9321
9682
  onOpen: (open) => {
9322
9683
  if (open && document.pointerLockElement) document.exitPointerLock?.();