@adatechnology/game-test-room 0.11.1 → 0.13.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,5 +1,5 @@
1
1
  // src/room.ts
2
- import { createDestruction, JOINT_DEFAULTS as JOINT_DEFAULTS2, resolveStrings as resolveDestructionStrings } from "@adatechnology/game-destruction";
2
+ import { createDestruction, JOINT_DEFAULTS, resolveStrings as resolveDestructionStrings } from "@adatechnology/game-destruction";
3
3
  import { createAudioEngine, createAudioSink } from "@adatechnology/game-audio";
4
4
  import { createDestructionRenderer, createDestructionSound, specFromObject3D } from "@adatechnology/game-destruction-three";
5
5
  import { createArsenal, DEFAULT_LOADOUT } from "@adatechnology/game-weapons";
@@ -7,1256 +7,283 @@ import { createFilmLcg, createFluids, createItemUse, ITEM_USES, resolveStrings a
7
7
  import { createFilmBridge, createFilmManager, createFluidRenderer } from "@adatechnology/game-fluids-three";
8
8
  import { createGroundField, createVfx } from "@adatechnology/game-vfx";
9
9
  import { createGroundPatch, createVfxRenderer } from "@adatechnology/game-vfx-three";
10
- import { checkAnimation, checkBench, checkDestructible, checkSpec, createEnvironmentBus } from "@adatechnology/game-contracts";
10
+ import { checkAnimation, checkBench, checkDestructible, checkSpec, createEnvironmentBus, shotExtras, useToHit } from "@adatechnology/game-contracts";
11
11
 
12
12
  // src/items.ts
13
- import { buildFrame } from "@adatechnology/game-destruction";
13
+ var box = (id, material, size, position, extra = {}) => ({ id, material, shape: { kind: "box", size }, position, ...extra });
14
+ var joint = (a, b, kind, extra = {}) => ({ a, b, kind, ...extra });
15
+ function brickWallSpec(cols = 8, rows = 6, panel = [1.25, 0.5, 0.2]) {
16
+ const parts = [];
17
+ const joints = [];
18
+ const id = (c, r) => `brick_${r}_${c}`;
19
+ for (let r = 0; r < rows; r++) {
20
+ for (let c = 0; c < cols; c++) {
21
+ parts.push(box(id(c, r), "brick", panel, [(c - (cols - 1) / 2) * panel[0], panel[1] / 2 + r * panel[1], 0], { anchored: r === 0 }));
22
+ if (c > 0) joints.push(joint(id(c - 1, r), id(c, r), "mortar"));
23
+ if (r > 0) joints.push(joint(id(c, r - 1), id(c, r), "mortar"));
24
+ }
25
+ }
26
+ return { name: "brick wall", parts, joints, tags: ["structure"] };
27
+ }
14
28
 
15
- // src/build-from-spec.ts
16
- var MATERIAL_COLORS = {
17
- steel: 9278620,
18
- cast_iron: 4869458,
19
- aluminium: 12896718,
20
- copper: 12088122,
21
- wood_soft: 12158287,
22
- wood_hard: 8014374,
23
- plywood: 13214826,
24
- mdf: 11045732,
25
- glass: 10278630,
26
- tempered_glass: 10278630,
27
- ceramic: 15328474,
28
- concrete: 10132118,
29
- brick: 10831662,
30
- plaster: 15131092,
31
- stone: 7828591,
32
- plastic: 3897540,
33
- rubber: 2039586,
34
- fabric: 9063262,
35
- foam: 14926936,
36
- leather: 5978662,
37
- paper: 15657695,
38
- foliage: 4165434,
39
- green_wood: 7031339,
40
- cactus: 4095306,
41
- flesh_fruit: 13781810,
42
- wax: 15721141,
43
- soil: 4862754
29
+ // src/arena.ts
30
+ var sizeOf = (shape) => shape.kind === "box" ? shape.size : shape.kind === "cylinder" ? [shape.radius * 2, shape.height, shape.radius * 2] : [shape.radius * 2, shape.radius * 2, shape.radius * 2];
31
+ function specBounds(THREE, spec, position, rotationY, options = {}) {
32
+ const out = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity, minZ: Infinity, maxZ: -Infinity };
33
+ const q = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, rotationY, 0));
34
+ const v = new THREE.Vector3();
35
+ const e = new THREE.Euler();
36
+ const pq = new THREE.Quaternion();
37
+ let any = false;
38
+ for (const p of spec.parts) {
39
+ if (options.onlyPart !== void 0 && p.id !== options.onlyPart) continue;
40
+ const [sx, sy, sz] = sizeOf(p.shape);
41
+ pq.setFromEuler(e.set(p.rotation?.[0] ?? 0, p.rotation?.[1] ?? 0, p.rotation?.[2] ?? 0, "XYZ"));
42
+ const corners = [];
43
+ let top = -Infinity;
44
+ for (const cx of [-0.5, 0.5]) for (const cy of [-0.5, 0.5]) for (const cz of [-0.5, 0.5]) {
45
+ v.set(cx * sx, cy * sy, cz * sz).applyQuaternion(pq);
46
+ const c = [v.x + p.position[0], v.y + p.position[1], v.z + p.position[2]];
47
+ top = Math.max(top, c[1]);
48
+ corners.push(c);
49
+ }
50
+ if (top < (options.minHeight ?? 0)) continue;
51
+ any = true;
52
+ for (const c of corners) {
53
+ v.set(c[0], c[1], c[2]).applyQuaternion(q);
54
+ out.minX = Math.min(out.minX, v.x + position[0]);
55
+ out.maxX = Math.max(out.maxX, v.x + position[0]);
56
+ out.minY = Math.min(out.minY, v.y + position[1]);
57
+ out.maxY = Math.max(out.maxY, v.y + position[1]);
58
+ out.minZ = Math.min(out.minZ, v.z + position[2]);
59
+ out.maxZ = Math.max(out.maxZ, v.z + position[2]);
60
+ }
61
+ }
62
+ return any ? out : null;
63
+ }
64
+ var tableSpec = () => {
65
+ const parts = [{ id: "top", material: "wood_hard", shape: { kind: "box", size: [1.6, 0.05, 0.8] }, position: [0, 0.755, 0] }];
66
+ const joints = [];
67
+ for (const [i, [x, z]] of [
68
+ [-0.72, -0.32],
69
+ [0.72, -0.32],
70
+ [-0.72, 0.32],
71
+ [0.72, 0.32]
72
+ ].entries()) {
73
+ parts.push({ id: `leg_${i + 1}`, material: "wood_hard", shape: { kind: "box", size: [0.06, 0.73, 0.06] }, position: [x, 0.365, z], anchored: true });
74
+ joints.push({ a: `leg_${i + 1}`, b: "top", kind: "screw" });
75
+ }
76
+ return { name: "table", parts, joints, tags: ["furniture"] };
44
77
  };
45
- var TRANSPARENT = { glass: 0.38, tempered_glass: 0.38 };
46
- function buildFromSpec(THREE, spec, options = {}) {
47
- const root = new THREE.Group();
48
- root.name = spec.name ?? "object";
49
- const seg = options.segments ?? 12;
50
- const shadows = options.shadows !== false;
51
- const mats = /* @__PURE__ */ new Map();
52
- const materialFor = (p) => {
53
- const color = options.colors?.[p.id] ?? options.colors?.[p.material] ?? MATERIAL_COLORS[p.material] ?? 8947848;
54
- const key = `${p.material}:${color}`;
55
- let m = mats.get(key);
56
- if (!m) {
57
- const alpha = TRANSPARENT[p.material];
58
- const metal = p.material === "steel" || p.material === "aluminium" || p.material === "copper" || p.material === "cast_iron";
59
- m = new THREE.MeshStandardMaterial({
60
- color,
61
- roughness: alpha ? 0.1 : metal ? 0.45 : 0.85,
62
- metalness: metal ? 0.7 : 0,
63
- transparent: !!alpha,
64
- opacity: alpha ?? 1,
65
- depthWrite: !alpha
78
+ var shelfSpec = () => {
79
+ const parts = [];
80
+ const joints = [];
81
+ for (const [i, x] of [-0.6, 0.6].entries()) {
82
+ parts.push({ id: `upright_${i + 1}`, material: "wood_hard", shape: { kind: "box", size: [0.04, 1.7, 0.4] }, position: [x, 0.85, 0], anchored: true });
83
+ }
84
+ for (const [i, y] of [0.4, 0.9, 1.4].entries()) {
85
+ parts.push({ id: `board_${i + 1}`, material: "wood_soft", shape: { kind: "box", size: [1.2, 0.03, 0.4] }, position: [0, y, 0] });
86
+ joints.push({ a: `board_${i + 1}`, b: "upright_1", kind: "screw" }, { a: `board_${i + 1}`, b: "upright_2", kind: "screw" });
87
+ }
88
+ return { name: "shelf", parts, joints, tags: ["furniture"] };
89
+ };
90
+ var defaultScenery = (wallZ) => ({
91
+ target_wall: { spec: brickWallSpec(), position: [0, 0, wallZ], colors: { brick: 10831662 } },
92
+ table: { spec: tableSpec(), position: [3.5, 0, 0], top: { part: "top", y: 0.78 } },
93
+ shelf: { spec: shelfSpec(), position: [-3.5, 0, 0], colors: { wood_soft: 13081186 } }
94
+ });
95
+ var DEFAULT_WALL_Z = -11;
96
+ var WALL_ID = "target_wall";
97
+ var TABLE_ID = "table";
98
+ function resolveScenery(scenery) {
99
+ const merged = new Map(Object.entries(defaultScenery(DEFAULT_WALL_Z)));
100
+ for (const [id, obj] of Object.entries(scenery ?? {})) {
101
+ if (obj === null) {
102
+ merged.delete(id);
103
+ continue;
104
+ }
105
+ const old = merged.get(id);
106
+ const position = obj.position ?? old?.position;
107
+ if (!position) throw new Error(`game-test-room: scenery "${id}" is new, so it needs a position`);
108
+ merged.set(id, { ...obj, position });
109
+ }
110
+ 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 }));
111
+ }
112
+ function createArena(THREE, size, scene, scenery, tapOption) {
113
+ const group = new THREE.Group();
114
+ group.name = "test-room-arena";
115
+ scene.background = new THREE.Color(10466504);
116
+ group.add(new THREE.HemisphereLight(13624319, 7037527, 0.75));
117
+ const sun = new THREE.DirectionalLight(16777215, 2.1);
118
+ sun.position.set(7, 13, 8);
119
+ sun.castShadow = true;
120
+ sun.shadow.mapSize.set(1024, 1024);
121
+ const sc = sun.shadow.camera;
122
+ sc.left = -16;
123
+ sc.right = 16;
124
+ sc.top = 16;
125
+ sc.bottom = -16;
126
+ sc.near = 1;
127
+ sc.far = 40;
128
+ sun.shadow.bias = -6e-4;
129
+ group.add(sun);
130
+ const floor = new THREE.Mesh(new THREE.PlaneGeometry(size * 2, size * 2), new THREE.MeshStandardMaterial({ color: 9342602, roughness: 0.95 }));
131
+ floor.rotation.x = -Math.PI / 2;
132
+ floor.receiveShadow = true;
133
+ floor.name = "floor";
134
+ group.add(floor);
135
+ const grid = new THREE.GridHelper(size * 2, size * 2, 5000274, 7829371);
136
+ grid.position.y = 4e-3;
137
+ group.add(grid);
138
+ const sceneryObjects = resolveScenery(scenery);
139
+ const wallObject = sceneryObjects.find((o) => o.id === WALL_ID);
140
+ const wallZ = wallObject ? wallObject.position[2] : DEFAULT_WALL_Z;
141
+ const playerStart = [0, 1.7, 5];
142
+ const pts = [];
143
+ const rx = -1.6;
144
+ for (let z = Math.round(playerStart[2]); z >= wallZ; z--) {
145
+ const d = playerStart[2] - z;
146
+ const len = d % 5 === 0 ? 0.5 : 0.2;
147
+ pts.push(rx, 0.01, z, rx + len, 0.01, z);
148
+ }
149
+ pts.push(rx, 0.01, playerStart[2], rx, 0.01, wallZ);
150
+ const rg = new THREE.BufferGeometry();
151
+ rg.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3));
152
+ group.add(new THREE.LineSegments(rg, new THREE.LineBasicMaterial({ color: 16765503 })));
153
+ for (let d = 5; playerStart[2] - d >= wallZ; d += 5) {
154
+ try {
155
+ const canvas = document.createElement("canvas");
156
+ canvas.width = 128;
157
+ canvas.height = 64;
158
+ const ctx = canvas.getContext("2d");
159
+ if (!ctx) break;
160
+ ctx.fillStyle = "#ffd23f";
161
+ ctx.font = "bold 44px sans-serif";
162
+ ctx.textAlign = "center";
163
+ ctx.fillText(`${d} m`, 64, 48);
164
+ const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(canvas), transparent: true, depthWrite: false }));
165
+ sprite.scale.set(1.2, 0.6, 1);
166
+ sprite.position.set(rx - 0.8, 0.45, playerStart[2] - d);
167
+ group.add(sprite);
168
+ } catch {
169
+ break;
170
+ }
171
+ }
172
+ const tops = [];
173
+ const surfaces = [{ id: "floor", point: [0, 0, 0], normal: [0, 1, 0], halfSize: [size, size], porosity: 0.05 }];
174
+ for (const o of sceneryObjects) {
175
+ if (!o.top) continue;
176
+ const b = specBounds(THREE, o.spec, o.position, o.rotationY, { onlyPart: o.top.part });
177
+ if (!b) continue;
178
+ const top = o.position[1] + o.top.y;
179
+ tops.push({ id: o.id, topPart: o.top.part, minX: b.minX, maxX: b.maxX, minZ: b.minZ, maxZ: b.maxZ, top });
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 });
181
+ }
182
+ const colliders = [];
183
+ for (const o of sceneryObjects) {
184
+ const b = specBounds(THREE, o.spec, o.position, o.rotationY, { minHeight: 0.1 });
185
+ if (!b) continue;
186
+ colliders.push(o.id === WALL_ID ? [-size, b.minZ - 1, size, b.maxZ + 0.1] : [b.minX, b.minZ, b.maxX, b.maxZ]);
187
+ }
188
+ let tapSpout = null;
189
+ const tableTop = tops.find((t) => t.id === TABLE_ID);
190
+ const spouting = sceneryObjects.find((o) => o.spout);
191
+ if (spouting?.spout && tapOption !== null) {
192
+ const s = new THREE.Vector3(spouting.spout[0], spouting.spout[1], spouting.spout[2]).applyAxisAngle(new THREE.Vector3(0, 1, 0), spouting.rotationY);
193
+ tapSpout = [spouting.position[0] + s.x, spouting.position[1] + s.y, spouting.position[2] + s.z];
194
+ } else if (tableTop && tapOption !== null) {
195
+ const cx = (tableTop.minX + tableTop.maxX) / 2;
196
+ const cz = (tableTop.minZ + tableTop.maxZ) / 2;
197
+ const y = tableTop.top;
198
+ if (tapOption) {
199
+ const rot = tapOption.rotationY ?? 0;
200
+ const sp = new THREE.Vector3(tapOption.spout[0], tapOption.spout[1], tapOption.spout[2]).applyAxisAngle(new THREE.Vector3(0, 1, 0), rot);
201
+ const at = tapOption.position ?? [cx + 0.3 - sp.x, y, cz - sp.z];
202
+ const model = tapOption.build(THREE);
203
+ model.position.set(at[0], at[1], at[2]);
204
+ model.rotation.y = rot;
205
+ model.traverse((m) => {
206
+ m.castShadow = true;
66
207
  });
67
- mats.set(key, m);
208
+ group.add(model);
209
+ tapSpout = [at[0] + sp.x, at[1] + sp.y, at[2] + sp.z];
210
+ } else {
211
+ const postX = tableTop.maxX + 0.35;
212
+ const tapMat = new THREE.MeshStandardMaterial({ color: 12172996, metalness: 0.8, roughness: 0.35 });
213
+ const post = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 1.4, 10), tapMat);
214
+ post.position.set(postX, 0.7, cz);
215
+ const arm = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.025, postX - (cx + 0.3), 10), tapMat);
216
+ arm.rotation.z = Math.PI / 2;
217
+ arm.position.set((postX + cx + 0.3) / 2, y + 0.6, cz);
218
+ const spoutMesh = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.025, 0.1, 10), tapMat);
219
+ spoutMesh.position.set(cx + 0.3, y + 0.55, cz);
220
+ for (const m of [post, arm, spoutMesh]) {
221
+ m.castShadow = true;
222
+ group.add(m);
223
+ }
224
+ tapSpout = [cx + 0.3, y + 0.5, cz];
225
+ colliders.push([postX - 0.05, cz - 0.1, postX + 0.05, cz + 0.1]);
68
226
  }
69
- return m;
70
- };
71
- for (const p of spec.parts) {
72
- let geo;
73
- const s = p.shape;
74
- if (s.kind === "box") geo = new THREE.BoxGeometry(s.size[0], s.size[1], s.size[2]);
75
- else if (s.kind === "cylinder") geo = new THREE.CylinderGeometry(s.radius, s.radius, s.height, seg);
76
- else geo = new THREE.SphereGeometry(s.radius, seg, Math.max(6, seg - 4));
77
- const mesh = new THREE.Mesh(geo, materialFor(p));
78
- mesh.name = p.id;
79
- mesh.userData.destructionPart = p.id;
80
- mesh.userData.destructionMaterial = p.material;
81
- mesh.position.set(p.position[0], p.position[1], p.position[2]);
82
- if (p.rotation) mesh.rotation.set(p.rotation[0], p.rotation[1], p.rotation[2], "XYZ");
83
- mesh.castShadow = shadows && !TRANSPARENT[p.material];
84
- mesh.receiveShadow = shadows;
85
- root.add(mesh);
86
227
  }
87
- return root;
228
+ return {
229
+ group,
230
+ objects: sceneryObjects,
231
+ surfaces,
232
+ colliders,
233
+ playerStart,
234
+ wallZ,
235
+ tops,
236
+ tapSpout,
237
+ sun
238
+ };
88
239
  }
89
240
 
