@adatechnology/game-test-room 0.21.0 → 0.23.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
@@ -1,7 +1,8 @@
1
1
  // src/room.ts
2
2
  import { createDestruction, JOINT_DEFAULTS, resolveStrings as resolveDestructionStrings } from "@adatechnology/game-destruction";
3
- import { createAudioEngine, createAudioSink } from "@adatechnology/game-audio";
4
- import { surfaceOfMaterial } from "@adatechnology/game-contracts";
3
+ import { createAudioEngine, createAudioSink, loadKitSamples } from "@adatechnology/game-audio";
4
+ import { createDestructionRenderer, createDestructionSound, dragSoundKey, impactKey, impactSoundKeys, specFromObject3D } from "@adatechnology/game-destruction-three";
5
+ import { bodyMass, KNOCK_CHEST, knockOf, surfaceOfMaterial } from "@adatechnology/game-contracts";
5
6
 
6
7
  // src/body-sounds.ts
7
8
  import { BODY_FOLEY_BANK } from "@adatechnology/game-audio";
@@ -31,13 +32,14 @@ function blowOfTool(id, category, table = DEFAULT_TOOL_BLOWS) {
31
32
  }
32
33
  var levelOf = (energyJ) => Math.max(0.5, Math.min(1, 0.5 + energyJ / 800));
33
34
  var STRIDE_METRES = { walk: 0.72, run: 1.05 };
35
+ var SLAM_HURT_SPEED = 3.5;
34
36
  function createBodySounds(audio) {
35
37
  audio?.register(BODY_FOLEY_BANK);
36
38
  const played = [];
37
39
  const play = (id, position, volume, delay = 0) => {
38
40
  played.push(id);
39
41
  if (played.length > 64) played.shift();
40
- audio?.play(id, { position, volume, ...delay > 0 ? { delay } : {} });
42
+ audio?.play(id, { position, volume, urgent: true, ...delay > 0 ? { delay } : {} });
41
43
  };
42
44
  return {
43
45
  swing(blow, position, delay = 0) {
@@ -49,6 +51,10 @@ function createBodySounds(audio) {
49
51
  },
50
52
  jump: (position) => play("jump", position, 0.6),
51
53
  land: (surface, speed, position) => play(landSoundId(surface, speed), position, Math.min(1, 0.45 + speed / 8)),
54
+ slam(speed, position) {
55
+ play("body_fall", position, Math.min(1, 0.4 + speed / 8));
56
+ if (speed >= SLAM_HURT_SPEED) play("player_hurt", position, Math.min(1, 0.5 + speed / 12));
57
+ },
52
58
  played
53
59
  };
54
60
  }
@@ -73,7 +79,6 @@ function createStrideCounter(stride = STRIDE_METRES) {
73
79
  }
74
80
 
75
81
  // src/room.ts
76
- import { createDestructionRenderer, createDestructionSound, dragSoundKey, impactKey, specFromObject3D } from "@adatechnology/game-destruction-three";
77
82
  import { createArsenal, DEFAULT_LOADOUT } from "@adatechnology/game-weapons";
78
83
  import { createFilmLcg, createFluids, createItemUse, ITEM_USES, resolveStrings as resolveFluidStrings } from "@adatechnology/game-fluids";
79
84
  import { createFilmBridge, createFilmManager, createFluidRenderer } from "@adatechnology/game-fluids-three";
@@ -316,6 +321,38 @@ function createArena(THREE, size, scene, scenery, tapOption) {
316
321
  };
317
322
  }
318
323
 
324
+ // src/hand-sounds.ts
325
+ import { LEAF_FOLEY_SETS } from "@adatechnology/game-audio";
326
+ function pickupClassOf(def) {
327
+ if (!def) return "plastic";
328
+ const tags = def.tags ?? [];
329
+ if (def.id === "paper") return "paper";
330
+ if (tags.includes("metal")) return "metal";
331
+ if (tags.includes("glass") || tags.includes("ceramic")) return "glass";
332
+ if (tags.includes("plastic") || tags.includes("rubber")) return "plastic";
333
+ if (tags.includes("wood")) return "wood";
334
+ if (tags.includes("fabric")) return "fabric";
335
+ if (tags.includes("plant")) return "food";
336
+ return "plastic";
337
+ }
338
+ var pickupSoundOf = (sounds, def) => `item_pickup_${sounds?.handling ?? pickupClassOf(def)}`;
339
+ function leafFoleyOf(sounds, part, impactKey2, slides2) {
340
+ const named = sounds?.leaves?.[part] ?? sounds?.leaves?.["*"];
341
+ const set = named ? LEAF_FOLEY_SETS[named] : void 0;
342
+ if (set) return set;
343
+ if (slides2) return LEAF_FOLEY_SETS.drawer;
344
+ return /metal|glass/.test(impactKey2) ? LEAF_FOLEY_SETS.metal_door : LEAF_FOLEY_SETS.wood_door;
345
+ }
346
+ var openSoundOf = (sounds, def) => sounds?.open ?? (pickupClassOf(def) === "metal" ? "can_open" : "bottle_open");
347
+ var USE_SOUNDS = {
348
+ drink: "drink_gulp",
349
+ sip: "drink_gulp",
350
+ chug: "drink_gulp",
351
+ eat: "eat_chew"
352
+ };
353
+ var USE_SOUND_EVERY = 0.55;
354
+ var useSoundOf = (sounds, useId) => sounds?.uses?.[useId] ?? USE_SOUNDS[useId];
355
+
319
356
  // src/bench.ts
