@adatechnology/game-test-room 0.3.0 → 0.5.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
@@ -249,6 +249,15 @@ var en = {
249
249
  "act.heat.air": "{weapon}: nothing in range",
250
250
  "act.throw.grenade": "{weapon}: thrown, fuse {fuse} s",
251
251
  "act.throw.molotov": "{weapon}: thrown, breaks on impact",
252
+ "act.launch": "{weapon}: launched, arms after {arm} m",
253
+ "act.place": "{weapon}: charge set down, waits for {triggers}",
254
+ "act.tripwire": "{weapon}: {who} crossed the trip wire",
255
+ "act.dud": "{weapon}: hit before arming ({distance} m), did not explode",
256
+ "act.backblast": "{weapon}: blast behind the tube, {radius} m, {n} objects in the cone, {player} on the shooter",
257
+ "act.blast_player": "{weapon}: you are in the {zone} zone, {distance} m away (stun {stun} s)",
258
+ "hud.no_charges": "No charge set down waits for the remote detonator",
259
+ "blast.zone.lethal": "lethal",
260
+ "blast.zone.injury": "injury",
252
261
  "act.explode": "{weapon}: exploded, {energy} J within {radius} m, {n} objects in range",
253
262
  "act.molotov_fire": "{weapon}: fire on the ground, radius {radius} m for {seconds} s",
254
263
  "act.tool.none": "{weapon}: nothing in reach",
@@ -274,6 +283,15 @@ var en = {
274
283
  "hud.reloading": "Reloading...",
275
284
  "hud.empty": "Empty",
276
285
  "hud.no_fuel": "Out of fuel",
286
+ "hud.fault": "Jammed ({fault}): press X to clear",
287
+ "hud.safe": "Safety on: press B",
288
+ "hud.clearing": "Clearing the jam...",
289
+ "hud.safety_on": "Safety on",
290
+ "hud.safety_off": "Safety off",
291
+ "fault.misfire": "misfire",
292
+ "fault.stovepipe": "stovepipe",
293
+ "fault.double_feed": "double feed",
294
+ "fault.failure_to_extract": "failure to extract",
277
295
  "hud.nothing_to_grab": "Nothing to grab here",
278
296
  "hud.cannot_grab": "{name} cannot be picked up",
279
297
  "hud.too_heavy": "{name} is too heavy ({kg} kg)",
@@ -284,7 +302,7 @@ var en = {
284
302
  "hud.throw": "throw",
285
303
  "hud.tilt": "tilt (hold)",
286
304
  "hud.equip": "equip {weapon}",
287
- "ui.help": "Click: look \xB7 WASD: walk \xB7 Shift: run \xB7 Space: jump \xB7 1-0 / wheel: tool \xB7 LMB: use \xB7 E: grab/drop \xB7 G or RMB: throw \xB7 Tab: panels \xB7 Esc: free the mouse",
305
+ "ui.help": "Click: look \xB7 WASD: walk \xB7 Shift: run \xB7 Space: jump \xB7 1-0 / wheel: tool \xB7 LMB: use \xB7 E: grab/drop \xB7 G or RMB: throw \xB7 R: reload \xB7 Q: fire mode \xB7 X: clear a jam \xB7 B: safety \xB7 Y: detonate charges set down \xB7 Tab: panels \xB7 Esc: free the mouse",
288
306
  "ui.log": "Behaviour log",
289
307
  "ui.filter.action": "actions",
290
308
  "ui.filter.event": "events",
@@ -556,6 +574,15 @@ var ptBR = {
556
574
  "act.heat.air": "{weapon}: nada ao alcance",
557
575
  "act.throw.grenade": "{weapon}: arremessada, pavio de {fuse} s",
558
576
  "act.throw.molotov": "{weapon}: arremessado, quebra no impacto",
577
+ "act.launch": "{weapon}: disparado, arma depois de {arm} m",
578
+ "act.place": "{weapon}: carga posta, espera {triggers}",
579
+ "act.tripwire": "{weapon}: {who} cruzou o fio",
580
+ "act.dud": "{weapon}: bateu antes de armar ({distance} m), n\xE3o explodiu",
581
+ "act.backblast": "{weapon}: sopro atr\xE1s do tubo, {radius} m, {n} objetos no cone, {player} no atirador",
582
+ "act.blast_player": "{weapon}: voc\xEA est\xE1 na zona {zone}, a {distance} m (atordoado {stun} s)",
583
+ "hud.no_charges": "Nenhuma carga posta espera o detonador",
584
+ "blast.zone.lethal": "letal",
585
+ "blast.zone.injury": "de ferimento",
559
586
  "act.explode": "{weapon}: explodiu, {energy} J em {radius} m, {n} objetos ao alcance",
560
587
  "act.molotov_fire": "{weapon}: fogo no ch\xE3o, raio de {radius} m por {seconds} s",
561
588
  "act.tool.none": "{weapon}: nada ao alcance",
@@ -581,6 +608,15 @@ var ptBR = {
581
608
  "hud.reloading": "Recarregando...",
582
609
  "hud.empty": "Sem muni\xE7\xE3o",
583
610
  "hud.no_fuel": "Sem combust\xEDvel",
611
+ "hud.fault": "Travou ({fault}): aperte X para destravar",
612
+ "hud.safe": "Trava ligada: aperte B",
613
+ "hud.clearing": "Destravando...",
614
+ "hud.safety_on": "Trava ligada",
615
+ "hud.safety_off": "Trava desligada",
616
+ "fault.misfire": "nega",
617
+ "fault.stovepipe": "chamin\xE9",
618
+ "fault.double_feed": "dupla alimenta\xE7\xE3o",
619
+ "fault.failure_to_extract": "falha de extra\xE7\xE3o",
584
620
  "hud.nothing_to_grab": "Nada para pegar aqui",
585
621
  "hud.cannot_grab": "{name} n\xE3o d\xE1 para pegar",
586
622
  "hud.too_heavy": "{name} \xE9 pesado demais ({kg} kg)",
@@ -591,7 +627,7 @@ var ptBR = {
591
627
  "hud.throw": "arremessar",
592
628
  "hud.tilt": "inclinar (segurar)",
593
629
  "hud.equip": "equipar {weapon}",
594
- "ui.help": "Clique: olhar \xB7 WASD: andar \xB7 Shift: correr \xB7 Espa\xE7o: pular \xB7 1-0 / roda: ferramenta \xB7 bot\xE3o esquerdo: usar \xB7 E: pegar/soltar \xB7 G ou bot\xE3o direito: arremessar \xB7 Tab: pain\xE9is \xB7 Esc: soltar o mouse",
630
+ "ui.help": "Clique: olhar \xB7 WASD: andar \xB7 Shift: correr \xB7 Espa\xE7o: pular \xB7 1-0 / roda: ferramenta \xB7 bot\xE3o esquerdo: usar \xB7 E: pegar/soltar \xB7 G ou bot\xE3o direito: arremessar \xB7 R: recarregar \xB7 Q: modo de tiro \xB7 X: destravar \xB7 B: trava \xB7 Y: detonar cargas postas \xB7 Tab: pain\xE9is \xB7 Esc: soltar o mouse",
595
631
  "ui.log": "Registro de comportamento",
596
632
  "ui.filter.action": "a\xE7\xF5es",
597
633
  "ui.filter.event": "eventos",
@@ -1300,6 +1336,354 @@ function createBenchStore(module, locale, onListenerError) {
1300
1336
  return store;
1301
1337
  }
1302
1338
 
1339
+ // src/explosives.ts
1340
+ import { backblastEffect, blastEffect, createExplosiveBody, tripwireSegment } from "@adatechnology/game-weapons";
1341
+
1342
+ // src/segment-box.ts
1343
+ var segmentTouchesBox = (from, to, min, max) => {
1344
+ let near = 0;
1345
+ let far = 1;
1346
+ for (let axis = 0; axis < 3; axis++) {
1347
+ const start = from[axis];
1348
+ const delta = to[axis] - start;
1349
+ const low = min[axis];
1350
+ const high = max[axis];
1351
+ if (Math.abs(delta) < 1e-12) {
1352
+ if (start < low || start > high) return false;
1353
+ continue;
1354
+ }
1355
+ const entering = (low - start) / delta;
1356
+ const leaving = (high - start) / delta;
1357
+ near = Math.max(near, Math.min(entering, leaving));
1358
+ far = Math.min(far, Math.max(entering, leaving));
1359
+ if (near > far) return false;
1360
+ }
1361
+ return true;
1362
+ };
1363
+
1364
+ // src/explosives.ts
1365
+ var CONE_BLAST_RADIUS = 0.5;
1366
+ var toArray = (vector) => [vector.x, vector.y, vector.z];
1367
+ function createExplosives(context) {
1368
+ const { THREE, scene, camera, world, renderer, arsenal, translate, format, action } = context;
1369
+ const projectiles = [];
1370
+ const meshes = [];
1371
+ const up = new THREE.Vector3(0, 1, 0);
1372
+ const clear = () => {
1373
+ projectiles.length = 0;
1374
+ for (const mesh of meshes) mesh.removeFromParent();
1375
+ meshes.length = 0;
1376
+ };
1377
+ const addMesh = (geometry, color, metalness, at) => {
1378
+ const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({ color, roughness: 0.6, metalness }));
1379
+ mesh.castShadow = true;
1380
+ mesh.position.set(at[0], at[1], at[2]);
1381
+ scene.add(mesh);
1382
+ meshes.push(mesh);
1383
+ return mesh;
1384
+ };
1385
+ const remove = (projectile) => {
1386
+ for (const extra of projectile.attached ?? []) {
1387
+ extra.removeFromParent();
1388
+ const extraAt = meshes.indexOf(extra);
1389
+ if (extraAt >= 0) meshes.splice(extraAt, 1);
1390
+ }
1391
+ projectile.mesh.removeFromParent();
1392
+ const meshAt = meshes.indexOf(projectile.mesh);
1393
+ if (meshAt >= 0) meshes.splice(meshAt, 1);
1394
+ const at = projectiles.indexOf(projectile);
1395
+ if (at >= 0) projectiles.splice(at, 1);
1396
+ };
1397
+ const throwUse = (use) => {
1398
+ const isMolotov = !!use.fire;
1399
+ const isRocket = use.trigger === "impact" && (use.armDistance ?? 0) > 0;
1400
+ const geometry = isRocket ? new THREE.CylinderGeometry(0.04, 0.04, 0.5, 8) : isMolotov ? new THREE.CylinderGeometry(0.035, 0.04, 0.2, 8) : new THREE.SphereGeometry(0.045, 8, 6);
1401
+ const mesh = addMesh(geometry, isMolotov ? 3897915 : isRocket ? 7041116 : 4936480, isMolotov ? 0 : 0.4, use.origin);
1402
+ projectiles.push({
1403
+ mesh,
1404
+ pos: new THREE.Vector3(use.origin[0], use.origin[1], use.origin[2]),
1405
+ vel: new THREE.Vector3(use.velocity[0], use.velocity[1], use.velocity[2]),
1406
+ weapon: context.toolId(),
1407
+ fuse: use.fuse,
1408
+ burstOnImpact: isMolotov && use.fuse <= 0,
1409
+ energy: use.energy,
1410
+ radius: use.radius,
1411
+ fire: use.fire,
1412
+ age: 0,
1413
+ ...use.explosive !== void 0 ? { body: createExplosiveBody(use, arsenal.explosives, arsenal.rules.explosives), aligned: isRocket } : {}
1414
+ });
1415
+ const key = isRocket ? "act.launch" : isMolotov ? "act.throw.molotov" : "act.throw.grenade";
1416
+ action(isRocket ? "launch_projectile" : "throw_projectile", translate(key, { weapon: context.toolName(), fuse: format(use.fuse, 1), arm: format(use.armDistance ?? 0, 0) }), {
1417
+ data: { weapon: context.toolId(), fuse: use.fuse, explosive: use.explosive }
1418
+ });
1419
+ };
1420
+ const placementPoint = (use) => {
1421
+ const hit = context.withoutHeld(() => world.raycast(use.origin, use.direction, 3));
1422
+ if (hit) return [hit.point[0] + hit.normal[0] * 0.03, hit.point[1] + hit.normal[1] * 0.03, hit.point[2] + hit.normal[2] * 0.03];
1423
+ const x = use.origin[0] + use.direction[0] * 2;
1424
+ const z = use.origin[2] + use.direction[2] * 2;
1425
+ return [x, context.groundAt(x, z) + 0.03, z];
1426
+ };
1427
+ const markerAt = (at) => {
1428
+ const marker = new THREE.Group();
1429
+ marker.name = "charge_marker";
1430
+ const stake = new THREE.Mesh(new THREE.CylinderGeometry(0.01, 0.01, 0.4, 6), new THREE.MeshStandardMaterial({ color: 9136957, roughness: 0.8 }));
1431
+ stake.position.y = 0.2;
1432
+ const flag = new THREE.Mesh(new THREE.ConeGeometry(0.06, 0.12, 4), new THREE.MeshStandardMaterial({ color: 16738816, emissive: 16724736, emissiveIntensity: 0.4 }));
1433
+ flag.position.y = 0.46;
1434
+ marker.add(stake, flag);
1435
+ marker.position.set(at[0] + 0.12, at[1] - 0.03, at[2]);
1436
+ scene.add(marker);
1437
+ meshes.push(marker);
1438
+ return marker;
1439
+ };
1440
+ const wireLine = (from, to) => {
1441
+ const geometry = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(...from), new THREE.Vector3(...to)]);
1442
+ const line = new THREE.Line(geometry, new THREE.LineBasicMaterial({ color: 15262920 }));
1443
+ line.name = "charge_wire";
1444
+ scene.add(line);
1445
+ meshes.push(line);
1446
+ return line;
1447
+ };
1448
+ const touching = (wire) => context.bodies().filter((body) => segmentTouchesBox(wire.from, wire.to, toArray(body.box.min), toArray(body.box.max))).map((body) => body.id);
1449
+ const placeUse = (use) => {
1450
+ const at = placementPoint(use);
1451
+ const placed = { ...use, origin: at };
1452
+ const body = createExplosiveBody(placed, arsenal.explosives, arsenal.rules.explosives);
1453
+ if (!body) return;
1454
+ const mesh = addMesh(new THREE.BoxGeometry(0.18, 0.06, 0.1), use.explosive === "claymore" ? 5597999 : 12757112, 0.1, at);
1455
+ mesh.rotation.y = Math.atan2(use.direction[0], use.direction[2]);
1456
+ const attached = [markerAt(at)];
1457
+ const segment = tripwireSegment(placed);
1458
+ const projectile = { mesh, pos: new THREE.Vector3(at[0], at[1], at[2]), vel: new THREE.Vector3(), weapon: context.toolId(), fuse: 0, burstOnImpact: false, energy: use.energy, radius: use.radius, age: 0, body, placed: true, attached };
1459
+ if (segment) {
1460
+ const wire = { from: segment.from, to: segment.to, letThrough: /* @__PURE__ */ new Set() };
1461
+ for (const id of touching(wire)) wire.letThrough.add(id);
1462
+ attached.push(wireLine(segment.from, segment.to));
1463
+ projectile.wire = wire;
1464
+ }
1465
+ projectiles.push(projectile);
1466
+ action("place_charge", translate("act.place", { weapon: context.toolName(), triggers: use.triggers.join("/") || "-" }), { data: { weapon: context.toolId(), explosive: use.explosive, triggers: [...use.triggers], point: at, ...segment ? { wire: segment } : {} } });
1467
+ };
1468
+ const backblastUse = (use) => {
1469
+ renderer.emit("smoke", use.origin, use.direction, 14, use.radius);
1470
+ let reached = 0;
1471
+ for (const object of context.objects()) if (backblastEffect(use, toArray(object.position)) > 0) reached++;
1472
+ const onPlayer = backblastEffect(use, toArray(camera.position));
1473
+ action("backblast", translate("act.backblast", { weapon: context.toolName(), radius: format(use.radius, 0), n: reached, player: format(onPlayer, 2) }), {
1474
+ data: { weapon: context.toolId(), radius: use.radius, halfAngleDeg: use.halfAngleDeg, objects: reached, onPlayer }
1475
+ });
1476
+ };
1477
+ const surfaceProbe = (from, to) => {
1478
+ const dx = to[0] - from[0];
1479
+ const dy = to[1] - from[1];
1480
+ const dz = to[2] - from[2];
1481
+ const length = Math.hypot(dx, dy, dz);
1482
+ if (length < 1e-6) return null;
1483
+ const direction = [dx / length, dy / length, dz / length];
1484
+ const ray = context.withoutHeld(() => world.raycast(from, direction, length + 0.03));
1485
+ let best = ray && ray.distance <= length + 0.03 ? { point: ray.point, normal: ray.normal, ground: ray.normal[1] > 0.7, at: ray.distance } : null;
1486
+ const floor = context.groundAt(to[0], to[2]) + 0.045;
1487
+ if (to[1] < floor) {
1488
+ const point = [to[0], floor, to[2]];
1489
+ const at = Math.hypot(point[0] - from[0], point[1] - from[1], point[2] - from[2]);
1490
+ if (!best || at < best.at) best = { point, normal: [0, 1, 0], ground: true, at };
1491
+ }
1492
+ return best ? { point: best.point, normal: best.normal, ground: best.ground } : null;
1493
+ };
1494
+ const mostExposedPoint = (box2, detonation) => {
1495
+ const { min, max } = box2;
1496
+ const candidates = [toArray(box2.getCenter(new THREE.Vector3())), toArray(box2.clampPoint(new THREE.Vector3(...detonation.point), new THREE.Vector3()))];
1497
+ for (const x of [min.x, max.x]) for (const y of [min.y, max.y]) for (const z of [min.z, max.z]) candidates.push([x, y, z]);
1498
+ let best;
1499
+ for (const point of candidates) {
1500
+ const effect = blastEffect(detonation, point);
1501
+ if (effect.zone !== "none" && (!best || effect.falloff > best.falloff)) best = { point, falloff: effect.falloff };
1502
+ }
1503
+ return best?.point;
1504
+ };
1505
+ const coneBlast = (detonation) => {
1506
+ let reached = 0;
1507
+ for (const object of context.objects()) {
1508
+ const box2 = new THREE.Box3().setFromObject(object);
1509
+ if (box2.isEmpty()) box2.expandByPoint(object.position);
1510
+ const at = mostExposedPoint(box2, detonation);
1511
+ if (!at) continue;
1512
+ reached += world.explode(at, detonation.energy * blastEffect(detonation, at).falloff, CONE_BLAST_RADIUS, "player").length;
1513
+ }
1514
+ return reached;
1515
+ };
1516
+ const detonate = (projectile, detonation) => {
1517
+ const weaponName = context.toolName(projectile.weapon);
1518
+ const reach = Math.max(detonation.injuryRadius, detonation.lethalRadius);
1519
+ const reached = detonation.energy <= 0 ? 0 : detonation.cone ? coneBlast(detonation) : world.explode(detonation.point, detonation.energy, reach, "player").length;
1520
+ if (detonation.energy > 0) renderer.explode(detonation.point, detonation.energy, reach);
1521
+ if (detonation.stunSeconds !== void 0) renderer.emit("flash", detonation.point, [0, 1, 0], 1, 1);
1522
+ const onPlayer = blastEffect(detonation, toArray(camera.position));
1523
+ action("explode", translate("act.explode", { weapon: weaponName, energy: format(detonation.energy, 0), radius: format(reach, 1), n: reached }), {
1524
+ data: { weapon: projectile.weapon, explosive: detonation.explosive, cause: detonation.cause, energyJ: detonation.energy, radius: reach, point: detonation.point, player: onPlayer.zone, stunSeconds: onPlayer.stunSeconds }
1525
+ });
1526
+ if (onPlayer.zone !== "none") {
1527
+ action("blast_player", translate("act.blast_player", { weapon: weaponName, zone: translate(`blast.zone.${onPlayer.zone}`), distance: format(onPlayer.distance, 1), stun: format(onPlayer.stunSeconds, 1) }), { data: { ...onPlayer } });
1528
+ }
1529
+ context.emitHost({ type: "explosion", source: projectile.weapon, point: detonation.point, energy: detonation.energy, radius: reach, cause: detonation.cause });
1530
+ if (detonation.fire) context.startFire(projectile.weapon, detonation.point, detonation.fire, detonation.cause === "impact");
1531
+ };
1532
+ const detonateBare = (projectile, isImpact) => {
1533
+ detonate(projectile, {
1534
+ explosive: projectile.weapon,
1535
+ cause: isImpact ? "impact" : "timer",
1536
+ point: toArray(projectile.pos),
1537
+ direction: [0, 1, 0],
1538
+ at: projectile.age,
1539
+ energy: projectile.energy,
1540
+ lethalRadius: 0,
1541
+ injuryRadius: projectile.radius,
1542
+ noise: 0,
1543
+ destructionLevel: 0,
1544
+ ...projectile.fire ? { fire: projectile.fire } : {}
1545
+ });
1546
+ };
1547
+ const detonateCharges = () => {
1548
+ let count = 0;
1549
+ for (const projectile of [...projectiles]) {
1550
+ if (!projectile.placed || !projectile.body) continue;
1551
+ const event = projectile.body.trigger("remote");
1552
+ if (event?.type !== "detonate") continue;
1553
+ detonate(projectile, event.detonation);
1554
+ remove(projectile);
1555
+ count++;
1556
+ }
1557
+ if (count === 0) context.say(translate("hud.no_charges"));
1558
+ return count;
1559
+ };
1560
+ const stepBody = (projectile, body, dt) => {
1561
+ const events = body.step(dt, surfaceProbe);
1562
+ projectile.pos.set(body.position[0], body.position[1], body.position[2]);
1563
+ projectile.mesh.position.copy(projectile.pos);
1564
+ if (projectile.aligned && body.velocity[0] ** 2 + body.velocity[1] ** 2 + body.velocity[2] ** 2 > 1e-6) {
1565
+ projectile.mesh.quaternion.setFromUnitVectors(up, new THREE.Vector3(body.velocity[0], body.velocity[1], body.velocity[2]).normalize());
1566
+ } else if (!projectile.placed) projectile.mesh.rotation.x += dt * 8;
1567
+ for (const event of events) {
1568
+ if (event.type === "dud") action("dud", translate("act.dud", { weapon: context.toolName(projectile.weapon), distance: format(body.travelled, 1) }), { data: { weapon: projectile.weapon, point: event.point } });
1569
+ else if (event.type === "detonate") detonate(projectile, event.detonation);
1570
+ }
1571
+ };
1572
+ const flyBare = (projectile, dt) => {
1573
+ const { gravity } = arsenal.rules.explosives;
1574
+ let isBurst = false;
1575
+ projectile.vel.y -= gravity * dt;
1576
+ const speed = projectile.vel.length();
1577
+ const step = speed * dt;
1578
+ if (step > 1e-6) {
1579
+ const direction = [projectile.vel.x / speed, projectile.vel.y / speed, projectile.vel.z / speed];
1580
+ const hit = context.withoutHeld(() => world.raycast(toArray(projectile.pos), direction, step + 0.05));
1581
+ if (hit && hit.distance <= step + 0.04) {
1582
+ projectile.pos.set(hit.point[0] - direction[0] * 0.03, hit.point[1] - direction[1] * 0.03, hit.point[2] - direction[2] * 0.03);
1583
+ if (projectile.burstOnImpact) isBurst = true;
1584
+ else {
1585
+ const normal = hit.normal;
1586
+ const normalSpeed = projectile.vel.x * normal[0] + projectile.vel.y * normal[1] + projectile.vel.z * normal[2];
1587
+ projectile.vel.x = (projectile.vel.x - 2 * normalSpeed * normal[0]) * 0.35;
1588
+ projectile.vel.y = (projectile.vel.y - 2 * normalSpeed * normal[1]) * 0.35;
1589
+ projectile.vel.z = (projectile.vel.z - 2 * normalSpeed * normal[2]) * 0.35;
1590
+ }
1591
+ } else projectile.pos.addScaledVector(projectile.vel, dt);
1592
+ }
1593
+ const ground = context.groundAt(projectile.pos.x, projectile.pos.z) + 0.045;
1594
+ if (projectile.pos.y < ground) {
1595
+ projectile.pos.y = ground;
1596
+ if (projectile.burstOnImpact) isBurst = true;
1597
+ else {
1598
+ projectile.vel.y = Math.abs(projectile.vel.y) * 0.35;
1599
+ projectile.vel.x *= 0.7;
1600
+ projectile.vel.z *= 0.7;
1601
+ }
1602
+ }
1603
+ return isBurst;
1604
+ };
1605
+ const checkWire = (projectile, body) => {
1606
+ const wire = projectile.wire;
1607
+ if (!wire) return;
1608
+ const crossing = touching(wire);
1609
+ for (const id of [...wire.letThrough]) if (!crossing.includes(id)) wire.letThrough.delete(id);
1610
+ const crosser = crossing.find((id) => !wire.letThrough.has(id));
1611
+ if (crosser === void 0) return;
1612
+ const event = body.trigger("wire");
1613
+ if (event?.type !== "detonate") return;
1614
+ action("tripwire", translate("act.tripwire", { weapon: context.toolName(projectile.weapon), who: crosser }), { data: { weapon: projectile.weapon, body: crosser } });
1615
+ detonate(projectile, event.detonation);
1616
+ };
1617
+ const update = (dt) => {
1618
+ for (let index = projectiles.length - 1; index >= 0; index--) {
1619
+ const projectile = projectiles[index];
1620
+ if (!projectile) continue;
1621
+ projectile.age += dt;
1622
+ if (projectile.body) {
1623
+ if (projectile.wire && !projectile.body.done) checkWire(projectile, projectile.body);
1624
+ stepBody(projectile, projectile.body, dt);
1625
+ if (projectile.body.done || !projectile.placed && projectile.age > 60) remove(projectile);
1626
+ continue;
1627
+ }
1628
+ const isBurst = flyBare(projectile, dt);
1629
+ projectile.mesh.position.copy(projectile.pos);
1630
+ projectile.mesh.rotation.x += dt * 8;
1631
+ projectile.fuse -= dt;
1632
+ if (isBurst || !projectile.burstOnImpact && projectile.fuse <= 0 || projectile.age > 30) {
1633
+ detonateBare(projectile, isBurst);
1634
+ remove(projectile);
1635
+ }
1636
+ }
1637
+ };
1638
+ return { throwUse, placeUse, backblastUse, detonateCharges, update, clear };
1639
+ }
1640
+
1641
+ // src/host-events.ts
1642
+ var NO_RECOIL = [0, 0];
1643
+ var durationOf = (phase, id) => phase?.id === id ? { duration: Math.round((phase.end - phase.start) * 1e6) / 1e6 } : {};
1644
+ var toHostEvent = (weapon, event, options = {}) => {
1645
+ const { recoil = NO_RECOIL, phase } = options;
1646
+ switch (event.type) {
1647
+ case "draw":
1648
+ return event.phase === "start" ? { type: "tool", weapon, ...durationOf(phase, "draw") } : null;
1649
+ // a launcher spends a round and throws one projectile: the weapon counts none because it is a throw, a host counts the one that leaves
1650
+ case "shot":
1651
+ return { type: "shot", weapon, shots: Math.max(1, event.shots), recoil, rounds: event.rounds, mode: event.mode, ...event.suppressed ? { suppressed: true } : {} };
1652
+ case "misfire":
1653
+ return { type: "misfire", weapon };
1654
+ case "reload":
1655
+ return { type: "reload", weapon, phase: event.phase, ...event.phase === "start" ? { kind: event.kind, ...durationOf(phase, "reload") } : {} };
1656
+ case "reload_stage":
1657
+ return { type: "stage", weapon, stage: event.stage };
1658
+ case "feed":
1659
+ return { type: "feed", weapon };
1660
+ case "cycle":
1661
+ return { type: "cycle", weapon, phase: event.phase, ...event.phase === "start" ? durationOf(phase, "cycle") : {} };
1662
+ case "empty":
1663
+ return { type: "empty", weapon };
1664
+ case "mode":
1665
+ return { type: "mode", weapon, mode: event.mode };
1666
+ case "fault":
1667
+ return { type: "fault", weapon, fault: event.fault };
1668
+ case "clear":
1669
+ return { type: "clear", weapon, fault: event.fault, phase: event.phase, ...event.phase === "start" ? durationOf(phase, "clear") : {} };
1670
+ case "safety":
1671
+ return { type: "safety", weapon, on: event.on };
1672
+ case "cook":
1673
+ return { type: "cook", weapon, phase: event.phase, fuse: event.fuse };
1674
+ case "attachment":
1675
+ return { type: "attachment", weapon, action: event.action, attachment: event.id, mount: event.mount, ...event.action === "break" ? {} : durationOf(phase, "fit") };
1676
+ case "toggle":
1677
+ return { type: "toggle", weapon, attachment: event.id, on: event.on };
1678
+ case "stock":
1679
+ return { type: "stock", weapon, state: event.state, kind: event.kind, ...durationOf(phase, "stock") };
1680
+ case "underbarrel":
1681
+ return { type: "underbarrel", weapon, on: event.on, ...durationOf(phase, "switch") };
1682
+ default:
1683
+ return null;
1684
+ }
1685
+ };
1686
+
1303
1687
  // src/log.ts
1304
1688
  var MAX_ENTRIES = 5e3;
1305
1689
  var pad = (n, w = 2) => String(n).padStart(w, "0");
@@ -1991,7 +2375,7 @@ function createNarrator(c) {
1991
2375
 
1992
2376
  // src/player.ts
1993
2377
  var EYE = 1.7;
1994
- var RADIUS = 0.3;
2378
+ var PLAYER_RADIUS = 0.3;
1995
2379
  var WALK = 3.4;
1996
2380
  var RUN = 6.2;
1997
2381
  var JUMP = 4.6;
@@ -2016,8 +2400,8 @@ function createPlayer(THREE, o) {
2016
2400
  };
2017
2401
  apply();
2018
2402
  const blocked = (x, z) => {
2019
- if (Math.abs(x) > o.limit - RADIUS || Math.abs(z) > o.limit - RADIUS) return true;
2020
- for (const c of o.colliders) if (x > c[0] - RADIUS && x < c[2] + RADIUS && z > c[1] - RADIUS && z < c[3] + RADIUS) return true;
2403
+ if (Math.abs(x) > o.limit - PLAYER_RADIUS || Math.abs(z) > o.limit - PLAYER_RADIUS) return true;
2404
+ for (const c of o.colliders) if (x > c[0] - PLAYER_RADIUS && x < c[2] + PLAYER_RADIUS && z > c[1] - PLAYER_RADIUS && z < c[3] + PLAYER_RADIUS) return true;
2021
2405
  return false;
2022
2406
  };
2023
2407
  const move = (dx, dz) => {
@@ -2239,6 +2623,7 @@ function makeScenarios(t, num, _locale = "en") {
2239
2623
  sc("steel_through_hole", ({ room, step, expect }) => {
2240
2624
  const id = at(room, "steel_sheet", 0, -4);
2241
2625
  room.setTool("pistol_9mm");
2626
+ room.waitUntilReady();
2242
2627
  look(room, [0, 1.1, -1.5], [0, 1.1, -4]);
2243
2628
  room.use();
2244
2629
  step(0.2);
@@ -2253,6 +2638,7 @@ function makeScenarios(t, num, _locale = "en") {
2253
2638
  sc("concrete_stops_bullet", ({ room, step, expect }) => {
2254
2639
  const id = at(room, "concrete_block", 0, -4);
2255
2640
  room.setTool("pistol_9mm");
2641
+ room.waitUntilReady();
2256
2642
  look(room, [0, 0.5, -1.5], [0, 0.2, -3.8]);
2257
2643
  room.use();
2258
2644
  step(0.2);
@@ -2268,6 +2654,7 @@ function makeScenarios(t, num, _locale = "en") {
2268
2654
  sc("hammer_dents_drum", ({ room, step, expect }) => {
2269
2655
  const id = at(room, "steel_drum", 0, -4);
2270
2656
  room.setTool("hammer");
2657
+ room.waitUntilReady();
2271
2658
  look(room, [0, 0.6, -3.35], [0, 0.5, -4]);
2272
2659
  let swings = 0;
2273
2660
  for (; swings < 8 && (part(room, id, "drum_body")?.dents.length ?? 0) === 0; swings++) {
@@ -2284,6 +2671,7 @@ function makeScenarios(t, num, _locale = "en") {
2284
2671
  sc("glass_shatters", ({ room, step, expect }) => {
2285
2672
  const id = at(room, "glass_pane", 0, -4);
2286
2673
  room.setTool("pistol_9mm");
2674
+ room.waitUntilReady();
2287
2675
  look(room, [0, 1.3, -1.5], [0, 1.05, -4]);
2288
2676
  let shots = 0;
2289
2677
  for (; shots < 10 && part(room, id, "pane")?.status !== "broken"; shots++) {
@@ -2298,6 +2686,7 @@ function makeScenarios(t, num, _locale = "en") {
2298
2686
  sc("shotgun_splinters_crate", ({ room, step, expect }) => {
2299
2687
  const id = at(room, "wooden_crate", 0, -4);
2300
2688
  room.setTool("shotgun_pump_12");
2689
+ room.waitUntilReady();
2301
2690
  look(room, [0, 0.6, -1.8], [0, 0.25, -3.77]);
2302
2691
  let shots = 0;
2303
2692
  const splinters = () => room.log.entries({ type: "fracture", objectId: id }).filter((e) => e.data?.style === "splinter").length;
@@ -2331,6 +2720,7 @@ function makeScenarios(t, num, _locale = "en") {
2331
2720
  const id = at(room, "chair", 0, -4);
2332
2721
  const before = room.renderer.pieces().length;
2333
2722
  room.setTool("kick");
2723
+ room.waitUntilReady();
2334
2724
  let kicks = 0;
2335
2725
  for (; kicks < 8 && room.renderer.pieces().length <= before && part(room, id, "leg_1"); kicks++) {
2336
2726
  const st = room.world.state(id);
@@ -2347,6 +2737,7 @@ function makeScenarios(t, num, _locale = "en") {
2347
2737
  sc("screwdriver_shelf_board", ({ room, step, expect }) => {
2348
2738
  const id = at(room, "shelf", 0, -4);
2349
2739
  room.setTool("screwdriver");
2740
+ room.waitUntilReady();
2350
2741
  look(room, [0, 0.8, -3.5], [0, 0.75, -3.86]);
2351
2742
  const t0 = room.log.entries().length ? room.log.entries().slice(-1)[0].t : 0;
2352
2743
  room.use(5);
@@ -2360,6 +2751,7 @@ function makeScenarios(t, num, _locale = "en") {
2360
2751
  sc("wrong_tool_refused", ({ room, expect }) => {
2361
2752
  const id = at(room, "steel_sheet", 0, -4);
2362
2753
  room.setTool("screwdriver");
2754
+ room.waitUntilReady();
2363
2755
  look(room, [0.2, 1.05, -3.6], [0.34, 1, -4]);
2364
2756
  room.use(0.5);
2365
2757
  const refused = count(room, "tool_refused");
@@ -2369,6 +2761,7 @@ function makeScenarios(t, num, _locale = "en") {
2369
2761
  sc("lighter_burns_crate", ({ room, step, expect }) => {
2370
2762
  const id = at(room, "wooden_crate", 0, -4);
2371
2763
  room.setTool("lighter");
2764
+ room.waitUntilReady();
2372
2765
  look(room, [0, 0.3, -3.65], [0, 0.25, -3.76]);
2373
2766
  room.use(5);
2374
2767
  const lit = anyBurning(room, id);
@@ -2391,6 +2784,7 @@ function makeScenarios(t, num, _locale = "en") {
2391
2784
  const melt0 = part(room, drum2, "drum_body")?.melt ?? 0;
2392
2785
  expect(melt0 === 0 && peak < 1450, t("sc.torch_melts_steel.wood", { peak: num(peak, 0), melt: num(melt0 * 100, 0), crate: yn(anyBurning(room, crate2)) }), { peak });
2393
2786
  room.setTool("cutting_torch");
2787
+ room.waitUntilReady();
2394
2788
  look(room, [-0.15, 0.55, -3.55], [-0.15, 0.5, -3.72]);
2395
2789
  room.use(20);
2396
2790
  const p = part(room, drum2, "drum_body");
@@ -2399,6 +2793,7 @@ function makeScenarios(t, num, _locale = "en") {
2399
2793
  sc("bucket_melts_drips", ({ room, step, expect }) => {
2400
2794
  const id = at(room, "plastic_bucket", 0, -4);
2401
2795
  room.setTool("flamethrower");
2796
+ room.waitUntilReady();
2402
2797
  look(room, [0, 0.5, -2.4], [0, 0.15, -4]);
2403
2798
  room.use(2.5);
2404
2799
  step(10);
@@ -2473,6 +2868,7 @@ function makeScenarios(t, num, _locale = "en") {
2473
2868
  sc("jerrycan_leaks_fuel", ({ room, step, expect }) => {
2474
2869
  const id = at(room, "jerrycan", 0, -3.5);
2475
2870
  room.setTool("pistol_9mm");
2871
+ room.waitUntilReady();
2476
2872
  look(room, [0, 0.4, -1.5], [0, 0.12, -3.43]);
2477
2873
  room.use();
2478
2874
  step(1.5);
@@ -2487,6 +2883,7 @@ function makeScenarios(t, num, _locale = "en") {
2487
2883
  const id = at(room, "jerrycan", 0, -3.5);
2488
2884
  const crate2 = at(room, "wooden_crate", 0.9, -3.2);
2489
2885
  room.setTool("pistol_9mm");
2886
+ room.waitUntilReady();
2490
2887
  look(room, [0, 0.4, -1.5], [0, 0.12, -3.43]);
2491
2888
  room.use();
2492
2889
  step(6);
@@ -2494,6 +2891,7 @@ function makeScenarios(t, num, _locale = "en") {
2494
2891
  expect(!!stain, t("sc.fuel_stain_burns.stain", { stain: yn(!!stain) }));
2495
2892
  if (!stain) return;
2496
2893
  room.setTool("lighter");
2894
+ room.waitUntilReady();
2497
2895
  look(room, [stain.center[0], 0.1, stain.center[2] + 0.06], [stain.center[0], 0, stain.center[2]]);
2498
2896
  room.use(1);
2499
2897
  step(4);
@@ -2529,6 +2927,7 @@ function makeScenarios(t, num, _locale = "en") {
2529
2927
  const before = anyBurning(room, id);
2530
2928
  expect(before, t("sc.extinguisher_puts_out_fire.burning", { burning: yn(before) }));
2531
2929
  room.setTool("extinguisher");
2930
+ room.waitUntilReady();
2532
2931
  look(room, [0, 0.9, -2.2], [0, 0.25, -4]);
2533
2932
  room.use(2.5);
2534
2933
  step(1);
@@ -3397,10 +3796,14 @@ function createTestRoom(options) {
3397
3796
  const tools = /* @__PURE__ */ new Map();
3398
3797
  let toolIndex = Math.max(0, loadout.indexOf("hand"));
3399
3798
  let toolId = loadout[toolIndex] ?? "hand";
3799
+ const toolSettings = {};
3800
+ let fuseLength;
3801
+ const SETTING_IDS = { condition: "condition", ammoQuality: "ammo_quality", fuseLength: "fuse_length" };
3400
3802
  const toolState = (id) => {
3401
3803
  let s = tools.get(id);
3402
3804
  if (!s) {
3403
- s = arsenal.create(id, { seed: 7, reserve: 999 });
3805
+ s = arsenal.create(id, { seed: 7, reserve: 999, ...toolSettings });
3806
+ s.setFuseLength(fuseLength);
3404
3807
  s.refill();
3405
3808
  tools.set(id, s);
3406
3809
  }
@@ -3501,6 +3904,7 @@ function createTestRoom(options) {
3501
3904
  for (const ev of batch) {
3502
3905
  if (ev.type === "leak") puncture(ev);
3503
3906
  else if (ev.type === "ignite") igniteNear(ev.point, 0.9);
3907
+ else if (ev.type === "explode") emitHost({ type: "explosion", source: ev.object, point: ev.point, energy: ev.energy, radius: ev.radius, cause: "part" });
3504
3908
  fire("event", ev);
3505
3909
  options.onEvent?.(ev);
3506
3910
  }
@@ -3521,7 +3925,7 @@ function createTestRoom(options) {
3521
3925
  },
3522
3926
  primaryUp: () => {
3523
3927
  useHeld = false;
3524
- tool.release();
3928
+ releaseTrigger();
3525
3929
  },
3526
3930
  secondary: () => {
3527
3931
  if (heldEntry) throwHeld();
@@ -3678,8 +4082,7 @@ function createTestRoom(options) {
3678
4082
  if (heldEntry || heldPiece) releaseHeld();
3679
4083
  useItemPlayer = null;
3680
4084
  playing.length = 0;
3681
- projectiles.length = 0;
3682
- clearProjectileMeshes();
4085
+ explosives.clear();
3683
4086
  fireZones.length = 0;
3684
4087
  burningStains.length = 0;
3685
4088
  for (const id of [...entries.keys()]) if (!entries.get(id)?.arena) removeEntry(id);
@@ -3915,7 +4318,6 @@ function createTestRoom(options) {
3915
4318
  }
3916
4319
  });
3917
4320
  player.kick(recoil[0], recoil[1]);
3918
- emitHost({ type: "shot", weapon: toolId, shots: shots.length, recoil, rounds: shots.filter((s) => s.pellet === 0).length, mode: tool.mode });
3919
4321
  const f = first;
3920
4322
  const d = f ? num(f.distance, 1) : "";
3921
4323
  let text;
@@ -4063,100 +4465,46 @@ function createTestRoom(options) {
4063
4465
  1
4064
4466
  );
4065
4467
  };
4066
- const projectiles = [];
4067
- const projMeshes = [];
4068
- const clearProjectileMeshes = () => {
4069
- for (const m of projMeshes) m.removeFromParent();
4070
- projMeshes.length = 0;
4071
- };
4072
- const throwUse = (u) => {
4073
- const w = toolName();
4074
- const molotov = !!u.fire;
4075
- const mesh = new THREE.Mesh(
4076
- molotov ? new THREE.CylinderGeometry(0.035, 0.04, 0.2, 8) : new THREE.SphereGeometry(0.045, 8, 6),
4077
- new THREE.MeshStandardMaterial({ color: molotov ? 3897915 : 4936480, roughness: 0.6, metalness: molotov ? 0 : 0.4 })
4078
- );
4079
- mesh.castShadow = true;
4080
- mesh.position.set(u.origin[0], u.origin[1], u.origin[2]);
4081
- scene.add(mesh);
4082
- projMeshes.push(mesh);
4083
- projectiles.push({
4084
- mesh,
4085
- pos: new THREE.Vector3(u.origin[0], u.origin[1], u.origin[2]),
4086
- vel: new THREE.Vector3(u.velocity[0], u.velocity[1], u.velocity[2]),
4087
- weapon: toolId,
4088
- fuse: u.fuse,
4089
- burstOnImpact: molotov && u.fuse <= 0,
4090
- energy: u.energy,
4091
- radius: u.radius,
4092
- fire: u.fire,
4093
- age: 0
4094
- });
4095
- action("throw_projectile", t(molotov ? "act.throw.molotov" : "act.throw.grenade", { weapon: w, fuse: num(u.fuse, 1) }), { data: { weapon: toolId, fuse: u.fuse } });
4096
- };
4097
- const detonate = (p, at, impact) => {
4098
- const w = toolName(p.weapon);
4099
- const results = world.explode(at, p.energy, p.radius, "player");
4100
- renderer.explode(at, p.energy, p.radius);
4101
- action("explode", t("act.explode", { weapon: w, energy: num(p.energy, 0), radius: num(p.radius, 1), n: results.length }), { data: { weapon: p.weapon, energyJ: p.energy, radius: p.radius, point: at } });
4102
- if (p.fire) {
4103
- fluids.splash("fuel", [at[0], Math.max(at[1], groundAt(at[0], at[2])), at[2]], 0.5);
4104
- fireZones.push({ point: [at[0], Math.max(0.02, groundAt(at[0], at[2])), at[2]], radius: p.fire.radius, temperature: p.fire.temperature, remaining: p.fire.seconds, acc: 0 });
4105
- renderer.emit("fire", at, [0, 1, 0], 24, 3);
4106
- action("molotov_fire", t("act.molotov_fire", { weapon: w, seconds: num(p.fire.seconds, 0), radius: num(p.fire.radius, 1) }), { data: { weapon: p.weapon, impact } });
4107
- }
4108
- };
4109
- const updateProjectiles = (dt) => {
4110
- for (let i = projectiles.length - 1; i >= 0; i--) {
4111
- const p = projectiles[i];
4112
- p.age += dt;
4113
- p.vel.y -= 9.81 * dt;
4114
- const speed = p.vel.length();
4115
- const step2 = speed * dt;
4116
- let burst = false;
4117
- if (step2 > 1e-6) {
4118
- const d = [p.vel.x / speed, p.vel.y / speed, p.vel.z / speed];
4119
- const r = withoutHeld(() => world.raycast(v3(p.pos), d, step2 + 0.05));
4120
- if (r && r.distance <= step2 + 0.04) {
4121
- p.pos.set(r.point[0] - d[0] * 0.03, r.point[1] - d[1] * 0.03, r.point[2] - d[2] * 0.03);
4122
- if (p.burstOnImpact) burst = true;
4123
- else {
4124
- const n = r.normal;
4125
- const vn = p.vel.x * n[0] + p.vel.y * n[1] + p.vel.z * n[2];
4126
- p.vel.x = (p.vel.x - 2 * vn * n[0]) * 0.35;
4127
- p.vel.y = (p.vel.y - 2 * vn * n[1]) * 0.35;
4128
- p.vel.z = (p.vel.z - 2 * vn * n[2]) * 0.35;
4129
- }
4130
- } else p.pos.addScaledVector(p.vel, dt);
4131
- }
4132
- const g = groundAt(p.pos.x, p.pos.z) + 0.045;
4133
- if (p.pos.y < g) {
4134
- p.pos.y = g;
4135
- if (p.burstOnImpact) burst = true;
4136
- else {
4137
- p.vel.y = Math.abs(p.vel.y) * 0.35;
4138
- p.vel.x *= 0.7;
4139
- p.vel.z *= 0.7;
4140
- }
4141
- }
4142
- p.mesh.position.copy(p.pos);
4143
- p.mesh.rotation.x += dt * 8;
4144
- p.fuse -= dt;
4145
- if (burst || !p.burstOnImpact && p.fuse <= 0 || p.age > 30) {
4146
- detonate(p, v3(p.pos), burst);
4147
- p.mesh.removeFromParent();
4148
- projMeshes.splice(projMeshes.indexOf(p.mesh), 1);
4149
- projectiles.splice(i, 1);
4150
- }
4151
- }
4152
- };
4468
+ const startFire = (weapon, at, fire2, impact) => {
4469
+ fluids.splash("fuel", [at[0], Math.max(at[1], groundAt(at[0], at[2])), at[2]], 0.5);
4470
+ fireZones.push({ point: [at[0], Math.max(0.02, groundAt(at[0], at[2])), at[2]], radius: fire2.radius, temperature: fire2.temperature, remaining: fire2.seconds, acc: 0 });
4471
+ renderer.emit("fire", at, [0, 1, 0], 24, 3);
4472
+ action("molotov_fire", t("act.molotov_fire", { weapon: toolName(weapon), seconds: num(fire2.seconds, 0), radius: num(fire2.radius, 1) }), { data: { weapon, impact } });
4473
+ };
4474
+ function crossingBodies() {
4475
+ const bodies = [...entries.values()].filter((e) => !e.arena).map((e) => ({ id: e.so.objectId, box: new THREE.Box3().setFromObject(e.so.root) }));
4476
+ const { x, y, z } = camera.position;
4477
+ const feet = groundAt(x, z);
4478
+ bodies.push({ id: "player", box: new THREE.Box3(new THREE.Vector3(x - PLAYER_RADIUS, feet, z - PLAYER_RADIUS), new THREE.Vector3(x + PLAYER_RADIUS, y + 0.1, z + PLAYER_RADIUS)) });
4479
+ return bodies;
4480
+ }
4481
+ const explosives = createExplosives({
4482
+ THREE,
4483
+ scene,
4484
+ camera,
4485
+ world,
4486
+ renderer,
4487
+ arsenal,
4488
+ objects: () => [...entries.values()].filter((e) => !e.arena).map((e) => e.so.root),
4489
+ bodies: crossingBodies,
4490
+ toolId: () => toolId,
4491
+ toolName,
4492
+ withoutHeld,
4493
+ groundAt,
4494
+ translate: t,
4495
+ format: num,
4496
+ action,
4497
+ emitHost,
4498
+ startFire,
4499
+ say
4500
+ });
4153
4501
  const useTool = (dt, held) => {
4154
4502
  aim();
4155
4503
  const cat = tool.def.category;
4156
4504
  if (cat === "hand") return;
4157
4505
  const res = tool.trigger([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], dt, held, "player");
4506
+ flushEvents(toolId, tool, res.recoil);
4158
4507
  if (res.status === "empty") {
4159
- emitHost({ type: "empty", weapon: toolId });
4160
4508
  if (!tool.reloading && tool.reserve > 0) startReload();
4161
4509
  say(t(tool.reloading ? "hud.reloading" : "hud.empty"));
4162
4510
  return;
@@ -4164,8 +4512,13 @@ function createTestRoom(options) {
4164
4512
  if (res.status !== "ok") {
4165
4513
  if (res.status === "reloading") say(t("hud.reloading"));
4166
4514
  else if (res.status === "no_fuel") say(t("hud.no_fuel"));
4515
+ else if (res.status === "fault" && res.fault) say(t("hud.fault", { fault: t(`fault.${res.fault}`) }));
4516
+ else if (res.status === "safe") say(t("hud.safe"));
4167
4517
  return;
4168
4518
  }
4519
+ applyUses(res, dt);
4520
+ };
4521
+ const applyUses = (res, dt) => {
4169
4522
  if (res.shots.length) fireShots(res.shots, res.recoil);
4170
4523
  for (const u of res.uses) {
4171
4524
  switch (u.kind) {
@@ -4179,7 +4532,13 @@ function createTestRoom(options) {
4179
4532
  heatRays(u, "heat");
4180
4533
  break;
4181
4534
  case "throw":
4182
- throwUse(u);
4535
+ explosives.throwUse(u);
4536
+ break;
4537
+ case "place":
4538
+ explosives.placeUse(u);
4539
+ break;
4540
+ case "backblast":
4541
+ explosives.backblastUse(u);
4183
4542
  break;
4184
4543
  case "tool":
4185
4544
  toolWork(u, dt);
@@ -4192,6 +4551,16 @@ function createTestRoom(options) {
4192
4551
  }
4193
4552
  }
4194
4553
  };
4554
+ const releaseTrigger = () => {
4555
+ if (!tool.cooking) {
4556
+ tool.release();
4557
+ return;
4558
+ }
4559
+ aim();
4560
+ const res = tool.release([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]]);
4561
+ flushEvents(toolId, tool);
4562
+ if (res) applyUses(res, 0);
4563
+ };
4195
4564
  const kick = () => {
4196
4565
  if (!arsenal.weapons.has("kick")) return;
4197
4566
  aim();
@@ -4201,7 +4570,7 @@ function createTestRoom(options) {
4201
4570
  };
4202
4571
  const setTool = (id) => {
4203
4572
  if (!arsenal.weapons.has(id)) throw new Error(`game-test-room: unknown weapon "${id}"`);
4204
- tool.release();
4573
+ releaseTrigger();
4205
4574
  toolId = id;
4206
4575
  const i = loadout.indexOf(id);
4207
4576
  if (i >= 0) toolIndex = i;
@@ -4209,7 +4578,8 @@ function createTestRoom(options) {
4209
4578
  progress = null;
4210
4579
  action("tool", t("act.tool", { weapon: toolName(id) }), { data: { weapon: id } });
4211
4580
  fire("tool", id);
4212
- emitHost({ type: "tool", weapon: id });
4581
+ tool.draw();
4582
+ flushEvents(id, tool);
4213
4583
  };
4214
4584
  const cycleTool = (dir) => {
4215
4585
  if (!loadout.length) return;
@@ -4442,11 +4812,16 @@ function createTestRoom(options) {
4442
4812
  }
4443
4813
  break;
4444
4814
  case "KeyQ": {
4445
- const before = tool.mode;
4446
- const mode = tool.cycleMode();
4447
- if (mode !== before) emitHost({ type: "mode", weapon: toolId, mode });
4815
+ tool.cycleMode();
4816
+ flushEvents(toolId, tool);
4448
4817
  break;
4449
4818
  }
4819
+ case "KeyX":
4820
+ clearToolFault();
4821
+ break;
4822
+ case "KeyB":
4823
+ toggleSafety();
4824
+ break;
4450
4825
  case "KeyZ":
4451
4826
  tiltKey = 1;
4452
4827
  break;
@@ -4463,6 +4838,9 @@ function createTestRoom(options) {
4463
4838
  case "KeyT":
4464
4839
  toggleTap();
4465
4840
  break;
4841
+ case "KeyY":
4842
+ explosives.detonateCharges();
4843
+ break;
4466
4844
  case "KeyH": {
4467
4845
  const w = (heldEntry ?? entryOf(inspectTarget()?.object ?? ""))?.item?.weapon;
4468
4846
  if (w) equip(w);
@@ -4577,7 +4955,7 @@ function createTestRoom(options) {
4577
4955
  const reps = fastForward ? 10 : 1;
4578
4956
  updateAnimations(dt);
4579
4957
  updateItemUse(dt);
4580
- updateProjectiles(dt);
4958
+ explosives.update(dt);
4581
4959
  syncContainers(dt);
4582
4960
  for (let i = 0; i < reps; i++) {
4583
4961
  world.update(dt);
@@ -4588,9 +4966,10 @@ function createTestRoom(options) {
4588
4966
  renderer.update(dt);
4589
4967
  updateImpacts();
4590
4968
  fluidView.update(dt);
4591
- for (const s of tools.values()) s.update(dt);
4592
- if (reloadingSeen && !tool.reloading) emitHost({ type: "reload", weapon: toolId, phase: "end" });
4593
- reloadingSeen = tool.reloading;
4969
+ for (const [id, s] of tools) {
4970
+ s.update(dt);
4971
+ flushEvents(id, s);
4972
+ }
4594
4973
  for (const update2 of frameListeners) update2(dt);
4595
4974
  for (const tr of tracers) {
4596
4975
  if (tr.life > 0) {
@@ -4793,12 +5172,49 @@ function createTestRoom(options) {
4793
5172
  };
4794
5173
  const hostListeners = /* @__PURE__ */ new Set();
4795
5174
  const frameListeners = /* @__PURE__ */ new Set();
4796
- let reloadingSeen = false;
4797
5175
  function emitHost(event) {
4798
5176
  for (const listener of hostListeners) listener(event);
4799
5177
  }
5178
+ function flushEvents(id, state, recoil) {
5179
+ const phase = state.phase;
5180
+ for (const event of state.drain()) {
5181
+ const hostEvent = toHostEvent(id, event, { ...recoil ? { recoil } : {}, phase });
5182
+ if (hostEvent) emitHost(hostEvent);
5183
+ }
5184
+ }
4800
5185
  function startReload() {
4801
- if (tool.reload()) emitHost({ type: "reload", weapon: toolId, phase: "start" });
5186
+ tool.reload();
5187
+ flushEvents(toolId, tool);
5188
+ }
5189
+ function clearToolFault() {
5190
+ if (!tool.clearFault()) return;
5191
+ flushEvents(toolId, tool);
5192
+ say(t("hud.clearing"));
5193
+ }
5194
+ function toggleSafety() {
5195
+ tool.setSafety(!tool.safety);
5196
+ flushEvents(toolId, tool);
5197
+ say(t(tool.safety ? "hud.safety_on" : "hud.safety_off"));
5198
+ }
5199
+ function runToolCommand(request) {
5200
+ const work = (done) => done.ok ? { ok: true, seconds: done.seconds } : { ok: false, reason: done.reason };
5201
+ switch (request.type) {
5202
+ case "fit":
5203
+ return work(tool.fit(request.attachment, { install: "bench", ...request.mount ? { mount: request.mount } : {} }));
5204
+ case "remove":
5205
+ return work(tool.remove(request.attachment, { install: "bench" }));
5206
+ case "toggle":
5207
+ return tool.toggle(request.attachment) === null ? { ok: false, reason: "not_fitted" } : { ok: true };
5208
+ case "stock":
5209
+ return tool.setStock(request.state) ? { ok: true } : { ok: false, reason: "busy" };
5210
+ case "underbarrel":
5211
+ return tool.useUnderbarrel(request.on) ? { ok: true } : { ok: false, reason: "busy" };
5212
+ case "stance":
5213
+ tool.setStance({ ...request.aiming === void 0 ? {} : { aiming: request.aiming }, ...request.supported === void 0 ? {} : { supported: request.supported } });
5214
+ return { ok: true };
5215
+ default:
5216
+ return { ok: false, reason: "unknown" };
5217
+ }
4802
5218
  }
4803
5219
  const host = {
4804
5220
  THREE,
@@ -4806,7 +5222,42 @@ function createTestRoom(options) {
4806
5222
  camera,
4807
5223
  locale,
4808
5224
  groundAt,
4809
- tool: () => ({ weapon: toolId, category: tool.def.category, ammo: tool.ammo, capacity: tool.capacity, reserve: tool.reserve, reloading: tool.reloading, mode: tool.mode }),
5225
+ tool: () => ({
5226
+ weapon: toolId,
5227
+ category: tool.def.category,
5228
+ ammo: tool.ammo,
5229
+ capacity: tool.capacity,
5230
+ reserve: tool.reserve,
5231
+ reloading: tool.reloading,
5232
+ mode: tool.mode,
5233
+ ...tool.fault ? { fault: tool.fault } : {},
5234
+ safety: tool.safety,
5235
+ heat: tool.heatLevel,
5236
+ attachments: tool.attachments.map((part) => ({ id: part.id, mount: part.mount, ...part.integral ? { integral: true } : {}, ...part.on === void 0 ? {} : { on: part.on } })),
5237
+ stock: tool.stock,
5238
+ suppressed: tool.suppressed,
5239
+ underbarrelActive: tool.underbarrelActive,
5240
+ mass: tool.mass,
5241
+ aimSeconds: tool.aimSeconds
5242
+ }),
5243
+ command(request) {
5244
+ const result = runToolCommand(request);
5245
+ flushEvents(toolId, tool);
5246
+ return result;
5247
+ },
5248
+ configureTool(settings) {
5249
+ const condition = settings[SETTING_IDS.condition];
5250
+ const ammoQuality = settings[SETTING_IDS.ammoQuality];
5251
+ if (condition !== void 0) toolSettings.condition = condition;
5252
+ if (ammoQuality !== void 0) toolSettings.ammoQuality = ammoQuality;
5253
+ const fuseMetres = settings[SETTING_IDS.fuseLength] === void 0 ? void 0 : Number(settings[SETTING_IDS.fuseLength]);
5254
+ if (fuseMetres !== void 0 && fuseMetres > 0) fuseLength = fuseMetres;
5255
+ for (const state of tools.values()) {
5256
+ if (condition !== void 0) state.setCondition(condition);
5257
+ if (ammoQuality !== void 0) state.setAmmoQuality(ammoQuality);
5258
+ if (fuseMetres !== void 0 && fuseMetres > 0) state.setFuseLength(fuseMetres);
5259
+ }
5260
+ },
4810
5261
  on(listener) {
4811
5262
  hostListeners.add(listener);
4812
5263
  return () => hostListeners.delete(listener);
@@ -5044,7 +5495,7 @@ function createTestRoom(options) {
5044
5495
  stepSim(FIXED_DT);
5045
5496
  t0 += FIXED_DT;
5046
5497
  }
5047
- tool.release();
5498
+ releaseTrigger();
5048
5499
  },
5049
5500
  grab,
5050
5501
  throwHeld,
@@ -5061,6 +5512,15 @@ function createTestRoom(options) {
5061
5512
  return [...entries.values()].filter((e) => !e.arena).map((e) => e.so);
5062
5513
  },
5063
5514
  update,
5515
+ waitUntilReady(maxSeconds = 5) {
5516
+ let waited = 0;
5517
+ while (tool.phase && waited < maxSeconds) {
5518
+ update(FIXED_DT);
5519
+ waited += FIXED_DT;
5520
+ }
5521
+ update(FIXED_DT);
5522
+ return waited + FIXED_DT;
5523
+ },
5064
5524
  on(type, fn) {
5065
5525
  listeners[type].add(fn);
5066
5526
  return () => listeners[type].delete(fn);
@@ -5113,7 +5573,7 @@ function createTestRoom(options) {
5113
5573
  renderer.dispose();
5114
5574
  fluidView.dispose();
5115
5575
  for (const tr of tracers) tr.line.removeFromParent();
5116
- clearProjectileMeshes();
5576
+ explosives.clear();
5117
5577
  if (ownRenderer) {
5118
5578
  gl.dispose();
5119
5579
  dom.remove();