@adatechnology/game-test-room 0.16.0 → 0.17.1

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
@@ -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;
1922
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;
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" };
@@ -2349,6 +2625,8 @@ var en = {
2349
2625
  "act.strike.hand": "{weapon}: {clip} with the {hand} hand begins, impact in {seconds} s",
2350
2626
  "hand.left": "left",
2351
2627
  "hand.right": "right",
2628
+ "act.combo.on": "{name}: on (each blow follows the sequence)",
2629
+ "act.combo.off": "{name}: off",
2352
2630
  "act.flame.hit": "{weapon}: flame at {temp} \xB0C on {where}",
2353
2631
  "act.flame.air": "{weapon}: flame, nothing in range",
2354
2632
  "act.heat.hit": "{weapon}: heating {where} at {temp} \xB0C",
@@ -2372,8 +2650,10 @@ var en = {
2372
2650
  "act.extinguish.hit": "{weapon}: foam on {what}",
2373
2651
  "act.extinguish.air": "{weapon}: foam, nothing in range",
2374
2652
  "act.grab": "Grabbed {name} ({kg} kg)",
2653
+ "act.bump": "Pushed {name} ({kg} kg) walking into it",
2375
2654
  "act.drop": "Dropped {name}",
2376
2655
  "act.throw": "Threw {name} at {speed} m/s",
2656
+ "ground.name": "Ground",
2377
2657
  "act.landing": "{name} landed at {speed} m/s: {energy} J",
2378
2658
  "act.bench_failed": "{name} failed: {error}",
2379
2659
  "loading.working": "Working\u2026",
@@ -2407,6 +2687,14 @@ var en = {
2407
2687
  "hud.nothing_to_grab": "Nothing to grab here",
2408
2688
  "hud.cannot_grab": "{name} cannot be picked up",
2409
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}",
2410
2698
  "hud.empty_container": "{name} is empty",
2411
2699
  "hud.refused": "Wrong tool for the {kind}",
2412
2700
  "hud.drop": "drop",
@@ -2414,7 +2702,7 @@ var en = {
2414
2702
  "hud.throw": "throw",
2415
2703
  "hud.tilt": "tilt (hold)",
2416
2704
  "hud.equip": "equip {weapon}",
2417
- "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",
2418
2706
  "ui.help.camera": "V: 1st/3rd person (Shift+V: kick)",
2419
2707
  "camera.first": "1st person",
2420
2708
  "camera.third": "3rd person",
@@ -2461,6 +2749,7 @@ var en = {
2461
2749
  "ui.slow": "Slow motion 0.25\xD7",
2462
2750
  "ui.fast": "Time \xD710",
2463
2751
  "ui.tap": "Tap",
2752
+ "ui.kick": "Kick",
2464
2753
  "ui.fullscreen": "Full screen",
2465
2754
  "ui.fullscreen.hint": "Full screen opened from here keeps the whole keyboard in the game, Ctrl+W included (F11 does not)",
2466
2755
  "ui.blast.shelter": "Shelter",
@@ -2660,6 +2949,8 @@ var ptBR = {
2660
2949
  "act.strike.hand": "{weapon}: {clip} com a m\xE3o {hand} come\xE7a, impacto em {seconds} s",
2661
2950
  "hand.left": "esquerda",
2662
2951
  "hand.right": "direita",
2952
+ "act.combo.on": "{name}: ligada (cada golpe segue a sequ\xEAncia)",
2953
+ "act.combo.off": "{name}: desligada",
2663
2954
  "act.flame.hit": "{weapon}: chama a {temp} \xB0C em {where}",
2664
2955
  "act.flame.air": "{weapon}: chama, nada ao alcance",
2665
2956
  "act.heat.hit": "{weapon}: esquentando {where} a {temp} \xB0C",
@@ -2683,8 +2974,10 @@ var ptBR = {
2683
2974
  "act.extinguish.hit": "{weapon}: espuma em {what}",
2684
2975
  "act.extinguish.air": "{weapon}: espuma, nada ao alcance",
2685
2976
  "act.grab": "Pegou {name} ({kg} kg)",
2977
+ "act.bump": "Empurrou {name} ({kg} kg) ao esbarrar",
2686
2978
  "act.drop": "Soltou {name}",
2687
2979
  "act.throw": "Arremessou {name} a {speed} m/s",
2980
+ "ground.name": "Ch\xE3o",
2688
2981
  "act.landing": "{name} caiu a {speed} m/s: {energy} J",
2689
2982
  "act.bench_failed": "{name} falhou: {error}",
2690
2983
  "loading.working": "Trabalhando\u2026",
@@ -2718,6 +3011,14 @@ var ptBR = {
2718
3011
  "hud.nothing_to_grab": "Nada para pegar aqui",
2719
3012
  "hud.cannot_grab": "{name} n\xE3o d\xE1 para pegar",
2720
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}",
2721
3022
  "hud.empty_container": "{name} est\xE1 vazio",
2722
3023
  "hud.refused": "Ferramenta errada para {kind}",
2723
3024
  "hud.drop": "soltar",
@@ -2725,7 +3026,7 @@ var ptBR = {
2725
3026
  "hud.throw": "arremessar",
2726
3027
  "hud.tilt": "inclinar (segurar)",
2727
3028
  "hud.equip": "equipar {weapon}",
2728
- "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",
2729
3030
  "ui.help.camera": "V: 1\xAA/3\xAA pessoa (Shift+V: chute)",
2730
3031
  "camera.first": "1\xAA pessoa",
2731
3032
  "camera.third": "3\xAA pessoa",
@@ -2772,6 +3073,7 @@ var ptBR = {
2772
3073
  "ui.slow": "C\xE2mera lenta 0,25\xD7",
2773
3074
  "ui.fast": "Tempo \xD710",
2774
3075
  "ui.tap": "Torneira",
3076
+ "ui.kick": "Chute",
2775
3077
  "ui.fullscreen": "Tela cheia",
2776
3078
  "ui.fullscreen.hint": "A tela cheia aberta por aqui segura o teclado todo no jogo, inclusive Ctrl+W (o F11 n\xE3o)",
2777
3079
  "ui.blast.shelter": "Abrigo",
@@ -3238,6 +3540,8 @@ var CSS = `
3238
3540
  .tr-cross{position:absolute;left:50%;top:50%;width:18px;height:18px;margin:-9px 0 0 -9px}
3239
3541
  .tr-cross:before,.tr-cross:after{content:"";position:absolute;background:#fff;opacity:.85;box-shadow:0 0 2px #000}
3240
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}
3241
3545
  .tr-cross i{position:absolute;left:6px;top:6px;width:6px;height:6px;background:#0b0c0e;border-radius:50%;z-index:1}
3242
3546
  .tr-top{position:absolute;top:8px;left:50%;transform:translateX(-50%);text-align:center;max-width:46vw}
3243
3547
  .tr-tool{display:inline-block;background:rgba(10,12,16,.72);padding:4px 12px;border-radius:6px;font-size:14px;font-weight:600}
@@ -3341,6 +3645,22 @@ function createUi(o) {
3341
3645
  o.container.appendChild(root);
3342
3646
  const cross = mk3("div", "tr-cross");
3343
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);
3344
3664
  const top = mk3("div", "tr-top");
3345
3665
  const toolBox = mk3("div", "tr-tool");
3346
3666
  const status = mk3("div", "tr-status");
@@ -3542,6 +3862,8 @@ function createUi(o) {
3542
3862
  o.onFast(fast);
3543
3863
  });
3544
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());
3545
3867
  const fullscreenBtn = ctl(t("ui.fullscreen"), () => void toggleFullscreen());
3546
3868
  fullscreenBtn.title = t("ui.fullscreen.hint");
3547
3869
  const blastBtns = ["shelter", "fragmentHits", "confinement"].map((key) => ({ key, b: ctl(t(`ui.blast.${key}`), () => o.onBlast(key)) }));
@@ -3624,7 +3946,7 @@ function createUi(o) {
3624
3946
  o.onResize();
3625
3947
  };
3626
3948
  toggle.onclick = togglePanels;
3627
- root.append(cross, top, bottom, help, left, right, toggle);
3949
+ root.append(cross, ring, top, bottom, help, left, right, toggle);
3628
3950
  const bar = (value, max, cls) => {
3629
3951
  const b = mk3("div", `tr-bar ${cls}`);
3630
3952
  const i = mk3("i");
@@ -3705,6 +4027,10 @@ function createUi(o) {
3705
4027
  let lastInspect = "";
3706
4028
  let hudPage = 0;
3707
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
+ },
3708
4034
  hud(s) {
3709
4035
  toolBox.textContent = "";
3710
4036
  toolBox.append(document.createTextNode(s.toolName));
@@ -3753,6 +4079,7 @@ function createUi(o) {
3753
4079
  }
3754
4080
  slotsEl.forEach((b, i) => b.classList.toggle("on", i === s.slot));
3755
4081
  tapBtn.classList.toggle("on", s.tap);
4082
+ comboBtn?.classList.toggle("on", !!s.combo);
3756
4083
  for (const { key: key2, b } of blastBtns) b.classList.toggle("on", s.blast[key2]);
3757
4084
  count.textContent = t("ui.objects", { n: s.objects });
3758
4085
  const ak = JSON.stringify(s.animations);
@@ -3802,6 +4129,18 @@ function createUi(o) {
3802
4129
 
3803
4130
  // src/room.ts
3804
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;
3805
4144
  var SHOVE_SPEED = 0.04;
3806
4145
  var METRICS_INTERVAL_S = 0.25;
3807
4146
  var CARRY_NEAR = 0.62;
@@ -3862,10 +4201,12 @@ function createTestRoom(options) {
3862
4201
  const ownWorld = !options.world;
3863
4202
  const world = options.world ?? createDestruction({ seed: 1, ...options.worldOptions, materials: [...options.worldOptions?.materials ?? []] });
3864
4203
  const pristineWorld = ownWorld ? world.serialize() : null;
3865
- const topOk = (t2) => {
3866
- const p = world.part(t2.id, t2.topPart);
3867
- 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);
3868
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);
3869
4210
  const restingTops = [];
3870
4211
  const restingTopAt = (x, z) => {
3871
4212
  let top = 0;
@@ -3874,10 +4215,18 @@ function createTestRoom(options) {
3874
4215
  };
3875
4216
  const sceneryTopAt = (x, z) => {
3876
4217
  let top = 0;
3877
- 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;
3878
4219
  return top;
3879
4220
  };
3880
- 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
+ };
3881
4230
  const ownFluids = !options.fluids;
3882
4231
  const fluids = options.fluids ?? createFluids({ seed: 1, locale, liquids: options.liquids, groundAt });
3883
4232
  const pristineFluids = ownFluids ? fluids.serialize() : null;
@@ -3898,13 +4247,6 @@ function createTestRoom(options) {
3898
4247
  destructionSound?.onSound(key, point, volume);
3899
4248
  options.onSound?.(key, point, volume);
3900
4249
  };
3901
- const craterField = options.craters ? createGroundField() : void 0;
3902
- const craterPatch = craterField ? createGroundPatch(THREE, { field: craterField, material: new THREE.MeshStandardMaterial({ color: 9342602, roughness: 0.95 }) }) : void 0;
3903
- if (craterPatch) {
3904
- const floor = arena.group.getObjectByName("floor");
3905
- if (floor) floor.visible = false;
3906
- scene.add(craterPatch.mesh);
3907
- }
3908
4250
  const vfx = createVfxRenderer(THREE, {
3909
4251
  scene,
3910
4252
  camera,
@@ -3913,8 +4255,7 @@ function createTestRoom(options) {
3913
4255
  pointScale,
3914
4256
  vfx: createVfx({
3915
4257
  profile: quality,
3916
- groundAt: (x, z) => groundAt(x, z) + (craterField?.heightAt(x, z) ?? 0),
3917
- ...craterField ? { ground: craterField } : {}
4258
+ groundAt
3918
4259
  })
3919
4260
  });
3920
4261
  const renderer = createDestructionRenderer(THREE, {
@@ -3935,6 +4276,7 @@ function createTestRoom(options) {
3935
4276
  const e = entries.get(objectId);
3936
4277
  if (!e) return;
3937
4278
  e.piece = piece;
4279
+ leaveFootprint(objectId);
3938
4280
  rendererPieces.set(objectId, piece);
3939
4281
  action("loosened", t("act.loosened", { name: e.name }), { objectId, itemId: e.so.itemId });
3940
4282
  },
@@ -3959,6 +4301,31 @@ function createTestRoom(options) {
3959
4301
  if (best) splitOf.set(piece.objectId, best);
3960
4302
  }
3961
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
+ };
3962
4329
  const fluidView = createFluidRenderer(THREE, { scene, fluids, quality, pointScale, vfx: vfx.world });
3963
4330
  const filmRandom = createFilmLcg(1);
3964
4331
  const film = createFilmManager(THREE, {
@@ -4093,12 +4460,79 @@ function createTestRoom(options) {
4093
4460
  let useHeld = false;
4094
4461
  let useFirst = false;
4095
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
+ };
4096
4528
  const player = createPlayer(THREE, {
4097
4529
  camera,
4098
4530
  dom,
4099
- colliders: arena.colliders,
4531
+ colliders: standingColliders,
4532
+ footing: (x, z, radius, feet) => footing(x, z, radius, feet),
4100
4533
  blockers,
4101
4534
  limit: arenaSize,
4535
+ floorAt: terrain.heightAt,
4102
4536
  start: arena.playerStart,
4103
4537
  crouchKeys: options.crouch?.keys,
4104
4538
  hooks: {
@@ -4122,6 +4556,68 @@ function createTestRoom(options) {
4122
4556
  let counter = 0;
4123
4557
  let slot = 0;
4124
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;
4125
4621
  const massOfObject = (id) => {
4126
4622
  const st = world.state(id);
4127
4623
  let m = 0;
@@ -4189,6 +4685,45 @@ function createTestRoom(options) {
4189
4685
  Object.defineProperty(holder, "visible", { get: () => built.visible, set: (v) => void (built.visible = v), configurable: true });
4190
4686
  return holder;
4191
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
+ };
4192
4727
  const spawn = (itemId, position, rotationY) => {
4193
4728
  const item = catalogMap.get(itemId);
4194
4729
  if (!item) throw new Error(`game-test-room: unknown item "${itemId}"`);
@@ -4240,6 +4775,77 @@ function createTestRoom(options) {
4240
4775
  fire("spawn", e.so);
4241
4776
  return e.so;
4242
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
+ };
4243
4849
  const massGuess = (spec) => {
4244
4850
  let m = 0;
4245
4851
  for (const p of spec.parts) {
@@ -4292,6 +4898,7 @@ function createTestRoom(options) {
4292
4898
  explosives.clear();
4293
4899
  fireZones.length = 0;
4294
4900
  burningStains.length = 0;
4901
+ terrain.setLayout(defaultGround);
4295
4902
  for (const id of [...entries.keys()]) if (!entries.get(id)?.arena) removeEntry(id);
4296
4903
  clearLoose();
4297
4904
  for (const e of entries.values()) {
@@ -4329,6 +4936,8 @@ function createTestRoom(options) {
4329
4936
  action("reset", t("act.reset"));
4330
4937
  };
4331
4938
  const hardReset = () => {
4939
+ pushByKey = false;
4940
+ push.end();
4332
4941
  clearEverything();
4333
4942
  for (const id of [...entries.keys()]) removeEntry(id);
4334
4943
  tools.clear();
@@ -4336,6 +4945,7 @@ function createTestRoom(options) {
4336
4945
  if (pristineWorld) {
4337
4946
  for (const id of world.ids()) world.remove(id);
4338
4947
  world.load(pristineWorld);
4948
+ registerGround();
4339
4949
  }
4340
4950
  if (pristineFluids) {
4341
4951
  fluids.load(pristineFluids);
@@ -4349,6 +4959,7 @@ function createTestRoom(options) {
4349
4959
  prevVy.clear();
4350
4960
  lastImpact.clear();
4351
4961
  addArena();
4962
+ standFootprints();
4352
4963
  timeScale = 1;
4353
4964
  fastForward = false;
4354
4965
  walkPlan = null;
@@ -4413,10 +5024,11 @@ function createTestRoom(options) {
4413
5024
  }
4414
5025
  feetPrev = [x, z];
4415
5026
  avatarState.running = !player.crouching && (player.keys.has("ShiftLeft") || player.keys.has("ShiftRight"));
4416
- avatarState.crouch = crouchForced || player.crouching;
5027
+ avatarState.crouch = crouchForced || player.crouching || push.isBent;
4417
5028
  avatarState.armed = armedForced ?? tool.def.action.kind === "firearm";
4418
5029
  avatarState.pitch = player.pitch;
4419
5030
  avatarState.holding = heldPiece !== null;
5031
+ avatarState.push = push.avatarState;
4420
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;
4421
5033
  avatar.root.position.set(x, player.feet, z);
4422
5034
  avatar.root.rotation.set(0, player.yaw + Math.PI, 0);
@@ -4432,11 +5044,11 @@ function createTestRoom(options) {
4432
5044
  const stepFirstPerson = (dt) => {
4433
5045
  const arms = avatar?.firstPerson;
4434
5046
  if (!arms) return;
4435
- const targets = handSource?.() ?? carriedHand;
5047
+ const targets = push.handTargets() ?? handSource?.() ?? carriedHand;
4436
5048
  const state = {
4437
5049
  camera,
4438
5050
  targets,
4439
- stance: handStances[toolId] ?? (targets ? "hold" : "relaxed"),
5051
+ stance: push.active ? "hold" : handStances[toolId] ?? (targets ? "hold" : "relaxed"),
4440
5052
  speed: avatarState.speed,
4441
5053
  running: avatarState.running,
4442
5054
  crouch: avatarState.crouch,
@@ -4457,6 +5069,19 @@ function createTestRoom(options) {
4457
5069
  camera.updateMatrixWorld(true);
4458
5070
  };
4459
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
+ };
4460
5085
  const aim = () => {
4461
5086
  camera.updateMatrixWorld(true);
4462
5087
  camera.getWorldPosition(tmpV);
@@ -4623,17 +5248,51 @@ function createTestRoom(options) {
4623
5248
  if (!rendererPieces.has(objectId)) return;
4624
5249
  renderer.impulse(objectId, [dir[0] * momentum, dir[1] * momentum, dir[2] * momentum], point);
4625
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
+ };
4626
5284
  const shoveFree = (pushes) => {
4627
- for (const [objectId, push] of pushes) {
5285
+ for (const [objectId, push2] of pushes) {
4628
5286
  const e = entries.get(objectId);
4629
5287
  if (!e || e.piece || !e.free) continue;
4630
5288
  const mass = Math.max(0.2, massOfObject(objectId));
4631
- if (Math.hypot(...push.impulse) / mass < SHOVE_SPEED) continue;
5289
+ if (Math.hypot(...push2.impulse) / mass < SHOVE_SPEED) continue;
4632
5290
  const piece = renderer.makePiece(objectId, { mass });
4633
5291
  e.piece = piece;
4634
5292
  rendererPieces.set(objectId, piece);
5293
+ leaveFootprint(objectId);
4635
5294
  action("loosened", t("act.shoved", { name: e.name }), { objectId, itemId: e.so.itemId });
4636
- renderer.impulse(objectId, push.impulse, push.point);
5295
+ renderer.impulse(objectId, push2.impulse, push2.point);
4637
5296
  }
4638
5297
  };
4639
5298
  const fireShots = (shots, recoil) => {
@@ -4656,12 +5315,11 @@ function createTestRoom(options) {
4656
5315
  hits++;
4657
5316
  end = r0.point;
4658
5317
  if (!first) first = { object: r0.object, part: r0.part, point: r0.point, normal: [0, 1, 0], distance: dist3(s.origin, r0.point) };
4659
- const speed = Math.max(80, s.speed);
4660
- const momentum = 2 * s.energy / speed;
4661
- pushPiece(r0.object, s.direction, momentum, r0.point);
4662
- const push = pushes.get(r0.object) ?? { impulse: [0, 0, 0], point: r0.point };
4663
- for (let k = 0; k < 3; k++) push.impulse[k] += s.direction[k] * momentum;
4664
- pushes.set(r0.object, push);
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);
4665
5323
  }
4666
5324
  if (shots.length <= 3 || rnd() < 0.34) tracer(m, end);
4667
5325
  }
@@ -4739,8 +5397,22 @@ function createTestRoom(options) {
4739
5397
  1
4740
5398
  );
4741
5399
  };
4742
- 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) => {
4743
5414
  const w = toolName(id);
5415
+ const u = id === "kick" ? footRay(use) : use;
4744
5416
  const r = rayWorld(u.origin, u.direction, u.reach);
4745
5417
  if (!r) {
4746
5418
  action("melee", t("act.melee.miss", { weapon: w }), { data: { weapon: id } });
@@ -4761,27 +5433,49 @@ function createTestRoom(options) {
4761
5433
  const punchHand = options.punchHand ?? "alternate";
4762
5434
  const punchTools = new Set(options.punchTools ?? ["hand", "fists"]);
4763
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
+ };
4764
5448
  const strikeUse = (u, id = toolId) => {
4765
- 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];
4766
5452
  const leg = id === "kick" ? kickLeg === "alternate" ? nextLeg : kickLeg : void 0;
4767
- const hand = punchTools.has(id) ? punchHand === "alternate" ? nextHand : punchHand : void 0;
5453
+ const hand = step2 ? step2.hand : punchTools.has(id) ? punchHand === "alternate" ? nextHand : punchHand : void 0;
4768
5454
  const side = leg ? { leg } : hand ? { hand } : void 0;
4769
5455
  const seconds = clip === void 0 ? null : avatar?.strike?.(clip, side) ?? null;
4770
5456
  if (leg && seconds !== null && kickLeg === "alternate") nextLeg = leg === "right" ? "left" : "right";
4771
- if (hand && seconds !== null && punchHand === "alternate") nextHand = hand === "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
+ }
4772
5462
  if (clip === void 0 || seconds === null) {
4773
- 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);
4774
5467
  return;
4775
5468
  }
4776
5469
  const text = leg ? "act.strike.leg" : hand ? "act.strike.hand" : "act.strike";
4777
5470
  action("strike", t(text, { weapon: toolName(id), clip, seconds: num(seconds, 2), leg: leg ? t(`leg.${leg}`) : "", hand: hand ? t(`hand.${hand}`) : "" }), {
4778
- data: { weapon: id, clip, impactIn: seconds, ...leg ? { leg } : {}, ...hand ? { hand } : {} }
5471
+ data: { weapon: id, clip, impactIn: seconds, ...leg ? { leg } : {}, ...hand ? { hand } : {}, ...step2 ? { combo: combo.id, step: stepIndex } : {} }
4779
5472
  });
4780
5473
  strikes.push({
4781
5474
  key: id,
4782
5475
  remaining: seconds,
4783
5476
  land: () => {
4784
- aim();
5477
+ if (leg) legAim();
5478
+ else aim();
4785
5479
  meleeUse({ ...u, origin: [aimO[0], aimO[1], aimO[2]], direction: [aimD[0], aimD[1], aimD[2]] }, id);
4786
5480
  }
4787
5481
  });
@@ -4967,12 +5661,14 @@ function createTestRoom(options) {
4967
5661
  if (res) applyUses(res, 0);
4968
5662
  };
4969
5663
  const kick = () => {
4970
- 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;
4971
5665
  aim();
4972
5666
  const res = toolState("kick").trigger([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], 0, false, "player");
4973
- if (res.status !== "ok") return;
5667
+ if (res.status !== "ok") return false;
4974
5668
  for (const u of res.uses) if (u.kind === "melee") strikeUse(u, "kick");
5669
+ return true;
4975
5670
  };
5671
+ const kickKeys = options.kick?.keys ?? ["KeyK"];
4976
5672
  const setTool = (id) => {
4977
5673
  if (!arsenal.weapons.has(id)) throw new Error(`game-test-room: unknown weapon "${id}"`);
4978
5674
  releaseTrigger();
@@ -5020,6 +5716,7 @@ function createTestRoom(options) {
5020
5716
  else useTool(FIXED_DT, false);
5021
5717
  };
5022
5718
  const grab = () => {
5719
+ if (push.active) return false;
5023
5720
  if (heldPiece) {
5024
5721
  const name = heldName;
5025
5722
  releaseHeld();
@@ -5235,7 +5932,7 @@ function createTestRoom(options) {
5235
5932
  const reach = Math.max(size.x, size.y, size.z) * 0.5;
5236
5933
  const origin = [centre.x + dir[0] * (reach + 0.02), centre.y + dir[1] * (reach + 0.02), centre.z + dir[2] * (reach + 0.02)];
5237
5934
  const r = withoutHeld(() => world.raycast(origin, dir, speed * FIXED_DT * 2 + 0.06));
5238
- if (!r || r.object === piece.objectId) return;
5935
+ if (!r || r.object === piece.objectId || r.object === GROUND_ID) return;
5239
5936
  const energy = 0.5 * piece.mass * speed * speed;
5240
5937
  lastImpact.set(piece.objectId, simTime);
5241
5938
  world.hitPart(r.object, r.part, { type: "blunt", point: r.point, direction: dir, energy, radius: 0.08, source: "thrown" });
@@ -5248,12 +5945,21 @@ function createTestRoom(options) {
5248
5945
  data: { energyJ: Math.round(energy), speedMs: Math.round(speed * 10) / 10, target: r.object, targetPart: r.part }
5249
5946
  });
5250
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
+ };
5251
5956
  const land = (piece) => {
5252
5957
  const vy = piece.velocity[1];
5253
5958
  const prev = prevVy.get(piece.objectId) ?? 0;
5254
5959
  prevVy.set(piece.objectId, vy);
5255
5960
  if (piece.held || prev > -MIN_LANDING_LOG || vy < prev + 2 || !world.has(piece.objectId)) return;
5256
5961
  const energy = 0.5 * piece.mass * prev * prev;
5962
+ strikeGroundWith(piece, energy);
5257
5963
  action("landing", t("act.landing", { name: nameOf(piece.objectId), speed: num(-prev, 1), energy: num(energy, energy < 100 ? 1 : 0) }), {
5258
5964
  objectId: piece.objectId,
5259
5965
  itemId: entryOf(piece.objectId)?.so.itemId,
@@ -5274,6 +5980,10 @@ function createTestRoom(options) {
5274
5980
  const onKey = (code, down, e) => {
5275
5981
  if (!down) {
5276
5982
  if (code === "KeyZ") tiltKey = 0;
5983
+ if (code === "KeyE" && pushByKey) {
5984
+ pushByKey = false;
5985
+ if (push.end()) grab();
5986
+ }
5277
5987
  return;
5278
5988
  }
5279
5989
  const digit = /^Digit(\d)$/.exec(code);
@@ -5284,9 +5994,15 @@ function createTestRoom(options) {
5284
5994
  if (idx < loadout.length) setTool(loadout[idx]);
5285
5995
  return;
5286
5996
  }
5997
+ if (kickKeys.includes(code) && !e.ctrlKey && !e.metaKey) {
5998
+ kick();
5999
+ return;
6000
+ }
5287
6001
  switch (code) {
5288
6002
  case "KeyE":
5289
- grab();
6003
+ if (e.repeat) break;
6004
+ pushByKey = !heldPiece && push.begin();
6005
+ if (!pushByKey) grab();
5290
6006
  break;
5291
6007
  case "KeyG":
5292
6008
  throwHeld();
@@ -5329,6 +6045,9 @@ function createTestRoom(options) {
5329
6045
  case "KeyY":
5330
6046
  explosives.detonateCharges();
5331
6047
  break;
6048
+ case "KeyN":
6049
+ setStrikeCombo(!comboOn);
6050
+ break;
5332
6051
  case "KeyH": {
5333
6052
  const w = (heldEntry ?? entryOf(inspectTarget()?.object ?? ""))?.item?.weapon;
5334
6053
  if (w) equip(w);
@@ -5457,8 +6176,9 @@ function createTestRoom(options) {
5457
6176
  }
5458
6177
  syncLiters();
5459
6178
  renderer.update(dt);
6179
+ terrainView.sync(camera.position);
6180
+ showGroundFire(dt);
5460
6181
  vfx.update(dt);
5461
- craterPatch?.sync();
5462
6182
  updateImpacts();
5463
6183
  fluidView.update(dt);
5464
6184
  filmBridge.update(dt);
@@ -5482,12 +6202,21 @@ function createTestRoom(options) {
5482
6202
  };
5483
6203
  const update = (dt) => {
5484
6204
  if (!ownsLoop) roomMetrics.frame(dt);
6205
+ frameStamp++;
5485
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();
5486
6215
  stepAvatar(paused ? 0 : dt);
5487
6216
  if (tiltKey || heldTilt > 0) {
5488
6217
  heldTilt = Math.max(0, Math.min(2.4, heldTilt + (tiltKey ? 2.2 : -3) * dt));
5489
6218
  }
5490
- if ((useHeld || useFirst) && !paused) {
6219
+ if ((useHeld || useFirst) && !paused && !push.active) {
5491
6220
  if (tool.def.category === "hand") {
5492
6221
  if (useFirst) handUse();
5493
6222
  } else if (useHeld) useTool(dt, !useFirst);
@@ -5529,6 +6258,7 @@ function createTestRoom(options) {
5529
6258
  timeScale,
5530
6259
  fast: fastForward,
5531
6260
  tap: isTapOn(),
6261
+ combo: combo ? comboOn : null,
5532
6262
  blast: { shelter: blastOn("shelter"), fragmentHits: blastOn("fragmentHits"), confinement: blastOn("confinement") },
5533
6263
  quality: qualityName,
5534
6264
  camera: avatar ? cameraMode : null,
@@ -5804,6 +6534,9 @@ function createTestRoom(options) {
5804
6534
  onClear(listener) {
5805
6535
  clearListeners.add(listener);
5806
6536
  return () => clearListeners.delete(listener);
6537
+ },
6538
+ addObject(itemId, place) {
6539
+ return addScenery(itemId, place.position, place.rotationY);
5807
6540
  }
5808
6541
  };
5809
6542
  let sendingInitial = false;
@@ -5958,6 +6691,7 @@ function createTestRoom(options) {
5958
6691
  const n = Math.max(1, Math.round(seconds / dt));
5959
6692
  for (let i = 0; i < n; i++) {
5960
6693
  player.update(0);
6694
+ push.step(dt);
5961
6695
  followHeld(dt);
5962
6696
  stepSim(dt);
5963
6697
  }
@@ -6000,6 +6734,7 @@ function createTestRoom(options) {
6000
6734
  } finally {
6001
6735
  scenarioRunning = false;
6002
6736
  paused = wasPaused;
6737
+ restoreScenery();
6003
6738
  }
6004
6739
  return report;
6005
6740
  };
@@ -6033,6 +6768,7 @@ function createTestRoom(options) {
6033
6768
  const openArea = (area) => {
6034
6769
  clear();
6035
6770
  const mod = [...modules.values()].find((m) => m.id === area || m.group === area);
6771
+ if (mod?.ground) terrain.setLayout(mod.ground);
6036
6772
  const label = mod?.group ?? S.groups[area] ?? area;
6037
6773
  ui?.showGroup(label);
6038
6774
  activeBench = mod && benches.has(mod.id) ? mod.id : lastBench();
@@ -6199,7 +6935,13 @@ function createTestRoom(options) {
6199
6935
  setFastForward(on) {
6200
6936
  fastForward = on;
6201
6937
  },
6938
+ setStrikeCombo,
6939
+ kick,
6940
+ get strikeCombo() {
6941
+ return combo ? comboOn : null;
6942
+ },
6202
6943
  equip,
6944
+ terrain,
6203
6945
  openArea,
6204
6946
  modules: () => [...modules.values()].map((m) => ({ id: m.id, name: m.name, group: m.group, hasArea: typeof m.area === "function" })),
6205
6947
  openAreaAsync: (area) => loading.busy(t("loading.open_area", { area: areaLabel(area) }), () => openArea(area)),
@@ -6251,6 +6993,12 @@ function createTestRoom(options) {
6251
6993
  debugView.updateProjectionMatrix();
6252
6994
  debugView.updateMatrixWorld(true);
6253
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
+ } },
6254
7002
  setCameraMode,
6255
7003
  get cameraMode() {
6256
7004
  return cameraMode;
@@ -6272,7 +7020,6 @@ function createTestRoom(options) {
6272
7020
  loading.dispose();
6273
7021
  renderer.dispose();
6274
7022
  vfx.dispose();
6275
- craterPatch?.dispose();
6276
7023
  fluidView.dispose();
6277
7024
  filmBridge.dispose();
6278
7025
  film.dispose();
@@ -6350,6 +7097,8 @@ function createTestRoom(options) {
6350
7097
  setTool(loadout[index]);
6351
7098
  },
6352
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() } } : {},
6353
7102
  onBlast: (key) => toggleBlast(key),
6354
7103
  onPlay: (objectId, clipId) => {
6355
7104
  playAnimation(clipId, objectId);