90
- // src/locale.ts
91
- var en = {
92
- items: {
93
- chair: "Wooden chair",
94
- sofa: "Sofa",
95
- shelf: "Shelf with boards",
96
- old_tv: "Old TV",
97
- steel_sheet: "Steel sheet on a stand",
98
- glass_pane: "Glass pane",
99
- concrete_block: "Concrete block",
100
- brick_column: "Brick column",
101
- building_frame: "Building frame",
102
- car_door: "Car door",
103
- small_tree: "Small tree",
104
- potted_plant: "Potted plant",
105
- coffee_cup: "Cup of coffee",
106
- glass_bottle: "Glass bottle",
107
- wooden_crate: "Wooden crate",
108
- steel_drum: "Steel drum",
109
- plastic_bucket: "Plastic bucket",
110
- jerrycan: "Jerrycan",
111
- gas_bottle: "Gas bottle"
112
- },
113
- groups: { furniture: "Furniture", structure: "Structure", vehicles: "Vehicles", plants: "Plants", items: "Items", containers: "Containers" },
114
- text: {
115
- "locale.decimal": ".",
116
- yes: "yes",
117
- no: "no",
118
- piece: "piece",
119
- coords: "x {x} \xB7 z {z}",
120
- "count.piece.one": "1 piece",
121
- "count.piece.other": "{n} pieces",
122
- "count.joint.one": "1 joint",
123
- "count.joint.other": "{n} joints",
124
- "count.dent.one": "1 dent",
125
- "count.dent.other": "{n} dents",
126
- "count.hole.one": "1 hole",
127
- "count.hole.other": "{n} holes",
128
- "count.crack.one": "1 crack",
129
- "count.crack.other": "{n} cracks",
130
- "joint.generic": "joint",
131
- "lost.broken": "broke",
132
- "lost.melted": "melted away",
133
- "lost.ashes": "burned to ash",
134
- "tool.none": "by hand only",
135
- "arena.target_wall": "Target wall",
136
- "arena.table": "Table",
137
- "arena.shelf": "Shelf",
138
- "surface.floor": "on the floor",
139
- "surface.ground": "on the floor",
140
- "surface.table": "on the table",
141
- "by.break": "broken loose",
142
- "by.tool": "undone with a tool",
143
- "by.part_lost": "the part next to it was lost",
144
- "dmg.ballistic": "ballistic",
145
- "dmg.blunt": "blunt",
146
- "dmg.slash": "slash",
147
- "dmg.explosive": "blast",
148
- "dmg.fire": "fire",
149
- "dmg.heat": "heat",
150
- "effect.caffeine": "caffeine",
151
- "effect.alcohol": "alcohol",
152
- "effect.poison": "poison",
153
- "effect.disgust": "disgust",
154
- "ev.dent": "{where}: dent of {mm} mm, {hp}",
155
- "ev.hole.through": "{where}: {mm} mm hole went through, {hp}",
156
- "ev.hole.stuck": "{where}: {mm} mm hole, did not go through, {hp}",
157
- "ev.dents": "{where}: {n} dents, deepest {mm} mm, {hp}",
158
- "ev.holes": "{where}: {n} holes of {mm} mm, {thr} went through, {hp}",
159
- "ev.leak.tail": ", leaking {liquid} at {rate} l/s",
160
- "ev.leak.alone": "{where}: leaking {liquid} at {rate} l/s",
161
- "ev.crack": "{where}: cracked, {rays} rays up to {cm} cm, {hp}",
162
- "ev.ricochet": "{where}: bullet ricocheted, {energy} J left",
163
- "ev.fracture.shatter": "{where}: shattered into {n} shards",
164
- "ev.fracture.crumble": "{where}: crumbled into {n} chunks",
165
- "ev.fracture.splinter": "{where}: splintered into {n} pieces",
166
- "ev.fracture.tear": "{where}: torn into {n} scraps",
167
- "ev.fracture.snap": "{where}: snapped into {n} pieces",
168
- "ev.fracture.none": "{where}: broke into {n} pieces",
169
- "ev.detach": "{obj}: {a} came off {b} ({by}); the object now has {pieces}",
170
- "ev.detach.many.break": "{obj}: {joints} broke loose; the object now has {pieces}",
171
- "ev.detach.many.tool": "{obj}: {joints} undone with a tool; the object now has {pieces}",
172
- "ev.lost.tail": " and came loose from {joints}; the object now has {pieces}",
173
- "ev.lost.alone": "{where}: {verb} and came loose from {joints}; the object now has {pieces}",
174
- "ev.cracks": "{where}: cracking under the load, {n} cracks so far, longest {cm} cm, {hp}",
175
- "ev.disassemble": "{obj}: undoing the {kind} between {a} and {b} with {tool}: {pct}%",
176
- "ev.ignite": "{where}: caught fire at {temp} \xB0C",
177
- "ev.ignite.many": "{obj}: {n} parts caught fire at {temp} \xB0C ({parts})",
178
- "ev.extinguish": "{where}: fire put out",
179
- "ev.extinguish.many": "{obj}: fire put out on {n} parts ({parts})",
180
- "ev.burnout": "{where}: burned to ash",
181
- "ev.melt": "{where}: melting, {pct}% melted away",
182
- "ev.drip": "{where}: dripping molten material",
183
- "ev.explode": "{where}: exploded, {energy} J within {radius} m",
184
- "ev.destroyed": "{obj}: destroyed",
185
- "ev.damage": "{where}: {type} damage {amount}, {hp}",
186
- "ev.damage.overload": "{where}: crushed by the weight it carries, lost {amount} hp so far, {hp}",
187
- "ev.stress": "{where}: overloaded at {stress}% of its capacity, carrying {load} kg",
188
- "ev.collapse": "{obj}: collapsed, {pieces} fell ({mass} kg): {parts}",
189
- "ev.stain_ignite": "The {liquid} stain at x {x} \xB7 z {z} caught fire and will burn for about {seconds} s",
190
- "ev.stain_burned": "The burning stain burned itself out",
191
- "ev.fire_dries": "Fire dried the wet stains nearby",
192
- "fl.fill": "{who}: filled with {liquid} (+{liters} l)",
193
- "fl.pour": "{who}: pouring {liquid} at {rate} l/s",
194
- "fl.spill": "{who}: spilled {liquid} over the lip at {rate} l/s",
195
- "fl.leak": "{who}: leaking {liquid} at {rate} l/s",
196
- "fl.drink": "{who}: drank {liters} l, thirst +{thirst}",
197
- "fl.burn": "too hot: burned the mouth",
198
- "fl.empty": "{who}: empty",
199
- "fl.splash": "Splash of {liquid}",
200
- "fl.stain": "Stain of {liquid} {surface} at {where}",
201
- "fl.stains": "{n} stains of {liquid} {surface}, the latest at {where}",
202
- "fl.dried": "A stain dried up",
203
- "fl.dried.mark": "A stain dried and left a mark",
204
- "fl.dried.known": "The {liquid} stain {surface} at {where} dried up",
205
- "fl.dried.known.mark": "The {liquid} stain {surface} at {where} dried and left a mark",
206
- "fl.dried.many": "{n} {liquid} stains dried up",
207
- "fl.dried.many.mark": "{n} {liquid} stains dried and left marks",
208
- "fl.mix": "{who}: {from} mixed into {into}",
209
- "act.anim_start": '{name}: animation "{clip}" ({seconds} s)',
210
- "act.anim_event": '{name}: "{clip}" event {type}',
211
- "act.anim_end": '{name}: animation "{clip}" finished',
212
- "act.module_loaded": 'Module "{name}" loaded: {items} item(s), {scenarios} scenario(s), contract checks: {result}',
213
- "mod.all_ok": "all passed",
214
- "mod.some_failed": "some FAILED",
215
- "mod.destructible_ok": "{name}: destructible contract ok ({parts} parts, hit and state well-formed)",
216
- "mod.destructible_fail": "{name}: destructible contract broken: {problems}",
217
- "mod.animation_ok": '{name}: animation "{clip}" ok ({seconds} s)',
218
- "mod.spec_ok": "{name}: spec ok ({parts} parts)",
219
- "mod.spec_fail": "{name}: spec broken: {problems}",
220
- "mod.spec_warn": "{name}: spec warning: {warning}",
221
- "mod.animation_fail": '{name}: animation "{clip}" broken: {problems}',
222
- "ui.animations": "Animations",
223
- "ui.animations_aim": "Aim at an item or hold it to see its animations.",
224
- "ui.animations_none": "This item brings no animations.",
225
- "mod.bench_ok": "{name}: bench ok ({controls} controls, {layers} layers, {metrics} metrics)",
226
- "mod.bench_fail": "{name}: bench broken: {problems}",
227
- "mod.metric_failed": '{name}: metric "{metric}" threw and shows n/a: {error}',
228
- "mod.listener_failed": "{name}: a bench listener threw: {error}",
229
- "ui.bench": "Bench",
230
- "ui.layers": "Layers",
231
- "ui.metrics": "Counters",
232
- "metric.fps": "FPS",
233
- "metric.frame_ms": "ms/frame",
234
- "metric.draw_calls": "Draw calls",
235
- "metric.triangles": "Triangles",
236
- "metric.objects": "Objects",
237
- "metric.fragments": "Fragments",
238
- "metric.particles": "Particles",
239
- "metric.decals": "Decals",
240
- "act.spawn": "Spawned {name} at x {x} \xB7 z {z}",
241
- "act.clear": "Cleared the room",
242
- "act.reset": "Repaired every object",
243
- "act.tool": "Equipped {weapon}",
244
- "act.shoot.hit": "{weapon}: shot, {energy} J, hit {where} at {dist} m",
245
- "act.shoot.miss": "{weapon}: shot, {energy} J, hit nothing",
246
- "act.shoot.pellets": "{weapon}: shot, {n} projectiles of up to {energy} J, {hits} hit {where} at {dist} m",
247
- "act.melee.hit": "{weapon}: strike, {energy} J, hit {where} at {dist} m",
248
- "act.melee.miss": "{weapon}: struck the air",
249
- "act.flame.hit": "{weapon}: flame at {temp} \xB0C on {where}",
250
- "act.flame.air": "{weapon}: flame, nothing in range",
251
- "act.heat.hit": "{weapon}: heating {where} at {temp} \xB0C",
252
- "act.heat.air": "{weapon}: nothing in range",
253
- "act.throw.grenade": "{weapon}: thrown, fuse {fuse} s",
254
- "act.throw.molotov": "{weapon}: thrown, breaks on impact",
255
- "act.launch": "{weapon}: launched, arms after {arm} m",
256
- "act.place": "{weapon}: charge set down, waits for {triggers}",
257
- "act.tripwire": "{weapon}: {who} crossed the trip wire",
258
- "act.dud": "{weapon}: hit before arming ({distance} m), did not explode",
259
- "act.backblast": "{weapon}: blast behind the tube, {radius} m, {n} objects in the cone, {player} on the shooter",
260
- "act.blast_player": "{weapon}: you are in the {zone} zone, {distance} m away (stun {stun} s)",
261
- "hud.no_charges": "No charge set down waits for the remote detonator",
262
- "blast.zone.lethal": "lethal",
263
- "blast.zone.injury": "injury",
264
- "act.explode": "{weapon}: exploded, {energy} J within {radius} m, {n} objects in range",
265
- "act.molotov_fire": "{weapon}: fire on the ground, radius {radius} m for {seconds} s",
266
- "act.tool.none": "{weapon}: nothing in reach",
267
- "act.tool.nojoint": "{weapon}: no joint near {where}",
268
- "act.tool.refused": "{weapon} cannot undo the {kind} of {obj}; it needs: {need}",
269
- "act.extinguish.hit": "{weapon}: foam on {what}",
270
- "act.extinguish.air": "{weapon}: foam, nothing in range",
271
- "act.grab": "Grabbed {name} ({kg} kg)",
272
- "act.drop": "Dropped {name}",
273
- "act.throw": "Threw {name} at {speed} m/s",
274
- "act.landing": "{name} landed at {speed} m/s: {energy} J",
275
- "act.impact": "{name} hit {where} at {speed} m/s: {energy} J of blunt damage",
276
- "act.use_item": "{name}: {use}",
277
- "act.tap.on": "Tap opened",
278
- "act.tap.off": "Tap closed",
279
- "use.drink": "drink",
280
- "use.sip": "sip",
281
- "use.chug": "chug",
282
- "use.pour": "pour",
283
- "use.pour_out": "pour out",
284
- "use.open": "open",
285
- "use.close": "close",
286
- "hud.reloading": "Reloading...",
287
- "hud.empty": "Empty",
288
- "hud.no_fuel": "Out of fuel",
289
- "hud.fault": "Jammed ({fault}): press X to clear",
290
- "hud.safe": "Safety on: press B",
291
- "hud.clearing": "Clearing the jam...",
292
- "hud.safety_on": "Safety on",
293
- "hud.safety_off": "Safety off",
294
- "fault.misfire": "misfire",
295
- "fault.stovepipe": "stovepipe",
296
- "fault.double_feed": "double feed",
297
- "fault.failure_to_extract": "failure to extract",
298
- "hud.nothing_to_grab": "Nothing to grab here",
299
- "hud.cannot_grab": "{name} cannot be picked up",
300
- "hud.too_heavy": "{name} is too heavy ({kg} kg)",
301
- "hud.empty_container": "{name} is empty",
302
- "hud.refused": "Wrong tool for the {kind}",
303
- "hud.drop": "drop",
304
- "hud.rotate": "rotate",
305
- "hud.throw": "throw",
306
- "hud.tilt": "tilt (hold)",
307
- "hud.equip": "equip {weapon}",
308
- "ui.help": "Click: look \xB7 WASD: walk \xB7 Shift: run \xB7 Space: jump \xB7 1-0 / wheel: tool \xB7 LMB: use \xB7 E: grab/drop \xB7 G or RMB: throw \xB7 R: reload \xB7 Q: fire mode \xB7 X: clear a jam \xB7 B: safety \xB7 Y: detonate charges set down \xB7 Tab: panels \xB7 Esc: free the mouse",
309
- "ui.log": "Behaviour log",
310
- "ui.filter.action": "actions",
311
- "ui.filter.event": "events",
312
- "ui.filter.check": "checks",
313
- "ui.autoscroll": "autoscroll",
314
- "log.export_cancelled": "export cancelled",
315
- "ui.copy_json": "Copy JSON",
316
- "ui.download_ndjson": "Download NDJSON",
317
- "ui.download_txt": "Download TXT",
318
- "ui.clear_log": "Clear",
319
- "ui.catalog": "Catalogue",
320
- "ui.clear": "Clear",
321
- "ui.reset_all": "Reset all",
322
- "ui.slow": "Slow motion 0.25\xD7",
323
- "ui.fast": "Time \xD710",
324
- "ui.tap": "Tap",
325
- "ui.blast.shelter": "Shelter",
326
- "ui.blast.fragmentHits": "Fragments",
327
- "ui.blast.confinement": "Confinement",
328
- "ui.blast.shelter.hint": "Blasts: walls and other parts between the blast and the target take their share",
329
- "ui.blast.fragmentHits.hint": "Blasts: the pieces of what breaks fly on and hit what is behind",
330
- "ui.blast.confinement.hint": "Blasts: pressure builds up in an enclosed room",
331
- "metric.blast_shelter": "Blast reaching (%)",
332
- "metric.blast_confinement": "Blast pressure (x)",
333
- "metric.blast_fragments": "Fragment hits",
334
- "act.blast_report": "Blast: {shelter}% reached the most sheltered target, pressure x{confinement}, {fragments} fragment hits, {n} targets",
335
- "ui.quality": "Quality",
336
- "ui.quality.low": "low",
337
- "ui.quality.medium": "medium",
338
- "ui.quality.high": "high",
339
- "ui.equip": "Equip as a tool",
340
- "ui.search_items": "Search items (all groups)",
341
- "ui.search_tests": "Search tests",
342
- "ui.no_results": "Nothing found",
343
- "ui.objects": "{n} objects",
344
- "ui.tests": "Tests",
345
- "ui.run_all": "Run all",
346
- "ui.running": "Running...",
347
- "ui.report": "{passed} passed, {failed} failed",
348
- "ui.panel": "Panels",
349
- "ui.inspector": "Inspector",
350
- "ui.aim_at_something": "Aim at an object",
351
- "ins.material": "Material",
352
- "ins.status": "Status",
353
- "ins.burning": "burning",
354
- "ins.hp": "Hit points",
355
- "ins.temperature": "Temperature",
356
- "ins.char": "Charred",
357
- "ins.melt": "Melted",
358
- "ins.liters": "Liquid",
359
- "ins.damage": "Marks",
360
- "ins.damage_counts": "{dents}, {holes}, {cracks}",
361
- "ins.mass": "Mass",
362
- "ins.load": "Load",
363
- "ins.stress": "Stress",
364
- "ins.joints": "Joints",
365
- "ins.open": "open",
366
- "sc.start": "Scenario: {name}",
367
- "sc.pass": "Scenario passed: {name}",
368
- "sc.fail": "Scenario FAILED: {name}",
369
- "sc.error": "the scenario threw an error: {msg}",
370
- "leg.still": "leg_1 still attached",
371
- "leg.gone": "leg_1 came off",
372
- "sc.steel_through_hole": "9 mm into a steel sheet makes a through hole",
373
- "sc.steel_through_hole.check": "9 mm into 3 mm steel: {holes} hole(s) of {mm} mm, went through: {through}, sheet {hp} hp (expected: through)",
374
- "sc.concrete_stops_bullet": "9 mm into a concrete block does not go through",
375
- "sc.concrete_stops_bullet.check": "9 mm into 40 cm of concrete: went through: {through}, {dents}, {holes}, {cracks}, block {hp} hp (expected: stopped, wall behind untouched)",
376
- "sc.hammer_dents_drum": "Hammer dents a steel drum",
377
- "sc.hammer_dents_drum.check": "Hammer on the drum: {swings} swing(s), {dents} dent(s), first {depth} mm deep, body {hp} hp (expected: dented, not broken)",
378
- "sc.glass_shatters": "Glass pane shatters on the first 9 mm shot",
379
- "sc.glass_shatters.check": "Glass pane: broken after {shots} shot(s) into {n} shards (expected: broken on the first shot, at least 4 shards)",
380
- "sc.shotgun_splinters_crate": "Shotgun splinters a wooden crate",
381
- "sc.shotgun_splinters_crate.check": "12 ga on the crate: {shots} shot(s), {splinters} board(s) splintered, {holes} holes (expected: at least one splintered)",
382
- "sc.kick_chair_leg": "A kick takes a chair leg off",
383
- "sc.kick_chair_leg.check": "Chair: {kicks} kick(s), {groups} group(s), loose pieces {before} \u2192 {after}, {leg} (expected: a loose piece appears)",
384
- "sc.kick_detaches_leg_via_tool": "The kick tool (hotbar) takes a chair leg off",
385
- "sc.kick_detaches_leg_via_tool.check": "Chair, kick tool: {kicks} kick(s), loose pieces {before} \u2192 {after}, {leg} (expected: a loose piece appears)",
386
- "sc.screwdriver_shelf_board": "Screwdriver takes a screwed shelf board off in about 2.5 s",
387
- "sc.screwdriver_shelf_board.check": "Screwdriver on the shelf board: took {secs} s, expected about {want} s, the shelf is now in {pieces}",
388
- "sc.wrong_tool_refused": "The wrong tool is refused",
389
- "sc.wrong_tool_refused.check": "Screwdriver on a weld: refused {refused} time(s), joints opened {opened} (expected: refused, none opened)",
390
- "sc.lighter_burns_crate": "A lighter ignites a wooden crate and it ends as ash",
391
- "sc.lighter_burns_crate.lit": "Lighter on the crate: burning: {lit} (expected: yes)",
392
- "sc.lighter_burns_crate.ash": "After burning for 500 s: {ash} of {total} boards are ash, most charred {char}% (expected: all ash or charred)",
393
- "sc.torch_melts_steel": "Wood fire does not melt a steel drum, the cutting torch does",
394
- "sc.torch_melts_steel.wood": "Burning crate next to the drum: drum peaked at {peak} \xB0C, melted {melt}%, crate still burning: {crate} (expected: no melt)",
395
- "sc.torch_melts_steel.torch": "Cutting torch on the drum: {temp} \xB0C, melted {melt}% (expected: melting)",
396
- "sc.bucket_melts_drips": "A plastic bucket melts and drips near fire",
397
- "sc.bucket_melts_drips.check": "Bucket in flames: melted {melt}%, {temp} \xB0C, {drips} drip line(s) (expected: melting)",
398
- "sc.gas_bottle_explodes": "A gas bottle in a fire explodes and pushes a crate",
399
- "sc.gas_bottle_explodes.explode": "Gas bottle in fire: exploded after {seconds} s (expected: explodes)",
400
- "sc.gas_bottle_explodes.push": "Crate 1.3 m away: moved {moved} m, lost {lost} (expected: pushed or broken)",
401
- "sc.bottle_drop_shatters": "A glass bottle dropped from 1.5 m shatters",
402
- "sc.bottle_drop_shatters.check": "Bottle dropped from 1.5 m: status {status}, {cracks} cracks, water stain: {stain} (expected: broken, water on the floor)",
403
- "sc.throw_chair_at_wall": "A chair thrown at the wall deals blunt damage",
404
- "sc.throw_chair_at_wall.check": "Chair thrown at the wall: {impacts} impact(s), first {energy} J, {wall} damage line(s) on the wall (expected: impact and damage)",
405
- "sc.coffee_three_sips": "Three sips of coffee lower the level and log caffeine",
406
- "sc.coffee_three_sips.check": "Coffee: {sips} sip(s), level {before} \u2192 {after} l, caffeine logged: {caffeine}, burned the mouth: {burn} (expected: 3 sips, caffeine)",
407
- "sc.tilt_cup_spills": "Tilting the cup spills coffee and leaves a stain",
408
- "sc.tilt_cup_spills.check": "Cup tilted: level {before} \u2192 {after} l, {stains} coffee stain(s) (expected: spilled with a stain)",
409
- "sc.jerrycan_leaks_fuel": "Shooting the jerrycan makes a leak and a fuel stain",
410
- "sc.jerrycan_leaks_fuel.check": "Jerrycan shot: {streams} leak stream(s), fuel stain: {stain}, {left} l left of 20, hole line mentions the leak: {leak} (expected: leaking, stain)",
411
- "sc.fuel_stain_burns": "A lit fuel stain burns",
412
- "sc.fuel_stain_burns.stain": "Fuel stain on the floor after the leak: {stain} (expected: yes)",
413
- "sc.fuel_stain_burns.ignite": "Lighter on the stain: {ignited} ignition(s), fire reached nearby objects: {near} (expected: ignited)",
414
- "sc.fuel_stain_burns.out": "After 30 s the stain burned out: {burned} (expected: yes)",
415
- "roof.standing": "roof standing",
416
- "roof.fell": "roof fell",
417
- "sc.one_column_roof_stands": "One column destroyed: the roof stays up",
418
- "sc.one_column_roof_stands.check": "Frame with one column gone: {roof}, heaviest collapse {heavy} kg, peak stress {stress}% (expected: standing, no big collapse)",
419
- "sc.two_columns_roof_collapses": "Two columns destroyed: the roof collapses",
420
- "sc.two_columns_roof_collapses.check": "Frame with two columns gone: the others at {stress}% right away, then heavy loose pieces: {pieces} ({kg} kg), collapse event logged: {logged} (expected: the roof comes down)",
421
- "sc.extinguisher_puts_out_fire": "The extinguisher puts out a burning crate",
422
- "sc.extinguisher_puts_out_fire.burning": "Crate set on fire: burning: {burning} (expected: yes)",
423
- "sc.extinguisher_puts_out_fire.out": "After the extinguisher: still burning: {burning}, {events} extinguish event(s) (expected: out)"
424
- }
241
+ // src/bench.ts
242
+ var snap = (control, value) => {
243
+ const clamped = Math.min(control.max, Math.max(control.min, value));
244
+ if (!control.step) return clamped;
245
+ const stepped = control.min + Math.round((clamped - control.min) / control.step) * control.step;
246
+ return Number(Math.min(control.max, stepped).toFixed(10));
425
247
  };
426
- var ptBR = {
427
- items: {
428
- chair: "Cadeira de madeira",
429
- sofa: "Sof\xE1",
430
- shelf: "Estante com prateleiras",
431
- old_tv: "TV antiga",
432
- steel_sheet: "Chapa de a\xE7o com suporte",
433
- glass_pane: "Vidro de janela",
434
- concrete_block: "Bloco de concreto",
435
- brick_column: "Coluna de tijolos",
436
- building_frame: "Estrutura de pr\xE9dio",
437
- car_door: "Porta de carro",
438
- small_tree: "\xC1rvore pequena",
439
- potted_plant: "Vaso de planta",
440
- coffee_cup: "Copo de caf\xE9",
441
- glass_bottle: "Garrafa de vidro",
442
- wooden_crate: "Caixote de madeira",
443
- steel_drum: "Tambor de a\xE7o",
444
- plastic_bucket: "Balde de pl\xE1stico",
445
- jerrycan: "Gal\xE3o",
446
- gas_bottle: "Botij\xE3o de g\xE1s"
447
- },
448
- groups: { furniture: "M\xF3veis", structure: "Estrutura", vehicles: "Ve\xEDculos", plants: "Plantas", items: "Itens", containers: "Recipientes" },
449
- text: {
450
- "locale.decimal": ",",
451
- yes: "sim",
452
- no: "n\xE3o",
453
- piece: "pe\xE7a",
454
- coords: "x {x} \xB7 z {z}",
455
- "count.piece.one": "1 pe\xE7a",
456
- "count.piece.other": "{n} pe\xE7as",
457
- "count.joint.one": "1 encaixe",
458
- "count.joint.other": "{n} encaixes",
459
- "count.dent.one": "1 amassado",
460
- "count.dent.other": "{n} amassados",
461
- "count.hole.one": "1 furo",
462
- "count.hole.other": "{n} furos",
463
- "count.crack.one": "1 trinca",
464
- "count.crack.other": "{n} trincas",
465
- "joint.generic": "junta",
466
- "lost.broken": "quebrou",
467
- "lost.melted": "derreteu",
468
- "lost.ashes": "virou cinza",
469
- "tool.none": "s\xF3 com a m\xE3o",
470
- "arena.target_wall": "Parede de alvo",
471
- "arena.table": "Mesa",
472
- "arena.shelf": "Estante",
473
- "surface.floor": "no ch\xE3o",
474
- "surface.ground": "no ch\xE3o",
475
- "surface.table": "na mesa",
476
- "by.break": "arrebentou",
477
- "by.tool": "desmontado com ferramenta",
478
- "by.part_lost": "a pe\xE7a ao lado se perdeu",
479
- "dmg.ballistic": "de tiro",
480
- "dmg.blunt": "de impacto",
481
- "dmg.slash": "de corte",
482
- "dmg.explosive": "de explos\xE3o",
483
- "dmg.fire": "de fogo",
484
- "dmg.heat": "de calor",
485
- "effect.caffeine": "cafe\xEDna",
486
- "effect.alcohol": "\xE1lcool",
487
- "effect.poison": "veneno",
488
- "effect.disgust": "nojo",
489
- "ev.dent": "{where}: amassado de {mm} mm, {hp}",
490
- "ev.hole.through": "{where}: furo de {mm} mm atravessou, {hp}",
491
- "ev.hole.stuck": "{where}: furo de {mm} mm n\xE3o atravessou, {hp}",
492
- "ev.dents": "{where}: {n} amassados, o mais fundo com {mm} mm, {hp}",
493
- "ev.holes": "{where}: {n} furos de {mm} mm, {thr} atravessaram, {hp}",
494
- "ev.leak.tail": ", vazando {liquid} a {rate} l/s",
495
- "ev.leak.alone": "{where}: vazando {liquid} a {rate} l/s",
496
- "ev.crack": "{where}: trincou, {rays} raios de at\xE9 {cm} cm, {hp}",
497
- "ev.ricochet": "{where}: a bala ricocheteou, sobraram {energy} J",
498
- "ev.fracture.shatter": "{where}: estilha\xE7ou em {n} cacos",
499
- "ev.fracture.crumble": "{where}: esfarelou em {n} peda\xE7os",
500
- "ev.fracture.splinter": "{where}: lascou em {n} peda\xE7os",
501
- "ev.fracture.tear": "{where}: rasgou em {n} retalhos",
502
- "ev.fracture.snap": "{where}: partiu em {n} peda\xE7os",
503
- "ev.fracture.none": "{where}: quebrou em {n} peda\xE7os",
504
- "ev.detach": "{obj}: {a} soltou de {b} ({by}); o objeto agora tem {pieces}",
505
- "ev.detach.many.break": "{obj}: {joints} arrebentaram; o objeto agora tem {pieces}",
506
- "ev.detach.many.tool": "{obj}: {joints} soltos com ferramenta; o objeto agora tem {pieces}",
507
- "ev.lost.tail": " e se soltou de {joints}; o objeto agora tem {pieces}",
508
- "ev.lost.alone": "{where}: {verb} e se soltou de {joints}; o objeto agora tem {pieces}",
509
- "ev.cracks": "{where}: rachando sob o peso, {n} trincas at\xE9 agora, a maior com {cm} cm, {hp}",
510
- "ev.disassemble": "{obj}: soltando {kind} entre {a} e {b} com {tool}: {pct}%",
511
- "ev.ignite": "{where}: pegou fogo a {temp} \xB0C",
512
- "ev.ignite.many": "{obj}: {n} pe\xE7as pegaram fogo a {temp} \xB0C ({parts})",
513
- "ev.extinguish": "{where}: fogo apagado",
514
- "ev.extinguish.many": "{obj}: fogo apagado em {n} pe\xE7as ({parts})",
515
- "ev.burnout": "{where}: virou cinza",
516
- "ev.melt": "{where}: derretendo, {pct}% j\xE1 derreteu",
517
- "ev.drip": "{where}: pingando material derretido",
518
- "ev.explode": "{where}: explodiu, {energy} J em {radius} m",
519
- "ev.destroyed": "{obj}: destru\xEDdo",
520
- "ev.damage": "{where}: dano {type} de {amount}, {hp}",
521
- "ev.damage.overload": "{where}: esmagado pelo peso que sustenta, perdeu {amount} hp at\xE9 agora, {hp}",
522
- "ev.stress": "{where}: sobrecarregado a {stress}% da capacidade, sustentando {load} kg",
523
- "ev.collapse": "{obj}: desabou, ca\xEDram {pieces} ({mass} kg): {parts}",
524
- "ev.stain_ignite": "A mancha de {liquid} em x {x} \xB7 z {z} pegou fogo e vai queimar por uns {seconds} s",
525
- "ev.stain_burned": "A mancha em chamas se consumiu",
526
- "ev.fire_dries": "O fogo secou as manchas molhadas por perto",
527
- "fl.fill": "{who}: encheu de {liquid} (+{liters} l)",
528
- "fl.pour": "{who}: despejando {liquid} a {rate} l/s",
529
- "fl.spill": "{who}: derramou {liquid} pela borda a {rate} l/s",
530
- "fl.leak": "{who}: vazando {liquid} a {rate} l/s",
531
- "fl.drink": "{who}: bebeu {liters} l, sede +{thirst}",
532
- "fl.burn": "quente demais: queimou a boca",
533
- "fl.empty": "{who}: vazio",
534
- "fl.splash": "Respingo de {liquid}",
535
- "fl.stain": "Mancha de {liquid} {surface} em {where}",
536
- "fl.stains": "{n} manchas de {liquid} {surface}, a \xFAltima em {where}",
537
- "fl.dried": "Uma mancha secou",
538
- "fl.dried.mark": "Uma mancha secou e deixou marca",
539
- "fl.dried.known": "A mancha de {liquid} {surface} em {where} secou",
540
- "fl.dried.known.mark": "A mancha de {liquid} {surface} em {where} secou e deixou marca",
541
- "fl.dried.many": "{n} manchas de {liquid} secaram",
542
- "fl.dried.many.mark": "{n} manchas de {liquid} secaram e deixaram marca",
543
- "fl.mix": "{who}: {from} misturou em {into}",
544
- "act.anim_start": '{name}: anima\xE7\xE3o "{clip}" ({seconds} s)',
545
- "act.anim_event": '{name}: "{clip}" evento {type}',
546
- "act.anim_end": '{name}: anima\xE7\xE3o "{clip}" terminou',
547
- "act.module_loaded": 'M\xF3dulo "{name}" carregado: {items} item(ns), {scenarios} cen\xE1rio(s), verifica\xE7\xF5es do contrato: {result}',
548
- "mod.all_ok": "todas passaram",
549
- "mod.some_failed": "algumas FALHARAM",
550
- "mod.destructible_ok": "{name}: contrato de destrui\xE7\xE3o ok ({parts} pe\xE7as, golpe e estado bem formados)",
551
- "mod.destructible_fail": "{name}: contrato de destrui\xE7\xE3o quebrado: {problems}",
552
- "mod.animation_ok": '{name}: anima\xE7\xE3o "{clip}" ok ({seconds} s)',
553
- "mod.spec_ok": "{name}: spec ok ({parts} pe\xE7as)",
554
- "mod.spec_fail": "{name}: spec quebrada: {problems}",
555
- "mod.spec_warn": "{name}: aviso na spec: {warning}",
556
- "mod.animation_fail": '{name}: anima\xE7\xE3o "{clip}" quebrada: {problems}',
557
- "ui.animations": "Anima\xE7\xF5es",
558
- "ui.animations_aim": "Mire num item ou segure-o para ver as anima\xE7\xF5es.",
559
- "ui.animations_none": "Este item n\xE3o traz anima\xE7\xF5es.",
560
- "mod.bench_ok": "{name}: bancada ok ({controls} controles, {layers} camadas, {metrics} contadores)",
561
- "mod.bench_fail": "{name}: bancada quebrada: {problems}",
562
- "mod.metric_failed": '{name}: o contador "{metric}" lan\xE7ou erro e mostra n/a: {error}',
563
- "mod.listener_failed": "{name}: um ouvinte da bancada lan\xE7ou erro: {error}",
564
- "ui.bench": "Bancada",
565
- "ui.layers": "Camadas",
566
- "ui.metrics": "Contadores",
567
- "metric.fps": "FPS",
568
- "metric.frame_ms": "ms/quadro",
569
- "metric.draw_calls": "Chamadas de desenho",
570
- "metric.triangles": "Tri\xE2ngulos",
571
- "metric.objects": "Objetos",
572
- "metric.fragments": "Fragmentos",
573
- "metric.particles": "Part\xEDculas",
574
- "metric.decals": "Decalques",
575
- "act.spawn": "Criou {name} em x {x} \xB7 z {z}",
576
- "act.clear": "Limpou a sala",
577
- "act.reset": "Consertou todos os objetos",
578
- "act.tool": "Equipou {weapon}",
579
- "act.shoot.hit": "{weapon}: disparo, {energy} J, acertou {where} a {dist} m",
580
- "act.shoot.miss": "{weapon}: disparo, {energy} J, n\xE3o acertou nada",
581
- "act.shoot.pellets": "{weapon}: disparo, {n} proj\xE9teis de at\xE9 {energy} J, {hits} acertaram {where} a {dist} m",
582
- "act.melee.hit": "{weapon}: golpe, {energy} J, acertou {where} a {dist} m",
583
- "act.melee.miss": "{weapon}: golpe no ar",
584
- "act.flame.hit": "{weapon}: chama a {temp} \xB0C em {where}",
585
- "act.flame.air": "{weapon}: chama, nada ao alcance",
586
- "act.heat.hit": "{weapon}: esquentando {where} a {temp} \xB0C",
587
- "act.heat.air": "{weapon}: nada ao alcance",
588
- "act.throw.grenade": "{weapon}: arremessada, pavio de {fuse} s",
589
- "act.throw.molotov": "{weapon}: arremessado, quebra no impacto",
590
- "act.launch": "{weapon}: disparado, arma depois de {arm} m",
591
- "act.place": "{weapon}: carga posta, espera {triggers}",
592
- "act.tripwire": "{weapon}: {who} cruzou o fio",
593
- "act.dud": "{weapon}: bateu antes de armar ({distance} m), n\xE3o explodiu",
594
- "act.backblast": "{weapon}: sopro atr\xE1s do tubo, {radius} m, {n} objetos no cone, {player} no atirador",
595
- "act.blast_player": "{weapon}: voc\xEA est\xE1 na zona {zone}, a {distance} m (atordoado {stun} s)",
596
- "hud.no_charges": "Nenhuma carga posta espera o detonador",
597
- "blast.zone.lethal": "letal",
598
- "blast.zone.injury": "de ferimento",
599
- "act.explode": "{weapon}: explodiu, {energy} J em {radius} m, {n} objetos ao alcance",
600
- "act.molotov_fire": "{weapon}: fogo no ch\xE3o, raio de {radius} m por {seconds} s",
601
- "act.tool.none": "{weapon}: nada ao alcance",
602
- "act.tool.nojoint": "{weapon}: nenhuma junta perto de {where}",
603
- "act.tool.refused": "{weapon} n\xE3o solta {kind} de {obj}; precisa de: {need}",
604
- "act.extinguish.hit": "{weapon}: espuma em {what}",
605
- "act.extinguish.air": "{weapon}: espuma, nada ao alcance",
606
- "act.grab": "Pegou {name} ({kg} kg)",
607
- "act.drop": "Soltou {name}",
608
- "act.throw": "Arremessou {name} a {speed} m/s",
609
- "act.landing": "{name} caiu a {speed} m/s: {energy} J",
610
- "act.impact": "{name} bateu em {where} a {speed} m/s: {energy} J de dano de impacto",
611
- "act.use_item": "{name}: {use}",
612
- "act.tap.on": "Abriu a torneira",
613
- "act.tap.off": "Fechou a torneira",
614
- "use.drink": "beber",
615
- "use.sip": "golinho",
616
- "use.chug": "virar",
617
- "use.pour": "despejar",
618
- "use.pour_out": "esvaziar",
619
- "use.open": "abrir",
620
- "use.close": "fechar",
621
- "hud.reloading": "Recarregando...",
622
- "hud.empty": "Sem muni\xE7\xE3o",
623
- "hud.no_fuel": "Sem combust\xEDvel",
624
- "hud.fault": "Travou ({fault}): aperte X para destravar",
625
- "hud.safe": "Trava ligada: aperte B",
626
- "hud.clearing": "Destravando...",
627
- "hud.safety_on": "Trava ligada",
628
- "hud.safety_off": "Trava desligada",
629
- "fault.misfire": "nega",
630
- "fault.stovepipe": "chamin\xE9",
631
- "fault.double_feed": "dupla alimenta\xE7\xE3o",
632
- "fault.failure_to_extract": "falha de extra\xE7\xE3o",
633
- "hud.nothing_to_grab": "Nada para pegar aqui",
634
- "hud.cannot_grab": "{name} n\xE3o d\xE1 para pegar",
635
- "hud.too_heavy": "{name} \xE9 pesado demais ({kg} kg)",
636
- "hud.empty_container": "{name} est\xE1 vazio",
637
- "hud.refused": "Ferramenta errada para {kind}",
638
- "hud.drop": "soltar",
639
- "hud.rotate": "girar",
640
- "hud.throw": "arremessar",
641
- "hud.tilt": "inclinar (segurar)",
642
- "hud.equip": "equipar {weapon}",
643
- "ui.help": "Clique: olhar \xB7 WASD: andar \xB7 Shift: correr \xB7 Espa\xE7o: pular \xB7 1-0 / roda: ferramenta \xB7 bot\xE3o esquerdo: usar \xB7 E: pegar/soltar \xB7 G ou bot\xE3o direito: arremessar \xB7 R: recarregar \xB7 Q: modo de tiro \xB7 X: destravar \xB7 B: trava \xB7 Y: detonar cargas postas \xB7 Tab: pain\xE9is \xB7 Esc: soltar o mouse",
644
- "ui.log": "Registro de comportamento",
645
- "ui.filter.action": "a\xE7\xF5es",
646
- "ui.filter.event": "eventos",
647
- "ui.filter.check": "testes",
648
- "ui.autoscroll": "rolar sozinho",
649
- "log.export_cancelled": "exporta\xE7\xE3o cancelada",
650
- "ui.copy_json": "Copiar JSON",
651
- "ui.download_ndjson": "Baixar NDJSON",
652
- "ui.download_txt": "Baixar TXT",
653
- "ui.clear_log": "Limpar",
654
- "ui.catalog": "Cat\xE1logo",
655
- "ui.clear": "Limpar",
656
- "ui.reset_all": "Consertar tudo",
657
- "ui.slow": "C\xE2mera lenta 0,25\xD7",
658
- "ui.fast": "Tempo \xD710",
659
- "ui.tap": "Torneira",
660
- "ui.blast.shelter": "Abrigo",
661
- "ui.blast.fragmentHits": "Estilha\xE7os",
662
- "ui.blast.confinement": "Confinamento",
663
- "ui.blast.shelter.hint": "Explos\xF5es: paredes e outras partes entre a explos\xE3o e o alvo ficam com a sua parte",
664
- "ui.blast.fragmentHits.hint": "Explos\xF5es: os peda\xE7os do que quebra voam e acertam o que est\xE1 atr\xE1s",
665
- "ui.blast.confinement.hint": "Explos\xF5es: a press\xE3o sobe num ambiente fechado",
666
- "metric.blast_shelter": "Explos\xE3o que chegou (%)",
667
- "metric.blast_confinement": "Press\xE3o da explos\xE3o (x)",
668
- "metric.blast_fragments": "Estilha\xE7os que acertaram",
669
- "act.blast_report": "Explos\xE3o: {shelter}% chegou ao alvo mais abrigado, press\xE3o x{confinement}, {fragments} estilha\xE7os acertaram, {n} alvos",
670
- "ui.quality": "Qualidade",
671
- "ui.quality.low": "baixa",
672
- "ui.quality.medium": "m\xE9dia",
673
- "ui.quality.high": "alta",
674
- "ui.equip": "Equipar como ferramenta",
675
- "ui.search_items": "Buscar item (todas as abas)",
676
- "ui.search_tests": "Buscar teste",
677
- "ui.no_results": "Nada encontrado",
678
- "ui.objects": "{n} objetos",
679
- "ui.tests": "Testes",
680
- "ui.run_all": "Rodar todos",
681
- "ui.running": "Rodando...",
682
- "ui.report": "{passed} passaram, {failed} falharam",
683
- "ui.panel": "Pain\xE9is",
684
- "ui.inspector": "Inspetor",
685
- "ui.aim_at_something": "Mire em um objeto",
686
- "ins.material": "Material",
687
- "ins.status": "Estado",
688
- "ins.burning": "em chamas",
689
- "ins.hp": "Pontos de vida",
690
- "ins.temperature": "Temperatura",
691
- "ins.char": "Carbonizado",
692
- "ins.melt": "Derretido",
693
- "ins.liters": "L\xEDquido",
694
- "ins.damage": "Marcas",
695
- "ins.damage_counts": "{dents}, {holes}, {cracks}",
696
- "ins.mass": "Massa",
697
- "ins.load": "Carga",
698
- "ins.stress": "Tens\xE3o",
699
- "ins.joints": "Juntas",
700
- "ins.open": "aberta",
701
- "sc.start": "Cen\xE1rio: {name}",
702
- "sc.pass": "Cen\xE1rio passou: {name}",
703
- "sc.fail": "Cen\xE1rio FALHOU: {name}",
704
- "sc.error": "o cen\xE1rio lan\xE7ou um erro: {msg}",
705
- "leg.still": "leg_1 continua presa",
706
- "leg.gone": "leg_1 soltou",
707
- "sc.steel_through_hole": "Tiro de 9 mm em chapa de a\xE7o faz furo que atravessa",
708
- "sc.steel_through_hole.check": "9 mm em a\xE7o de 3 mm: {holes} furo(s) de {mm} mm, atravessou: {through}, chapa com {hp} hp (esperado: atravessar)",
709
- "sc.concrete_stops_bullet": "Tiro de 9 mm em bloco de concreto n\xE3o atravessa",
710
- "sc.concrete_stops_bullet.check": "9 mm em 40 cm de concreto: atravessou: {through}, {dents}, {holes}, {cracks}, bloco com {hp} hp (esperado: parou, parede atr\xE1s intacta)",
711
- "sc.hammer_dents_drum": "Martelo amassa um tambor de a\xE7o",
712
- "sc.hammer_dents_drum.check": "Martelo no tambor: {swings} golpe(s), {dents} amassado(s), o primeiro com {depth} mm, corpo com {hp} hp (esperado: amassado, sem quebrar)",
713
- "sc.glass_shatters": "Vidro estilha\xE7a no primeiro tiro de 9 mm",
714
- "sc.glass_shatters.check": "Vidro: quebrou depois de {shots} tiro(s) em {n} cacos (esperado: quebrado no primeiro tiro, pelo menos 4 cacos)",
715
- "sc.shotgun_splinters_crate": "Escopeta lasca um caixote de madeira",
716
- "sc.shotgun_splinters_crate.check": "Calibre 12 no caixote: {shots} tiro(s), {splinters} t\xE1bua(s) lascada(s), {holes} furos (esperado: pelo menos uma lascada)",
717
- "sc.kick_chair_leg": "Um chute solta a perna da cadeira",
718
- "sc.kick_chair_leg.check": "Cadeira: {kicks} chute(s), {groups} grupo(s), pe\xE7as soltas {before} \u2192 {after}, {leg} (esperado: aparece uma pe\xE7a solta)",
719
- "sc.kick_detaches_leg_via_tool": "A ferramenta chute (barra de atalhos) solta a perna da cadeira",
720
- "sc.kick_detaches_leg_via_tool.check": "Cadeira, ferramenta chute: {kicks} chute(s), pe\xE7as soltas {before} \u2192 {after}, {leg} (esperado: aparece uma pe\xE7a solta)",
721
- "sc.screwdriver_shelf_board": "Chave de fenda solta a prateleira parafusada em cerca de 2,5 s",
722
- "sc.screwdriver_shelf_board.check": "Chave de fenda na prateleira: levou {secs} s, esperado cerca de {want} s, a estante agora est\xE1 em {pieces}",
723
- "sc.wrong_tool_refused": "A ferramenta errada \xE9 recusada",
724
- "sc.wrong_tool_refused.check": "Chave de fenda numa solda: recusou {refused} vez(es), juntas abertas {opened} (esperado: recusar, nenhuma aberta)",
725
- "sc.lighter_burns_crate": "Isqueiro acende o caixote de madeira e ele vira cinza",
726
- "sc.lighter_burns_crate.lit": "Isqueiro no caixote: pegou fogo: {lit} (esperado: sim)",
727
- "sc.lighter_burns_crate.ash": "Depois de queimar por 500 s: {ash} de {total} t\xE1buas viraram cinza, a mais queimada {char}% (esperado: tudo cinza ou carbonizado)",
728
- "sc.torch_melts_steel": "Fogo de madeira n\xE3o derrete o tambor de a\xE7o, o ma\xE7arico derrete",
729
- "sc.torch_melts_steel.wood": "Caixote em chamas ao lado do tambor: o tambor chegou a {peak} \xB0C, derreteu {melt}%, caixote ainda queimando: {crate} (esperado: sem derreter)",
730
- "sc.torch_melts_steel.torch": "Ma\xE7arico no tambor: {temp} \xB0C, derreteu {melt}% (esperado: derretendo)",
731
- "sc.bucket_melts_drips": "Balde de pl\xE1stico derrete e pinga perto do fogo",
732
- "sc.bucket_melts_drips.check": "Balde nas chamas: derreteu {melt}%, {temp} \xB0C, {drips} linha(s) de pingos (esperado: derretendo)",
733
- "sc.gas_bottle_explodes": "Botij\xE3o de g\xE1s no fogo explode e empurra um caixote",
734
- "sc.gas_bottle_explodes.explode": "Botij\xE3o no fogo: explodiu depois de {seconds} s (esperado: explode)",
735
- "sc.gas_bottle_explodes.push": "Caixote a 1,3 m: andou {moved} m, perdeu {lost} (esperado: empurrado ou quebrado)",
736
- "sc.bottle_drop_shatters": "Garrafa de vidro solta de 1,5 m estilha\xE7a",
737
- "sc.bottle_drop_shatters.check": "Garrafa solta de 1,5 m: estado {status}, {cracks} trincas, mancha de \xE1gua: {stain} (esperado: quebrada, \xE1gua no ch\xE3o)",
738
- "sc.throw_chair_at_wall": "Cadeira arremessada na parede causa dano de impacto",
739
- "sc.throw_chair_at_wall.check": "Cadeira na parede: {impacts} impacto(s), o primeiro com {energy} J, {wall} linha(s) de dano na parede (esperado: impacto e dano)",
740
- "sc.coffee_three_sips": "Tr\xEAs goles de caf\xE9 baixam o n\xEDvel e registram cafe\xEDna",
741
- "sc.coffee_three_sips.check": "Caf\xE9: {sips} gole(s), n\xEDvel {before} \u2192 {after} l, cafe\xEDna registrada: {caffeine}, queimou a boca: {burn} (esperado: 3 goles, cafe\xEDna)",
742
- "sc.tilt_cup_spills": "Inclinar o copo derrama caf\xE9 e deixa mancha",
743
- "sc.tilt_cup_spills.check": "Copo inclinado: n\xEDvel {before} \u2192 {after} l, {stains} mancha(s) de caf\xE9 (esperado: derramou com mancha)",
744
- "sc.jerrycan_leaks_fuel": "Tiro no gal\xE3o faz vazamento e mancha de combust\xEDvel",
745
- "sc.jerrycan_leaks_fuel.check": "Gal\xE3o baleado: {streams} jato(s) de vazamento, mancha de combust\xEDvel: {stain}, restam {left} l de 20, a linha do furo cita o vazamento: {leak} (esperado: vazando, mancha)",
746
- "sc.fuel_stain_burns": "Mancha de combust\xEDvel acesa queima",
747
- "sc.fuel_stain_burns.stain": "Mancha de combust\xEDvel no ch\xE3o depois do vazamento: {stain} (esperado: sim)",
748
- "sc.fuel_stain_burns.ignite": "Isqueiro na mancha: {ignited} igni\xE7\xE3o(\xF5es), o fogo alcan\xE7ou objetos por perto: {near} (esperado: acendeu)",
749
- "sc.fuel_stain_burns.out": "Depois de 30 s a mancha se consumiu: {burned} (esperado: sim)",
750
- "roof.standing": "laje de p\xE9",
751
- "roof.fell": "laje caiu",
752
- "sc.one_column_roof_stands": "Pilar destru\xEDdo: a laje fica de p\xE9",
753
- "sc.one_column_roof_stands.check": "Estrutura sem um pilar: {roof}, maior desabamento {heavy} kg, tens\xE3o m\xE1xima {stress}% (esperado: de p\xE9, sem desabamento grande)",
754
- "sc.two_columns_roof_collapses": "Dois pilares destru\xEDdos: a laje desaba",
755
- "sc.two_columns_roof_collapses.check": "Estrutura sem dois pilares: os outros logo a {stress}%, depois pe\xE7as pesadas soltas: {pieces} ({kg} kg), evento de desabamento registrado: {logged} (esperado: a laje desaba)",
756
- "sc.extinguisher_puts_out_fire": "Extintor apaga um caixote em chamas",
757
- "sc.extinguisher_puts_out_fire.burning": "Caixote incendiado: queimando: {burning} (esperado: sim)",
758
- "sc.extinguisher_puts_out_fire.out": "Depois do extintor: ainda queimando: {burning}, {events} evento(s) de apagar (esperado: apagado)"
759
- }
248
+ function fitValue(control, value) {
249
+ if (control.kind === "number") return typeof value === "number" && Number.isFinite(value) ? snap(control, value) : void 0;
250
+ if (control.kind === "boolean") return typeof value === "boolean" ? value : void 0;
251
+ if (control.kind === "choice") return typeof value === "string" && control.options.some((option) => option.id === value) ? value : void 0;
252
+ return void 0;
253
+ }
254
+ var fromText = (control, text) => {
255
+ if (control.kind === "number") return text.trim() === "" ? Number.NaN : Number(text);
256
+ if (control.kind === "boolean") return text === "1" ? true : text === "0" ? false : void 0;
257
+ return text;
760
258
  };
761
- var locales = { en, "pt-BR": ptBR };
762
- var resolveStrings = (locale) => locales[locale ?? "en"] ?? locales.en;
763
-
764
- // src/items.ts
765
- var DEMO_MATERIALS = [];
766
- var box = (id, material, size, position, extra = {}) => ({ id, material, shape: { kind: "box", size }, position, ...extra });
767
- var cyl = (id, material, radius, height, position, extra = {}) => ({
768
- id,
769
- material,
770
- shape: { kind: "cylinder", radius, height },
771
- position,
772
- ...extra
773
- });
774
- var sph = (id, material, radius, position, extra = {}) => ({ id, material, shape: { kind: "sphere", radius }, position, ...extra });
775
- var joint = (a, b, kind, extra = {}) => ({ a, b, kind, ...extra });
776
- function brickWallSpec(cols = 8, rows = 6, panel = [1.25, 0.5, 0.2]) {
777
- const parts = [];
778
- const joints = [];
779
- const id = (c, r) => `brick_${r}_${c}`;
780
- for (let r = 0; r < rows; r++) {
781
- for (let c = 0; c < cols; c++) {
782
- parts.push(box(id(c, r), "brick", panel, [(c - (cols - 1) / 2) * panel[0], panel[1] / 2 + r * panel[1], 0], { anchored: r === 0 }));
783
- if (c > 0) joints.push(joint(id(c - 1, r), id(c, r), "mortar"));
784
- if (r > 0) joints.push(joint(id(c, r - 1), id(c, r), "mortar"));
259
+ var toText = (value) => typeof value === "boolean" ? value ? "1" : "0" : String(value);
260
+ function encodeBenchState(controls, layers) {
261
+ const pairs = [];
262
+ for (const control of controls) if (control.value !== void 0) pairs.push(`${control.id}=${encodeURIComponent(toText(control.value))}`);
263
+ for (const layer of layers) pairs.push(`${layer.id}=${layer.on ? "1" : "0"}`);
264
+ return pairs.join(";");
265
+ }
266
+ function decodeBenchState(encoded, controls, layers) {
267
+ const out = /* @__PURE__ */ new Map();
268
+ for (const pair of encoded.split(";")) {
269
+ const at = pair.indexOf("=");
270
+ if (at <= 0) continue;
271
+ const id = pair.slice(0, at);
272
+ let text;
273
+ try {
274
+ text = decodeURIComponent(pair.slice(at + 1));
275
+ } catch {
276
+ continue;
277
+ }
278
+ const control = controls.find((c) => c.id === id);
279
+ if (control) {
280
+ const value = fitValue(control, fromText(control, text));
281
+ if (value !== void 0) out.set(id, value);
282
+ continue;
785
283
  }
284
+ if (layers.some((layer) => layer.id === id) && (text === "1" || text === "0")) out.set(id, text === "1");
786
285
  }
787
- return { name: "brick wall", parts, joints, tags: ["structure"] };
788
- }
789
- var chair = () => {
790
- const legs = [
791
- [-0.18, -0.18],
792
- [0.18, -0.18],
793
- [-0.18, 0.18],
794
- [0.18, 0.18]
795
- ];
796
- return {
797
- name: "chair",
798
- parts: [
799
- box("seat", "wood_soft", [0.42, 0.04, 0.42], [0, 0.45, 0]),
800
- ...legs.map(([x, z], i) => box(`leg_${i + 1}`, "wood_soft", [0.04, 0.43, 0.04], [x, 0.215, z])),
801
- box("backrest", "wood_soft", [0.4, 0.3, 0.03], [0, 0.64, -0.195])
802
- ],
803
- joints: [...legs.map((_, i) => joint(`leg_${i + 1}`, "seat", "screw")), joint("backrest", "seat", "screw")],
804
- tags: ["furniture"]
805
- };
806
- };
807
- var crate = () => ({
808
- name: "wooden crate",
809
- parts: [
810
- box("front", "wood_soft", [0.5, 0.5, 0.02], [0, 0.25, 0.24], { hp: 70 }),
811
- box("back", "wood_soft", [0.5, 0.5, 0.02], [0, 0.25, -0.24], { hp: 70 }),
812
- box("left", "wood_soft", [0.02, 0.5, 0.46], [-0.24, 0.25, 0], { hp: 70 }),
813
- box("right", "wood_soft", [0.02, 0.5, 0.46], [0.24, 0.25, 0], { hp: 70 }),
814
- box("bottom", "wood_soft", [0.46, 0.02, 0.46], [0, 0.01, 0], { hp: 70 })
815
- ],
816
- joints: [
817
- joint("front", "left", "nail"),
818
- joint("front", "right", "nail"),
819
- joint("back", "left", "nail"),
820
- joint("back", "right", "nail"),
821
- joint("front", "bottom", "nail"),
822
- joint("back", "bottom", "nail"),
823
- joint("left", "bottom", "nail"),
824
- joint("right", "bottom", "nail")
825
- ],
826
- tags: ["container"]
827
- });
828
- var drum = () => ({
829
- name: "steel drum",
830
- parts: [
831
- cyl("drum_body", "steel", 0.29, 0.86, [0, 0.45, 0], { mass: 12, thickness: 12e-4 }),
832
- cyl("drum_top", "steel", 0.29, 0.02, [0, 0.89, 0], { mass: 1.5, thickness: 12e-4 }),
833
- cyl("drum_bottom", "steel", 0.29, 0.02, [0, 0.01, 0], { mass: 1.5, thickness: 12e-4 })
834
- ],
835
- joints: [joint("drum_body", "drum_top", "weld"), joint("drum_body", "drum_bottom", "weld")],
836
- tags: ["container"]
837
- });
838
- var steelSheet = () => ({
839
- name: "steel sheet",
840
- parts: [
841
- box("sheet", "steel", [1, 1, 3e-3], [0, 1.1, 0]),
842
- box("post_l", "steel", [0.04, 0.54, 0.04], [-0.35, 0.33, 0], { mass: 4 }),
843
- box("post_r", "steel", [0.04, 0.54, 0.04], [0.35, 0.33, 0], { mass: 4 }),
844
- box("foot_l", "steel", [0.06, 0.06, 0.5], [-0.35, 0.03, 0], { mass: 6 }),
845
- box("foot_r", "steel", [0.06, 0.06, 0.5], [0.35, 0.03, 0], { mass: 6 })
846
- ],
847
- joints: [joint("sheet", "post_l", "weld"), joint("sheet", "post_r", "weld"), joint("post_l", "foot_l", "weld"), joint("post_r", "foot_r", "weld")],
848
- tags: ["structure"]
849
- });
850
- var glassPane = () => ({
851
- name: "glass pane",
852
- parts: [
853
- box("pane", "glass", [0.8, 1, 6e-3], [0, 1.05, 0], { hp: 40 }),
854
- box("frame_top", "aluminium", [0.88, 0.04, 0.04], [0, 1.57, 0], { mass: 1 }),
855
- box("frame_bottom", "aluminium", [0.88, 0.04, 0.04], [0, 0.53, 0], { mass: 1 }),
856
- box("frame_l", "aluminium", [0.04, 1.04, 0.04], [-0.42, 1.05, 0], { mass: 1 }),
857
- box("frame_r", "aluminium", [0.04, 1.04, 0.04], [0.42, 1.05, 0], { mass: 1 }),
858
- box("foot_l", "steel", [0.06, 0.5, 0.4], [-0.35, 0.25, 0], { mass: 5 }),
859
- box("foot_r", "steel", [0.06, 0.5, 0.4], [0.35, 0.25, 0], { mass: 5 })
860
- ],
861
- joints: [
862
- joint("pane", "frame_top", "clip"),
863
- joint("pane", "frame_bottom", "clip"),
864
- joint("pane", "frame_l", "clip"),
865
- joint("pane", "frame_r", "clip"),
866
- joint("frame_bottom", "foot_l", "bolt"),
867
- joint("frame_bottom", "foot_r", "bolt"),
868
- joint("frame_l", "frame_top", "weld"),
869
- joint("frame_r", "frame_top", "weld"),
870
- joint("frame_l", "frame_bottom", "weld"),
871
- joint("frame_r", "frame_bottom", "weld")
872
- ],
873
- tags: ["structure"]
874
- });
875
- var concreteBlock = () => ({
876
- name: "concrete block",
877
- parts: [box("block", "concrete", [0.4, 0.4, 0.4], [0, 0.2, 0])],
878
- tags: ["structure"]
879
- });
880
- var brickColumn = () => {
881
- const parts = [];
882
- const joints = [];
883
- const layers = 8;
884
- const id = (l, x, z) => `brick_${l}_${x}${z}`;
885
- for (let l = 0; l < layers; l++) {
886
- for (let x = 0; x < 2; x++) {
887
- for (let z = 0; z < 2; z++) {
888
- parts.push(box(id(l, x, z), "brick", [0.2, 0.2, 0.2], [(x - 0.5) * 0.2, 0.1 + l * 0.2, (z - 0.5) * 0.2], { anchored: l === 0 }));
889
- if (x > 0) joints.push(joint(id(l, x - 1, z), id(l, x, z), "mortar"));
890
- if (z > 0) joints.push(joint(id(l, x, z - 1), id(l, x, z), "mortar"));
891
- if (l > 0) joints.push(joint(id(l - 1, x, z), id(l, x, z), "mortar"));
892
- }
893
- }
894
- }
895
- return { name: "brick column", parts, joints, tags: ["structure"] };
896
- };
897
- var frame = () => ({ ...buildFrame({ spanX: 4, spanZ: 4, columnsX: 2, columnsZ: 2, height: 2.8, floors: 1, walls: false, roof: true, columnCapacity: 4400 }), name: "building frame" });
898
- var bucket = () => ({
899
- name: "plastic bucket",
900
- parts: [
901
- cyl("bucket_body", "plastic", 0.14, 0.28, [0, 0.14, 0], { mass: 0.5, thickness: 2e-3 }),
902
- box("handle", "plastic", [0.3, 0.012, 0.012], [0, 0.3, 0], { mass: 0.05 })
903
- ],
904
- joints: [joint("handle", "bucket_body", "clip")],
905
- tags: ["container"]
906
- });
907
- var bottle = () => ({
908
- name: "glass bottle",
909
- parts: [
910
- cyl("bottle_body", "glass", 0.037, 0.17, [0, 0.085, 0], { mass: 0.3, thickness: 3e-3, contains: { fluid: "water", liters: 0.5 } }),
911
- cyl("bottle_neck", "glass", 0.015, 0.09, [0, 0.215, 0], { mass: 0.08, thickness: 2e-3 })
912
- ],
913
- joints: [joint("bottle_neck", "bottle_body", "fused")],
914
- tags: ["item"]
915
- });
916
- var cup = () => ({
917
- name: "coffee cup",
918
- parts: [
919
- cyl("cup", "ceramic", 0.05, 0.11, [0, 0.055, 0], { mass: 0.3, thickness: 4e-3, contains: { fluid: "coffee", liters: 0.25 } }),
920
- box("cup_handle", "ceramic", [0.02, 0.06, 0.014], [0.068, 0.06, 0], { mass: 0.04 })
921
- ],
922
- joints: [joint("cup_handle", "cup", "fused")],
923
- tags: ["item"]
924
- });
925
- var jerrycan = () => ({
926
- name: "jerrycan",
927
- parts: [
928
- box("tank", "plastic", [0.34, 0.45, 0.14], [0, 0.225, 0], { mass: 1.2, thickness: 3e-3, contains: { fluid: "fuel", liters: 20 } }),
929
- cyl("cap", "plastic", 0.025, 0.03, [0.08, 0.465, 0], { mass: 0.05 }),
930
- box("handle", "plastic", [0.1, 0.03, 0.03], [-0.07, 0.465, 0], { mass: 0.1 })
931
- ],
932
- joints: [joint("cap", "tank", "screw"), joint("handle", "tank", "fused")],
933
- tags: ["container"]
934
- });
935
- var gasBottle = () => ({
936
- name: "gas bottle",
937
- parts: [
938
- cyl("cylinder", "steel", 0.15, 0.7, [0, 0.4, 0], {
939
- mass: 11,
940
- thickness: 3e-3,
941
- contains: { fluid: "gas", liters: 13, pressurized: true },
942
- explodes: { energy: 12e4, radius: 5, atC: 300 }
943
- }),
944
- cyl("valve", "steel", 0.03, 0.1, [0, 0.8, 0], { mass: 0.5 }),
945
- cyl("foot_ring", "steel", 0.14, 0.05, [0, 0.025, 0], { mass: 1 })
946
- ],
947
- joints: [joint("valve", "cylinder", "weld"), joint("foot_ring", "cylinder", "weld")],
948
- tags: ["container"]
949
- });
950
- var tree = () => ({
951
- name: "small tree",
952
- parts: [
953
- cyl("trunk", "green_wood", 0.08, 2.2, [0, 1.1, 0], { anchored: true }),
954
- cyl("branch_1", "green_wood", 0.03, 0.8, [0.34, 1.7, 0], { rotation: [0, 0, -0.9] }),
955
- cyl("branch_2", "green_wood", 0.03, 0.8, [-0.3, 1.9, 0.12], { rotation: [0, 0, 0.8] }),
956
- cyl("branch_3", "green_wood", 0.025, 0.7, [0.05, 2, -0.28], { rotation: [0.8, 0, 0] }),
957
- sph("crown_top", "foliage", 0.55, [0, 2.55, 0], { mass: 2 }),
958
- sph("crown_1", "foliage", 0.38, [0.62, 2, 0], { mass: 1 }),
959
- sph("crown_2", "foliage", 0.38, [-0.58, 2.2, 0.15], { mass: 1 }),
960
- sph("crown_3", "foliage", 0.32, [0.05, 2.3, -0.55], { mass: 1 })
961
- ],
962
- joints: [
963
- joint("branch_1", "trunk", "stem"),
964
- joint("branch_2", "trunk", "stem"),
965
- joint("branch_3", "trunk", "stem"),
966
- joint("crown_top", "trunk", "stem"),
967
- joint("crown_1", "branch_1", "stem"),
968
- joint("crown_2", "branch_2", "stem"),
969
- joint("crown_3", "branch_3", "stem")
970
- ],
971
- tags: ["plant"]
972
- });
973
- var pottedPlant = () => ({
974
- name: "potted plant",
975
- parts: [
976
- cyl("pot", "ceramic", 0.15, 0.25, [0, 0.125, 0], { mass: 2, thickness: 8e-3 }),
977
- cyl("soil", "soil", 0.14, 0.06, [0, 0.26, 0], { mass: 1.5 }),
978
- sph("leaves_1", "foliage", 0.16, [0, 0.46, 0], { mass: 0.3 }),
979
- sph("leaves_2", "foliage", 0.12, [0.12, 0.38, 0.04], { mass: 0.2 }),
980
- sph("leaves_3", "foliage", 0.12, [-0.1, 0.4, -0.06], { mass: 0.2 })
981
- ],
982
- joints: [joint("soil", "pot", "fused"), joint("leaves_1", "soil", "stem"), joint("leaves_2", "soil", "stem"), joint("leaves_3", "soil", "stem")],
983
- tags: ["plant"]
984
- });
985
- var oldTv = () => ({
986
- name: "old TV",
987
- parts: [
988
- box("stand", "wood_soft", [0.6, 0.35, 0.42], [0, 0.175, 0]),
989
- box("shell", "plastic", [0.58, 0.44, 0.42], [0, 0.57, 0], { mass: 3, thickness: 4e-3 }),
990
- box("screen", "glass", [0.42, 0.32, 0.012], [0, 0.57, 0.212], { mass: 1.5 }),
991
- box("coil", "copper", [0.2, 0.1, 0.2], [0, 0.55, -0.05], { mass: 1.5 })
992
- ],
993
- joints: [joint("shell", "stand", "screw"), joint("screen", "shell", "clip"), joint("coil", "shell", "screw")],
994
- tags: ["furniture"]
995
- });
996
- var carDoor = () => ({
997
- name: "car door",
998
- parts: [
999
- box("post", "steel", [0.1, 1.3, 0.1], [-0.58, 0.65, 0], { mass: 12, anchored: true }),
1000
- box("skin", "steel", [1, 0.8, 0.05], [0, 0.75, 0], { mass: 14, thickness: 8e-4 }),
1001
- box("window", "glass", [0.78, 0.32, 0.01], [0.04, 1.31, 0.01], { mass: 3 }),
1002
- box("seal", "rubber", [0.84, 0.03, 0.03], [0.04, 1.14, 0.01], { mass: 0.3 })
1003
- ],
1004
- joints: [
1005
- joint("skin", "post", "hinge", { id: "hinge_upper" }),
1006
- joint("skin", "post", "hinge", { id: "hinge_lower" }),
1007
- joint("window", "seal", "clip"),
1008
- joint("seal", "skin", "clip")
1009
- ],
1010
- tags: ["vehicle"]
1011
- });
1012
- var sofa = () => ({
1013
- name: "sofa",
1014
- parts: [
1015
- box("frame", "wood_soft", [1.8, 0.3, 0.8], [0, 0.15, 0], { mass: 14 }),
1016
- box("seat_cushion", "foam", [1.5, 0.18, 0.62], [0, 0.39, 0.06], { mass: 4 }),
1017
- box("back_cushion", "foam", [1.5, 0.5, 0.16], [0, 0.65, -0.3], { mass: 4 }),
1018
- box("arm_l", "fabric", [0.15, 0.4, 0.8], [-0.82, 0.5, 0], { mass: 3 }),
1019
- box("arm_r", "fabric", [0.15, 0.4, 0.8], [0.82, 0.5, 0], { mass: 3 })
1020
- ],
1021
- joints: [
1022
- joint("seat_cushion", "frame", "clip"),
1023
- joint("back_cushion", "frame", "clip"),
1024
- joint("arm_l", "frame", "screw"),
1025
- joint("arm_r", "frame", "screw")
1026
- ],
1027
- tags: ["furniture"]
1028
- });
1029
- var shelf = () => ({
1030
- name: "shelf",
1031
- parts: [
1032
- box("back", "plywood", [0.84, 1.5, 0.012], [0, 0.75, -0.14], { anchored: true }),
1033
- box("board_1", "wood_soft", [0.8, 0.025, 0.28], [0, 0.35, 0]),
1034
- box("board_2", "wood_soft", [0.8, 0.025, 0.28], [0, 0.75, 0]),
1035
- box("board_3", "wood_soft", [0.8, 0.025, 0.28], [0, 1.15, 0]),
1036
- box("board_4", "wood_soft", [0.8, 0.025, 0.28], [0, 1.5, 0])
1037
- ],
1038
- joints: [joint("board_1", "back", "screw"), joint("board_2", "back", "screw"), joint("board_3", "back", "screw"), joint("board_4", "back", "screw")],
1039
- tags: ["furniture"]
1040
- });
1041
- var DEFS = [
1042
- { id: "chair", group: "furniture", spec: chair, colors: { wood_soft: 11038778 } },
1043
- { id: "sofa", group: "furniture", spec: sofa, colors: { frame: 7029798, seat_cushion: 8207180, back_cushion: 8207180, arm_l: 6958912, arm_r: 6958912 } },
1044
- { id: "shelf", group: "furniture", spec: shelf, colors: { back: 14272931 }, movable: false },
1045
- { id: "old_tv", group: "furniture", spec: oldTv, colors: { shell: 7166526, stand: 9067056, screen: 5205864 } },
1046
- { id: "steel_sheet", group: "structure", spec: steelSheet, movable: false, colors: { sheet: 11910340 } },
1047
- { id: "glass_pane", group: "structure", spec: glassPane, movable: false },
1048
- { id: "concrete_block", group: "structure", spec: concreteBlock },
1049
- { id: "brick_column", group: "structure", spec: brickColumn, movable: false },
1050
- { id: "building_frame", group: "structure", spec: frame, movable: false, colors: { roof: 8224121 } },
1051
- { id: "car_door", group: "vehicles", spec: carDoor, movable: false, colors: { skin: 12597547, window: 10278630 } },
1052
- { id: "small_tree", group: "plants", spec: tree, movable: false },
1053
- { id: "potted_plant", group: "plants", spec: pottedPlant, colors: { pot: 12871482 } },
1054
- { id: "coffee_cup", group: "items", spec: cup, colors: { cup: 15986662 }, fluid: { container: { capacity: 0.6, liquid: "coffee", liters: 0.25, temperature: 80, shape: { kind: "cylinder", radius: 0.045, height: 0.1 }, opening: 0.045 }, part: "cup", uses: ["drink", "sip", "pour"], offset: [0, 0.01, 0] } },
1055
- {
1056
- id: "glass_bottle",
1057
- group: "items",
1058
- spec: bottle,
1059
- colors: { bottle_body: 8378032, bottle_neck: 8378032 },
1060
- fluid: { container: { capacity: 0.5, liquid: "water", liters: 0.5, temperature: 18, shape: { kind: "cylinder", radius: 0.034, height: 0.14 }, opening: 0.012 }, part: "bottle_body", uses: ["drink", "sip", "pour"], offset: [0, 0.015, 0] }
1061
- },
1062
- { id: "wooden_crate", group: "containers", spec: crate },
1063
- { id: "steel_drum", group: "containers", spec: drum, colors: { drum_body: 2842526, drum_top: 2842526, drum_bottom: 2842526 } },
1064
- { id: "plastic_bucket", group: "containers", spec: bucket, colors: { bucket_body: 14239787, handle: 9079434 } },
1065
- {
1066
- id: "jerrycan",
1067
- group: "containers",
1068
- spec: jerrycan,
1069
- colors: { tank: 12729122, cap: 15909424, handle: 12729122 },
1070
- fluid: { container: { capacity: 20, liquid: "fuel", liters: 20, temperature: 20, shape: { kind: "box", size: [0.33, 0.44, 0.13] }, opening: 0.025, closed: true }, part: "tank", uses: ["pour", "open", "close"], offset: [0, 8e-3, 0] }
1071
- },
1072
- { id: "gas_bottle", group: "containers", spec: gasBottle, colors: { cylinder: 14211280, valve: 7829367, foot_ring: 5592405 } }
1073
- ];
1074
- function demoItems(locale = "en", extra = []) {
1075
- const s = resolveStrings(locale);
1076
- const out = DEFS.map((d) => ({
1077
- id: d.id,
1078
- name: s.items[d.id] ?? d.id,
1079
- group: s.groups[d.group] ?? d.group,
1080
- build: (THREE) => buildFromSpec(THREE, d.spec(), { colors: d.colors }),
1081
- spec: d.spec,
1082
- movable: d.movable,
1083
- fluid: d.fluid
1084
- }));
1085
- return [...out, ...extra];
1086
- }
1087
- var DEMO_ITEMS = demoItems("en");
1088
-
1089
- // src/arena.ts
1090
- var tableSpec = () => {
1091
- const parts = [{ id: "top", material: "wood_hard", shape: { kind: "box", size: [1.6, 0.05, 0.8] }, position: [0, 0.755, 0] }];
1092
- const joints = [];
1093
- for (const [i, [x, z]] of [
1094
- [-0.72, -0.32],
1095
- [0.72, -0.32],
1096
- [-0.72, 0.32],
1097
- [0.72, 0.32]
1098
- ].entries()) {
1099
- parts.push({ id: `leg_${i + 1}`, material: "wood_hard", shape: { kind: "box", size: [0.06, 0.73, 0.06] }, position: [x, 0.365, z], anchored: true });
1100
- joints.push({ a: `leg_${i + 1}`, b: "top", kind: "screw" });
1101
- }
1102
- return { name: "table", parts, joints, tags: ["furniture"] };
1103
- };
1104
- var shelfSpec = () => {
1105
- const parts = [];
1106
- const joints = [];
1107
- for (const [i, x] of [-0.6, 0.6].entries()) {
1108
- parts.push({ id: `upright_${i + 1}`, material: "wood_hard", shape: { kind: "box", size: [0.04, 1.7, 0.4] }, position: [x, 0.85, 0], anchored: true });
1109
- }
1110
- for (const [i, y] of [0.4, 0.9, 1.4].entries()) {
1111
- parts.push({ id: `board_${i + 1}`, material: "wood_soft", shape: { kind: "box", size: [1.2, 0.03, 0.4] }, position: [0, y, 0] });
1112
- joints.push({ a: `board_${i + 1}`, b: "upright_1", kind: "screw" }, { a: `board_${i + 1}`, b: "upright_2", kind: "screw" });
1113
- }
1114
- return { name: "shelf", parts, joints, tags: ["furniture"] };
1115
- };
1116
- function createArena(THREE, size, scene) {
1117
- const group = new THREE.Group();
1118
- group.name = "test-room-arena";
1119
- scene.background = new THREE.Color(10466504);
1120
- group.add(new THREE.HemisphereLight(13624319, 7037527, 0.75));
1121
- const sun = new THREE.DirectionalLight(16777215, 2.1);
1122
- sun.position.set(7, 13, 8);
1123
- sun.castShadow = true;
1124
- sun.shadow.mapSize.set(1024, 1024);
1125
- const sc = sun.shadow.camera;
1126
- sc.left = -16;
1127
- sc.right = 16;
1128
- sc.top = 16;
1129
- sc.bottom = -16;
1130
- sc.near = 1;
1131
- sc.far = 40;
1132
- sun.shadow.bias = -6e-4;
1133
- group.add(sun);
1134
- const floor = new THREE.Mesh(new THREE.PlaneGeometry(size * 2, size * 2), new THREE.MeshStandardMaterial({ color: 9342602, roughness: 0.95 }));
1135
- floor.rotation.x = -Math.PI / 2;
1136
- floor.receiveShadow = true;
1137
- floor.name = "floor";
1138
- group.add(floor);
1139
- const grid = new THREE.GridHelper(size * 2, size * 2, 5000274, 7829371);
1140
- grid.position.y = 4e-3;
1141
- group.add(grid);
1142
- const wallZ = -11;
1143
- const playerStart = [0, 1.7, 5];
1144
- const pts = [];
1145
- const rx = -1.6;
1146
- for (let z = Math.round(playerStart[2]); z >= wallZ; z--) {
1147
- const d = playerStart[2] - z;
1148
- const len = d % 5 === 0 ? 0.5 : 0.2;
1149
- pts.push(rx, 0.01, z, rx + len, 0.01, z);
1150
- }
1151
- pts.push(rx, 0.01, playerStart[2], rx, 0.01, wallZ);
1152
- const rg = new THREE.BufferGeometry();
1153
- rg.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3));
1154
- group.add(new THREE.LineSegments(rg, new THREE.LineBasicMaterial({ color: 16765503 })));
1155
- for (let d = 5; playerStart[2] - d >= wallZ; d += 5) {
1156
- try {
1157
- const canvas = document.createElement("canvas");
1158
- canvas.width = 128;
1159
- canvas.height = 64;
1160
- const ctx = canvas.getContext("2d");
1161
- if (!ctx) break;
1162
- ctx.fillStyle = "#ffd23f";
1163
- ctx.font = "bold 44px sans-serif";
1164
- ctx.textAlign = "center";
1165
- ctx.fillText(`${d} m`, 64, 48);
1166
- const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(canvas), transparent: true, depthWrite: false }));
1167
- sprite.scale.set(1.2, 0.6, 1);
1168
- sprite.position.set(rx - 0.8, 0.45, playerStart[2] - d);
1169
- group.add(sprite);
1170
- } catch {
1171
- break;
1172
- }
1173
- }
1174
- const tableX = 3.5;
1175
- const tapMat = new THREE.MeshStandardMaterial({ color: 12172996, metalness: 0.8, roughness: 0.35 });
1176
- const post = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 1.4, 10), tapMat);
1177
- post.position.set(tableX + 1.15, 0.7, 0);
1178
- const arm = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.025, 1.15 - 0.3, 10), tapMat);
1179
- arm.rotation.z = Math.PI / 2;
1180
- arm.position.set(tableX + (1.15 + 0.3) / 2, 1.38, 0);
1181
- const spoutMesh = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.025, 0.1, 10), tapMat);
1182
- spoutMesh.position.set(tableX + 0.3, 1.33, 0);
1183
- for (const m of [post, arm, spoutMesh]) {
1184
- m.castShadow = true;
1185
- group.add(m);
1186
- }
1187
- const tapSpout = [tableX + 0.3, 1.28, 0];
1188
- const objects = [
1189
- { id: "target_wall", spec: brickWallSpec(), position: [0, 0, wallZ], colors: { brick: 10831662 } },
1190
- { id: "table", spec: tableSpec(), position: [tableX, 0, 0] },
1191
- { id: "shelf", spec: shelfSpec(), position: [-3.5, 0, 0], colors: { wood_soft: 13081186 } }
1192
- ];
1193
- return {
1194
- group,
1195
- objects,
1196
- surfaces: [
1197
- { id: "floor", point: [0, 0, 0], normal: [0, 1, 0], halfSize: [size, size], porosity: 0.05 },
1198
- { id: "table", point: [tableX, 0.78, 0], normal: [0, 1, 0], halfSize: [0.8, 0.4], porosity: 0.3 }
1199
- ],
1200
- colliders: [
1201
- [tableX - 0.8, -0.4, tableX + 0.8, 0.4],
1202
- [tableX + 1.1, -0.1, tableX + 1.2, 0.1],
1203
- [-3.5 - 0.62, -0.2, -3.5 + 0.62, 0.2],
1204
- [-size, wallZ - 1, size, wallZ + 0.2]
1205
- ],
1206
- playerStart,
1207
- wallZ,
1208
- table: { minX: tableX - 0.8, maxX: tableX + 0.8, minZ: -0.4, maxZ: 0.4, top: 0.78, id: "table", topPart: "top" },
1209
- tapSpout,
1210
- sun
1211
- };
1212
- }
1213
-
1214
- // src/bench.ts
1215
- var snap = (control, value) => {
1216
- const clamped = Math.min(control.max, Math.max(control.min, value));
1217
- if (!control.step) return clamped;
1218
- const stepped = control.min + Math.round((clamped - control.min) / control.step) * control.step;
1219
- return Number(Math.min(control.max, stepped).toFixed(10));
1220
- };
1221
- function fitValue(control, value) {
1222
- if (control.kind === "number") return typeof value === "number" && Number.isFinite(value) ? snap(control, value) : void 0;
1223
- if (control.kind === "boolean") return typeof value === "boolean" ? value : void 0;
1224
- if (control.kind === "choice") return typeof value === "string" && control.options.some((option) => option.id === value) ? value : void 0;
1225
- return void 0;
1226
- }
1227
- var fromText = (control, text) => {
1228
- if (control.kind === "number") return text.trim() === "" ? Number.NaN : Number(text);
1229
- if (control.kind === "boolean") return text === "1" ? true : text === "0" ? false : void 0;
1230
- return text;
1231
- };
1232
- var toText = (value) => typeof value === "boolean" ? value ? "1" : "0" : String(value);
1233
- function encodeBenchState(controls, layers) {
1234
- const pairs = [];
1235
- for (const control of controls) if (control.value !== void 0) pairs.push(`${control.id}=${encodeURIComponent(toText(control.value))}`);
1236
- for (const layer of layers) pairs.push(`${layer.id}=${layer.on ? "1" : "0"}`);
1237
- return pairs.join(";");
1238
- }
1239
- function decodeBenchState(encoded, controls, layers) {
1240
- const out = /* @__PURE__ */ new Map();
1241
- for (const pair of encoded.split(";")) {
1242
- const at = pair.indexOf("=");
1243
- if (at <= 0) continue;
1244
- const id = pair.slice(0, at);
1245
- let text;
1246
- try {
1247
- text = decodeURIComponent(pair.slice(at + 1));
1248
- } catch {
1249
- continue;
1250
- }
1251
- const control = controls.find((c) => c.id === id);
1252
- if (control) {
1253
- const value = fitValue(control, fromText(control, text));
1254
- if (value !== void 0) out.set(id, value);
1255
- continue;
1256
- }
1257
- if (layers.some((layer) => layer.id === id) && (text === "1" || text === "0")) out.set(id, text === "1");
1258
- }
1259
- return out;
286
+ return out;
1260
287
  }
