@adatechnology/game-test-room 0.15.0 → 0.17.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,12 +1,12 @@
1
1
  // src/room.ts
2
2
  import { createDestruction, JOINT_DEFAULTS, resolveStrings as resolveDestructionStrings } from "@adatechnology/game-destruction";
3
3
  import { createAudioEngine, createAudioSink } from "@adatechnology/game-audio";
4
- import { createDestructionRenderer, createDestructionSound, specFromObject3D } from "@adatechnology/game-destruction-three";
4
+ import { createDestructionRenderer, createDestructionSound, dragSoundKey, impactKey, specFromObject3D } from "@adatechnology/game-destruction-three";
5
5
  import { createArsenal, DEFAULT_LOADOUT } from "@adatechnology/game-weapons";
6
6
  import { createFilmLcg, createFluids, createItemUse, ITEM_USES, resolveStrings as resolveFluidStrings } from "@adatechnology/game-fluids";
7
7
  import { createFilmBridge, createFilmManager, createFluidRenderer } from "@adatechnology/game-fluids-three";
8
- import { createGroundField, createVfx } from "@adatechnology/game-vfx";
9
- import { createGroundPatch, createVfxRenderer } from "@adatechnology/game-vfx-three";
8
+ import { createVfx } from "@adatechnology/game-vfx";
9
+ import { createVfxRenderer } from "@adatechnology/game-vfx-three";
10
10
  import { checkAnimation, checkBench, checkDestructible, checkSpec, createEnvironmentBus, shotExtras, useToHit } from "@adatechnology/game-contracts";
11
11
 
12
12
  // src/arena.ts
@@ -110,7 +110,7 @@ function resolveScenery(scenery) {
110
110
  if (!position) throw new Error(`game-test-room: scenery "${id}" is new, so it needs a position`);
111
111
  merged.set(id, { ...obj, position });
112
112
  }
113
- return [...merged.entries()].map(([id, o]) => ({ id, spec: o.spec, position: o.position, rotationY: o.rotationY ?? 0, colors: o.colors, build: o.build, label: o.label, top: o.top, spout: o.spout }));
113
+ return [...merged.entries()].map(([id, o]) => ({ id, spec: o.spec, position: o.position, rotationY: o.rotationY ?? 0, colors: o.colors, build: o.build, follow: o.follow, label: o.label, top: o.top, spout: o.spout, free: o.free }));
114
114
  }
