@adatechnology/game-test-room 0.11.1 → 0.12.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,1254 +7,281 @@ 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
  }
786
- }
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");
284
+ if (layers.some((layer) => layer.id === id) && (text === "1" || text === "0")) out.set(id, text === "1");
1258
285
  }
1259
286
  return out;
1260
287
  }
@@ -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 {
@@ -2447,6 +1717,8 @@ function createPlayer(THREE, o) {
2447
1717
  let y = 0;
2448
1718
  let eye = EYE;
2449
1719
  let vy = 0;
1720
+ let walked = 0;
1721
+ let running = false;
2450
1722
  const px = { x: o.start[0], z: o.start[2] };
2451
1723
  let locked = false;
2452
1724
  const keys = /* @__PURE__ */ new Set();
@@ -2579,9 +1851,29 @@ function createPlayer(THREE, o) {
2579
1851
  get locked() {
2580
1852
  return locked;
2581
1853
  },
1854
+ get x() {
1855
+ return px.x;
1856
+ },
1857
+ get z() {
1858
+ return px.z;
1859
+ },
1860
+ get feet() {
1861
+ return y;
1862
+ },
1863
+ get position() {
1864
+ return [px.x, y, px.z];
1865
+ },
1866
+ get speed() {
1867
+ return walked;
1868
+ },
1869
+ get running() {
1870
+ return running;
1871
+ },
2582
1872
  keys,
2583
1873
  update(dt) {
2584
1874
  const run = keys.has("ShiftLeft") || keys.has("ShiftRight");
1875
+ const fromX = px.x;
1876
+ const fromZ = px.z;
2585
1877
  const speed = run ? RUN : WALK;
2586
1878
  let f = 0;
2587
1879
  let r = 0;
@@ -2598,6 +1890,8 @@ function createPlayer(THREE, o) {
2598
1890
  const rz = -Math.sin(yaw);
2599
1891
  move((fx * f + rx * r) / len * speed * dt, (fz * f + rz * r) / len * speed * dt);
2600
1892
  }
1893
+ walked = dt > 0 ? Math.hypot(px.x - fromX, px.z - fromZ) / dt : 0;
1894
+ running = run && walked > 0;
2601
1895
  if (y <= 0 && keys.has("Space")) vy = JUMP;
2602
1896
  if (y > 0 || vy > 0) {
2603
1897
  vy -= GRAVITY * dt;
@@ -2661,353 +1955,601 @@ function createPlayer(THREE, o) {
2661
1955
  };
2662
1956
  }
2663
1957
 
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
- ];
1958
+ // src/avatar.ts
1959
+ var DEFAULT_STRIKE_CLIPS = { fists: "punch_right", shove: "shove_two_hands", kick: "front_kick" };
1960
+ var DEFAULT_HAND_STANCES = { fists: "fists" };
1961
+ var THIRD_PERSON_DEFAULTS = {
1962
+ back: 3.8,
1963
+ side: 0.9,
1964
+ up: 1.95,
1965
+ crouchUp: 1.4,
1966
+ lift: 0.1,
1967
+ margin: 0.3,
1968
+ minDistance: 0.2,
1969
+ probes: [0, 0.2, -0.2],
1970
+ recover: 4
1971
+ };
1972
+ function thirdPersonPose(input, options, probe) {
1973
+ const cy = Math.cos(input.pitch);
1974
+ const d = [-Math.sin(input.yaw) * cy, Math.sin(input.pitch), -Math.cos(input.yaw) * cy];
1975
+ const rx = Math.cos(input.yaw);
1976
+ const rz = -Math.sin(input.yaw);
1977
+ const pivot = [input.x + rx * options.side, input.feet + (input.crouch ? options.crouchUp : options.up), input.z + rz * options.side];
1978
+ const dx = -d[0] * options.back;
1979
+ const dy = -d[1] * options.back + options.lift;
1980
+ const dz = -d[2] * options.back;
1981
+ const wanted = Math.hypot(dx, dy, dz);
1982
+ const dir = [dx / wanted, dy / wanted, dz / wanted];
1983
+ let free = wanted;
1984
+ for (const o of options.probes) {
1985
+ const hit = probe([pivot[0] + rx * o, pivot[1], pivot[2] + rz * o], dir, wanted);
1986
+ if (hit !== null) free = Math.min(free, Math.max(options.minDistance, hit - options.margin));
1987
+ }
1988
+ const at = (distance) => [pivot[0] + dir[0] * distance, Math.max(0.1, pivot[1] + dir[1] * distance), pivot[2] + dir[2] * distance];
1989
+ return { position: at(free), at, pivot, direction: d, wanted, free };
3000
1990
  }
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;
1991
+
1992
+ // src/locale.ts
1993
+ var en = {
1994
+ items: {},
1995
+ groups: { furniture: "Furniture", structure: "Structure", vehicles: "Vehicles", plants: "Plants", items: "Items", containers: "Containers" },
1996
+ text: {
1997
+ "locale.decimal": ".",
1998
+ yes: "yes",
1999
+ no: "no",
2000
+ piece: "piece",
2001
+ coords: "x {x} \xB7 z {z}",
2002
+ "count.piece.one": "1 piece",
2003
+ "count.piece.other": "{n} pieces",
2004
+ "count.joint.one": "1 joint",
2005
+ "count.joint.other": "{n} joints",
2006
+ "count.dent.one": "1 dent",
2007
+ "count.dent.other": "{n} dents",
2008
+ "count.hole.one": "1 hole",
2009
+ "count.hole.other": "{n} holes",
2010
+ "count.crack.one": "1 crack",
2011
+ "count.crack.other": "{n} cracks",
2012
+ "joint.generic": "joint",
2013
+ "lost.broken": "broke",
2014
+ "lost.melted": "melted away",
2015
+ "lost.ashes": "burned to ash",
2016
+ "tool.none": "by hand only",
2017
+ "arena.target_wall": "Target wall",
2018
+ "arena.table": "Table",
2019
+ "arena.shelf": "Shelf",
2020
+ "surface.floor": "on the floor",
2021
+ "surface.ground": "on the floor",
2022
+ "surface.table": "on the table",
2023
+ "by.break": "broken loose",
2024
+ "by.tool": "undone with a tool",
2025
+ "by.part_lost": "the part next to it was lost",
2026
+ "dmg.ballistic": "ballistic",
2027
+ "dmg.blunt": "blunt",
2028
+ "dmg.slash": "slash",
2029
+ "dmg.explosive": "blast",
2030
+ "dmg.fire": "fire",
2031
+ "dmg.heat": "heat",
2032
+ "effect.caffeine": "caffeine",
2033
+ "effect.alcohol": "alcohol",
2034
+ "effect.poison": "poison",
2035
+ "effect.disgust": "disgust",
2036
+ "ev.dent": "{where}: dent of {mm} mm, {hp}",
2037
+ "ev.hole.through": "{where}: {mm} mm hole went through, {hp}",
2038
+ "ev.hole.stuck": "{where}: {mm} mm hole, did not go through, {hp}",
2039
+ "ev.dents": "{where}: {n} dents, deepest {mm} mm, {hp}",
2040
+ "ev.holes": "{where}: {n} holes of {mm} mm, {thr} went through, {hp}",
2041
+ "ev.leak.tail": ", leaking {liquid} at {rate} l/s",
2042
+ "ev.leak.alone": "{where}: leaking {liquid} at {rate} l/s",
2043
+ "ev.crack": "{where}: cracked, {rays} rays up to {cm} cm, {hp}",
2044
+ "ev.ricochet": "{where}: bullet ricocheted, {energy} J left",
2045
+ "ev.fracture.shatter": "{where}: shattered into {n} shards",
2046
+ "ev.fracture.crumble": "{where}: crumbled into {n} chunks",
2047
+ "ev.fracture.splinter": "{where}: splintered into {n} pieces",
2048
+ "ev.fracture.tear": "{where}: torn into {n} scraps",
2049
+ "ev.fracture.snap": "{where}: snapped into {n} pieces",
2050
+ "ev.fracture.none": "{where}: broke into {n} pieces",
2051
+ "ev.detach": "{obj}: {a} came off {b} ({by}); the object now has {pieces}",
2052
+ "ev.detach.many.break": "{obj}: {joints} broke loose; the object now has {pieces}",
2053
+ "ev.detach.many.tool": "{obj}: {joints} undone with a tool; the object now has {pieces}",
2054
+ "ev.lost.tail": " and came loose from {joints}; the object now has {pieces}",
2055
+ "ev.lost.alone": "{where}: {verb} and came loose from {joints}; the object now has {pieces}",
2056
+ "ev.cracks": "{where}: cracking under the load, {n} cracks so far, longest {cm} cm, {hp}",
2057
+ "ev.disassemble": "{obj}: undoing the {kind} between {a} and {b} with {tool}: {pct}%",
2058
+ "ev.ignite": "{where}: caught fire at {temp} \xB0C",
2059
+ "ev.ignite.many": "{obj}: {n} parts caught fire at {temp} \xB0C ({parts})",
2060
+ "ev.extinguish": "{where}: fire put out",
2061
+ "ev.extinguish.many": "{obj}: fire put out on {n} parts ({parts})",
2062
+ "ev.burnout": "{where}: burned to ash",
2063
+ "ev.melt": "{where}: melting, {pct}% melted away",
2064
+ "ev.drip": "{where}: dripping molten material",
2065
+ "ev.explode": "{where}: exploded, {energy} J within {radius} m",
2066
+ "ev.destroyed": "{obj}: destroyed",
2067
+ "ev.damage": "{where}: {type} damage {amount}, {hp}",
2068
+ "ev.damage.overload": "{where}: crushed by the weight it carries, lost {amount} hp so far, {hp}",
2069
+ "ev.stress": "{where}: overloaded at {stress}% of its capacity, carrying {load} kg",
2070
+ "ev.collapse": "{obj}: collapsed, {pieces} fell ({mass} kg): {parts}",
2071
+ "ev.stain_ignite": "The {liquid} stain at x {x} \xB7 z {z} caught fire and will burn for about {seconds} s",
2072
+ "ev.stain_burned": "The burning stain burned itself out",
2073
+ "ev.fire_dries": "Fire dried the wet stains nearby",
2074
+ "fl.fill": "{who}: filled with {liquid} (+{liters} l)",
2075
+ "fl.pour": "{who}: pouring {liquid} at {rate} l/s",
2076
+ "fl.spill": "{who}: spilled {liquid} over the lip at {rate} l/s",
2077
+ "fl.leak": "{who}: leaking {liquid} at {rate} l/s",
2078
+ "fl.drink": "{who}: drank {liters} l, thirst +{thirst}",
2079
+ "fl.burn": "too hot: burned the mouth",
2080
+ "fl.empty": "{who}: empty",
2081
+ "fl.splash": "Splash of {liquid}",
2082
+ "fl.stain": "Stain of {liquid} {surface} at {where}",
2083
+ "fl.stains": "{n} stains of {liquid} {surface}, the latest at {where}",
2084
+ "fl.dried": "A stain dried up",
2085
+ "fl.dried.mark": "A stain dried and left a mark",
2086
+ "fl.dried.known": "The {liquid} stain {surface} at {where} dried up",
2087
+ "fl.dried.known.mark": "The {liquid} stain {surface} at {where} dried and left a mark",
2088
+ "fl.dried.many": "{n} {liquid} stains dried up",
2089
+ "fl.dried.many.mark": "{n} {liquid} stains dried and left marks",
2090
+ "fl.mix": "{who}: {from} mixed into {into}",
2091
+ "act.anim_start": '{name}: animation "{clip}" ({seconds} s)',
2092
+ "act.anim_event": '{name}: "{clip}" event {type}',
2093
+ "act.anim_end": '{name}: animation "{clip}" finished',
2094
+ "act.area_skip": 'Area item "{item}" is not loaded in this room: left out',
2095
+ "act.module_loaded": 'Module "{name}" loaded: {items} item(s), {scenarios} scenario(s), contract checks: {result}',
2096
+ "mod.all_ok": "all passed",
2097
+ "mod.some_failed": "some FAILED",
2098
+ "mod.destructible_ok": "{name}: destructible contract ok ({parts} parts, hit and state well-formed)",
2099
+ "mod.destructible_fail": "{name}: destructible contract broken: {problems}",
2100
+ "mod.animation_ok": '{name}: animation "{clip}" ok ({seconds} s)',
2101
+ "mod.spec_ok": "{name}: spec ok ({parts} parts)",
2102
+ "mod.spec_fail": "{name}: spec broken: {problems}",
2103
+ "mod.spec_warn": "{name}: spec warning: {warning}",
2104
+ "mod.animation_fail": '{name}: animation "{clip}" broken: {problems}',
2105
+ "ui.animations": "Animations",
2106
+ "ui.animations_aim": "Aim at an item or hold it to see its animations.",
2107
+ "ui.animations_none": "This item brings no animations.",
2108
+ "mod.bench_ok": "{name}: bench ok ({controls} controls, {layers} layers, {metrics} metrics)",
2109
+ "mod.bench_fail": "{name}: bench broken: {problems}",
2110
+ "mod.metric_failed": '{name}: metric "{metric}" threw and shows n/a: {error}',
2111
+ "mod.listener_failed": "{name}: a bench listener threw: {error}",
2112
+ "ui.bench": "Bench",
2113
+ "ui.layers": "Layers",
2114
+ "ui.metrics": "Counters",
2115
+ "metric.fps": "FPS",
2116
+ "metric.frame_ms": "ms/frame",
2117
+ "metric.draw_calls": "Draw calls",
2118
+ "metric.triangles": "Triangles",
2119
+ "metric.objects": "Objects",
2120
+ "metric.fragments": "Fragments",
2121
+ "metric.particles": "Particles",
2122
+ "metric.decals": "Decals",
2123
+ "act.spawn": "Spawned {name} at x {x} \xB7 z {z}",
2124
+ "act.clear": "Cleared the room",
2125
+ "act.reset": "Repaired every object",
2126
+ "act.tool": "Equipped {weapon}",
2127
+ "act.shoot.hit": "{weapon}: shot, {energy} J, hit {where} at {dist} m",
2128
+ "act.shoot.miss": "{weapon}: shot, {energy} J, hit nothing",
2129
+ "act.shoot.pellets": "{weapon}: shot, {n} projectiles of up to {energy} J, {hits} hit {where} at {dist} m",
2130
+ "act.melee.hit": "{weapon}: strike, {energy} J, hit {where} at {dist} m",
2131
+ "act.melee.miss": "{weapon}: struck the air",
2132
+ "act.strike": "{weapon}: {clip} begins, impact in {seconds} s",
2133
+ "act.strike.leg": "{weapon}: {clip} with the {leg} leg begins, impact in {seconds} s",
2134
+ "leg.left": "left",
2135
+ "leg.right": "right",
2136
+ "act.flame.hit": "{weapon}: flame at {temp} \xB0C on {where}",
2137
+ "act.flame.air": "{weapon}: flame, nothing in range",
2138
+ "act.heat.hit": "{weapon}: heating {where} at {temp} \xB0C",
2139
+ "act.heat.air": "{weapon}: nothing in range",
2140
+ "act.throw.grenade": "{weapon}: thrown, fuse {fuse} s",
2141
+ "act.throw.molotov": "{weapon}: thrown, breaks on impact",
2142
+ "act.launch": "{weapon}: launched, arms after {arm} m",
2143
+ "act.place": "{weapon}: charge set down, waits for {triggers}",
2144
+ "act.tripwire": "{weapon}: {who} crossed the trip wire",
2145
+ "act.dud": "{weapon}: hit before arming ({distance} m), did not explode",
2146
+ "act.backblast": "{weapon}: blast behind the tube, {radius} m, {n} objects in the cone, {player} on the shooter",
2147
+ "act.blast_player": "{weapon}: you are in the {zone} zone, {distance} m away (stun {stun} s)",
2148
+ "hud.no_charges": "No charge set down waits for the remote detonator",
2149
+ "blast.zone.lethal": "lethal",
2150
+ "blast.zone.injury": "injury",
2151
+ "act.explode": "{weapon}: exploded, {energy} J within {radius} m, {n} objects in range",
2152
+ "act.molotov_fire": "{weapon}: fire on the ground, radius {radius} m for {seconds} s",
2153
+ "act.tool.none": "{weapon}: nothing in reach",
2154
+ "act.tool.nojoint": "{weapon}: no joint near {where}",
2155
+ "act.tool.refused": "{weapon} cannot undo the {kind} of {obj}; it needs: {need}",
2156
+ "act.extinguish.hit": "{weapon}: foam on {what}",
2157
+ "act.extinguish.air": "{weapon}: foam, nothing in range",
2158
+ "act.grab": "Grabbed {name} ({kg} kg)",
2159
+ "act.drop": "Dropped {name}",
2160
+ "act.throw": "Threw {name} at {speed} m/s",
2161
+ "act.landing": "{name} landed at {speed} m/s: {energy} J",
2162
+ "act.bench_failed": "{name} failed: {error}",
2163
+ "loading.working": "Working\u2026",
2164
+ "loading.open_area": "Opening {area}\u2026",
2165
+ "loading.spawn": "Building {name}\u2026",
2166
+ "act.loosened": "{name} was heavy but free: the blast moved it",
2167
+ "act.impact": "{name} hit {where} at {speed} m/s: {energy} J of blunt damage",
2168
+ "act.use_item": "{name}: {use}",
2169
+ "act.tap.on": "Tap opened",
2170
+ "act.tap.off": "Tap closed",
2171
+ "use.drink": "drink",
2172
+ "use.sip": "sip",
2173
+ "use.chug": "chug",
2174
+ "use.pour": "pour",
2175
+ "use.pour_out": "pour out",
2176
+ "use.open": "open",
2177
+ "use.close": "close",
2178
+ "hud.reloading": "Reloading...",
2179
+ "hud.empty": "Empty",
2180
+ "hud.no_fuel": "Out of fuel",
2181
+ "hud.fault": "Jammed ({fault}): press X to clear",
2182
+ "hud.safe": "Safety on: press B",
2183
+ "hud.clearing": "Clearing the jam...",
2184
+ "hud.safety_on": "Safety on",
2185
+ "hud.safety_off": "Safety off",
2186
+ "fault.misfire": "misfire",
2187
+ "fault.stovepipe": "stovepipe",
2188
+ "fault.double_feed": "double feed",
2189
+ "fault.failure_to_extract": "failure to extract",
2190
+ "hud.nothing_to_grab": "Nothing to grab here",
2191
+ "hud.cannot_grab": "{name} cannot be picked up",
2192
+ "hud.too_heavy": "{name} is too heavy ({kg} kg)",
2193
+ "hud.empty_container": "{name} is empty",
2194
+ "hud.refused": "Wrong tool for the {kind}",
2195
+ "hud.drop": "drop",
2196
+ "hud.rotate": "rotate",
2197
+ "hud.throw": "throw",
2198
+ "hud.tilt": "tilt (hold)",
2199
+ "hud.equip": "equip {weapon}",
2200
+ "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",
2201
+ "ui.help.camera": "V: 1st/3rd person (Shift+V: kick)",
2202
+ "camera.first": "1st person",
2203
+ "camera.third": "3rd person",
2204
+ "act.camera": "View: {mode}",
2205
+ "ui.log": "Behaviour log",
2206
+ "ui.filter.action": "actions",
2207
+ "ui.filter.event": "events",
2208
+ "ui.filter.check": "checks",
2209
+ "ui.autoscroll": "autoscroll",
2210
+ "log.export_cancelled": "export cancelled",
2211
+ "ui.copy_json": "Copy JSON",
2212
+ "ui.download_ndjson": "Download NDJSON",
2213
+ "ui.download_txt": "Download TXT",
2214
+ "ui.clear_log": "Clear",
2215
+ "ui.catalog": "Catalogue",
2216
+ "ui.clear": "Clear",
2217
+ "ui.reset_all": "Reset all",
2218
+ "ui.slow": "Slow motion 0.25\xD7",
2219
+ "ui.fast": "Time \xD710",
2220
+ "ui.tap": "Tap",
2221
+ "ui.blast.shelter": "Shelter",
2222
+ "ui.blast.fragmentHits": "Fragments",
2223
+ "ui.blast.confinement": "Confinement",
2224
+ "ui.blast.shelter.hint": "Blasts: walls and other parts between the blast and the target take their share",
2225
+ "ui.blast.fragmentHits.hint": "Blasts: the pieces of what breaks fly on and hit what is behind",
2226
+ "ui.blast.confinement.hint": "Blasts: pressure builds up in an enclosed room",
2227
+ "metric.blast_shelter": "Blast reaching (%)",
2228
+ "metric.blast_confinement": "Blast pressure (x)",
2229
+ "metric.blast_fragments": "Fragment hits",
2230
+ "act.blast_report": "Blast: {shelter}% reached the most sheltered target, pressure x{confinement}, {fragments} fragment hits, {n} targets",
2231
+ "ui.quality": "Quality",
2232
+ "ui.quality.low": "low",
2233
+ "ui.quality.medium": "medium",
2234
+ "ui.quality.high": "high",
2235
+ "ui.equip": "Equip as a tool",
2236
+ "ui.search_items": "Search items (all groups)",
2237
+ "ui.search_tests": "Search tests",
2238
+ "ui.no_results": "Nothing found",
2239
+ "ui.objects": "{n} objects",
2240
+ "ui.tests": "Tests",
2241
+ "ui.run_all": "Run all",
2242
+ "ui.running": "Running...",
2243
+ "ui.report": "{passed} passed, {failed} failed",
2244
+ "ui.panel": "Panels",
2245
+ "ui.inspector": "Inspector",
2246
+ "ui.aim_at_something": "Aim at an object",
2247
+ "ins.material": "Material",
2248
+ "ins.status": "Status",
2249
+ "ins.burning": "burning",
2250
+ "ins.hp": "Hit points",
2251
+ "ins.temperature": "Temperature",
2252
+ "ins.char": "Charred",
2253
+ "ins.melt": "Melted",
2254
+ "ins.liters": "Liquid",
2255
+ "ins.damage": "Marks",
2256
+ "ins.damage_counts": "{dents}, {holes}, {cracks}",
2257
+ "ins.mass": "Mass",
2258
+ "ins.load": "Load",
2259
+ "ins.stress": "Stress",
2260
+ "ins.joints": "Joints",
2261
+ "ins.open": "open",
2262
+ "sc.start": "Scenario: {name}",
2263
+ "sc.pass": "Scenario passed: {name}",
2264
+ "sc.fail": "Scenario FAILED: {name}",
2265
+ "sc.error": "the scenario threw an error: {msg}",
2266
+ "leg.still": "leg_1 still attached",
2267
+ "leg.gone": "leg_1 came off",
2268
+ "roof.standing": "roof standing",
2269
+ "roof.fell": "roof fell"
2270
+ }
2271
+ };
2272
+ var ptBR = {
2273
+ items: {},
2274
+ groups: { furniture: "M\xF3veis", structure: "Estrutura", vehicles: "Ve\xEDculos", plants: "Plantas", items: "Itens", containers: "Recipientes" },
2275
+ text: {
2276
+ "locale.decimal": ",",
2277
+ yes: "sim",
2278
+ no: "n\xE3o",
2279
+ piece: "pe\xE7a",
2280
+ coords: "x {x} \xB7 z {z}",
2281
+ "count.piece.one": "1 pe\xE7a",
2282
+ "count.piece.other": "{n} pe\xE7as",
2283
+ "count.joint.one": "1 encaixe",
2284
+ "count.joint.other": "{n} encaixes",
2285
+ "count.dent.one": "1 amassado",
2286
+ "count.dent.other": "{n} amassados",
2287
+ "count.hole.one": "1 furo",
2288
+ "count.hole.other": "{n} furos",
2289
+ "count.crack.one": "1 trinca",
2290
+ "count.crack.other": "{n} trincas",
2291
+ "joint.generic": "junta",
2292
+ "lost.broken": "quebrou",
2293
+ "lost.melted": "derreteu",
2294
+ "lost.ashes": "virou cinza",
2295
+ "tool.none": "s\xF3 com a m\xE3o",
2296
+ "arena.target_wall": "Parede de alvo",
2297
+ "arena.table": "Mesa",
2298
+ "arena.shelf": "Estante",
2299
+ "surface.floor": "no ch\xE3o",
2300
+ "surface.ground": "no ch\xE3o",
2301
+ "surface.table": "na mesa",
2302
+ "by.break": "arrebentou",
2303
+ "by.tool": "desmontado com ferramenta",
2304
+ "by.part_lost": "a pe\xE7a ao lado se perdeu",
2305
+ "dmg.ballistic": "de tiro",
2306
+ "dmg.blunt": "de impacto",
2307
+ "dmg.slash": "de corte",
2308
+ "dmg.explosive": "de explos\xE3o",
2309
+ "dmg.fire": "de fogo",
2310
+ "dmg.heat": "de calor",
2311
+ "effect.caffeine": "cafe\xEDna",
2312
+ "effect.alcohol": "\xE1lcool",
2313
+ "effect.poison": "veneno",
2314
+ "effect.disgust": "nojo",
2315
+ "ev.dent": "{where}: amassado de {mm} mm, {hp}",
2316
+ "ev.hole.through": "{where}: furo de {mm} mm atravessou, {hp}",
2317
+ "ev.hole.stuck": "{where}: furo de {mm} mm n\xE3o atravessou, {hp}",
2318
+ "ev.dents": "{where}: {n} amassados, o mais fundo com {mm} mm, {hp}",
2319
+ "ev.holes": "{where}: {n} furos de {mm} mm, {thr} atravessaram, {hp}",
2320
+ "ev.leak.tail": ", vazando {liquid} a {rate} l/s",
2321
+ "ev.leak.alone": "{where}: vazando {liquid} a {rate} l/s",
2322
+ "ev.crack": "{where}: trincou, {rays} raios de at\xE9 {cm} cm, {hp}",
2323
+ "ev.ricochet": "{where}: a bala ricocheteou, sobraram {energy} J",
2324
+ "ev.fracture.shatter": "{where}: estilha\xE7ou em {n} cacos",
2325
+ "ev.fracture.crumble": "{where}: esfarelou em {n} peda\xE7os",
2326
+ "ev.fracture.splinter": "{where}: lascou em {n} peda\xE7os",
2327
+ "ev.fracture.tear": "{where}: rasgou em {n} retalhos",
2328
+ "ev.fracture.snap": "{where}: partiu em {n} peda\xE7os",
2329
+ "ev.fracture.none": "{where}: quebrou em {n} peda\xE7os",
2330
+ "ev.detach": "{obj}: {a} soltou de {b} ({by}); o objeto agora tem {pieces}",
2331
+ "ev.detach.many.break": "{obj}: {joints} arrebentaram; o objeto agora tem {pieces}",
2332
+ "ev.detach.many.tool": "{obj}: {joints} soltos com ferramenta; o objeto agora tem {pieces}",
2333
+ "ev.lost.tail": " e se soltou de {joints}; o objeto agora tem {pieces}",
2334
+ "ev.lost.alone": "{where}: {verb} e se soltou de {joints}; o objeto agora tem {pieces}",
2335
+ "ev.cracks": "{where}: rachando sob o peso, {n} trincas at\xE9 agora, a maior com {cm} cm, {hp}",
2336
+ "ev.disassemble": "{obj}: soltando {kind} entre {a} e {b} com {tool}: {pct}%",
2337
+ "ev.ignite": "{where}: pegou fogo a {temp} \xB0C",
2338
+ "ev.ignite.many": "{obj}: {n} pe\xE7as pegaram fogo a {temp} \xB0C ({parts})",
2339
+ "ev.extinguish": "{where}: fogo apagado",
2340
+ "ev.extinguish.many": "{obj}: fogo apagado em {n} pe\xE7as ({parts})",
2341
+ "ev.burnout": "{where}: virou cinza",
2342
+ "ev.melt": "{where}: derretendo, {pct}% j\xE1 derreteu",
2343
+ "ev.drip": "{where}: pingando material derretido",
2344
+ "ev.explode": "{where}: explodiu, {energy} J em {radius} m",
2345
+ "ev.destroyed": "{obj}: destru\xEDdo",
2346
+ "ev.damage": "{where}: dano {type} de {amount}, {hp}",
2347
+ "ev.damage.overload": "{where}: esmagado pelo peso que sustenta, perdeu {amount} hp at\xE9 agora, {hp}",
2348
+ "ev.stress": "{where}: sobrecarregado a {stress}% da capacidade, sustentando {load} kg",
2349
+ "ev.collapse": "{obj}: desabou, ca\xEDram {pieces} ({mass} kg): {parts}",
2350
+ "ev.stain_ignite": "A mancha de {liquid} em x {x} \xB7 z {z} pegou fogo e vai queimar por uns {seconds} s",
2351
+ "ev.stain_burned": "A mancha em chamas se consumiu",
2352
+ "ev.fire_dries": "O fogo secou as manchas molhadas por perto",
2353
+ "fl.fill": "{who}: encheu de {liquid} (+{liters} l)",
2354
+ "fl.pour": "{who}: despejando {liquid} a {rate} l/s",
2355
+ "fl.spill": "{who}: derramou {liquid} pela borda a {rate} l/s",
2356
+ "fl.leak": "{who}: vazando {liquid} a {rate} l/s",
2357
+ "fl.drink": "{who}: bebeu {liters} l, sede +{thirst}",
2358
+ "fl.burn": "quente demais: queimou a boca",
2359
+ "fl.empty": "{who}: vazio",
2360
+ "fl.splash": "Respingo de {liquid}",
2361
+ "fl.stain": "Mancha de {liquid} {surface} em {where}",
2362
+ "fl.stains": "{n} manchas de {liquid} {surface}, a \xFAltima em {where}",
2363
+ "fl.dried": "Uma mancha secou",
2364
+ "fl.dried.mark": "Uma mancha secou e deixou marca",
2365
+ "fl.dried.known": "A mancha de {liquid} {surface} em {where} secou",
2366
+ "fl.dried.known.mark": "A mancha de {liquid} {surface} em {where} secou e deixou marca",
2367
+ "fl.dried.many": "{n} manchas de {liquid} secaram",
2368
+ "fl.dried.many.mark": "{n} manchas de {liquid} secaram e deixaram marca",
2369
+ "fl.mix": "{who}: {from} misturou em {into}",
2370
+ "act.anim_start": '{name}: anima\xE7\xE3o "{clip}" ({seconds} s)',
2371
+ "act.anim_event": '{name}: "{clip}" evento {type}',
2372
+ "act.anim_end": '{name}: anima\xE7\xE3o "{clip}" terminou',
2373
+ "act.area_skip": 'Item "{item}" da \xE1rea n\xE3o est\xE1 carregado nesta sala: ficou de fora',
2374
+ "act.module_loaded": 'M\xF3dulo "{name}" carregado: {items} item(ns), {scenarios} cen\xE1rio(s), verifica\xE7\xF5es do contrato: {result}',
2375
+ "mod.all_ok": "todas passaram",
2376
+ "mod.some_failed": "algumas FALHARAM",
2377
+ "mod.destructible_ok": "{name}: contrato de destrui\xE7\xE3o ok ({parts} pe\xE7as, golpe e estado bem formados)",
2378
+ "mod.destructible_fail": "{name}: contrato de destrui\xE7\xE3o quebrado: {problems}",
2379
+ "mod.animation_ok": '{name}: anima\xE7\xE3o "{clip}" ok ({seconds} s)',
2380
+ "mod.spec_ok": "{name}: spec ok ({parts} pe\xE7as)",
2381
+ "mod.spec_fail": "{name}: spec quebrada: {problems}",
2382
+ "mod.spec_warn": "{name}: aviso na spec: {warning}",
2383
+ "mod.animation_fail": '{name}: anima\xE7\xE3o "{clip}" quebrada: {problems}',
2384
+ "ui.animations": "Anima\xE7\xF5es",
2385
+ "ui.animations_aim": "Mire num item ou segure-o para ver as anima\xE7\xF5es.",
2386
+ "ui.animations_none": "Este item n\xE3o traz anima\xE7\xF5es.",
2387
+ "mod.bench_ok": "{name}: bancada ok ({controls} controles, {layers} camadas, {metrics} contadores)",
2388
+ "mod.bench_fail": "{name}: bancada quebrada: {problems}",
2389
+ "mod.metric_failed": '{name}: o contador "{metric}" lan\xE7ou erro e mostra n/a: {error}',
2390
+ "mod.listener_failed": "{name}: um ouvinte da bancada lan\xE7ou erro: {error}",
2391
+ "ui.bench": "Bancada",
2392
+ "ui.layers": "Camadas",
2393
+ "ui.metrics": "Contadores",
2394
+ "metric.fps": "FPS",
2395
+ "metric.frame_ms": "ms/quadro",
2396
+ "metric.draw_calls": "Chamadas de desenho",
2397
+ "metric.triangles": "Tri\xE2ngulos",
2398
+ "metric.objects": "Objetos",
2399
+ "metric.fragments": "Fragmentos",
2400
+ "metric.particles": "Part\xEDculas",
2401
+ "metric.decals": "Decalques",
2402
+ "act.spawn": "Criou {name} em x {x} \xB7 z {z}",
2403
+ "act.clear": "Limpou a sala",
2404
+ "act.reset": "Consertou todos os objetos",
2405
+ "act.tool": "Equipou {weapon}",
2406
+ "act.shoot.hit": "{weapon}: disparo, {energy} J, acertou {where} a {dist} m",
2407
+ "act.shoot.miss": "{weapon}: disparo, {energy} J, n\xE3o acertou nada",
2408
+ "act.shoot.pellets": "{weapon}: disparo, {n} proj\xE9teis de at\xE9 {energy} J, {hits} acertaram {where} a {dist} m",
2409
+ "act.melee.hit": "{weapon}: golpe, {energy} J, acertou {where} a {dist} m",
2410
+ "act.melee.miss": "{weapon}: golpe no ar",
2411
+ "act.strike": "{weapon}: {clip} come\xE7a, impacto em {seconds} s",
2412
+ "act.strike.leg": "{weapon}: {clip} com a perna {leg} come\xE7a, impacto em {seconds} s",
2413
+ "leg.left": "esquerda",
2414
+ "leg.right": "direita",
2415
+ "act.flame.hit": "{weapon}: chama a {temp} \xB0C em {where}",
2416
+ "act.flame.air": "{weapon}: chama, nada ao alcance",
2417
+ "act.heat.hit": "{weapon}: esquentando {where} a {temp} \xB0C",
2418
+ "act.heat.air": "{weapon}: nada ao alcance",
2419
+ "act.throw.grenade": "{weapon}: arremessada, pavio de {fuse} s",
2420
+ "act.throw.molotov": "{weapon}: arremessado, quebra no impacto",
2421
+ "act.launch": "{weapon}: disparado, arma depois de {arm} m",
2422
+ "act.place": "{weapon}: carga posta, espera {triggers}",
2423
+ "act.tripwire": "{weapon}: {who} cruzou o fio",
2424
+ "act.dud": "{weapon}: bateu antes de armar ({distance} m), n\xE3o explodiu",
2425
+ "act.backblast": "{weapon}: sopro atr\xE1s do tubo, {radius} m, {n} objetos no cone, {player} no atirador",
2426
+ "act.blast_player": "{weapon}: voc\xEA est\xE1 na zona {zone}, a {distance} m (atordoado {stun} s)",
2427
+ "hud.no_charges": "Nenhuma carga posta espera o detonador",
2428
+ "blast.zone.lethal": "letal",
2429
+ "blast.zone.injury": "de ferimento",
2430
+ "act.explode": "{weapon}: explodiu, {energy} J em {radius} m, {n} objetos ao alcance",
2431
+ "act.molotov_fire": "{weapon}: fogo no ch\xE3o, raio de {radius} m por {seconds} s",
2432
+ "act.tool.none": "{weapon}: nada ao alcance",
2433
+ "act.tool.nojoint": "{weapon}: nenhuma junta perto de {where}",
2434
+ "act.tool.refused": "{weapon} n\xE3o solta {kind} de {obj}; precisa de: {need}",
2435
+ "act.extinguish.hit": "{weapon}: espuma em {what}",
2436
+ "act.extinguish.air": "{weapon}: espuma, nada ao alcance",
2437
+ "act.grab": "Pegou {name} ({kg} kg)",
2438
+ "act.drop": "Soltou {name}",
2439
+ "act.throw": "Arremessou {name} a {speed} m/s",
2440
+ "act.landing": "{name} caiu a {speed} m/s: {energy} J",
2441
+ "act.bench_failed": "{name} falhou: {error}",
2442
+ "loading.working": "Trabalhando\u2026",
2443
+ "loading.open_area": "Abrindo {area}\u2026",
2444
+ "loading.spawn": "Montando {name}\u2026",
2445
+ "act.loosened": "{name} \xE9 pesado mas estava solto: a explos\xE3o o moveu",
2446
+ "act.impact": "{name} bateu em {where} a {speed} m/s: {energy} J de dano de impacto",
2447
+ "act.use_item": "{name}: {use}",
2448
+ "act.tap.on": "Abriu a torneira",
2449
+ "act.tap.off": "Fechou a torneira",
2450
+ "use.drink": "beber",
2451
+ "use.sip": "golinho",
2452
+ "use.chug": "virar",
2453
+ "use.pour": "despejar",
2454
+ "use.pour_out": "esvaziar",
2455
+ "use.open": "abrir",
2456
+ "use.close": "fechar",
2457
+ "hud.reloading": "Recarregando...",
2458
+ "hud.empty": "Sem muni\xE7\xE3o",
2459
+ "hud.no_fuel": "Sem combust\xEDvel",
2460
+ "hud.fault": "Travou ({fault}): aperte X para destravar",
2461
+ "hud.safe": "Trava ligada: aperte B",
2462
+ "hud.clearing": "Destravando...",
2463
+ "hud.safety_on": "Trava ligada",
2464
+ "hud.safety_off": "Trava desligada",
2465
+ "fault.misfire": "nega",
2466
+ "fault.stovepipe": "chamin\xE9",
2467
+ "fault.double_feed": "dupla alimenta\xE7\xE3o",
2468
+ "fault.failure_to_extract": "falha de extra\xE7\xE3o",
2469
+ "hud.nothing_to_grab": "Nada para pegar aqui",
2470
+ "hud.cannot_grab": "{name} n\xE3o d\xE1 para pegar",
2471
+ "hud.too_heavy": "{name} \xE9 pesado demais ({kg} kg)",
2472
+ "hud.empty_container": "{name} est\xE1 vazio",
2473
+ "hud.refused": "Ferramenta errada para {kind}",
2474
+ "hud.drop": "soltar",
2475
+ "hud.rotate": "girar",
2476
+ "hud.throw": "arremessar",
2477
+ "hud.tilt": "inclinar (segurar)",
2478
+ "hud.equip": "equipar {weapon}",
2479
+ "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",
2480
+ "ui.help.camera": "V: 1\xAA/3\xAA pessoa (Shift+V: chute)",
2481
+ "camera.first": "1\xAA pessoa",
2482
+ "camera.third": "3\xAA pessoa",
2483
+ "act.camera": "C\xE2mera: {mode}",
2484
+ "ui.log": "Registro de comportamento",
2485
+ "ui.filter.action": "a\xE7\xF5es",
2486
+ "ui.filter.event": "eventos",
2487
+ "ui.filter.check": "testes",
2488
+ "ui.autoscroll": "rolar sozinho",
2489
+ "log.export_cancelled": "exporta\xE7\xE3o cancelada",
2490
+ "ui.copy_json": "Copiar JSON",
2491
+ "ui.download_ndjson": "Baixar NDJSON",
2492
+ "ui.download_txt": "Baixar TXT",
2493
+ "ui.clear_log": "Limpar",
2494
+ "ui.catalog": "Cat\xE1logo",
2495
+ "ui.clear": "Limpar",
2496
+ "ui.reset_all": "Consertar tudo",
2497
+ "ui.slow": "C\xE2mera lenta 0,25\xD7",
2498
+ "ui.fast": "Tempo \xD710",
2499
+ "ui.tap": "Torneira",
2500
+ "ui.blast.shelter": "Abrigo",
2501
+ "ui.blast.fragmentHits": "Estilha\xE7os",
2502
+ "ui.blast.confinement": "Confinamento",
2503
+ "ui.blast.shelter.hint": "Explos\xF5es: paredes e outras partes entre a explos\xE3o e o alvo ficam com a sua parte",
2504
+ "ui.blast.fragmentHits.hint": "Explos\xF5es: os peda\xE7os do que quebra voam e acertam o que est\xE1 atr\xE1s",
2505
+ "ui.blast.confinement.hint": "Explos\xF5es: a press\xE3o sobe num ambiente fechado",
2506
+ "metric.blast_shelter": "Explos\xE3o que chegou (%)",
2507
+ "metric.blast_confinement": "Press\xE3o da explos\xE3o (x)",
2508
+ "metric.blast_fragments": "Estilha\xE7os que acertaram",
2509
+ "act.blast_report": "Explos\xE3o: {shelter}% chegou ao alvo mais abrigado, press\xE3o x{confinement}, {fragments} estilha\xE7os acertaram, {n} alvos",
2510
+ "ui.quality": "Qualidade",
2511
+ "ui.quality.low": "baixa",
2512
+ "ui.quality.medium": "m\xE9dia",
2513
+ "ui.quality.high": "alta",
2514
+ "ui.equip": "Equipar como ferramenta",
2515
+ "ui.search_items": "Buscar item (todas as abas)",
2516
+ "ui.search_tests": "Buscar teste",
2517
+ "ui.no_results": "Nada encontrado",
2518
+ "ui.objects": "{n} objetos",
2519
+ "ui.tests": "Testes",
2520
+ "ui.run_all": "Rodar todos",
2521
+ "ui.running": "Rodando...",
2522
+ "ui.report": "{passed} passaram, {failed} falharam",
2523
+ "ui.panel": "Pain\xE9is",
2524
+ "ui.inspector": "Inspetor",
2525
+ "ui.aim_at_something": "Mire em um objeto",
2526
+ "ins.material": "Material",
2527
+ "ins.status": "Estado",
2528
+ "ins.burning": "em chamas",
2529
+ "ins.hp": "Pontos de vida",
2530
+ "ins.temperature": "Temperatura",
2531
+ "ins.char": "Carbonizado",
2532
+ "ins.melt": "Derretido",
2533
+ "ins.liters": "L\xEDquido",
2534
+ "ins.damage": "Marcas",
2535
+ "ins.damage_counts": "{dents}, {holes}, {cracks}",
2536
+ "ins.mass": "Massa",
2537
+ "ins.load": "Carga",
2538
+ "ins.stress": "Tens\xE3o",
2539
+ "ins.joints": "Juntas",
2540
+ "ins.open": "aberta",
2541
+ "sc.start": "Cen\xE1rio: {name}",
2542
+ "sc.pass": "Cen\xE1rio passou: {name}",
2543
+ "sc.fail": "Cen\xE1rio FALHOU: {name}",
2544
+ "sc.error": "o cen\xE1rio lan\xE7ou um erro: {msg}",
2545
+ "leg.still": "leg_1 continua presa",
2546
+ "leg.gone": "leg_1 soltou",
2547
+ "roof.standing": "laje de p\xE9",
2548
+ "roof.fell": "laje caiu"
2549
+ }
3005
2550
  };
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");
2551
+ var locales = { en, "pt-BR": ptBR };
2552
+ var resolveStrings = (locale) => locales[locale ?? "en"] ?? locales.en;
3011
2553
 
3012
2554
  // src/bench-panel.ts
3013
2555
  var SEGMENTED_MAX = 4;
@@ -3758,6 +3300,9 @@ function createUi(o) {
3758
3300
  toolBox.textContent = "";
3759
3301
  toolBox.append(document.createTextNode(s.toolName));
3760
3302
  if (s.ammo) toolBox.append(mk2("small", void 0, s.ammo));
3303
+ if (s.camera) toolBox.append(mk2("small", void 0, t(`camera.${s.camera}`)));
3304
+ const helpText = s.camera ? `${t("ui.help")} \xB7 ${t("ui.help.camera")}` : t("ui.help");
3305
+ if (help.textContent !== helpText) help.textContent = helpText;
3761
3306
  if (s.status) status.textContent = s.status;
3762
3307
  else if (!toastTimer) status.textContent = "";
3763
3308
  if (s.progress) {
@@ -3847,6 +3392,10 @@ function createUi(o) {
3847
3392
  // src/room.ts
3848
3393
  var FIXED_DT = 1 / 60;
3849
3394
  var METRICS_INTERVAL_S = 0.25;
3395
+ var CARRY_NEAR = 0.62;
3396
+ var CARRY_PALM = 0.012;
3397
+ var CARRY_GRIP = 0.45;
3398
+ var MAX_STRIDE_SPEED = 10;
3850
3399
  var v3 = (v) => [v.x, v.y, v.z];
3851
3400
  var dist3 = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
3852
3401
  function createTestRoom(options) {
@@ -3860,6 +3409,7 @@ function createTestRoom(options) {
3860
3409
  if (vars) for (const k of Object.keys(vars)) s = s.split(`{${k}}`).join(String(vars[k]));
3861
3410
  return s;
3862
3411
  };
3412
+ const loading = createLoading({ container: options.container, draw: options.ui !== false, working: t("loading.working") });
3863
3413
  const num = (n, digits = 1) => {
3864
3414
  const s = (Math.round(n * 10 ** digits) / 10 ** digits).toFixed(digits);
3865
3415
  return locale.toLowerCase().startsWith("pt") ? s.replace(".", ",") : s;
@@ -3895,13 +3445,13 @@ function createTestRoom(options) {
3895
3445
  const onResize = () => resize();
3896
3446
  window.addEventListener("resize", onResize);
3897
3447
  const arenaSize = options.arenaSize ?? 14;
3898
- const arena = createArena(THREE, arenaSize, scene);
3448
+ const arena = createArena(THREE, arenaSize, scene, options.scenery, options.tap);
3899
3449
  scene.add(arena.group);
3900
3450
  const ownWorld = !options.world;
3901
- const world = options.world ?? createDestruction({ seed: 1, ...options.worldOptions, materials: [...DEMO_MATERIALS, ...options.worldOptions?.materials ?? []] });
3451
+ const world = options.world ?? createDestruction({ seed: 1, ...options.worldOptions, materials: [...options.worldOptions?.materials ?? []] });
3902
3452
  const pristineWorld = ownWorld ? world.serialize() : null;
3903
- const tableOk = () => {
3904
- const p = world.part(arena.table.id, arena.table.topPart);
3453
+ const topOk = (t2) => {
3454
+ const p = world.part(t2.id, t2.topPart);
3905
3455
  return !!p && p.status !== "broken" && p.status !== "melted" && p.status !== "ashes";
3906
3456
  };
3907
3457
  const restingTops = [];
@@ -3910,7 +3460,12 @@ function createTestRoom(options) {
3910
3460
  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
3461
  return top;
3912
3462
  };
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));
3463
+ const sceneryTopAt = (x, z) => {
3464
+ let top = 0;
3465
+ 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;
3466
+ return top;
3467
+ };
3468
+ const groundAt = (x, z) => Math.max(sceneryTopAt(x, z), restingTopAt(x, z));
3914
3469
  const ownFluids = !options.fluids;
3915
3470
  const fluids = options.fluids ?? createFluids({ seed: 1, locale, liquids: options.liquids, groundAt });
3916
3471
  const pristineFluids = ownFluids ? fluids.serialize() : null;
@@ -3958,6 +3513,19 @@ function createTestRoom(options) {
3958
3513
  groundAt,
3959
3514
  pointScale,
3960
3515
  onSound: playSound,
3516
+ ...options.blastShove ? { blastShove: options.blastShove } : {},
3517
+ // a blast moves what is heavy but not nailed down: the renderer makes it a piece the moment the shove reaches it
3518
+ shoveable: (objectId) => {
3519
+ const e = entries.get(objectId);
3520
+ return e?.free === true && !e.piece;
3521
+ },
3522
+ onLoosened: (objectId, piece) => {
3523
+ const e = entries.get(objectId);
3524
+ if (!e) return;
3525
+ e.piece = piece;
3526
+ rendererPieces.set(objectId, piece);
3527
+ action("loosened", t("act.loosened", { name: e.name }), { objectId, itemId: e.so.itemId });
3528
+ },
3961
3529
  onPiece: (piece) => {
3962
3530
  rendererPieces.set(piece.objectId, piece);
3963
3531
  loose.push(piece);
@@ -4012,7 +3580,6 @@ function createTestRoom(options) {
4012
3580
  };
4013
3581
  let tool = toolState(toolId);
4014
3582
  const catalogMap = /* @__PURE__ */ new Map();
4015
- for (const i of demoItems(locale)) catalogMap.set(i.id, i);
4016
3583
  for (const i of options.catalog) catalogMap.set(i.id, i);
4017
3584
  const catalog = [...options.catalog];
4018
3585
  const entryOf = (objectId) => entries.get(objectId) ?? splitOf.get(objectId);
@@ -4052,7 +3619,7 @@ function createTestRoom(options) {
4052
3619
  });
4053
3620
  let pending = [];
4054
3621
  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 };
3622
+ 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
3623
  const fire = (type, data) => {
4057
3624
  for (const fn of listeners[type] ?? []) fn(data);
4058
3625
  };
@@ -4179,19 +3746,38 @@ function createTestRoom(options) {
4179
3746
  };
4180
3747
  const addArena = () => {
4181
3748
  for (const o of arena.objects) {
4182
- const root = buildFromSpec(THREE, o.spec, { colors: o.colors });
3749
+ const root = o.build ? o.build(THREE) : buildFromSpec(THREE, o.spec, { colors: o.colors });
4183
3750
  root.position.set(o.position[0], o.position[1], o.position[2]);
3751
+ root.rotation.y = o.rotationY;
4184
3752
  scene.add(root);
4185
3753
  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);
3754
+ 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)] } : {} });
3755
+ registerEntry(o.id, o.id, root, o.spec, o.label ?? S.text[`arena.${o.id}`] ?? o.id, void 0, void 0, true, false);
4188
3756
  }
4189
3757
  };
4190
3758
  addArena();
3759
+ const wrapped = /* @__PURE__ */ new WeakMap();
3760
+ const letGo = (root) => {
3761
+ const inner = wrapped.get(root);
3762
+ if (!inner) return;
3763
+ inner.removeFromParent();
3764
+ wrapped.delete(root);
3765
+ };
3766
+ const buildRoot = (item) => {
3767
+ const built = item.build(THREE);
3768
+ 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;
3769
+ const holder = new THREE.Group();
3770
+ holder.name = built.name;
3771
+ holder.userData = built.userData;
3772
+ holder.add(built);
3773
+ wrapped.set(holder, built);
3774
+ Object.defineProperty(holder, "visible", { get: () => built.visible, set: (v) => void (built.visible = v), configurable: true });
3775
+ return holder;
3776
+ };
4191
3777
  const spawn = (itemId, position, rotationY) => {
4192
3778
  const item = catalogMap.get(itemId);
4193
3779
  if (!item) throw new Error(`game-test-room: unknown item "${itemId}"`);
4194
- const root = item.build(THREE);
3780
+ const root = buildRoot(item);
4195
3781
  let pos;
4196
3782
  if (position) pos = [position[0], position[1], position[2]];
4197
3783
  else {
@@ -4234,6 +3820,7 @@ function createTestRoom(options) {
4234
3820
  if (own) world.register(own, { id: objectId, position: pos, rotation: [q.x, q.y, q.z, q.w] });
4235
3821
  else world.add(spec, { id: objectId, position: pos, rotation: [q.x, q.y, q.z, q.w] });
4236
3822
  const e = registerEntry(objectId, item.id, root, spec, item.name, parts, item, false, movable);
3823
+ if (!own && !movable && item.movable === void 0) e.free = true;
4237
3824
  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
3825
  fire("spawn", e.so);
4239
3826
  return e.so;
@@ -4262,6 +3849,7 @@ function createTestRoom(options) {
4262
3849
  }
4263
3850
  if (world.has(id)) world.remove(id);
4264
3851
  e.so.root.removeFromParent();
3852
+ letGo(e.so.root);
4265
3853
  entries.delete(id);
4266
3854
  rendererPieces.delete(id);
4267
3855
  specs.delete(id);
@@ -4336,6 +3924,7 @@ function createTestRoom(options) {
4336
3924
  for (const s of [...arena.surfaces, ...options.surfaces ?? []]) fluids.addSurface(s);
4337
3925
  }
4338
3926
  pending = [];
3927
+ strikes = [];
4339
3928
  rng = 12345;
4340
3929
  counter = 0;
4341
3930
  slot = 0;
@@ -4348,6 +3937,104 @@ function createTestRoom(options) {
4348
3937
  };
4349
3938
  const aimO = [0, 0, 0];
4350
3939
  const aimD = [0, 0, -1];
3940
+ const third = { ...THIRD_PERSON_DEFAULTS, ...options.thirdPerson };
3941
+ let avatar = null;
3942
+ let cameraMode = options.cameraMode ?? "first";
3943
+ const avatarState = { speed: 0, running: false, crouch: false, down: false, armed: false, pitch: 0 };
3944
+ let feetPrev = null;
3945
+ let armedForced = null;
3946
+ let camDistance = Infinity;
3947
+ const tmpHand = new THREE.Vector3();
3948
+ const thirdPose = (dt) => {
3949
+ const pose = thirdPersonPose(
3950
+ { x: player.x, z: player.z, feet: player.feet, yaw: player.yaw, pitch: player.pitch, crouch: avatarState.crouch },
3951
+ third,
3952
+ (o, d, max) => withoutHeld(() => world.raycast(o, d, max))?.distance ?? null
3953
+ );
3954
+ camDistance = pose.free <= camDistance ? pose.free : dt > 0 ? camDistance + (pose.free - camDistance) * (1 - Math.exp(-dt * third.recover)) : camDistance;
3955
+ return { ...pose, position: pose.at(camDistance), free: camDistance };
3956
+ };
3957
+ const thirdActive = () => cameraMode === "third" && avatar !== null;
3958
+ const syncAvatarVisible = () => {
3959
+ if (!avatar) return;
3960
+ avatar.root.visible = cameraMode === "third";
3961
+ if (avatar.firstPerson) avatar.firstPerson.root.visible = cameraMode === "first";
3962
+ };
3963
+ const setCameraMode = (mode) => {
3964
+ if (mode !== "first" && mode !== "third") return;
3965
+ if (mode === cameraMode) return;
3966
+ cameraMode = mode;
3967
+ camDistance = Infinity;
3968
+ syncAvatarVisible();
3969
+ action("camera", t("act.camera", { mode: t(`camera.${mode}`) }), { data: { mode, avatar: avatar !== null } });
3970
+ fire("camera", mode);
3971
+ };
3972
+ const setAvatar = (next) => {
3973
+ if (avatar === next) return;
3974
+ avatar?.root.removeFromParent();
3975
+ avatar?.firstPerson?.root.removeFromParent();
3976
+ avatar = next;
3977
+ feetPrev = null;
3978
+ if (avatar) {
3979
+ scene.add(avatar.root);
3980
+ if (avatar.firstPerson) scene.add(avatar.firstPerson.root);
3981
+ syncAvatarVisible();
3982
+ stepAvatar(0);
3983
+ }
3984
+ ui?.hud(hudState());
3985
+ };
3986
+ const stepAvatar = (dt) => {
3987
+ if (!avatar) return;
3988
+ const x = player.x;
3989
+ const z = player.z;
3990
+ if (dt > 0 && feetPrev) {
3991
+ const raw = Math.hypot(x - feetPrev[0], z - feetPrev[1]) / dt;
3992
+ avatarState.speed = raw > MAX_STRIDE_SPEED ? 0 : avatarState.speed + (raw - avatarState.speed) * (1 - Math.exp(-dt * 15));
3993
+ if (avatarState.speed < 0.02) avatarState.speed = 0;
3994
+ }
3995
+ feetPrev = [x, z];
3996
+ avatarState.running = player.keys.has("ShiftLeft") || player.keys.has("ShiftRight");
3997
+ avatarState.armed = armedForced ?? tool.def.action.kind === "firearm";
3998
+ avatarState.pitch = player.pitch;
3999
+ avatar.root.position.set(x, player.feet, z);
4000
+ avatar.root.rotation.set(0, player.yaw + Math.PI, 0);
4001
+ avatar.update(dt, avatarState);
4002
+ avatar.root.updateMatrixWorld(true);
4003
+ };
4004
+ let handSource = null;
4005
+ let carriedHand = null;
4006
+ let debugView = null;
4007
+ let lastHand = { stance: "relaxed", triggerFinger: null, targets: null };
4008
+ const strikeClips = options.strikeClips ?? DEFAULT_STRIKE_CLIPS;
4009
+ const handStances = options.handStances ?? DEFAULT_HAND_STANCES;
4010
+ const stepFirstPerson = (dt) => {
4011
+ const arms = avatar?.firstPerson;
4012
+ if (!arms) return;
4013
+ const targets = handSource?.() ?? carriedHand;
4014
+ const state = {
4015
+ camera,
4016
+ targets,
4017
+ stance: handStances[toolId] ?? (targets ? "hold" : "relaxed"),
4018
+ speed: avatarState.speed,
4019
+ running: avatarState.running,
4020
+ crouch: avatarState.crouch,
4021
+ contact: (origin, direction, maxDistance) => rayWorld(origin, direction, maxDistance)?.distance ?? null,
4022
+ triggerFinger: targets?.fingers?.index ?? null
4023
+ };
4024
+ lastHand = { stance: state.stance, triggerFinger: state.triggerFinger, targets };
4025
+ arms.update(dt, state);
4026
+ };
4027
+ const applyThirdView = () => {
4028
+ if (!thirdActive()) return () => void 0;
4029
+ const home = camera.position.clone();
4030
+ const pose = thirdPose(0);
4031
+ camera.position.set(pose.position[0], pose.position[1], pose.position[2]);
4032
+ camera.updateMatrixWorld(true);
4033
+ return () => {
4034
+ camera.position.copy(home);
4035
+ camera.updateMatrixWorld(true);
4036
+ };
4037
+ };
4351
4038
  const aim = () => {
4352
4039
  camera.updateMatrixWorld(true);
4353
4040
  camera.getWorldPosition(tmpV);
@@ -4358,6 +4045,21 @@ function createTestRoom(options) {
4358
4045
  aimD[0] = tmpV2.x;
4359
4046
  aimD[1] = tmpV2.y;
4360
4047
  aimD[2] = tmpV2.z;
4048
+ if (thirdActive()) {
4049
+ const pose = thirdPose(0);
4050
+ const reach = 80;
4051
+ const hit = withoutHeld(() => world.raycast(pose.position, pose.direction, reach));
4052
+ const dist = hit ? hit.distance : reach;
4053
+ const tx = pose.position[0] + pose.direction[0] * dist - aimO[0];
4054
+ const ty = pose.position[1] + pose.direction[1] * dist - aimO[1];
4055
+ const tz = pose.position[2] + pose.direction[2] * dist - aimO[2];
4056
+ const len = Math.hypot(tx, ty, tz);
4057
+ if (len > 0.5 && (tx * pose.direction[0] + ty * pose.direction[1] + tz * pose.direction[2]) / len > 0.5) {
4058
+ aimD[0] = tx / len;
4059
+ aimD[1] = ty / len;
4060
+ aimD[2] = tz / len;
4061
+ }
4062
+ }
4361
4063
  };
4362
4064
  const FAR = [0, 0, 0, 1];
4363
4065
  const withoutHeld = (fn) => {
@@ -4511,7 +4213,7 @@ function createTestRoom(options) {
4511
4213
  withoutHeld(() => {
4512
4214
  for (const s of shots) {
4513
4215
  energy = Math.max(energy, s.energy);
4514
- const results = world.shoot(s.origin, s.direction, s.energy, s.caliber, "player");
4216
+ const results = world.shoot(s.origin, s.direction, s.energy, s.caliber, "player", shotExtras(s));
4515
4217
  const r0 = results.find((r) => r.part && r.point);
4516
4218
  let end = [s.origin[0] + s.direction[0] * 40, s.origin[1] + s.direction[1] * 40, s.origin[2] + s.direction[2] * 40];
4517
4219
  if (r0?.point && r0.part) {
@@ -4569,7 +4271,7 @@ function createTestRoom(options) {
4569
4271
  const r = world.raycast(u.origin, d, u.range);
4570
4272
  if (!r) continue;
4571
4273
  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" });
4274
+ world.hit(r.object, { ...useToHit(u, r.point, r.normal), type, direction: d, source: "player" });
4573
4275
  if (type === "fire") igniteNear(r.point, 0.25);
4574
4276
  else renderer.emit("sparks", r.point, r.normal, 2, 4);
4575
4277
  }
@@ -4603,7 +4305,7 @@ function createTestRoom(options) {
4603
4305
  action("melee", t("act.melee.miss", { weapon: w }), { data: { weapon: id } });
4604
4306
  return;
4605
4307
  }
4606
- world.hitPart(r.object, r.part, { type: u.damage, point: r.point, direction: u.direction, energy: u.energy, radius: u.radius, source: "player" });
4308
+ world.hitPart(r.object, r.part, { ...useToHit(u, r.point, r.normal), source: "player" });
4607
4309
  pushPiece(r.object, u.direction, 2 * u.energy / 6, r.point);
4608
4310
  action("melee", t("act.melee.hit", { weapon: w, energy: num(u.energy, 0), where: hitWhere(r), dist: num(r.distance, 1) }), {
4609
4311
  objectId: r.object,
@@ -4612,6 +4314,35 @@ function createTestRoom(options) {
4612
4314
  data: { weapon: id, damage: u.damage, energyJ: u.energy, distanceM: Math.round(r.distance * 100) / 100 }
4613
4315
  });
4614
4316
  };
4317
+ let strikes = [];
4318
+ const kickLeg = options.kickLeg ?? "alternate";
4319
+ let nextLeg = "right";
4320
+ const strikeUse = (u, id = toolId) => {
4321
+ const clip = strikeClips[id];
4322
+ const leg = id === "kick" ? kickLeg === "alternate" ? nextLeg : kickLeg : void 0;
4323
+ const seconds = clip === void 0 ? null : avatar?.strike?.(clip, leg ? { leg } : void 0) ?? null;
4324
+ if (leg && seconds !== null && kickLeg === "alternate") nextLeg = leg === "right" ? "left" : "right";
4325
+ if (clip === void 0 || seconds === null) {
4326
+ meleeUse(u, id);
4327
+ return;
4328
+ }
4329
+ 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 } : {} } });
4330
+ strikes.push({
4331
+ key: id,
4332
+ remaining: seconds,
4333
+ land: () => {
4334
+ aim();
4335
+ meleeUse({ ...u, origin: [aimO[0], aimO[1], aimO[2]], direction: [aimD[0], aimD[1], aimD[2]] }, id);
4336
+ }
4337
+ });
4338
+ };
4339
+ const stepStrikes = (dt) => {
4340
+ if (strikes.length === 0) return;
4341
+ for (const strike of strikes) strike.remaining -= dt;
4342
+ const landed = strikes.filter((strike) => strike.remaining <= 0);
4343
+ strikes = strikes.filter((strike) => strike.remaining > 0);
4344
+ for (const strike of landed) strike.land();
4345
+ };
4615
4346
  const toolWork = (u, dt) => {
4616
4347
  const w = toolName();
4617
4348
  const r = rayWorld(u.origin, u.direction, u.reach);
@@ -4630,7 +4361,7 @@ function createTestRoom(options) {
4630
4361
  const p = world.disassemble(r.object, j.id, tool_, dt);
4631
4362
  const kind = dStrings.jointKinds[j.kind] ?? j.kind;
4632
4363
  if (p < 0) {
4633
- const need = JOINT_DEFAULTS2[j.kind].tools.map((k) => dStrings.toolKinds[k] ?? k).join(", ") || t("tool.none");
4364
+ const need = JOINT_DEFAULTS[j.kind].tools.map((k) => dStrings.toolKinds[k] ?? k).join(", ") || t("tool.none");
4634
4365
  progress = { label: t("hud.refused", { kind }), value: 0, at: simTime };
4635
4366
  log.coalesce(
4636
4367
  `refuse:${r.object}:${j.id}:${toolId}`,
@@ -4640,7 +4371,7 @@ function createTestRoom(options) {
4640
4371
  text: t("act.tool.refused", { weapon: w, kind, obj: nameOf(r.object), need }),
4641
4372
  objectId: r.object,
4642
4373
  itemId: entryOf(r.object)?.so.itemId,
4643
- data: { weapon: toolId, joint: j.id, kind: j.kind, needs: JOINT_DEFAULTS2[j.kind].tools }
4374
+ data: { weapon: toolId, joint: j.id, kind: j.kind, needs: JOINT_DEFAULTS[j.kind].tools }
4644
4375
  },
4645
4376
  2
4646
4377
  );
@@ -4714,12 +4445,14 @@ function createTestRoom(options) {
4714
4445
  action,
4715
4446
  emitHost,
4716
4447
  startFire,
4717
- say
4448
+ say,
4449
+ projectileModel: options.projectileModel
4718
4450
  });
4719
4451
  const useTool = (dt, held) => {
4720
4452
  aim();
4721
4453
  const cat = tool.def.category;
4722
4454
  if (cat === "hand") return;
4455
+ if (strikes.some((strike) => strike.key === toolId)) return;
4723
4456
  const res = tool.trigger([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], dt, held, "player");
4724
4457
  flushEvents(toolId, tool, res.recoil);
4725
4458
  if (res.status === "empty") {
@@ -4741,7 +4474,7 @@ function createTestRoom(options) {
4741
4474
  for (const u of res.uses) {
4742
4475
  switch (u.kind) {
4743
4476
  case "melee":
4744
- meleeUse(u);
4477
+ strikeUse(u);
4745
4478
  break;
4746
4479
  case "flame":
4747
4480
  heatRays(u, "fire");
@@ -4780,11 +4513,11 @@ function createTestRoom(options) {
4780
4513
  if (res) applyUses(res, 0);
4781
4514
  };
4782
4515
  const kick = () => {
4783
- if (!arsenal.weapons.has("kick")) return;
4516
+ if (!arsenal.weapons.has("kick") || strikes.some((strike) => strike.key === "kick")) return;
4784
4517
  aim();
4785
4518
  const res = toolState("kick").trigger([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], 0, false, "player");
4786
4519
  if (res.status !== "ok") return;
4787
- for (const u of res.uses) if (u.kind === "melee") meleeUse(u, "kick");
4520
+ for (const u of res.uses) if (u.kind === "melee") strikeUse(u, "kick");
4788
4521
  };
4789
4522
  const setTool = (id) => {
4790
4523
  if (!arsenal.weapons.has(id)) throw new Error(`game-test-room: unknown weapon "${id}"`);
@@ -4811,6 +4544,7 @@ function createTestRoom(options) {
4811
4544
  let heldTilt = 0;
4812
4545
  let tiltKey = 0;
4813
4546
  const heldLocal = new THREE.Vector3();
4547
+ let heldRadius = 0.2;
4814
4548
  const heldVel = [0, 0, 0];
4815
4549
  let useItemPlayer = null;
4816
4550
  let useItemName = "";
@@ -4860,6 +4594,7 @@ function createTestRoom(options) {
4860
4594
  piece.root.updateMatrixWorld(true);
4861
4595
  heldLocal.copy(c).sub(piece.root.position);
4862
4596
  heldLocal.applyQuaternion(piece.root.quaternion.clone().invert());
4597
+ heldRadius = box2.getSize(new THREE.Vector3()).length() / 3;
4863
4598
  heldYaw = 0;
4864
4599
  heldTilt = 0;
4865
4600
  heldVel[0] = heldVel[1] = heldVel[2] = 0;
@@ -4880,14 +4615,32 @@ function createTestRoom(options) {
4880
4615
  fire("throw", { objectId: id, speed });
4881
4616
  return true;
4882
4617
  };
4618
+ const carriedTargets = (root) => {
4619
+ const box2 = new THREE.Box3().setFromObject(root);
4620
+ const center = box2.getCenter(new THREE.Vector3());
4621
+ const forward = camera.getWorldDirection(new THREE.Vector3());
4622
+ forward.y = 0;
4623
+ if (forward.lengthSq() < 1e-6) forward.set(0, 0, -1);
4624
+ forward.normalize();
4625
+ const back = new THREE.Vector3(0, -1, 0);
4626
+ const z = forward.clone().cross(back).normalize();
4627
+ const q = new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(forward, back, z));
4628
+ return { R: { position: [center.x, box2.min.y - CARRY_PALM, center.z], quaternion: [q.x, q.y, q.z, q.w] }, grip: CARRY_GRIP };
4629
+ };
4883
4630
  const followHeld = (dt) => {
4884
4631
  const piece = heldPiece;
4632
+ carriedHand = null;
4885
4633
  if (!piece) return;
4886
4634
  camera.updateMatrixWorld(true);
4887
4635
  const root = piece.root;
4888
4636
  let pos;
4889
4637
  const targetQ = tmpQ;
4890
- if (useItemPlayer) {
4638
+ const hand = thirdActive() ? avatar?.hand?.("R") : void 0;
4639
+ if (hand) {
4640
+ pos = hand.getWorldPosition(tmpHand);
4641
+ targetQ.setFromAxisAngle(new THREE.Vector3(0, 1, 0), player.yaw + heldYaw);
4642
+ targetQ.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), -heldTilt));
4643
+ } else if (useItemPlayer) {
4891
4644
  const pose = useItemPlayer.pose();
4892
4645
  const handQ = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), player.yaw);
4893
4646
  pos = tmpV.set(pose.position[0], pose.position[1], pose.position[2]).applyQuaternion(handQ).add(camera.position);
@@ -4895,7 +4648,7 @@ function createTestRoom(options) {
4895
4648
  targetQ.copy(handQ).multiply(eq);
4896
4649
  targetQ.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), heldYaw));
4897
4650
  } else {
4898
- pos = tmpV.set(0.05, -0.28, -1.15).applyMatrix4(camera.matrixWorld);
4651
+ pos = tmpV.set(0.05, -0.28, avatar?.firstPerson ? -Math.min(1.15, CARRY_NEAR + heldRadius) : -1.15).applyMatrix4(camera.matrixWorld);
4899
4652
  targetQ.setFromAxisAngle(new THREE.Vector3(0, 1, 0), player.yaw + heldYaw);
4900
4653
  targetQ.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), -heldTilt));
4901
4654
  }
@@ -4912,6 +4665,7 @@ function createTestRoom(options) {
4912
4665
  heldVel[2] = (root.position.z - pz) / dt;
4913
4666
  }
4914
4667
  root.updateMatrixWorld(true);
4668
+ if (avatar?.firstPerson && cameraMode === "first") carriedHand = carriedTargets(root);
4915
4669
  };
4916
4670
  const useItem = (useId) => {
4917
4671
  const e = heldEntry;
@@ -4941,7 +4695,7 @@ function createTestRoom(options) {
4941
4695
  };
4942
4696
  const TAP_ID = "room_tap";
4943
4697
  const TAP_RATE = 0.25;
4944
- fluids.addTap({ id: TAP_ID, position: arena.tapSpout, liquid: "water", rate: TAP_RATE, temperature: 16, on: false });
4698
+ if (arena.tapSpout) fluids.addTap({ id: TAP_ID, position: arena.tapSpout, liquid: "water", rate: TAP_RATE, temperature: 16, on: false });
4945
4699
  const isTapOn = () => fluids.taps().some((x) => x.id === TAP_ID && x.on);
4946
4700
  const toggleTap = (on = !isTapOn()) => {
4947
4701
  fluids.setTap(TAP_ID, on);
@@ -5051,6 +4805,7 @@ function createTestRoom(options) {
5051
4805
  break;
5052
4806
  case "KeyV":
5053
4807
  if (heldEntry?.uses[2]) useItem(heldEntry.uses[2]);
4808
+ else if (avatar && !e.shiftKey) setCameraMode(cameraMode === "first" ? "third" : "first");
5054
4809
  else kick();
5055
4810
  break;
5056
4811
  case "KeyT":
@@ -5098,7 +4853,7 @@ function createTestRoom(options) {
5098
4853
  const joints = st.joints.filter((j) => j.a === r.part || j.b === r.part).map((j) => ({
5099
4854
  id: j.id,
5100
4855
  kind: dStrings.jointKinds[j.kind] ?? j.kind,
5101
- tools: JOINT_DEFAULTS2[j.kind].tools.map((k) => dStrings.toolKinds[k] ?? k),
4856
+ tools: JOINT_DEFAULTS[j.kind].tools.map((k) => dStrings.toolKinds[k] ?? k),
5102
4857
  open: j.open,
5103
4858
  progress: j.progress
5104
4859
  }));
@@ -5172,6 +4927,7 @@ function createTestRoom(options) {
5172
4927
  if (paused) return;
5173
4928
  simTime += dt;
5174
4929
  stepWalk(dt);
4930
+ stepStrikes(dt);
5175
4931
  const reps = fastForward ? 10 : 1;
5176
4932
  updateAnimations(dt);
5177
4933
  updateItemUse(dt);
@@ -5210,6 +4966,7 @@ function createTestRoom(options) {
5210
4966
  const update = (dt) => {
5211
4967
  if (!ownsLoop) roomMetrics.frame(dt);
5212
4968
  player.update(dt);
4969
+ stepAvatar(paused ? 0 : dt);
5213
4970
  if (tiltKey || heldTilt > 0) {
5214
4971
  heldTilt = Math.max(0, Math.min(2.4, heldTilt + (tiltKey ? 2.2 : -3) * dt));
5215
4972
  }
@@ -5221,6 +4978,7 @@ function createTestRoom(options) {
5221
4978
  }
5222
4979
  if (!paused) followHeld(dt);
5223
4980
  stepSim(dt * timeScale);
4981
+ stepFirstPerson(paused ? 0 : dt);
5224
4982
  if (progress && simTime - progress.at > 0.25) progress = null;
5225
4983
  hudAcc += dt;
5226
4984
  if (hudAcc >= 0.1 && ui) {
@@ -5256,6 +5014,7 @@ function createTestRoom(options) {
5256
5014
  tap: isTapOn(),
5257
5015
  blast: { shelter: blastOn("shelter"), fragmentHits: blastOn("fragmentHits"), confinement: blastOn("confinement") },
5258
5016
  quality: qualityName,
5017
+ camera: avatar ? cameraMode : null,
5259
5018
  objects: [...entries.values()].filter((e) => !e.arena).length
5260
5019
  };
5261
5020
  };
@@ -5457,6 +5216,8 @@ function createTestRoom(options) {
5457
5216
  environment,
5458
5217
  groundAt,
5459
5218
  effects: vfx,
5219
+ loading,
5220
+ ...options.blastShove ? { blastShove: options.blastShove } : {},
5460
5221
  tool: () => ({
5461
5222
  weapon: toolId,
5462
5223
  category: tool.def.category,
@@ -5480,6 +5241,15 @@ function createTestRoom(options) {
5480
5241
  flushEvents(toolId, tool);
5481
5242
  return result;
5482
5243
  },
5244
+ player: () => ({
5245
+ position: player.position,
5246
+ yaw: player.yaw,
5247
+ pitch: player.pitch,
5248
+ crouching: false,
5249
+ speed: player.speed,
5250
+ running: player.running,
5251
+ cameraMode: "first"
5252
+ }),
5483
5253
  configureTool(settings) {
5484
5254
  const condition = settings[SETTING_IDS.condition];
5485
5255
  const ammoQuality = settings[SETTING_IDS.ammoQuality];
@@ -5501,8 +5271,14 @@ function createTestRoom(options) {
5501
5271
  frameListeners.add(update2);
5502
5272
  return () => frameListeners.delete(update2);
5503
5273
  },
5504
- log(text, data) {
5505
- action("module_note", text, { data });
5274
+ setHandTargets(source) {
5275
+ handSource = source;
5276
+ return () => {
5277
+ if (handSource === source) handSource = null;
5278
+ };
5279
+ },
5280
+ log(text, data, type) {
5281
+ action(type ?? "module_note", text, { data });
5506
5282
  },
5507
5283
  addBlocker(blocked) {
5508
5284
  blockers.add(blocked);
@@ -5513,13 +5289,17 @@ function createTestRoom(options) {
5513
5289
  return () => clearListeners.delete(listener);
5514
5290
  }
5515
5291
  };
5292
+ let sendingInitial = false;
5516
5293
  const lendBench = (store) => ({
5517
5294
  value: store.value,
5518
5295
  layer: store.layer,
5519
5296
  onChange: store.onChange,
5520
5297
  patch: store.patch,
5521
5298
  sheet: store.setSheet,
5522
- look: (eye, target) => room.look(eye, target)
5299
+ // 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)
5300
+ look: (eye, target) => {
5301
+ if (!sendingInitial) room.look(eye, target);
5302
+ }
5523
5303
  });
5524
5304
  const checkModuleBench = (mod, fail) => {
5525
5305
  const store = createBenchStore(mod, locale, (error) => reportOnce(`${mod.id}:listener`, t("mod.listener_failed", { name: mod.name, error: failureText(error) })));
@@ -5531,6 +5311,29 @@ function createTestRoom(options) {
5531
5311
  for (const p of r.problems) fail(`bench: ${p}`);
5532
5312
  return r.ok ? store : null;
5533
5313
  };
5314
+ const LIGHTS_CHECK_MS = 250;
5315
+ const warmUp = () => {
5316
+ const probes = [renderer.warmUpProbes(), vfx.warmUpProbes(), fluidView.warmUpProbes()];
5317
+ const groups = probes.map((probe) => probe.group);
5318
+ scene.add(...groups);
5319
+ gl.render(scene, camera);
5320
+ scene.remove(...groups);
5321
+ for (const probe of probes) probe.dispose();
5322
+ };
5323
+ const warmUpEffects = () => {
5324
+ const ids = vfx.catalog?.() ?? [];
5325
+ const flames = ids.filter((id) => /fire|flame/i.test(id)).slice(0, 1);
5326
+ gl.compile(scene, camera);
5327
+ 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] }));
5328
+ for (let i = 0; i < 3; i++) {
5329
+ vfx.update(0.02);
5330
+ gl.render(scene, camera);
5331
+ }
5332
+ for (const h of handles) if (h !== null) vfx.stop(h);
5333
+ vfx.world.clear();
5334
+ vfx.update(0);
5335
+ gl.render(scene, camera);
5336
+ };
5534
5337
  const loadModule = (mod) => {
5535
5338
  const report = { module: mod.id, items: [], scenarios: [], ok: true, problems: [] };
5536
5339
  const fail = (problem) => {
@@ -5546,6 +5349,7 @@ function createTestRoom(options) {
5546
5349
  build: (T) => ti.build(T),
5547
5350
  spec: ti.spec,
5548
5351
  movable: ti.movable,
5352
+ slow: ti.slow,
5549
5353
  fluid: ti.fluid,
5550
5354
  weapon: ti.weapon,
5551
5355
  impl: ti.impl,
@@ -5560,8 +5364,9 @@ function createTestRoom(options) {
5560
5364
  report.items.push(item.id);
5561
5365
  let partIds = [];
5562
5366
  const base = { module: mod.id, item: item.id };
5367
+ let checked;
5563
5368
  try {
5564
- const root = item.build(THREE);
5369
+ const root = checked = buildRoot(item);
5565
5370
  root.updateMatrixWorld(true);
5566
5371
  if (item.impl) {
5567
5372
  const probe = item.impl(root, `${item.id}_check`);
@@ -5583,6 +5388,7 @@ function createTestRoom(options) {
5583
5388
  moduleCheck(false, t("mod.destructible_fail", { name: item.name, problems: msg }), { ...base, check: "destructible", problems: [msg] }, item.id);
5584
5389
  fail(`${item.id}: ${msg}`);
5585
5390
  }
5391
+ if (checked) letGo(checked);
5586
5392
  for (const clip of item.animations ?? []) {
5587
5393
  const r = checkAnimation(clip, partIds);
5588
5394
  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 +5424,17 @@ function createTestRoom(options) {
5618
5424
  if (!activeBench || !benches.has(activeBench)) activeBench = mod.id;
5619
5425
  } else if (activeBench === mod.id) activeBench = lastBench();
5620
5426
  if (mod.attach) detachers.set(mod.id, mod.attach(bench ? { ...host, bench: lendBench(bench) } : host));
5621
- bench?.sendInitial();
5427
+ sendingInitial = true;
5428
+ try {
5429
+ bench?.sendInitial();
5430
+ } finally {
5431
+ sendingInitial = false;
5432
+ }
5622
5433
  showBench();
5623
5434
  ui?.refresh();
5624
5435
  return report;
5625
5436
  };
5626
- const allScenarios = () => [...makeScenarios(t, num, locale), ...extraScenarios];
5437
+ const allScenarios = () => [...extraScenarios];
5627
5438
  let scenarioRunning = false;
5628
5439
  const step = (seconds) => {
5629
5440
  const dt = seconds > 6 ? 1 / 30 : FIXED_DT;
@@ -5676,8 +5487,13 @@ function createTestRoom(options) {
5676
5487
  return report;
5677
5488
  };
5678
5489
  const layArea = (layout) => {
5679
- const holders = layout.placements.filter((p) => p.surface);
5680
- const rest = layout.placements.filter((p) => !p.surface);
5490
+ const known = layout.placements.filter((p) => {
5491
+ if (catalogMap.has(p.item)) return true;
5492
+ action("area_skip", t("act.area_skip", { item: p.item }), { itemId: p.item });
5493
+ return false;
5494
+ });
5495
+ const holders = known.filter((p) => p.surface);
5496
+ const rest = known.filter((p) => !p.surface);
5681
5497
  const placed = [...holders, ...rest].map((p) => {
5682
5498
  const so = spawn(p.item, p.position, p.rotationY ?? 0);
5683
5499
  if (p.surface) {
@@ -5693,6 +5509,10 @@ function createTestRoom(options) {
5693
5509
  if (layout.view) room.look(layout.view.eye, layout.view.target);
5694
5510
  return placed.map(({ so }) => so);
5695
5511
  };
5512
+ const areaLabel = (area) => {
5513
+ const mod = [...modules.values()].find((m) => m.id === area || m.group === area);
5514
+ return mod?.group ?? S.groups[area] ?? area;
5515
+ };
5696
5516
  const openArea = (area) => {
5697
5517
  clear();
5698
5518
  const mod = [...modules.values()].find((m) => m.id === area || m.group === area);
@@ -5805,14 +5625,56 @@ function createTestRoom(options) {
5805
5625
  },
5806
5626
  equip,
5807
5627
  openArea,
5628
+ openAreaAsync: (area) => loading.busy(t("loading.open_area", { area: areaLabel(area) }), () => openArea(area)),
5629
+ loading,
5630
+ busy: (label, work, busyOptions) => loading.busy(label, work, busyOptions),
5808
5631
  loadModule,
5632
+ warmUp: () => {
5633
+ warmUp();
5634
+ warmUpEffects();
5635
+ },
5809
5636
  animations: animationsOf,
5810
5637
  playAnimation,
5811
5638
  bench(moduleId) {
5812
5639
  const id = moduleId ?? activeBench;
5813
5640
  return (id ? benches.get(id) : void 0) ?? null;
5814
5641
  },
5815
- applyCamera: () => vfx.applyCamera(),
5642
+ applyCamera: () => {
5643
+ const back = applyThirdView();
5644
+ const shake = vfx.applyCamera();
5645
+ return () => {
5646
+ shake();
5647
+ back();
5648
+ };
5649
+ },
5650
+ setAvatar,
5651
+ get avatar() {
5652
+ return avatar;
5653
+ },
5654
+ setAvatarState(state) {
5655
+ if (state.crouch !== void 0) avatarState.crouch = state.crouch;
5656
+ if (state.down !== void 0) avatarState.down = state.down;
5657
+ if (state.armed !== void 0) armedForced = state.armed;
5658
+ },
5659
+ avatarState: () => ({ ...avatarState }),
5660
+ handState: () => ({ ...lastHand }),
5661
+ setDebugCamera(view) {
5662
+ if (!view) {
5663
+ debugView = null;
5664
+ return;
5665
+ }
5666
+ debugView ?? (debugView = new THREE.PerspectiveCamera(60, camera.aspect, 0.01, 200));
5667
+ debugView.fov = view.fov ?? 40;
5668
+ debugView.aspect = camera.aspect;
5669
+ debugView.position.set(...view.position);
5670
+ debugView.lookAt(...view.target);
5671
+ debugView.updateProjectionMatrix();
5672
+ debugView.updateMatrixWorld(true);
5673
+ },
5674
+ setCameraMode,
5675
+ get cameraMode() {
5676
+ return cameraMode;
5677
+ },
5816
5678
  metrics() {
5817
5679
  return { room: roomMetrics.sample(), module: moduleMetrics() };
5818
5680
  },
@@ -5827,6 +5689,7 @@ function createTestRoom(options) {
5827
5689
  window.removeEventListener("resize", onResize);
5828
5690
  player.dispose();
5829
5691
  ui?.dispose();
5692
+ loading.dispose();
5830
5693
  renderer.dispose();
5831
5694
  vfx.dispose();
5832
5695
  craterPatch?.dispose();
@@ -5841,6 +5704,17 @@ function createTestRoom(options) {
5841
5704
  }
5842
5705
  }
5843
5706
  };
5707
+ const slowly = (moduleId, controlId, run) => {
5708
+ const control = benches.get(moduleId)?.controls().find((c) => c.id === controlId);
5709
+ const slow = control?.slow;
5710
+ if (!slow) {
5711
+ run();
5712
+ return;
5713
+ }
5714
+ void loading.busy(typeof slow === "string" ? slow : control.name, run).catch((error) => {
5715
+ action("bench_failed", t("act.bench_failed", { name: control.name, error: error instanceof Error ? error.message : String(error) }), { data: { module: moduleId, control: controlId } });
5716
+ });
5717
+ };
5844
5718
  let ui = null;
5845
5719
  if (options.ui !== false) {
5846
5720
  const groups = () => {
@@ -5860,7 +5734,9 @@ function createTestRoom(options) {
5860
5734
  groups,
5861
5735
  loadout: () => loadout.map((id) => ({ id, name: arsenal.name(id) })),
5862
5736
  onSpawn: (id) => {
5863
- spawn(id);
5737
+ const item = catalogMap.get(id);
5738
+ if (item?.slow) void loading.busy(typeof item.slow === "string" ? item.slow : t("loading.spawn", { name: item.name }), () => spawn(id));
5739
+ else spawn(id);
5864
5740
  },
5865
5741
  onEquip: (itemId) => {
5866
5742
  const w = catalogMap.get(itemId)?.weapon;
@@ -5900,13 +5776,13 @@ function createTestRoom(options) {
5900
5776
  },
5901
5777
  onResize: resize,
5902
5778
  onControl: (id, value) => {
5903
- if (activeBench) benches.get(activeBench)?.set(id, value);
5779
+ if (activeBench) slowly(activeBench, id, () => benches.get(activeBench)?.set(id, value));
5904
5780
  },
5905
5781
  onLayer: (id, on) => {
5906
5782
  if (activeBench) benches.get(activeBench)?.setLayer(id, on);
5907
5783
  },
5908
5784
  onAction: (id) => {
5909
- if (activeBench) benches.get(activeBench)?.act(id);
5785
+ if (activeBench) slowly(activeBench, id, () => benches.get(activeBench)?.act(id));
5910
5786
  }
5911
5787
  });
5912
5788
  ui.hud(hudState());
@@ -5916,15 +5792,32 @@ function createTestRoom(options) {
5916
5792
  if (ownRenderer && typeof requestAnimationFrame === "function") {
5917
5793
  let last = performance.now();
5918
5794
  ownsLoop = true;
5795
+ let nextLightsCheck = 0;
5796
+ let warmedLights = "";
5797
+ const lightsKey = () => {
5798
+ let key = "";
5799
+ scene.traverseVisible((object) => {
5800
+ if (object.isLight) key += `${object.type[0]}${object.castShadow ? "s" : ""}`;
5801
+ });
5802
+ return key;
5803
+ };
5919
5804
  const loop = (now) => {
5920
5805
  raf = requestAnimationFrame(loop);
5806
+ if (now >= nextLightsCheck) {
5807
+ nextLightsCheck = now + LIGHTS_CHECK_MS;
5808
+ const key = lightsKey();
5809
+ if (key !== warmedLights) {
5810
+ warmedLights = key;
5811
+ warmUp();
5812
+ }
5813
+ }
5921
5814
  const realDelta = (now - last) / 1e3;
5922
5815
  roomMetrics.frame(realDelta);
5923
5816
  const dt = Math.min(0.05, realDelta);
5924
5817
  last = now;
5925
5818
  if (!scenarioRunning) update(dt);
5926
- const restoreCamera = vfx.applyCamera();
5927
- gl.render(scene, camera);
5819
+ const restoreCamera = room.applyCamera();
5820
+ gl.render(scene, debugView ?? camera);
5928
5821
  restoreCamera();
5929
5822
  roomMetrics.rendered();
5930
5823
  };
@@ -5933,6 +5826,7 @@ function createTestRoom(options) {
5933
5826
  if (options.initial) {
5934
5827
  for (const [i, id] of options.initial.entries()) spawn(id, [(i - (options.initial.length - 1) / 2) * 1.3, 0, 0.5]);
5935
5828
  }
5829
+ if (options.avatar) setAvatar(options.avatar);
5936
5830
  if (options.area) openArea(options.area);
5937
5831
  return room;
5938
5832
  }
@@ -5940,20 +5834,21 @@ function structuredCloneSpec(spec) {
5940
5834
  return JSON.parse(JSON.stringify(spec));
5941
5835
  }
5942
5836
  export {
5943
- DEMO_ITEMS,
5944
- DEMO_MATERIALS,
5837
+ DEFAULT_HAND_STANCES,
5838
+ DEFAULT_STRIKE_CLIPS,
5839
+ LOADING_CLASS,
5840
+ LOADING_CSS,
5945
5841
  MATERIAL_COLORS,
5946
- SCENARIOS,
5947
5842
  brickWallSpec,
5948
5843
  buildFromSpec,
5844
+ createLoading,
5949
5845
  createLog,
5950
5846
  createTestRoom,
5951
5847
  decodeBenchState,
5952
- demoItems,
5953
5848
  encodeBenchState,
5849
+ loadingMarkup,
5954
5850
  locales,
5955
- makeScenarios,
5956
- resolveStrings,
5957
- scenariosFor
5851
+ nextPaint,
5852
+ resolveStrings
5958
5853
  };
5959
5854
  //# sourceMappingURL=index.js.map