1261
288
  function createBenchStore(module, locale, onListenerError) {
1262
289
  const declared = { controls: module.controls?.(locale) ?? [], layers: module.layers?.(locale) ?? [], metrics: module.metrics?.(locale) ?? [] };
@@ -1365,6 +392,81 @@ function createBenchStore(module, locale, onListenerError) {
1365
392
  return store;
1366
393
  }
1367
394
 
395
+ // src/build-from-spec.ts
396
+ var MATERIAL_COLORS = {
397
+ steel: 9278620,
398
+ cast_iron: 4869458,
399
+ aluminium: 12896718,
400
+ copper: 12088122,
401
+ wood_soft: 12158287,
402
+ wood_hard: 8014374,
403
+ plywood: 13214826,
404
+ mdf: 11045732,
405
+ glass: 10278630,
406
+ tempered_glass: 10278630,
407
+ ceramic: 15328474,
408
+ concrete: 10132118,
409
+ brick: 10831662,
410
+ plaster: 15131092,
411
+ stone: 7828591,
412
+ plastic: 3897540,
413
+ rubber: 2039586,
414
+ fabric: 9063262,
415
+ foam: 14926936,
416
+ leather: 5978662,
417
+ paper: 15657695,
418
+ foliage: 4165434,
419
+ green_wood: 7031339,
420
+ cactus: 4095306,
421
+ flesh_fruit: 13781810,
422
+ wax: 15721141,
423
+ soil: 4862754
424
+ };
425
+ var TRANSPARENT = { glass: 0.38, tempered_glass: 0.38 };
426
+ function buildFromSpec(THREE, spec, options = {}) {
427
+ const root = new THREE.Group();
428
+ root.name = spec.name ?? "object";
429
+ const seg = options.segments ?? 12;
430
+ const shadows = options.shadows !== false;
431
+ const mats = /* @__PURE__ */ new Map();
432
+ const materialFor = (p) => {
433
+ const color = options.colors?.[p.id] ?? options.colors?.[p.material] ?? MATERIAL_COLORS[p.material] ?? 8947848;
434
+ const key = `${p.material}:${color}`;
435
+ let m = mats.get(key);
436
+ if (!m) {
437
+ const alpha = TRANSPARENT[p.material];
438
+ const metal = p.material === "steel" || p.material === "aluminium" || p.material === "copper" || p.material === "cast_iron";
439
+ m = new THREE.MeshStandardMaterial({
440
+ color,
441
+ roughness: alpha ? 0.1 : metal ? 0.45 : 0.85,
442
+ metalness: metal ? 0.7 : 0,
443
+ transparent: !!alpha,
444
+ opacity: alpha ?? 1,
445
+ depthWrite: !alpha
446
+ });
447
+ mats.set(key, m);
448
+ }
449
+ return m;
450
+ };
451
+ for (const p of spec.parts) {
452
+ let geo;
453
+ const s = p.shape;
454
+ if (s.kind === "box") geo = new THREE.BoxGeometry(s.size[0], s.size[1], s.size[2]);
455
+ else if (s.kind === "cylinder") geo = new THREE.CylinderGeometry(s.radius, s.radius, s.height, seg);
456
+ else geo = new THREE.SphereGeometry(s.radius, seg, Math.max(6, seg - 4));
457
+ const mesh = new THREE.Mesh(geo, materialFor(p));
458
+ mesh.name = p.id;
459
+ mesh.userData.destructionPart = p.id;
460
+ mesh.userData.destructionMaterial = p.material;
461
+ mesh.position.set(p.position[0], p.position[1], p.position[2]);
462
+ if (p.rotation) mesh.rotation.set(p.rotation[0], p.rotation[1], p.rotation[2], "XYZ");
463
+ mesh.castShadow = shadows && !TRANSPARENT[p.material];
464
+ mesh.receiveShadow = shadows;
465
+ root.add(mesh);
466
+ }
467
+ return root;
468
+ }
469
+
1368
470
  // src/explosives.ts
1369
471
  import { backblastEffect, blastEffect, createExplosiveBody, tripwireSegment } from "@adatechnology/game-weapons";
1370
472
 
@@ -1421,6 +523,17 @@ function createExplosives(context) {
1421
523
  meshes.push(mesh);
1422
524
  return mesh;
1423
525
  };
526
+ const addModel = (query, at) => {
527
+ const custom = context.projectileModel?.(query);
528
+ if (!custom) return void 0;
529
+ custom.model.position.set(at[0], at[1], at[2]);
530
+ custom.model.traverse((child) => {
531
+ child.castShadow = true;
532
+ });
533
+ scene.add(custom.model);
534
+ meshes.push(custom.model);
535
+ return { mesh: custom.model, ...custom.axis ? { axis: new THREE.Vector3(custom.axis[0], custom.axis[1], custom.axis[2]).normalize() } : {} };
536
+ };
1424
537
  const remove = (projectile) => {
1425
538
  for (const extra of projectile.attached ?? []) {
1426
539
  extra.removeFromParent();
@@ -1436,10 +549,11 @@ function createExplosives(context) {
1436
549
  const throwUse = (use) => {
1437
550
  const isMolotov = !!use.fire;
1438
551
  const isRocket = use.trigger === "impact" && (use.armDistance ?? 0) > 0;
1439
- const geometry = isRocket ? new THREE.CylinderGeometry(0.04, 0.04, 0.5, 8) : isMolotov ? new THREE.CylinderGeometry(0.035, 0.04, 0.2, 8) : new THREE.SphereGeometry(0.045, 8, 6);
1440
- const mesh = addMesh(geometry, isMolotov ? 3897915 : isRocket ? 7041116 : 4936480, isMolotov ? 0 : 0.4, use.origin);
552
+ const modeled = addModel({ weapon: context.toolId(), ...use.explosive !== void 0 ? { explosive: use.explosive } : {}, kind: isRocket ? "rocket" : isMolotov ? "molotov" : "grenade" }, use.origin);
553
+ const mesh = modeled?.mesh ?? addMesh(isRocket ? new THREE.CylinderGeometry(0.04, 0.04, 0.5, 8) : isMolotov ? new THREE.CylinderGeometry(0.035, 0.04, 0.2, 8) : new THREE.SphereGeometry(0.045, 8, 6), isMolotov ? 3897915 : isRocket ? 7041116 : 4936480, isMolotov ? 0 : 0.4, use.origin);
1441
554
  projectiles.push({
1442
555
  mesh,
556
+ ...modeled?.axis ? { axis: modeled.axis } : {},
1443
557
  pos: new THREE.Vector3(use.origin[0], use.origin[1], use.origin[2]),
1444
558
  vel: new THREE.Vector3(use.velocity[0], use.velocity[1], use.velocity[2]),
1445
559
  weapon: context.toolId(),
@@ -1490,7 +604,8 @@ function createExplosives(context) {
1490
604
  const placed = { ...use, origin: at };
1491
605
  const body = createExplosiveBody(placed, arsenal.explosives, arsenal.rules.explosives);
1492
606
  if (!body) return;
1493
- const mesh = addMesh(new THREE.BoxGeometry(0.18, 0.06, 0.1), use.explosive === "claymore" ? 5597999 : 12757112, 0.1, at);
607
+ const modeled = addModel({ weapon: context.toolId(), explosive: use.explosive, kind: "charge" }, at);
608
+ const mesh = modeled?.mesh ?? addMesh(new THREE.BoxGeometry(0.18, 0.06, 0.1), use.explosive === "claymore" ? 5597999 : 12757112, 0.1, at);
1494
609
  mesh.rotation.y = Math.atan2(use.direction[0], use.direction[2]);
1495
610
  const attached = [markerAt(at)];
1496
611
  const segment = tripwireSegment(placed);
@@ -1614,7 +729,7 @@ function createExplosives(context) {
1614
729
  projectile.pos.set(body.position[0], body.position[1], body.position[2]);
1615
730
  projectile.mesh.position.copy(projectile.pos);
1616
731
  if (projectile.aligned && body.velocity[0] ** 2 + body.velocity[1] ** 2 + body.velocity[2] ** 2 > 1e-6) {
1617
- projectile.mesh.quaternion.setFromUnitVectors(up, new THREE.Vector3(body.velocity[0], body.velocity[1], body.velocity[2]).normalize());
732
+ projectile.mesh.quaternion.setFromUnitVectors(projectile.axis ?? up, new THREE.Vector3(body.velocity[0], body.velocity[1], body.velocity[2]).normalize());
1618
733
  } else if (!projectile.placed) projectile.mesh.rotation.x += dt * 8;
1619
734
  for (const event of events) {
1620
735
  if (event.type === "dud") action("dud", translate("act.dud", { weapon: context.toolName(projectile.weapon), distance: format(body.travelled, 1) }), { data: { weapon: projectile.weapon, point: event.point } });
@@ -1736,16 +851,171 @@ var toHostEvent = (weapon, event, options = {}) => {
1736
851
  }
1737
852
  };
1738
853
 
1739
- // src/log.ts
1740
- var MAX_ENTRIES = 5e3;
1741
- var pad = (n, w = 2) => String(n).padStart(w, "0");
1742
- var formatTime = (t) => {
1743
- const m = Math.floor(t / 60);
1744
- const s = t - m * 60;
1745
- return `${pad(m)}:${s.toFixed(2).padStart(5, "0")}`;
854
+ // src/loading-view.ts
855
+ var LOADING_CLASS = "tr-loading";
856
+ var LOADING_CSS = [
857
+ '.tr-loading{position:absolute;inset:0;z-index:2147483000;display:flex;align-items:center;justify-content:center;background:rgba(16,19,26,.72);color:#e8eaee;font:15px/1.4 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;transition:opacity .25s;pointer-events:auto}',
858
+ ".tr-loading.tr-loading-fixed{position:fixed}",
859
+ ".tr-loading.tr-loading-solid{background:#10131a}",
860
+ ".tr-loading.tr-loading-hidden{opacity:0;pointer-events:none}",
861
+ ".tr-loading-card{width:min(440px,86vw);text-align:center}",
862
+ ".tr-loading-title{font-size:18px;font-weight:600;margin-bottom:18px}",
863
+ ".tr-loading-spin{display:inline-block;width:16px;height:16px;margin-right:10px;vertical-align:-2px;border:2px solid #3a3f4a;border-top-color:#ffd23f;border-radius:50%;animation:tr-loading-turn .8s linear infinite}",
864
+ ".tr-loading-bar{position:relative;height:8px;border-radius:4px;background:#2a2e36;overflow:hidden}",
865
+ ".tr-loading-bar i{display:block;height:100%;width:0;background:#ffd23f;transition:width .15s}",
866
+ ".tr-loading-bar.tr-loading-indeterminate i{position:absolute;width:35%;animation:tr-loading-sweep 1.1s ease-in-out infinite}",
867
+ ".tr-loading-step{margin-top:12px;color:#9aa3b2;min-height:1.4em}",
868
+ "@keyframes tr-loading-turn{to{transform:rotate(360deg)}}",
869
+ "@keyframes tr-loading-sweep{0%{left:-35%}100%{left:100%}}",
870
+ "@media (prefers-reduced-motion:reduce){.tr-loading-spin,.tr-loading-bar.tr-loading-indeterminate i{animation-duration:3s}}"
871
+ ].join("");
872
+ var escape = (text) => text.replace(/[&<>"]/g, (c) => c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : "&quot;");
873
+ function loadingMarkup(options) {
874
+ const classes = [LOADING_CLASS, options.solid ? "tr-loading-solid" : "", options.fixed ? "tr-loading-fixed" : ""].filter(Boolean).join(" ");
875
+ const id = options.id ? ` id="${escape(options.id)}"` : "";
876
+ return `<div${id} class="${classes}" role="status" aria-live="polite"><div class="tr-loading-card"><div class="tr-loading-title"><span class="tr-loading-spin"></span><span class="tr-loading-label">${escape(options.title)}</span></div><div class="tr-loading-bar tr-loading-indeterminate"><i></i></div><div class="tr-loading-step">${escape(options.step ?? "")}</div></div></div>`;
877
+ }
878
+
879
+ // src/loading.ts
880
+ var PAINT_FALLBACK_MS = 100;
881
+ var styled = false;
882
+ var ensureStyle = (document2) => {
883
+ if (styled && document2.getElementById("tr-loading-style")) return;
884
+ const style = document2.createElement("style");
885
+ style.id = "tr-loading-style";
886
+ style.textContent = LOADING_CSS;
887
+ document2.head.appendChild(style);
888
+ styled = true;
1746
889
  };
1747
- function createLog(options = {}) {
1748
- const host = globalThis.claude;
890
+ var nextPaint = () => new Promise((resolve) => {
891
+ let done = false;
892
+ const finish = () => {
893
+ if (!done) {
894
+ done = true;
895
+ resolve();
896
+ }
897
+ };
898
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => setTimeout(finish, 0));
899
+ setTimeout(finish, PAINT_FALLBACK_MS);
900
+ });
901
+ function createLoading(options) {
902
+ const draw = options.draw !== false && typeof document !== "undefined";
903
+ const tasks = [];
904
+ const listeners = /* @__PURE__ */ new Set();
905
+ let element;
906
+ let labelNode = null;
907
+ let stepNode = null;
908
+ let barNode = null;
909
+ let fillNode = null;
910
+ let hideTimer;
911
+ const idlers = [];
912
+ const current = () => tasks[tasks.length - 1];
913
+ const state = () => {
914
+ const task = current();
915
+ return task ? { label: task.label, text: task.text, done: task.done, total: task.total, opaque: task.opaque, depth: tasks.length } : void 0;
916
+ };
917
+ const build = () => {
918
+ ensureStyle(options.container.ownerDocument);
919
+ const root = document.createElement("div");
920
+ root.className = `${LOADING_CLASS} tr-loading-hidden`;
921
+ root.setAttribute("role", "status");
922
+ root.setAttribute("aria-live", "polite");
923
+ root.innerHTML = '<div class="tr-loading-card"><div class="tr-loading-title"><span class="tr-loading-spin"></span><span class="tr-loading-label"></span></div><div class="tr-loading-bar tr-loading-indeterminate"><i></i></div><div class="tr-loading-step"></div></div>';
924
+ labelNode = root.querySelector(".tr-loading-label");
925
+ stepNode = root.querySelector(".tr-loading-step");
926
+ barNode = root.querySelector(".tr-loading-bar");
927
+ fillNode = root.querySelector(".tr-loading-bar i");
928
+ options.container.appendChild(root);
929
+ return root;
930
+ };
931
+ const render = () => {
932
+ const now = state();
933
+ for (const listener of listeners) listener(now);
934
+ if (!now) for (const wake of idlers.splice(0)) wake();
935
+ if (!draw) return;
936
+ if (!now) {
937
+ if (element) {
938
+ element.classList.add("tr-loading-hidden");
939
+ hideTimer = setTimeout(() => element?.style.setProperty("display", "none"), 300);
940
+ }
941
+ return;
942
+ }
943
+ if (hideTimer !== void 0) clearTimeout(hideTimer);
944
+ element ?? (element = build());
945
+ element.style.removeProperty("display");
946
+ element.classList.remove("tr-loading-hidden");
947
+ element.classList.toggle("tr-loading-solid", now.opaque);
948
+ if (labelNode) labelNode.textContent = now.label;
949
+ if (stepNode) stepNode.textContent = now.text;
950
+ const determinate = now.total !== void 0 && now.total > 0;
951
+ barNode?.classList.toggle("tr-loading-indeterminate", !determinate);
952
+ if (fillNode) fillNode.style.width = determinate ? `${Math.max(0, Math.min(1, now.done / now.total)) * 100}%` : "";
953
+ };
954
+ const begin = (label, taskOptions = {}) => {
955
+ const task = { label, text: options.working, done: 0, total: taskOptions.total, opaque: taskOptions.opaque === true, ended: false };
956
+ tasks.push(task);
957
+ render();
958
+ return {
959
+ step(text, done) {
960
+ if (task.ended) return;
961
+ if (text !== void 0) task.text = text;
962
+ if (done !== void 0) task.done = done;
963
+ render();
964
+ },
965
+ paint: nextPaint,
966
+ end() {
967
+ if (task.ended) return;
968
+ task.ended = true;
969
+ const at = tasks.indexOf(task);
970
+ if (at >= 0) tasks.splice(at, 1);
971
+ render();
972
+ }
973
+ };
974
+ };
975
+ return {
976
+ get active() {
977
+ return tasks.length > 0;
978
+ },
979
+ get element() {
980
+ return element;
981
+ },
982
+ state,
983
+ idle: () => tasks.length === 0 ? Promise.resolve() : new Promise((resolve) => idlers.push(resolve)),
984
+ on(listener) {
985
+ listeners.add(listener);
986
+ return () => listeners.delete(listener);
987
+ },
988
+ begin,
989
+ async busy(label, work, taskOptions) {
990
+ const task = begin(label, taskOptions);
991
+ try {
992
+ await task.paint();
993
+ return await work(task);
994
+ } finally {
995
+ task.end();
996
+ }
997
+ },
998
+ dispose() {
999
+ if (hideTimer !== void 0) clearTimeout(hideTimer);
1000
+ tasks.length = 0;
1001
+ for (const wake of idlers.splice(0)) wake();
1002
+ listeners.clear();
1003
+ element?.remove();
1004
+ element = void 0;
1005
+ }
1006
+ };
1007
+ }
1008
+
1009
+ // src/log.ts
1010
+ var MAX_ENTRIES = 5e3;
1011
+ var pad = (n, w = 2) => String(n).padStart(w, "0");
1012
+ var formatTime = (t) => {
1013
+ const m = Math.floor(t / 60);
1014
+ const s = t - m * 60;
1015
+ return `${pad(m)}:${s.toFixed(2).padStart(5, "0")}`;
1016
+ };
1017
+ function createLog(options = {}) {
1018
+ const host = globalThis.claude;
1749
1019
  let downloadsP = null;
1750
1020
  if (host && typeof host.use === "function") {
1751
1021
  try {
@@ -2436,6 +1706,9 @@ function createNarrator(c) {
2436
1706
  var EYE = 1.7;
2437
1707
  var PLAYER_RADIUS = 0.3;
2438
1708
  var WALK = 3.4;
1709
+ var CROUCH_DROP = 0.55;
1710
+ var CROUCH_WALK = 1.7;
1711
+ var CROUCH_RATE = 9;
2439
1712
  var RUN = 6.2;
2440
1713
  var JUMP = 4.6;
2441
1714
  var GRAVITY = 13;
@@ -2446,15 +1719,20 @@ function createPlayer(THREE, o) {
2446
1719
  let pitch = 0;
2447
1720
  let y = 0;
2448
1721
  let eye = EYE;
1722
+ let crouch = 0;
2449
1723
  let vy = 0;
1724
+ let walked = 0;
1725
+ let running = false;
2450
1726
  const px = { x: o.start[0], z: o.start[2] };
2451
1727
  let locked = false;
2452
1728
  const keys = /* @__PURE__ */ new Set();
1729
+ const crouchKeys = o.crouchKeys ?? ["KeyC"];
1730
+ const isCrouchHeld = () => crouchKeys.some((code) => keys.has(code));
2453
1731
  const euler = new THREE.Euler(0, 0, 0, "YXZ");
2454
1732
  const apply = () => {
2455
1733
  euler.set(pitch, yaw, 0, "YXZ");
2456
1734
  cam.quaternion.setFromEuler(euler);
2457
- cam.position.set(px.x, eye + y, px.z);
1735
+ cam.position.set(px.x, eye + y - CROUCH_DROP * crouch, px.z);
2458
1736
  cam.updateMatrixWorld(true);
2459
1737
  };
2460
1738
  apply();
@@ -2481,6 +1759,20 @@ function createPlayer(THREE, o) {
2481
1759
  };
2482
1760
  const onLockChange = () => {
2483
1761
  locked = document.pointerLockElement === o.dom;
1762
+ if (locked) {
1763
+ try {
1764
+ const nav = typeof navigator !== "undefined" ? navigator : null;
1765
+ nav?.keyboard?.lock?.().catch(() => {
1766
+ });
1767
+ } catch {
1768
+ }
1769
+ } else {
1770
+ try {
1771
+ const nav = typeof navigator !== "undefined" ? navigator : null;
1772
+ nav?.keyboard?.unlock?.();
1773
+ } catch {
1774
+ }
1775
+ }
2484
1776
  };
2485
1777
  const onMouseDown = (e) => {
2486
1778
  o.dom.focus?.();
@@ -2550,6 +1842,10 @@ function createPlayer(THREE, o) {
2550
1842
  const onKeyDown = (e) => {
2551
1843
  if (typing(e)) return;
2552
1844
  if (e.code === "Space" || e.code === "Tab") e.preventDefault();
1845
+ const isControlHeld = e.ctrlKey || e.metaKey || keys.has("ControlLeft") || keys.has("ControlRight");
1846
+ if (isControlHeld || e.altKey && locked) {
1847
+ if (!/^F\d+$/.test(e.code)) e.preventDefault();
1848
+ }
2553
1849
  if (!e.repeat) keys.add(e.code);
2554
1850
  o.hooks.key(e.code, true, e);
2555
1851
  };
@@ -2560,6 +1856,11 @@ function createPlayer(THREE, o) {
2560
1856
  };
2561
1857
  const onContext = (e) => e.preventDefault();
2562
1858
  const onBlur = () => keys.clear();
1859
+ const onBeforeUnload = (e) => {
1860
+ if (!locked && !keys.has("ControlLeft") && !keys.has("ControlRight")) return;
1861
+ e.preventDefault();
1862
+ e.returnValue = "";
1863
+ };
2563
1864
  o.dom.addEventListener("mousedown", onMouseDown);
2564
1865
  window.addEventListener("mouseup", onMouseUp);
2565
1866
  window.addEventListener("mousemove", onMouseMove);
@@ -2569,6 +1870,7 @@ function createPlayer(THREE, o) {
2569
1870
  window.addEventListener("keydown", onKeyDown);
2570
1871
  window.addEventListener("keyup", onKeyUp);
2571
1872
  window.addEventListener("blur", onBlur);
1873
+ window.addEventListener("beforeunload", onBeforeUnload);
2572
1874
  return {
2573
1875
  get yaw() {
2574
1876
  return yaw;
@@ -2579,10 +1881,36 @@ function createPlayer(THREE, o) {
2579
1881
  get locked() {
2580
1882
  return locked;
2581
1883
  },
1884
+ get x() {
1885
+ return px.x;
1886
+ },
1887
+ get z() {
1888
+ return px.z;
1889
+ },
1890
+ get feet() {
1891
+ return y;
1892
+ },
1893
+ get position() {
1894
+ return [px.x, y, px.z];
1895
+ },
1896
+ get speed() {
1897
+ return walked;
1898
+ },
1899
+ get running() {
1900
+ return running;
1901
+ },
1902
+ get crouching() {
1903
+ return isCrouchHeld();
1904
+ },
2582
1905
  keys,
2583
1906
  update(dt) {
2584
- const run = keys.has("ShiftLeft") || keys.has("ShiftRight");
2585
- const speed = run ? RUN : WALK;
1907
+ const crouched = isCrouchHeld();
1908
+ crouch += ((crouched ? 1 : 0) - crouch) * Math.min(1, dt * CROUCH_RATE);
1909
+ if (!crouched && crouch < 1e-3) crouch = 0;
1910
+ const run = !crouched && (keys.has("ShiftLeft") || keys.has("ShiftRight"));
1911
+ const fromX = px.x;
1912
+ const fromZ = px.z;
1913
+ const speed = crouched ? CROUCH_WALK : run ? RUN : WALK;
2586
1914
  let f = 0;
2587
1915
  let r = 0;
2588
1916
  if (keys.has("KeyW") || keys.has("ArrowUp")) f += 1;
@@ -2598,6 +1926,8 @@ function createPlayer(THREE, o) {
2598
1926
  const rz = -Math.sin(yaw);
2599
1927
  move((fx * f + rx * r) / len * speed * dt, (fz * f + rz * r) / len * speed * dt);
2600
1928
  }
1929
+ walked = dt > 0 ? Math.hypot(px.x - fromX, px.z - fromZ) / dt : 0;
1930
+ running = run && walked > 0;
2601
1931
  if (y <= 0 && keys.has("Space")) vy = JUMP;
2602
1932
  if (y > 0 || vy > 0) {
2603
1933
  vy -= GRAVITY * dt;
@@ -2656,358 +1986,611 @@ function createPlayer(THREE, o) {
2656
1986
  window.removeEventListener("keydown", onKeyDown);
2657
1987
  window.removeEventListener("keyup", onKeyUp);
2658
1988
  window.removeEventListener("blur", onBlur);
1989
+ window.removeEventListener("beforeunload", onBeforeUnload);
2659
1990
  if (document.pointerLockElement === o.dom) document.exitPointerLock?.();
2660
1991
  }
2661
1992
  };
2662
1993
  }
2663
1994
 
2664
- // src/scenarios.ts
2665
- import { JOINT_DEFAULTS, rotateVec, toWorld } from "@adatechnology/game-destruction";
2666
- function makeScenarios(t, num, _locale = "en") {
2667
- const yn = (b) => t(b ? "yes" : "no");
2668
- const cn = (what, n) => t(`count.${what}.${n === 1 ? "one" : "other"}`, { n });
2669
- const pc = (n) => t(n === 1 ? "count.piece.one" : "count.piece.other", { n });
2670
- const sc = (id, run) => ({ id, name: t(`sc.${id}`), run });
2671
- const at = (room, itemId, x, z, y = 0, rot = 0) => room.spawn(itemId, [x, y, z], rot).objectId;
2672
- const look = (room, from, to) => room.look(from, to);
2673
- const part = (room, id, p) => room.world.part(id, p);
2674
- const count = (room, type, objectId) => room.log.entries({ type, objectId }).length;
2675
- const anyBurning = (room, id) => (room.world.state(id)?.parts ?? []).some((p) => p.burning);
2676
- const igniteHit = (room, id, point, temperature = 900, seconds = 3) => {
2677
- room.world.hit(id, { type: "fire", point, temperature, radius: 0.6, duration: seconds });
2678
- };
2679
- const COLUMN = { column_0_0_0: [-2, 1.4, -8], column_0_1_0: [2, 1.4, -8], column_0_0_1: [-2, 1.4, -4], column_0_1_1: [2, 1.4, -4] };
2680
- const killColumns = (room, id, ids) => {
2681
- for (const c of ids) room.world.hitPart(id, c, { type: "blunt", point: COLUMN[c], energy: 3e6, radius: 0.05, source: "test" });
2682
- };
2683
- const heavyCollapse = (room) => room.log.entries({ type: "collapse" }).reduce((m, e) => Math.max(m, Number(e.data?.massKg ?? 0)), 0);
2684
- return [
2685
- sc("steel_through_hole", ({ room, step, expect }) => {
2686
- const id = at(room, "steel_sheet", 0, -4);
2687
- room.setTool("pistol_9mm");
2688
- room.waitUntilReady();
2689
- look(room, [0, 1.1, -1.5], [0, 1.1, -4]);
2690
- room.use();
2691
- step(0.2);
2692
- const p = part(room, id, "sheet");
2693
- const hole = p?.holes[0];
2694
- expect(
2695
- !!hole?.through,
2696
- t("sc.steel_through_hole.check", { mm: num((hole?.diameter ?? 0) * 1e3, 0), through: yn(!!hole?.through), holes: p?.holes.length ?? 0, hp: `${num(p?.hp ?? 0, 0)}/${num(p?.maxHp ?? 0, 0)}` }),
2697
- { holes: p?.holes.length, through: hole?.through }
2698
- );
2699
- }),
2700
- sc("concrete_stops_bullet", ({ room, step, expect }) => {
2701
- const id = at(room, "concrete_block", 0, -4);
2702
- room.setTool("pistol_9mm");
2703
- room.waitUntilReady();
2704
- look(room, [0, 0.5, -1.5], [0, 0.2, -3.8]);
2705
- room.use();
2706
- step(0.2);
2707
- const p = part(room, id, "block");
2708
- const wall = part(room, "target_wall", "brick_1_4");
2709
- const through = p?.holes.some((h) => h.through) ?? true;
2710
- expect(
2711
- !through && (p?.holes.length ?? 0) + (p?.dents.length ?? 0) + (p?.cracks.length ?? 0) > 0 && (wall?.holes.length ?? 0) === 0,
2712
- t("sc.concrete_stops_bullet.check", { through: yn(through), dents: cn("dent", p?.dents.length ?? 0), holes: cn("hole", p?.holes.length ?? 0), cracks: cn("crack", p?.cracks.length ?? 0), hp: `${num(p?.hp ?? 0, 0)}/${num(p?.maxHp ?? 0, 0)}` }),
2713
- { through }
2714
- );
2715
- }),
2716
- sc("hammer_dents_drum", ({ room, step, expect }) => {
2717
- const id = at(room, "steel_drum", 0, -4);
2718
- room.setTool("hammer");
2719
- room.waitUntilReady();
2720
- look(room, [0, 0.6, -3.35], [0, 0.5, -4]);
2721
- let swings = 0;
2722
- for (; swings < 8 && (part(room, id, "drum_body")?.dents.length ?? 0) === 0; swings++) {
2723
- room.use();
2724
- step(1);
2725
- }
2726
- const p = part(room, id, "drum_body");
2727
- expect(
2728
- (p?.dents.length ?? 0) > 0 && p?.status !== "broken",
2729
- t("sc.hammer_dents_drum.check", { swings, dents: p?.dents.length ?? 0, depth: num((p?.dents[0]?.depth ?? 0) * 1e3, 1), hp: `${num(p?.hp ?? 0, 0)}/${num(p?.maxHp ?? 0, 0)}` }),
2730
- { swings }
2731
- );
2732
- }),
2733
- sc("glass_shatters", ({ room, step, expect }) => {
2734
- const id = at(room, "glass_pane", 0, -4);
2735
- room.setTool("pistol_9mm");
2736
- room.waitUntilReady();
2737
- look(room, [0, 1.3, -1.5], [0, 1.05, -4]);
2738
- let shots = 0;
2739
- for (; shots < 10 && part(room, id, "pane")?.status !== "broken"; shots++) {
2740
- room.use();
2741
- step(0.25);
2742
- }
2743
- step(0.5);
2744
- const fr = room.log.entries({ type: "fracture", objectId: id }).find((e) => e.part === "pane");
2745
- const n = Number(fr?.data?.fragments ?? 0);
2746
- expect(part(room, id, "pane")?.status === "broken" && shots === 1 && n >= 4, t("sc.glass_shatters.check", { shots, n }), { shots, fragments: n });
2747
- }),
2748
- sc("shotgun_splinters_crate", ({ room, step, expect }) => {
2749
- const id = at(room, "wooden_crate", 0, -4);
2750
- room.setTool("shotgun_pump_12");
2751
- room.waitUntilReady();
2752
- look(room, [0, 0.6, -1.8], [0, 0.25, -3.77]);
2753
- let shots = 0;
2754
- const splinters = () => room.log.entries({ type: "fracture", objectId: id }).filter((e) => e.data?.style === "splinter").length;
2755
- for (; shots < 4 && splinters() === 0; shots++) {
2756
- room.use();
2757
- step(1.1);
2758
- }
2759
- const holes = (room.world.state(id)?.parts ?? []).reduce((n, p) => n + p.holes.length, 0);
2760
- expect(splinters() > 0, t("sc.shotgun_splinters_crate.check", { shots, splinters: splinters(), holes }), { shots });
2761
- }),
2762
- sc("kick_chair_leg", ({ room, step, expect }) => {
2763
- const id = at(room, "chair", 0, -4);
2764
- const before = room.renderer.pieces().length;
2765
- const leg = part(room, id, "leg_1");
2766
- void leg;
2767
- let kicks = 0;
2768
- for (; kicks < 6 && room.renderer.pieces().length <= before && part(room, id, "leg_1"); kicks++) {
2769
- room.world.hitPart(id, "leg_1", { type: "blunt", point: [-0.18, 0.2, -4.18], direction: [1, 0, 0], energy: 220, radius: 0.05, source: "kick" });
2770
- step(0.3);
2771
- }
2772
- step(0.5);
2773
- const after = room.renderer.pieces().length;
2774
- const groups = room.world.state(id)?.groups.length ?? 1;
2775
- expect(
2776
- after > before,
2777
- t("sc.kick_chair_leg.check", { kicks, groups, before, after, leg: part(room, id, "leg_1") ? t("leg.still") : t("leg.gone") }),
2778
- { kicks, before, after }
2779
- );
2780
- }),
2781
- sc("kick_detaches_leg_via_tool", ({ room, step, expect }) => {
2782
- const id = at(room, "chair", 0, -4);
2783
- const before = room.renderer.pieces().length;
2784
- room.setTool("kick");
2785
- room.waitUntilReady();
2786
- let kicks = 0;
2787
- for (; kicks < 8 && room.renderer.pieces().length <= before && part(room, id, "leg_1"); kicks++) {
2788
- const st = room.world.state(id);
2789
- const leg = toWorld(st.position, st.rotation, [-0.18, 0.2, -0.18]);
2790
- const side = rotateVec(st.rotation, [-0.8, 0, 0]);
2791
- look(room, [leg[0] + side[0], leg[1] + side[1], leg[2] + side[2]], leg);
2792
- room.use();
2793
- step(1.05);
2794
- }
2795
- step(0.5);
2796
- const after = room.renderer.pieces().length;
2797
- expect(after > before, t("sc.kick_detaches_leg_via_tool.check", { kicks, before, after, leg: part(room, id, "leg_1") ? t("leg.still") : t("leg.gone") }), { kicks, before, after });
2798
- }),
2799
- sc("screwdriver_shelf_board", ({ room, step, expect }) => {
2800
- const id = at(room, "shelf", 0, -4);
2801
- room.setTool("screwdriver");
2802
- room.waitUntilReady();
2803
- look(room, [0, 0.8, -3.5], [0, 0.75, -3.86]);
2804
- const t0 = room.log.entries().length ? room.log.entries().slice(-1)[0].t : 0;
2805
- room.use(5);
2806
- const done = room.log.entries({ type: "detach", objectId: id })[0];
2807
- const elapsed = done ? done.t - t0 : 5;
2808
- const want = JOINT_DEFAULTS.screw.seconds;
2809
- const groups = room.renderer.pieces().length;
2810
- expect(!!done && Math.abs(elapsed - want) < 0.6 && groups >= 1, t("sc.screwdriver_shelf_board.check", { secs: num(elapsed, 1), want: num(want, 1), pieces: pc(groups) }), { elapsed, want });
2811
- step(0.2);
2812
- }),
2813
- sc("wrong_tool_refused", ({ room, expect }) => {
2814
- const id = at(room, "steel_sheet", 0, -4);
2815
- room.setTool("screwdriver");
2816
- room.waitUntilReady();
2817
- look(room, [0.2, 1.05, -3.6], [0.34, 1, -4]);
2818
- room.use(0.5);
2819
- const refused = count(room, "tool_refused");
2820
- const opened = (room.world.state(id)?.joints ?? []).filter((j) => j.open).length;
2821
- expect(refused > 0 && opened === 0, t("sc.wrong_tool_refused.check", { refused, opened }), { refused, opened });
2822
- }),
2823
- sc("lighter_burns_crate", ({ room, step, expect }) => {
2824
- const id = at(room, "wooden_crate", 0, -4);
2825
- room.setTool("lighter");
2826
- room.waitUntilReady();
2827
- look(room, [0, 0.3, -3.65], [0, 0.25, -3.76]);
2828
- room.use(5);
2829
- const lit = anyBurning(room, id);
2830
- expect(lit, t("sc.lighter_burns_crate.lit", { lit: yn(lit) }));
2831
- step(500);
2832
- const parts = room.world.state(id)?.parts ?? [];
2833
- const ash = parts.filter((p) => p.status === "ashes").length;
2834
- const charred = Math.max(...parts.map((p) => p.char));
2835
- expect(ash === parts.length || ash > 0 && parts.every((p) => p.status === "ashes" || p.status === "broken" || p.char > 0.8), t("sc.lighter_burns_crate.ash", { ash, total: parts.length, char: num(charred * 100, 0) }), { ash, total: parts.length });
2836
- }),
2837
- sc("torch_melts_steel", ({ room, step, expect }) => {
2838
- const drum2 = at(room, "steel_drum", 0, -4);
2839
- const crate2 = at(room, "wooden_crate", 0.7, -4);
2840
- igniteHit(room, crate2, [0.7, 0.25, -3.8], 900, 2);
2841
- let peak = 0;
2842
- for (let i = 0; i < 40; i++) {
2843
- step(1);
2844
- peak = Math.max(peak, part(room, drum2, "drum_body")?.temperature ?? 0);
2845
- }
2846
- const melt0 = part(room, drum2, "drum_body")?.melt ?? 0;
2847
- expect(melt0 === 0 && peak < 1450, t("sc.torch_melts_steel.wood", { peak: num(peak, 0), melt: num(melt0 * 100, 0), crate: yn(anyBurning(room, crate2)) }), { peak });
2848
- room.setTool("cutting_torch");
2849
- room.waitUntilReady();
2850
- look(room, [-0.15, 0.55, -3.55], [-0.15, 0.5, -3.72]);
2851
- room.use(20);
2852
- const p = part(room, drum2, "drum_body");
2853
- expect((p?.melt ?? 0) > 0.02 || p?.status === "melted" || (p?.holes.length ?? 0) > 0, t("sc.torch_melts_steel.torch", { temp: num(p?.temperature ?? 0, 0), melt: num((p?.melt ?? 0) * 100, 0) }), { temperature: p?.temperature, melt: p?.melt });
2854
- }),
2855
- sc("bucket_melts_drips", ({ room, step, expect }) => {
2856
- const id = at(room, "plastic_bucket", 0, -4);
2857
- room.setTool("flamethrower");
2858
- room.waitUntilReady();
2859
- look(room, [0, 0.5, -2.4], [0, 0.15, -4]);
2860
- room.use(2.5);
2861
- step(10);
2862
- const p = part(room, id, "bucket_body");
2863
- const drips = count(room, "drip", id);
2864
- expect((p?.melt ?? 0) > 0.05 || p?.status === "melted", t("sc.bucket_melts_drips.check", { melt: num((p?.melt ?? 0) * 100, 0), drips, temp: num(p?.temperature ?? 0, 0) }), { melt: p?.melt, drips });
2865
- }),
2866
- sc("gas_bottle_explodes", ({ room, step, expect }) => {
2867
- const bottle2 = at(room, "gas_bottle", 0, -4);
2868
- const crate2 = at(room, "wooden_crate", 1.3, -4);
2869
- const c0 = room.spawned().find((s) => s.objectId === crate2)?.root.position.clone();
2870
- let seconds = 0;
2871
- for (; seconds < 60 && count(room, "explode", bottle2) === 0; seconds++) {
2872
- igniteHit(room, bottle2, [0, 0.4, -3.9], 1e3, 1);
2873
- step(1);
2874
- }
2875
- step(1.5);
2876
- const c1 = room.spawned().find((s) => s.objectId === crate2)?.root.position;
2877
- const moved = c0 && c1 ? c0.distanceTo(c1) : 0;
2878
- const state = room.world.state(crate2);
2879
- const lost = (state?.parts ?? []).filter((p) => p.status === "broken").length + (state && state.groups.length > 1 ? 1 : 0);
2880
- expect(count(room, "explode", bottle2) > 0, t("sc.gas_bottle_explodes.explode", { seconds }), { seconds });
2881
- expect(moved > 0.3 || lost > 0, t("sc.gas_bottle_explodes.push", { moved: num(moved, 1), lost: pc(lost) }), { moved, lost });
2882
- }),
2883
- sc("bottle_drop_shatters", ({ room, step, expect }) => {
2884
- const id = at(room, "glass_bottle", 0, -3, 1.5);
2885
- step(2);
2886
- const p = part(room, id, "bottle_body");
2887
- const stain = room.fluids.stains().find((s) => s.liquid === "water");
2888
- expect(p?.status === "broken", t("sc.bottle_drop_shatters.check", { status: p?.status ?? "?", cracks: p?.cracks.length ?? 0, stain: yn(!!stain) }), { status: p?.status });
2889
- }),
2890
- sc("throw_chair_at_wall", ({ room, step, expect }) => {
2891
- const id = at(room, "chair", 0, -5.2);
2892
- look(room, [0, 1.7, -4.2], [0, 0.35, -5.2]);
2893
- room.grab();
2894
- look(room, [0, 1.7, -4.2], [0, 2.3, -11]);
2895
- step(0.3);
2896
- room.throwHeld(16);
2897
- step(2.5);
2898
- const hits = room.log.entries({ type: "impact" });
2899
- const wallDamage = room.log.entries().filter((e) => e.objectId === "target_wall" && (e.type === "dent" || e.type === "damage" || e.type === "crack" || e.type === "fracture")).length;
2900
- expect(hits.length > 0 && wallDamage > 0, t("sc.throw_chair_at_wall.check", { impacts: hits.length, wall: wallDamage, energy: num(Number(hits[0]?.data?.energyJ ?? 0), 0) }), { id });
2901
- }),
2902
- sc("coffee_three_sips", ({ room, step, expect }) => {
2903
- const id = at(room, "coffee_cup", 0, -3);
2904
- look(room, [0, 1, -1.8], [0, 0.05, -3]);
2905
- const cid = `${id}_fluid`;
2906
- const before = room.fluids.container(cid)?.liters ?? 0;
2907
- room.grab();
2908
- for (let i = 0; i < 3; i++) {
2909
- room.useItem("sip");
2910
- step(1.4);
2911
- }
2912
- const after = room.fluids.container(cid)?.liters ?? 0;
2913
- const drinks = room.log.entries().filter((e) => e.type === "fluid.drink");
2914
- const caffeine = drinks.some((e) => Array.isArray(e.data?.effects) && (e.data?.effects).includes("caffeine"));
2915
- expect(drinks.length === 3 && before - after > 0.06 && caffeine, t("sc.coffee_three_sips.check", { sips: drinks.length, before: num(before, 2), after: num(after, 2), caffeine: yn(caffeine), burn: yn(drinks.some((e) => !!e.data?.burn)) }), { before, after });
2916
- }),
2917
- sc("tilt_cup_spills", ({ room, step, expect }) => {
2918
- const id = at(room, "coffee_cup", 0, -3);
2919
- look(room, [0, 1, -1.8], [0, 0.05, -3]);
2920
- const cid = `${id}_fluid`;
2921
- const before = room.fluids.container(cid)?.liters ?? 0;
2922
- room.grab();
2923
- step(0.6);
2924
- room.tilt(2);
2925
- step(2.5);
2926
- const after = room.fluids.container(cid)?.liters ?? 0;
2927
- const stains = room.fluids.stains().filter((s) => s.liquid === "coffee").length;
2928
- expect(before - after > 0.03 && stains > 0, t("sc.tilt_cup_spills.check", { before: num(before, 2), after: num(after, 2), stains }), { before, after });
2929
- }),
2930
- sc("jerrycan_leaks_fuel", ({ room, step, expect }) => {
2931
- const id = at(room, "jerrycan", 0, -3.5);
2932
- room.setTool("pistol_9mm");
2933
- room.waitUntilReady();
2934
- look(room, [0, 0.4, -1.5], [0, 0.12, -3.43]);
2935
- room.use();
2936
- step(1.5);
2937
- const streams = room.fluids.streams().filter((s) => s.cause === "leak" && s.liquid === "fuel");
2938
- step(5);
2939
- const stain = room.fluids.stains().find((s) => s.liquid === "fuel");
2940
- const left = room.fluids.container(`${id}_fluid`)?.liters ?? 20;
2941
- const hole = room.log.entries({ type: "hole", objectId: id })[0];
2942
- expect(streams.length > 0 && !!stain && left < 20, t("sc.jerrycan_leaks_fuel.check", { streams: streams.length, stain: yn(!!stain), left: num(left, 2), leak: hole?.data?.leak ? yn(true) : yn(false) }), { left });
2943
- }),
2944
- sc("fuel_stain_burns", ({ room, step, expect }) => {
2945
- const id = at(room, "jerrycan", 0, -3.5);
2946
- const crate2 = at(room, "wooden_crate", 0.9, -3.2);
2947
- room.setTool("pistol_9mm");
2948
- room.waitUntilReady();
2949
- look(room, [0, 0.4, -1.5], [0, 0.12, -3.43]);
2950
- room.use();
2951
- step(6);
2952
- const stain = room.fluids.stains().find((s) => s.liquid === "fuel");
2953
- expect(!!stain, t("sc.fuel_stain_burns.stain", { stain: yn(!!stain) }));
2954
- if (!stain) return;
2955
- room.setTool("lighter");
2956
- room.waitUntilReady();
2957
- look(room, [stain.center[0], 0.1, stain.center[2] + 0.06], [stain.center[0], 0, stain.center[2]]);
2958
- room.use(1);
2959
- step(4);
2960
- const ignited = count(room, "stain_ignite");
2961
- const fireHits = (room.world.state(id)?.parts ?? []).some((p) => p.burning || p.temperature > 100) || anyBurning(room, crate2);
2962
- expect(ignited > 0, t("sc.fuel_stain_burns.ignite", { ignited, near: yn(fireHits) }), { ignited });
2963
- step(30);
2964
- expect(count(room, "stain_burned") > 0 || room.fluids.stains().filter((s) => s.liquid === "fuel" && s.wetness > 0.05).length === 0, t("sc.fuel_stain_burns.out", { burned: count(room, "stain_burned") }));
2965
- }),
2966
- sc("one_column_roof_stands", ({ room, step, expect }) => {
2967
- const id = at(room, "building_frame", 0, -6);
2968
- killColumns(room, id, ["column_0_0_0"]);
2969
- step(40);
2970
- const roof = part(room, id, "roof");
2971
- const neighbour = part(room, id, "column_0_0_1")?.stress ?? 0;
2972
- const heavy = Math.max(heavyCollapse(room), ...room.renderer.pieces().map((p) => p.mass));
2973
- expect(!!roof && roof.status !== "broken" && heavy < 2e3, t("sc.one_column_roof_stands.check", { roof: roof ? t("roof.standing") : t("roof.fell"), heavy: num(heavy, 0), stress: num(neighbour * 100, 0) }), { heavy, neighbour });
2974
- }),
2975
- sc("two_columns_roof_collapses", ({ room, step, expect }) => {
2976
- const id = at(room, "building_frame", 0, -6);
2977
- killColumns(room, id, ["column_0_0_0", "column_0_1_0"]);
2978
- const stress0 = room.world.part(id, "column_0_0_1")?.stress ?? 0;
2979
- step(40);
2980
- const pieces = room.renderer.pieces().filter((p) => p.mass >= 2e3);
2981
- const logged = heavyCollapse(room) >= 2e3;
2982
- const kg = pieces.reduce((m, p) => m + p.mass, 0);
2983
- expect(!part(room, id, "roof") && pieces.length > 0, t("sc.two_columns_roof_collapses.check", { stress: num(stress0 * 100, 0), pieces: pc(pieces.length), kg: num(kg, 0), logged: yn(logged) }), { kg, logged });
2984
- }),
2985
- sc("extinguisher_puts_out_fire", ({ room, step, expect }) => {
2986
- const id = at(room, "wooden_crate", 0, -4);
2987
- igniteHit(room, id, [0, 0.25, -3.8], 900, 4);
2988
- step(6);
2989
- const before = anyBurning(room, id);
2990
- expect(before, t("sc.extinguisher_puts_out_fire.burning", { burning: yn(before) }));
2991
- room.setTool("extinguisher");
2992
- room.waitUntilReady();
2993
- look(room, [0, 0.9, -2.2], [0, 0.25, -4]);
2994
- room.use(2.5);
2995
- step(1);
2996
- const after = anyBurning(room, id);
2997
- expect(!after, t("sc.extinguisher_puts_out_fire.out", { burning: yn(after), events: count(room, "extinguish", id) }), { events: count(room, "extinguish", id) });
2998
- })
2999
- ];
1995
+ // src/avatar.ts
1996
+ var DEFAULT_STRIKE_CLIPS = { hand: "punch_right", fists: "punch_right", shove: "shove_two_hands", kick: "front_kick" };
1997
+ var DEFAULT_HAND_STANCES = { fists: "fists" };
1998
+ var THIRD_PERSON_DEFAULTS = {
1999
+ back: 3.8,
2000
+ side: 0.9,
2001
+ up: 1.95,
2002
+ crouchUp: 1.4,
2003
+ lift: 0.1,
2004
+ margin: 0.3,
2005
+ minDistance: 0.2,
2006
+ probes: [0, 0.2, -0.2],
2007
+ recover: 4
2008
+ };
2009
+ function thirdPersonPose(input, options, probe) {
2010
+ const cy = Math.cos(input.pitch);
2011
+ const d = [-Math.sin(input.yaw) * cy, Math.sin(input.pitch), -Math.cos(input.yaw) * cy];
2012
+ const rx = Math.cos(input.yaw);
2013
+ const rz = -Math.sin(input.yaw);
2014
+ const pivot = [input.x + rx * options.side, input.feet + (input.crouch ? options.crouchUp : options.up), input.z + rz * options.side];
2015
+ const dx = -d[0] * options.back;
2016
+ const dy = -d[1] * options.back + options.lift;
2017
+ const dz = -d[2] * options.back;
2018
+ const wanted = Math.hypot(dx, dy, dz);
2019
+ const dir = [dx / wanted, dy / wanted, dz / wanted];
2020
+ let free = wanted;
2021
+ for (const o of options.probes) {
2022
+ const hit = probe([pivot[0] + rx * o, pivot[1], pivot[2] + rz * o], dir, wanted);
2023
+ if (hit !== null) free = Math.min(free, Math.max(options.minDistance, hit - options.margin));
2024
+ }
2025
+ const at = (distance) => [pivot[0] + dir[0] * distance, Math.max(0.1, pivot[1] + dir[1] * distance), pivot[2] + dir[2] * distance];
2026
+ return { position: at(free), at, pivot, direction: d, wanted, free };
3000
2027
  }
3001
- var makeT = (locale) => (key, vars) => {
3002
- let s = resolveStrings(locale).text[key] ?? key;
3003
- if (vars) for (const k of Object.keys(vars)) s = s.split(`{${k}}`).join(String(vars[k]));
3004
- return s;
2028
+
2029
+ // src/locale.ts
2030
+ var en = {
2031
+ items: {},
2032
+ groups: { furniture: "Furniture", structure: "Structure", vehicles: "Vehicles", plants: "Plants", items: "Items", containers: "Containers" },
2033
+ text: {
2034
+ "locale.decimal": ".",
2035
+ yes: "yes",
2036
+ no: "no",
2037
+ piece: "piece",
2038
+ coords: "x {x} \xB7 z {z}",
2039
+ "count.piece.one": "1 piece",
2040
+ "count.piece.other": "{n} pieces",
2041
+ "count.joint.one": "1 joint",
2042
+ "count.joint.other": "{n} joints",
2043
+ "count.dent.one": "1 dent",
2044
+ "count.dent.other": "{n} dents",
2045
+ "count.hole.one": "1 hole",
2046
+ "count.hole.other": "{n} holes",
2047
+ "count.crack.one": "1 crack",
2048
+ "count.crack.other": "{n} cracks",
2049
+ "joint.generic": "joint",
2050
+ "lost.broken": "broke",
2051
+ "lost.melted": "melted away",
2052
+ "lost.ashes": "burned to ash",
2053
+ "tool.none": "by hand only",
2054
+ "arena.target_wall": "Target wall",
2055
+ "arena.table": "Table",
2056
+ "arena.shelf": "Shelf",
2057
+ "surface.floor": "on the floor",
2058
+ "surface.ground": "on the floor",
2059
+ "surface.table": "on the table",
2060
+ "by.break": "broken loose",
2061
+ "by.tool": "undone with a tool",
2062
+ "by.part_lost": "the part next to it was lost",
2063
+ "dmg.ballistic": "ballistic",
2064
+ "dmg.blunt": "blunt",
2065
+ "dmg.slash": "slash",
2066
+ "dmg.explosive": "blast",
2067
+ "dmg.fire": "fire",
2068
+ "dmg.heat": "heat",
2069
+ "effect.caffeine": "caffeine",
2070
+ "effect.alcohol": "alcohol",
2071
+ "effect.poison": "poison",
2072
+ "effect.disgust": "disgust",
2073
+ "ev.dent": "{where}: dent of {mm} mm, {hp}",
2074
+ "ev.hole.through": "{where}: {mm} mm hole went through, {hp}",
2075
+ "ev.hole.stuck": "{where}: {mm} mm hole, did not go through, {hp}",
2076
+ "ev.dents": "{where}: {n} dents, deepest {mm} mm, {hp}",
2077
+ "ev.holes": "{where}: {n} holes of {mm} mm, {thr} went through, {hp}",
2078
+ "ev.leak.tail": ", leaking {liquid} at {rate} l/s",
2079
+ "ev.leak.alone": "{where}: leaking {liquid} at {rate} l/s",
2080
+ "ev.crack": "{where}: cracked, {rays} rays up to {cm} cm, {hp}",
2081
+ "ev.ricochet": "{where}: bullet ricocheted, {energy} J left",
2082
+ "ev.fracture.shatter": "{where}: shattered into {n} shards",
2083
+ "ev.fracture.crumble": "{where}: crumbled into {n} chunks",
2084
+ "ev.fracture.splinter": "{where}: splintered into {n} pieces",
2085
+ "ev.fracture.tear": "{where}: torn into {n} scraps",
2086
+ "ev.fracture.snap": "{where}: snapped into {n} pieces",
2087
+ "ev.fracture.none": "{where}: broke into {n} pieces",
2088
+ "ev.detach": "{obj}: {a} came off {b} ({by}); the object now has {pieces}",
2089
+ "ev.detach.many.break": "{obj}: {joints} broke loose; the object now has {pieces}",
2090
+ "ev.detach.many.tool": "{obj}: {joints} undone with a tool; the object now has {pieces}",
2091
+ "ev.lost.tail": " and came loose from {joints}; the object now has {pieces}",
2092
+ "ev.lost.alone": "{where}: {verb} and came loose from {joints}; the object now has {pieces}",
2093
+ "ev.cracks": "{where}: cracking under the load, {n} cracks so far, longest {cm} cm, {hp}",
2094
+ "ev.disassemble": "{obj}: undoing the {kind} between {a} and {b} with {tool}: {pct}%",
2095
+ "ev.ignite": "{where}: caught fire at {temp} \xB0C",
2096
+ "ev.ignite.many": "{obj}: {n} parts caught fire at {temp} \xB0C ({parts})",
2097
+ "ev.extinguish": "{where}: fire put out",
2098
+ "ev.extinguish.many": "{obj}: fire put out on {n} parts ({parts})",
2099
+ "ev.burnout": "{where}: burned to ash",
2100
+ "ev.melt": "{where}: melting, {pct}% melted away",
2101
+ "ev.drip": "{where}: dripping molten material",
2102
+ "ev.explode": "{where}: exploded, {energy} J within {radius} m",
2103
+ "ev.destroyed": "{obj}: destroyed",
2104
+ "ev.damage": "{where}: {type} damage {amount}, {hp}",
2105
+ "ev.damage.overload": "{where}: crushed by the weight it carries, lost {amount} hp so far, {hp}",
2106
+ "ev.stress": "{where}: overloaded at {stress}% of its capacity, carrying {load} kg",
2107
+ "ev.collapse": "{obj}: collapsed, {pieces} fell ({mass} kg): {parts}",
2108
+ "ev.stain_ignite": "The {liquid} stain at x {x} \xB7 z {z} caught fire and will burn for about {seconds} s",
2109
+ "ev.stain_burned": "The burning stain burned itself out",
2110
+ "ev.fire_dries": "Fire dried the wet stains nearby",
2111
+ "fl.fill": "{who}: filled with {liquid} (+{liters} l)",
2112
+ "fl.pour": "{who}: pouring {liquid} at {rate} l/s",
2113
+ "fl.spill": "{who}: spilled {liquid} over the lip at {rate} l/s",
2114
+ "fl.leak": "{who}: leaking {liquid} at {rate} l/s",
2115
+ "fl.drink": "{who}: drank {liters} l, thirst +{thirst}",
2116
+ "fl.burn": "too hot: burned the mouth",
2117
+ "fl.empty": "{who}: empty",
2118
+ "fl.splash": "Splash of {liquid}",
2119
+ "fl.stain": "Stain of {liquid} {surface} at {where}",
2120
+ "fl.stains": "{n} stains of {liquid} {surface}, the latest at {where}",
2121
+ "fl.dried": "A stain dried up",
2122
+ "fl.dried.mark": "A stain dried and left a mark",
2123
+ "fl.dried.known": "The {liquid} stain {surface} at {where} dried up",
2124
+ "fl.dried.known.mark": "The {liquid} stain {surface} at {where} dried and left a mark",
2125
+ "fl.dried.many": "{n} {liquid} stains dried up",
2126
+ "fl.dried.many.mark": "{n} {liquid} stains dried and left marks",
2127
+ "fl.mix": "{who}: {from} mixed into {into}",
2128
+ "act.anim_start": '{name}: animation "{clip}" ({seconds} s)',
2129
+ "act.anim_event": '{name}: "{clip}" event {type}',
2130
+ "act.anim_end": '{name}: animation "{clip}" finished',
2131
+ "act.area_skip": 'Area item "{item}" is not loaded in this room: left out',
2132
+ "act.module_loaded": 'Module "{name}" loaded: {items} item(s), {scenarios} scenario(s), contract checks: {result}',
2133
+ "mod.all_ok": "all passed",
2134
+ "mod.some_failed": "some FAILED",
2135
+ "mod.destructible_ok": "{name}: destructible contract ok ({parts} parts, hit and state well-formed)",
2136
+ "mod.destructible_fail": "{name}: destructible contract broken: {problems}",
2137
+ "mod.animation_ok": '{name}: animation "{clip}" ok ({seconds} s)',
2138
+ "mod.spec_ok": "{name}: spec ok ({parts} parts)",
2139
+ "mod.spec_fail": "{name}: spec broken: {problems}",
2140
+ "mod.spec_warn": "{name}: spec warning: {warning}",
2141
+ "mod.animation_fail": '{name}: animation "{clip}" broken: {problems}',
2142
+ "ui.animations": "Animations",
2143
+ "ui.animations_aim": "Aim at an item or hold it to see its animations.",
2144
+ "ui.animations_none": "This item brings no animations.",
2145
+ "mod.bench_ok": "{name}: bench ok ({controls} controls, {layers} layers, {metrics} metrics)",
2146
+ "mod.bench_fail": "{name}: bench broken: {problems}",
2147
+ "mod.metric_failed": '{name}: metric "{metric}" threw and shows n/a: {error}',
2148
+ "mod.listener_failed": "{name}: a bench listener threw: {error}",
2149
+ "ui.bench": "Bench",
2150
+ "ui.layers": "Layers",
2151
+ "ui.metrics": "Counters",
2152
+ "metric.fps": "FPS",
2153
+ "metric.frame_ms": "ms/frame",
2154
+ "metric.draw_calls": "Draw calls",
2155
+ "metric.triangles": "Triangles",
2156
+ "metric.objects": "Objects",
2157
+ "metric.fragments": "Fragments",
2158
+ "metric.particles": "Particles",
2159
+ "metric.decals": "Decals",
2160
+ "act.spawn": "Spawned {name} at x {x} \xB7 z {z}",
2161
+ "act.clear": "Cleared the room",
2162
+ "act.reset": "Repaired every object",
2163
+ "act.tool": "Equipped {weapon}",
2164
+ "act.shoot.hit": "{weapon}: shot, {energy} J, hit {where} at {dist} m",
2165
+ "act.shoot.miss": "{weapon}: shot, {energy} J, hit nothing",
2166
+ "act.shoot.pellets": "{weapon}: shot, {n} projectiles of up to {energy} J, {hits} hit {where} at {dist} m",
2167
+ "act.melee.hit": "{weapon}: strike, {energy} J, hit {where} at {dist} m",
2168
+ "act.melee.miss": "{weapon}: struck the air",
2169
+ "act.strike": "{weapon}: {clip} begins, impact in {seconds} s",
2170
+ "act.strike.leg": "{weapon}: {clip} with the {leg} leg begins, impact in {seconds} s",
2171
+ "leg.left": "left",
2172
+ "leg.right": "right",
2173
+ "act.flame.hit": "{weapon}: flame at {temp} \xB0C on {where}",
2174
+ "act.flame.air": "{weapon}: flame, nothing in range",
2175
+ "act.heat.hit": "{weapon}: heating {where} at {temp} \xB0C",
2176
+ "act.heat.air": "{weapon}: nothing in range",
2177
+ "act.throw.grenade": "{weapon}: thrown, fuse {fuse} s",
2178
+ "act.throw.molotov": "{weapon}: thrown, breaks on impact",
2179
+ "act.launch": "{weapon}: launched, arms after {arm} m",
2180
+ "act.place": "{weapon}: charge set down, waits for {triggers}",
2181
+ "act.tripwire": "{weapon}: {who} crossed the trip wire",
2182
+ "act.dud": "{weapon}: hit before arming ({distance} m), did not explode",
2183
+ "act.backblast": "{weapon}: blast behind the tube, {radius} m, {n} objects in the cone, {player} on the shooter",
2184
+ "act.blast_player": "{weapon}: you are in the {zone} zone, {distance} m away (stun {stun} s)",
2185
+ "hud.no_charges": "No charge set down waits for the remote detonator",
2186
+ "blast.zone.lethal": "lethal",
2187
+ "blast.zone.injury": "injury",
2188
+ "act.explode": "{weapon}: exploded, {energy} J within {radius} m, {n} objects in range",
2189
+ "act.molotov_fire": "{weapon}: fire on the ground, radius {radius} m for {seconds} s",
2190
+ "act.tool.none": "{weapon}: nothing in reach",
2191
+ "act.tool.nojoint": "{weapon}: no joint near {where}",
2192
+ "act.tool.refused": "{weapon} cannot undo the {kind} of {obj}; it needs: {need}",
2193
+ "act.extinguish.hit": "{weapon}: foam on {what}",
2194
+ "act.extinguish.air": "{weapon}: foam, nothing in range",
2195
+ "act.grab": "Grabbed {name} ({kg} kg)",
2196
+ "act.drop": "Dropped {name}",
2197
+ "act.throw": "Threw {name} at {speed} m/s",
2198
+ "act.landing": "{name} landed at {speed} m/s: {energy} J",
2199
+ "act.bench_failed": "{name} failed: {error}",
2200
+ "loading.working": "Working\u2026",
2201
+ "loading.open_area": "Opening {area}\u2026",
2202
+ "loading.spawn": "Building {name}\u2026",
2203
+ "act.loosened": "{name} was heavy but free: the blast moved it",
2204
+ "act.impact": "{name} hit {where} at {speed} m/s: {energy} J of blunt damage",
2205
+ "act.use_item": "{name}: {use}",
2206
+ "act.tap.on": "Tap opened",
2207
+ "act.tap.off": "Tap closed",
2208
+ "use.drink": "drink",
2209
+ "use.sip": "sip",
2210
+ "use.chug": "chug",
2211
+ "use.pour": "pour",
2212
+ "use.pour_out": "pour out",
2213
+ "use.open": "open",
2214
+ "use.close": "close",
2215
+ "hud.reloading": "Reloading...",
2216
+ "hud.empty": "Empty",
2217
+ "hud.no_fuel": "Out of fuel",
2218
+ "hud.fault": "Jammed ({fault}): press X to clear",
2219
+ "hud.safe": "Safety on: press B",
2220
+ "hud.clearing": "Clearing the jam...",
2221
+ "hud.safety_on": "Safety on",
2222
+ "hud.safety_off": "Safety off",
2223
+ "fault.misfire": "misfire",
2224
+ "fault.stovepipe": "stovepipe",
2225
+ "fault.double_feed": "double feed",
2226
+ "fault.failure_to_extract": "failure to extract",
2227
+ "hud.nothing_to_grab": "Nothing to grab here",
2228
+ "hud.cannot_grab": "{name} cannot be picked up",
2229
+ "hud.too_heavy": "{name} is too heavy ({kg} kg)",
2230
+ "hud.empty_container": "{name} is empty",
2231
+ "hud.refused": "Wrong tool for the {kind}",
2232
+ "hud.drop": "drop",
2233
+ "hud.rotate": "rotate",
2234
+ "hud.throw": "throw",
2235
+ "hud.tilt": "tilt (hold)",
2236
+ "hud.equip": "equip {weapon}",
2237
+ "ui.help": "Click: look \xB7 WASD: walk \xB7 Shift: run \xB7 C: crouch \xB7 Space: jump \xB7 1-0 / wheel: tool \xB7 LMB: use \xB7 E: grab/drop \xB7 G or RMB: throw \xB7 R: reload \xB7 Q: fire mode \xB7 X: clear a jam \xB7 B: safety \xB7 Y: detonate charges set down \xB7 Tab: panels \xB7 Esc: free the mouse",
2238
+ "ui.help.camera": "V: 1st/3rd person (Shift+V: kick)",
2239
+ "camera.first": "1st person",
2240
+ "camera.third": "3rd person",
2241
+ "act.camera": "View: {mode}",
2242
+ "ui.log": "Behaviour log",
2243
+ "ui.filter.action": "actions",
2244
+ "ui.filter.event": "events",
2245
+ "ui.filter.check": "checks",
2246
+ "ui.autoscroll": "autoscroll",
2247
+ "log.export_cancelled": "export cancelled",
2248
+ "ui.copy_json": "Copy JSON",
2249
+ "ui.download_ndjson": "Download NDJSON",
2250
+ "ui.download_txt": "Download TXT",
2251
+ "ui.clear_log": "Clear",
2252
+ "ui.catalog": "Catalogue",
2253
+ "ui.clear": "Clear",
2254
+ "ui.reset_all": "Reset all",
2255
+ "ui.slow": "Slow motion 0.25\xD7",
2256
+ "ui.fast": "Time \xD710",
2257
+ "ui.tap": "Tap",
2258
+ "ui.fullscreen": "Full screen",
2259
+ "ui.fullscreen.hint": "Full screen opened from here keeps the whole keyboard in the game, Ctrl+W included (F11 does not)",
2260
+ "ui.blast.shelter": "Shelter",
2261
+ "ui.blast.fragmentHits": "Fragments",
2262
+ "ui.blast.confinement": "Confinement",
2263
+ "ui.blast.shelter.hint": "Blasts: walls and other parts between the blast and the target take their share",
2264
+ "ui.blast.fragmentHits.hint": "Blasts: the pieces of what breaks fly on and hit what is behind",
2265
+ "ui.blast.confinement.hint": "Blasts: pressure builds up in an enclosed room",
2266
+ "metric.blast_shelter": "Blast reaching (%)",
2267
+ "metric.blast_confinement": "Blast pressure (x)",
2268
+ "metric.blast_fragments": "Fragment hits",
2269
+ "act.blast_report": "Blast: {shelter}% reached the most sheltered target, pressure x{confinement}, {fragments} fragment hits, {n} targets",
2270
+ "ui.quality": "Quality",
2271
+ "ui.quality.low": "low",
2272
+ "ui.quality.medium": "medium",
2273
+ "ui.quality.high": "high",
2274
+ "ui.equip": "Equip as a tool",
2275
+ "ui.search_items": "Search items (all groups)",
2276
+ "ui.search_tests": "Search tests",
2277
+ "ui.no_results": "Nothing found",
2278
+ "ui.objects": "{n} objects",
2279
+ "ui.tests": "Tests",
2280
+ "ui.run_all": "Run all",
2281
+ "ui.running": "Running...",
2282
+ "ui.report": "{passed} passed, {failed} failed",
2283
+ "ui.panel": "Panels",
2284
+ "ui.inspector": "Inspector",
2285
+ "ui.aim_at_something": "Aim at an object",
2286
+ "ins.material": "Material",
2287
+ "ins.status": "Status",
2288
+ "ins.burning": "burning",
2289
+ "ins.hp": "Hit points",
2290
+ "ins.temperature": "Temperature",
2291
+ "ins.char": "Charred",
2292
+ "ins.melt": "Melted",
2293
+ "ins.liters": "Liquid",
2294
+ "ins.damage": "Marks",
2295
+ "ins.damage_counts": "{dents}, {holes}, {cracks}",
2296
+ "ins.mass": "Mass",
2297
+ "ins.load": "Load",
2298
+ "ins.stress": "Stress",
2299
+ "ins.joints": "Joints",
2300
+ "ins.open": "open",
2301
+ "sc.start": "Scenario: {name}",
2302
+ "sc.pass": "Scenario passed: {name}",
2303
+ "sc.fail": "Scenario FAILED: {name}",
2304
+ "sc.error": "the scenario threw an error: {msg}",
2305
+ "leg.still": "leg_1 still attached",
2306
+ "leg.gone": "leg_1 came off",
2307
+ "roof.standing": "roof standing",
2308
+ "roof.fell": "roof fell"
2309
+ }
2310
+ };
2311
+ var ptBR = {
2312
+ items: {},
2313
+ groups: { furniture: "M\xF3veis", structure: "Estrutura", vehicles: "Ve\xEDculos", plants: "Plantas", items: "Itens", containers: "Recipientes" },
2314
+ text: {
2315
+ "locale.decimal": ",",
2316
+ yes: "sim",
2317
+ no: "n\xE3o",
2318
+ piece: "pe\xE7a",
2319
+ coords: "x {x} \xB7 z {z}",
2320
+ "count.piece.one": "1 pe\xE7a",
2321
+ "count.piece.other": "{n} pe\xE7as",
2322
+ "count.joint.one": "1 encaixe",
2323
+ "count.joint.other": "{n} encaixes",
2324
+ "count.dent.one": "1 amassado",
2325
+ "count.dent.other": "{n} amassados",
2326
+ "count.hole.one": "1 furo",
2327
+ "count.hole.other": "{n} furos",
2328
+ "count.crack.one": "1 trinca",
2329
+ "count.crack.other": "{n} trincas",
2330
+ "joint.generic": "junta",
2331
+ "lost.broken": "quebrou",
2332
+ "lost.melted": "derreteu",
2333
+ "lost.ashes": "virou cinza",
2334
+ "tool.none": "s\xF3 com a m\xE3o",
2335
+ "arena.target_wall": "Parede de alvo",
2336
+ "arena.table": "Mesa",
2337
+ "arena.shelf": "Estante",
2338
+ "surface.floor": "no ch\xE3o",
2339
+ "surface.ground": "no ch\xE3o",
2340
+ "surface.table": "na mesa",
2341
+ "by.break": "arrebentou",
2342
+ "by.tool": "desmontado com ferramenta",
2343
+ "by.part_lost": "a pe\xE7a ao lado se perdeu",
2344
+ "dmg.ballistic": "de tiro",
2345
+ "dmg.blunt": "de impacto",
2346
+ "dmg.slash": "de corte",
2347
+ "dmg.explosive": "de explos\xE3o",
2348
+ "dmg.fire": "de fogo",
2349
+ "dmg.heat": "de calor",
2350
+ "effect.caffeine": "cafe\xEDna",
2351
+ "effect.alcohol": "\xE1lcool",
2352
+ "effect.poison": "veneno",
2353
+ "effect.disgust": "nojo",
2354
+ "ev.dent": "{where}: amassado de {mm} mm, {hp}",
2355
+ "ev.hole.through": "{where}: furo de {mm} mm atravessou, {hp}",
2356
+ "ev.hole.stuck": "{where}: furo de {mm} mm n\xE3o atravessou, {hp}",
2357
+ "ev.dents": "{where}: {n} amassados, o mais fundo com {mm} mm, {hp}",
2358
+ "ev.holes": "{where}: {n} furos de {mm} mm, {thr} atravessaram, {hp}",
2359
+ "ev.leak.tail": ", vazando {liquid} a {rate} l/s",
2360
+ "ev.leak.alone": "{where}: vazando {liquid} a {rate} l/s",
2361
+ "ev.crack": "{where}: trincou, {rays} raios de at\xE9 {cm} cm, {hp}",
2362
+ "ev.ricochet": "{where}: a bala ricocheteou, sobraram {energy} J",
2363
+ "ev.fracture.shatter": "{where}: estilha\xE7ou em {n} cacos",
2364
+ "ev.fracture.crumble": "{where}: esfarelou em {n} peda\xE7os",
2365
+ "ev.fracture.splinter": "{where}: lascou em {n} peda\xE7os",
2366
+ "ev.fracture.tear": "{where}: rasgou em {n} retalhos",
2367
+ "ev.fracture.snap": "{where}: partiu em {n} peda\xE7os",
2368
+ "ev.fracture.none": "{where}: quebrou em {n} peda\xE7os",
2369
+ "ev.detach": "{obj}: {a} soltou de {b} ({by}); o objeto agora tem {pieces}",
2370
+ "ev.detach.many.break": "{obj}: {joints} arrebentaram; o objeto agora tem {pieces}",
2371
+ "ev.detach.many.tool": "{obj}: {joints} soltos com ferramenta; o objeto agora tem {pieces}",
2372
+ "ev.lost.tail": " e se soltou de {joints}; o objeto agora tem {pieces}",
2373
+ "ev.lost.alone": "{where}: {verb} e se soltou de {joints}; o objeto agora tem {pieces}",
2374
+ "ev.cracks": "{where}: rachando sob o peso, {n} trincas at\xE9 agora, a maior com {cm} cm, {hp}",
2375
+ "ev.disassemble": "{obj}: soltando {kind} entre {a} e {b} com {tool}: {pct}%",
2376
+ "ev.ignite": "{where}: pegou fogo a {temp} \xB0C",
2377
+ "ev.ignite.many": "{obj}: {n} pe\xE7as pegaram fogo a {temp} \xB0C ({parts})",
2378
+ "ev.extinguish": "{where}: fogo apagado",
2379
+ "ev.extinguish.many": "{obj}: fogo apagado em {n} pe\xE7as ({parts})",
2380
+ "ev.burnout": "{where}: virou cinza",
2381
+ "ev.melt": "{where}: derretendo, {pct}% j\xE1 derreteu",
2382
+ "ev.drip": "{where}: pingando material derretido",
2383
+ "ev.explode": "{where}: explodiu, {energy} J em {radius} m",
2384
+ "ev.destroyed": "{obj}: destru\xEDdo",
2385
+ "ev.damage": "{where}: dano {type} de {amount}, {hp}",
2386
+ "ev.damage.overload": "{where}: esmagado pelo peso que sustenta, perdeu {amount} hp at\xE9 agora, {hp}",
2387
+ "ev.stress": "{where}: sobrecarregado a {stress}% da capacidade, sustentando {load} kg",
2388
+ "ev.collapse": "{obj}: desabou, ca\xEDram {pieces} ({mass} kg): {parts}",
2389
+ "ev.stain_ignite": "A mancha de {liquid} em x {x} \xB7 z {z} pegou fogo e vai queimar por uns {seconds} s",
2390
+ "ev.stain_burned": "A mancha em chamas se consumiu",
2391
+ "ev.fire_dries": "O fogo secou as manchas molhadas por perto",
2392
+ "fl.fill": "{who}: encheu de {liquid} (+{liters} l)",
2393
+ "fl.pour": "{who}: despejando {liquid} a {rate} l/s",
2394
+ "fl.spill": "{who}: derramou {liquid} pela borda a {rate} l/s",
2395
+ "fl.leak": "{who}: vazando {liquid} a {rate} l/s",
2396
+ "fl.drink": "{who}: bebeu {liters} l, sede +{thirst}",
2397
+ "fl.burn": "quente demais: queimou a boca",
2398
+ "fl.empty": "{who}: vazio",
2399
+ "fl.splash": "Respingo de {liquid}",
2400
+ "fl.stain": "Mancha de {liquid} {surface} em {where}",
2401
+ "fl.stains": "{n} manchas de {liquid} {surface}, a \xFAltima em {where}",
2402
+ "fl.dried": "Uma mancha secou",
2403
+ "fl.dried.mark": "Uma mancha secou e deixou marca",
2404
+ "fl.dried.known": "A mancha de {liquid} {surface} em {where} secou",
2405
+ "fl.dried.known.mark": "A mancha de {liquid} {surface} em {where} secou e deixou marca",
2406
+ "fl.dried.many": "{n} manchas de {liquid} secaram",
2407
+ "fl.dried.many.mark": "{n} manchas de {liquid} secaram e deixaram marca",
2408
+ "fl.mix": "{who}: {from} misturou em {into}",
2409
+ "act.anim_start": '{name}: anima\xE7\xE3o "{clip}" ({seconds} s)',
2410
+ "act.anim_event": '{name}: "{clip}" evento {type}',
2411
+ "act.anim_end": '{name}: anima\xE7\xE3o "{clip}" terminou',
2412
+ "act.area_skip": 'Item "{item}" da \xE1rea n\xE3o est\xE1 carregado nesta sala: ficou de fora',
2413
+ "act.module_loaded": 'M\xF3dulo "{name}" carregado: {items} item(ns), {scenarios} cen\xE1rio(s), verifica\xE7\xF5es do contrato: {result}',
2414
+ "mod.all_ok": "todas passaram",
2415
+ "mod.some_failed": "algumas FALHARAM",
2416
+ "mod.destructible_ok": "{name}: contrato de destrui\xE7\xE3o ok ({parts} pe\xE7as, golpe e estado bem formados)",
2417
+ "mod.destructible_fail": "{name}: contrato de destrui\xE7\xE3o quebrado: {problems}",
2418
+ "mod.animation_ok": '{name}: anima\xE7\xE3o "{clip}" ok ({seconds} s)',
2419
+ "mod.spec_ok": "{name}: spec ok ({parts} pe\xE7as)",
2420
+ "mod.spec_fail": "{name}: spec quebrada: {problems}",
2421
+ "mod.spec_warn": "{name}: aviso na spec: {warning}",
2422
+ "mod.animation_fail": '{name}: anima\xE7\xE3o "{clip}" quebrada: {problems}',
2423
+ "ui.animations": "Anima\xE7\xF5es",
2424
+ "ui.animations_aim": "Mire num item ou segure-o para ver as anima\xE7\xF5es.",
2425
+ "ui.animations_none": "Este item n\xE3o traz anima\xE7\xF5es.",
2426
+ "mod.bench_ok": "{name}: bancada ok ({controls} controles, {layers} camadas, {metrics} contadores)",
2427
+ "mod.bench_fail": "{name}: bancada quebrada: {problems}",
2428
+ "mod.metric_failed": '{name}: o contador "{metric}" lan\xE7ou erro e mostra n/a: {error}',
2429
+ "mod.listener_failed": "{name}: um ouvinte da bancada lan\xE7ou erro: {error}",
2430
+ "ui.bench": "Bancada",
2431
+ "ui.layers": "Camadas",
2432
+ "ui.metrics": "Contadores",
2433
+ "metric.fps": "FPS",
2434
+ "metric.frame_ms": "ms/quadro",
2435
+ "metric.draw_calls": "Chamadas de desenho",
2436
+ "metric.triangles": "Tri\xE2ngulos",
2437
+ "metric.objects": "Objetos",
2438
+ "metric.fragments": "Fragmentos",
2439
+ "metric.particles": "Part\xEDculas",
2440
+ "metric.decals": "Decalques",
2441
+ "act.spawn": "Criou {name} em x {x} \xB7 z {z}",
2442
+ "act.clear": "Limpou a sala",
2443
+ "act.reset": "Consertou todos os objetos",
2444
+ "act.tool": "Equipou {weapon}",
2445
+ "act.shoot.hit": "{weapon}: disparo, {energy} J, acertou {where} a {dist} m",
2446
+ "act.shoot.miss": "{weapon}: disparo, {energy} J, n\xE3o acertou nada",
2447
+ "act.shoot.pellets": "{weapon}: disparo, {n} proj\xE9teis de at\xE9 {energy} J, {hits} acertaram {where} a {dist} m",
2448
+ "act.melee.hit": "{weapon}: golpe, {energy} J, acertou {where} a {dist} m",
2449
+ "act.melee.miss": "{weapon}: golpe no ar",
2450
+ "act.strike": "{weapon}: {clip} come\xE7a, impacto em {seconds} s",
2451
+ "act.strike.leg": "{weapon}: {clip} com a perna {leg} come\xE7a, impacto em {seconds} s",
2452
+ "leg.left": "esquerda",
2453
+ "leg.right": "direita",
2454
+ "act.flame.hit": "{weapon}: chama a {temp} \xB0C em {where}",
2455
+ "act.flame.air": "{weapon}: chama, nada ao alcance",
2456
+ "act.heat.hit": "{weapon}: esquentando {where} a {temp} \xB0C",
2457
+ "act.heat.air": "{weapon}: nada ao alcance",
2458
+ "act.throw.grenade": "{weapon}: arremessada, pavio de {fuse} s",
2459
+ "act.throw.molotov": "{weapon}: arremessado, quebra no impacto",
2460
+ "act.launch": "{weapon}: disparado, arma depois de {arm} m",
2461
+ "act.place": "{weapon}: carga posta, espera {triggers}",
2462
+ "act.tripwire": "{weapon}: {who} cruzou o fio",
2463
+ "act.dud": "{weapon}: bateu antes de armar ({distance} m), n\xE3o explodiu",
2464
+ "act.backblast": "{weapon}: sopro atr\xE1s do tubo, {radius} m, {n} objetos no cone, {player} no atirador",
2465
+ "act.blast_player": "{weapon}: voc\xEA est\xE1 na zona {zone}, a {distance} m (atordoado {stun} s)",
2466
+ "hud.no_charges": "Nenhuma carga posta espera o detonador",
2467
+ "blast.zone.lethal": "letal",
2468
+ "blast.zone.injury": "de ferimento",
2469
+ "act.explode": "{weapon}: explodiu, {energy} J em {radius} m, {n} objetos ao alcance",
2470
+ "act.molotov_fire": "{weapon}: fogo no ch\xE3o, raio de {radius} m por {seconds} s",
2471
+ "act.tool.none": "{weapon}: nada ao alcance",
2472
+ "act.tool.nojoint": "{weapon}: nenhuma junta perto de {where}",
2473
+ "act.tool.refused": "{weapon} n\xE3o solta {kind} de {obj}; precisa de: {need}",
2474
+ "act.extinguish.hit": "{weapon}: espuma em {what}",
2475
+ "act.extinguish.air": "{weapon}: espuma, nada ao alcance",
2476
+ "act.grab": "Pegou {name} ({kg} kg)",
2477
+ "act.drop": "Soltou {name}",
2478
+ "act.throw": "Arremessou {name} a {speed} m/s",
2479
+ "act.landing": "{name} caiu a {speed} m/s: {energy} J",
2480
+ "act.bench_failed": "{name} falhou: {error}",
2481
+ "loading.working": "Trabalhando\u2026",
2482
+ "loading.open_area": "Abrindo {area}\u2026",
2483
+ "loading.spawn": "Montando {name}\u2026",
2484
+ "act.loosened": "{name} \xE9 pesado mas estava solto: a explos\xE3o o moveu",
2485
+ "act.impact": "{name} bateu em {where} a {speed} m/s: {energy} J de dano de impacto",
2486
+ "act.use_item": "{name}: {use}",
2487
+ "act.tap.on": "Abriu a torneira",
2488
+ "act.tap.off": "Fechou a torneira",
2489
+ "use.drink": "beber",
2490
+ "use.sip": "golinho",
2491
+ "use.chug": "virar",
2492
+ "use.pour": "despejar",
2493
+ "use.pour_out": "esvaziar",
2494
+ "use.open": "abrir",
2495
+ "use.close": "fechar",
2496
+ "hud.reloading": "Recarregando...",
2497
+ "hud.empty": "Sem muni\xE7\xE3o",
2498
+ "hud.no_fuel": "Sem combust\xEDvel",
2499
+ "hud.fault": "Travou ({fault}): aperte X para destravar",
2500
+ "hud.safe": "Trava ligada: aperte B",
2501
+ "hud.clearing": "Destravando...",
2502
+ "hud.safety_on": "Trava ligada",
2503
+ "hud.safety_off": "Trava desligada",
2504
+ "fault.misfire": "nega",
2505
+ "fault.stovepipe": "chamin\xE9",
2506
+ "fault.double_feed": "dupla alimenta\xE7\xE3o",
2507
+ "fault.failure_to_extract": "falha de extra\xE7\xE3o",
2508
+ "hud.nothing_to_grab": "Nada para pegar aqui",
2509
+ "hud.cannot_grab": "{name} n\xE3o d\xE1 para pegar",
2510
+ "hud.too_heavy": "{name} \xE9 pesado demais ({kg} kg)",
2511
+ "hud.empty_container": "{name} est\xE1 vazio",
2512
+ "hud.refused": "Ferramenta errada para {kind}",
2513
+ "hud.drop": "soltar",
2514
+ "hud.rotate": "girar",
2515
+ "hud.throw": "arremessar",
2516
+ "hud.tilt": "inclinar (segurar)",
2517
+ "hud.equip": "equipar {weapon}",
2518
+ "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 \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",
2519
+ "ui.help.camera": "V: 1\xAA/3\xAA pessoa (Shift+V: chute)",
2520
+ "camera.first": "1\xAA pessoa",
2521
+ "camera.third": "3\xAA pessoa",
2522
+ "act.camera": "C\xE2mera: {mode}",
2523
+ "ui.log": "Registro de comportamento",
2524
+ "ui.filter.action": "a\xE7\xF5es",
2525
+ "ui.filter.event": "eventos",
2526
+ "ui.filter.check": "testes",
2527
+ "ui.autoscroll": "rolar sozinho",
2528
+ "log.export_cancelled": "exporta\xE7\xE3o cancelada",
2529
+ "ui.copy_json": "Copiar JSON",
2530
+ "ui.download_ndjson": "Baixar NDJSON",
2531
+ "ui.download_txt": "Baixar TXT",
2532
+ "ui.clear_log": "Limpar",
2533
+ "ui.catalog": "Cat\xE1logo",
2534
+ "ui.clear": "Limpar",
2535
+ "ui.reset_all": "Consertar tudo",
2536
+ "ui.slow": "C\xE2mera lenta 0,25\xD7",
2537
+ "ui.fast": "Tempo \xD710",
2538
+ "ui.tap": "Torneira",
2539
+ "ui.fullscreen": "Tela cheia",
2540
+ "ui.fullscreen.hint": "A tela cheia aberta por aqui segura o teclado todo no jogo, inclusive Ctrl+W (o F11 n\xE3o)",
2541
+ "ui.blast.shelter": "Abrigo",
2542
+ "ui.blast.fragmentHits": "Estilha\xE7os",
2543
+ "ui.blast.confinement": "Confinamento",
2544
+ "ui.blast.shelter.hint": "Explos\xF5es: paredes e outras partes entre a explos\xE3o e o alvo ficam com a sua parte",
2545
+ "ui.blast.fragmentHits.hint": "Explos\xF5es: os peda\xE7os do que quebra voam e acertam o que est\xE1 atr\xE1s",
2546
+ "ui.blast.confinement.hint": "Explos\xF5es: a press\xE3o sobe num ambiente fechado",
2547
+ "metric.blast_shelter": "Explos\xE3o que chegou (%)",
2548
+ "metric.blast_confinement": "Press\xE3o da explos\xE3o (x)",
2549
+ "metric.blast_fragments": "Estilha\xE7os que acertaram",
2550
+ "act.blast_report": "Explos\xE3o: {shelter}% chegou ao alvo mais abrigado, press\xE3o x{confinement}, {fragments} estilha\xE7os acertaram, {n} alvos",
2551
+ "ui.quality": "Qualidade",
2552
+ "ui.quality.low": "baixa",
2553
+ "ui.quality.medium": "m\xE9dia",
2554
+ "ui.quality.high": "alta",
2555
+ "ui.equip": "Equipar como ferramenta",
2556
+ "ui.search_items": "Buscar item (todas as abas)",
2557
+ "ui.search_tests": "Buscar teste",
2558
+ "ui.no_results": "Nada encontrado",
2559
+ "ui.objects": "{n} objetos",
2560
+ "ui.tests": "Testes",
2561
+ "ui.run_all": "Rodar todos",
2562
+ "ui.running": "Rodando...",
2563
+ "ui.report": "{passed} passaram, {failed} falharam",
2564
+ "ui.panel": "Pain\xE9is",
2565
+ "ui.inspector": "Inspetor",
2566
+ "ui.aim_at_something": "Mire em um objeto",
2567
+ "ins.material": "Material",
2568
+ "ins.status": "Estado",
2569
+ "ins.burning": "em chamas",
2570
+ "ins.hp": "Pontos de vida",
2571
+ "ins.temperature": "Temperatura",
2572
+ "ins.char": "Carbonizado",
2573
+ "ins.melt": "Derretido",
2574
+ "ins.liters": "L\xEDquido",
2575
+ "ins.damage": "Marcas",
2576
+ "ins.damage_counts": "{dents}, {holes}, {cracks}",
2577
+ "ins.mass": "Massa",
2578
+ "ins.load": "Carga",
2579
+ "ins.stress": "Tens\xE3o",
2580
+ "ins.joints": "Juntas",
2581
+ "ins.open": "aberta",
2582
+ "sc.start": "Cen\xE1rio: {name}",
2583
+ "sc.pass": "Cen\xE1rio passou: {name}",
2584
+ "sc.fail": "Cen\xE1rio FALHOU: {name}",
2585
+ "sc.error": "o cen\xE1rio lan\xE7ou um erro: {msg}",
2586
+ "leg.still": "leg_1 continua presa",
2587
+ "leg.gone": "leg_1 soltou",
2588
+ "roof.standing": "laje de p\xE9",
2589
+ "roof.fell": "laje caiu"
2590
+ }
3005
2591
  };
3006
- function scenariosFor(locale = "en") {
3007
- const pt = locale.toLowerCase().startsWith("pt");
3008
- return makeScenarios(makeT(locale), (n, d = 1) => pt ? (Math.round(n * 10 ** d) / 10 ** d).toFixed(d).replace(".", ",") : (Math.round(n * 10 ** d) / 10 ** d).toFixed(d), locale);
3009
- }
3010
- var SCENARIOS = scenariosFor("en");
2592
+ var locales = { en, "pt-BR": ptBR };
2593
+ var resolveStrings = (locale) => locales[locale ?? "en"] ?? locales.en;
3011
2594
 
3012
2595
  // src/bench-panel.ts
3013
2596
  var SEGMENTED_MAX = 4;
@@ -3382,6 +2965,19 @@ var thumb = (thumbnail, credit) => {
3382
2965
  e.style.setProperty("--tr-thumb", `url("${thumbSource(thumbnail)}")`);
3383
2966
  return e;
3384
2967
  };
2968
+ async function toggleFullscreen() {
2969
+ const keyboard = navigator.keyboard;
2970
+ try {
2971
+ if (document.fullscreenElement) {
2972
+ keyboard?.unlock?.();
2973
+ await document.exitFullscreen();
2974
+ return;
2975
+ }
2976
+ await document.documentElement.requestFullscreen();
2977
+ await keyboard?.lock?.();
2978
+ } catch {
2979
+ }
2980
+ }
3385
2981
  function createUi(o) {
3386
2982
  const { t } = o;
3387
2983
  const style = mk2("style");
@@ -3593,6 +3189,8 @@ function createUi(o) {
3593
3189
  o.onFast(fast);
3594
3190
  });
3595
3191
  const tapBtn = ctl(t("ui.tap"), () => o.onTap());
3192
+ const fullscreenBtn = ctl(t("ui.fullscreen"), () => void toggleFullscreen());
3193
+ fullscreenBtn.title = t("ui.fullscreen.hint");
3596
3194
  const blastBtns = ["shelter", "fragmentHits", "confinement"].map((key) => ({ key, b: ctl(t(`ui.blast.${key}`), () => o.onBlast(key)) }));
3597
3195
  for (const { key, b } of blastBtns) b.title = t(`ui.blast.${key}.hint`);
3598
3196
  const qSel = mk2("select");
@@ -3758,6 +3356,9 @@ function createUi(o) {
3758
3356
  toolBox.textContent = "";
3759
3357
  toolBox.append(document.createTextNode(s.toolName));
3760
3358
  if (s.ammo) toolBox.append(mk2("small", void 0, s.ammo));
3359
+ if (s.camera) toolBox.append(mk2("small", void 0, t(`camera.${s.camera}`)));
3360
+ const helpText = s.camera ? `${t("ui.help")} \xB7 ${t("ui.help.camera")}` : t("ui.help");
3361
+ if (help.textContent !== helpText) help.textContent = helpText;
3761
3362
  if (s.status) status.textContent = s.status;
3762
3363
  else if (!toastTimer) status.textContent = "";
3763
3364
  if (s.progress) {
@@ -3847,6 +3448,10 @@ function createUi(o) {
3847
3448
  // src/room.ts
3848
3449
  var FIXED_DT = 1 / 60;
3849
3450
  var METRICS_INTERVAL_S = 0.25;
3451
+ var CARRY_NEAR = 0.62;
3452
+ var CARRY_PALM = 0.012;
3453
+ var CARRY_GRIP = 0.45;
3454
+ var MAX_STRIDE_SPEED = 10;
3850
3455
  var v3 = (v) => [v.x, v.y, v.z];
3851
3456
  var dist3 = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
3852
3457
  function createTestRoom(options) {
@@ -3860,6 +3465,7 @@ function createTestRoom(options) {
3860
3465
  if (vars) for (const k of Object.keys(vars)) s = s.split(`{${k}}`).join(String(vars[k]));
3861
3466
  return s;
3862
3467
  };
3468
+ const loading = createLoading({ container: options.container, draw: options.ui !== false, working: t("loading.working") });
3863
3469
  const num = (n, digits = 1) => {
3864
3470
  const s = (Math.round(n * 10 ** digits) / 10 ** digits).toFixed(digits);
3865
3471
  return locale.toLowerCase().startsWith("pt") ? s.replace(".", ",") : s;
@@ -3895,13 +3501,13 @@ function createTestRoom(options) {
3895
3501
  const onResize = () => resize();
3896
3502
  window.addEventListener("resize", onResize);
3897
3503
  const arenaSize = options.arenaSize ?? 14;
3898
- const arena = createArena(THREE, arenaSize, scene);
3504
+ const arena = createArena(THREE, arenaSize, scene, options.scenery, options.tap);
3899
3505
  scene.add(arena.group);
3900
3506
  const ownWorld = !options.world;
3901
- const world = options.world ?? createDestruction({ seed: 1, ...options.worldOptions, materials: [...DEMO_MATERIALS, ...options.worldOptions?.materials ?? []] });
3507
+ const world = options.world ?? createDestruction({ seed: 1, ...options.worldOptions, materials: [...options.worldOptions?.materials ?? []] });
3902
3508
  const pristineWorld = ownWorld ? world.serialize() : null;
3903
- const tableOk = () => {
3904
- const p = world.part(arena.table.id, arena.table.topPart);
3509
+ const topOk = (t2) => {
3510
+ const p = world.part(t2.id, t2.topPart);
3905
3511
  return !!p && p.status !== "broken" && p.status !== "melted" && p.status !== "ashes";
3906
3512
  };
3907
3513
  const restingTops = [];
@@ -3910,7 +3516,12 @@ function createTestRoom(options) {
3910
3516
  for (const c of restingTops) if (x > c.minX && x < c.maxX && z > c.minZ && z < c.maxZ && world.has(c.objectId)) top = Math.max(top, c.top);
3911
3517
  return top;
3912
3518
  };
3913
- const groundAt = (x, z) => Math.max(x > arena.table.minX && x < arena.table.maxX && z > arena.table.minZ && z < arena.table.maxZ && tableOk() ? arena.table.top : 0, restingTopAt(x, z));
3519
+ const sceneryTopAt = (x, z) => {
3520
+ let top = 0;
3521
+ 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;
3522
+ return top;
3523
+ };
3524
+ const groundAt = (x, z) => Math.max(sceneryTopAt(x, z), restingTopAt(x, z));
3914
3525
  const ownFluids = !options.fluids;
3915
3526
  const fluids = options.fluids ?? createFluids({ seed: 1, locale, liquids: options.liquids, groundAt });
3916
3527
  const pristineFluids = ownFluids ? fluids.serialize() : null;
@@ -3958,6 +3569,19 @@ function createTestRoom(options) {
3958
3569
  groundAt,
3959
3570
  pointScale,
3960
3571
  onSound: playSound,
3572
+ ...options.blastShove ? { blastShove: options.blastShove } : {},
3573
+ // a blast moves what is heavy but not nailed down: the renderer makes it a piece the moment the shove reaches it
3574
+ shoveable: (objectId) => {
3575
+ const e = entries.get(objectId);
3576
+ return e?.free === true && !e.piece;
3577
+ },
3578
+ onLoosened: (objectId, piece) => {
3579
+ const e = entries.get(objectId);
3580
+ if (!e) return;
3581
+ e.piece = piece;
3582
+ rendererPieces.set(objectId, piece);
3583
+ action("loosened", t("act.loosened", { name: e.name }), { objectId, itemId: e.so.itemId });
3584
+ },
3961
3585
  onPiece: (piece) => {
3962
3586
  rendererPieces.set(piece.objectId, piece);
3963
3587
  loose.push(piece);
@@ -4012,7 +3636,6 @@ function createTestRoom(options) {
4012
3636
  };
4013
3637
  let tool = toolState(toolId);
4014
3638
  const catalogMap = /* @__PURE__ */ new Map();
4015
- for (const i of demoItems(locale)) catalogMap.set(i.id, i);
4016
3639
  for (const i of options.catalog) catalogMap.set(i.id, i);
4017
3640
  const catalog = [...options.catalog];
4018
3641
  const entryOf = (objectId) => entries.get(objectId) ?? splitOf.get(objectId);
@@ -4052,7 +3675,7 @@ function createTestRoom(options) {
4052
3675
  });
4053
3676
  let pending = [];
4054
3677
  const eventListeners = /* @__PURE__ */ new Set();
4055
- const listeners = { spawn: /* @__PURE__ */ new Set(), tool: /* @__PURE__ */ new Set(), grab: /* @__PURE__ */ new Set(), throw: /* @__PURE__ */ new Set(), event: eventListeners };
3678
+ const listeners = { spawn: /* @__PURE__ */ new Set(), tool: /* @__PURE__ */ new Set(), grab: /* @__PURE__ */ new Set(), throw: /* @__PURE__ */ new Set(), event: eventListeners, camera: /* @__PURE__ */ new Set() };
4056
3679
  const fire = (type, data) => {
4057
3680
  for (const fn of listeners[type] ?? []) fn(data);
4058
3681
  };
@@ -4121,6 +3744,7 @@ function createTestRoom(options) {
4121
3744
  blockers,
4122
3745
  limit: arenaSize,
4123
3746
  start: arena.playerStart,
3747
+ crouchKeys: options.crouch?.keys,
4124
3748
  hooks: {
4125
3749
  primaryDown: () => {
4126
3750
  useHeld = true;
@@ -4179,19 +3803,38 @@ function createTestRoom(options) {
4179
3803
  };
4180
3804
  const addArena = () => {
4181
3805
  for (const o of arena.objects) {
4182
- const root = buildFromSpec(THREE, o.spec, { colors: o.colors });
3806
+ const root = o.build ? o.build(THREE) : buildFromSpec(THREE, o.spec, { colors: o.colors });
4183
3807
  root.position.set(o.position[0], o.position[1], o.position[2]);
3808
+ root.rotation.y = o.rotationY;
4184
3809
  scene.add(root);
4185
3810
  root.updateMatrixWorld(true);
4186
- world.add(structuredCloneSpec(o.spec), { id: o.id, position: o.position });
4187
- registerEntry(o.id, o.id, root, o.spec, S.text[`arena.${o.id}`] ?? o.id, void 0, void 0, true, false);
3811
+ 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)] } : {} });
3812
+ registerEntry(o.id, o.id, root, o.spec, o.label ?? S.text[`arena.${o.id}`] ?? o.id, void 0, void 0, true, false);
4188
3813
  }
4189
3814
  };
4190
3815
  addArena();
3816
+ const wrapped = /* @__PURE__ */ new WeakMap();
3817
+ const letGo = (root) => {
3818
+ const inner = wrapped.get(root);
3819
+ if (!inner) return;
3820
+ inner.removeFromParent();
3821
+ wrapped.delete(root);
3822
+ };
3823
+ const buildRoot = (item) => {
3824
+ const built = item.build(THREE);
3825
+ if (item.impl || Math.abs(built.scale.x - 1) < 1e-6 && Math.abs(built.scale.y - 1) < 1e-6 && Math.abs(built.scale.z - 1) < 1e-6) return built;
3826
+ const holder = new THREE.Group();
3827
+ holder.name = built.name;
3828
+ holder.userData = built.userData;
3829
+ holder.add(built);
3830
+ wrapped.set(holder, built);
3831
+ Object.defineProperty(holder, "visible", { get: () => built.visible, set: (v) => void (built.visible = v), configurable: true });
3832
+ return holder;
3833
+ };
4191
3834
  const spawn = (itemId, position, rotationY) => {
4192
3835
  const item = catalogMap.get(itemId);
4193
3836
  if (!item) throw new Error(`game-test-room: unknown item "${itemId}"`);
4194
- const root = item.build(THREE);
3837
+ const root = buildRoot(item);
4195
3838
  let pos;
4196
3839
  if (position) pos = [position[0], position[1], position[2]];
4197
3840
  else {
@@ -4234,6 +3877,7 @@ function createTestRoom(options) {
4234
3877
  if (own) world.register(own, { id: objectId, position: pos, rotation: [q.x, q.y, q.z, q.w] });
4235
3878
  else world.add(spec, { id: objectId, position: pos, rotation: [q.x, q.y, q.z, q.w] });
4236
3879
  const e = registerEntry(objectId, item.id, root, spec, item.name, parts, item, false, movable);
3880
+ if (!own && !movable && item.movable === void 0) e.free = true;
4237
3881
  action("spawn", t("act.spawn", { name: item.name, x: num(pos[0], 1), z: num(pos[2], 1) }), { objectId, itemId: item.id, data: { position: pos, movable, massKg: Math.round(massOfObject(objectId) * 10) / 10 } });
4238
3882
  fire("spawn", e.so);
4239
3883
  return e.so;
@@ -4262,6 +3906,7 @@ function createTestRoom(options) {
4262
3906
  }
4263
3907
  if (world.has(id)) world.remove(id);
4264
3908
  e.so.root.removeFromParent();
3909
+ letGo(e.so.root);
4265
3910
  entries.delete(id);
4266
3911
  rendererPieces.delete(id);
4267
3912
  specs.delete(id);
@@ -4336,6 +3981,7 @@ function createTestRoom(options) {
4336
3981
  for (const s of [...arena.surfaces, ...options.surfaces ?? []]) fluids.addSurface(s);
4337
3982
  }
4338
3983
  pending = [];
3984
+ strikes = [];
4339
3985
  rng = 12345;
4340
3986
  counter = 0;
4341
3987
  slot = 0;
@@ -4348,6 +3994,108 @@ function createTestRoom(options) {
4348
3994
  };
4349
3995
  const aimO = [0, 0, 0];
4350
3996
  const aimD = [0, 0, -1];
3997
+ const third = { ...THIRD_PERSON_DEFAULTS, ...options.thirdPerson };
3998
+ let avatar = null;
3999
+ let cameraMode = options.cameraMode ?? "first";
4000
+ let crouchForced = false;
4001
+ const avatarState = { speed: 0, running: false, crouch: false, down: false, armed: false, pitch: 0 };
4002
+ let feetPrev = null;
4003
+ let armedForced = null;
4004
+ let camDistance = Infinity;
4005
+ const tmpHand = new THREE.Vector3();
4006
+ const thirdPose = (dt) => {
4007
+ const pose = thirdPersonPose(
4008
+ { x: player.x, z: player.z, feet: player.feet, yaw: player.yaw, pitch: player.pitch, crouch: avatarState.crouch },
4009
+ third,
4010
+ (o, d, max) => withoutHeld(() => world.raycast(o, d, max))?.distance ?? null
4011
+ );
4012
+ camDistance = pose.free <= camDistance ? pose.free : dt > 0 ? camDistance + (pose.free - camDistance) * (1 - Math.exp(-dt * third.recover)) : camDistance;
4013
+ return { ...pose, position: pose.at(camDistance), free: camDistance };
4014
+ };
4015
+ const thirdActive = () => cameraMode === "third" && avatar !== null;
4016
+ const syncAvatarVisible = () => {
4017
+ if (!avatar) return;
4018
+ avatar.root.visible = cameraMode === "third";
4019
+ if (avatar.firstPerson) avatar.firstPerson.root.visible = cameraMode === "first";
4020
+ };
4021
+ const setCameraMode = (mode) => {
4022
+ if (mode !== "first" && mode !== "third") return;
4023
+ if (mode === cameraMode) return;
4024
+ cameraMode = mode;
4025
+ camDistance = Infinity;
4026
+ syncAvatarVisible();
4027
+ action("camera", t("act.camera", { mode: t(`camera.${mode}`) }), { data: { mode, avatar: avatar !== null } });
4028
+ fire("camera", mode);
4029
+ };
4030
+ const setAvatar = (next) => {
4031
+ if (avatar === next) return;
4032
+ avatar?.root.removeFromParent();
4033
+ avatar?.firstPerson?.root.removeFromParent();
4034
+ avatar = next;
4035
+ feetPrev = null;
4036
+ if (avatar) {
4037
+ scene.add(avatar.root);
4038
+ if (avatar.firstPerson) scene.add(avatar.firstPerson.root);
4039
+ syncAvatarVisible();
4040
+ stepAvatar(0);
4041
+ }
4042
+ ui?.hud(hudState());
4043
+ };
4044
+ const stepAvatar = (dt) => {
4045
+ if (!avatar) return;
4046
+ const x = player.x;
4047
+ const z = player.z;
4048
+ if (dt > 0 && feetPrev) {
4049
+ const raw = Math.hypot(x - feetPrev[0], z - feetPrev[1]) / dt;
4050
+ avatarState.speed = raw > MAX_STRIDE_SPEED ? 0 : avatarState.speed + (raw - avatarState.speed) * (1 - Math.exp(-dt * 15));
4051
+ if (avatarState.speed < 0.02) avatarState.speed = 0;
4052
+ }
4053
+ feetPrev = [x, z];
4054
+ avatarState.running = !player.crouching && (player.keys.has("ShiftLeft") || player.keys.has("ShiftRight"));
4055
+ avatarState.crouch = crouchForced || player.crouching;
4056
+ avatarState.armed = armedForced ?? tool.def.action.kind === "firearm";
4057
+ avatarState.pitch = player.pitch;
4058
+ avatarState.holding = heldPiece !== null;
4059
+ 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;
4060
+ avatar.root.position.set(x, player.feet, z);
4061
+ avatar.root.rotation.set(0, player.yaw + Math.PI, 0);
4062
+ avatar.update(dt, avatarState);
4063
+ avatar.root.updateMatrixWorld(true);
4064
+ };
4065
+ let handSource = null;
4066
+ let carriedHand = null;
4067
+ let debugView = null;
4068
+ let lastHand = { stance: "relaxed", triggerFinger: null, targets: null };
4069
+ const strikeClips = options.strikeClips ?? DEFAULT_STRIKE_CLIPS;
4070
+ const handStances = options.handStances ?? DEFAULT_HAND_STANCES;
4071
+ const stepFirstPerson = (dt) => {
4072
+ const arms = avatar?.firstPerson;
4073
+ if (!arms) return;
4074
+ const targets = handSource?.() ?? carriedHand;
4075
+ const state = {
4076
+ camera,
4077
+ targets,
4078
+ stance: handStances[toolId] ?? (targets ? "hold" : "relaxed"),
4079
+ speed: avatarState.speed,
4080
+ running: avatarState.running,
4081
+ crouch: avatarState.crouch,
4082
+ contact: (origin, direction, maxDistance) => rayWorld(origin, direction, maxDistance)?.distance ?? null,
4083
+ triggerFinger: targets?.fingers?.index ?? null
4084
+ };
4085
+ lastHand = { stance: state.stance, triggerFinger: state.triggerFinger, targets };
4086
+ arms.update(dt, state);
4087
+ };
4088
+ const applyThirdView = () => {
4089
+ if (!thirdActive()) return () => void 0;
4090
+ const home = camera.position.clone();
4091
+ const pose = thirdPose(0);
4092
+ camera.position.set(pose.position[0], pose.position[1], pose.position[2]);
4093
+ camera.updateMatrixWorld(true);
4094
+ return () => {
4095
+ camera.position.copy(home);
4096
+ camera.updateMatrixWorld(true);
4097
+ };
4098
+ };
4351
4099
  const aim = () => {
4352
4100
  camera.updateMatrixWorld(true);
4353
4101
  camera.getWorldPosition(tmpV);
@@ -4358,6 +4106,21 @@ function createTestRoom(options) {
4358
4106
  aimD[0] = tmpV2.x;
4359
4107
  aimD[1] = tmpV2.y;
4360
4108
  aimD[2] = tmpV2.z;
4109
+ if (thirdActive()) {
4110
+ const pose = thirdPose(0);
4111
+ const reach = 80;
4112
+ const hit = withoutHeld(() => world.raycast(pose.position, pose.direction, reach));
4113
+ const dist = hit ? hit.distance : reach;
4114
+ const tx = pose.position[0] + pose.direction[0] * dist - aimO[0];
4115
+ const ty = pose.position[1] + pose.direction[1] * dist - aimO[1];
4116
+ const tz = pose.position[2] + pose.direction[2] * dist - aimO[2];
4117
+ const len = Math.hypot(tx, ty, tz);
4118
+ if (len > 0.5 && (tx * pose.direction[0] + ty * pose.direction[1] + tz * pose.direction[2]) / len > 0.5) {
4119
+ aimD[0] = tx / len;
4120
+ aimD[1] = ty / len;
4121
+ aimD[2] = tz / len;
4122
+ }
4123
+ }
4361
4124
  };
4362
4125
  const FAR = [0, 0, 0, 1];
4363
4126
  const withoutHeld = (fn) => {
@@ -4511,7 +4274,7 @@ function createTestRoom(options) {
4511
4274
  withoutHeld(() => {
4512
4275
  for (const s of shots) {
4513
4276
  energy = Math.max(energy, s.energy);
4514
- const results = world.shoot(s.origin, s.direction, s.energy, s.caliber, "player");
4277
+ const results = world.shoot(s.origin, s.direction, s.energy, s.caliber, "player", shotExtras(s));
4515
4278
  const r0 = results.find((r) => r.part && r.point);
4516
4279
  let end = [s.origin[0] + s.direction[0] * 40, s.origin[1] + s.direction[1] * 40, s.origin[2] + s.direction[2] * 40];
4517
4280
  if (r0?.point && r0.part) {
@@ -4569,7 +4332,7 @@ function createTestRoom(options) {
4569
4332
  const r = world.raycast(u.origin, d, u.range);
4570
4333
  if (!r) continue;
4571
4334
  if (!target) target = r;
4572
- world.hit(r.object, { type, point: r.point, direction: d, temperature: u.temperature, radius: Math.min(u.radius, 0.3), duration: u.duration, source: "player" });
4335
+ world.hit(r.object, { ...useToHit(u, r.point, r.normal), type, direction: d, source: "player" });
4573
4336
  if (type === "fire") igniteNear(r.point, 0.25);
4574
4337
  else renderer.emit("sparks", r.point, r.normal, 2, 4);
4575
4338
  }
@@ -4603,7 +4366,7 @@ function createTestRoom(options) {
4603
4366
  action("melee", t("act.melee.miss", { weapon: w }), { data: { weapon: id } });
4604
4367
  return;
4605
4368
  }
4606
- world.hitPart(r.object, r.part, { type: u.damage, point: r.point, direction: u.direction, energy: u.energy, radius: u.radius, source: "player" });
4369
+ world.hitPart(r.object, r.part, { ...useToHit(u, r.point, r.normal), source: "player" });
4607
4370
  pushPiece(r.object, u.direction, 2 * u.energy / 6, r.point);
4608
4371
  action("melee", t("act.melee.hit", { weapon: w, energy: num(u.energy, 0), where: hitWhere(r), dist: num(r.distance, 1) }), {
4609
4372
  objectId: r.object,
@@ -4612,6 +4375,35 @@ function createTestRoom(options) {
4612
4375
  data: { weapon: id, damage: u.damage, energyJ: u.energy, distanceM: Math.round(r.distance * 100) / 100 }
4613
4376
  });
4614
4377
  };
4378
+ let strikes = [];
4379
+ const kickLeg = options.kickLeg ?? "alternate";
4380
+ let nextLeg = "right";
4381
+ const strikeUse = (u, id = toolId) => {
4382
+ const clip = strikeClips[id];
4383
+ const leg = id === "kick" ? kickLeg === "alternate" ? nextLeg : kickLeg : void 0;
4384
+ const seconds = clip === void 0 ? null : avatar?.strike?.(clip, leg ? { leg } : void 0) ?? null;
4385
+ if (leg && seconds !== null && kickLeg === "alternate") nextLeg = leg === "right" ? "left" : "right";
4386
+ if (clip === void 0 || seconds === null) {
4387
+ meleeUse(u, id);
4388
+ return;
4389
+ }
4390
+ 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 } : {} } });
4391
+ strikes.push({
4392
+ key: id,
4393
+ remaining: seconds,
4394
+ land: () => {
4395
+ aim();
4396
+ meleeUse({ ...u, origin: [aimO[0], aimO[1], aimO[2]], direction: [aimD[0], aimD[1], aimD[2]] }, id);
4397
+ }
4398
+ });
4399
+ };
4400
+ const stepStrikes = (dt) => {
4401
+ if (strikes.length === 0) return;
4402
+ for (const strike of strikes) strike.remaining -= dt;
4403
+ const landed = strikes.filter((strike) => strike.remaining <= 0);
4404
+ strikes = strikes.filter((strike) => strike.remaining > 0);
4405
+ for (const strike of landed) strike.land();
4406
+ };
4615
4407
  const toolWork = (u, dt) => {
4616
4408
  const w = toolName();
4617
4409
  const r = rayWorld(u.origin, u.direction, u.reach);
@@ -4630,7 +4422,7 @@ function createTestRoom(options) {
4630
4422
  const p = world.disassemble(r.object, j.id, tool_, dt);
4631
4423
  const kind = dStrings.jointKinds[j.kind] ?? j.kind;
4632
4424
  if (p < 0) {
4633
- const need = JOINT_DEFAULTS2[j.kind].tools.map((k) => dStrings.toolKinds[k] ?? k).join(", ") || t("tool.none");
4425
+ const need = JOINT_DEFAULTS[j.kind].tools.map((k) => dStrings.toolKinds[k] ?? k).join(", ") || t("tool.none");
4634
4426
  progress = { label: t("hud.refused", { kind }), value: 0, at: simTime };
4635
4427
  log.coalesce(
4636
4428
  `refuse:${r.object}:${j.id}:${toolId}`,
@@ -4640,7 +4432,7 @@ function createTestRoom(options) {
4640
4432
  text: t("act.tool.refused", { weapon: w, kind, obj: nameOf(r.object), need }),
4641
4433
  objectId: r.object,
4642
4434
  itemId: entryOf(r.object)?.so.itemId,
4643
- data: { weapon: toolId, joint: j.id, kind: j.kind, needs: JOINT_DEFAULTS2[j.kind].tools }
4435
+ data: { weapon: toolId, joint: j.id, kind: j.kind, needs: JOINT_DEFAULTS[j.kind].tools }
4644
4436
  },
4645
4437
  2
4646
4438
  );
@@ -4714,12 +4506,18 @@ function createTestRoom(options) {
4714
4506
  action,
4715
4507
  emitHost,
4716
4508
  startFire,
4717
- say
4509
+ say,
4510
+ projectileModel: options.projectileModel
4718
4511
  });
4719
4512
  const useTool = (dt, held) => {
4720
4513
  aim();
4721
4514
  const cat = tool.def.category;
4722
- if (cat === "hand") return;
4515
+ if (cat === "hand") {
4516
+ if (strikes.some((strike) => strike.key === "hand" || strike.key === "fists")) return;
4517
+ strikeUse({ kind: "melee", damage: "blunt", energy: 25, radius: 0.1, reach: 0.8, origin: [aimO[0], aimO[1], aimO[2]], direction: [aimD[0], aimD[1], aimD[2]] }, "hand");
4518
+ return;
4519
+ }
4520
+ if (strikes.some((strike) => strike.key === toolId)) return;
4723
4521
  const res = tool.trigger([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], dt, held, "player");
4724
4522
  flushEvents(toolId, tool, res.recoil);
4725
4523
  if (res.status === "empty") {
@@ -4741,7 +4539,7 @@ function createTestRoom(options) {
4741
4539
  for (const u of res.uses) {
4742
4540
  switch (u.kind) {
4743
4541
  case "melee":
4744
- meleeUse(u);
4542
+ strikeUse(u);
4745
4543
  break;
4746
4544
  case "flame":
4747
4545
  heatRays(u, "fire");
@@ -4780,11 +4578,11 @@ function createTestRoom(options) {
4780
4578
  if (res) applyUses(res, 0);
4781
4579
  };
4782
4580
  const kick = () => {
4783
- if (!arsenal.weapons.has("kick")) return;
4581
+ if (!arsenal.weapons.has("kick") || strikes.some((strike) => strike.key === "kick")) return;
4784
4582
  aim();
4785
4583
  const res = toolState("kick").trigger([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], 0, false, "player");
4786
4584
  if (res.status !== "ok") return;
4787
- for (const u of res.uses) if (u.kind === "melee") meleeUse(u, "kick");
4585
+ for (const u of res.uses) if (u.kind === "melee") strikeUse(u, "kick");
4788
4586
  };
4789
4587
  const setTool = (id) => {
4790
4588
  if (!arsenal.weapons.has(id)) throw new Error(`game-test-room: unknown weapon "${id}"`);
@@ -4811,6 +4609,8 @@ function createTestRoom(options) {
4811
4609
  let heldTilt = 0;
4812
4610
  let tiltKey = 0;
4813
4611
  const heldLocal = new THREE.Vector3();
4612
+ const heldSize = new THREE.Vector3(0.2, 0.2, 0.2);
4613
+ let heldRadius = 0.2;
4814
4614
  const heldVel = [0, 0, 0];
4815
4615
  let useItemPlayer = null;
4816
4616
  let useItemName = "";
@@ -4855,11 +4655,15 @@ function createTestRoom(options) {
4855
4655
  heldName = nameOf(r.object);
4856
4656
  piece.held = true;
4857
4657
  piece.sleeping = false;
4658
+ const savedQ = piece.root.quaternion.clone();
4659
+ piece.root.quaternion.identity();
4660
+ piece.root.updateMatrixWorld(true);
4858
4661
  const box2 = new THREE.Box3().setFromObject(piece.root);
4859
- const c = box2.getCenter(new THREE.Vector3());
4662
+ box2.getCenter(heldLocal).sub(piece.root.position);
4663
+ box2.getSize(heldSize);
4664
+ piece.root.quaternion.copy(savedQ);
4860
4665
  piece.root.updateMatrixWorld(true);
4861
- heldLocal.copy(c).sub(piece.root.position);
4862
- heldLocal.applyQuaternion(piece.root.quaternion.clone().invert());
4666
+ heldRadius = heldSize.length() / 3;
4863
4667
  heldYaw = 0;
4864
4668
  heldTilt = 0;
4865
4669
  heldVel[0] = heldVel[1] = heldVel[2] = 0;
@@ -4880,24 +4684,93 @@ function createTestRoom(options) {
4880
4684
  fire("throw", { objectId: id, speed });
4881
4685
  return true;
4882
4686
  };
4687
+ const carriedTargets = (root, gripQ) => {
4688
+ const width2 = Math.max(heldSize.x, heldSize.z);
4689
+ const sideGrip = heldSize.y > width2 * 1.04 && width2 <= 0.22;
4690
+ if (sideGrip) {
4691
+ const center2 = heldLocal.clone().applyQuaternion(root.quaternion).add(root.position);
4692
+ const radius = Math.min(heldSize.x, heldSize.z) * 0.5;
4693
+ const handX = new THREE.Vector3(-0.35, 0, -0.94).normalize().applyQuaternion(gripQ);
4694
+ const handY = new THREE.Vector3(0.94, 0, -0.35).normalize().applyQuaternion(gripQ);
4695
+ const handZ = new THREE.Vector3().crossVectors(handX, handY).normalize();
4696
+ const q2 = new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(handX, handY, handZ));
4697
+ const up = new THREE.Vector3(0, 1, 0).applyQuaternion(gripQ);
4698
+ const palm = center2.clone().addScaledVector(handY, radius + CARRY_PALM).addScaledVector(handX, -0.022).addScaledVector(up, heldSize.y > 0.16 ? -heldSize.y * 0.08 : -5e-3);
4699
+ if (useItemName === "open" || useItemName === "close") {
4700
+ const leftX = new THREE.Vector3(0.35, 0, -0.94).normalize().applyQuaternion(gripQ);
4701
+ const leftY = new THREE.Vector3(-0.94, 0, -0.35).normalize().applyQuaternion(gripQ);
4702
+ const leftZ = new THREE.Vector3().crossVectors(leftX, leftY).normalize();
4703
+ const lq = new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(leftX, leftY, leftZ));
4704
+ const leftPalm = center2.clone().addScaledVector(up, heldSize.y * 0.45).addScaledVector(leftY, radius * 0.65 + CARRY_PALM).addScaledVector(leftX, -0.018);
4705
+ return {
4706
+ R: { position: [palm.x, palm.y, palm.z], quaternion: [q2.x, q2.y, q2.z, q2.w] },
4707
+ L: { position: [leftPalm.x, leftPalm.y, leftPalm.z], quaternion: [lq.x, lq.y, lq.z, lq.w] },
4708
+ grip: 0.85,
4709
+ gripL: 0.8
4710
+ };
4711
+ }
4712
+ return { R: { position: [palm.x, palm.y, palm.z], quaternion: [q2.x, q2.y, q2.z, q2.w] }, grip: 0.85 };
4713
+ }
4714
+ const box2 = new THREE.Box3().setFromObject(root);
4715
+ const center = box2.getCenter(new THREE.Vector3());
4716
+ const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(gripQ).normalize();
4717
+ const back = new THREE.Vector3(0, -1, 0).applyQuaternion(gripQ).normalize();
4718
+ const z = new THREE.Vector3().crossVectors(forward, back).normalize();
4719
+ const y = new THREE.Vector3().crossVectors(z, forward).normalize();
4720
+ const q = new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(forward, y, z));
4721
+ return { R: { position: [center.x, box2.min.y - CARRY_PALM, center.z], quaternion: [q.x, q.y, q.z, q.w] }, grip: CARRY_GRIP };
4722
+ };
4723
+ const gripQuat = new THREE.Quaternion();
4883
4724
  const followHeld = (dt) => {
4884
4725
  const piece = heldPiece;
4726
+ carriedHand = null;
4885
4727
  if (!piece) return;
4886
4728
  camera.updateMatrixWorld(true);
4887
4729
  const root = piece.root;
4888
4730
  let pos;
4889
4731
  const targetQ = tmpQ;
4890
- if (useItemPlayer) {
4732
+ const hand = thirdActive() ? avatar?.hand?.("R") : void 0;
4733
+ const isDrink = useItemName === "drink" || useItemName === "sip" || useItemName === "chug";
4734
+ if (hand) {
4735
+ const handWorld = hand.getWorldPosition(tmpHand);
4736
+ const yawQ = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), player.yaw);
4737
+ const pose = useItemPlayer?.pose();
4738
+ const rx = pose ? isDrink ? Math.abs(pose.rotation[0]) * 0.9 : pose.rotation[0] : -heldTilt;
4739
+ const ry = pose ? pose.rotation[1] : 0;
4740
+ const rz = pose ? pose.rotation[2] : 0;
4741
+ gripQuat.copy(yawQ).multiply(new THREE.Quaternion().setFromEuler(new THREE.Euler(Math.max(-1.35, Math.min(1.25, rx)), ry, rz, "XYZ")));
4742
+ targetQ.copy(yawQ).multiply(new THREE.Quaternion().setFromEuler(new THREE.Euler(rx, ry, rz, "XYZ")));
4743
+ targetQ.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), heldYaw));
4744
+ const gripOffset = new THREE.Vector3(-0.02, 0.015, -0.04).applyQuaternion(gripQuat);
4745
+ pos = tmpV.copy(handWorld).add(gripOffset);
4746
+ } else if (useItemPlayer) {
4891
4747
  const pose = useItemPlayer.pose();
4892
- const handQ = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), player.yaw);
4893
- pos = tmpV.set(pose.position[0], pose.position[1], pose.position[2]).applyQuaternion(handQ).add(camera.position);
4894
- const eq = new THREE.Quaternion().setFromEuler(new THREE.Euler(pose.rotation[0], pose.rotation[1], pose.rotation[2], "XYZ"));
4895
- targetQ.copy(handQ).multiply(eq);
4748
+ const frameQ = avatar?.firstPerson ? camera.quaternion : new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), player.yaw);
4749
+ const raise = isDrink ? Math.max(0, Math.min(1, (pose.position[1] + 0.2) / 0.22)) : 0;
4750
+ const px2 = pose.position[0] + (isDrink ? 0.02 * raise : 0);
4751
+ const py2 = pose.position[1] - (isDrink ? 0.08 * raise : 0);
4752
+ const pz2 = pose.position[2] - (isDrink ? 0.05 * raise : 0);
4753
+ pos = tmpV.set(px2, py2, pz2).applyQuaternion(frameQ).add(camera.position);
4754
+ const rx = isDrink ? Math.abs(pose.rotation[0]) * 0.85 : pose.rotation[0];
4755
+ const eq = new THREE.Quaternion().setFromEuler(new THREE.Euler(rx, pose.rotation[1], pose.rotation[2], "XYZ"));
4756
+ const wristEq = new THREE.Quaternion().setFromEuler(new THREE.Euler(Math.max(-1.35, Math.min(1.25, rx)), pose.rotation[1], pose.rotation[2], "XYZ"));
4757
+ gripQuat.copy(frameQ).multiply(wristEq);
4758
+ targetQ.copy(frameQ).multiply(eq);
4896
4759
  targetQ.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), heldYaw));
4897
4760
  } else {
4898
- pos = tmpV.set(0.05, -0.28, -1.15).applyMatrix4(camera.matrixWorld);
4761
+ const small = heldRadius < 0.22;
4762
+ const holdX = avatar?.firstPerson ? small ? 0.22 : 0.08 : 0.05;
4763
+ const holdY = avatar?.firstPerson ? small ? -0.2 : -0.26 : -0.28;
4764
+ const holdZ = avatar?.firstPerson ? small ? -0.42 : -Math.min(1.15, CARRY_NEAR + heldRadius) : -1.15;
4765
+ pos = tmpV.set(holdX, holdY, holdZ).applyMatrix4(camera.matrixWorld);
4766
+ const baseQ = avatar?.firstPerson && small ? camera.quaternion : new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), player.yaw);
4767
+ gripQuat.copy(baseQ).multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.max(-1.35, -heldTilt)));
4899
4768
  targetQ.setFromAxisAngle(new THREE.Vector3(0, 1, 0), player.yaw + heldYaw);
4900
- targetQ.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), -heldTilt));
4769
+ if (avatar?.firstPerson && small && heldTilt <= 1e-4) {
4770
+ targetQ.copy(camera.quaternion).multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), heldYaw));
4771
+ } else {
4772
+ targetQ.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), -heldTilt));
4773
+ }
4901
4774
  }
4902
4775
  const k = Math.min(1, (useItemPlayer ? 30 : 16) * dt);
4903
4776
  const px = root.position.x;
@@ -4912,6 +4785,7 @@ function createTestRoom(options) {
4912
4785
  heldVel[2] = (root.position.z - pz) / dt;
4913
4786
  }
4914
4787
  root.updateMatrixWorld(true);
4788
+ if (avatar?.firstPerson && cameraMode === "first") carriedHand = carriedTargets(root, gripQuat);
4915
4789
  };
4916
4790
  const useItem = (useId) => {
4917
4791
  const e = heldEntry;
@@ -4921,11 +4795,13 @@ function createTestRoom(options) {
4921
4795
  const c = fluids.container(e.containerId);
4922
4796
  if (!c) return false;
4923
4797
  if (def.liquids && def.liquids.length > 0 && (!c.liquid || !def.liquids.includes(c.liquid))) return false;
4924
- if ((useId === "drink" || useId === "sip" || useId === "chug" || useId === "pour" || useId === "pour_out") && c.liters <= 1e-6) {
4798
+ const consuming = useId === "drink" || useId === "sip" || useId === "chug" || useId === "pour" || useId === "pour_out";
4799
+ if (consuming && c.liters <= 1e-6) {
4925
4800
  say(t("hud.empty_container", { name: e.name }));
4926
4801
  return false;
4927
4802
  }
4928
4803
  if (useItemPlayer && !useItemPlayer.done) return false;
4804
+ if (consuming && c.closed) fluids.open(e.containerId);
4929
4805
  useItemPlayer = createItemUse(fluids, e.containerId, def);
4930
4806
  useItemName = def.id;
4931
4807
  action("use_item", t("act.use_item", { name: e.name, use: S.text[`use.${def.id}`] ?? def.id }), { objectId: e.so.objectId, itemId: e.so.itemId, data: { use: def.id } });
@@ -4941,7 +4817,7 @@ function createTestRoom(options) {
4941
4817
  };
4942
4818
  const TAP_ID = "room_tap";
4943
4819
  const TAP_RATE = 0.25;
4944
- fluids.addTap({ id: TAP_ID, position: arena.tapSpout, liquid: "water", rate: TAP_RATE, temperature: 16, on: false });
4820
+ if (arena.tapSpout) fluids.addTap({ id: TAP_ID, position: arena.tapSpout, liquid: "water", rate: TAP_RATE, temperature: 16, on: false });
4945
4821
  const isTapOn = () => fluids.taps().some((x) => x.id === TAP_ID && x.on);
4946
4822
  const toggleTap = (on = !isTapOn()) => {
4947
4823
  fluids.setTap(TAP_ID, on);
@@ -5047,10 +4923,11 @@ function createTestRoom(options) {
5047
4923
  if (heldEntry?.uses[0]) useItem(heldEntry.uses[0]);
5048
4924
  break;
5049
4925
  case "KeyC":
5050
- if (heldEntry?.uses[1]) useItem(heldEntry.uses[1]);
4926
+ if (heldEntry?.uses[1] && !(options.crouch?.keys ?? ["KeyC"]).includes("KeyC")) useItem(heldEntry.uses[1]);
5051
4927
  break;
5052
4928
  case "KeyV":
5053
4929
  if (heldEntry?.uses[2]) useItem(heldEntry.uses[2]);
4930
+ else if (avatar && !e.shiftKey) setCameraMode(cameraMode === "first" ? "third" : "first");
5054
4931
  else kick();
5055
4932
  break;
5056
4933
  case "KeyT":
@@ -5098,7 +4975,7 @@ function createTestRoom(options) {
5098
4975
  const joints = st.joints.filter((j) => j.a === r.part || j.b === r.part).map((j) => ({
5099
4976
  id: j.id,
5100
4977
  kind: dStrings.jointKinds[j.kind] ?? j.kind,
5101
- tools: JOINT_DEFAULTS2[j.kind].tools.map((k) => dStrings.toolKinds[k] ?? k),
4978
+ tools: JOINT_DEFAULTS[j.kind].tools.map((k) => dStrings.toolKinds[k] ?? k),
5102
4979
  open: j.open,
5103
4980
  progress: j.progress
5104
4981
  }));
@@ -5138,6 +5015,7 @@ function createTestRoom(options) {
5138
5015
  const roomMetrics = createRoomMetrics({ gl, destruction: renderer, fluids: fluidView, effects: vfx.world, ids: roomMetricIds, blast: () => lastBlast });
5139
5016
  let qualityName = quality;
5140
5017
  const syncContainers = (sdt) => {
5018
+ const drinkingHeld = useItemPlayer !== null && (useItemName === "drink" || useItemName === "sip" || useItemName === "chug");
5141
5019
  for (const e of entries.values()) {
5142
5020
  if (!e.containerId) continue;
5143
5021
  const host2 = hostOf(e);
@@ -5146,7 +5024,8 @@ function createTestRoom(options) {
5146
5024
  const p = tmpV.set(e.fluidOffset[0], e.fluidOffset[1], e.fluidOffset[2]).applyMatrix4(host2.root.matrixWorld);
5147
5025
  const vel = sdt > 0 ? [(p.x - e.last[0]) / sdt, (p.y - e.last[1]) / sdt, (p.z - e.last[2]) / sdt] : void 0;
5148
5026
  e.last = [p.x, p.y, p.z];
5149
- fluids.setTransform(e.containerId, [p.x, p.y, p.z], [q.x, q.y, q.z, q.w], vel);
5027
+ const rot = drinkingHeld && e === heldEntry ? [0, 0, 0, 1] : [q.x, q.y, q.z, q.w];
5028
+ fluids.setTransform(e.containerId, [p.x, p.y, p.z], rot, vel);
5150
5029
  }
5151
5030
  };
5152
5031
  const syncLiters = () => {
@@ -5172,6 +5051,7 @@ function createTestRoom(options) {
5172
5051
  if (paused) return;
5173
5052
  simTime += dt;
5174
5053
  stepWalk(dt);
5054
+ stepStrikes(dt);
5175
5055
  const reps = fastForward ? 10 : 1;
5176
5056
  updateAnimations(dt);
5177
5057
  updateItemUse(dt);
@@ -5210,6 +5090,7 @@ function createTestRoom(options) {
5210
5090
  const update = (dt) => {
5211
5091
  if (!ownsLoop) roomMetrics.frame(dt);
5212
5092
  player.update(dt);
5093
+ stepAvatar(paused ? 0 : dt);
5213
5094
  if (tiltKey || heldTilt > 0) {
5214
5095
  heldTilt = Math.max(0, Math.min(2.4, heldTilt + (tiltKey ? 2.2 : -3) * dt));
5215
5096
  }
@@ -5221,6 +5102,7 @@ function createTestRoom(options) {
5221
5102
  }
5222
5103
  if (!paused) followHeld(dt);
5223
5104
  stepSim(dt * timeScale);
5105
+ stepFirstPerson(paused ? 0 : dt);
5224
5106
  if (progress && simTime - progress.at > 0.25) progress = null;
5225
5107
  hudAcc += dt;
5226
5108
  if (hudAcc >= 0.1 && ui) {
@@ -5256,6 +5138,7 @@ function createTestRoom(options) {
5256
5138
  tap: isTapOn(),
5257
5139
  blast: { shelter: blastOn("shelter"), fragmentHits: blastOn("fragmentHits"), confinement: blastOn("confinement") },
5258
5140
  quality: qualityName,
5141
+ camera: avatar ? cameraMode : null,
5259
5142
  objects: [...entries.values()].filter((e) => !e.arena).length
5260
5143
  };
5261
5144
  };
@@ -5457,6 +5340,8 @@ function createTestRoom(options) {
5457
5340
  environment,
5458
5341
  groundAt,
5459
5342
  effects: vfx,
5343
+ loading,
5344
+ ...options.blastShove ? { blastShove: options.blastShove } : {},
5460
5345
  tool: () => ({
5461
5346
  weapon: toolId,
5462
5347
  category: tool.def.category,
@@ -5480,6 +5365,15 @@ function createTestRoom(options) {
5480
5365
  flushEvents(toolId, tool);
5481
5366
  return result;
5482
5367
  },
5368
+ player: () => ({
5369
+ position: player.position,
5370
+ yaw: player.yaw,
5371
+ pitch: player.pitch,
5372
+ crouching: false,
5373
+ speed: player.speed,
5374
+ running: player.running,
5375
+ cameraMode: "first"
5376
+ }),
5483
5377
  configureTool(settings) {
5484
5378
  const condition = settings[SETTING_IDS.condition];
5485
5379
  const ammoQuality = settings[SETTING_IDS.ammoQuality];
@@ -5501,8 +5395,14 @@ function createTestRoom(options) {
5501
5395
  frameListeners.add(update2);
5502
5396
  return () => frameListeners.delete(update2);
5503
5397
  },
5504
- log(text, data) {
5505
- action("module_note", text, { data });
5398
+ setHandTargets(source) {
5399
+ handSource = source;
5400
+ return () => {
5401
+ if (handSource === source) handSource = null;
5402
+ };
5403
+ },
5404
+ log(text, data, type) {
5405
+ action(type ?? "module_note", text, { data });
5506
5406
  },
5507
5407
  addBlocker(blocked) {
5508
5408
  blockers.add(blocked);
@@ -5513,13 +5413,17 @@ function createTestRoom(options) {
5513
5413
  return () => clearListeners.delete(listener);
5514
5414
  }
5515
5415
  };
5416
+ let sendingInitial = false;
5516
5417
  const lendBench = (store) => ({
5517
5418
  value: store.value,
5518
5419
  layer: store.layer,
5519
5420
  onChange: store.onChange,
5520
5421
  patch: store.patch,
5521
5422
  sheet: store.setSheet,
5522
- look: (eye, target) => room.look(eye, target)
5423
+ // the initial and restored values only set the module's state: they do not move the player's camera (`ModuleArea.view` is how a module asks for a view when its area opens)
5424
+ look: (eye, target) => {
5425
+ if (!sendingInitial) room.look(eye, target);
5426
+ }
5523
5427
  });
5524
5428
  const checkModuleBench = (mod, fail) => {
5525
5429
  const store = createBenchStore(mod, locale, (error) => reportOnce(`${mod.id}:listener`, t("mod.listener_failed", { name: mod.name, error: failureText(error) })));
@@ -5531,6 +5435,29 @@ function createTestRoom(options) {
5531
5435
  for (const p of r.problems) fail(`bench: ${p}`);
5532
5436
  return r.ok ? store : null;
5533
5437
  };
5438
+ const LIGHTS_CHECK_MS = 250;
5439
+ const warmUp = () => {
5440
+ const probes = [renderer.warmUpProbes(), vfx.warmUpProbes(), fluidView.warmUpProbes()];
5441
+ const groups = probes.map((probe) => probe.group);
5442
+ scene.add(...groups);
5443
+ gl.render(scene, camera);
5444
+ scene.remove(...groups);
5445
+ for (const probe of probes) probe.dispose();
5446
+ };
5447
+ const warmUpEffects = () => {
5448
+ const ids = vfx.catalog?.() ?? [];
5449
+ const flames = ids.filter((id) => /fire|flame/i.test(id)).slice(0, 1);
5450
+ gl.compile(scene, camera);
5451
+ const handles = ["m67", ...flames].filter((id) => ids.includes(id)).map((effect) => vfx.play({ effect, point: [0, 0.1, -4], direction: [0, 1, 0], normal: [0, 1, 0] }));
5452
+ for (let i = 0; i < 3; i++) {
5453
+ vfx.update(0.02);
5454
+ gl.render(scene, camera);
5455
+ }
5456
+ for (const h of handles) if (h !== null) vfx.stop(h);
5457
+ vfx.world.clear();
5458
+ vfx.update(0);
5459
+ gl.render(scene, camera);
5460
+ };
5534
5461
  const loadModule = (mod) => {
5535
5462
  const report = { module: mod.id, items: [], scenarios: [], ok: true, problems: [] };
5536
5463
  const fail = (problem) => {
@@ -5546,6 +5473,7 @@ function createTestRoom(options) {
5546
5473
  build: (T) => ti.build(T),
5547
5474
  spec: ti.spec,
5548
5475
  movable: ti.movable,
5476
+ slow: ti.slow,
5549
5477
  fluid: ti.fluid,
5550
5478
  weapon: ti.weapon,
5551
5479
  impl: ti.impl,
@@ -5560,8 +5488,9 @@ function createTestRoom(options) {
5560
5488
  report.items.push(item.id);
5561
5489
  let partIds = [];
5562
5490
  const base = { module: mod.id, item: item.id };
5491
+ let checked;
5563
5492
  try {
5564
- const root = item.build(THREE);
5493
+ const root = checked = buildRoot(item);
5565
5494
  root.updateMatrixWorld(true);
5566
5495
  if (item.impl) {
5567
5496
  const probe = item.impl(root, `${item.id}_check`);
@@ -5583,6 +5512,7 @@ function createTestRoom(options) {
5583
5512
  moduleCheck(false, t("mod.destructible_fail", { name: item.name, problems: msg }), { ...base, check: "destructible", problems: [msg] }, item.id);
5584
5513
  fail(`${item.id}: ${msg}`);
5585
5514
  }
5515
+ if (checked) letGo(checked);
5586
5516
  for (const clip of item.animations ?? []) {
5587
5517
  const r = checkAnimation(clip, partIds);
5588
5518
  moduleCheck(r.ok, r.ok ? t("mod.animation_ok", { name: item.name, clip: clip.id, seconds: num(clip.duration, 1) }) : t("mod.animation_fail", { name: item.name, clip: clip.id, problems: r.problems.join("; ") }), { ...base, check: "animation", clip: clip.id, problems: r.problems }, item.id);
@@ -5618,12 +5548,17 @@ function createTestRoom(options) {
5618
5548
  if (!activeBench || !benches.has(activeBench)) activeBench = mod.id;
5619
5549
  } else if (activeBench === mod.id) activeBench = lastBench();
5620
5550
  if (mod.attach) detachers.set(mod.id, mod.attach(bench ? { ...host, bench: lendBench(bench) } : host));
5621
- bench?.sendInitial();
5551
+ sendingInitial = true;
5552
+ try {
5553
+ bench?.sendInitial();
5554
+ } finally {
5555
+ sendingInitial = false;
5556
+ }
5622
5557
  showBench();
5623
5558
  ui?.refresh();
5624
5559
  return report;
5625
5560
  };
5626
- const allScenarios = () => [...makeScenarios(t, num, locale), ...extraScenarios];
5561
+ const allScenarios = () => [...extraScenarios];
5627
5562
  let scenarioRunning = false;
5628
5563
  const step = (seconds) => {
5629
5564
  const dt = seconds > 6 ? 1 / 30 : FIXED_DT;
@@ -5676,8 +5611,13 @@ function createTestRoom(options) {
5676
5611
  return report;
5677
5612
  };
5678
5613
  const layArea = (layout) => {
5679
- const holders = layout.placements.filter((p) => p.surface);
5680
- const rest = layout.placements.filter((p) => !p.surface);
5614
+ const known = layout.placements.filter((p) => {
5615
+ if (catalogMap.has(p.item)) return true;
5616
+ action("area_skip", t("act.area_skip", { item: p.item }), { itemId: p.item });
5617
+ return false;
5618
+ });
5619
+ const holders = known.filter((p) => p.surface);
5620
+ const rest = known.filter((p) => !p.surface);
5681
5621
  const placed = [...holders, ...rest].map((p) => {
5682
5622
  const so = spawn(p.item, p.position, p.rotationY ?? 0);
5683
5623
  if (p.surface) {
@@ -5693,6 +5633,10 @@ function createTestRoom(options) {
5693
5633
  if (layout.view) room.look(layout.view.eye, layout.view.target);
5694
5634
  return placed.map(({ so }) => so);
5695
5635
  };
5636
+ const areaLabel = (area) => {
5637
+ const mod = [...modules.values()].find((m) => m.id === area || m.group === area);
5638
+ return mod?.group ?? S.groups[area] ?? area;
5639
+ };
5696
5640
  const openArea = (area) => {
5697
5641
  clear();
5698
5642
  const mod = [...modules.values()].find((m) => m.id === area || m.group === area);
@@ -5805,14 +5749,59 @@ function createTestRoom(options) {
5805
5749
  },
5806
5750
  equip,
5807
5751
  openArea,
5752
+ openAreaAsync: (area) => loading.busy(t("loading.open_area", { area: areaLabel(area) }), () => openArea(area)),
5753
+ loading,
5754
+ busy: (label, work, busyOptions) => loading.busy(label, work, busyOptions),
5808
5755
  loadModule,
5756
+ warmUp: () => {
5757
+ warmUp();
5758
+ warmUpEffects();
5759
+ },
5809
5760
  animations: animationsOf,
5810
5761
  playAnimation,
5811
5762
  bench(moduleId) {
5812
5763
  const id = moduleId ?? activeBench;
5813
5764
  return (id ? benches.get(id) : void 0) ?? null;
5814
5765
  },
5815
- applyCamera: () => vfx.applyCamera(),
5766
+ applyCamera: () => {
5767
+ const back = applyThirdView();
5768
+ const shake = vfx.applyCamera();
5769
+ return () => {
5770
+ shake();
5771
+ back();
5772
+ };
5773
+ },
5774
+ setAvatar,
5775
+ get avatar() {
5776
+ return avatar;
5777
+ },
5778
+ setAvatarState(state) {
5779
+ if (state.crouch !== void 0) {
5780
+ crouchForced = state.crouch;
5781
+ avatarState.crouch = state.crouch || player.crouching;
5782
+ }
5783
+ if (state.down !== void 0) avatarState.down = state.down;
5784
+ if (state.armed !== void 0) armedForced = state.armed;
5785
+ },
5786
+ avatarState: () => ({ ...avatarState }),
5787
+ handState: () => ({ ...lastHand }),
5788
+ setDebugCamera(view) {
5789
+ if (!view) {
5790
+ debugView = null;
5791
+ return;
5792
+ }
5793
+ debugView ?? (debugView = new THREE.PerspectiveCamera(60, camera.aspect, 0.01, 200));
5794
+ debugView.fov = view.fov ?? 40;
5795
+ debugView.aspect = camera.aspect;
5796
+ debugView.position.set(...view.position);
5797
+ debugView.lookAt(...view.target);
5798
+ debugView.updateProjectionMatrix();
5799
+ debugView.updateMatrixWorld(true);
5800
+ },
5801
+ setCameraMode,
5802
+ get cameraMode() {
5803
+ return cameraMode;
5804
+ },
5816
5805
  metrics() {
5817
5806
  return { room: roomMetrics.sample(), module: moduleMetrics() };
5818
5807
  },
@@ -5827,6 +5816,7 @@ function createTestRoom(options) {
5827
5816
  window.removeEventListener("resize", onResize);
5828
5817
  player.dispose();
5829
5818
  ui?.dispose();
5819
+ loading.dispose();
5830
5820
  renderer.dispose();
5831
5821
  vfx.dispose();
5832
5822
  craterPatch?.dispose();
@@ -5841,6 +5831,17 @@ function createTestRoom(options) {
5841
5831
  }
5842
5832
  }
5843
5833
  };
5834
+ const slowly = (moduleId, controlId, run) => {
5835
+ const control = benches.get(moduleId)?.controls().find((c) => c.id === controlId);
5836
+ const slow = control?.slow;
5837
+ if (!slow) {
5838
+ run();
5839
+ return;
5840
+ }
5841
+ void loading.busy(typeof slow === "string" ? slow : control.name, run).catch((error) => {
5842
+ action("bench_failed", t("act.bench_failed", { name: control.name, error: error instanceof Error ? error.message : String(error) }), { data: { module: moduleId, control: controlId } });
5843
+ });
5844
+ };
5844
5845
  let ui = null;
5845
5846
  if (options.ui !== false) {
5846
5847
  const groups = () => {
@@ -5860,7 +5861,9 @@ function createTestRoom(options) {
5860
5861
  groups,
5861
5862
  loadout: () => loadout.map((id) => ({ id, name: arsenal.name(id) })),
5862
5863
  onSpawn: (id) => {
5863
- spawn(id);
5864
+ const item = catalogMap.get(id);
5865
+ if (item?.slow) void loading.busy(typeof item.slow === "string" ? item.slow : t("loading.spawn", { name: item.name }), () => spawn(id));
5866
+ else spawn(id);
5864
5867
  },
5865
5868
  onEquip: (itemId) => {
5866
5869
  const w = catalogMap.get(itemId)?.weapon;
@@ -5900,13 +5903,13 @@ function createTestRoom(options) {
5900
5903
  },
5901
5904
  onResize: resize,
5902
5905
  onControl: (id, value) => {
5903
- if (activeBench) benches.get(activeBench)?.set(id, value);
5906
+ if (activeBench) slowly(activeBench, id, () => benches.get(activeBench)?.set(id, value));
5904
5907
  },
5905
5908
  onLayer: (id, on) => {
5906
5909
  if (activeBench) benches.get(activeBench)?.setLayer(id, on);
5907
5910
  },
5908
5911
  onAction: (id) => {
5909
- if (activeBench) benches.get(activeBench)?.act(id);
5912
+ if (activeBench) slowly(activeBench, id, () => benches.get(activeBench)?.act(id));
5910
5913
  }
5911
5914
  });
5912
5915
  ui.hud(hudState());
@@ -5916,15 +5919,32 @@ function createTestRoom(options) {
5916
5919
  if (ownRenderer && typeof requestAnimationFrame === "function") {
5917
5920
  let last = performance.now();
5918
5921
  ownsLoop = true;
5922
+ let nextLightsCheck = 0;
5923
+ let warmedLights = "";
5924
+ const lightsKey = () => {
5925
+ let key = "";
5926
+ scene.traverseVisible((object) => {
5927
+ if (object.isLight) key += `${object.type[0]}${object.castShadow ? "s" : ""}`;
5928
+ });
5929
+ return key;
5930
+ };
5919
5931
  const loop = (now) => {
5920
5932
  raf = requestAnimationFrame(loop);
5933
+ if (now >= nextLightsCheck) {
5934
+ nextLightsCheck = now + LIGHTS_CHECK_MS;
5935
+ const key = lightsKey();
5936
+ if (key !== warmedLights) {
5937
+ warmedLights = key;
5938
+ warmUp();
5939
+ }
5940
+ }
5921
5941
  const realDelta = (now - last) / 1e3;
5922
5942
  roomMetrics.frame(realDelta);
5923
5943
  const dt = Math.min(0.05, realDelta);
5924
5944
  last = now;
5925
5945
  if (!scenarioRunning) update(dt);
5926
- const restoreCamera = vfx.applyCamera();
5927
- gl.render(scene, camera);
5946
+ const restoreCamera = room.applyCamera();
5947
+ gl.render(scene, debugView ?? camera);
5928
5948
  restoreCamera();
5929
5949
  roomMetrics.rendered();
5930
5950
  };
@@ -5933,6 +5953,7 @@ function createTestRoom(options) {
5933
5953
  if (options.initial) {
5934
5954
  for (const [i, id] of options.initial.entries()) spawn(id, [(i - (options.initial.length - 1) / 2) * 1.3, 0, 0.5]);
5935
5955
  }
5956
+ if (options.avatar) setAvatar(options.avatar);
5936
5957
  if (options.area) openArea(options.area);
5937
5958
  return room;
5938
5959
  }
@@ -5940,20 +5961,21 @@ function structuredCloneSpec(spec) {
5940
5961
  return JSON.parse(JSON.stringify(spec));
5941
5962
  }
5942
5963
  export {
5943
- DEMO_ITEMS,
5944
- DEMO_MATERIALS,
5964
+ DEFAULT_HAND_STANCES,
5965
+ DEFAULT_STRIKE_CLIPS,
5966
+ LOADING_CLASS,
5967
+ LOADING_CSS,
5945
5968
  MATERIAL_COLORS,
5946
- SCENARIOS,
5947
5969
  brickWallSpec,
5948
5970
  buildFromSpec,
5971
+ createLoading,
5949
5972
  createLog,
5950
5973
  createTestRoom,
5951
5974
  decodeBenchState,
5952
- demoItems,
5953
5975
  encodeBenchState,
5976
+ loadingMarkup,
5954
5977
  locales,
5955
- makeScenarios,
5956
- resolveStrings,
5957
- scenariosFor
5978
+ nextPaint,
5979
+ resolveStrings
5958
5980
  };
5959
5981
  //# sourceMappingURL=index.js.map