@adatechnology/game-test-room 0.3.0 → 0.4.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,346 @@ 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 };
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
+ default:
1675
+ return null;
1676
+ }
1677
+ };
1678
+
1303
1679
  // src/log.ts
1304
1680
  var MAX_ENTRIES = 5e3;
1305
1681
  var pad = (n, w = 2) => String(n).padStart(w, "0");
@@ -1991,7 +2367,7 @@ function createNarrator(c) {
1991
2367
 
1992
2368
  // src/player.ts
1993
2369
  var EYE = 1.7;
1994
- var RADIUS = 0.3;
2370
+ var PLAYER_RADIUS = 0.3;
1995
2371
  var WALK = 3.4;
1996
2372
  var RUN = 6.2;
1997
2373
  var JUMP = 4.6;
@@ -2016,8 +2392,8 @@ function createPlayer(THREE, o) {
2016
2392
  };
2017
2393
  apply();
2018
2394
  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;
2395
+ if (Math.abs(x) > o.limit - PLAYER_RADIUS || Math.abs(z) > o.limit - PLAYER_RADIUS) return true;
2396
+ 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
2397
  return false;
2022
2398
  };
2023
2399
  const move = (dx, dz) => {
@@ -2239,6 +2615,7 @@ function makeScenarios(t, num, _locale = "en") {
2239
2615
  sc("steel_through_hole", ({ room, step, expect }) => {
2240
2616
  const id = at(room, "steel_sheet", 0, -4);
2241
2617
  room.setTool("pistol_9mm");
2618
+ room.waitUntilReady();
2242
2619
  look(room, [0, 1.1, -1.5], [0, 1.1, -4]);
2243
2620
  room.use();
2244
2621
  step(0.2);
@@ -2253,6 +2630,7 @@ function makeScenarios(t, num, _locale = "en") {
2253
2630
  sc("concrete_stops_bullet", ({ room, step, expect }) => {
2254
2631
  const id = at(room, "concrete_block", 0, -4);
2255
2632
  room.setTool("pistol_9mm");
2633
+ room.waitUntilReady();
2256
2634
  look(room, [0, 0.5, -1.5], [0, 0.2, -3.8]);
2257
2635
  room.use();
2258
2636
  step(0.2);
@@ -2268,6 +2646,7 @@ function makeScenarios(t, num, _locale = "en") {
2268
2646
  sc("hammer_dents_drum", ({ room, step, expect }) => {
2269
2647
  const id = at(room, "steel_drum", 0, -4);
2270
2648
  room.setTool("hammer");
2649
+ room.waitUntilReady();
2271
2650
  look(room, [0, 0.6, -3.35], [0, 0.5, -4]);
2272
2651
  let swings = 0;
2273
2652
  for (; swings < 8 && (part(room, id, "drum_body")?.dents.length ?? 0) === 0; swings++) {
@@ -2284,6 +2663,7 @@ function makeScenarios(t, num, _locale = "en") {
2284
2663
  sc("glass_shatters", ({ room, step, expect }) => {
2285
2664
  const id = at(room, "glass_pane", 0, -4);
2286
2665
  room.setTool("pistol_9mm");
2666
+ room.waitUntilReady();
2287
2667
  look(room, [0, 1.3, -1.5], [0, 1.05, -4]);
2288
2668
  let shots = 0;
2289
2669
  for (; shots < 10 && part(room, id, "pane")?.status !== "broken"; shots++) {
@@ -2298,6 +2678,7 @@ function makeScenarios(t, num, _locale = "en") {
2298
2678
  sc("shotgun_splinters_crate", ({ room, step, expect }) => {
2299
2679
  const id = at(room, "wooden_crate", 0, -4);
2300
2680
  room.setTool("shotgun_pump_12");
2681
+ room.waitUntilReady();
2301
2682
  look(room, [0, 0.6, -1.8], [0, 0.25, -3.77]);
2302
2683
  let shots = 0;
2303
2684
  const splinters = () => room.log.entries({ type: "fracture", objectId: id }).filter((e) => e.data?.style === "splinter").length;
@@ -2331,6 +2712,7 @@ function makeScenarios(t, num, _locale = "en") {
2331
2712
  const id = at(room, "chair", 0, -4);
2332
2713
  const before = room.renderer.pieces().length;
2333
2714
  room.setTool("kick");
2715
+ room.waitUntilReady();
2334
2716
  let kicks = 0;
2335
2717
  for (; kicks < 8 && room.renderer.pieces().length <= before && part(room, id, "leg_1"); kicks++) {
2336
2718
  const st = room.world.state(id);
@@ -2347,6 +2729,7 @@ function makeScenarios(t, num, _locale = "en") {
2347
2729
  sc("screwdriver_shelf_board", ({ room, step, expect }) => {
2348
2730
  const id = at(room, "shelf", 0, -4);
2349
2731
  room.setTool("screwdriver");
2732
+ room.waitUntilReady();
2350
2733
  look(room, [0, 0.8, -3.5], [0, 0.75, -3.86]);
2351
2734
  const t0 = room.log.entries().length ? room.log.entries().slice(-1)[0].t : 0;
2352
2735
  room.use(5);
@@ -2360,6 +2743,7 @@ function makeScenarios(t, num, _locale = "en") {
2360
2743
  sc("wrong_tool_refused", ({ room, expect }) => {
2361
2744
  const id = at(room, "steel_sheet", 0, -4);
2362
2745
  room.setTool("screwdriver");
2746
+ room.waitUntilReady();
2363
2747
  look(room, [0.2, 1.05, -3.6], [0.34, 1, -4]);
2364
2748
  room.use(0.5);
2365
2749
  const refused = count(room, "tool_refused");
@@ -2369,6 +2753,7 @@ function makeScenarios(t, num, _locale = "en") {
2369
2753
  sc("lighter_burns_crate", ({ room, step, expect }) => {
2370
2754
  const id = at(room, "wooden_crate", 0, -4);
2371
2755
  room.setTool("lighter");
2756
+ room.waitUntilReady();
2372
2757
  look(room, [0, 0.3, -3.65], [0, 0.25, -3.76]);
2373
2758
  room.use(5);
2374
2759
  const lit = anyBurning(room, id);
@@ -2391,6 +2776,7 @@ function makeScenarios(t, num, _locale = "en") {
2391
2776
  const melt0 = part(room, drum2, "drum_body")?.melt ?? 0;
2392
2777
  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
2778
  room.setTool("cutting_torch");
2779
+ room.waitUntilReady();
2394
2780
  look(room, [-0.15, 0.55, -3.55], [-0.15, 0.5, -3.72]);
2395
2781
  room.use(20);
2396
2782
  const p = part(room, drum2, "drum_body");
@@ -2399,6 +2785,7 @@ function makeScenarios(t, num, _locale = "en") {
2399
2785
  sc("bucket_melts_drips", ({ room, step, expect }) => {
2400
2786
  const id = at(room, "plastic_bucket", 0, -4);
2401
2787
  room.setTool("flamethrower");
2788
+ room.waitUntilReady();
2402
2789
  look(room, [0, 0.5, -2.4], [0, 0.15, -4]);
2403
2790
  room.use(2.5);
2404
2791
  step(10);
@@ -2473,6 +2860,7 @@ function makeScenarios(t, num, _locale = "en") {
2473
2860
  sc("jerrycan_leaks_fuel", ({ room, step, expect }) => {
2474
2861
  const id = at(room, "jerrycan", 0, -3.5);
2475
2862
  room.setTool("pistol_9mm");
2863
+ room.waitUntilReady();
2476
2864
  look(room, [0, 0.4, -1.5], [0, 0.12, -3.43]);
2477
2865
  room.use();
2478
2866
  step(1.5);
@@ -2487,6 +2875,7 @@ function makeScenarios(t, num, _locale = "en") {
2487
2875
  const id = at(room, "jerrycan", 0, -3.5);
2488
2876
  const crate2 = at(room, "wooden_crate", 0.9, -3.2);
2489
2877
  room.setTool("pistol_9mm");
2878
+ room.waitUntilReady();
2490
2879
  look(room, [0, 0.4, -1.5], [0, 0.12, -3.43]);
2491
2880
  room.use();
2492
2881
  step(6);
@@ -2494,6 +2883,7 @@ function makeScenarios(t, num, _locale = "en") {
2494
2883
  expect(!!stain, t("sc.fuel_stain_burns.stain", { stain: yn(!!stain) }));
2495
2884
  if (!stain) return;
2496
2885
  room.setTool("lighter");
2886
+ room.waitUntilReady();
2497
2887
  look(room, [stain.center[0], 0.1, stain.center[2] + 0.06], [stain.center[0], 0, stain.center[2]]);
2498
2888
  room.use(1);
2499
2889
  step(4);
@@ -2529,6 +2919,7 @@ function makeScenarios(t, num, _locale = "en") {
2529
2919
  const before = anyBurning(room, id);
2530
2920
  expect(before, t("sc.extinguisher_puts_out_fire.burning", { burning: yn(before) }));
2531
2921
  room.setTool("extinguisher");
2922
+ room.waitUntilReady();
2532
2923
  look(room, [0, 0.9, -2.2], [0, 0.25, -4]);
2533
2924
  room.use(2.5);
2534
2925
  step(1);
@@ -3397,10 +3788,14 @@ function createTestRoom(options) {
3397
3788
  const tools = /* @__PURE__ */ new Map();
3398
3789
  let toolIndex = Math.max(0, loadout.indexOf("hand"));
3399
3790
  let toolId = loadout[toolIndex] ?? "hand";
3791
+ const toolSettings = {};
3792
+ let fuseLength;
3793
+ const SETTING_IDS = { condition: "condition", ammoQuality: "ammo_quality", fuseLength: "fuse_length" };
3400
3794
  const toolState = (id) => {
3401
3795
  let s = tools.get(id);
3402
3796
  if (!s) {
3403
- s = arsenal.create(id, { seed: 7, reserve: 999 });
3797
+ s = arsenal.create(id, { seed: 7, reserve: 999, ...toolSettings });
3798
+ s.setFuseLength(fuseLength);
3404
3799
  s.refill();
3405
3800
  tools.set(id, s);
3406
3801
  }
@@ -3501,6 +3896,7 @@ function createTestRoom(options) {
3501
3896
  for (const ev of batch) {
3502
3897
  if (ev.type === "leak") puncture(ev);
3503
3898
  else if (ev.type === "ignite") igniteNear(ev.point, 0.9);
3899
+ else if (ev.type === "explode") emitHost({ type: "explosion", source: ev.object, point: ev.point, energy: ev.energy, radius: ev.radius, cause: "part" });
3504
3900
  fire("event", ev);
3505
3901
  options.onEvent?.(ev);
3506
3902
  }
@@ -3521,7 +3917,7 @@ function createTestRoom(options) {
3521
3917
  },
3522
3918
  primaryUp: () => {
3523
3919
  useHeld = false;
3524
- tool.release();
3920
+ releaseTrigger();
3525
3921
  },
3526
3922
  secondary: () => {
3527
3923
  if (heldEntry) throwHeld();
@@ -3678,8 +4074,7 @@ function createTestRoom(options) {
3678
4074
  if (heldEntry || heldPiece) releaseHeld();
3679
4075
  useItemPlayer = null;
3680
4076
  playing.length = 0;
3681
- projectiles.length = 0;
3682
- clearProjectileMeshes();
4077
+ explosives.clear();
3683
4078
  fireZones.length = 0;
3684
4079
  burningStains.length = 0;
3685
4080
  for (const id of [...entries.keys()]) if (!entries.get(id)?.arena) removeEntry(id);
@@ -3915,7 +4310,6 @@ function createTestRoom(options) {
3915
4310
  }
3916
4311
  });
3917
4312
  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
4313
  const f = first;
3920
4314
  const d = f ? num(f.distance, 1) : "";
3921
4315
  let text;
@@ -4063,100 +4457,46 @@ function createTestRoom(options) {
4063
4457
  1
4064
4458
  );
4065
4459
  };
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
- };
4460
+ const startFire = (weapon, at, fire2, impact) => {
4461
+ fluids.splash("fuel", [at[0], Math.max(at[1], groundAt(at[0], at[2])), at[2]], 0.5);
4462
+ 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 });
4463
+ renderer.emit("fire", at, [0, 1, 0], 24, 3);
4464
+ action("molotov_fire", t("act.molotov_fire", { weapon: toolName(weapon), seconds: num(fire2.seconds, 0), radius: num(fire2.radius, 1) }), { data: { weapon, impact } });
4465
+ };
4466
+ function crossingBodies() {
4467
+ const bodies = [...entries.values()].filter((e) => !e.arena).map((e) => ({ id: e.so.objectId, box: new THREE.Box3().setFromObject(e.so.root) }));
4468
+ const { x, y, z } = camera.position;
4469
+ const feet = groundAt(x, z);
4470
+ 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)) });
4471
+ return bodies;
4472
+ }
4473
+ const explosives = createExplosives({
4474
+ THREE,
4475
+ scene,
4476
+ camera,
4477
+ world,
4478
+ renderer,
4479
+ arsenal,
4480
+ objects: () => [...entries.values()].filter((e) => !e.arena).map((e) => e.so.root),
4481
+ bodies: crossingBodies,
4482
+ toolId: () => toolId,
4483
+ toolName,
4484
+ withoutHeld,
4485
+ groundAt,
4486
+ translate: t,
4487
+ format: num,
4488
+ action,
4489
+ emitHost,
4490
+ startFire,
4491
+ say
4492
+ });
4153
4493
  const useTool = (dt, held) => {
4154
4494
  aim();
4155
4495
  const cat = tool.def.category;
4156
4496
  if (cat === "hand") return;
4157
4497
  const res = tool.trigger([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], dt, held, "player");
4498
+ flushEvents(toolId, tool, res.recoil);
4158
4499
  if (res.status === "empty") {
4159
- emitHost({ type: "empty", weapon: toolId });
4160
4500
  if (!tool.reloading && tool.reserve > 0) startReload();
4161
4501
  say(t(tool.reloading ? "hud.reloading" : "hud.empty"));
4162
4502
  return;
@@ -4164,8 +4504,13 @@ function createTestRoom(options) {
4164
4504
  if (res.status !== "ok") {
4165
4505
  if (res.status === "reloading") say(t("hud.reloading"));
4166
4506
  else if (res.status === "no_fuel") say(t("hud.no_fuel"));
4507
+ else if (res.status === "fault" && res.fault) say(t("hud.fault", { fault: t(`fault.${res.fault}`) }));
4508
+ else if (res.status === "safe") say(t("hud.safe"));
4167
4509
  return;
4168
4510
  }
4511
+ applyUses(res, dt);
4512
+ };
4513
+ const applyUses = (res, dt) => {
4169
4514
  if (res.shots.length) fireShots(res.shots, res.recoil);
4170
4515
  for (const u of res.uses) {
4171
4516
  switch (u.kind) {
@@ -4179,7 +4524,13 @@ function createTestRoom(options) {
4179
4524
  heatRays(u, "heat");
4180
4525
  break;
4181
4526
  case "throw":
4182
- throwUse(u);
4527
+ explosives.throwUse(u);
4528
+ break;
4529
+ case "place":
4530
+ explosives.placeUse(u);
4531
+ break;
4532
+ case "backblast":
4533
+ explosives.backblastUse(u);
4183
4534
  break;
4184
4535
  case "tool":
4185
4536
  toolWork(u, dt);
@@ -4192,6 +4543,16 @@ function createTestRoom(options) {
4192
4543
  }
4193
4544
  }
4194
4545
  };
4546
+ const releaseTrigger = () => {
4547
+ if (!tool.cooking) {
4548
+ tool.release();
4549
+ return;
4550
+ }
4551
+ aim();
4552
+ const res = tool.release([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]]);
4553
+ flushEvents(toolId, tool);
4554
+ if (res) applyUses(res, 0);
4555
+ };
4195
4556
  const kick = () => {
4196
4557
  if (!arsenal.weapons.has("kick")) return;
4197
4558
  aim();
@@ -4201,7 +4562,7 @@ function createTestRoom(options) {
4201
4562
  };
4202
4563
  const setTool = (id) => {
4203
4564
  if (!arsenal.weapons.has(id)) throw new Error(`game-test-room: unknown weapon "${id}"`);
4204
- tool.release();
4565
+ releaseTrigger();
4205
4566
  toolId = id;
4206
4567
  const i = loadout.indexOf(id);
4207
4568
  if (i >= 0) toolIndex = i;
@@ -4209,7 +4570,8 @@ function createTestRoom(options) {
4209
4570
  progress = null;
4210
4571
  action("tool", t("act.tool", { weapon: toolName(id) }), { data: { weapon: id } });
4211
4572
  fire("tool", id);
4212
- emitHost({ type: "tool", weapon: id });
4573
+ tool.draw();
4574
+ flushEvents(id, tool);
4213
4575
  };
4214
4576
  const cycleTool = (dir) => {
4215
4577
  if (!loadout.length) return;
@@ -4442,11 +4804,16 @@ function createTestRoom(options) {
4442
4804
  }
4443
4805
  break;
4444
4806
  case "KeyQ": {
4445
- const before = tool.mode;
4446
- const mode = tool.cycleMode();
4447
- if (mode !== before) emitHost({ type: "mode", weapon: toolId, mode });
4807
+ tool.cycleMode();
4808
+ flushEvents(toolId, tool);
4448
4809
  break;
4449
4810
  }
4811
+ case "KeyX":
4812
+ clearToolFault();
4813
+ break;
4814
+ case "KeyB":
4815
+ toggleSafety();
4816
+ break;
4450
4817
  case "KeyZ":
4451
4818
  tiltKey = 1;
4452
4819
  break;
@@ -4463,6 +4830,9 @@ function createTestRoom(options) {
4463
4830
  case "KeyT":
4464
4831
  toggleTap();
4465
4832
  break;
4833
+ case "KeyY":
4834
+ explosives.detonateCharges();
4835
+ break;
4466
4836
  case "KeyH": {
4467
4837
  const w = (heldEntry ?? entryOf(inspectTarget()?.object ?? ""))?.item?.weapon;
4468
4838
  if (w) equip(w);
@@ -4577,7 +4947,7 @@ function createTestRoom(options) {
4577
4947
  const reps = fastForward ? 10 : 1;
4578
4948
  updateAnimations(dt);
4579
4949
  updateItemUse(dt);
4580
- updateProjectiles(dt);
4950
+ explosives.update(dt);
4581
4951
  syncContainers(dt);
4582
4952
  for (let i = 0; i < reps; i++) {
4583
4953
  world.update(dt);
@@ -4588,9 +4958,10 @@ function createTestRoom(options) {
4588
4958
  renderer.update(dt);
4589
4959
  updateImpacts();
4590
4960
  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;
4961
+ for (const [id, s] of tools) {
4962
+ s.update(dt);
4963
+ flushEvents(id, s);
4964
+ }
4594
4965
  for (const update2 of frameListeners) update2(dt);
4595
4966
  for (const tr of tracers) {
4596
4967
  if (tr.life > 0) {
@@ -4793,12 +5164,29 @@ function createTestRoom(options) {
4793
5164
  };
4794
5165
  const hostListeners = /* @__PURE__ */ new Set();
4795
5166
  const frameListeners = /* @__PURE__ */ new Set();
4796
- let reloadingSeen = false;
4797
5167
  function emitHost(event) {
4798
5168
  for (const listener of hostListeners) listener(event);
4799
5169
  }
5170
+ function flushEvents(id, state, recoil) {
5171
+ const phase = state.phase;
5172
+ for (const event of state.drain()) {
5173
+ const hostEvent = toHostEvent(id, event, { ...recoil ? { recoil } : {}, phase });
5174
+ if (hostEvent) emitHost(hostEvent);
5175
+ }
5176
+ }
4800
5177
  function startReload() {
4801
- if (tool.reload()) emitHost({ type: "reload", weapon: toolId, phase: "start" });
5178
+ tool.reload();
5179
+ flushEvents(toolId, tool);
5180
+ }
5181
+ function clearToolFault() {
5182
+ if (!tool.clearFault()) return;
5183
+ flushEvents(toolId, tool);
5184
+ say(t("hud.clearing"));
5185
+ }
5186
+ function toggleSafety() {
5187
+ tool.setSafety(!tool.safety);
5188
+ flushEvents(toolId, tool);
5189
+ say(t(tool.safety ? "hud.safety_on" : "hud.safety_off"));
4802
5190
  }
4803
5191
  const host = {
4804
5192
  THREE,
@@ -4806,7 +5194,31 @@ function createTestRoom(options) {
4806
5194
  camera,
4807
5195
  locale,
4808
5196
  groundAt,
4809
- tool: () => ({ weapon: toolId, category: tool.def.category, ammo: tool.ammo, capacity: tool.capacity, reserve: tool.reserve, reloading: tool.reloading, mode: tool.mode }),
5197
+ tool: () => ({
5198
+ weapon: toolId,
5199
+ category: tool.def.category,
5200
+ ammo: tool.ammo,
5201
+ capacity: tool.capacity,
5202
+ reserve: tool.reserve,
5203
+ reloading: tool.reloading,
5204
+ mode: tool.mode,
5205
+ ...tool.fault ? { fault: tool.fault } : {},
5206
+ safety: tool.safety,
5207
+ heat: tool.heatLevel
5208
+ }),
5209
+ configureTool(settings) {
5210
+ const condition = settings[SETTING_IDS.condition];
5211
+ const ammoQuality = settings[SETTING_IDS.ammoQuality];
5212
+ if (condition !== void 0) toolSettings.condition = condition;
5213
+ if (ammoQuality !== void 0) toolSettings.ammoQuality = ammoQuality;
5214
+ const fuseMetres = settings[SETTING_IDS.fuseLength] === void 0 ? void 0 : Number(settings[SETTING_IDS.fuseLength]);
5215
+ if (fuseMetres !== void 0 && fuseMetres > 0) fuseLength = fuseMetres;
5216
+ for (const state of tools.values()) {
5217
+ if (condition !== void 0) state.setCondition(condition);
5218
+ if (ammoQuality !== void 0) state.setAmmoQuality(ammoQuality);
5219
+ if (fuseMetres !== void 0 && fuseMetres > 0) state.setFuseLength(fuseMetres);
5220
+ }
5221
+ },
4810
5222
  on(listener) {
4811
5223
  hostListeners.add(listener);
4812
5224
  return () => hostListeners.delete(listener);
@@ -5044,7 +5456,7 @@ function createTestRoom(options) {
5044
5456
  stepSim(FIXED_DT);
5045
5457
  t0 += FIXED_DT;
5046
5458
  }
5047
- tool.release();
5459
+ releaseTrigger();
5048
5460
  },
5049
5461
  grab,
5050
5462
  throwHeld,
@@ -5061,6 +5473,15 @@ function createTestRoom(options) {
5061
5473
  return [...entries.values()].filter((e) => !e.arena).map((e) => e.so);
5062
5474
  },
5063
5475
  update,
5476
+ waitUntilReady(maxSeconds = 5) {
5477
+ let waited = 0;
5478
+ while (tool.phase && waited < maxSeconds) {
5479
+ update(FIXED_DT);
5480
+ waited += FIXED_DT;
5481
+ }
5482
+ update(FIXED_DT);
5483
+ return waited + FIXED_DT;
5484
+ },
5064
5485
  on(type, fn) {
5065
5486
  listeners[type].add(fn);
5066
5487
  return () => listeners[type].delete(fn);
@@ -5113,7 +5534,7 @@ function createTestRoom(options) {
5113
5534
  renderer.dispose();
5114
5535
  fluidView.dispose();
5115
5536
  for (const tr of tracers) tr.line.removeFromParent();
5116
- clearProjectileMeshes();
5537
+ explosives.clear();
5117
5538
  if (ownRenderer) {
5118
5539
  gl.dispose();
5119
5540
  dom.remove();