@adatechnology/game-test-room 0.28.1 → 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",
@@ -3576,6 +3678,7 @@ var en = {
3576
3678
  "act.throw": "Threw {name} at {speed} m/s",
3577
3679
  "ground.name": "Ground",
3578
3680
  "act.landing": "{name} landed at {speed} m/s: {energy} J",
3681
+ "act.ground_gave_way": "the bare bars could not hold the weight on them: the slab gave way",
3579
3682
  "act.player_land": "You landed at {speed} m/s",
3580
3683
  "act.bench_failed": "{name} failed: {error}",
3581
3684
  "loading.working": "Working\u2026",
@@ -3700,6 +3803,7 @@ var en = {
3700
3803
  "ui.clear_log": "Clear",
3701
3804
  "ui.catalog": "Catalogue",
3702
3805
  "ui.tool_menu": "Weapons",
3806
+ "ui.tool_menu.dual": "Shift+click: one in each hand",
3703
3807
  "ui.tool_menu.search": "Search weapons and tools",
3704
3808
  "ui.tool_menu.close": "Close",
3705
3809
  "ui.tool_menu.results": "{n} found",
@@ -3903,6 +4007,12 @@ var ptBR = {
3903
4007
  "act.clear": "Limpou a sala",
3904
4008
  "act.reset": "Consertou todos os objetos",
3905
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",
3906
4016
  "act.shoot.hit": "{weapon}: disparo, {energy} J, acertou {where} a {dist} m",
3907
4017
  "act.shoot.miss": "{weapon}: disparo, {energy} J, n\xE3o acertou nada",
3908
4018
  "act.shoot.pellets": "{weapon}: disparo, {n} proj\xE9teis de at\xE9 {energy} J, {hits} acertaram {where} a {dist} m",
@@ -3932,6 +4042,7 @@ var ptBR = {
3932
4042
  "hud.no_charges": "Nenhuma carga posta espera o detonador",
3933
4043
  "blast.zone.lethal": "letal",
3934
4044
  "blast.zone.injury": "de ferimento",
4045
+ "act.smoke": "{weapon}: fuma\xE7a, queima por {seconds} s",
3935
4046
  "act.explode": "{weapon}: explodiu, {energy} J em {radius} m, {n} objetos ao alcance",
3936
4047
  "act.molotov_fire": "{weapon}: fogo no ch\xE3o, raio de {radius} m por {seconds} s",
3937
4048
  "act.tool.none": "{weapon}: nada ao alcance",
@@ -3945,6 +4056,7 @@ var ptBR = {
3945
4056
  "act.throw": "Arremessou {name} a {speed} m/s",
3946
4057
  "ground.name": "Ch\xE3o",
3947
4058
  "act.landing": "{name} caiu a {speed} m/s: {energy} J",
4059
+ "act.ground_gave_way": "os vergalh\xF5es expostos n\xE3o aguentaram o peso: a laje cedeu",
3948
4060
  "act.player_land": "Voc\xEA aterrissou a {speed} m/s",
3949
4061
  "act.bench_failed": "{name} falhou: {error}",
3950
4062
  "loading.working": "Trabalhando\u2026",
@@ -4069,6 +4181,7 @@ var ptBR = {
4069
4181
  "ui.clear_log": "Limpar",
4070
4182
  "ui.catalog": "Cat\xE1logo",
4071
4183
  "ui.tool_menu": "Armas",
4184
+ "ui.tool_menu.dual": "Shift+clique: uma em cada m\xE3o",
4072
4185
  "ui.tool_menu.search": "Buscar armas e ferramentas",
4073
4186
  "ui.tool_menu.close": "Fechar",
4074
4187
  "ui.tool_menu.results": "{n} encontradas",
@@ -4277,6 +4390,27 @@ function createBenchPanel(o) {
4277
4390
  return wrap;
4278
4391
  };
4279
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
+ };
4280
4414
  const sheetCard = (sheet) => {
4281
4415
  const card = mk("div", "tr-sheet");
4282
4416
  if (sheet.kicker) card.appendChild(mk("div", "sub", sheet.kicker));
@@ -4328,6 +4462,7 @@ function createBenchPanel(o) {
4328
4462
  playhead = { node: head, metric, end };
4329
4463
  card.appendChild(strip);
4330
4464
  }
4465
+ if (sheet.image) card.appendChild(sheetImage(sheet.image));
4331
4466
  if (sheet.note) card.appendChild(mk("p", "sub", sheet.note));
4332
4467
  return card;
4333
4468
  };
@@ -4561,7 +4696,7 @@ function createToolMenu(o) {
4561
4696
  const close = mk3("button", "tr-btn", "\xD7");
4562
4697
  close.type = "button";
4563
4698
  close.title = o.t("ui.tool_menu.close");
4564
- 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);
4565
4700
  const tabs = mk3("div", "tr-tabs");
4566
4701
  const body = mk3("div", "tr-wmenu-body");
4567
4702
  element.append(header, tabs, body);
@@ -4569,15 +4704,17 @@ function createToolMenu(o) {
4569
4704
  let activeGroup = "";
4570
4705
  let groups = [];
4571
4706
  const toolButton = (tool, group) => {
4572
- 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");
4573
4709
  button.type = "button";
4574
4710
  button.title = tool.name;
4711
+ if (left) button.dataset.left = "";
4575
4712
  button.dataset.tool = tool.id;
4576
4713
  if (tool.thumbnail) button.appendChild(o.thumb(tool.thumbnail, tool.thumbnailCredit));
4577
4714
  button.appendChild(mk3("span", void 0, tool.name));
4578
4715
  if (group) button.appendChild(mk3("small", void 0, group));
4579
- button.onclick = () => {
4580
- o.onPick(tool.id);
4716
+ button.onclick = (event) => {
4717
+ o.onPick(tool.id, { second: event.shiftKey });
4581
4718
  toggle(false);
4582
4719
  };
4583
4720
  return button;
@@ -4673,6 +4810,8 @@ var CSS = `
4673
4810
  .tr-prog b{display:block;font-weight:500;margin-bottom:3px}
4674
4811
  .tr-sheet{margin-top:8px;padding:8px;border:1px solid #2a2e36;border-radius:6px;font-size:12px;line-height:1.4}
4675
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}
4676
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}
4677
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}
4678
4817
  .tr-timeline{position:relative;height:22px;margin:8px 0;background:#1b1e24;border-radius:4px;overflow:hidden}
@@ -4800,7 +4939,7 @@ function createUi(o) {
4800
4939
  bottom.append(held, hotbar);
4801
4940
  let currentTool = "";
4802
4941
  const menuOptions = o.toolMenu;
4803
- 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;
4804
4943
  if (toolMenu && menuOptions) {
4805
4944
  const menuButton = mk4("button", "tr-slot tr-menu-btn");
4806
4945
  menuButton.type = "button";
@@ -5448,6 +5587,8 @@ function createModuleViews(THREE, gl) {
5448
5587
 
5449
5588
  // src/room.ts
5450
5589
  var FIXED_DT = 1 / 60;
5590
+ var LEFT_HAND_KEY = "#L";
5591
+ var OFF_HAND_SEED = 11;
5451
5592
  var TOOL_MENU_KEY_LABELS = { Backquote: "'" };
5452
5593
  var EXPLOSIVE_EFFECT_IDS = ["m67", "molotov", "pipe_bomb", "m79_40mm", "m72_66mm", "at4_84mm", "dynamite", "c4", "claymore"];
5453
5594
  var DEFAULT_PARTICLE_LIMIT = 900;
@@ -5465,6 +5606,11 @@ var GROUND_ID = "room_ground";
5465
5606
  var TERRAIN_CELL = 0.1;
5466
5607
  var GROUND_SPLASH_MAX_LITRES = 3;
5467
5608
  var WATER_ENTRY_SPEED = 1.5;
5609
+ var LOAD_EVERY = 0.2;
5610
+ var RESTING_GAP = 0.06;
5611
+ var PLAYER_FOOTPRINT = 0.25;
5612
+ var RESTING_SPEED = 0.3;
5613
+ var GRAVITY_EARTH = 9.81;
5468
5614
  var TERRAIN_MAX_CELLS = 1e3;
5469
5615
  var GROUND_BURST_MAX = 28;
5470
5616
  var GROUND_BURST_PER_METRE = 30;
@@ -5738,6 +5884,11 @@ function createTestRoom(options) {
5738
5884
  return s;
5739
5885
  };
5740
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";
5741
5892
  const catalogMap = /* @__PURE__ */ new Map();
5742
5893
  for (const i of options.catalog) catalogMap.set(i.id, i);
5743
5894
  const catalog = [...options.catalog];
@@ -5947,9 +6098,19 @@ function createTestRoom(options) {
5947
6098
  releaseTrigger();
5948
6099
  },
5949
6100
  secondary: () => {
6101
+ if (offTool && !heldEntry) {
6102
+ offHeld = true;
6103
+ offFirst = true;
6104
+ return;
6105
+ }
5950
6106
  if (heldEntry) throwHeld();
5951
6107
  else grab();
5952
6108
  },
6109
+ secondaryUp: () => {
6110
+ if (!offHeld) return;
6111
+ offHeld = false;
6112
+ releaseTrigger("L");
6113
+ },
5953
6114
  key: (code, down, e) => onKey(code, down, e),
5954
6115
  wheel: (dir) => cycleTool(dir)
5955
6116
  }
@@ -6488,6 +6649,10 @@ function createTestRoom(options) {
6488
6649
  for (const listener of [...resetListeners]) listener();
6489
6650
  tools.clear();
6490
6651
  tool = toolState(toolId);
6652
+ dualReload.cancel();
6653
+ offTool = null;
6654
+ offToolId = null;
6655
+ firedHand = "R";
6491
6656
  if (pristineWorld) {
6492
6657
  for (const id of world.ids()) world.remove(id);
6493
6658
  world.load(pristineWorld);
@@ -6753,12 +6918,13 @@ function createTestRoom(options) {
6753
6918
  const pocketAt = new THREE.Vector3();
6754
6919
  const aimTurn = new THREE.Quaternion();
6755
6920
  const aimEuler = new THREE.Euler(0, 0, 0, "YXZ");
6756
- const bodyHoldFrame = () => {
6757
- const shoulder = avatar?.shoulder?.("R");
6921
+ const bodyHoldFrame = (hand = "R") => {
6922
+ const shoulder = avatar?.shoulder?.(hand);
6758
6923
  if (!shoulder) return null;
6759
6924
  shoulder.getWorldPosition(pocketAt);
6760
- pocketAt.x -= Math.cos(player.yaw) * SHOULDER_POCKET_IN;
6761
- 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;
6762
6928
  aimTurn.setFromEuler(aimEuler.set(player.pitch, player.yaw, 0, "YXZ"));
6763
6929
  return { position: [pocketAt.x, pocketAt.y, pocketAt.z], quaternion: [aimTurn.x, aimTurn.y, aimTurn.z, aimTurn.w] };
6764
6930
  };
@@ -6868,17 +7034,18 @@ function createTestRoom(options) {
6868
7034
  rng = rng * 1664525 + 1013904223 >>> 0;
6869
7035
  return rng / 4294967296;
6870
7036
  };
6871
- const muzzlePose = () => {
6872
- const lent = muzzleSource?.();
6873
- 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 };
6874
7040
  };
6875
- const muzzle = () => {
7041
+ const muzzle = (hand = "R") => {
6876
7042
  aim();
6877
7043
  const e = camera.matrixWorld.elements;
7044
+ const side = hand === "L" ? -0.16 : 0.16;
6878
7045
  return [
6879
- aimO[0] + aimD[0] * 0.45 + e[0] * 0.16 - e[4] * 0.1,
6880
- aimO[1] + aimD[1] * 0.45 + e[1] * 0.16 - e[5] * 0.1,
6881
- 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
6882
7049
  ];
6883
7050
  };
6884
7051
  const tracers = [];
@@ -7058,10 +7225,10 @@ function createTestRoom(options) {
7058
7225
  renderer.impulse(objectId, push2.impulse, push2.point);
7059
7226
  }
7060
7227
  };
7061
- const fireShots = (shots, recoil) => {
7228
+ const fireShots = (shots, recoil, id = toolId, hand = "R") => {
7062
7229
  if (shots.length === 0) return;
7063
- const w = toolName();
7064
- const { position: m, drawsFlash } = muzzlePose();
7230
+ const w = toolName(id);
7231
+ const { position: m, drawsFlash } = muzzlePose(hand);
7065
7232
  if (!drawsFlash) {
7066
7233
  renderer.emit("flash", m, [aimD[0], aimD[1], aimD[2]], 1, 0);
7067
7234
  renderer.emit("sparks", m, [aimD[0], aimD[1], aimD[2]], 4, 6);
@@ -7100,7 +7267,7 @@ function createTestRoom(options) {
7100
7267
  objectId: f?.object,
7101
7268
  itemId: f ? entryOf(f.object)?.so.itemId : void 0,
7102
7269
  part: f?.part,
7103
- 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 } : {} }
7104
7271
  });
7105
7272
  };
7106
7273
  const fireRays = (u) => {
@@ -7204,7 +7371,7 @@ function createTestRoom(options) {
7204
7371
  const kickLeg = options.kickLeg ?? "alternate";
7205
7372
  let nextLeg = "right";
7206
7373
  const punchHand = options.punchHand ?? "alternate";
7207
- const punchTools = new Set(options.punchTools ?? ["hand", "fists"]);
7374
+ const punchTools = new Set(options.punchTools ?? ["hand", "fists", "brass_knuckles"]);
7208
7375
  let nextHand = "right";
7209
7376
  const combo = options.strikeCombo;
7210
7377
  let comboOn = !!combo && options.strikeComboOn === true;
@@ -7218,16 +7385,16 @@ function createTestRoom(options) {
7218
7385
  action("combo", t(on ? "act.combo.on" : "act.combo.off", { name }), { data: { combo: combo.id, on } });
7219
7386
  say(t(on ? "act.combo.on" : "act.combo.off", { name }));
7220
7387
  };
7221
- const strikeUse = (u, id = toolId) => {
7388
+ const strikeUse = (u, id = toolId, side) => {
7222
7389
  const stepIndex = comboOn && combo?.tools.includes(id) && combo.steps.length > 0 ? simTime - comboAt > combo.reset ? 0 : comboNext : null;
7223
7390
  const step2 = stepIndex === null ? void 0 : combo.steps[stepIndex];
7224
7391
  const clip = step2 ? step2.clip : strikeClips[id];
7225
7392
  const leg = id === "kick" ? kickLeg === "alternate" ? nextLeg : kickLeg : void 0;
7226
- const hand = step2 ? step2.hand : punchTools.has(id) ? punchHand === "alternate" ? nextHand : punchHand : void 0;
7227
- const side = leg ? { leg } : hand ? { hand } : void 0;
7228
- 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;
7229
7396
  if (leg && seconds !== null && kickLeg === "alternate") nextLeg = leg === "right" ? "left" : "right";
7230
- 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";
7231
7398
  if (step2 && stepIndex !== null) {
7232
7399
  comboNext = (stepIndex + 1) % combo.steps.length;
7233
7400
  comboAt = simTime;
@@ -7248,7 +7415,7 @@ function createTestRoom(options) {
7248
7415
  data: { weapon: id, clip, impactIn: seconds, ...leg ? { leg } : {}, ...hand ? { hand } : {}, ...step2 ? { combo: combo.id, step: stepIndex } : {} }
7249
7416
  });
7250
7417
  strikes.push({
7251
- key: id,
7418
+ key: side?.key ?? id,
7252
7419
  remaining: seconds,
7253
7420
  ...swing && seconds > lead ? { swing, lead } : {},
7254
7421
  land: () => {
@@ -7378,20 +7545,50 @@ function createTestRoom(options) {
7378
7545
  say,
7379
7546
  projectileModel: options.projectileModel
7380
7547
  });
7381
- 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") => {
7382
7569
  aim();
7383
- 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;
7384
7578
  if (cat === "hand") {
7385
7579
  if (strikes.some((strike) => strike.key === "hand" || strike.key === "fists")) return "cooldown";
7386
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");
7387
7581
  return "ok";
7388
7582
  }
7389
- if (strikes.some((strike) => strike.key === toolId)) return "cooldown";
7390
- const res = tool.trigger([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], dt, held, "player");
7391
- 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);
7392
7586
  if (res.status === "empty") {
7393
- if (!tool.reloading && tool.reserve > 0) startReload();
7394
- 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"));
7395
7592
  return res.status;
7396
7593
  }
7397
7594
  if (res.status !== "ok") {
@@ -7401,15 +7598,18 @@ function createTestRoom(options) {
7401
7598
  else if (res.status === "safe") say(t("hud.safe"));
7402
7599
  return res.status;
7403
7600
  }
7404
- applyUses(res, dt);
7601
+ firedHand = hand;
7602
+ applyUses(res, dt, hand);
7405
7603
  return res.status;
7406
7604
  };
7407
- const applyUses = (res, dt) => {
7408
- 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);
7409
7608
  for (const u of res.uses) {
7410
7609
  switch (u.kind) {
7411
7610
  case "melee":
7412
- strikeUse(u);
7611
+ if (hand === "L") strikeUse(u, id, { hand: "left", key: strikeKeyOf(id, hand) });
7612
+ else strikeUse(u);
7413
7613
  break;
7414
7614
  case "flame":
7415
7615
  heatRays(u, "fire");
@@ -7437,15 +7637,17 @@ function createTestRoom(options) {
7437
7637
  }
7438
7638
  }
7439
7639
  };
7440
- const releaseTrigger = () => {
7441
- if (!tool.cooking) {
7442
- 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();
7443
7645
  return;
7444
7646
  }
7445
7647
  aim();
7446
- const res = tool.release([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]]);
7447
- flushEvents(toolId, tool);
7448
- 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);
7449
7651
  };
7450
7652
  const kick = () => {
7451
7653
  if (!arsenal.weapons.has("kick") || strikes.some((strike) => strike.key === "kick")) return false;
@@ -7474,8 +7676,43 @@ function createTestRoom(options) {
7474
7676
  }));
7475
7677
  };
7476
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
+ };
7477
7713
  const setTool = (id) => {
7478
7714
  if (!arsenal.weapons.has(id)) throw new Error(`game-test-room: unknown weapon "${id}"`);
7715
+ dropOffTool();
7479
7716
  releaseTrigger();
7480
7717
  toolId = id;
7481
7718
  const i = loadout.indexOf(id);
@@ -7905,6 +8142,30 @@ function createTestRoom(options) {
7905
8142
  for (const p of loose) floatPiece(p, dt);
7906
8143
  };
7907
8144
  const footprints = createFootprints(terrain);
8145
+ let loadClock = 0;
8146
+ const restingLoad = (piece) => {
8147
+ if (piece.held || Math.hypot(piece.velocity[0], piece.velocity[1], piece.velocity[2]) > RESTING_SPEED) return null;
8148
+ const box2 = tmpBox.setFromObject(piece.root);
8149
+ const centre = box2.getCenter(tmpV);
8150
+ return { x: centre.x, z: centre.z, radius: Math.max(piece.halfExtents[0], piece.halfExtents[2]), newtons: piece.mass * GRAVITY_EARTH };
8151
+ };
8152
+ const updateLoads = (dt) => {
8153
+ loadClock += dt;
8154
+ if (loadClock < LOAD_EVERY) return;
8155
+ loadClock = 0;
8156
+ const loads = [];
8157
+ const [px, py, pz] = player.position;
8158
+ if (py - terrain.heightAt(px, pz) <= RESTING_GAP) loads.push({ x: px, z: pz, radius: PLAYER_FOOTPRINT, newtons: playerMass * GRAVITY_EARTH });
8159
+ for (const e of entries.values()) {
8160
+ const load = e.piece ? restingLoad(e.piece) : null;
8161
+ if (load) loads.push(load);
8162
+ }
8163
+ for (const p of loose) {
8164
+ const load = restingLoad(p);
8165
+ if (load) loads.push(load);
8166
+ }
8167
+ if (loads.length > 0 && terrain.bear(loads)) action("ground_gave_way", t("act.ground_gave_way"));
8168
+ };
7908
8169
  const prevVy = /* @__PURE__ */ new Map();
7909
8170
  const tmpBox = new THREE.Box3();
7910
8171
  const onKey = (code, down, e) => {
@@ -7944,15 +8205,13 @@ function createTestRoom(options) {
7944
8205
  case "KeyR":
7945
8206
  if (heldPiece) heldYaw += Math.PI / 8;
7946
8207
  else {
7947
- startReload();
8208
+ reloadHands();
7948
8209
  say(t("hud.reloading"));
7949
8210
  }
7950
8211
  break;
7951
- case "KeyQ": {
7952
- tool.cycleMode();
7953
- flushEvents(toolId, tool);
8212
+ case "KeyQ":
8213
+ cycleToolMode();
7954
8214
  break;
7955
- }
7956
8215
  case "KeyX":
7957
8216
  clearToolFault();
7958
8217
  break;
@@ -8009,6 +8268,20 @@ function createTestRoom(options) {
8009
8268
  if (!loadout.includes(weaponId)) loadout.push(weaponId);
8010
8269
  setTool(weaponId);
8011
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
+ };
8012
8285
  let hudAimHit;
8013
8286
  const inspectTarget = () => {
8014
8287
  if (hudAimHit !== void 0) return hudAimHit;
@@ -8203,6 +8476,7 @@ function createTestRoom(options) {
8203
8476
  vfx.update(dt);
8204
8477
  updateImpacts();
8205
8478
  updateWater(dt);
8479
+ updateLoads(dt);
8206
8480
  if (player.position[1] <= terrain.footAt(player.position[0], player.position[2]) + 0.15) footprints.update(player.position[0], player.position[2]);
8207
8481
  fluidView.update(dt);
8208
8482
  filmBridge.update(dt);
@@ -8211,6 +8485,11 @@ function createTestRoom(options) {
8211
8485
  s.update(dt);
8212
8486
  flushEvents(id, s);
8213
8487
  }
8488
+ if (offTool && offToolId) {
8489
+ offTool.update(dt);
8490
+ flushEvents(offToolId, offTool, void 0, "L");
8491
+ dualReload.step(dt);
8492
+ }
8214
8493
  if (audio) {
8215
8494
  const look = camera.getWorldDirection(listenerDirection);
8216
8495
  audio.setListener({ position: [camera.position.x, camera.position.y, camera.position.z], yaw: Math.atan2(-look.x, -look.z) });
@@ -8256,6 +8535,10 @@ function createTestRoom(options) {
8256
8535
  } else if (useHeld) useTool(dt, !useFirst);
8257
8536
  useFirst = false;
8258
8537
  }
8538
+ if ((offHeld || offFirst) && offTool && !isPaused && !push.active) {
8539
+ useTool(dt, !offFirst, "L");
8540
+ offFirst = false;
8541
+ }
8259
8542
  if (!isPaused) followHeld(dt);
8260
8543
  };
8261
8544
  const updatePresent = (dt) => {
@@ -8282,12 +8565,18 @@ function createTestRoom(options) {
8282
8565
  hudAimHit = void 0;
8283
8566
  }
8284
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
+ };
8285
8573
  const readHudState = () => {
8286
8574
  const page = Math.floor(toolIndex / 10);
8287
8575
  const slots = loadout.slice(page * 10, page * 10 + 10).map((id) => arsenal.name(id));
8288
- 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}` : ""}`;
8289
8578
  return {
8290
- toolName: toolName(),
8579
+ toolName: offToolId ? `${toolName()} + ${toolName(offToolId)}` : toolName(),
8291
8580
  toolId,
8292
8581
  ammo,
8293
8582
  slots,
@@ -8477,26 +8766,53 @@ function createTestRoom(options) {
8477
8766
  if (event.type === "explosion") knockPlayer(event);
8478
8767
  for (const listener of hostListeners) listener(event);
8479
8768
  }
8480
- function flushEvents(id, state, recoil) {
8769
+ function flushEvents(id, state, recoil, hand = "R") {
8481
8770
  const phase = state.phase;
8482
8771
  for (const event of state.drain()) {
8483
8772
  const hostEvent = toHostEvent(id, event, { ...recoil ? { recoil } : {}, phase });
8484
- if (hostEvent) emitHost(hostEvent);
8773
+ if (hostEvent) emitHost(hand === "L" ? { ...hostEvent, hand } : hostEvent);
8485
8774
  }
8486
8775
  }
8487
- function startReload() {
8488
- tool.reload();
8489
- 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;
8490
8782
  }
8783
+ function reloadHands() {
8784
+ if (!offTool) {
8785
+ startReload();
8786
+ return;
8787
+ }
8788
+ dualReload.start(["R", "L"]);
8789
+ }
8790
+ const commandHand = () => offTool ? firedHand : "R";
8491
8791
  function clearToolFault() {
8492
- if (!tool.clearFault()) return;
8493
- flushEvents(toolId, tool);
8494
- 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
+ }
8495
8800
  }
8496
8801
  function toggleSafety() {
8497
- tool.setSafety(!tool.safety);
8498
- flushEvents(toolId, tool);
8499
- 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}`);
8500
8816
  }
8501
8817
  function runToolCommand(request) {
8502
8818
  const work = (done) => done.ok ? { ok: true, seconds: done.seconds } : { ok: false, reason: done.reason };
@@ -8518,6 +8834,24 @@ function createTestRoom(options) {
8518
8834
  return { ok: false, reason: "unknown" };
8519
8835
  }
8520
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
+ });
8521
8855
  const host = {
8522
8856
  THREE,
8523
8857
  scene,
@@ -8544,22 +8878,9 @@ function createTestRoom(options) {
8544
8878
  ...options.blastShove ? { blastShove: options.blastShove } : {},
8545
8879
  blastReach: (point, target, ignore) => world.blastReach(point, target, ignore ? { ignore } : {}),
8546
8880
  tool: () => ({
8547
- weapon: toolId,
8548
- category: tool.def.category,
8549
- ammo: tool.ammo,
8550
- capacity: tool.capacity,
8551
- reserve: tool.reserve,
8552
- reloading: tool.reloading,
8553
- mode: tool.mode,
8554
- ...tool.fault ? { fault: tool.fault } : {},
8555
- safety: tool.safety,
8556
- heat: tool.heatLevel,
8557
- attachments: tool.attachments.map((part) => ({ id: part.id, mount: part.mount, ...part.integral ? { integral: true } : {}, ...part.on === void 0 ? {} : { on: part.on } })),
8558
- stock: tool.stock,
8559
- suppressed: tool.suppressed,
8560
- underbarrelActive: tool.underbarrelActive,
8561
- mass: tool.mass,
8562
- 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 } : {} } } : {}
8563
8884
  }),
8564
8885
  command(request) {
8565
8886
  const result = runToolCommand(request);
@@ -8596,7 +8917,7 @@ function createTestRoom(options) {
8596
8917
  onFrame(update, frameOptions) {
8597
8918
  return scheduler.add((ctx) => update(ctx.dt), { phase: frameOptions?.phase ?? "lateSimulate", order: frameOptions?.order ?? 0, id: `module.frame#${moduleFrameCount++}` });
8598
8919
  },
8599
- holdFrame: () => bodyHoldFrame(),
8920
+ holdFrame: (hand) => bodyHoldFrame(hand),
8600
8921
  reachFor(reach) {
8601
8922
  if (!avatar) {
8602
8923
  reach.onGrip?.();
@@ -8972,6 +9293,7 @@ function createTestRoom(options) {
8972
9293
  fluids,
8973
9294
  film: filmBridge,
8974
9295
  vfx: vfx.world,
9296
+ effects: vfx,
8975
9297
  fluidRenderer: fluidView,
8976
9298
  audio,
8977
9299
  environment,
@@ -8987,6 +9309,29 @@ function createTestRoom(options) {
8987
9309
  clear,
8988
9310
  resetAll,
8989
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(),
8990
9335
  useItem,
8991
9336
  use(hold = 0) {
8992
9337
  if (tool.def.category === "hand") {
@@ -9284,7 +9629,8 @@ function createTestRoom(options) {
9284
9629
  ...options.toolMenu !== false ? {
9285
9630
  toolMenu: {
9286
9631
  groups: toolMenuGroups,
9287
- onPick: equip,
9632
+ onPick: pickTool,
9633
+ currentLeft: () => offToolId,
9288
9634
  // the list takes clicks: the mouse leaves the view while it is open
9289
9635
  onOpen: (open) => {
9290
9636
  if (open && document.pointerLockElement) document.exitPointerLock?.();