320
357
  var snap = (control, value) => {
321
358
  const clamped = Math.min(control.max, Math.max(control.min, value));
@@ -765,7 +802,7 @@ function createExplosives(context) {
765
802
  if (!detonation.cone) told(results);
766
803
  if (detonation.energy > 0) {
767
804
  const isPlayed = context.effects.explosion(detonation.explosive, detonation.point, { direction: detonation.direction }) !== null;
768
- renderer.explode(detonation.point, detonation.energy, reach, { visual: !isPlayed, report: results });
805
+ renderer.explode(detonation.point, detonation.energy, reach, { visual: !isPlayed, report: results, effect: detonation.explosive });
769
806
  }
770
807
  if (detonation.stunSeconds !== void 0) renderer.emit("flash", detonation.point, [0, 1, 0], 1, 1);
771
808
  const onPlayer = blastEffect(detonation, toArray(camera.position));
@@ -2088,6 +2125,8 @@ function createNarrator(c) {
2088
2125
  }
2089
2126
 
2090
2127
  // src/player.ts
2128
+ import { stepKnock } from "@adatechnology/game-contracts";
2129
+ var SLAM_MIN_SPEED = 1.5;
2091
2130
  var LANDING_MIN_SPEED = 1;
2092
2131
  var EYE = 1.7;
2093
2132
  var PLAYER_RADIUS = 0.3;
@@ -2105,6 +2144,10 @@ var GRAVITY = 13;
2105
2144
  var FLOOR_CLIMB_SPEED = 3;
2106
2145
  var FLOOR_FALL_SPEED = 6;
2107
2146
  var LOOK = 22e-4;
2147
+ var LYING_EYE = 0.3;
2148
+ var LYING_PITCH = 1.05;
2149
+ var LYING_ROLL = 0.35;
2150
+ var LYING_PITCH_FACE_DOWN = -1.05;
2108
2151
  function createPlayer(THREE, o) {
2109
2152
  const cam = o.camera;
2110
2153
  let yaw = 0;
@@ -2142,10 +2185,16 @@ function createPlayer(THREE, o) {
2142
2185
  const reach = (gap > 0 ? FLOOR_CLIMB_SPEED : FLOOR_FALL_SPEED) * dt;
2143
2186
  floorLevel += Math.max(-reach, Math.min(reach, gap));
2144
2187
  };
2188
+ let knocked = null;
2189
+ let knockedForward = false;
2190
+ let rollSide = 1;
2145
2191
  const apply = () => {
2146
- euler.set(pitch, yaw, 0, "YXZ");
2192
+ const lying = knocked?.lying ?? 0;
2193
+ const fallen = lying * lying * (3 - 2 * lying);
2194
+ euler.set(pitch + ((knockedForward ? LYING_PITCH_FACE_DOWN : LYING_PITCH) - pitch) * fallen, yaw, LYING_ROLL * rollSide * fallen, "YXZ");
2147
2195
  cam.quaternion.setFromEuler(euler);
2148
- cam.position.set(px.x, eye + floorLevel + y - CROUCH_DROP * crouch - bend, px.z);
2196
+ const standing = eye - CROUCH_DROP * crouch - bend;
2197
+ cam.position.set(px.x, floorLevel + y + standing + (LYING_EYE - standing) * fallen, px.z);
2149
2198
  cam.updateMatrixWorld(true);
2150
2199
  };
2151
2200
  apply();
@@ -2320,6 +2369,41 @@ function createPlayer(THREE, o) {
2320
2369
  window.addEventListener("keyup", onKeyUp);
2321
2370
  window.addEventListener("blur", onBlur);
2322
2371
  window.addEventListener("beforeunload", onBeforeUnload);
2372
+ const stepKnocked = (dt) => {
2373
+ const k = knocked;
2374
+ if (!k) return;
2375
+ const floor = Math.max(floorHere(), o.footing?.(px.x, px.z, PLAYER_RADIUS * FOOT, y).floor ?? -Infinity);
2376
+ floorUnder = floor;
2377
+ const at = { x: px.x, y, z: px.z };
2378
+ const fromY = y;
2379
+ const fallSpeed = Math.max(0, -k.velocity[1]);
2380
+ const done = stepKnock(k, at, dt, () => floor, true);
2381
+ const wantX = at.x - px.x;
2382
+ const wantZ = at.z - px.z;
2383
+ const fromX = px.x;
2384
+ const fromZ = px.z;
2385
+ const vx = k.velocity[0];
2386
+ const vz = k.velocity[2];
2387
+ move(wantX, wantZ);
2388
+ const stoppedX = Math.abs(wantX) > 1e-6 && Math.abs(px.x - fromX) < Math.abs(wantX) * 0.5;
2389
+ const stoppedZ = Math.abs(wantZ) > 1e-6 && Math.abs(px.z - fromZ) < Math.abs(wantZ) * 0.5;
2390
+ if (stoppedX) k.velocity[0] = 0;
2391
+ if (stoppedZ) k.velocity[2] = 0;
2392
+ const into = Math.hypot(stoppedX ? vx : 0, stoppedZ ? vz : 0);
2393
+ if (into >= SLAM_MIN_SPEED) o.onSlam?.({ against: "obstacle", speed: into, direction: [stoppedX ? vx / into : 0, 0, stoppedZ ? vz / into : 0] });
2394
+ y = at.y;
2395
+ if (fromY > floor && y <= floor) {
2396
+ const speed = Math.max(fallSpeed, (fromY - y) / Math.max(dt, 1e-6));
2397
+ if (k.down && speed >= SLAM_MIN_SPEED * 0.5) o.onSlam?.({ against: "ground", speed: Math.hypot(speed, Math.hypot(vx, vz) * 0.5), direction: [0, -1, 0] });
2398
+ else if (speed >= LANDING_MIN_SPEED) o.onFeet?.({ type: "land", speed });
2399
+ }
2400
+ vy = 0;
2401
+ walked = dt > 0 ? Math.hypot(px.x - fromX, px.z - fromZ) / dt : 0;
2402
+ running = false;
2403
+ if (done) knocked = null;
2404
+ followFloor(dt);
2405
+ apply();
2406
+ };
2323
2407
  return {
2324
2408
  get walkIntent() {
2325
2409
  return walkKeys();
@@ -2375,7 +2459,32 @@ function createPlayer(THREE, o) {
2375
2459
  return isCrouched();
2376
2460
  },
2377
2461
  keys,
2462
+ knock(next) {
2463
+ if (seated) return;
2464
+ if (knocked) {
2465
+ knocked.velocity = [knocked.velocity[0] + next.velocity[0], knocked.velocity[1] + next.velocity[1], knocked.velocity[2] + next.velocity[2]];
2466
+ knocked.airborne || (knocked.airborne = next.airborne);
2467
+ knocked.down || (knocked.down = next.down);
2468
+ } else knocked = { ...next, velocity: [...next.velocity] };
2469
+ if (next.down) {
2470
+ const right = Math.cos(yaw) * next.velocity[0] - Math.sin(yaw) * next.velocity[2];
2471
+ rollSide = right >= 0 ? -1 : 1;
2472
+ knockedForward = -Math.sin(yaw) * next.velocity[0] - Math.cos(yaw) * next.velocity[2] > 0;
2473
+ crouchLatched = false;
2474
+ }
2475
+ if (knocked.airborne) vy = Math.max(vy, knocked.velocity[1]);
2476
+ },
2477
+ get knocked() {
2478
+ return knocked;
2479
+ },
2480
+ get knockedForward() {
2481
+ return knockedForward;
2482
+ },
2378
2483
  update(dt) {
2484
+ if (knocked) {
2485
+ stepKnocked(dt);
2486
+ return;
2487
+ }
2379
2488
  if (crouchPress) crouchPress.held += dt;
2380
2489
  const crouched = isCrouched();
2381
2490
  crouch += ((crouched ? 1 : 0) - crouch) * Math.min(1, dt * CROUCH_RATE);
@@ -2415,6 +2524,7 @@ function createPlayer(THREE, o) {
2415
2524
  apply();
2416
2525
  },
2417
2526
  setPose(position, yw, pt) {
2527
+ knocked = null;
2418
2528
  px.x = position[0];
2419
2529
  px.z = position[2];
2420
2530
  floorLevel = floorHere();
@@ -2426,6 +2536,7 @@ function createPlayer(THREE, o) {
2426
2536
  apply();
2427
2537
  },
2428
2538
  look(position, target) {
2539
+ knocked = null;
2429
2540
  px.x = position[0];
2430
2541
  px.z = position[2];
2431
2542
  floorLevel = floorHere();
@@ -2782,6 +2893,8 @@ var TURN_RATE = 8;
2782
2893
  var MAX_WALK_SECONDS = 1.5;
2783
2894
  var APPROACH_SPEED2 = 3;
2784
2895
  var FLOOR_GAP = 0.03;
2896
+ var LEAF_SLAM_VOLUME = 0.85;
2897
+ var shutVolume = (massKg) => Math.min(1, 0.3 + massKg * 0.03);
2785
2898
  var key = (objectId, part) => `${objectId}\0${part}`;
2786
2899
  var slides = (clip, part) => {
2787
2900
  const last = clip.keys[clip.keys.length - 1]?.parts?.[part];
@@ -3027,7 +3140,7 @@ function createRoomDoors(options) {
3027
3140
  using = { objectId, leaf, obj, grip, pull, phase: control.phase, progress: 0, walked: 0, there: goal === null };
3028
3141
  const at = obj.localToWorld(grip.clone());
3029
3142
  if (goal === null) {
3030
- options.sound("leaf_rattle", [at.x, at.y, at.z], 0.6);
3143
+ options.sound(options.leafFoley?.(objectId, leaf.part, slides(leaf.clip, leaf.part))?.locked ?? "leaf_rattle", [at.x, at.y, at.z], 0.6);
3031
3144
  event("locked", using);
3032
3145
  } else event("reach", using);
3033
3146
  return true;
@@ -3112,17 +3225,27 @@ function createRoomDoors(options) {
3112
3225
  const at = u.obj.localToWorld(tmp.copy(u.grip));
3113
3226
  const point = [at.x, at.y, at.z];
3114
3227
  const slide = slides(u.leaf.clip, u.leaf.part);
3115
- const shut = options.impactKey(u.objectId, u.leaf.part).replace("impact_", "leaf_shut_");
3228
+ const foley = options.leafFoley?.(u.objectId, u.leaf.part, slide) ?? null;
3229
+ const own = (moment) => options.leafSound?.(u.objectId, u.leaf.part, moment);
3230
+ const shut = own("shut") ?? options.impactKey(u.objectId, u.leaf.part).replace("impact_", "leaf_shut_");
3116
3231
  if (wasPhase === "reach" && s.phase !== "reach") {
3117
- if (slide) options.sound("leaf_slide", point, 0.5);
3118
- else {
3232
+ const opening = foley ? void 0 : own("open");
3233
+ if (foley) {
3234
+ if (was < 0.02) options.sound(foley.open, point, 0.5);
3235
+ if (foley.turn) options.sound(foley.turn, point, 0.35);
3236
+ } else if (slide) options.sound("leaf_slide", point, 0.5);
3237
+ else if (opening) {
3238
+ if (was < 0.02) options.sound(opening, point, 0.5);
3239
+ } else {
3119
3240
  if (was < 0.02) options.sound("leaf_latch", point, 0.5);
3120
3241
  options.sound(/metal|glass/.test(shut) ? "leaf_creak_metal" : "leaf_creak_wood", point, 0.35);
3121
3242
  }
3122
3243
  }
3123
3244
  if (s.arrived) {
3124
3245
  if (s.opening <= 0) {
3125
- options.sound(slide ? "leaf_slide" : shut, point, Math.min(1, 0.3 + massOf(u.objectId, u.leaf.part) * 0.03));
3246
+ const volume = shutVolume(massOf(u.objectId, u.leaf.part));
3247
+ if (foley) options.sound(foley.slam && volume >= LEAF_SLAM_VOLUME ? foley.slam : foley.close, point, volume);
3248
+ else options.sound(slide ? "leaf_slide" : shut, point, volume);
3126
3249
  event("closed", u);
3127
3250
  } else event("opened", u);
3128
3251
  }
@@ -3393,6 +3516,10 @@ var en = {
3393
3516
  "loading.open_area": "Opening {area}\u2026",
3394
3517
  "loading.spawn": "Building {name}\u2026",
3395
3518
  "act.loosened": "{name} was heavy but free: the blast moved it",
3519
+ "act.knocked_down": "The blast threw the player off their feet at {speed} m/s ({reach}% got through the cover)",
3520
+ "act.player_slammed": "The player was thrown into {what} at {speed} m/s",
3521
+ "act.player_fell": "The player hit the floor at {speed} m/s",
3522
+ "act.player_shoved": "The blast pushed the player at {speed} m/s ({reach}% got through the cover)",
3396
3523
  "act.came_apart": "{name} came apart: what stayed is loose and falls",
3397
3524
  "act.shoved": "{name} stands free: the shots moved it",
3398
3525
  "act.impact": "{name} hit {where} at {speed} m/s: {energy} J of blunt damage",
@@ -3751,6 +3878,10 @@ var ptBR = {
3751
3878
  "loading.open_area": "Abrindo {area}\u2026",
3752
3879
  "loading.spawn": "Montando {name}\u2026",
3753
3880
  "act.loosened": "{name} \xE9 pesado mas estava solto: a explos\xE3o o moveu",
3881
+ "act.knocked_down": "A explos\xE3o derrubou o jogador a {speed} m/s ({reach}% passou pela cobertura)",
3882
+ "act.player_slammed": "O jogador foi arremessado contra {what} a {speed} m/s",
3883
+ "act.player_fell": "O jogador bateu no ch\xE3o a {speed} m/s",
3884
+ "act.player_shoved": "A explos\xE3o empurrou o jogador a {speed} m/s ({reach}% passou pela cobertura)",
3754
3885
  "act.came_apart": "{name} se desmontou: o que ficou est\xE1 solto e cai",
3755
3886
  "act.shoved": "{name} estava solto no ch\xE3o: os tiros o moveram",
3756
3887
  "act.impact": "{name} bateu em {where} a {speed} m/s: {energy} J de dano de impacto",
@@ -4576,9 +4707,16 @@ function createUi(o) {
4576
4707
  nodes.delete(oldest.value);
4577
4708
  logBody.firstElementChild?.remove();
4578
4709
  }
4579
- if (autoscroll) logBody.scrollTop = logBody.scrollHeight;
4710
+ if (autoscroll) isScrollDue = true;
4580
4711
  }
4712
+ let isScrollDue = false;
4713
+ const scrollToEnd = () => {
4714
+ if (!isScrollDue) return;
4715
+ isScrollDue = false;
4716
+ logBody.scrollTop = logBody.scrollHeight;
4717
+ };
4581
4718
  const flushLog = () => {
4719
+ scrollToEnd();
4582
4720
  if (pendingAdds.length === 0 && pendingUpdates.size === 0) return;
4583
4721
  if (!isLogVisible()) {
4584
4722
  isLogStale = true;
@@ -4971,6 +5109,134 @@ function createUi(o) {
4971
5109
  return api;
4972
5110
  }
4973
5111
 
5112
+ // src/module-views.ts
5113
+ var FRAME_PX = 3;
5114
+ function createModuleViews(THREE, gl) {
5115
+ const entries = [];
5116
+ let mainCamera = null;
5117
+ const overlay = new THREE.Scene();
5118
+ const ortho = new THREE.OrthographicCamera(-0.5, 0.5, 0.5, -0.5, 0, 2);
5119
+ ortho.position.z = 1;
5120
+ const plane = new THREE.PlaneGeometry(1, 1);
5121
+ const roundedMask = (rounded, aspect) => {
5122
+ if (!rounded || typeof document === "undefined") return null;
5123
+ const canvas = document.createElement("canvas");
5124
+ canvas.width = 256;
5125
+ canvas.height = Math.max(16, Math.round(256 / aspect));
5126
+ const g = canvas.getContext("2d");
5127
+ if (!g) return null;
5128
+ const r = Math.min(canvas.width, canvas.height) * Math.min(0.5, rounded);
5129
+ g.fillStyle = "#000";
5130
+ g.fillRect(0, 0, canvas.width, canvas.height);
5131
+ g.fillStyle = "#fff";
5132
+ g.beginPath();
5133
+ g.roundRect(0, 0, canvas.width, canvas.height, r);
5134
+ g.fill();
5135
+ const texture = new THREE.CanvasTexture(canvas);
5136
+ texture.colorSpace = THREE.NoColorSpace;
5137
+ return texture;
5138
+ };
5139
+ const size = () => {
5140
+ if (typeof gl.getSize !== "function") return { w: 960, h: 600 };
5141
+ const v = new THREE.Vector2();
5142
+ gl.getSize(v);
5143
+ return { w: v.x, h: v.y };
5144
+ };
5145
+ const take = (entry) => {
5146
+ const i = entries.indexOf(entry);
5147
+ if (i < 0) return;
5148
+ entries.splice(i, 1);
5149
+ overlay.remove(entry.quad, entry.frame);
5150
+ entry.target.dispose();
5151
+ entry.quad.material.dispose();
5152
+ entry.frame.material.dispose();
5153
+ entry.alpha?.dispose();
5154
+ };
5155
+ return {
5156
+ add(view) {
5157
+ const { w, h } = size();
5158
+ const scale = view.resolution ?? 0.5;
5159
+ const pr = typeof gl.getPixelRatio === "function" ? gl.getPixelRatio() : 1;
5160
+ const target = new THREE.WebGLRenderTarget(Math.max(16, Math.round(w * view.rect.width * pr * scale)), Math.max(16, Math.round(h * view.rect.height * pr * scale)), { samples: 2 });
5161
+ target.texture.colorSpace = THREE.LinearSRGBColorSpace;
5162
+ const alpha = roundedMask(view.rounded ?? 0, w * view.rect.width / Math.max(1, h * view.rect.height));
5163
+ const quad = new THREE.Mesh(plane, new THREE.MeshBasicMaterial({ map: target.texture, side: THREE.DoubleSide, depthTest: false, depthWrite: false, ...alpha ? { alphaMap: alpha, transparent: true } : {} }));
5164
+ const frame = new THREE.Mesh(plane, new THREE.MeshBasicMaterial({ color: 723982, side: THREE.DoubleSide, depthTest: false, depthWrite: false, ...alpha ? { alphaMap: alpha, transparent: true } : {} }));
5165
+ quad.renderOrder = 1;
5166
+ frame.visible = quad.visible = false;
5167
+ overlay.add(frame, quad);
5168
+ const entry = { view, target, quad, frame, alpha };
5169
+ entries.push(entry);
5170
+ return () => take(entry);
5171
+ },
5172
+ setMain(camera) {
5173
+ mainCamera = camera;
5174
+ },
5175
+ main(fallback) {
5176
+ if (!mainCamera) return fallback;
5177
+ const persp = mainCamera;
5178
+ const back = fallback;
5179
+ if (persp.isPerspectiveCamera && back.isPerspectiveCamera && persp.aspect !== back.aspect) {
5180
+ persp.aspect = back.aspect;
5181
+ persp.updateProjectionMatrix();
5182
+ }
5183
+ return mainCamera;
5184
+ },
5185
+ render(scene) {
5186
+ if (!entries.length) return;
5187
+ const { w, h } = size();
5188
+ const autoClear = gl.autoClear;
5189
+ const shadows = gl.shadowMap.autoUpdate;
5190
+ gl.shadowMap.autoUpdate = false;
5191
+ for (const entry of entries) {
5192
+ const cam = entry.view.camera;
5193
+ const aspect = w * entry.view.rect.width / Math.max(1, h * entry.view.rect.height);
5194
+ if (cam.isPerspectiveCamera && Math.abs(cam.aspect - aspect) > 1e-3) {
5195
+ cam.aspect = aspect;
5196
+ cam.updateProjectionMatrix();
5197
+ }
5198
+ gl.setRenderTarget(entry.target);
5199
+ gl.clear();
5200
+ gl.render(scene, cam);
5201
+ }
5202
+ gl.setRenderTarget(null);
5203
+ gl.autoClear = false;
5204
+ gl.setScissorTest(true);
5205
+ for (const entry of entries) {
5206
+ const r = entry.view.rect;
5207
+ const x = r.x * w;
5208
+ const y = (1 - r.y - r.height) * h;
5209
+ const vw = r.width * w;
5210
+ const vh = r.height * h;
5211
+ for (const mesh of [entry.frame, entry.quad]) mesh.visible = false;
5212
+ entry.frame.visible = true;
5213
+ gl.setViewport(x - FRAME_PX, y - FRAME_PX, vw + 2 * FRAME_PX, vh + 2 * FRAME_PX);
5214
+ gl.setScissor(x - FRAME_PX, y - FRAME_PX, vw + 2 * FRAME_PX, vh + 2 * FRAME_PX);
5215
+ gl.render(overlay, ortho);
5216
+ entry.frame.visible = false;
5217
+ entry.quad.visible = true;
5218
+ entry.quad.scale.x = entry.view.mirror ? -1 : 1;
5219
+ gl.setViewport(x, y, vw, vh);
5220
+ gl.setScissor(x, y, vw, vh);
5221
+ gl.render(overlay, ortho);
5222
+ entry.quad.visible = false;
5223
+ }
5224
+ gl.setScissorTest(false);
5225
+ gl.setViewport(0, 0, w, h);
5226
+ gl.autoClear = autoClear;
5227
+ gl.shadowMap.autoUpdate = shadows;
5228
+ },
5229
+ get count() {
5230
+ return entries.length;
5231
+ },
5232
+ dispose() {
5233
+ for (const entry of [...entries]) take(entry);
5234
+ plane.dispose();
5235
+ mainCamera = null;
5236
+ }
5237
+ };
5238
+ }
5239
+
4974
5240
  // src/room.ts
4975
5241
  var FIXED_DT = 1 / 60;
4976
5242
  var EXPLOSIVE_EFFECT_IDS = ["m67", "molotov", "pipe_bomb", "m79_40mm", "m72_66mm", "at4_84mm", "dynamite", "c4", "claymore"];
@@ -4999,6 +5265,12 @@ var CAP_RISE = 0.03;
4999
5265
  var MAX_STRIDE_SPEED = 10;
5000
5266
  var v3 = (v) => [v.x, v.y, v.z];
5001
5267
  var dist3 = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
5268
+ var vehicleLeafKey = (spec, part, moment) => {
5269
+ const tags = spec.parts.find((p) => p.id === part)?.tags ?? [];
5270
+ const leaf = tags.includes("hood") ? "hood" : tags.includes("lid") || tags.includes("trunk") ? "trunk" : "door";
5271
+ return `vehicle_${leaf}_${moment}`;
5272
+ };
5273
+ var MARK_EVENTS = /* @__PURE__ */ new Set(["dent", "hole", "crack", "chip", "stage", "fracture"]);
5002
5274
  function createTestRoom(options) {
5003
5275
  const THREE = options.THREE;
5004
5276
  const locale = options.locale ?? "en";
@@ -5045,6 +5317,7 @@ function createTestRoom(options) {
5045
5317
  };
5046
5318
  const onResize = () => resize();
5047
5319
  window.addEventListener("resize", onResize);
5320
+ const moduleViews = createModuleViews(THREE, gl);
5048
5321
  const arenaSize = options.arenaSize ?? 14;
5049
5322
  const arena = createArena(THREE, arenaSize, scene, options.scenery, options.tap);
5050
5323
  scene.add(arena.group);
@@ -5104,10 +5377,23 @@ function createTestRoom(options) {
5104
5377
  const ownAudio = options.audio === void 0;
5105
5378
  const audio = options.audio === false ? null : options.audio ?? createAudioEngine({ deferredStart: {} });
5106
5379
  const destructionSound = audio ? createDestructionSound(audio) : null;
5380
+ if (audio && options.samplesBaseUrl !== null) {
5381
+ void loadKitSamples(audio, { baseUrl: options.samplesBaseUrl ?? "./sfx/" }).then((bank) => {
5382
+ if (!bank || typeof window === "undefined") return;
5383
+ const preload = () => {
5384
+ window.removeEventListener("keydown", preload, true);
5385
+ window.removeEventListener("pointerdown", preload, true);
5386
+ void audio.loadSamples(["latch", "creak_wood", "shut_wood", "rattle", "grab_hard", "grab_soft"]).catch(() => void 0);
5387
+ };
5388
+ window.addEventListener("keydown", preload, true);
5389
+ window.addEventListener("pointerdown", preload, true);
5390
+ });
5391
+ }
5107
5392
  const bodySounds = createBodySounds(audio);
5108
- const playSound = (key2, point, volume) => {
5109
- destructionSound?.onSound(key2, point, volume);
5110
- options.onSound?.(key2, point, volume);
5393
+ const playSound = (key2, point, volume, detail) => {
5394
+ destructionSound?.onSound(key2, point, volume, detail);
5395
+ if (detail) options.onSound?.(key2, point, volume, detail);
5396
+ else options.onSound?.(key2, point, volume);
5111
5397
  };
5112
5398
  const lights = createLightBudget(THREE, scene, { ...ROOM_LIGHT_SIZES, ...options.lightSizes });
5113
5399
  const warmup = createProgramWarmup(THREE);
@@ -5177,7 +5463,7 @@ function createTestRoom(options) {
5177
5463
  const size = Math.min(GROUND_BURST_MAX, Math.ceil(result.radius * GROUND_BURST_PER_METRE));
5178
5464
  renderer.emit(material.debris === "clods" ? "dust" : material.debris, result.point, [0, 1, 0], Math.max(2, size), Math.min(4, 1 + result.radius * 4));
5179
5465
  if (result.radius > GROUND_DUST_RADIUS) renderer.emit("dust", result.point, [0, 1, 0], size, 1.4);
5180
- playSound(impactKey(def), result.point, Math.min(1, 0.25 + result.radius));
5466
+ playSound(impactKey(def), result.point, Math.min(1, 0.25 + result.radius), impactSoundKeys(material.id, def));
5181
5467
  };
5182
5468
  terrain.onDeform((region) => renderer.wakeRegion(region.x, region.z, region.radius));
5183
5469
  const groundImpl = createTerrainImpl(terrain, { id: GROUND_ID, name: t("ground.name"), onBlow: showGroundBlow });
@@ -5405,6 +5691,7 @@ function createTestRoom(options) {
5405
5691
  floorAt: terrain.heightAt,
5406
5692
  start: arena.playerStart,
5407
5693
  crouchKeys: options.crouch?.keys,
5694
+ onSlam: (event) => slamPlayer(event),
5408
5695
  onFeet: (event) => {
5409
5696
  const at = player.position;
5410
5697
  if (event.type === "jump") bodySounds.jump(at);
@@ -5484,7 +5771,11 @@ function createTestRoom(options) {
5484
5771
  return piece;
5485
5772
  },
5486
5773
  dragSound: (objectId, point, volume) => playSound(dragSoundKey(world.materials.get(heaviestMaterial(objectId) ?? "")), point, volume),
5487
- hitSound: (objectId, point) => playSound(impactKey(world.materials.get(heaviestMaterial(objectId) ?? "")), point, 0.5),
5774
+ hitSound: (objectId, point) => {
5775
+ const material = heaviestMaterial(objectId) ?? "";
5776
+ const def = world.materials.get(material);
5777
+ playSound(impactKey(def), point, 0.5, impactSoundKeys(material, def, "blunt"));
5778
+ },
5488
5779
  onEvent: (ev) => {
5489
5780
  const vars = { name: ev.name, kg: num(ev.massKg, 0) };
5490
5781
  action(`push_${ev.type}`, t(`act.push_${ev.type}`, vars), { objectId: ev.objectId, itemId: entryOf(ev.objectId)?.so.itemId, data: { massKg: Math.round(ev.massKg * 10) / 10 } });
@@ -5574,7 +5865,13 @@ function createTestRoom(options) {
5574
5865
  isLocked: (objectId, part) => lockedLeaves.has(`${objectId}\0*`) || lockedLeaves.has(`${objectId}\0${part}`),
5575
5866
  nameOf,
5576
5867
  impactKey: (objectId, part) => impactKey(world.materials.get(world.part(objectId, part)?.material ?? heaviestMaterial(objectId) ?? "")),
5868
+ leafSound: (objectId, part, moment) => {
5869
+ const spec = specs.get(objectId);
5870
+ return spec?.tags?.includes("vehicle") ? vehicleLeafKey(spec, part, moment) : void 0;
5871
+ },
5577
5872
  sound: playSound,
5873
+ // a vehicle's doors, hood and lid have their own sounds in the vehicle bank (game-vehicles-three), not those of the house
5874
+ leafFoley: (objectId, part, slides2) => specs.get(objectId)?.tags?.includes("vehicle") ? null : leafFoleyOf(entryOf(objectId)?.item?.sounds, part, impactKey(world.materials.get(world.part(objectId, part)?.material ?? heaviestMaterial(objectId) ?? "")), slides2),
5578
5875
  onEvent: (ev) => {
5579
5876
  const vars = { name: ev.name, percent: Math.round(ev.opening * 100) };
5580
5877
  action(`door_${ev.type}`, t(`act.door_${ev.type}`, vars), { objectId: ev.objectId, itemId: entryOf(ev.objectId)?.so.itemId, part: ev.part, data: { opening: Math.round(ev.opening * 1e3) / 1e3, ...ev.by ? { by: ev.by } : {} } });
@@ -5903,6 +6200,7 @@ function createTestRoom(options) {
5903
6200
  explosives.clear();
5904
6201
  fireZones.length = 0;
5905
6202
  burningStains.length = 0;
6203
+ destructionSound?.reset();
5906
6204
  terrain.setLayout(defaultGround);
5907
6205
  for (const id of [...entries.keys()]) if (!entries.get(id)?.arena) removeEntry(id);
5908
6206
  clearLoose();
@@ -5983,6 +6281,37 @@ function createTestRoom(options) {
5983
6281
  let avatar = null;
5984
6282
  let cameraMode = options.cameraMode ?? "first";
5985
6283
  let crouchForced = false;
6284
+ let downForced = false;
6285
+ const playerMass = options.playerMass ?? bodyMass(1.75);
6286
+ const SLAM_REACH = PLAYER_RADIUS + 0.35;
6287
+ const SLAM_SHOULDER = 1.3;
6288
+ const slamPlayer = (event) => {
6289
+ const [x, feet, z] = player.position;
6290
+ const energy = 0.5 * playerMass * event.speed * event.speed;
6291
+ let hit = null;
6292
+ if (event.against === "obstacle") {
6293
+ const from = [x, feet + ((player.knocked?.lying ?? 0) > 0.5 ? 0.3 : SLAM_SHOULDER), z];
6294
+ hit = rayWorld(from, event.direction, SLAM_REACH);
6295
+ if (hit) world.hitPart(hit.object, hit.part, { type: "blunt", point: hit.point, direction: event.direction, energy, radius: 0.2, ...hit.normal ? { normal: hit.normal } : {} });
6296
+ }
6297
+ bodySounds.slam(event.speed, [x, feet + 0.5, z]);
6298
+ action("player_slammed", t(event.against === "obstacle" ? "act.player_slammed" : "act.player_fell", { speed: num(event.speed, 1), what: hit ? nameOf(hit.object) : "" }), {
6299
+ ...hit ? { objectId: hit.object, part: hit.part } : {},
6300
+ data: { against: event.against, speed: event.speed, energy, ...hit ? { object: hit.object, part: hit.part } : {} }
6301
+ });
6302
+ };
6303
+ const knockPlayer = (event) => {
6304
+ if (seating.seat) return;
6305
+ const feet = [player.x, player.feet, player.z];
6306
+ const reach = world.blastReach(event.point, [feet[0], feet[1] + KNOCK_CHEST, feet[2]], event.source ? { ignore: [event.source] } : {});
6307
+ const knock = knockOf({ point: event.point, energy: event.energy, radius: event.radius, reach }, feet, playerMass, options.blastShove);
6308
+ if (!knock) return;
6309
+ player.knock(knock);
6310
+ const speed = Math.hypot(knock.velocity[0], knock.velocity[1], knock.velocity[2]);
6311
+ action(knock.down ? "player_knocked_down" : "player_shoved", t(knock.down ? "act.knocked_down" : "act.player_shoved", { speed: num(speed, 1), reach: num(reach * 100, 0) }), {
6312
+ data: { speed, reach, down: knock.down, source: event.source }
6313
+ });
6314
+ };
5986
6315
  const avatarState = { speed: 0, running: false, crouch: false, down: false, armed: false, pitch: 0 };
5987
6316
  let feetPrev = null;
5988
6317
  let armedForced = null;
@@ -6038,6 +6367,8 @@ function createTestRoom(options) {
6038
6367
  feetPrev = [x, z];
6039
6368
  avatarState.running = !player.crouching && (player.keys.has("ShiftLeft") || player.keys.has("ShiftRight"));
6040
6369
  avatarState.crouch = crouchForced || player.crouching || push.isBent;
6370
+ avatarState.down = downForced || Boolean(player.knocked?.down || (player.knocked?.lying ?? 0) > 0.5);
6371
+ avatarState.fallForward = downForced ? void 0 : player.knockedForward;
6041
6372
  avatarState.armed = armedForced ?? tool.def.action.kind === "firearm";
6042
6373
  avatarState.pitch = player.pitch;
6043
6374
  avatarState.holding = heldPiece !== null;
@@ -6110,14 +6441,18 @@ function createTestRoom(options) {
6110
6441
  if (!avatar?.reach) return;
6111
6442
  avatar.reach(targets, { camera, keepOutside: { contact: (origin, direction, maxDistance) => rayWorld(origin, direction, maxDistance)?.distance ?? null } });
6112
6443
  };
6444
+ const thirdEuler = new THREE.Euler(0, 0, 0, "YXZ");
6113
6445
  const applyThirdView = () => {
6114
6446
  if (!thirdActive()) return () => void 0;
6115
6447
  const home = camera.position.clone();
6448
+ const turn = camera.quaternion.clone();
6116
6449
  const pose = thirdPose(0);
6117
6450
  camera.position.set(pose.position[0], pose.position[1], pose.position[2]);
6451
+ if (player.knocked) camera.quaternion.setFromEuler(thirdEuler.set(player.pitch, player.yaw, 0, "YXZ"));
6118
6452
  camera.updateMatrixWorld(true);
6119
6453
  return () => {
6120
6454
  camera.position.copy(home);
6455
+ camera.quaternion.copy(turn);
6121
6456
  camera.updateMatrixWorld(true);
6122
6457
  };
6123
6458
  };
@@ -6233,6 +6568,15 @@ function createTestRoom(options) {
6233
6568
  };
6234
6569
  const fireZones = [];
6235
6570
  const burningStains = [];
6571
+ const poolFire = (point, burning) => playSound(burning ? "burn_loop" : "burn_end", point, 0.5, { fuel: "liquid" });
6572
+ const dropZone = (i) => {
6573
+ const [zone] = fireZones.splice(i, 1);
6574
+ if (zone) poolFire(zone.point, false);
6575
+ };
6576
+ const dropStain = (i) => {
6577
+ const [stain] = burningStains.splice(i, 1);
6578
+ if (stain) poolFire(stain.center, false);
6579
+ };
6236
6580
  const stainRadius = (s) => {
6237
6581
  let sum = 0;
6238
6582
  for (const r of s.front) sum += r;
@@ -6249,6 +6593,7 @@ function createTestRoom(options) {
6249
6593
  if (stainBurning(s)) continue;
6250
6594
  const seconds = Math.max(3, Math.min(20, 4 + s.liters * 30 + stainRadius(s) * 6));
6251
6595
  burningStains.push({ center: [s.center[0], s.center[1], s.center[2]], remaining: seconds, acc: 0 });
6596
+ poolFire([s.center[0], s.center[1], s.center[2]], true);
6252
6597
  lit = true;
6253
6598
  log.add({
6254
6599
  channel: "event",
@@ -6279,7 +6624,7 @@ function createTestRoom(options) {
6279
6624
  const a = rnd() * Math.PI * 2;
6280
6625
  const r = Math.sqrt(rnd()) * z.radius;
6281
6626
  renderer.emit("fire", [z.point[0] + Math.cos(a) * r, z.point[1] + 0.05, z.point[2] + Math.sin(a) * r], [0, 1, 0], 2, 1.5);
6282
- if (z.remaining <= 0) fireZones.splice(i, 1);
6627
+ if (z.remaining <= 0) dropZone(i);
6283
6628
  }
6284
6629
  if (burningStains.length) {
6285
6630
  const stains = fluids.stains();
@@ -6300,7 +6645,7 @@ function createTestRoom(options) {
6300
6645
  if (b.remaining <= 0 || !st) {
6301
6646
  fluids.clearStains(b.center, r + 0.1);
6302
6647
  log.add({ channel: "event", type: "stain_burned", text: t("ev.stain_burned"), data: { point: b.center } });
6303
- burningStains.splice(i, 1);
6648
+ dropStain(i);
6304
6649
  }
6305
6650
  }
6306
6651
  }
@@ -6499,7 +6844,13 @@ function createTestRoom(options) {
6499
6844
  action("melee", t("act.melee.miss", { weapon: w }), { data: { weapon: id } });
6500
6845
  return;
6501
6846
  }
6502
- world.hitPart(r.object, r.part, { ...useToHit(u, r.point, r.normal), source: "player" });
6847
+ const hit = useToHit(u, r.point, r.normal);
6848
+ const result = world.hitPart(r.object, r.part, { ...hit, source: "player" });
6849
+ if (!result.events.some((e) => MARK_EVENTS.has(e.type))) {
6850
+ const material = world.part(r.object, r.part)?.material ?? heaviestMaterial(r.object) ?? "";
6851
+ const def = world.materials.get(material);
6852
+ playSound(impactKey(def), r.point, Math.min(0.8, 0.25 + u.energy / 400), impactSoundKeys(material, def, hit.type));
6853
+ }
6503
6854
  const blow = blowOfTool(id, toolDef(id)?.category ?? "");
6504
6855
  if (blow) bodySounds.hit(blow, blowTarget(r), r.point, u.energy);
6505
6856
  pushPiece(r.object, u.direction, 2 * u.energy / 6, r.point);
@@ -6631,8 +6982,8 @@ function createTestRoom(options) {
6631
6982
  hit.add(r.object);
6632
6983
  world.extinguish(r.object, void 0, u.coolC * Math.max(u.duration, 0.05) * 6);
6633
6984
  fluids.clearStains(r.point, 0.5);
6634
- for (let i = burningStains.length - 1; i >= 0; i--) if (Math.hypot(burningStains[i].center[0] - r.point[0], burningStains[i].center[2] - r.point[2]) < 1.2) burningStains.splice(i, 1);
6635
- for (let i = fireZones.length - 1; i >= 0; i--) if (Math.hypot(fireZones[i].point[0] - r.point[0], fireZones[i].point[2] - r.point[2]) < 1.5) fireZones.splice(i, 1);
6985
+ for (let i = burningStains.length - 1; i >= 0; i--) if (Math.hypot(burningStains[i].center[0] - r.point[0], burningStains[i].center[2] - r.point[2]) < 1.2) dropStain(i);
6986
+ for (let i = fireZones.length - 1; i >= 0; i--) if (Math.hypot(fireZones[i].point[0] - r.point[0], fireZones[i].point[2] - r.point[2]) < 1.5) dropZone(i);
6636
6987
  }
6637
6988
  });
6638
6989
  log.coalesce(
@@ -6643,7 +6994,9 @@ function createTestRoom(options) {
6643
6994
  };
6644
6995
  const startFire = (weapon, at, fire2, impact) => {
6645
6996
  fluids.splash("fuel", [at[0], Math.max(at[1], groundAt(at[0], at[2])), at[2]], 0.5);
6646
- 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 });
6997
+ const zone = { 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 };
6998
+ fireZones.push(zone);
6999
+ poolFire(zone.point, true);
6647
7000
  renderer.emit("fire", at, [0, 1, 0], 24, 3);
6648
7001
  action("molotov_fire", t("act.molotov_fire", { weapon: toolName(weapon), seconds: num(fire2.seconds, 0), radius: num(fire2.radius, 1) }), { data: { weapon, impact } });
6649
7002
  };
@@ -6857,7 +7210,9 @@ function createTestRoom(options) {
6857
7210
  heldYaw = 0;
6858
7211
  heldTilt = 0;
6859
7212
  heldVel[0] = heldVel[1] = heldVel[2] = 0;
7213
+ playSound(pickupSoundOf(heldEntry?.item?.sounds, world.materials.get(heaviestMaterial(r.object) ?? "")), r.point, Math.min(1, 0.45 + kg * 0.1));
6860
7214
  action("grab", t("act.grab", { name: heldName, kg: num(kg, 1) }), { objectId: r.object, itemId: entryOf(r.object)?.so.itemId, data: { massKg: Math.round(kg * 10) / 10 } });
7215
+ playSound(impactKey(world.materials.get(heaviestMaterial(r.object) ?? "")).replace("impact_", "grab_"), v3(piece.root.getWorldPosition(tmpV)), 0.8);
6861
7216
  fire("grab", { held: true, objectId: r.object });
6862
7217
  return true;
6863
7218
  };
@@ -6870,6 +7225,7 @@ function createTestRoom(options) {
6870
7225
  releaseHeld();
6871
7226
  piece.velocity = [0, 0, 0];
6872
7227
  renderer.impulse(id, [aimD[0] * speed * piece.mass, (aimD[1] * speed + 1.2) * piece.mass, aimD[2] * speed * piece.mass]);
7228
+ playSound("item_throw", [piece.root.position.x, piece.root.position.y, piece.root.position.z], Math.min(1, 0.3 + speed * 0.04));
6873
7229
  action("throw", t("act.throw", { name, speed: num(speed, 1) }), { objectId: id, itemId: entryOf(id)?.so.itemId, data: { speedMs: speed, massKg: piece.mass } });
6874
7230
  fire("throw", { objectId: id, speed });
6875
7231
  return true;
@@ -6981,6 +7337,7 @@ function createTestRoom(options) {
6981
7337
  e.opened = true;
6982
7338
  if (e.containerId) fluids.open(e.containerId);
6983
7339
  seal.open?.(wrapped.get(e.so.root) ?? e.so.root);
7340
+ playSound(openSoundOf(e.item?.sounds, world.materials.get(heaviestMaterial(e.so.objectId) ?? "")), v3(e.so.root.getWorldPosition(tmpV)), 0.7);
6984
7341
  const tool2 = opener.tool ? catalogMap.get(opener.tool)?.name ?? (arsenal.weapons.has(opener.tool) ? toolName(opener.tool) : opener.tool) : void 0;
6985
7342
  action("open_seal", tool2 ? t("act.open_with", { name: e.name, tool: tool2 }) : t("act.open_by_hand", { name: e.name }), { objectId: e.so.objectId, itemId: e.so.itemId, data: { tool: opener.tool ?? "hand" } });
6986
7343
  return true;
@@ -7029,17 +7386,28 @@ function createTestRoom(options) {
7029
7386
  if (consuming && c.closed) fluids.open(e.containerId);
7030
7387
  useItemPlayer = createItemUse(fluids, e.containerId, def);
7031
7388
  useItemName = def.id;
7389
+ useSoundIn = 0;
7032
7390
  if (eating) useLiters = c.liters;
7033
7391
  action("use_item", t("act.use_item", { name: e.name, use: S.text[`use.${def.id}`] ?? def.id }), { objectId: e.so.objectId, itemId: e.so.itemId, data: { use: def.id } });
7034
7392
  return true;
7035
7393
  };
7036
7394
  let useLiters = null;
7395
+ let useSoundIn = 0;
7037
7396
  const updateItemUse = (dt) => {
7038
7397
  if (!useItemPlayer) return;
7039
7398
  const e = heldEntry;
7040
7399
  const c = e?.containerId ? fluids.container(e.containerId) : void 0;
7041
7400
  const before = c?.liters;
7042
7401
  useItemPlayer.update(dt);
7402
+ const useSound = e && useLiters !== null ? useSoundOf(e.item?.sounds, useItemName) : void 0;
7403
+ if (useSound && e) {
7404
+ useSoundIn -= dt;
7405
+ const now = e.containerId ? fluids.container(e.containerId)?.liters : void 0;
7406
+ if (useSoundIn <= 0 && before !== void 0 && now !== void 0 && now < before) {
7407
+ playSound(useSound, v3(e.so.root.getWorldPosition(tmpV)), 0.6);
7408
+ useSoundIn = USE_SOUND_EVERY;
7409
+ }
7410
+ }
7043
7411
  const seal = e?.item?.seal;
7044
7412
  twisting = null;
7045
7413
  if (e && seal?.openUse === useItemName && !e.opened) {
@@ -7249,7 +7617,9 @@ function createTestRoom(options) {
7249
7617
  if (!loadout.includes(weaponId)) loadout.push(weaponId);
7250
7618
  setTool(weaponId);
7251
7619
  };
7620
+ let hudAimHit;
7252
7621
  const inspectTarget = () => {
7622
+ if (hudAimHit !== void 0) return hudAimHit;
7253
7623
  aim();
7254
7624
  return rayWorld(aimO, aimD, 14);
7255
7625
  };
@@ -7434,6 +7804,7 @@ function createTestRoom(options) {
7434
7804
  }
7435
7805
  syncLiters();
7436
7806
  renderer.update(dt);
7807
+ destructionSound?.update(dt);
7437
7808
  for (const e of entries.values()) e.follower?.update?.();
7438
7809
  terrainView.sync(camera.position);
7439
7810
  showGroundFire(dt);
@@ -7490,12 +7861,12 @@ function createTestRoom(options) {
7490
7861
  const updatePresent = (dt) => {
7491
7862
  reachAvatar();
7492
7863
  if (progress && simTime - progress.at > 0.25) progress = null;
7864
+ ui?.flushLog();
7493
7865
  hudAcc += dt;
7494
7866
  if (hudAcc >= 0.1 && ui) {
7495
7867
  hudAcc = 0;
7496
7868
  ui.hud(hudState());
7497
7869
  }
7498
- ui?.flushLog();
7499
7870
  metricsAcc += dt;
7500
7871
  if (metricsAcc >= METRICS_INTERVAL_S && ui) {
7501
7872
  metricsAcc = 0;
@@ -7503,6 +7874,14 @@ function createTestRoom(options) {
7503
7874
  }
7504
7875
  };
7505
7876
  const hudState = () => {
7877
+ hudAimHit = inspectTarget();
7878
+ try {
7879
+ return readHudState();
7880
+ } finally {
7881
+ hudAimHit = void 0;
7882
+ }
7883
+ };
7884
+ const readHudState = () => {
7506
7885
  const page = Math.floor(toolIndex / 10);
7507
7886
  const slots = loadout.slice(page * 10, page * 10 + 10).map((id) => arsenal.name(id));
7508
7887
  const ammo = tool.def.action.kind === "firearm" ? `${tool.ammo}/${tool.capacity}${tool.reserve ? ` \xB7 ${tool.reserve}` : ""}` : tool.def.action.kind === "hand" ? "" : tool.def.action.kind === "melee" || tool.def.action.kind === "tool" ? "" : `${num(tool.ammo, 0)}`;
@@ -7693,6 +8072,7 @@ function createTestRoom(options) {
7693
8072
  const environment = options.environment ?? createEnvironmentBus();
7694
8073
  const audioSink = audio ? createAudioSink(audio) : void 0;
7695
8074
  function emitHost(event) {
8075
+ if (event.type === "explosion") knockPlayer(event);
7696
8076
  for (const listener of hostListeners) listener(event);
7697
8077
  }
7698
8078
  function flushEvents(id, state, recoil) {
@@ -7752,7 +8132,11 @@ function createTestRoom(options) {
7752
8132
  lights,
7753
8133
  pools,
7754
8134
  loading,
8135
+ addView: (view) => moduleViews.add(view),
8136
+ setViewCamera: (view) => moduleViews.setMain(view ?? null),
8137
+ holdPlayer: (held) => player.setWalkHeld(held),
7755
8138
  ...options.blastShove ? { blastShove: options.blastShove } : {},
8139
+ blastReach: (point, target, ignore) => world.blastReach(point, target, ignore ? { ignore } : {}),
7756
8140
  tool: () => ({
7757
8141
  weapon: toolId,
7758
8142
  category: tool.def.category,
@@ -7927,6 +8311,7 @@ function createTestRoom(options) {
7927
8311
  impl: ti.impl,
7928
8312
  animations: ti.animations,
7929
8313
  seats: ti.seats,
8314
+ sounds: ti.sounds,
7930
8315
  thumbnail: ti.thumbnail,
7931
8316
  thumbnailCredit: ti.thumbnailCredit
7932
8317
  };
@@ -8313,7 +8698,10 @@ function createTestRoom(options) {
8313
8698
  crouchForced = state.crouch;
8314
8699
  avatarState.crouch = state.crouch || player.crouching;
8315
8700
  }
8316
- if (state.down !== void 0) avatarState.down = state.down;
8701
+ if (state.down !== void 0) {
8702
+ downForced = state.down;
8703
+ avatarState.down = state.down || Boolean(player.knocked?.down);
8704
+ }
8317
8705
  if (state.armed !== void 0) armedForced = state.armed;
8318
8706
  },
8319
8707
  avatarState: () => ({ ...avatarState }),
@@ -8334,6 +8722,9 @@ function createTestRoom(options) {
8334
8722
  get playerPosition() {
8335
8723
  return player.position;
8336
8724
  },
8725
+ get playerKnock() {
8726
+ return player.knocked;
8727
+ },
8337
8728
  sit: (objectId, seatId) => seating.sit(objectId, seatId),
8338
8729
  stand: () => seating.stand(),
8339
8730
  get seat() {
@@ -8357,6 +8748,7 @@ function createTestRoom(options) {
8357
8748
  if (ownAudio) audio?.dispose();
8358
8749
  for (const detach of detachers.values()) detach();
8359
8750
  detachers.clear();
8751
+ moduleViews.dispose();
8360
8752
  for (const store of benches.values()) store.dispose();
8361
8753
  benches.clear();
8362
8754
  stopLoop?.();
@@ -8519,7 +8911,8 @@ function createTestRoom(options) {
8519
8911
  for (const batcher of batchers.values()) batcher.update();
8520
8912
  const restoreCamera = room.applyCamera();
8521
8913
  graphics?.beforeRender(camera);
8522
- gl.render(scene, debugView ?? camera);
8914
+ gl.render(scene, debugView ?? moduleViews.main(camera));
8915
+ moduleViews.render(scene);
8523
8916
  graphics?.afterRender();
8524
8917
  restoreCamera();
8525
8918
  pacer.submitted();