115
115
  function createArena(THREE, size, scene, scenery, tapOption) {
116
116
  const group = markKitOrigin(new THREE.Group(), { owner: "game-test-room", kind: "room" });
@@ -130,14 +130,6 @@ function createArena(THREE, size, scene, scenery, tapOption) {
130
130
  sc.far = 40;
131
131
  sun.shadow.bias = -6e-4;
132
132
  group.add(sun);
133
- const floor = new THREE.Mesh(new THREE.PlaneGeometry(size * 2, size * 2), new THREE.MeshStandardMaterial({ color: 9342602, roughness: 0.95 }));
134
- floor.rotation.x = -Math.PI / 2;
135
- floor.receiveShadow = true;
136
- floor.name = "floor";
137
- group.add(floor);
138
- const grid = new THREE.GridHelper(size * 2, size * 2, 5000274, 7829371);
139
- grid.position.y = 4e-3;
140
- group.add(grid);
141
133
  const sceneryObjects = resolveScenery(scenery);
142
134
  const wallObject = sceneryObjects.find((o) => o.id === WALL_ID);
143
135
  const wallZ = wallObject ? wallObject.position[2] : DEFAULT_WALL_Z;
@@ -176,16 +168,23 @@ function createArena(THREE, size, scene, scenery, tapOption) {
176
168
  const surfaces = [{ id: "floor", point: [0, 0, 0], normal: [0, 1, 0], halfSize: [size, size], porosity: 0.05 }];
177
169
  for (const o of sceneryObjects) {
178
170
  if (!o.top) continue;
179
- const b = specBounds(THREE, o.spec, o.position, o.rotationY, { onlyPart: o.top.part });
180
- if (!b) continue;
171
+ const boards = (o.top.parts ?? []).flatMap((part) => {
172
+ const f = specBounds(THREE, o.spec, o.position, o.rotationY, { onlyPart: part });
173
+ return f ? [{ part, minX: f.minX, maxX: f.maxX, minZ: f.minZ, maxZ: f.maxZ }] : [];
174
+ });
175
+ const whole = specBounds(THREE, o.spec, o.position, o.rotationY, { onlyPart: o.top.part });
176
+ if (!whole) continue;
177
+ const b = boards.length === 0 ? whole : { minX: Math.min(...boards.map((f) => f.minX)), maxX: Math.max(...boards.map((f) => f.maxX)), minZ: Math.min(...boards.map((f) => f.minZ)), maxZ: Math.max(...boards.map((f) => f.maxZ)) };
181
178
  const top = o.position[1] + o.top.y;
182
- tops.push({ id: o.id, topPart: o.top.part, minX: b.minX, maxX: b.maxX, minZ: b.minZ, maxZ: b.maxZ, top });
179
+ tops.push({ id: o.id, topPart: o.top.part, minX: b.minX, maxX: b.maxX, minZ: b.minZ, maxZ: b.maxZ, top, ...boards.length > 0 ? { boards } : {} });
183
180
  surfaces.push({ id: o.id, point: [(b.minX + b.maxX) / 2, top, (b.minZ + b.maxZ) / 2], normal: [0, 1, 0], halfSize: [(b.maxX - b.minX) / 2, (b.maxZ - b.minZ) / 2], porosity: 0.3 });
184
181
  }
185
182
  const colliders = [];
183
+ const colliderIds = [];
186
184
  for (const o of sceneryObjects) {
187
185
  const b = specBounds(THREE, o.spec, o.position, o.rotationY, { minHeight: 0.1 });
188
186
  if (!b) continue;
187
+ colliderIds.push(o.id);
189
188
  colliders.push(o.id === WALL_ID ? [-size, b.minZ - 1, size, b.maxZ + 0.1] : [b.minX, b.minZ, b.maxX, b.maxZ]);
190
189
  }
191
190
  let tapSpout = null;
@@ -226,6 +225,7 @@ function createArena(THREE, size, scene, scenery, tapOption) {
226
225
  }
227
226
  tapSpout = [cx + 0.3, y + 0.5, cz];
228
227
  colliders.push([postX - 0.05, cz - 0.1, postX + 0.05, cz + 0.1]);
228
+ colliderIds.push(null);
229
229
  }
230
230
  }
231
231
  return {
@@ -233,6 +233,7 @@ function createArena(THREE, size, scene, scenery, tapOption) {
233
233
  objects: sceneryObjects,
234
234
  surfaces,
235
235
  colliders,
236
+ colliderIds,
236
237
  playerStart,
237
238
  wallZ,
238
239
  tops,
@@ -1881,13 +1882,19 @@ function createNarrator(c) {
1881
1882
  // src/player.ts
1882
1883
  var EYE = 1.7;
1883
1884
  var PLAYER_RADIUS = 0.3;
1885
+ var FOOT = 0.5;
1884
1886
  var WALK = 3.4;
1885
1887
  var CROUCH_DROP = 0.55;
1886
1888
  var CROUCH_WALK = 1.7;
1887
1889
  var CROUCH_RATE = 9;
1890
+ var BEND_RATE = 7;
1891
+ var CLEAR_SHARES = [1, 0.75, 0.5, 0.25];
1892
+ var CROUCH_TAP = 0.25;
1888
1893
  var RUN = 6.2;
1889
1894
  var JUMP = 4.6;
1890
1895
  var GRAVITY = 13;
1896
+ var FLOOR_CLIMB_SPEED = 3;
1897
+ var FLOOR_FALL_SPEED = 6;
1891
1898
  var LOOK = 22e-4;
1892
1899
  function createPlayer(THREE, o) {
1893
1900
  const cam = o.camera;
@@ -1896,6 +1903,8 @@ function createPlayer(THREE, o) {
1896
1903
  let y = 0;
1897
1904
  let eye = EYE;
1898
1905
  let crouch = 0;
1906
+ let bend = 0;
1907
+ let bendTarget = 0;
1899
1908
  let vy = 0;
1900
1909
  let walked = 0;
1901
1910
  let running = false;
@@ -1904,33 +1913,79 @@ function createPlayer(THREE, o) {
1904
1913
  const keys = /* @__PURE__ */ new Set();
1905
1914
  const crouchKeys = o.crouchKeys ?? ["KeyC"];
1906
1915
  const isCrouchHeld = () => crouchKeys.some((code) => keys.has(code));
1916
+ let crouchLatched = false;
1917
+ let crouchPress = null;
1918
+ const isCrouched = () => crouchLatched || isCrouchHeld();
1919
+ const pressCrouch = (code) => {
1920
+ if (crouchKeys.includes(code) && !isCrouchHeld()) crouchPress = { held: 0, latched: crouchLatched };
1921
+ };
1922
+ const releaseCrouch = (code) => {
1923
+ if (!crouchKeys.includes(code) || !crouchPress || isCrouchHeld()) return;
1924
+ crouchLatched = crouchPress.held < CROUCH_TAP ? !crouchPress.latched : false;
1925
+ crouchPress = null;
1926
+ };
1907
1927
  const euler = new THREE.Euler(0, 0, 0, "YXZ");
1928
+ const floorHere = () => o.floorAt?.(px.x, px.z) ?? 0;
1929
+ let floorLevel = 0;
1930
+ const followFloor = (dt) => {
1931
+ const gap = floorHere() - floorLevel;
1932
+ const reach = (gap > 0 ? FLOOR_CLIMB_SPEED : FLOOR_FALL_SPEED) * dt;
1933
+ floorLevel += Math.max(-reach, Math.min(reach, gap));
1934
+ };
1908
1935
  const apply = () => {
1909
1936
  euler.set(pitch, yaw, 0, "YXZ");
1910
1937
  cam.quaternion.setFromEuler(euler);
1911
- cam.position.set(px.x, eye + y - CROUCH_DROP * crouch, px.z);
1938
+ cam.position.set(px.x, eye + floorLevel + y - CROUCH_DROP * crouch - bend, px.z);
1912
1939
  cam.updateMatrixWorld(true);
1913
1940
  };
1914
1941
  apply();
1915
- const blocked = (x, z) => {
1916
- if (Math.abs(x) > o.limit - PLAYER_RADIUS || Math.abs(z) > o.limit - PLAYER_RADIUS) return true;
1917
- 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;
1942
+ const outside = (x, z) => Math.abs(x) > o.limit - PLAYER_RADIUS || Math.abs(z) > o.limit - PLAYER_RADIUS;
1943
+ const blocked = (x, z, radius = PLAYER_RADIUS) => {
1944
+ if (outside(x, z)) return true;
1945
+ 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;
1918
1946
  if (o.blockers) {
1919
- for (const blocked2 of o.blockers) if (blocked2(x, z, PLAYER_RADIUS)) return true;
1947
+ for (const isBlocked of o.blockers) if (isBlocked(x, z, radius)) return true;
1920
1948
  }
1921
- return false;
1949
+ return o.footing ? o.footing(x, z, radius, y).blocked : false;
1950
+ };
1951
+ const clearRadius = () => {
1952
+ for (const share of CLEAR_SHARES) if (!blocked(px.x, px.z, PLAYER_RADIUS * share)) return PLAYER_RADIUS * share;
1953
+ return null;
1922
1954
  };
1955
+ const wish = { x: 0, z: 0 };
1923
1956
  const move = (dx, dz) => {
1924
- if (!blocked(px.x + dx, px.z)) px.x += dx;
1925
- if (!blocked(px.x, px.z + dz)) px.z += dz;
1957
+ wish.x += dx;
1958
+ wish.z += dz;
1959
+ const radius = clearRadius();
1960
+ const free = (x, z) => radius === null ? !outside(x, z) : !blocked(x, z, radius);
1961
+ const steps = Math.max(1, Math.ceil(Math.hypot(dx, dz) / (PLAYER_RADIUS / 3)));
1962
+ const sx = dx / steps;
1963
+ const sz = dz / steps;
1964
+ for (let i = 0; i < steps; i++) {
1965
+ if (free(px.x + sx, px.z)) px.x += sx;
1966
+ if (free(px.x, px.z + sz)) px.z += sz;
1967
+ }
1968
+ };
1969
+ const walkKeys = () => {
1970
+ const f = (keys.has("KeyW") || keys.has("ArrowUp") ? 1 : 0) - (keys.has("KeyS") || keys.has("ArrowDown") ? 1 : 0);
1971
+ const r = (keys.has("KeyD") || keys.has("ArrowRight") ? 1 : 0) - (keys.has("KeyA") || keys.has("ArrowLeft") ? 1 : 0);
1972
+ if (!f && !r) return [0, 0];
1973
+ const len = Math.hypot(f, r);
1974
+ return [(-Math.sin(yaw) * f + Math.cos(yaw) * r) / len, (-Math.cos(yaw) * f - Math.sin(yaw) * r) / len];
1926
1975
  };
1927
1976
  let dragging = false;
1928
1977
  let moved = 0;
1929
1978
  let downAt = 0;
1930
1979
  let useStarted = false;
1931
1980
  let holdTimer = null;
1981
+ let walkHeld = false;
1982
+ let lookLimit = null;
1932
1983
  const look = (dx, dy) => {
1933
1984
  yaw -= dx * LOOK;
1985
+ if (lookLimit) {
1986
+ const off = Math.atan2(Math.sin(yaw - lookLimit.yaw), Math.cos(yaw - lookLimit.yaw));
1987
+ yaw = lookLimit.yaw + Math.max(-lookLimit.range, Math.min(lookLimit.range, off));
1988
+ }
1934
1989
  pitch = Math.max(-1.5, Math.min(1.5, pitch - dy * LOOK));
1935
1990
  };
1936
1991
  const onLockChange = () => {
@@ -2022,16 +2077,23 @@ function createPlayer(THREE, o) {
2022
2077
  if (isControlHeld || e.altKey && locked) {
2023
2078
  if (!/^F\d+$/.test(e.code)) e.preventDefault();
2024
2079
  }
2025
- if (!e.repeat) keys.add(e.code);
2080
+ if (!e.repeat) {
2081
+ pressCrouch(e.code);
2082
+ keys.add(e.code);
2083
+ }
2026
2084
  o.hooks.key(e.code, true, e);
2027
2085
  };
2028
2086
  const onKeyUp = (e) => {
2029
2087
  keys.delete(e.code);
2088
+ releaseCrouch(e.code);
2030
2089
  if (typing(e)) return;
2031
2090
  o.hooks.key(e.code, false, e);
2032
2091
  };
2033
2092
  const onContext = (e) => e.preventDefault();
2034
- const onBlur = () => keys.clear();
2093
+ const onBlur = () => {
2094
+ keys.clear();
2095
+ crouchPress = null;
2096
+ };
2035
2097
  const onBeforeUnload = (e) => {
2036
2098
  if (!locked && !keys.has("ControlLeft") && !keys.has("ControlRight")) return;
2037
2099
  e.preventDefault();
@@ -2048,6 +2110,21 @@ function createPlayer(THREE, o) {
2048
2110
  window.addEventListener("blur", onBlur);
2049
2111
  window.addEventListener("beforeunload", onBeforeUnload);
2050
2112
  return {
2113
+ get walkIntent() {
2114
+ return walkKeys();
2115
+ },
2116
+ canStand(x, z) {
2117
+ return !blocked(x, z);
2118
+ },
2119
+ setWalkHeld(held) {
2120
+ walkHeld = held;
2121
+ },
2122
+ setBend(metres) {
2123
+ bendTarget = metres;
2124
+ },
2125
+ setLookLimit(limit) {
2126
+ lookLimit = limit;
2127
+ },
2051
2128
  get yaw() {
2052
2129
  return yaw;
2053
2130
  },
@@ -2064,10 +2141,10 @@ function createPlayer(THREE, o) {
2064
2141
  return px.z;
2065
2142
  },
2066
2143
  get feet() {
2067
- return y;
2144
+ return floorLevel + y;
2068
2145
  },
2069
2146
  get position() {
2070
- return [px.x, y, px.z];
2147
+ return [px.x, floorLevel + y, px.z];
2071
2148
  },
2072
2149
  get speed() {
2073
2150
  return walked;
@@ -2076,49 +2153,43 @@ function createPlayer(THREE, o) {
2076
2153
  return running;
2077
2154
  },
2078
2155
  get crouching() {
2079
- return isCrouchHeld();
2156
+ return isCrouched();
2080
2157
  },
2081
2158
  keys,
2082
2159
  update(dt) {
2083
- const crouched = isCrouchHeld();
2160
+ if (crouchPress) crouchPress.held += dt;
2161
+ const crouched = isCrouched();
2084
2162
  crouch += ((crouched ? 1 : 0) - crouch) * Math.min(1, dt * CROUCH_RATE);
2085
2163
  if (!crouched && crouch < 1e-3) crouch = 0;
2086
- const run = !crouched && (keys.has("ShiftLeft") || keys.has("ShiftRight"));
2164
+ bend += (bendTarget - bend) * Math.min(1, dt * BEND_RATE);
2165
+ const run = !crouched && !walkHeld && (keys.has("ShiftLeft") || keys.has("ShiftRight"));
2087
2166
  const fromX = px.x;
2088
2167
  const fromZ = px.z;
2089
2168
  const speed = crouched ? CROUCH_WALK : run ? RUN : WALK;
2090
- let f = 0;
2091
- let r = 0;
2092
- if (keys.has("KeyW") || keys.has("ArrowUp")) f += 1;
2093
- if (keys.has("KeyS") || keys.has("ArrowDown")) f -= 1;
2094
- if (keys.has("KeyD") || keys.has("ArrowRight")) r += 1;
2095
- if (keys.has("KeyA") || keys.has("ArrowLeft")) r -= 1;
2096
- if ((f || r || keys.has("Space")) && eye !== EYE) eye = EYE;
2097
- if (f || r) {
2098
- const len = Math.hypot(f, r);
2099
- const fx = -Math.sin(yaw);
2100
- const fz = -Math.cos(yaw);
2101
- const rx = Math.cos(yaw);
2102
- const rz = -Math.sin(yaw);
2103
- move((fx * f + rx * r) / len * speed * dt, (fz * f + rz * r) / len * speed * dt);
2104
- }
2169
+ const [walkX, walkZ] = walkKeys();
2170
+ if ((walkX || walkZ || keys.has("Space")) && eye !== EYE) eye = EYE;
2171
+ if ((walkX || walkZ) && !walkHeld) move(walkX * speed * dt, walkZ * speed * dt);
2105
2172
  walked = dt > 0 ? Math.hypot(px.x - fromX, px.z - fromZ) / dt : 0;
2106
2173
  running = run && walked > 0;
2107
- if (y <= 0 && keys.has("Space")) vy = JUMP;
2108
- if (y > 0 || vy > 0) {
2174
+ const floor = Math.max(floorHere(), o.footing?.(px.x, px.z, PLAYER_RADIUS * FOOT, y).floor ?? -Infinity);
2175
+ if (y < floor && vy <= 0) y = floor;
2176
+ if (y <= floor && keys.has("Space") && !walkHeld) vy = JUMP;
2177
+ if (y > floor || vy > 0) {
2109
2178
  vy -= GRAVITY * dt;
2110
2179
  y += vy * dt;
2111
- if (y <= 0) {
2112
- y = 0;
2180
+ if (y <= floor) {
2181
+ y = floor;
2113
2182
  vy = 0;
2114
2183
  }
2115
2184
  }
2185
+ followFloor(dt);
2116
2186
  apply();
2117
2187
  },
2118
2188
  setPose(position, yw, pt) {
2119
2189
  px.x = position[0];
2120
2190
  px.z = position[2];
2121
- eye = position[1];
2191
+ floorLevel = floorHere();
2192
+ eye = position[1] - floorLevel;
2122
2193
  y = 0;
2123
2194
  vy = 0;
2124
2195
  yaw = yw;
@@ -2128,7 +2199,8 @@ function createPlayer(THREE, o) {
2128
2199
  look(position, target) {
2129
2200
  px.x = position[0];
2130
2201
  px.z = position[2];
2131
- eye = position[1];
2202
+ floorLevel = floorHere();
2203
+ eye = position[1] - floorLevel;
2132
2204
  y = 0;
2133
2205
  vy = 0;
2134
2206
  const dx = target[0] - position[0];
@@ -2147,6 +2219,12 @@ function createPlayer(THREE, o) {
2147
2219
  move(dx, dz);
2148
2220
  apply();
2149
2221
  },
2222
+ takeWish() {
2223
+ const out = [wish.x, wish.z];
2224
+ wish.x = 0;
2225
+ wish.z = 0;
2226
+ return out;
2227
+ },
2150
2228
  walk(forward, right) {
2151
2229
  move(-Math.sin(yaw) * forward + Math.cos(yaw) * right, -Math.cos(yaw) * forward - Math.sin(yaw) * right);
2152
2230
  apply();
@@ -2168,6 +2246,204 @@ function createPlayer(THREE, o) {
2168
2246
  };
2169
2247
  }
2170
2248
 
2249
+ // src/furniture-push.ts
2250
+ import { createFurniturePush } from "@adatechnology/game-player";
2251
+ var PUSH_REACH = 1.1;
2252
+ var STANDOFF = 0.58;
2253
+ var LOW_TOP = 0.95;
2254
+ var LOW_STANDOFF = 0.42;
2255
+ var LOW_HAND_ON_TOP = 0.07;
2256
+ var LOW_EYE_OVER_TOP = 0.62;
2257
+ var MAX_BEND = 0.8;
2258
+ var STANDING_EYE = 1.7;
2259
+ var APPROACH_SPEED = 1.2;
2260
+ var MAX_BASE_HEIGHT = 0.12;
2261
+ var FLOOR_CLEARANCE = 0.06;
2262
+ var SWEEP_SKIN = 0.015;
2263
+ var HAND_SPREAD = 0.2;
2264
+ var HAND_TOP = 1.35;
2265
+ var HAND_UNDER_TOP = 0.05;
2266
+ var LOOK_RANGE = 0.9;
2267
+ var DRAG_GRAIN_METRES = 0.3;
2268
+ var RING_DELAY_S = 0.1;
2269
+ function contactOf(box2, player) {
2270
+ const x = Math.max(box2.min[0] ?? 0, Math.min(box2.max[0] ?? 0, player.x));
2271
+ const z = Math.max(box2.min[2] ?? 0, Math.min(box2.max[2] ?? 0, player.z));
2272
+ const dx = x - player.x;
2273
+ const dz = z - player.z;
2274
+ const distance = Math.hypot(dx, dz);
2275
+ if (distance < 1e-3) return { contact: [x, z], into: [-Math.sin(player.yaw), -Math.cos(player.yaw)], distance: 0 };
2276
+ return { contact: [x, z], into: [dx / distance, dz / distance], distance };
2277
+ }
2278
+ function createRoomPush(options) {
2279
+ const { THREE, player, world, arena, blockers } = options;
2280
+ const control = createFurniturePush(options.rules);
2281
+ const box3 = new THREE.Box3();
2282
+ let leaning = null;
2283
+ let scriptedWalk = null;
2284
+ let dragged = 0;
2285
+ const event = (type, lean) => options.onEvent({ type, objectId: lean.target.objectId, name: lean.target.name, massKg: lean.massKg });
2286
+ const standsClear = (lean, dx, dz) => {
2287
+ const minX = lean.min[0] + dx;
2288
+ const maxX = lean.max[0] + dx;
2289
+ const minZ = lean.min[2] + dz;
2290
+ const maxZ = lean.max[2] + dz;
2291
+ if (Math.max(Math.abs(minX), Math.abs(maxX)) > arena.limit || Math.max(Math.abs(minZ), Math.abs(maxZ)) > arena.limit) return false;
2292
+ for (const c of arena.colliders) {
2293
+ if (lean.max[0] > c[0] && lean.min[0] < c[2] && lean.max[2] > c[1] && lean.min[2] < c[3]) continue;
2294
+ if (maxX > c[0] && minX < c[2] && maxZ > c[1] && minZ < c[3]) return false;
2295
+ }
2296
+ if (blockers.size === 0) return true;
2297
+ const midX = (minX + maxX) / 2;
2298
+ const midZ = (minZ + maxZ) / 2;
2299
+ for (const [x, z] of [[minX, minZ], [maxX, minZ], [minX, maxZ], [maxX, maxZ], [midX, minZ], [midX, maxZ], [minX, midZ], [maxX, midZ]]) {
2300
+ for (const isBlocked of blockers) if (isBlocked(x, z, 0)) return false;
2301
+ }
2302
+ return true;
2303
+ };
2304
+ const isFree = (lean, dx, dz) => {
2305
+ if (!standsClear(lean, dx, dz) || !player.canStand(player.x + dx, player.z + dz)) return false;
2306
+ const distance = Math.hypot(dx, dz);
2307
+ const box2 = { min: [lean.min[0], lean.min[1] + FLOOR_CLEARANCE, lean.min[2]], max: [lean.max[0], lean.max[1], lean.max[2]] };
2308
+ return world.sweepBox(box2, [dx / distance, 0, dz / distance], distance + SWEEP_SKIN, { ignore: lean.target.objectId }) === null;
2309
+ };
2310
+ const release = () => {
2311
+ const lean = leaning;
2312
+ if (!lean) return;
2313
+ leaning = null;
2314
+ if (lean.piece) {
2315
+ lean.piece.held = false;
2316
+ lean.piece.sleeping = false;
2317
+ lean.piece.velocity = [0, 0, 0];
2318
+ }
2319
+ control.release();
2320
+ player.setWalkHeld(false);
2321
+ player.setBend(0);
2322
+ player.setLookLimit(null);
2323
+ options.ring(null);
2324
+ event("release", lean);
2325
+ };
2326
+ const approach = (lean, dt) => {
2327
+ const off = Math.hypot(lean.contact[0] - player.x, lean.contact[1] - player.z) - (lean.isLow ? LOW_STANDOFF : STANDOFF);
2328
+ const move = Math.sign(off) * Math.min(Math.abs(off), APPROACH_SPEED * dt);
2329
+ if (move !== 0) player.moveBy(lean.into[0] * move, lean.into[1] * move);
2330
+ };
2331
+ const moveWith = (lean, dx, dz) => {
2332
+ lean.target.root.position.x += dx;
2333
+ lean.target.root.position.z += dz;
2334
+ lean.target.root.updateMatrixWorld(true);
2335
+ player.moveBy(dx, dz);
2336
+ lean.min[0] += dx;
2337
+ lean.max[0] += dx;
2338
+ lean.min[2] += dz;
2339
+ lean.max[2] += dz;
2340
+ lean.contact[0] += dx;
2341
+ lean.contact[1] += dz;
2342
+ dragged += Math.hypot(dx, dz);
2343
+ if (dragged < DRAG_GRAIN_METRES) return;
2344
+ dragged = 0;
2345
+ options.dragSound(lean.target.objectId, [lean.contact[0], lean.min[1], lean.contact[1]], 0.3 + 0.5 * Math.min(1, lean.massKg / 100));
2346
+ };
2347
+ return {
2348
+ get active() {
2349
+ return leaning !== null;
2350
+ },
2351
+ get state() {
2352
+ return leaning ? { objectId: leaning.target.objectId, phase: control.phase, progress: control.progress, block: control.block, massKg: leaning.massKg } : null;
2353
+ },
2354
+ get isBent() {
2355
+ return leaning?.isLow ?? false;
2356
+ },
2357
+ get avatarState() {
2358
+ return leaning && control.phase !== "idle" ? { phase: control.phase, progress: control.progress } : null;
2359
+ },
2360
+ begin() {
2361
+ if (leaning) return true;
2362
+ const target = options.pick();
2363
+ if (!target || !world.has(target.objectId)) return false;
2364
+ box3.setFromObject(target.root);
2365
+ if (box3.min.y > player.feet + MAX_BASE_HEIGHT) return false;
2366
+ const min = [box3.min.x, box3.min.y, box3.min.z];
2367
+ const max = [box3.max.x, box3.max.y, box3.max.z];
2368
+ const { contact, into, distance } = contactOf({ min, max }, player);
2369
+ if (distance > PUSH_REACH) return false;
2370
+ const massKg = options.massOf(target.objectId);
2371
+ const material = options.materialOf(target.objectId);
2372
+ const isLow = max[1] - player.feet < LOW_TOP;
2373
+ const side = new THREE.Vector3(-into[1], 0, into[0]);
2374
+ const way = new THREE.Vector3(into[0], 0, into[1]);
2375
+ const up = new THREE.Vector3(0, 1, 0);
2376
+ const basis = isLow ? new THREE.Matrix4().makeBasis(way, up, side) : new THREE.Matrix4().makeBasis(up, way.clone().negate(), side);
2377
+ const palm = new THREE.Quaternion().setFromRotationMatrix(basis);
2378
+ leaning = { target, massKg, piece: target.piece, min, max, contact, into, isLow, palm: [palm.x, palm.y, palm.z, palm.w] };
2379
+ if (target.piece) target.piece.held = true;
2380
+ control.begin(material === void 0 ? { massKg } : { massKg, material });
2381
+ dragged = 0;
2382
+ player.setWalkHeld(true);
2383
+ if (isLow) player.setBend(Math.min(MAX_BEND, Math.max(0, STANDING_EYE - (max[1] - player.feet + LOW_EYE_OVER_TOP))));
2384
+ player.setLookLimit({ yaw: Math.atan2(-into[0], -into[1]), range: LOOK_RANGE });
2385
+ event("grip", leaning);
2386
+ return true;
2387
+ },
2388
+ end() {
2389
+ if (!leaning) return false;
2390
+ const tap = control.isTap;
2391
+ release();
2392
+ return tap;
2393
+ },
2394
+ setWalk(walk) {
2395
+ scriptedWalk = walk;
2396
+ },
2397
+ step(dt) {
2398
+ const lean = leaning;
2399
+ if (!lean) return;
2400
+ if (!world.has(lean.target.objectId)) {
2401
+ release();
2402
+ return;
2403
+ }
2404
+ const gripping = control.phase === "grip";
2405
+ if (gripping) approach(lean, dt);
2406
+ const result = control.update(dt, { walk: scriptedWalk ?? player.walkIntent, isFree: (dx, dz) => isFree(lean, dx, dz) });
2407
+ if (result.engaged) {
2408
+ if (control.block !== "too_heavy") {
2409
+ lean.piece ?? (lean.piece = options.makePiece(lean.target.objectId));
2410
+ lean.piece.held = true;
2411
+ }
2412
+ event(control.block === "too_heavy" ? "too_heavy" : "engaged", lean);
2413
+ }
2414
+ if (result.hitObstacle) {
2415
+ options.hitSound(lean.target.objectId, [lean.contact[0], lean.min[1] + 0.3, lean.contact[1]]);
2416
+ event("obstacle", lean);
2417
+ }
2418
+ if (result.dx !== 0 || result.dz !== 0) moveWith(lean, result.dx, result.dz);
2419
+ options.ring(control.phase === "grip" && control.heldSeconds >= RING_DELAY_S ? control.progress : null);
2420
+ },
2421
+ handTargets() {
2422
+ const lean = leaning;
2423
+ if (!lean) return null;
2424
+ const strain = control.phase === "strain";
2425
+ const y = lean.isLow ? lean.max[1] + 0.01 : Math.min(HAND_TOP, lean.max[1] - HAND_UNDER_TOP);
2426
+ const shake = strain ? Math.sin(performance.now() * 0.045) * 6e-3 : 0;
2427
+ const inset = lean.isLow ? -LOW_HAND_ON_TOP + shake : (strain ? 0.05 : 0.02) + shake;
2428
+ const px = lean.contact[0] - lean.into[0] * inset;
2429
+ const pz = lean.contact[1] - lean.into[1] * inset;
2430
+ const sideX = -lean.into[1] * HAND_SPREAD;
2431
+ const sideZ = lean.into[0] * HAND_SPREAD;
2432
+ const grip = 0.05 + 0.2 * control.progress;
2433
+ return {
2434
+ R: { position: [px + sideX, y, pz + sideZ], quaternion: lean.palm },
2435
+ L: { position: [px - sideX, y, pz - sideZ], quaternion: lean.palm },
2436
+ grip,
2437
+ gripL: grip
2438
+ };
2439
+ }
2440
+ };
2441
+ }
2442
+
2443
+ // src/room.ts
2444
+ import { createTerrain, createTerrainImpl, DEFAULT_PROFILE } from "@adatechnology/game-terrain";
2445
+ import { createTerrainRenderer } from "@adatechnology/game-terrain-three";
2446
+
2171
2447
  // src/avatar.ts
2172
2448
  var DEFAULT_STRIKE_CLIPS = { hand: "punch_right", fists: "punch_right", shove: "shove_two_hands", kick: "front_kick" };
2173
2449
  var DEFAULT_HAND_STANCES = { fists: "fists" };
@@ -2346,6 +2622,11 @@ var en = {
2346
2622
  "act.strike.leg": "{weapon}: {clip} with the {leg} leg begins, impact in {seconds} s",
2347
2623
  "leg.left": "left",
2348
2624
  "leg.right": "right",
2625
+ "act.strike.hand": "{weapon}: {clip} with the {hand} hand begins, impact in {seconds} s",
2626
+ "hand.left": "left",
2627
+ "hand.right": "right",
2628
+ "act.combo.on": "{name}: on (each blow follows the sequence)",
2629
+ "act.combo.off": "{name}: off",
2349
2630
  "act.flame.hit": "{weapon}: flame at {temp} \xB0C on {where}",
2350
2631
  "act.flame.air": "{weapon}: flame, nothing in range",
2351
2632
  "act.heat.hit": "{weapon}: heating {where} at {temp} \xB0C",
@@ -2369,14 +2650,17 @@ var en = {
2369
2650
  "act.extinguish.hit": "{weapon}: foam on {what}",
2370
2651
  "act.extinguish.air": "{weapon}: foam, nothing in range",
2371
2652
  "act.grab": "Grabbed {name} ({kg} kg)",
2653
+ "act.bump": "Pushed {name} ({kg} kg) walking into it",
2372
2654
  "act.drop": "Dropped {name}",
2373
2655
  "act.throw": "Threw {name} at {speed} m/s",
2656
+ "ground.name": "Ground",
2374
2657
  "act.landing": "{name} landed at {speed} m/s: {energy} J",
2375
2658
  "act.bench_failed": "{name} failed: {error}",
2376
2659
  "loading.working": "Working\u2026",
2377
2660
  "loading.open_area": "Opening {area}\u2026",
2378
2661
  "loading.spawn": "Building {name}\u2026",
2379
2662
  "act.loosened": "{name} was heavy but free: the blast moved it",
2663
+ "act.shoved": "{name} stands free: the shots moved it",
2380
2664
  "act.impact": "{name} hit {where} at {speed} m/s: {energy} J of blunt damage",
2381
2665
  "act.use_item": "{name}: {use}",
2382
2666
  "act.tap.on": "Tap opened",
@@ -2403,6 +2687,14 @@ var en = {
2403
2687
  "hud.nothing_to_grab": "Nothing to grab here",
2404
2688
  "hud.cannot_grab": "{name} cannot be picked up",
2405
2689
  "hud.too_heavy": "{name} is too heavy ({kg} kg)",
2690
+ "hud.push_on": "Pushing {name}: W pushes, S pulls, let go of E to stop",
2691
+ "hud.push_too_heavy": "{name} does not budge ({kg} kg)",
2692
+ "hud.push_blocked": "{name} is stuck: there is something in the way",
2693
+ "act.push_grip": "leans on {name} ({kg} kg)",
2694
+ "act.push_engaged": "starts pushing {name} ({kg} kg)",
2695
+ "act.push_too_heavy": "strains against {name}: {kg} kg is too much",
2696
+ "act.push_obstacle": "{name} hits something and locks",
2697
+ "act.push_release": "lets go of {name}",
2406
2698
  "hud.empty_container": "{name} is empty",
2407
2699
  "hud.refused": "Wrong tool for the {kind}",
2408
2700
  "hud.drop": "drop",
@@ -2410,7 +2702,7 @@ var en = {
2410
2702
  "hud.throw": "throw",
2411
2703
  "hud.tilt": "tilt (hold)",
2412
2704
  "hud.equip": "equip {weapon}",
2413
- "ui.help": "Click: look \xB7 WASD: walk \xB7 Shift: run \xB7 C: crouch \xB7 Space: jump \xB7 1-0 / wheel: tool \xB7 LMB: use (bare hands: punch) \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",
2705
+ "ui.help": "Click: look \xB7 WASD: walk \xB7 Shift: run \xB7 C: crouch (tap stays, hold while pressed) \xB7 Space: jump \xB7 1-0 / wheel: tool \xB7 LMB: use (bare hands: punch) \xB7 E: grab/drop (hold against furniture: push) \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 K: kick \xB7 N: strike combo on/off \xB7 Tab: panels \xB7 Esc: free the mouse",
2414
2706
  "ui.help.camera": "V: 1st/3rd person (Shift+V: kick)",
2415
2707
  "camera.first": "1st person",
2416
2708
  "camera.third": "3rd person",
@@ -2457,6 +2749,7 @@ var en = {
2457
2749
  "ui.slow": "Slow motion 0.25\xD7",
2458
2750
  "ui.fast": "Time \xD710",
2459
2751
  "ui.tap": "Tap",
2752
+ "ui.kick": "Kick",
2460
2753
  "ui.fullscreen": "Full screen",
2461
2754
  "ui.fullscreen.hint": "Full screen opened from here keeps the whole keyboard in the game, Ctrl+W included (F11 does not)",
2462
2755
  "ui.blast.shelter": "Shelter",
@@ -2653,6 +2946,11 @@ var ptBR = {
2653
2946
  "act.strike.leg": "{weapon}: {clip} com a perna {leg} come\xE7a, impacto em {seconds} s",
2654
2947
  "leg.left": "esquerda",
2655
2948
  "leg.right": "direita",
2949
+ "act.strike.hand": "{weapon}: {clip} com a m\xE3o {hand} come\xE7a, impacto em {seconds} s",
2950
+ "hand.left": "esquerda",
2951
+ "hand.right": "direita",
2952
+ "act.combo.on": "{name}: ligada (cada golpe segue a sequ\xEAncia)",
2953
+ "act.combo.off": "{name}: desligada",
2656
2954
  "act.flame.hit": "{weapon}: chama a {temp} \xB0C em {where}",
2657
2955
  "act.flame.air": "{weapon}: chama, nada ao alcance",
2658
2956
  "act.heat.hit": "{weapon}: esquentando {where} a {temp} \xB0C",
@@ -2676,14 +2974,17 @@ var ptBR = {
2676
2974
  "act.extinguish.hit": "{weapon}: espuma em {what}",
2677
2975
  "act.extinguish.air": "{weapon}: espuma, nada ao alcance",
2678
2976
  "act.grab": "Pegou {name} ({kg} kg)",
2977
+ "act.bump": "Empurrou {name} ({kg} kg) ao esbarrar",
2679
2978
  "act.drop": "Soltou {name}",
2680
2979
  "act.throw": "Arremessou {name} a {speed} m/s",
2980
+ "ground.name": "Ch\xE3o",
2681
2981
  "act.landing": "{name} caiu a {speed} m/s: {energy} J",
2682
2982
  "act.bench_failed": "{name} falhou: {error}",
2683
2983
  "loading.working": "Trabalhando\u2026",
2684
2984
  "loading.open_area": "Abrindo {area}\u2026",
2685
2985
  "loading.spawn": "Montando {name}\u2026",
2686
2986
  "act.loosened": "{name} \xE9 pesado mas estava solto: a explos\xE3o o moveu",
2987
+ "act.shoved": "{name} estava solto no ch\xE3o: os tiros o moveram",
2687
2988
  "act.impact": "{name} bateu em {where} a {speed} m/s: {energy} J de dano de impacto",
2688
2989
  "act.use_item": "{name}: {use}",
2689
2990
  "act.tap.on": "Abriu a torneira",
@@ -2710,6 +3011,14 @@ var ptBR = {
2710
3011
  "hud.nothing_to_grab": "Nada para pegar aqui",
2711
3012
  "hud.cannot_grab": "{name} n\xE3o d\xE1 para pegar",
2712
3013
  "hud.too_heavy": "{name} \xE9 pesado demais ({kg} kg)",
3014
+ "hud.push_on": "Empurrando {name}: W empurra, S puxa, solte o E para parar",
3015
+ "hud.push_too_heavy": "{name} n\xE3o se mexe ({kg} kg)",
3016
+ "hud.push_blocked": "{name} travou: tem algo no caminho",
3017
+ "act.push_grip": "encosta em {name} ({kg} kg)",
3018
+ "act.push_engaged": "come\xE7a a empurrar {name} ({kg} kg)",
3019
+ "act.push_too_heavy": "faz for\xE7a contra {name}: {kg} kg \xE9 demais",
3020
+ "act.push_obstacle": "{name} bate em algo e trava",
3021
+ "act.push_release": "solta {name}",
2713
3022
  "hud.empty_container": "{name} est\xE1 vazio",
2714
3023
  "hud.refused": "Ferramenta errada para {kind}",
2715
3024
  "hud.drop": "soltar",
@@ -2717,7 +3026,7 @@ var ptBR = {
2717
3026
  "hud.throw": "arremessar",
2718
3027
  "hud.tilt": "inclinar (segurar)",
2719
3028
  "hud.equip": "equipar {weapon}",
2720
- "ui.help": "Clique: olhar \xB7 WASD: andar \xB7 Shift: correr \xB7 C: agachar \xB7 Espa\xE7o: pular \xB7 1-0 / roda: ferramenta \xB7 bot\xE3o esquerdo: usar (m\xE3o livre: soco) \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",
3029
+ "ui.help": "Clique: olhar \xB7 WASD: andar \xB7 Shift: correr \xB7 C: agachar (toque fixa, segurar s\xF3 enquanto aperta) \xB7 Espa\xE7o: pular \xB7 1-0 / roda: ferramenta \xB7 bot\xE3o esquerdo: usar (m\xE3o livre: soco) \xB7 E: pegar/soltar (segurar encostado no m\xF3vel: empurrar) \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 K: chute \xB7 N: liga/desliga a sequ\xEAncia de golpes \xB7 Tab: pain\xE9is \xB7 Esc: soltar o mouse",
2721
3030
  "ui.help.camera": "V: 1\xAA/3\xAA pessoa (Shift+V: chute)",
2722
3031
  "camera.first": "1\xAA pessoa",
2723
3032
  "camera.third": "3\xAA pessoa",
@@ -2764,6 +3073,7 @@ var ptBR = {
2764
3073
  "ui.slow": "C\xE2mera lenta 0,25\xD7",
2765
3074
  "ui.fast": "Tempo \xD710",
2766
3075
  "ui.tap": "Torneira",
3076
+ "ui.kick": "Chute",
2767
3077
  "ui.fullscreen": "Tela cheia",
2768
3078
  "ui.fullscreen.hint": "A tela cheia aberta por aqui segura o teclado todo no jogo, inclusive Ctrl+W (o F11 n\xE3o)",
2769
3079
  "ui.blast.shelter": "Abrigo",
@@ -3147,7 +3457,7 @@ function createTestTablePanel(table, t) {
3147
3457
  relay.type = "button";
3148
3458
  relay.onclick = () => table.relay();
3149
3459
  trials.appendChild(relay);
3150
- element.append(head, search, found, sub, list, trials);
3460
+ element.append(head, search, found, sub, trials, list);
3151
3461
  const groups = () => {
3152
3462
  const keep = select.value;
3153
3463
  select.textContent = "";
@@ -3230,6 +3540,8 @@ var CSS = `
3230
3540
  .tr-cross{position:absolute;left:50%;top:50%;width:18px;height:18px;margin:-9px 0 0 -9px}
3231
3541
  .tr-cross:before,.tr-cross:after{content:"";position:absolute;background:#fff;opacity:.85;box-shadow:0 0 2px #000}
3232
3542
  .tr-cross:before{left:8px;top:0;width:2px;height:18px}.tr-cross:after{top:8px;left:0;height:2px;width:18px}
3543
+ .tr-ring{position:absolute;left:50%;top:50%;width:64px;height:64px;margin:-32px 0 0 -32px;transform:rotate(-90deg);display:none;pointer-events:none;filter:drop-shadow(0 0 2px #000)}
3544
+ .tr-ring circle{fill:none;stroke-width:5}.tr-ring .tr-ring-bg{stroke:rgba(255,255,255,.25)}.tr-ring .tr-ring-fill{stroke:#ffd166;stroke-linecap:round}
3233
3545
  .tr-cross i{position:absolute;left:6px;top:6px;width:6px;height:6px;background:#0b0c0e;border-radius:50%;z-index:1}
3234
3546
  .tr-top{position:absolute;top:8px;left:50%;transform:translateX(-50%);text-align:center;max-width:46vw}
3235
3547
  .tr-tool{display:inline-block;background:rgba(10,12,16,.72);padding:4px 12px;border-radius:6px;font-size:14px;font-weight:600}
@@ -3333,6 +3645,22 @@ function createUi(o) {
3333
3645
  o.container.appendChild(root);
3334
3646
  const cross = mk3("div", "tr-cross");
3335
3647
  cross.appendChild(mk3("i"));
3648
+ const ring = document.createElementNS("http://www.w3.org/2000/svg", "svg");
3649
+ ring.setAttribute("class", "tr-ring");
3650
+ ring.setAttribute("viewBox", "0 0 64 64");
3651
+ const ringCircle = (className) => {
3652
+ const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
3653
+ circle.setAttribute("class", className);
3654
+ circle.setAttribute("cx", "32");
3655
+ circle.setAttribute("cy", "32");
3656
+ circle.setAttribute("r", "27");
3657
+ ring.appendChild(circle);
3658
+ return circle;
3659
+ };
3660
+ ringCircle("tr-ring-bg");
3661
+ const ringFill = ringCircle("tr-ring-fill");
3662
+ const RING_LENGTH = 2 * Math.PI * 27;
3663
+ ringFill.style.strokeDasharray = String(RING_LENGTH);
3336
3664
  const top = mk3("div", "tr-top");
3337
3665
  const toolBox = mk3("div", "tr-tool");
3338
3666
  const status = mk3("div", "tr-status");
@@ -3534,6 +3862,8 @@ function createUi(o) {
3534
3862
  o.onFast(fast);
3535
3863
  });
3536
3864
  const tapBtn = ctl(t("ui.tap"), () => o.onTap());
3865
+ const comboBtn = o.combo ? ctl(`${o.combo.label} (N)`, () => o.combo.onToggle()) : null;
3866
+ if (o.kick) ctl(o.kick.label, () => o.kick.onKick());
3537
3867
  const fullscreenBtn = ctl(t("ui.fullscreen"), () => void toggleFullscreen());
3538
3868
  fullscreenBtn.title = t("ui.fullscreen.hint");
3539
3869
  const blastBtns = ["shelter", "fragmentHits", "confinement"].map((key) => ({ key, b: ctl(t(`ui.blast.${key}`), () => o.onBlast(key)) }));
@@ -3616,7 +3946,7 @@ function createUi(o) {
3616
3946
  o.onResize();
3617
3947
  };
3618
3948
  toggle.onclick = togglePanels;
3619
- root.append(cross, top, bottom, help, left, right, toggle);
3949
+ root.append(cross, ring, top, bottom, help, left, right, toggle);
3620
3950
  const bar = (value, max, cls) => {
3621
3951
  const b = mk3("div", `tr-bar ${cls}`);
3622
3952
  const i = mk3("i");
@@ -3697,6 +4027,10 @@ function createUi(o) {
3697
4027
  let lastInspect = "";
3698
4028
  let hudPage = 0;
3699
4029
  const api = {
4030
+ holdRing(value) {
4031
+ ring.style.display = value === null ? "none" : "block";
4032
+ if (value !== null) ringFill.style.strokeDashoffset = String(RING_LENGTH * (1 - value));
4033
+ },
3700
4034
  hud(s) {
3701
4035
  toolBox.textContent = "";
3702
4036
  toolBox.append(document.createTextNode(s.toolName));
@@ -3745,6 +4079,7 @@ function createUi(o) {
3745
4079
  }
3746
4080
  slotsEl.forEach((b, i) => b.classList.toggle("on", i === s.slot));
3747
4081
  tapBtn.classList.toggle("on", s.tap);
4082
+ comboBtn?.classList.toggle("on", !!s.combo);
3748
4083
  for (const { key: key2, b } of blastBtns) b.classList.toggle("on", s.blast[key2]);
3749
4084
  count.textContent = t("ui.objects", { n: s.objects });
3750
4085
  const ak = JSON.stringify(s.animations);
@@ -3794,6 +4129,19 @@ function createUi(o) {
3794
4129
 
3795
4130
  // src/room.ts
3796
4131
  var FIXED_DT = 1 / 60;
4132
+ var SOLID_SPAN = 3;
4133
+ var STEP_UP = 0.45;
4134
+ var BODY_HEIGHT = 1.75;
4135
+ var KICK_MASS = 6;
4136
+ var KICK_HEIGHT = 0.6;
4137
+ var GROUND_ID = "room_ground";
4138
+ var TERRAIN_CELL = 0.1;
4139
+ var TERRAIN_MAX_CELLS = 1e3;
4140
+ var GROUND_BURST_MAX = 28;
4141
+ var GROUND_BURST_PER_METRE = 30;
4142
+ var GROUND_DUST_RADIUS = 0.2;
4143
+ var GROUND_FIRE_EVERY = 0.3;
4144
+ var SHOVE_SPEED = 0.04;
3797
4145
  var METRICS_INTERVAL_S = 0.25;
3798
4146
  var CARRY_NEAR = 0.62;
3799
4147
  var CARRY_PALM = 0.012;
@@ -3853,10 +4201,12 @@ function createTestRoom(options) {
3853
4201
  const ownWorld = !options.world;
3854
4202
  const world = options.world ?? createDestruction({ seed: 1, ...options.worldOptions, materials: [...options.worldOptions?.materials ?? []] });
3855
4203
  const pristineWorld = ownWorld ? world.serialize() : null;
3856
- const topOk = (t2) => {
3857
- const p = world.part(t2.id, t2.topPart);
3858
- return !!p && p.status !== "broken" && p.status !== "melted" && p.status !== "ashes";
4204
+ const partStands = (object, part) => {
4205
+ const p = world.part(object, part);
4206
+ return !!p && p.status !== "broken" && p.status !== "melted" && p.status !== "ashes" && !renderer.piece(object);
3859
4207
  };
4208
+ const topOk = (t2) => t2.boards ? t2.boards.some((b) => partStands(t2.id, b.part)) : partStands(t2.id, t2.topPart);
4209
+ const topHolds = (t2, x, z) => t2.boards ? t2.boards.some((b) => x > b.minX && x < b.maxX && z > b.minZ && z < b.maxZ && partStands(t2.id, b.part)) : topOk(t2);
3860
4210
  const restingTops = [];
3861
4211
  const restingTopAt = (x, z) => {
3862
4212
  let top = 0;
@@ -3865,10 +4215,18 @@ function createTestRoom(options) {
3865
4215
  };
3866
4216
  const sceneryTopAt = (x, z) => {
3867
4217
  let top = 0;
3868
- for (const t2 of arena.tops) if (t2.top > top && x > t2.minX && x < t2.maxX && z > t2.minZ && z < t2.maxZ && topOk(t2)) top = t2.top;
4218
+ for (const t2 of arena.tops) if (t2.top > top && x > t2.minX && x < t2.maxX && z > t2.minZ && z < t2.maxZ && topHolds(t2, x, z)) top = t2.top;
3869
4219
  return top;
3870
4220
  };
3871
- const groundAt = (x, z) => Math.max(sceneryTopAt(x, z), restingTopAt(x, z));
4221
+ const defaultGround = options.ground ?? { surface: DEFAULT_PROFILE };
4222
+ const terrain = createTerrain({ bounds: [-arenaSize, -arenaSize, arenaSize, arenaSize], cellSize: Math.max(TERRAIN_CELL, 2 * arenaSize / TERRAIN_MAX_CELLS), layout: defaultGround, ...options.terrainBudget ? { budget: options.terrainBudget } : {} });
4223
+ const terrainView = createTerrainRenderer(THREE, { terrain });
4224
+ arena.group.add(terrainView.group);
4225
+ const groundAt = (x, z) => {
4226
+ const top = Math.max(sceneryTopAt(x, z), restingTopAt(x, z));
4227
+ const floor = terrain.heightAt(x, z);
4228
+ return top > 0 ? Math.max(top, floor) : floor;
4229
+ };
3872
4230
  const ownFluids = !options.fluids;
3873
4231
  const fluids = options.fluids ?? createFluids({ seed: 1, locale, liquids: options.liquids, groundAt });
3874
4232
  const pristineFluids = ownFluids ? fluids.serialize() : null;
@@ -3889,13 +4247,6 @@ function createTestRoom(options) {
3889
4247
  destructionSound?.onSound(key, point, volume);
3890
4248
  options.onSound?.(key, point, volume);
3891
4249
  };
3892
- const craterField = options.craters ? createGroundField() : void 0;
3893
- const craterPatch = craterField ? createGroundPatch(THREE, { field: craterField, material: new THREE.MeshStandardMaterial({ color: 9342602, roughness: 0.95 }) }) : void 0;
3894
- if (craterPatch) {
3895
- const floor = arena.group.getObjectByName("floor");
3896
- if (floor) floor.visible = false;
3897
- scene.add(craterPatch.mesh);
3898
- }
3899
4250
  const vfx = createVfxRenderer(THREE, {
3900
4251
  scene,
3901
4252
  camera,
@@ -3904,8 +4255,7 @@ function createTestRoom(options) {
3904
4255
  pointScale,
3905
4256
  vfx: createVfx({
3906
4257
  profile: quality,
3907
- groundAt: (x, z) => groundAt(x, z) + (craterField?.heightAt(x, z) ?? 0),
3908
- ...craterField ? { ground: craterField } : {}
4258
+ groundAt
3909
4259
  })
3910
4260
  });
3911
4261
  const renderer = createDestructionRenderer(THREE, {
@@ -3926,6 +4276,7 @@ function createTestRoom(options) {
3926
4276
  const e = entries.get(objectId);
3927
4277
  if (!e) return;
3928
4278
  e.piece = piece;
4279
+ leaveFootprint(objectId);
3929
4280
  rendererPieces.set(objectId, piece);
3930
4281
  action("loosened", t("act.loosened", { name: e.name }), { objectId, itemId: e.so.itemId });
3931
4282
  },
@@ -3950,6 +4301,31 @@ function createTestRoom(options) {
3950
4301
  if (best) splitOf.set(piece.objectId, best);
3951
4302
  }
3952
4303
  });
4304
+ const showGroundBlow = (result) => {
4305
+ const material = terrain.material(result.removed[0]?.material ?? result.exposed);
4306
+ const def = world.materials.get(material.id);
4307
+ const size = Math.min(GROUND_BURST_MAX, Math.ceil(result.radius * GROUND_BURST_PER_METRE));
4308
+ renderer.emit(material.debris === "clods" ? "dust" : material.debris, result.point, [0, 1, 0], Math.max(2, size), Math.min(4, 1 + result.radius * 4));
4309
+ if (result.radius > GROUND_DUST_RADIUS) renderer.emit("dust", result.point, [0, 1, 0], size, 1.4);
4310
+ playSound(impactKey(def), result.point, Math.min(1, 0.25 + result.radius));
4311
+ };
4312
+ terrain.onDeform((region) => renderer.wakeRegion(region.x, region.z, region.radius));
4313
+ const groundImpl = createTerrainImpl(terrain, { id: GROUND_ID, name: t("ground.name"), onBlow: showGroundBlow });
4314
+ const registerGround = () => {
4315
+ if (!world.has(GROUND_ID)) world.register(groundImpl, { id: GROUND_ID });
4316
+ };
4317
+ registerGround();
4318
+ let groundFireClock = 0;
4319
+ const showGroundFire = (dt) => {
4320
+ groundFireClock += dt;
4321
+ if (groundFireClock < GROUND_FIRE_EVERY) return;
4322
+ groundFireClock = 0;
4323
+ for (const [x, z] of terrain.burningPoints()) {
4324
+ const y = terrain.heightAt(x, z);
4325
+ renderer.emit("fire", [x, y + 0.05, z], [0, 1, 0], 3, 1.6);
4326
+ if (rnd() < 0.25) renderer.emit("smoke", [x, y + 0.2, z], [0, 1, 0], 1, 1);
4327
+ }
4328
+ };
3953
4329
  const fluidView = createFluidRenderer(THREE, { scene, fluids, quality, pointScale, vfx: vfx.world });
3954
4330
  const filmRandom = createFilmLcg(1);
3955
4331
  const film = createFilmManager(THREE, {
@@ -4084,12 +4460,79 @@ function createTestRoom(options) {
4084
4460
  let useHeld = false;
4085
4461
  let useFirst = false;
4086
4462
  const blockers = /* @__PURE__ */ new Set();
4463
+ const drawnScenery = (i) => {
4464
+ const c = arena.colliders[i];
4465
+ return arena.colliderIds[i] !== null && c[2] - c[0] <= SOLID_SPAN && c[3] - c[1] <= SOLID_SPAN;
4466
+ };
4467
+ const standingColliders = [];
4468
+ const standingIds = [];
4469
+ const footprintsWithBody = /* @__PURE__ */ new Set();
4470
+ const standFootprints = () => {
4471
+ standingColliders.length = 0;
4472
+ standingIds.length = 0;
4473
+ footprintsWithBody.clear();
4474
+ arena.colliders.forEach((collider, i) => {
4475
+ if (drawnScenery(i)) return;
4476
+ standingColliders.push(collider);
4477
+ standingIds.push(arena.colliderIds[i] ?? null);
4478
+ });
4479
+ };
4480
+ standFootprints();
4481
+ const leaveFootprint = (objectId) => {
4482
+ const i = standingIds.indexOf(objectId);
4483
+ if (i < 0) return;
4484
+ standingIds.splice(i, 1);
4485
+ standingColliders.splice(i, 1);
4486
+ footprintsWithBody.add(objectId);
4487
+ };
4488
+ const footing = (x, z, radius, feet) => {
4489
+ let floor = -Infinity;
4490
+ let blocked = false;
4491
+ for (const s of renderer.solidsNear(x, z, radius)) {
4492
+ if ((s.piece?.mass ?? s.loose?.mass ?? Infinity) < KICK_MASS) continue;
4493
+ const dx = Math.max(s.min[0] - x, 0, x - s.max[0]);
4494
+ const dz = Math.max(s.min[2] - z, 0, z - s.max[2]);
4495
+ if (dx * dx + dz * dz > radius * radius) continue;
4496
+ if (s.max[1] <= feet + STEP_UP) floor = Math.max(floor, s.max[1]);
4497
+ else if (s.min[1] < feet + BODY_HEIGHT) blocked = true;
4498
+ }
4499
+ return { floor, blocked };
4500
+ };
4501
+ const kickFrom = { x: 0, z: 0 };
4502
+ const kickLight = (dt) => {
4503
+ const x = player.x;
4504
+ const z = player.z;
4505
+ const speed = dt > 0 ? Math.hypot(x - kickFrom.x, z - kickFrom.z) / dt : 0;
4506
+ kickFrom.x = x;
4507
+ kickFrom.z = z;
4508
+ if (speed < 0.2 || speed > 20) return;
4509
+ const kicked = /* @__PURE__ */ new Set();
4510
+ for (const s of renderer.solidsNear(x, z, PLAYER_RADIUS)) {
4511
+ const pc = s.piece;
4512
+ const body = pc ?? s.loose;
4513
+ if (!body || body.mass >= KICK_MASS || kicked.has(body) || s.min[1] > player.feet + KICK_HEIGHT) continue;
4514
+ kicked.add(body);
4515
+ const cx = (s.min[0] + s.max[0]) / 2 - x;
4516
+ const cz = (s.min[2] + s.max[2]) / 2 - z;
4517
+ const d = Math.hypot(cx, cz) || 1;
4518
+ const v = speed * 1.2;
4519
+ if (!pc) {
4520
+ s.loose?.push(cx / d * v, cz / d * v);
4521
+ continue;
4522
+ }
4523
+ if (pc.velocity[0] * cx + pc.velocity[2] * cz >= v * d) continue;
4524
+ pc.velocity = [cx / d * v, Math.max(pc.velocity[1], 0.6), cz / d * v];
4525
+ pc.sleeping = false;
4526
+ }
4527
+ };
4087
4528
  const player = createPlayer(THREE, {
4088
4529
  camera,
4089
4530
  dom,
4090
- colliders: arena.colliders,
4531
+ colliders: standingColliders,
4532
+ footing: (x, z, radius, feet) => footing(x, z, radius, feet),
4091
4533
  blockers,
4092
4534
  limit: arenaSize,
4535
+ floorAt: terrain.heightAt,
4093
4536
  start: arena.playerStart,
4094
4537
  crouchKeys: options.crouch?.keys,
4095
4538
  hooks: {
@@ -4113,6 +4556,68 @@ function createTestRoom(options) {
4113
4556
  let counter = 0;
4114
4557
  let slot = 0;
4115
4558
  const maxCarry = options.maxCarryKg ?? 60;
4559
+ const PUSH_AHEAD = 0.6;
4560
+ const heaviestMaterial = (id) => {
4561
+ let best;
4562
+ for (const p of world.state(id)?.parts ?? []) if (p.status !== "broken" && (!best || p.mass > best.mass)) best = { mass: p.mass, material: p.material };
4563
+ return best?.material;
4564
+ };
4565
+ const leanedOn = () => {
4566
+ const fx = -Math.sin(player.yaw);
4567
+ const fz = -Math.cos(player.yaw);
4568
+ let best = null;
4569
+ let bestDistance = PUSH_REACH;
4570
+ for (const e of entries.values()) {
4571
+ if (e.arena || !e.piece && !e.free) continue;
4572
+ const b = bodyBox(e);
4573
+ if (!b) continue;
4574
+ const dx = Math.max(b.minX, Math.min(b.maxX, player.x)) - player.x;
4575
+ const dz = Math.max(b.minZ, Math.min(b.maxZ, player.z)) - player.z;
4576
+ const distance = Math.hypot(dx, dz);
4577
+ if (distance >= bestDistance || distance > 1e-3 && (dx * fx + dz * fz) / distance < PUSH_AHEAD) continue;
4578
+ best = e;
4579
+ bestDistance = distance;
4580
+ }
4581
+ return best ? { objectId: best.so.objectId, root: best.so.root, name: nameOf(best.so.objectId), piece: best.piece ?? null } : null;
4582
+ };
4583
+ const push = createRoomPush({
4584
+ THREE,
4585
+ player,
4586
+ world,
4587
+ arena: { limit: arenaSize, colliders: standingColliders },
4588
+ blockers,
4589
+ ...options.push ? { rules: options.push } : {},
4590
+ pick: () => {
4591
+ if (heldPiece || tool.def.category !== "hand" && toolId !== "fists") return null;
4592
+ aim();
4593
+ const r = rayWorld([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], 3.2);
4594
+ const aimed = r ? rendererPieces.get(r.object)?.root ?? (entries.get(r.object)?.free ? entries.get(r.object)?.so.root : void 0) : void 0;
4595
+ if (r && aimed) return { objectId: r.object, root: aimed, name: nameOf(r.object), piece: rendererPieces.get(r.object) ?? null };
4596
+ return leanedOn();
4597
+ },
4598
+ massOf: (objectId) => massOfObject(objectId),
4599
+ materialOf: heaviestMaterial,
4600
+ makePiece: (objectId) => {
4601
+ const piece = renderer.makePiece(objectId, { mass: Math.max(0.2, massOfObject(objectId)) });
4602
+ const e = entries.get(objectId);
4603
+ if (e) e.piece = piece;
4604
+ rendererPieces.set(objectId, piece);
4605
+ leaveFootprint(objectId);
4606
+ return piece;
4607
+ },
4608
+ dragSound: (objectId, point, volume) => playSound(dragSoundKey(world.materials.get(heaviestMaterial(objectId) ?? "")), point, volume),
4609
+ hitSound: (objectId, point) => playSound(impactKey(world.materials.get(heaviestMaterial(objectId) ?? "")), point, 0.5),
4610
+ onEvent: (ev) => {
4611
+ const vars = { name: ev.name, kg: num(ev.massKg, 0) };
4612
+ 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 } });
4613
+ fire("push", ev);
4614
+ if (ev.type === "engaged") say(t("hud.push_on", vars));
4615
+ else if (ev.type === "too_heavy") say(t("hud.push_too_heavy", vars));
4616
+ else if (ev.type === "obstacle") say(t("hud.push_blocked", vars));
4617
+ },
4618
+ ring: (value) => ui?.holdRing(value)
4619
+ });
4620
+ let pushByKey = false;
4116
4621
  const massOfObject = (id) => {
4117
4622
  const st = world.state(id);
4118
4623
  let m = 0;
@@ -4156,7 +4661,9 @@ function createTestRoom(options) {
4156
4661
  scene.add(root);
4157
4662
  root.updateMatrixWorld(true);
4158
4663
  world.add(structuredCloneSpec(o.spec), { id: o.id, position: o.position, ...o.rotationY ? { rotation: [0, Math.sin(o.rotationY / 2), 0, Math.cos(o.rotationY / 2)] } : {} });
4159
- registerEntry(o.id, o.id, root, o.spec, o.label ?? S.text[`arena.${o.id}`] ?? o.id, void 0, void 0, true, false);
4664
+ const e = registerEntry(o.id, o.id, root, o.spec, o.label ?? S.text[`arena.${o.id}`] ?? o.id, void 0, void 0, true, false);
4665
+ if (o.free) e.free = true;
4666
+ e.follower = o.follow?.(root, world, o.id) || null;
4160
4667
  }
4161
4668
  };
4162
4669
  addArena();
@@ -4178,6 +4685,45 @@ function createTestRoom(options) {
4178
4685
  Object.defineProperty(holder, "visible", { get: () => built.visible, set: (v) => void (built.visible = v), configurable: true });
4179
4686
  return holder;
4180
4687
  };
4688
+ const sceneryObjects = /* @__PURE__ */ new Map();
4689
+ const placeScenery = (objectId, item, position, rotationY) => {
4690
+ const root = buildRoot(item);
4691
+ root.position.set(position[0], position[1], position[2]);
4692
+ root.rotation.y = rotationY;
4693
+ root.name = item.id;
4694
+ scene.add(root);
4695
+ root.updateMatrixWorld(true);
4696
+ const own = item.impl?.(root, objectId);
4697
+ const { spec, parts } = own ? { spec: structuredCloneSpec(own.describe()), parts: void 0 } : resolveSpec(item, root);
4698
+ root.updateMatrixWorld(true);
4699
+ const q = root.getWorldQuaternion(tmpQ);
4700
+ if (own) world.register(own, { id: objectId, position, rotation: [q.x, q.y, q.z, q.w] });
4701
+ else world.add(spec, { id: objectId, position, rotation: [q.x, q.y, q.z, q.w] });
4702
+ const e = registerEntry(objectId, item.id, root, spec, item.name, parts, item, true, false);
4703
+ fire("spawn", e.so);
4704
+ return e;
4705
+ };
4706
+ const restoreScenery = () => {
4707
+ for (const [objectId, o] of sceneryObjects) if (!entries.has(objectId)) placeScenery(objectId, o.item, o.position, o.rotationY);
4708
+ };
4709
+ const addScenery = (itemId, position, rotationY = 0) => {
4710
+ const item = catalogMap.get(itemId);
4711
+ if (!item) return null;
4712
+ const objectId = `${item.id}_scenery_${sceneryObjects.size + 1}`;
4713
+ sceneryObjects.set(objectId, { item, position: [position[0], position[1], position[2]], rotationY });
4714
+ const e = placeScenery(objectId, item, position, rotationY);
4715
+ action("spawn", t("act.spawn", { name: item.name, x: num(position[0], 1), z: num(position[2], 1) }), { objectId, itemId: item.id, data: { position, movable: false, scenery: true } });
4716
+ return {
4717
+ objectId,
4718
+ get root() {
4719
+ return entries.get(objectId)?.so.root ?? e.so.root;
4720
+ },
4721
+ remove() {
4722
+ sceneryObjects.delete(objectId);
4723
+ removeEntry(objectId);
4724
+ }
4725
+ };
4726
+ };
4181
4727
  const spawn = (itemId, position, rotationY) => {
4182
4728
  const item = catalogMap.get(itemId);
4183
4729
  if (!item) throw new Error(`game-test-room: unknown item "${itemId}"`);
@@ -4229,6 +4775,77 @@ function createTestRoom(options) {
4229
4775
  fire("spawn", e.so);
4230
4776
  return e.so;
4231
4777
  };
4778
+ const STEP_OVER = STEP_UP;
4779
+ const HEADROOM = 1.9;
4780
+ const PUSH_WALK_N = 250;
4781
+ const PUSH_RUN_N = 450;
4782
+ const FLOOR_FRICTION = 0.5;
4783
+ const bodyBoxes = /* @__PURE__ */ new Map();
4784
+ const bodyBounds = new THREE.Box3();
4785
+ let frameStamp = 0;
4786
+ const bodyBox = (e) => {
4787
+ const id = e.so.objectId;
4788
+ const known = bodyBoxes.get(id);
4789
+ if (known && (known.stamp === frameStamp || !e.piece || e.piece.sleeping)) return known;
4790
+ if (!e.so.root.parent || world.state(id)?.destroyed) {
4791
+ bodyBoxes.delete(id);
4792
+ return null;
4793
+ }
4794
+ bodyBounds.setFromObject(e.so.root);
4795
+ if (bodyBounds.isEmpty()) return null;
4796
+ const box2 = { minX: bodyBounds.min.x, maxX: bodyBounds.max.x, minZ: bodyBounds.min.z, maxZ: bodyBounds.max.z, base: bodyBounds.min.y, top: bodyBounds.max.y, stamp: frameStamp };
4797
+ bodyBoxes.set(id, box2);
4798
+ return box2;
4799
+ };
4800
+ const footPos = new THREE.Vector3();
4801
+ const inTheWay = (e, x, z, r) => {
4802
+ if (e.arena && !footprintsWithBody.has(e.so.objectId) || e.piece && e.piece.held) return null;
4803
+ const ud = e.so.root.userData;
4804
+ if ("collider" in ud && !ud.collider?.kind) return null;
4805
+ const b = bodyBox(e);
4806
+ if (!b || b.base > player.feet + HEADROOM) return null;
4807
+ const c = ud.collider;
4808
+ if (c?.kind === "cylinder" && c.radius != null) {
4809
+ e.so.root.getWorldPosition(footPos);
4810
+ const reach = c.radius + r;
4811
+ return (x - footPos.x) ** 2 + (z - footPos.z) ** 2 < reach * reach ? { ...b, top: Math.min(b.top, footPos.y + (c.height ?? b.top - footPos.y)) } : null;
4812
+ }
4813
+ return x > b.minX - r && x < b.maxX + r && z > b.minZ - r && z < b.maxZ + r ? b : null;
4814
+ };
4815
+ blockers.add((x, z, r) => {
4816
+ for (const e of entries.values()) {
4817
+ const b = inTheWay(e, x, z, r);
4818
+ if (b && b.top - b.base >= STEP_OVER) return true;
4819
+ }
4820
+ return false;
4821
+ });
4822
+ const pushByPlayer = (dt) => {
4823
+ const [wx, wz] = player.takeWish();
4824
+ const len = Math.hypot(wx, wz);
4825
+ if (dt <= 0 || len < 1e-6) return;
4826
+ const dx = wx / len;
4827
+ const dz = wz / len;
4828
+ const speed = len / dt;
4829
+ const force = player.running ? PUSH_RUN_N : PUSH_WALK_N;
4830
+ const ax = player.x + dx * (Math.min(len, 0.5) + 0.03);
4831
+ const az = player.z + dz * (Math.min(len, 0.5) + 0.03);
4832
+ for (const e of entries.values()) {
4833
+ const piece = e.piece;
4834
+ if (!piece) continue;
4835
+ const b = inTheWay(e, ax, az, PLAYER_RADIUS);
4836
+ if (!b) continue;
4837
+ const grip = FLOOR_FRICTION * piece.mass * 9.81;
4838
+ if (force <= grip) continue;
4839
+ const along = piece.velocity[0] * dx + piece.velocity[2] * dz;
4840
+ const impulse = Math.min(piece.mass * Math.max(0, speed - along), (force - grip) * dt);
4841
+ if (impulse <= 1e-4) continue;
4842
+ const y = (b.base + Math.max(b.base, Math.min(b.top, player.feet + 1.4))) / 2;
4843
+ const point = [Math.max(b.minX, Math.min(b.maxX, player.x)), y, Math.max(b.minZ, Math.min(b.maxZ, player.z))];
4844
+ piece.sleeping = false;
4845
+ renderer.impulse(e.so.objectId, [dx * impulse, 0, dz * impulse], point);
4846
+ log.coalesce(`bump:${e.so.objectId}`, { channel: "action", type: "bump", text: t("act.bump", { name: e.name, kg: num(piece.mass, 1) }), objectId: e.so.objectId, itemId: e.so.itemId, data: { massKg: Math.round(piece.mass * 10) / 10, pushN: force } }, 1);
4847
+ }
4848
+ };
4232
4849
  const massGuess = (spec) => {
4233
4850
  let m = 0;
4234
4851
  for (const p of spec.parts) {
@@ -4251,6 +4868,7 @@ function createTestRoom(options) {
4251
4868
  renderer.unbind(id);
4252
4869
  } catch {
4253
4870
  }
4871
+ e.follower?.dispose?.();
4254
4872
  if (world.has(id)) world.remove(id);
4255
4873
  e.so.root.removeFromParent();
4256
4874
  letGo(e.so.root);
@@ -4280,11 +4898,13 @@ function createTestRoom(options) {
4280
4898
  explosives.clear();
4281
4899
  fireZones.length = 0;
4282
4900
  burningStains.length = 0;
4901
+ terrain.setLayout(defaultGround);
4283
4902
  for (const id of [...entries.keys()]) if (!entries.get(id)?.arena) removeEntry(id);
4284
4903
  clearLoose();
4285
4904
  for (const e of entries.values()) {
4286
4905
  world.reset(e.so.objectId);
4287
4906
  renderer.restore(e.so.objectId);
4907
+ e.follower?.restore?.();
4288
4908
  }
4289
4909
  renderer.clear();
4290
4910
  vfx.world.clear();
@@ -4306,6 +4926,7 @@ function createTestRoom(options) {
4306
4926
  if (heldEntry === e) continue;
4307
4927
  world.reset(e.so.objectId);
4308
4928
  renderer.restore(e.so.objectId);
4929
+ e.follower?.restore?.();
4309
4930
  if (e.containerId) {
4310
4931
  const c = fluids.container(e.containerId);
4311
4932
  const f = e.item?.fluid?.container;
@@ -4315,6 +4936,8 @@ function createTestRoom(options) {
4315
4936
  action("reset", t("act.reset"));
4316
4937
  };
4317
4938
  const hardReset = () => {
4939
+ pushByKey = false;
4940
+ push.end();
4318
4941
  clearEverything();
4319
4942
  for (const id of [...entries.keys()]) removeEntry(id);
4320
4943
  tools.clear();
@@ -4322,6 +4945,7 @@ function createTestRoom(options) {
4322
4945
  if (pristineWorld) {
4323
4946
  for (const id of world.ids()) world.remove(id);
4324
4947
  world.load(pristineWorld);
4948
+ registerGround();
4325
4949
  }
4326
4950
  if (pristineFluids) {
4327
4951
  fluids.load(pristineFluids);
@@ -4335,6 +4959,7 @@ function createTestRoom(options) {
4335
4959
  prevVy.clear();
4336
4960
  lastImpact.clear();
4337
4961
  addArena();
4962
+ standFootprints();
4338
4963
  timeScale = 1;
4339
4964
  fastForward = false;
4340
4965
  walkPlan = null;
@@ -4399,10 +5024,11 @@ function createTestRoom(options) {
4399
5024
  }
4400
5025
  feetPrev = [x, z];
4401
5026
  avatarState.running = !player.crouching && (player.keys.has("ShiftLeft") || player.keys.has("ShiftRight"));
4402
- avatarState.crouch = crouchForced || player.crouching;
5027
+ avatarState.crouch = crouchForced || player.crouching || push.isBent;
4403
5028
  avatarState.armed = armedForced ?? tool.def.action.kind === "firearm";
4404
5029
  avatarState.pitch = player.pitch;
4405
5030
  avatarState.holding = heldPiece !== null;
5031
+ avatarState.push = push.avatarState;
4406
5032
  avatarState.itemUse = useItemPlayer ? { id: useItemName, ...useItemPlayer.pose() } : heldPiece && heldTilt > 0.01 ? { id: "tilt", position: [0.2, -0.1, -0.38], rotation: [-heldTilt, 0, 0] } : null;
4407
5033
  avatar.root.position.set(x, player.feet, z);
4408
5034
  avatar.root.rotation.set(0, player.yaw + Math.PI, 0);
@@ -4418,11 +5044,11 @@ function createTestRoom(options) {
4418
5044
  const stepFirstPerson = (dt) => {
4419
5045
  const arms = avatar?.firstPerson;
4420
5046
  if (!arms) return;
4421
- const targets = handSource?.() ?? carriedHand;
5047
+ const targets = push.handTargets() ?? handSource?.() ?? carriedHand;
4422
5048
  const state = {
4423
5049
  camera,
4424
5050
  targets,
4425
- stance: handStances[toolId] ?? (targets ? "hold" : "relaxed"),
5051
+ stance: push.active ? "hold" : handStances[toolId] ?? (targets ? "hold" : "relaxed"),
4426
5052
  speed: avatarState.speed,
4427
5053
  running: avatarState.running,
4428
5054
  crouch: avatarState.crouch,
@@ -4443,6 +5069,19 @@ function createTestRoom(options) {
4443
5069
  camera.updateMatrixWorld(true);
4444
5070
  };
4445
5071
  };
5072
+ const KICK_HIP = 0.9;
5073
+ const KICK_DROP = 0.35;
5074
+ const legAim = () => {
5075
+ aim();
5076
+ const low = aimO[1] < player.feet + KICK_HIP;
5077
+ const pitch = Math.max(-0.9, Math.min(0.1, player.pitch - (low ? 0 : KICK_DROP)));
5078
+ aimO[0] = player.x;
5079
+ aimO[1] = Math.min(player.feet + KICK_HIP, aimO[1]);
5080
+ aimO[2] = player.z;
5081
+ aimD[0] = -Math.sin(player.yaw) * Math.cos(pitch);
5082
+ aimD[1] = Math.sin(pitch);
5083
+ aimD[2] = -Math.cos(player.yaw) * Math.cos(pitch);
5084
+ };
4446
5085
  const aim = () => {
4447
5086
  camera.updateMatrixWorld(true);
4448
5087
  camera.getWorldPosition(tmpV);
@@ -4609,6 +5248,53 @@ function createTestRoom(options) {
4609
5248
  if (!rendererPieces.has(objectId)) return;
4610
5249
  renderer.impulse(objectId, [dir[0] * momentum, dir[1] * momentum, dir[2] * momentum], point);
4611
5250
  };
5251
+ const shotRay = new THREE.Raycaster();
5252
+ const LIQUID_SLOWING = 0.25;
5253
+ const SHOT_GRIP = 0.3;
5254
+ const shotPush = (s, objectId, results, fallback) => {
5255
+ const e = entries.get(objectId);
5256
+ const speed = Math.max(80, s.speed);
5257
+ const onIt = results.filter((r) => r.object === objectId);
5258
+ const last = onIt[onIt.length - 1];
5259
+ let kept = last && last.penetrated ? Math.sqrt(Math.max(0, Math.min(1, last.exitEnergy / Math.max(s.energy, 1e-6)))) : 0;
5260
+ let point = fallback;
5261
+ let inward = [s.direction[0], s.direction[1], s.direction[2]];
5262
+ if (e) {
5263
+ shotRay.set(new THREE.Vector3(...s.origin), new THREE.Vector3(...s.direction).normalize());
5264
+ shotRay.far = 60;
5265
+ shotRay.camera = camera;
5266
+ const hit = shotRay.intersectObject(e.so.root, true).find((h) => h.object.isMesh && !h.object.userData.destructionDebris && h.face);
5267
+ if (hit?.face) {
5268
+ point = [hit.point.x, hit.point.y, hit.point.z];
5269
+ const n = hit.face.normal.clone().transformDirection(hit.object.matrixWorld);
5270
+ if (n.x * s.direction[0] + n.y * s.direction[1] + n.z * s.direction[2] > 0) n.negate();
5271
+ inward = [-n.x, -n.y, -n.z];
5272
+ }
5273
+ const c = kept > 0 && e.containerId ? fluids.container(e.containerId) : null;
5274
+ if (c && c.liters > 1e-4) {
5275
+ const size = new THREE.Box3().setFromObject(e.so.root).getSize(new THREE.Vector3());
5276
+ kept *= Math.exp(-(Math.min(size.x, size.z) * 0.9) / LIQUID_SLOWING);
5277
+ }
5278
+ }
5279
+ const momentum = 2 * s.energy / speed * (1 - kept);
5280
+ const along = s.direction[0] * inward[0] + s.direction[1] * inward[1] + s.direction[2] * inward[2];
5281
+ const impulse = [0, 1, 2].map((k) => momentum * (along * inward[k] + SHOT_GRIP * (s.direction[k] - along * inward[k])));
5282
+ return { impulse, point };
5283
+ };
5284
+ const shoveFree = (pushes) => {
5285
+ for (const [objectId, push2] of pushes) {
5286
+ const e = entries.get(objectId);
5287
+ if (!e || e.piece || !e.free) continue;
5288
+ const mass = Math.max(0.2, massOfObject(objectId));
5289
+ if (Math.hypot(...push2.impulse) / mass < SHOVE_SPEED) continue;
5290
+ const piece = renderer.makePiece(objectId, { mass });
5291
+ e.piece = piece;
5292
+ rendererPieces.set(objectId, piece);
5293
+ leaveFootprint(objectId);
5294
+ action("loosened", t("act.shoved", { name: e.name }), { objectId, itemId: e.so.itemId });
5295
+ renderer.impulse(objectId, push2.impulse, push2.point);
5296
+ }
5297
+ };
4612
5298
  const fireShots = (shots, recoil) => {
4613
5299
  if (shots.length === 0) return;
4614
5300
  const w = toolName();
@@ -4618,6 +5304,7 @@ function createTestRoom(options) {
4618
5304
  let hits = 0;
4619
5305
  let first = null;
4620
5306
  let energy = 0;
5307
+ const pushes = /* @__PURE__ */ new Map();
4621
5308
  withoutHeld(() => {
4622
5309
  for (const s of shots) {
4623
5310
  energy = Math.max(energy, s.energy);
@@ -4628,12 +5315,16 @@ function createTestRoom(options) {
4628
5315
  hits++;
4629
5316
  end = r0.point;
4630
5317
  if (!first) first = { object: r0.object, part: r0.part, point: r0.point, normal: [0, 1, 0], distance: dist3(s.origin, r0.point) };
4631
- const speed = Math.max(80, s.speed);
4632
- pushPiece(r0.object, s.direction, 2 * s.energy / speed, r0.point);
5318
+ const blow = shotPush(s, r0.object, results, r0.point);
5319
+ if (rendererPieces.has(r0.object)) renderer.impulse(r0.object, blow.impulse, blow.point);
5320
+ const push2 = pushes.get(r0.object) ?? { impulse: [0, 0, 0], point: blow.point };
5321
+ for (let k = 0; k < 3; k++) push2.impulse[k] += blow.impulse[k];
5322
+ pushes.set(r0.object, push2);
4633
5323
  }
4634
5324
  if (shots.length <= 3 || rnd() < 0.34) tracer(m, end);
4635
5325
  }
4636
5326
  });
5327
+ shoveFree(pushes);
4637
5328
  player.kick(recoil[0], recoil[1]);
4638
5329
  const f = first;
4639
5330
  const d = f ? num(f.distance, 1) : "";
@@ -4706,8 +5397,22 @@ function createTestRoom(options) {
4706
5397
  1
4707
5398
  );
4708
5399
  };
4709
- const meleeUse = (u, id = toolId) => {
5400
+ const kickHeights = options.kickHeights ?? [0.25, 0.35, 0.45, 0.55, 0.65, 0.75, 0.85];
5401
+ const footRay = (u) => {
5402
+ const flat = Math.hypot(u.direction[0], u.direction[2]);
5403
+ const direction = flat > 1e-6 ? [u.direction[0] / flat, 0, u.direction[2] / flat] : [-Math.sin(player.yaw), 0, -Math.cos(player.yaw)];
5404
+ const feet = player.position;
5405
+ let best = null;
5406
+ for (const height2 of kickHeights) {
5407
+ const origin = [feet[0], feet[1] + height2, feet[2]];
5408
+ const hit = rayWorld(origin, direction, u.reach);
5409
+ if (hit && (!best || hit.distance < best.distance)) best = { origin, distance: hit.distance };
5410
+ }
5411
+ return best ? { ...u, origin: best.origin, direction } : u;
5412
+ };
5413
+ const meleeUse = (use, id = toolId) => {
4710
5414
  const w = toolName(id);
5415
+ const u = id === "kick" ? footRay(use) : use;
4711
5416
  const r = rayWorld(u.origin, u.direction, u.reach);
4712
5417
  if (!r) {
4713
5418
  action("melee", t("act.melee.miss", { weapon: w }), { data: { weapon: id } });
@@ -4725,21 +5430,52 @@ function createTestRoom(options) {
4725
5430
  let strikes = [];
4726
5431
  const kickLeg = options.kickLeg ?? "alternate";
4727
5432
  let nextLeg = "right";
5433
+ const punchHand = options.punchHand ?? "alternate";
5434
+ const punchTools = new Set(options.punchTools ?? ["hand", "fists"]);
5435
+ let nextHand = "right";
5436
+ const combo = options.strikeCombo;
5437
+ let comboOn = !!combo && options.strikeComboOn === true;
5438
+ let comboNext = 0;
5439
+ let comboAt = -Infinity;
5440
+ const setStrikeCombo = (on) => {
5441
+ if (!combo || on === comboOn) return;
5442
+ comboOn = on;
5443
+ comboNext = 0;
5444
+ const name = combo.name?.[locale] ?? combo.id;
5445
+ action("combo", t(on ? "act.combo.on" : "act.combo.off", { name }), { data: { combo: combo.id, on } });
5446
+ say(t(on ? "act.combo.on" : "act.combo.off", { name }));
5447
+ };
4728
5448
  const strikeUse = (u, id = toolId) => {
4729
- const clip = strikeClips[id];
5449
+ const stepIndex = comboOn && combo?.tools.includes(id) && combo.steps.length > 0 ? simTime - comboAt > combo.reset ? 0 : comboNext : null;
5450
+ const step2 = stepIndex === null ? void 0 : combo.steps[stepIndex];
5451
+ const clip = step2 ? step2.clip : strikeClips[id];
4730
5452
  const leg = id === "kick" ? kickLeg === "alternate" ? nextLeg : kickLeg : void 0;
4731
- const seconds = clip === void 0 ? null : avatar?.strike?.(clip, leg ? { leg } : void 0) ?? null;
5453
+ const hand = step2 ? step2.hand : punchTools.has(id) ? punchHand === "alternate" ? nextHand : punchHand : void 0;
5454
+ const side = leg ? { leg } : hand ? { hand } : void 0;
5455
+ const seconds = clip === void 0 ? null : avatar?.strike?.(clip, side) ?? null;
4732
5456
  if (leg && seconds !== null && kickLeg === "alternate") nextLeg = leg === "right" ? "left" : "right";
5457
+ if (!step2 && hand && seconds !== null && punchHand === "alternate") nextHand = hand === "right" ? "left" : "right";
5458
+ if (step2 && stepIndex !== null) {
5459
+ comboNext = (stepIndex + 1) % combo.steps.length;
5460
+ comboAt = simTime;
5461
+ }
4733
5462
  if (clip === void 0 || seconds === null) {
4734
- meleeUse(u, id);
5463
+ if (leg) {
5464
+ legAim();
5465
+ meleeUse({ ...u, origin: [aimO[0], aimO[1], aimO[2]], direction: [aimD[0], aimD[1], aimD[2]] }, id);
5466
+ } else meleeUse(u, id);
4735
5467
  return;
4736
5468
  }
4737
- action("strike", t(leg ? "act.strike.leg" : "act.strike", { weapon: toolName(id), clip, seconds: num(seconds, 2), leg: leg ? t(`leg.${leg}`) : "" }), { data: { weapon: id, clip, impactIn: seconds, ...leg ? { leg } : {} } });
5469
+ const text = leg ? "act.strike.leg" : hand ? "act.strike.hand" : "act.strike";
5470
+ action("strike", t(text, { weapon: toolName(id), clip, seconds: num(seconds, 2), leg: leg ? t(`leg.${leg}`) : "", hand: hand ? t(`hand.${hand}`) : "" }), {
5471
+ data: { weapon: id, clip, impactIn: seconds, ...leg ? { leg } : {}, ...hand ? { hand } : {}, ...step2 ? { combo: combo.id, step: stepIndex } : {} }
5472
+ });
4738
5473
  strikes.push({
4739
5474
  key: id,
4740
5475
  remaining: seconds,
4741
5476
  land: () => {
4742
- aim();
5477
+ if (leg) legAim();
5478
+ else aim();
4743
5479
  meleeUse({ ...u, origin: [aimO[0], aimO[1], aimO[2]], direction: [aimD[0], aimD[1], aimD[2]] }, id);
4744
5480
  }
4745
5481
  });
@@ -4925,12 +5661,14 @@ function createTestRoom(options) {
4925
5661
  if (res) applyUses(res, 0);
4926
5662
  };
4927
5663
  const kick = () => {
4928
- if (!arsenal.weapons.has("kick") || strikes.some((strike) => strike.key === "kick")) return;
5664
+ if (!arsenal.weapons.has("kick") || strikes.some((strike) => strike.key === "kick")) return false;
4929
5665
  aim();
4930
5666
  const res = toolState("kick").trigger([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], 0, false, "player");
4931
- if (res.status !== "ok") return;
5667
+ if (res.status !== "ok") return false;
4932
5668
  for (const u of res.uses) if (u.kind === "melee") strikeUse(u, "kick");
5669
+ return true;
4933
5670
  };
5671
+ const kickKeys = options.kick?.keys ?? ["KeyK"];
4934
5672
  const setTool = (id) => {
4935
5673
  if (!arsenal.weapons.has(id)) throw new Error(`game-test-room: unknown weapon "${id}"`);
4936
5674
  releaseTrigger();
@@ -4978,6 +5716,7 @@ function createTestRoom(options) {
4978
5716
  else useTool(FIXED_DT, false);
4979
5717
  };
4980
5718
  const grab = () => {
5719
+ if (push.active) return false;
4981
5720
  if (heldPiece) {
4982
5721
  const name = heldName;
4983
5722
  releaseHeld();
@@ -5193,7 +5932,7 @@ function createTestRoom(options) {
5193
5932
  const reach = Math.max(size.x, size.y, size.z) * 0.5;
5194
5933
  const origin = [centre.x + dir[0] * (reach + 0.02), centre.y + dir[1] * (reach + 0.02), centre.z + dir[2] * (reach + 0.02)];
5195
5934
  const r = withoutHeld(() => world.raycast(origin, dir, speed * FIXED_DT * 2 + 0.06));
5196
- if (!r || r.object === piece.objectId) return;
5935
+ if (!r || r.object === piece.objectId || r.object === GROUND_ID) return;
5197
5936
  const energy = 0.5 * piece.mass * speed * speed;
5198
5937
  lastImpact.set(piece.objectId, simTime);
5199
5938
  world.hitPart(r.object, r.part, { type: "blunt", point: r.point, direction: dir, energy, radius: 0.08, source: "thrown" });
@@ -5206,12 +5945,21 @@ function createTestRoom(options) {
5206
5945
  data: { energyJ: Math.round(energy), speedMs: Math.round(speed * 10) / 10, target: r.object, targetPart: r.part }
5207
5946
  });
5208
5947
  };
5948
+ const strikeGroundWith = (piece, energy) => {
5949
+ const box2 = tmpBox.setFromObject(piece.root);
5950
+ const centre = box2.getCenter(tmpV);
5951
+ const size = box2.getSize(tmpV2);
5952
+ if (sceneryTopAt(centre.x, centre.z) > 0 || restingTopAt(centre.x, centre.z) > 0) return;
5953
+ const result = terrain.strike("blunt", { x: centre.x, z: centre.z, energy, contactRadius: Math.max(size.x, size.z) / 2 });
5954
+ if (result.radius > 0) showGroundBlow(result);
5955
+ };
5209
5956
  const land = (piece) => {
5210
5957
  const vy = piece.velocity[1];
5211
5958
  const prev = prevVy.get(piece.objectId) ?? 0;
5212
5959
  prevVy.set(piece.objectId, vy);
5213
5960
  if (piece.held || prev > -MIN_LANDING_LOG || vy < prev + 2 || !world.has(piece.objectId)) return;
5214
5961
  const energy = 0.5 * piece.mass * prev * prev;
5962
+ strikeGroundWith(piece, energy);
5215
5963
  action("landing", t("act.landing", { name: nameOf(piece.objectId), speed: num(-prev, 1), energy: num(energy, energy < 100 ? 1 : 0) }), {
5216
5964
  objectId: piece.objectId,
5217
5965
  itemId: entryOf(piece.objectId)?.so.itemId,
@@ -5232,6 +5980,10 @@ function createTestRoom(options) {
5232
5980
  const onKey = (code, down, e) => {
5233
5981
  if (!down) {
5234
5982
  if (code === "KeyZ") tiltKey = 0;
5983
+ if (code === "KeyE" && pushByKey) {
5984
+ pushByKey = false;
5985
+ if (push.end()) grab();
5986
+ }
5235
5987
  return;
5236
5988
  }
5237
5989
  const digit = /^Digit(\d)$/.exec(code);
@@ -5242,9 +5994,15 @@ function createTestRoom(options) {
5242
5994
  if (idx < loadout.length) setTool(loadout[idx]);
5243
5995
  return;
5244
5996
  }
5997
+ if (kickKeys.includes(code) && !e.ctrlKey && !e.metaKey) {
5998
+ kick();
5999
+ return;
6000
+ }
5245
6001
  switch (code) {
5246
6002
  case "KeyE":
5247
- grab();
6003
+ if (e.repeat) break;
6004
+ pushByKey = !heldPiece && push.begin();
6005
+ if (!pushByKey) grab();
5248
6006
  break;
5249
6007
  case "KeyG":
5250
6008
  throwHeld();
@@ -5287,6 +6045,9 @@ function createTestRoom(options) {
5287
6045
  case "KeyY":
5288
6046
  explosives.detonateCharges();
5289
6047
  break;
6048
+ case "KeyN":
6049
+ setStrikeCombo(!comboOn);
6050
+ break;
5290
6051
  case "KeyH": {
5291
6052
  const w = (heldEntry ?? entryOf(inspectTarget()?.object ?? ""))?.item?.weapon;
5292
6053
  if (w) equip(w);
@@ -5415,8 +6176,9 @@ function createTestRoom(options) {
5415
6176
  }
5416
6177
  syncLiters();
5417
6178
  renderer.update(dt);
6179
+ terrainView.sync(camera.position);
6180
+ showGroundFire(dt);
5418
6181
  vfx.update(dt);
5419
- craterPatch?.sync();
5420
6182
  updateImpacts();
5421
6183
  fluidView.update(dt);
5422
6184
  filmBridge.update(dt);
@@ -5440,12 +6202,21 @@ function createTestRoom(options) {
5440
6202
  };
5441
6203
  const update = (dt) => {
5442
6204
  if (!ownsLoop) roomMetrics.frame(dt);
6205
+ frameStamp++;
5443
6206
  player.update(dt);
6207
+ if (pushByKey && !player.keys.has("KeyE")) {
6208
+ pushByKey = false;
6209
+ push.end();
6210
+ }
6211
+ push.step(paused ? 0 : dt);
6212
+ if (!paused) kickLight(dt);
6213
+ if (!paused) pushByPlayer(dt);
6214
+ else player.takeWish();
5444
6215
  stepAvatar(paused ? 0 : dt);
5445
6216
  if (tiltKey || heldTilt > 0) {
5446
6217
  heldTilt = Math.max(0, Math.min(2.4, heldTilt + (tiltKey ? 2.2 : -3) * dt));
5447
6218
  }
5448
- if ((useHeld || useFirst) && !paused) {
6219
+ if ((useHeld || useFirst) && !paused && !push.active) {
5449
6220
  if (tool.def.category === "hand") {
5450
6221
  if (useFirst) handUse();
5451
6222
  } else if (useHeld) useTool(dt, !useFirst);
@@ -5487,6 +6258,7 @@ function createTestRoom(options) {
5487
6258
  timeScale,
5488
6259
  fast: fastForward,
5489
6260
  tap: isTapOn(),
6261
+ combo: combo ? comboOn : null,
5490
6262
  blast: { shelter: blastOn("shelter"), fragmentHits: blastOn("fragmentHits"), confinement: blastOn("confinement") },
5491
6263
  quality: qualityName,
5492
6264
  camera: avatar ? cameraMode : null,
@@ -5762,6 +6534,9 @@ function createTestRoom(options) {
5762
6534
  onClear(listener) {
5763
6535
  clearListeners.add(listener);
5764
6536
  return () => clearListeners.delete(listener);
6537
+ },
6538
+ addObject(itemId, place) {
6539
+ return addScenery(itemId, place.position, place.rotationY);
5765
6540
  }
5766
6541
  };
5767
6542
  let sendingInitial = false;
@@ -5916,6 +6691,7 @@ function createTestRoom(options) {
5916
6691
  const n = Math.max(1, Math.round(seconds / dt));
5917
6692
  for (let i = 0; i < n; i++) {
5918
6693
  player.update(0);
6694
+ push.step(dt);
5919
6695
  followHeld(dt);
5920
6696
  stepSim(dt);
5921
6697
  }
@@ -5958,6 +6734,7 @@ function createTestRoom(options) {
5958
6734
  } finally {
5959
6735
  scenarioRunning = false;
5960
6736
  paused = wasPaused;
6737
+ restoreScenery();
5961
6738
  }
5962
6739
  return report;
5963
6740
  };
@@ -5991,6 +6768,7 @@ function createTestRoom(options) {
5991
6768
  const openArea = (area) => {
5992
6769
  clear();
5993
6770
  const mod = [...modules.values()].find((m) => m.id === area || m.group === area);
6771
+ if (mod?.ground) terrain.setLayout(mod.ground);
5994
6772
  const label = mod?.group ?? S.groups[area] ?? area;
5995
6773
  ui?.showGroup(label);
5996
6774
  activeBench = mod && benches.has(mod.id) ? mod.id : lastBench();
@@ -6157,7 +6935,13 @@ function createTestRoom(options) {
6157
6935
  setFastForward(on) {
6158
6936
  fastForward = on;
6159
6937
  },
6938
+ setStrikeCombo,
6939
+ kick,
6940
+ get strikeCombo() {
6941
+ return combo ? comboOn : null;
6942
+ },
6160
6943
  equip,
6944
+ terrain,
6161
6945
  openArea,
6162
6946
  modules: () => [...modules.values()].map((m) => ({ id: m.id, name: m.name, group: m.group, hasArea: typeof m.area === "function" })),
6163
6947
  openAreaAsync: (area) => loading.busy(t("loading.open_area", { area: areaLabel(area) }), () => openArea(area)),
@@ -6209,6 +6993,12 @@ function createTestRoom(options) {
6209
6993
  debugView.updateProjectionMatrix();
6210
6994
  debugView.updateMatrixWorld(true);
6211
6995
  },
6996
+ get playerPosition() {
6997
+ return player.position;
6998
+ },
6999
+ push: { begin: () => push.begin(), end: () => push.end(), setWalk: (walk) => push.setWalk(walk), get state() {
7000
+ return push.state;
7001
+ } },
6212
7002
  setCameraMode,
6213
7003
  get cameraMode() {
6214
7004
  return cameraMode;
@@ -6230,7 +7020,6 @@ function createTestRoom(options) {
6230
7020
  loading.dispose();
6231
7021
  renderer.dispose();
6232
7022
  vfx.dispose();
6233
- craterPatch?.dispose();
6234
7023
  fluidView.dispose();
6235
7024
  filmBridge.dispose();
6236
7025
  film.dispose();
@@ -6308,6 +7097,8 @@ function createTestRoom(options) {
6308
7097
  setTool(loadout[index]);
6309
7098
  },
6310
7099
  onTap: () => toggleTap(),
7100
+ ...combo ? { combo: { label: combo.name?.[locale] ?? combo.id, onToggle: () => setStrikeCombo(!comboOn) } } : {},
7101
+ ...arsenal.weapons.has("kick") ? { kick: { label: `${t("ui.kick")} (${kickKeys.map((key) => key.replace(/^Key/, "")).join("/")})`, onKick: () => void kick() } } : {},
6311
7102
  onBlast: (key) => toggleBlast(key),
6312
7103
  onPlay: (objectId, clipId) => {
6313
7104
  playAnimation(clipId, objectId);