@adatechnology/game-test-room 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,4543 @@
1
+ // src/room.ts
2
+ import { createDestruction, JOINT_DEFAULTS as JOINT_DEFAULTS2, resolveStrings as resolveDestructionStrings } from "@adatechnology/game-destruction";
3
+ import { createDestructionRenderer, specFromObject3D } from "@adatechnology/game-destruction-three";
4
+ import { createArsenal, DEFAULT_LOADOUT } from "@adatechnology/game-weapons";
5
+ import { createFluids, createItemUse, ITEM_USES, resolveStrings as resolveFluidStrings } from "@adatechnology/game-fluids";
6
+ import { createFluidRenderer } from "@adatechnology/game-fluids-three";
7
+ import { checkAnimation, checkDestructible, checkSpec } from "@adatechnology/game-contracts";
8
+
9
+ // src/items.ts
10
+ import { buildFrame } from "@adatechnology/game-destruction";
11
+
12
+ // src/build-from-spec.ts
13
+ var MATERIAL_COLORS = {
14
+ steel: 9278620,
15
+ cast_iron: 4869458,
16
+ aluminium: 12896718,
17
+ copper: 12088122,
18
+ wood_soft: 12158287,
19
+ wood_hard: 8014374,
20
+ plywood: 13214826,
21
+ mdf: 11045732,
22
+ glass: 10278630,
23
+ tempered_glass: 10278630,
24
+ ceramic: 15328474,
25
+ concrete: 10132118,
26
+ brick: 10831662,
27
+ plaster: 15131092,
28
+ stone: 7828591,
29
+ plastic: 3897540,
30
+ rubber: 2039586,
31
+ fabric: 9063262,
32
+ foam: 14926936,
33
+ leather: 5978662,
34
+ paper: 15657695,
35
+ foliage: 4165434,
36
+ green_wood: 7031339,
37
+ cactus: 4095306,
38
+ flesh_fruit: 13781810,
39
+ wax: 15721141,
40
+ soil: 4862754
41
+ };
42
+ var TRANSPARENT = { glass: 0.38, tempered_glass: 0.38 };
43
+ function buildFromSpec(THREE, spec, options = {}) {
44
+ const root = new THREE.Group();
45
+ root.name = spec.name ?? "object";
46
+ const seg = options.segments ?? 12;
47
+ const shadows = options.shadows !== false;
48
+ const mats = /* @__PURE__ */ new Map();
49
+ const materialFor = (p) => {
50
+ const color = options.colors?.[p.id] ?? options.colors?.[p.material] ?? MATERIAL_COLORS[p.material] ?? 8947848;
51
+ const key = `${p.material}:${color}`;
52
+ let m = mats.get(key);
53
+ if (!m) {
54
+ const alpha = TRANSPARENT[p.material];
55
+ const metal = p.material === "steel" || p.material === "aluminium" || p.material === "copper" || p.material === "cast_iron";
56
+ m = new THREE.MeshStandardMaterial({
57
+ color,
58
+ roughness: alpha ? 0.1 : metal ? 0.45 : 0.85,
59
+ metalness: metal ? 0.7 : 0,
60
+ transparent: !!alpha,
61
+ opacity: alpha ?? 1,
62
+ depthWrite: !alpha
63
+ });
64
+ mats.set(key, m);
65
+ }
66
+ return m;
67
+ };
68
+ for (const p of spec.parts) {
69
+ let geo;
70
+ const s = p.shape;
71
+ if (s.kind === "box") geo = new THREE.BoxGeometry(s.size[0], s.size[1], s.size[2]);
72
+ else if (s.kind === "cylinder") geo = new THREE.CylinderGeometry(s.radius, s.radius, s.height, seg);
73
+ else geo = new THREE.SphereGeometry(s.radius, seg, Math.max(6, seg - 4));
74
+ const mesh = new THREE.Mesh(geo, materialFor(p));
75
+ mesh.name = p.id;
76
+ mesh.userData.destructionPart = p.id;
77
+ mesh.userData.destructionMaterial = p.material;
78
+ mesh.position.set(p.position[0], p.position[1], p.position[2]);
79
+ if (p.rotation) mesh.rotation.set(p.rotation[0], p.rotation[1], p.rotation[2], "XYZ");
80
+ mesh.castShadow = shadows && !TRANSPARENT[p.material];
81
+ mesh.receiveShadow = shadows;
82
+ root.add(mesh);
83
+ }
84
+ return root;
85
+ }
86
+
87
+ // src/locale.ts
88
+ var en = {
89
+ items: {
90
+ chair: "Wooden chair",
91
+ sofa: "Sofa",
92
+ shelf: "Shelf with boards",
93
+ old_tv: "Old TV",
94
+ steel_sheet: "Steel sheet on a stand",
95
+ glass_pane: "Glass pane",
96
+ concrete_block: "Concrete block",
97
+ brick_column: "Brick column",
98
+ building_frame: "Building frame",
99
+ car_door: "Car door",
100
+ small_tree: "Small tree",
101
+ potted_plant: "Potted plant",
102
+ coffee_cup: "Cup of coffee",
103
+ glass_bottle: "Glass bottle",
104
+ wooden_crate: "Wooden crate",
105
+ steel_drum: "Steel drum",
106
+ plastic_bucket: "Plastic bucket",
107
+ jerrycan: "Jerrycan",
108
+ gas_bottle: "Gas bottle"
109
+ },
110
+ groups: { furniture: "Furniture", structure: "Structure", vehicles: "Vehicles", plants: "Plants", items: "Items", containers: "Containers" },
111
+ text: {
112
+ "locale.decimal": ".",
113
+ yes: "yes",
114
+ no: "no",
115
+ piece: "piece",
116
+ coords: "x {x} \xB7 z {z}",
117
+ "count.piece.one": "1 piece",
118
+ "count.piece.other": "{n} pieces",
119
+ "count.joint.one": "1 joint",
120
+ "count.joint.other": "{n} joints",
121
+ "count.dent.one": "1 dent",
122
+ "count.dent.other": "{n} dents",
123
+ "count.hole.one": "1 hole",
124
+ "count.hole.other": "{n} holes",
125
+ "count.crack.one": "1 crack",
126
+ "count.crack.other": "{n} cracks",
127
+ "joint.generic": "joint",
128
+ "lost.broken": "broke",
129
+ "lost.melted": "melted away",
130
+ "lost.ashes": "burned to ash",
131
+ "tool.none": "by hand only",
132
+ "arena.target_wall": "Target wall",
133
+ "arena.table": "Table",
134
+ "arena.shelf": "Shelf",
135
+ "surface.floor": "on the floor",
136
+ "surface.ground": "on the floor",
137
+ "surface.table": "on the table",
138
+ "by.break": "broken loose",
139
+ "by.tool": "undone with a tool",
140
+ "by.part_lost": "the part next to it was lost",
141
+ "dmg.ballistic": "ballistic",
142
+ "dmg.blunt": "blunt",
143
+ "dmg.slash": "slash",
144
+ "dmg.explosive": "blast",
145
+ "dmg.fire": "fire",
146
+ "dmg.heat": "heat",
147
+ "effect.caffeine": "caffeine",
148
+ "effect.alcohol": "alcohol",
149
+ "effect.poison": "poison",
150
+ "effect.disgust": "disgust",
151
+ "ev.dent": "{where}: dent of {mm} mm, {hp}",
152
+ "ev.hole.through": "{where}: {mm} mm hole went through, {hp}",
153
+ "ev.hole.stuck": "{where}: {mm} mm hole, did not go through, {hp}",
154
+ "ev.dents": "{where}: {n} dents, deepest {mm} mm, {hp}",
155
+ "ev.holes": "{where}: {n} holes of {mm} mm, {thr} went through, {hp}",
156
+ "ev.leak.tail": ", leaking {liquid} at {rate} l/s",
157
+ "ev.leak.alone": "{where}: leaking {liquid} at {rate} l/s",
158
+ "ev.crack": "{where}: cracked, {rays} rays up to {cm} cm, {hp}",
159
+ "ev.ricochet": "{where}: bullet ricocheted, {energy} J left",
160
+ "ev.fracture.shatter": "{where}: shattered into {n} shards",
161
+ "ev.fracture.crumble": "{where}: crumbled into {n} chunks",
162
+ "ev.fracture.splinter": "{where}: splintered into {n} pieces",
163
+ "ev.fracture.tear": "{where}: torn into {n} scraps",
164
+ "ev.fracture.snap": "{where}: snapped into {n} pieces",
165
+ "ev.fracture.none": "{where}: broke into {n} pieces",
166
+ "ev.detach": "{obj}: {a} came off {b} ({by}); the object now has {pieces}",
167
+ "ev.detach.many.break": "{obj}: {joints} broke loose; the object now has {pieces}",
168
+ "ev.detach.many.tool": "{obj}: {joints} undone with a tool; the object now has {pieces}",
169
+ "ev.lost.tail": " and came loose from {joints}; the object now has {pieces}",
170
+ "ev.lost.alone": "{where}: {verb} and came loose from {joints}; the object now has {pieces}",
171
+ "ev.cracks": "{where}: cracking under the load, {n} cracks so far, longest {cm} cm, {hp}",
172
+ "ev.disassemble": "{obj}: undoing the {kind} between {a} and {b} with {tool}: {pct}%",
173
+ "ev.ignite": "{where}: caught fire at {temp} \xB0C",
174
+ "ev.ignite.many": "{obj}: {n} parts caught fire at {temp} \xB0C ({parts})",
175
+ "ev.extinguish": "{where}: fire put out",
176
+ "ev.extinguish.many": "{obj}: fire put out on {n} parts ({parts})",
177
+ "ev.burnout": "{where}: burned to ash",
178
+ "ev.melt": "{where}: melting, {pct}% melted away",
179
+ "ev.drip": "{where}: dripping molten material",
180
+ "ev.explode": "{where}: exploded, {energy} J within {radius} m",
181
+ "ev.destroyed": "{obj}: destroyed",
182
+ "ev.damage": "{where}: {type} damage {amount}, {hp}",
183
+ "ev.damage.overload": "{where}: crushed by the weight it carries, lost {amount} hp so far, {hp}",
184
+ "ev.stress": "{where}: overloaded at {stress}% of its capacity, carrying {load} kg",
185
+ "ev.collapse": "{obj}: collapsed, {pieces} fell ({mass} kg): {parts}",
186
+ "ev.stain_ignite": "The {liquid} stain at x {x} \xB7 z {z} caught fire and will burn for about {seconds} s",
187
+ "ev.stain_burned": "The burning stain burned itself out",
188
+ "ev.fire_dries": "Fire dried the wet stains nearby",
189
+ "fl.fill": "{who}: filled with {liquid} (+{liters} l)",
190
+ "fl.pour": "{who}: pouring {liquid} at {rate} l/s",
191
+ "fl.spill": "{who}: spilled {liquid} over the lip at {rate} l/s",
192
+ "fl.leak": "{who}: leaking {liquid} at {rate} l/s",
193
+ "fl.drink": "{who}: drank {liters} l, thirst +{thirst}",
194
+ "fl.burn": "too hot: burned the mouth",
195
+ "fl.empty": "{who}: empty",
196
+ "fl.splash": "Splash of {liquid}",
197
+ "fl.stain": "Stain of {liquid} {surface} at {where}",
198
+ "fl.stains": "{n} stains of {liquid} {surface}, the latest at {where}",
199
+ "fl.dried": "A stain dried up",
200
+ "fl.dried.mark": "A stain dried and left a mark",
201
+ "fl.dried.known": "The {liquid} stain {surface} at {where} dried up",
202
+ "fl.dried.known.mark": "The {liquid} stain {surface} at {where} dried and left a mark",
203
+ "fl.dried.many": "{n} {liquid} stains dried up",
204
+ "fl.dried.many.mark": "{n} {liquid} stains dried and left marks",
205
+ "fl.mix": "{who}: {from} mixed into {into}",
206
+ "act.anim_start": '{name}: animation "{clip}" ({seconds} s)',
207
+ "act.anim_event": '{name}: "{clip}" event {type}',
208
+ "act.anim_end": '{name}: animation "{clip}" finished',
209
+ "act.module_loaded": 'Module "{name}" loaded: {items} item(s), {scenarios} scenario(s), contract checks: {result}',
210
+ "mod.all_ok": "all passed",
211
+ "mod.some_failed": "some FAILED",
212
+ "mod.destructible_ok": "{name}: destructible contract ok ({parts} parts, hit and state well-formed)",
213
+ "mod.destructible_fail": "{name}: destructible contract broken: {problems}",
214
+ "mod.animation_ok": '{name}: animation "{clip}" ok ({seconds} s)',
215
+ "mod.spec_ok": "{name}: spec ok ({parts} parts)",
216
+ "mod.spec_fail": "{name}: spec broken: {problems}",
217
+ "mod.spec_warn": "{name}: spec warning: {warning}",
218
+ "mod.animation_fail": '{name}: animation "{clip}" broken: {problems}',
219
+ "ui.animations": "Animations",
220
+ "ui.animations_aim": "Aim at an item or hold it to see its animations.",
221
+ "ui.animations_none": "This item brings no animations.",
222
+ "act.spawn": "Spawned {name} at x {x} \xB7 z {z}",
223
+ "act.clear": "Cleared the room",
224
+ "act.reset": "Repaired every object",
225
+ "act.tool": "Equipped {weapon}",
226
+ "act.shoot.hit": "{weapon}: shot, {energy} J, hit {where} at {dist} m",
227
+ "act.shoot.miss": "{weapon}: shot, {energy} J, hit nothing",
228
+ "act.shoot.pellets": "{weapon}: shot, {n} projectiles of up to {energy} J, {hits} hit {where} at {dist} m",
229
+ "act.melee.hit": "{weapon}: strike, {energy} J, hit {where} at {dist} m",
230
+ "act.melee.miss": "{weapon}: struck the air",
231
+ "act.flame.hit": "{weapon}: flame at {temp} \xB0C on {where}",
232
+ "act.flame.air": "{weapon}: flame, nothing in range",
233
+ "act.heat.hit": "{weapon}: heating {where} at {temp} \xB0C",
234
+ "act.heat.air": "{weapon}: nothing in range",
235
+ "act.throw.grenade": "{weapon}: thrown, fuse {fuse} s",
236
+ "act.throw.molotov": "{weapon}: thrown, breaks on impact",
237
+ "act.explode": "{weapon}: exploded, {energy} J within {radius} m, {n} objects in range",
238
+ "act.molotov_fire": "{weapon}: fire on the ground, radius {radius} m for {seconds} s",
239
+ "act.tool.none": "{weapon}: nothing in reach",
240
+ "act.tool.nojoint": "{weapon}: no joint near {where}",
241
+ "act.tool.refused": "{weapon} cannot undo the {kind} of {obj}; it needs: {need}",
242
+ "act.extinguish.hit": "{weapon}: foam on {what}",
243
+ "act.extinguish.air": "{weapon}: foam, nothing in range",
244
+ "act.grab": "Grabbed {name} ({kg} kg)",
245
+ "act.drop": "Dropped {name}",
246
+ "act.throw": "Threw {name} at {speed} m/s",
247
+ "act.landing": "{name} landed at {speed} m/s: {energy} J",
248
+ "act.impact": "{name} hit {where} at {speed} m/s: {energy} J of blunt damage",
249
+ "act.use_item": "{name}: {use}",
250
+ "act.tap.on": "Tap opened",
251
+ "act.tap.off": "Tap closed",
252
+ "use.drink": "drink",
253
+ "use.sip": "sip",
254
+ "use.chug": "chug",
255
+ "use.pour": "pour",
256
+ "use.pour_out": "pour out",
257
+ "use.open": "open",
258
+ "use.close": "close",
259
+ "hud.reloading": "Reloading...",
260
+ "hud.empty": "Empty",
261
+ "hud.no_fuel": "Out of fuel",
262
+ "hud.nothing_to_grab": "Nothing to grab here",
263
+ "hud.cannot_grab": "{name} cannot be picked up",
264
+ "hud.too_heavy": "{name} is too heavy ({kg} kg)",
265
+ "hud.empty_container": "{name} is empty",
266
+ "hud.refused": "Wrong tool for the {kind}",
267
+ "hud.drop": "drop",
268
+ "hud.rotate": "rotate",
269
+ "hud.throw": "throw",
270
+ "hud.tilt": "tilt (hold)",
271
+ "hud.equip": "equip {weapon}",
272
+ "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 Tab: panels \xB7 Esc: free the mouse",
273
+ "ui.log": "Behaviour log",
274
+ "ui.filter.action": "actions",
275
+ "ui.filter.event": "events",
276
+ "ui.filter.check": "checks",
277
+ "ui.autoscroll": "autoscroll",
278
+ "log.export_cancelled": "export cancelled",
279
+ "ui.copy_json": "Copy JSON",
280
+ "ui.download_ndjson": "Download NDJSON",
281
+ "ui.download_txt": "Download TXT",
282
+ "ui.clear_log": "Clear",
283
+ "ui.catalog": "Catalogue",
284
+ "ui.clear": "Clear",
285
+ "ui.reset_all": "Reset all",
286
+ "ui.slow": "Slow motion 0.25\xD7",
287
+ "ui.fast": "Time \xD710",
288
+ "ui.tap": "Tap",
289
+ "ui.quality": "Quality",
290
+ "ui.quality.low": "low",
291
+ "ui.quality.medium": "medium",
292
+ "ui.quality.high": "high",
293
+ "ui.equip": "Equip as a tool",
294
+ "ui.objects": "{n} objects",
295
+ "ui.tests": "Tests",
296
+ "ui.run_all": "Run all",
297
+ "ui.running": "Running...",
298
+ "ui.report": "{passed} passed, {failed} failed",
299
+ "ui.panel": "Panels",
300
+ "ui.inspector": "Inspector",
301
+ "ui.aim_at_something": "Aim at an object",
302
+ "ins.material": "Material",
303
+ "ins.status": "Status",
304
+ "ins.burning": "burning",
305
+ "ins.hp": "Hit points",
306
+ "ins.temperature": "Temperature",
307
+ "ins.char": "Charred",
308
+ "ins.melt": "Melted",
309
+ "ins.liters": "Liquid",
310
+ "ins.damage": "Marks",
311
+ "ins.damage_counts": "{dents}, {holes}, {cracks}",
312
+ "ins.mass": "Mass",
313
+ "ins.load": "Load",
314
+ "ins.stress": "Stress",
315
+ "ins.joints": "Joints",
316
+ "ins.open": "open",
317
+ "sc.start": "Scenario: {name}",
318
+ "sc.pass": "Scenario passed: {name}",
319
+ "sc.fail": "Scenario FAILED: {name}",
320
+ "sc.error": "the scenario threw an error: {msg}",
321
+ "leg.still": "leg_1 still attached",
322
+ "leg.gone": "leg_1 came off",
323
+ "sc.steel_through_hole": "9 mm into a steel sheet makes a through hole",
324
+ "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)",
325
+ "sc.concrete_stops_bullet": "9 mm into a concrete block does not go through",
326
+ "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)",
327
+ "sc.hammer_dents_drum": "Hammer dents a steel drum",
328
+ "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)",
329
+ "sc.glass_shatters": "Glass pane shatters after a few 9 mm shots",
330
+ "sc.glass_shatters.check": "Glass pane: broken after {shots} shot(s) into {n} shards (expected: broken, at least 4 shards)",
331
+ "sc.shotgun_splinters_crate": "Shotgun splinters a wooden crate",
332
+ "sc.shotgun_splinters_crate.check": "12 ga on the crate: {shots} shot(s), {splinters} board(s) splintered, {holes} holes (expected: at least one splintered)",
333
+ "sc.kick_chair_leg": "A kick takes a chair leg off",
334
+ "sc.kick_chair_leg.check": "Chair: {kicks} kick(s), {groups} group(s), loose pieces {before} \u2192 {after}, {leg} (expected: a loose piece appears)",
335
+ "sc.kick_detaches_leg_via_tool": "The kick tool (hotbar) takes a chair leg off",
336
+ "sc.kick_detaches_leg_via_tool.check": "Chair, kick tool: {kicks} kick(s), loose pieces {before} \u2192 {after}, {leg} (expected: a loose piece appears)",
337
+ "sc.screwdriver_shelf_board": "Screwdriver takes a screwed shelf board off in about 2.5 s",
338
+ "sc.screwdriver_shelf_board.check": "Screwdriver on the shelf board: took {secs} s, expected about {want} s, the shelf is now in {pieces}",
339
+ "sc.wrong_tool_refused": "The wrong tool is refused",
340
+ "sc.wrong_tool_refused.check": "Screwdriver on a weld: refused {refused} time(s), joints opened {opened} (expected: refused, none opened)",
341
+ "sc.lighter_burns_crate": "A lighter ignites a wooden crate and it ends as ash",
342
+ "sc.lighter_burns_crate.lit": "Lighter on the crate: burning: {lit} (expected: yes)",
343
+ "sc.lighter_burns_crate.ash": "After burning for 500 s: {ash} of {total} boards are ash, most charred {char}% (expected: all ash or charred)",
344
+ "sc.torch_melts_steel": "Wood fire does not melt a steel drum, the cutting torch does",
345
+ "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)",
346
+ "sc.torch_melts_steel.torch": "Cutting torch on the drum: {temp} \xB0C, melted {melt}% (expected: melting)",
347
+ "sc.bucket_melts_drips": "A plastic bucket melts and drips near fire",
348
+ "sc.bucket_melts_drips.check": "Bucket in flames: melted {melt}%, {temp} \xB0C, {drips} drip line(s) (expected: melting)",
349
+ "sc.gas_bottle_explodes": "A gas bottle in a fire explodes and pushes a crate",
350
+ "sc.gas_bottle_explodes.explode": "Gas bottle in fire: exploded after {seconds} s (expected: explodes)",
351
+ "sc.gas_bottle_explodes.push": "Crate 1.3 m away: moved {moved} m, lost {lost} (expected: pushed or broken)",
352
+ "sc.bottle_drop_shatters": "A glass bottle dropped from 1.5 m shatters",
353
+ "sc.bottle_drop_shatters.check": "Bottle dropped from 1.5 m: status {status}, {cracks} cracks, water stain: {stain} (expected: broken, water on the floor)",
354
+ "sc.throw_chair_at_wall": "A chair thrown at the wall deals blunt damage",
355
+ "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)",
356
+ "sc.coffee_three_sips": "Three sips of coffee lower the level and log caffeine",
357
+ "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)",
358
+ "sc.tilt_cup_spills": "Tilting the cup spills coffee and leaves a stain",
359
+ "sc.tilt_cup_spills.check": "Cup tilted: level {before} \u2192 {after} l, {stains} coffee stain(s) (expected: spilled with a stain)",
360
+ "sc.jerrycan_leaks_fuel": "Shooting the jerrycan makes a leak and a fuel stain",
361
+ "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)",
362
+ "sc.fuel_stain_burns": "A lit fuel stain burns",
363
+ "sc.fuel_stain_burns.stain": "Fuel stain on the floor after the leak: {stain} (expected: yes)",
364
+ "sc.fuel_stain_burns.ignite": "Lighter on the stain: {ignited} ignition(s), fire reached nearby objects: {near} (expected: ignited)",
365
+ "sc.fuel_stain_burns.out": "After 30 s the stain burned out: {burned} (expected: yes)",
366
+ "roof.standing": "roof standing",
367
+ "roof.fell": "roof fell",
368
+ "sc.one_column_roof_stands": "One column destroyed: the roof stays up",
369
+ "sc.one_column_roof_stands.check": "Frame with one column gone: {roof}, heaviest collapse {heavy} kg, peak stress {stress}% (expected: standing, no big collapse)",
370
+ "sc.two_columns_roof_collapses": "Two columns destroyed: the roof collapses",
371
+ "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)",
372
+ "sc.extinguisher_puts_out_fire": "The extinguisher puts out a burning crate",
373
+ "sc.extinguisher_puts_out_fire.burning": "Crate set on fire: burning: {burning} (expected: yes)",
374
+ "sc.extinguisher_puts_out_fire.out": "After the extinguisher: still burning: {burning}, {events} extinguish event(s) (expected: out)"
375
+ }
376
+ };
377
+ var ptBR = {
378
+ items: {
379
+ chair: "Cadeira de madeira",
380
+ sofa: "Sof\xE1",
381
+ shelf: "Estante com prateleiras",
382
+ old_tv: "TV antiga",
383
+ steel_sheet: "Chapa de a\xE7o com suporte",
384
+ glass_pane: "Vidro de janela",
385
+ concrete_block: "Bloco de concreto",
386
+ brick_column: "Coluna de tijolos",
387
+ building_frame: "Estrutura de pr\xE9dio",
388
+ car_door: "Porta de carro",
389
+ small_tree: "\xC1rvore pequena",
390
+ potted_plant: "Vaso de planta",
391
+ coffee_cup: "Copo de caf\xE9",
392
+ glass_bottle: "Garrafa de vidro",
393
+ wooden_crate: "Caixote de madeira",
394
+ steel_drum: "Tambor de a\xE7o",
395
+ plastic_bucket: "Balde de pl\xE1stico",
396
+ jerrycan: "Gal\xE3o",
397
+ gas_bottle: "Botij\xE3o de g\xE1s"
398
+ },
399
+ groups: { furniture: "M\xF3veis", structure: "Estrutura", vehicles: "Ve\xEDculos", plants: "Plantas", items: "Itens", containers: "Recipientes" },
400
+ text: {
401
+ "locale.decimal": ",",
402
+ yes: "sim",
403
+ no: "n\xE3o",
404
+ piece: "pe\xE7a",
405
+ coords: "x {x} \xB7 z {z}",
406
+ "count.piece.one": "1 pe\xE7a",
407
+ "count.piece.other": "{n} pe\xE7as",
408
+ "count.joint.one": "1 encaixe",
409
+ "count.joint.other": "{n} encaixes",
410
+ "count.dent.one": "1 amassado",
411
+ "count.dent.other": "{n} amassados",
412
+ "count.hole.one": "1 furo",
413
+ "count.hole.other": "{n} furos",
414
+ "count.crack.one": "1 trinca",
415
+ "count.crack.other": "{n} trincas",
416
+ "joint.generic": "junta",
417
+ "lost.broken": "quebrou",
418
+ "lost.melted": "derreteu",
419
+ "lost.ashes": "virou cinza",
420
+ "tool.none": "s\xF3 com a m\xE3o",
421
+ "arena.target_wall": "Parede de alvo",
422
+ "arena.table": "Mesa",
423
+ "arena.shelf": "Estante",
424
+ "surface.floor": "no ch\xE3o",
425
+ "surface.ground": "no ch\xE3o",
426
+ "surface.table": "na mesa",
427
+ "by.break": "arrebentou",
428
+ "by.tool": "desmontado com ferramenta",
429
+ "by.part_lost": "a pe\xE7a ao lado se perdeu",
430
+ "dmg.ballistic": "de tiro",
431
+ "dmg.blunt": "de impacto",
432
+ "dmg.slash": "de corte",
433
+ "dmg.explosive": "de explos\xE3o",
434
+ "dmg.fire": "de fogo",
435
+ "dmg.heat": "de calor",
436
+ "effect.caffeine": "cafe\xEDna",
437
+ "effect.alcohol": "\xE1lcool",
438
+ "effect.poison": "veneno",
439
+ "effect.disgust": "nojo",
440
+ "ev.dent": "{where}: amassado de {mm} mm, {hp}",
441
+ "ev.hole.through": "{where}: furo de {mm} mm atravessou, {hp}",
442
+ "ev.hole.stuck": "{where}: furo de {mm} mm n\xE3o atravessou, {hp}",
443
+ "ev.dents": "{where}: {n} amassados, o mais fundo com {mm} mm, {hp}",
444
+ "ev.holes": "{where}: {n} furos de {mm} mm, {thr} atravessaram, {hp}",
445
+ "ev.leak.tail": ", vazando {liquid} a {rate} l/s",
446
+ "ev.leak.alone": "{where}: vazando {liquid} a {rate} l/s",
447
+ "ev.crack": "{where}: trincou, {rays} raios de at\xE9 {cm} cm, {hp}",
448
+ "ev.ricochet": "{where}: a bala ricocheteou, sobraram {energy} J",
449
+ "ev.fracture.shatter": "{where}: estilha\xE7ou em {n} cacos",
450
+ "ev.fracture.crumble": "{where}: esfarelou em {n} peda\xE7os",
451
+ "ev.fracture.splinter": "{where}: lascou em {n} peda\xE7os",
452
+ "ev.fracture.tear": "{where}: rasgou em {n} retalhos",
453
+ "ev.fracture.snap": "{where}: partiu em {n} peda\xE7os",
454
+ "ev.fracture.none": "{where}: quebrou em {n} peda\xE7os",
455
+ "ev.detach": "{obj}: {a} soltou de {b} ({by}); o objeto agora tem {pieces}",
456
+ "ev.detach.many.break": "{obj}: {joints} arrebentaram; o objeto agora tem {pieces}",
457
+ "ev.detach.many.tool": "{obj}: {joints} soltos com ferramenta; o objeto agora tem {pieces}",
458
+ "ev.lost.tail": " e se soltou de {joints}; o objeto agora tem {pieces}",
459
+ "ev.lost.alone": "{where}: {verb} e se soltou de {joints}; o objeto agora tem {pieces}",
460
+ "ev.cracks": "{where}: rachando sob o peso, {n} trincas at\xE9 agora, a maior com {cm} cm, {hp}",
461
+ "ev.disassemble": "{obj}: soltando {kind} entre {a} e {b} com {tool}: {pct}%",
462
+ "ev.ignite": "{where}: pegou fogo a {temp} \xB0C",
463
+ "ev.ignite.many": "{obj}: {n} pe\xE7as pegaram fogo a {temp} \xB0C ({parts})",
464
+ "ev.extinguish": "{where}: fogo apagado",
465
+ "ev.extinguish.many": "{obj}: fogo apagado em {n} pe\xE7as ({parts})",
466
+ "ev.burnout": "{where}: virou cinza",
467
+ "ev.melt": "{where}: derretendo, {pct}% j\xE1 derreteu",
468
+ "ev.drip": "{where}: pingando material derretido",
469
+ "ev.explode": "{where}: explodiu, {energy} J em {radius} m",
470
+ "ev.destroyed": "{obj}: destru\xEDdo",
471
+ "ev.damage": "{where}: dano {type} de {amount}, {hp}",
472
+ "ev.damage.overload": "{where}: esmagado pelo peso que sustenta, perdeu {amount} hp at\xE9 agora, {hp}",
473
+ "ev.stress": "{where}: sobrecarregado a {stress}% da capacidade, sustentando {load} kg",
474
+ "ev.collapse": "{obj}: desabou, ca\xEDram {pieces} ({mass} kg): {parts}",
475
+ "ev.stain_ignite": "A mancha de {liquid} em x {x} \xB7 z {z} pegou fogo e vai queimar por uns {seconds} s",
476
+ "ev.stain_burned": "A mancha em chamas se consumiu",
477
+ "ev.fire_dries": "O fogo secou as manchas molhadas por perto",
478
+ "fl.fill": "{who}: encheu de {liquid} (+{liters} l)",
479
+ "fl.pour": "{who}: despejando {liquid} a {rate} l/s",
480
+ "fl.spill": "{who}: derramou {liquid} pela borda a {rate} l/s",
481
+ "fl.leak": "{who}: vazando {liquid} a {rate} l/s",
482
+ "fl.drink": "{who}: bebeu {liters} l, sede +{thirst}",
483
+ "fl.burn": "quente demais: queimou a boca",
484
+ "fl.empty": "{who}: vazio",
485
+ "fl.splash": "Respingo de {liquid}",
486
+ "fl.stain": "Mancha de {liquid} {surface} em {where}",
487
+ "fl.stains": "{n} manchas de {liquid} {surface}, a \xFAltima em {where}",
488
+ "fl.dried": "Uma mancha secou",
489
+ "fl.dried.mark": "Uma mancha secou e deixou marca",
490
+ "fl.dried.known": "A mancha de {liquid} {surface} em {where} secou",
491
+ "fl.dried.known.mark": "A mancha de {liquid} {surface} em {where} secou e deixou marca",
492
+ "fl.dried.many": "{n} manchas de {liquid} secaram",
493
+ "fl.dried.many.mark": "{n} manchas de {liquid} secaram e deixaram marca",
494
+ "fl.mix": "{who}: {from} misturou em {into}",
495
+ "act.anim_start": '{name}: anima\xE7\xE3o "{clip}" ({seconds} s)',
496
+ "act.anim_event": '{name}: "{clip}" evento {type}',
497
+ "act.anim_end": '{name}: anima\xE7\xE3o "{clip}" terminou',
498
+ "act.module_loaded": 'M\xF3dulo "{name}" carregado: {items} item(ns), {scenarios} cen\xE1rio(s), verifica\xE7\xF5es do contrato: {result}',
499
+ "mod.all_ok": "todas passaram",
500
+ "mod.some_failed": "algumas FALHARAM",
501
+ "mod.destructible_ok": "{name}: contrato de destrui\xE7\xE3o ok ({parts} pe\xE7as, golpe e estado bem formados)",
502
+ "mod.destructible_fail": "{name}: contrato de destrui\xE7\xE3o quebrado: {problems}",
503
+ "mod.animation_ok": '{name}: anima\xE7\xE3o "{clip}" ok ({seconds} s)',
504
+ "mod.spec_ok": "{name}: spec ok ({parts} pe\xE7as)",
505
+ "mod.spec_fail": "{name}: spec quebrada: {problems}",
506
+ "mod.spec_warn": "{name}: aviso na spec: {warning}",
507
+ "mod.animation_fail": '{name}: anima\xE7\xE3o "{clip}" quebrada: {problems}',
508
+ "ui.animations": "Anima\xE7\xF5es",
509
+ "ui.animations_aim": "Mire num item ou segure-o para ver as anima\xE7\xF5es.",
510
+ "ui.animations_none": "Este item n\xE3o traz anima\xE7\xF5es.",
511
+ "act.spawn": "Criou {name} em x {x} \xB7 z {z}",
512
+ "act.clear": "Limpou a sala",
513
+ "act.reset": "Consertou todos os objetos",
514
+ "act.tool": "Equipou {weapon}",
515
+ "act.shoot.hit": "{weapon}: disparo, {energy} J, acertou {where} a {dist} m",
516
+ "act.shoot.miss": "{weapon}: disparo, {energy} J, n\xE3o acertou nada",
517
+ "act.shoot.pellets": "{weapon}: disparo, {n} proj\xE9teis de at\xE9 {energy} J, {hits} acertaram {where} a {dist} m",
518
+ "act.melee.hit": "{weapon}: golpe, {energy} J, acertou {where} a {dist} m",
519
+ "act.melee.miss": "{weapon}: golpe no ar",
520
+ "act.flame.hit": "{weapon}: chama a {temp} \xB0C em {where}",
521
+ "act.flame.air": "{weapon}: chama, nada ao alcance",
522
+ "act.heat.hit": "{weapon}: esquentando {where} a {temp} \xB0C",
523
+ "act.heat.air": "{weapon}: nada ao alcance",
524
+ "act.throw.grenade": "{weapon}: arremessada, pavio de {fuse} s",
525
+ "act.throw.molotov": "{weapon}: arremessado, quebra no impacto",
526
+ "act.explode": "{weapon}: explodiu, {energy} J em {radius} m, {n} objetos ao alcance",
527
+ "act.molotov_fire": "{weapon}: fogo no ch\xE3o, raio de {radius} m por {seconds} s",
528
+ "act.tool.none": "{weapon}: nada ao alcance",
529
+ "act.tool.nojoint": "{weapon}: nenhuma junta perto de {where}",
530
+ "act.tool.refused": "{weapon} n\xE3o solta {kind} de {obj}; precisa de: {need}",
531
+ "act.extinguish.hit": "{weapon}: espuma em {what}",
532
+ "act.extinguish.air": "{weapon}: espuma, nada ao alcance",
533
+ "act.grab": "Pegou {name} ({kg} kg)",
534
+ "act.drop": "Soltou {name}",
535
+ "act.throw": "Arremessou {name} a {speed} m/s",
536
+ "act.landing": "{name} caiu a {speed} m/s: {energy} J",
537
+ "act.impact": "{name} bateu em {where} a {speed} m/s: {energy} J de dano de impacto",
538
+ "act.use_item": "{name}: {use}",
539
+ "act.tap.on": "Abriu a torneira",
540
+ "act.tap.off": "Fechou a torneira",
541
+ "use.drink": "beber",
542
+ "use.sip": "golinho",
543
+ "use.chug": "virar",
544
+ "use.pour": "despejar",
545
+ "use.pour_out": "esvaziar",
546
+ "use.open": "abrir",
547
+ "use.close": "fechar",
548
+ "hud.reloading": "Recarregando...",
549
+ "hud.empty": "Sem muni\xE7\xE3o",
550
+ "hud.no_fuel": "Sem combust\xEDvel",
551
+ "hud.nothing_to_grab": "Nada para pegar aqui",
552
+ "hud.cannot_grab": "{name} n\xE3o d\xE1 para pegar",
553
+ "hud.too_heavy": "{name} \xE9 pesado demais ({kg} kg)",
554
+ "hud.empty_container": "{name} est\xE1 vazio",
555
+ "hud.refused": "Ferramenta errada para {kind}",
556
+ "hud.drop": "soltar",
557
+ "hud.rotate": "girar",
558
+ "hud.throw": "arremessar",
559
+ "hud.tilt": "inclinar (segurar)",
560
+ "hud.equip": "equipar {weapon}",
561
+ "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 Tab: pain\xE9is \xB7 Esc: soltar o mouse",
562
+ "ui.log": "Registro de comportamento",
563
+ "ui.filter.action": "a\xE7\xF5es",
564
+ "ui.filter.event": "eventos",
565
+ "ui.filter.check": "testes",
566
+ "ui.autoscroll": "rolar sozinho",
567
+ "log.export_cancelled": "exporta\xE7\xE3o cancelada",
568
+ "ui.copy_json": "Copiar JSON",
569
+ "ui.download_ndjson": "Baixar NDJSON",
570
+ "ui.download_txt": "Baixar TXT",
571
+ "ui.clear_log": "Limpar",
572
+ "ui.catalog": "Cat\xE1logo",
573
+ "ui.clear": "Limpar",
574
+ "ui.reset_all": "Consertar tudo",
575
+ "ui.slow": "C\xE2mera lenta 0,25\xD7",
576
+ "ui.fast": "Tempo \xD710",
577
+ "ui.tap": "Torneira",
578
+ "ui.quality": "Qualidade",
579
+ "ui.quality.low": "baixa",
580
+ "ui.quality.medium": "m\xE9dia",
581
+ "ui.quality.high": "alta",
582
+ "ui.equip": "Equipar como ferramenta",
583
+ "ui.objects": "{n} objetos",
584
+ "ui.tests": "Testes",
585
+ "ui.run_all": "Rodar todos",
586
+ "ui.running": "Rodando...",
587
+ "ui.report": "{passed} passaram, {failed} falharam",
588
+ "ui.panel": "Pain\xE9is",
589
+ "ui.inspector": "Inspetor",
590
+ "ui.aim_at_something": "Mire em um objeto",
591
+ "ins.material": "Material",
592
+ "ins.status": "Estado",
593
+ "ins.burning": "em chamas",
594
+ "ins.hp": "Pontos de vida",
595
+ "ins.temperature": "Temperatura",
596
+ "ins.char": "Carbonizado",
597
+ "ins.melt": "Derretido",
598
+ "ins.liters": "L\xEDquido",
599
+ "ins.damage": "Marcas",
600
+ "ins.damage_counts": "{dents}, {holes}, {cracks}",
601
+ "ins.mass": "Massa",
602
+ "ins.load": "Carga",
603
+ "ins.stress": "Tens\xE3o",
604
+ "ins.joints": "Juntas",
605
+ "ins.open": "aberta",
606
+ "sc.start": "Cen\xE1rio: {name}",
607
+ "sc.pass": "Cen\xE1rio passou: {name}",
608
+ "sc.fail": "Cen\xE1rio FALHOU: {name}",
609
+ "sc.error": "o cen\xE1rio lan\xE7ou um erro: {msg}",
610
+ "leg.still": "leg_1 continua presa",
611
+ "leg.gone": "leg_1 soltou",
612
+ "sc.steel_through_hole": "Tiro de 9 mm em chapa de a\xE7o faz furo que atravessa",
613
+ "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)",
614
+ "sc.concrete_stops_bullet": "Tiro de 9 mm em bloco de concreto n\xE3o atravessa",
615
+ "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)",
616
+ "sc.hammer_dents_drum": "Martelo amassa um tambor de a\xE7o",
617
+ "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)",
618
+ "sc.glass_shatters": "Vidro estilha\xE7a depois de alguns tiros de 9 mm",
619
+ "sc.glass_shatters.check": "Vidro: quebrou depois de {shots} tiro(s) em {n} cacos (esperado: quebrado, pelo menos 4 cacos)",
620
+ "sc.shotgun_splinters_crate": "Escopeta lasca um caixote de madeira",
621
+ "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)",
622
+ "sc.kick_chair_leg": "Um chute solta a perna da cadeira",
623
+ "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)",
624
+ "sc.kick_detaches_leg_via_tool": "A ferramenta chute (barra de atalhos) solta a perna da cadeira",
625
+ "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)",
626
+ "sc.screwdriver_shelf_board": "Chave de fenda solta a prateleira parafusada em cerca de 2,5 s",
627
+ "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}",
628
+ "sc.wrong_tool_refused": "A ferramenta errada \xE9 recusada",
629
+ "sc.wrong_tool_refused.check": "Chave de fenda numa solda: recusou {refused} vez(es), juntas abertas {opened} (esperado: recusar, nenhuma aberta)",
630
+ "sc.lighter_burns_crate": "Isqueiro acende o caixote de madeira e ele vira cinza",
631
+ "sc.lighter_burns_crate.lit": "Isqueiro no caixote: pegou fogo: {lit} (esperado: sim)",
632
+ "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)",
633
+ "sc.torch_melts_steel": "Fogo de madeira n\xE3o derrete o tambor de a\xE7o, o ma\xE7arico derrete",
634
+ "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)",
635
+ "sc.torch_melts_steel.torch": "Ma\xE7arico no tambor: {temp} \xB0C, derreteu {melt}% (esperado: derretendo)",
636
+ "sc.bucket_melts_drips": "Balde de pl\xE1stico derrete e pinga perto do fogo",
637
+ "sc.bucket_melts_drips.check": "Balde nas chamas: derreteu {melt}%, {temp} \xB0C, {drips} linha(s) de pingos (esperado: derretendo)",
638
+ "sc.gas_bottle_explodes": "Botij\xE3o de g\xE1s no fogo explode e empurra um caixote",
639
+ "sc.gas_bottle_explodes.explode": "Botij\xE3o no fogo: explodiu depois de {seconds} s (esperado: explode)",
640
+ "sc.gas_bottle_explodes.push": "Caixote a 1,3 m: andou {moved} m, perdeu {lost} (esperado: empurrado ou quebrado)",
641
+ "sc.bottle_drop_shatters": "Garrafa de vidro solta de 1,5 m estilha\xE7a",
642
+ "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)",
643
+ "sc.throw_chair_at_wall": "Cadeira arremessada na parede causa dano de impacto",
644
+ "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)",
645
+ "sc.coffee_three_sips": "Tr\xEAs goles de caf\xE9 baixam o n\xEDvel e registram cafe\xEDna",
646
+ "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)",
647
+ "sc.tilt_cup_spills": "Inclinar o copo derrama caf\xE9 e deixa mancha",
648
+ "sc.tilt_cup_spills.check": "Copo inclinado: n\xEDvel {before} \u2192 {after} l, {stains} mancha(s) de caf\xE9 (esperado: derramou com mancha)",
649
+ "sc.jerrycan_leaks_fuel": "Tiro no gal\xE3o faz vazamento e mancha de combust\xEDvel",
650
+ "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)",
651
+ "sc.fuel_stain_burns": "Mancha de combust\xEDvel acesa queima",
652
+ "sc.fuel_stain_burns.stain": "Mancha de combust\xEDvel no ch\xE3o depois do vazamento: {stain} (esperado: sim)",
653
+ "sc.fuel_stain_burns.ignite": "Isqueiro na mancha: {ignited} igni\xE7\xE3o(\xF5es), o fogo alcan\xE7ou objetos por perto: {near} (esperado: acendeu)",
654
+ "sc.fuel_stain_burns.out": "Depois de 30 s a mancha se consumiu: {burned} (esperado: sim)",
655
+ "roof.standing": "laje de p\xE9",
656
+ "roof.fell": "laje caiu",
657
+ "sc.one_column_roof_stands": "Pilar destru\xEDdo: a laje fica de p\xE9",
658
+ "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)",
659
+ "sc.two_columns_roof_collapses": "Dois pilares destru\xEDdos: a laje desaba",
660
+ "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)",
661
+ "sc.extinguisher_puts_out_fire": "Extintor apaga um caixote em chamas",
662
+ "sc.extinguisher_puts_out_fire.burning": "Caixote incendiado: queimando: {burning} (esperado: sim)",
663
+ "sc.extinguisher_puts_out_fire.out": "Depois do extintor: ainda queimando: {burning}, {events} evento(s) de apagar (esperado: apagado)"
664
+ }
665
+ };
666
+ var locales = { en, "pt-BR": ptBR };
667
+ var resolveStrings = (locale) => locales[locale ?? "en"] ?? locales.en;
668
+
669
+ // src/items.ts
670
+ var DEMO_MATERIALS = [];
671
+ var box = (id, material, size, position, extra = {}) => ({ id, material, shape: { kind: "box", size }, position, ...extra });
672
+ var cyl = (id, material, radius, height, position, extra = {}) => ({
673
+ id,
674
+ material,
675
+ shape: { kind: "cylinder", radius, height },
676
+ position,
677
+ ...extra
678
+ });
679
+ var sph = (id, material, radius, position, extra = {}) => ({ id, material, shape: { kind: "sphere", radius }, position, ...extra });
680
+ var joint = (a, b, kind, extra = {}) => ({ a, b, kind, ...extra });
681
+ function brickWallSpec(cols = 8, rows = 6, panel = [1.25, 0.5, 0.2]) {
682
+ const parts = [];
683
+ const joints = [];
684
+ const id = (c, r) => `brick_${r}_${c}`;
685
+ for (let r = 0; r < rows; r++) {
686
+ for (let c = 0; c < cols; c++) {
687
+ parts.push(box(id(c, r), "brick", panel, [(c - (cols - 1) / 2) * panel[0], panel[1] / 2 + r * panel[1], 0], { anchored: r === 0 }));
688
+ if (c > 0) joints.push(joint(id(c - 1, r), id(c, r), "mortar"));
689
+ if (r > 0) joints.push(joint(id(c, r - 1), id(c, r), "mortar"));
690
+ }
691
+ }
692
+ return { name: "brick wall", parts, joints, tags: ["structure"] };
693
+ }
694
+ var chair = () => {
695
+ const legs = [
696
+ [-0.18, -0.18],
697
+ [0.18, -0.18],
698
+ [-0.18, 0.18],
699
+ [0.18, 0.18]
700
+ ];
701
+ return {
702
+ name: "chair",
703
+ parts: [
704
+ box("seat", "wood_soft", [0.42, 0.04, 0.42], [0, 0.45, 0]),
705
+ ...legs.map(([x, z], i) => box(`leg_${i + 1}`, "wood_soft", [0.04, 0.43, 0.04], [x, 0.215, z])),
706
+ box("backrest", "wood_soft", [0.4, 0.3, 0.03], [0, 0.64, -0.195])
707
+ ],
708
+ joints: [...legs.map((_, i) => joint(`leg_${i + 1}`, "seat", "screw")), joint("backrest", "seat", "screw")],
709
+ tags: ["furniture"]
710
+ };
711
+ };
712
+ var crate = () => ({
713
+ name: "wooden crate",
714
+ parts: [
715
+ box("front", "wood_soft", [0.5, 0.5, 0.02], [0, 0.25, 0.24], { hp: 70 }),
716
+ box("back", "wood_soft", [0.5, 0.5, 0.02], [0, 0.25, -0.24], { hp: 70 }),
717
+ box("left", "wood_soft", [0.02, 0.5, 0.46], [-0.24, 0.25, 0], { hp: 70 }),
718
+ box("right", "wood_soft", [0.02, 0.5, 0.46], [0.24, 0.25, 0], { hp: 70 }),
719
+ box("bottom", "wood_soft", [0.46, 0.02, 0.46], [0, 0.01, 0], { hp: 70 })
720
+ ],
721
+ joints: [
722
+ joint("front", "left", "nail"),
723
+ joint("front", "right", "nail"),
724
+ joint("back", "left", "nail"),
725
+ joint("back", "right", "nail"),
726
+ joint("front", "bottom", "nail"),
727
+ joint("back", "bottom", "nail"),
728
+ joint("left", "bottom", "nail"),
729
+ joint("right", "bottom", "nail")
730
+ ],
731
+ tags: ["container"]
732
+ });
733
+ var drum = () => ({
734
+ name: "steel drum",
735
+ parts: [
736
+ cyl("drum_body", "steel", 0.29, 0.86, [0, 0.45, 0], { mass: 12, thickness: 12e-4 }),
737
+ cyl("drum_top", "steel", 0.29, 0.02, [0, 0.89, 0], { mass: 1.5, thickness: 12e-4 }),
738
+ cyl("drum_bottom", "steel", 0.29, 0.02, [0, 0.01, 0], { mass: 1.5, thickness: 12e-4 })
739
+ ],
740
+ joints: [joint("drum_body", "drum_top", "weld"), joint("drum_body", "drum_bottom", "weld")],
741
+ tags: ["container"]
742
+ });
743
+ var steelSheet = () => ({
744
+ name: "steel sheet",
745
+ parts: [
746
+ box("sheet", "steel", [1, 1, 3e-3], [0, 1.1, 0]),
747
+ box("post_l", "steel", [0.04, 0.54, 0.04], [-0.35, 0.33, 0], { mass: 4 }),
748
+ box("post_r", "steel", [0.04, 0.54, 0.04], [0.35, 0.33, 0], { mass: 4 }),
749
+ box("foot_l", "steel", [0.06, 0.06, 0.5], [-0.35, 0.03, 0], { mass: 6 }),
750
+ box("foot_r", "steel", [0.06, 0.06, 0.5], [0.35, 0.03, 0], { mass: 6 })
751
+ ],
752
+ joints: [joint("sheet", "post_l", "weld"), joint("sheet", "post_r", "weld"), joint("post_l", "foot_l", "weld"), joint("post_r", "foot_r", "weld")],
753
+ tags: ["structure"]
754
+ });
755
+ var glassPane = () => ({
756
+ name: "glass pane",
757
+ parts: [
758
+ box("pane", "glass", [0.8, 1, 6e-3], [0, 1.05, 0], { hp: 40 }),
759
+ box("frame_top", "aluminium", [0.88, 0.04, 0.04], [0, 1.57, 0], { mass: 1 }),
760
+ box("frame_bottom", "aluminium", [0.88, 0.04, 0.04], [0, 0.53, 0], { mass: 1 }),
761
+ box("frame_l", "aluminium", [0.04, 1.04, 0.04], [-0.42, 1.05, 0], { mass: 1 }),
762
+ box("frame_r", "aluminium", [0.04, 1.04, 0.04], [0.42, 1.05, 0], { mass: 1 }),
763
+ box("foot_l", "steel", [0.06, 0.5, 0.4], [-0.35, 0.25, 0], { mass: 5 }),
764
+ box("foot_r", "steel", [0.06, 0.5, 0.4], [0.35, 0.25, 0], { mass: 5 })
765
+ ],
766
+ joints: [
767
+ joint("pane", "frame_top", "clip"),
768
+ joint("pane", "frame_bottom", "clip"),
769
+ joint("pane", "frame_l", "clip"),
770
+ joint("pane", "frame_r", "clip"),
771
+ joint("frame_bottom", "foot_l", "bolt"),
772
+ joint("frame_bottom", "foot_r", "bolt"),
773
+ joint("frame_l", "frame_top", "weld"),
774
+ joint("frame_r", "frame_top", "weld"),
775
+ joint("frame_l", "frame_bottom", "weld"),
776
+ joint("frame_r", "frame_bottom", "weld")
777
+ ],
778
+ tags: ["structure"]
779
+ });
780
+ var concreteBlock = () => ({
781
+ name: "concrete block",
782
+ parts: [box("block", "concrete", [0.4, 0.4, 0.4], [0, 0.2, 0])],
783
+ tags: ["structure"]
784
+ });
785
+ var brickColumn = () => {
786
+ const parts = [];
787
+ const joints = [];
788
+ const layers = 8;
789
+ const id = (l, x, z) => `brick_${l}_${x}${z}`;
790
+ for (let l = 0; l < layers; l++) {
791
+ for (let x = 0; x < 2; x++) {
792
+ for (let z = 0; z < 2; z++) {
793
+ 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 }));
794
+ if (x > 0) joints.push(joint(id(l, x - 1, z), id(l, x, z), "mortar"));
795
+ if (z > 0) joints.push(joint(id(l, x, z - 1), id(l, x, z), "mortar"));
796
+ if (l > 0) joints.push(joint(id(l - 1, x, z), id(l, x, z), "mortar"));
797
+ }
798
+ }
799
+ }
800
+ return { name: "brick column", parts, joints, tags: ["structure"] };
801
+ };
802
+ 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" });
803
+ var bucket = () => ({
804
+ name: "plastic bucket",
805
+ parts: [
806
+ cyl("bucket_body", "plastic", 0.14, 0.28, [0, 0.14, 0], { mass: 0.5, thickness: 2e-3 }),
807
+ box("handle", "plastic", [0.3, 0.012, 0.012], [0, 0.3, 0], { mass: 0.05 })
808
+ ],
809
+ joints: [joint("handle", "bucket_body", "clip")],
810
+ tags: ["container"]
811
+ });
812
+ var bottle = () => ({
813
+ name: "glass bottle",
814
+ parts: [
815
+ cyl("bottle_body", "glass", 0.037, 0.17, [0, 0.085, 0], { mass: 0.3, thickness: 3e-3, contains: { fluid: "water", liters: 0.5 } }),
816
+ cyl("bottle_neck", "glass", 0.015, 0.09, [0, 0.215, 0], { mass: 0.08, thickness: 2e-3 })
817
+ ],
818
+ joints: [joint("bottle_neck", "bottle_body", "fused")],
819
+ tags: ["item"]
820
+ });
821
+ var cup = () => ({
822
+ name: "coffee cup",
823
+ parts: [
824
+ cyl("cup", "ceramic", 0.05, 0.11, [0, 0.055, 0], { mass: 0.3, thickness: 4e-3, contains: { fluid: "coffee", liters: 0.25 } }),
825
+ box("cup_handle", "ceramic", [0.02, 0.06, 0.014], [0.068, 0.06, 0], { mass: 0.04 })
826
+ ],
827
+ joints: [joint("cup_handle", "cup", "fused")],
828
+ tags: ["item"]
829
+ });
830
+ var jerrycan = () => ({
831
+ name: "jerrycan",
832
+ parts: [
833
+ box("tank", "plastic", [0.34, 0.45, 0.14], [0, 0.225, 0], { mass: 1.2, thickness: 3e-3, contains: { fluid: "fuel", liters: 20 } }),
834
+ cyl("cap", "plastic", 0.025, 0.03, [0.08, 0.465, 0], { mass: 0.05 }),
835
+ box("handle", "plastic", [0.1, 0.03, 0.03], [-0.07, 0.465, 0], { mass: 0.1 })
836
+ ],
837
+ joints: [joint("cap", "tank", "screw"), joint("handle", "tank", "fused")],
838
+ tags: ["container"]
839
+ });
840
+ var gasBottle = () => ({
841
+ name: "gas bottle",
842
+ parts: [
843
+ cyl("cylinder", "steel", 0.15, 0.7, [0, 0.4, 0], {
844
+ mass: 11,
845
+ thickness: 3e-3,
846
+ contains: { fluid: "gas", liters: 13, pressurized: true },
847
+ explodes: { energy: 12e4, radius: 5, atC: 300 }
848
+ }),
849
+ cyl("valve", "steel", 0.03, 0.1, [0, 0.8, 0], { mass: 0.5 }),
850
+ cyl("foot_ring", "steel", 0.14, 0.05, [0, 0.025, 0], { mass: 1 })
851
+ ],
852
+ joints: [joint("valve", "cylinder", "weld"), joint("foot_ring", "cylinder", "weld")],
853
+ tags: ["container"]
854
+ });
855
+ var tree = () => ({
856
+ name: "small tree",
857
+ parts: [
858
+ cyl("trunk", "green_wood", 0.08, 2.2, [0, 1.1, 0], { anchored: true }),
859
+ cyl("branch_1", "green_wood", 0.03, 0.8, [0.34, 1.7, 0], { rotation: [0, 0, -0.9] }),
860
+ cyl("branch_2", "green_wood", 0.03, 0.8, [-0.3, 1.9, 0.12], { rotation: [0, 0, 0.8] }),
861
+ cyl("branch_3", "green_wood", 0.025, 0.7, [0.05, 2, -0.28], { rotation: [0.8, 0, 0] }),
862
+ sph("crown_top", "foliage", 0.55, [0, 2.55, 0], { mass: 2 }),
863
+ sph("crown_1", "foliage", 0.38, [0.62, 2, 0], { mass: 1 }),
864
+ sph("crown_2", "foliage", 0.38, [-0.58, 2.2, 0.15], { mass: 1 }),
865
+ sph("crown_3", "foliage", 0.32, [0.05, 2.3, -0.55], { mass: 1 })
866
+ ],
867
+ joints: [
868
+ joint("branch_1", "trunk", "stem"),
869
+ joint("branch_2", "trunk", "stem"),
870
+ joint("branch_3", "trunk", "stem"),
871
+ joint("crown_top", "trunk", "stem"),
872
+ joint("crown_1", "branch_1", "stem"),
873
+ joint("crown_2", "branch_2", "stem"),
874
+ joint("crown_3", "branch_3", "stem")
875
+ ],
876
+ tags: ["plant"]
877
+ });
878
+ var pottedPlant = () => ({
879
+ name: "potted plant",
880
+ parts: [
881
+ cyl("pot", "ceramic", 0.15, 0.25, [0, 0.125, 0], { mass: 2, thickness: 8e-3 }),
882
+ cyl("soil", "soil", 0.14, 0.06, [0, 0.26, 0], { mass: 1.5 }),
883
+ sph("leaves_1", "foliage", 0.16, [0, 0.46, 0], { mass: 0.3 }),
884
+ sph("leaves_2", "foliage", 0.12, [0.12, 0.38, 0.04], { mass: 0.2 }),
885
+ sph("leaves_3", "foliage", 0.12, [-0.1, 0.4, -0.06], { mass: 0.2 })
886
+ ],
887
+ joints: [joint("soil", "pot", "fused"), joint("leaves_1", "soil", "stem"), joint("leaves_2", "soil", "stem"), joint("leaves_3", "soil", "stem")],
888
+ tags: ["plant"]
889
+ });
890
+ var oldTv = () => ({
891
+ name: "old TV",
892
+ parts: [
893
+ box("stand", "wood_soft", [0.6, 0.35, 0.42], [0, 0.175, 0]),
894
+ box("shell", "plastic", [0.58, 0.44, 0.42], [0, 0.57, 0], { mass: 3, thickness: 4e-3 }),
895
+ box("screen", "glass", [0.42, 0.32, 0.012], [0, 0.57, 0.212], { mass: 1.5 }),
896
+ box("coil", "copper", [0.2, 0.1, 0.2], [0, 0.55, -0.05], { mass: 1.5 })
897
+ ],
898
+ joints: [joint("shell", "stand", "screw"), joint("screen", "shell", "clip"), joint("coil", "shell", "screw")],
899
+ tags: ["furniture"]
900
+ });
901
+ var carDoor = () => ({
902
+ name: "car door",
903
+ parts: [
904
+ box("post", "steel", [0.1, 1.3, 0.1], [-0.58, 0.65, 0], { mass: 12, anchored: true }),
905
+ box("skin", "steel", [1, 0.8, 0.05], [0, 0.75, 0], { mass: 14, thickness: 8e-4 }),
906
+ box("window", "glass", [0.78, 0.32, 0.01], [0.04, 1.31, 0.01], { mass: 3 }),
907
+ box("seal", "rubber", [0.84, 0.03, 0.03], [0.04, 1.14, 0.01], { mass: 0.3 })
908
+ ],
909
+ joints: [
910
+ joint("skin", "post", "hinge", { id: "hinge_upper" }),
911
+ joint("skin", "post", "hinge", { id: "hinge_lower" }),
912
+ joint("window", "seal", "clip"),
913
+ joint("seal", "skin", "clip")
914
+ ],
915
+ tags: ["vehicle"]
916
+ });
917
+ var sofa = () => ({
918
+ name: "sofa",
919
+ parts: [
920
+ box("frame", "wood_soft", [1.8, 0.3, 0.8], [0, 0.15, 0], { mass: 14 }),
921
+ box("seat_cushion", "foam", [1.5, 0.18, 0.62], [0, 0.39, 0.06], { mass: 4 }),
922
+ box("back_cushion", "foam", [1.5, 0.5, 0.16], [0, 0.65, -0.3], { mass: 4 }),
923
+ box("arm_l", "fabric", [0.15, 0.4, 0.8], [-0.82, 0.5, 0], { mass: 3 }),
924
+ box("arm_r", "fabric", [0.15, 0.4, 0.8], [0.82, 0.5, 0], { mass: 3 })
925
+ ],
926
+ joints: [
927
+ joint("seat_cushion", "frame", "clip"),
928
+ joint("back_cushion", "frame", "clip"),
929
+ joint("arm_l", "frame", "screw"),
930
+ joint("arm_r", "frame", "screw")
931
+ ],
932
+ tags: ["furniture"]
933
+ });
934
+ var shelf = () => ({
935
+ name: "shelf",
936
+ parts: [
937
+ box("back", "plywood", [0.84, 1.5, 0.012], [0, 0.75, -0.14], { anchored: true }),
938
+ box("board_1", "wood_soft", [0.8, 0.025, 0.28], [0, 0.35, 0]),
939
+ box("board_2", "wood_soft", [0.8, 0.025, 0.28], [0, 0.75, 0]),
940
+ box("board_3", "wood_soft", [0.8, 0.025, 0.28], [0, 1.15, 0]),
941
+ box("board_4", "wood_soft", [0.8, 0.025, 0.28], [0, 1.5, 0])
942
+ ],
943
+ joints: [joint("board_1", "back", "screw"), joint("board_2", "back", "screw"), joint("board_3", "back", "screw"), joint("board_4", "back", "screw")],
944
+ tags: ["furniture"]
945
+ });
946
+ var DEFS = [
947
+ { id: "chair", group: "furniture", spec: chair, colors: { wood_soft: 11038778 } },
948
+ { id: "sofa", group: "furniture", spec: sofa, colors: { frame: 7029798, seat_cushion: 8207180, back_cushion: 8207180, arm_l: 6958912, arm_r: 6958912 } },
949
+ { id: "shelf", group: "furniture", spec: shelf, colors: { back: 14272931 }, movable: false },
950
+ { id: "old_tv", group: "furniture", spec: oldTv, colors: { shell: 7166526, stand: 9067056, screen: 5205864 } },
951
+ { id: "steel_sheet", group: "structure", spec: steelSheet, movable: false, colors: { sheet: 11910340 } },
952
+ { id: "glass_pane", group: "structure", spec: glassPane, movable: false },
953
+ { id: "concrete_block", group: "structure", spec: concreteBlock },
954
+ { id: "brick_column", group: "structure", spec: brickColumn, movable: false },
955
+ { id: "building_frame", group: "structure", spec: frame, movable: false, colors: { roof: 8224121 } },
956
+ { id: "car_door", group: "vehicles", spec: carDoor, movable: false, colors: { skin: 12597547, window: 10278630 } },
957
+ { id: "small_tree", group: "plants", spec: tree, movable: false },
958
+ { id: "potted_plant", group: "plants", spec: pottedPlant, colors: { pot: 12871482 } },
959
+ { 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] } },
960
+ {
961
+ id: "glass_bottle",
962
+ group: "items",
963
+ spec: bottle,
964
+ colors: { bottle_body: 8378032, bottle_neck: 8378032 },
965
+ 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] }
966
+ },
967
+ { id: "wooden_crate", group: "containers", spec: crate },
968
+ { id: "steel_drum", group: "containers", spec: drum, colors: { drum_body: 2842526, drum_top: 2842526, drum_bottom: 2842526 } },
969
+ { id: "plastic_bucket", group: "containers", spec: bucket, colors: { bucket_body: 14239787, handle: 9079434 } },
970
+ {
971
+ id: "jerrycan",
972
+ group: "containers",
973
+ spec: jerrycan,
974
+ colors: { tank: 12729122, cap: 15909424, handle: 12729122 },
975
+ 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] }
976
+ },
977
+ { id: "gas_bottle", group: "containers", spec: gasBottle, colors: { cylinder: 14211280, valve: 7829367, foot_ring: 5592405 } }
978
+ ];
979
+ function demoItems(locale = "en", extra = []) {
980
+ const s = resolveStrings(locale);
981
+ const out = DEFS.map((d) => ({
982
+ id: d.id,
983
+ name: s.items[d.id] ?? d.id,
984
+ group: s.groups[d.group] ?? d.group,
985
+ build: (THREE) => buildFromSpec(THREE, d.spec(), { colors: d.colors }),
986
+ spec: d.spec,
987
+ movable: d.movable,
988
+ fluid: d.fluid
989
+ }));
990
+ return [...out, ...extra];
991
+ }
992
+ var DEMO_ITEMS = demoItems("en");
993
+
994
+ // src/arena.ts
995
+ var tableSpec = () => {
996
+ const parts = [{ id: "top", material: "wood_hard", shape: { kind: "box", size: [1.6, 0.05, 0.8] }, position: [0, 0.755, 0] }];
997
+ const joints = [];
998
+ for (const [i, [x, z]] of [
999
+ [-0.72, -0.32],
1000
+ [0.72, -0.32],
1001
+ [-0.72, 0.32],
1002
+ [0.72, 0.32]
1003
+ ].entries()) {
1004
+ 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 });
1005
+ joints.push({ a: `leg_${i + 1}`, b: "top", kind: "screw" });
1006
+ }
1007
+ return { name: "table", parts, joints, tags: ["furniture"] };
1008
+ };
1009
+ var shelfSpec = () => {
1010
+ const parts = [];
1011
+ const joints = [];
1012
+ for (const [i, x] of [-0.6, 0.6].entries()) {
1013
+ 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 });
1014
+ }
1015
+ for (const [i, y] of [0.4, 0.9, 1.4].entries()) {
1016
+ parts.push({ id: `board_${i + 1}`, material: "wood_soft", shape: { kind: "box", size: [1.2, 0.03, 0.4] }, position: [0, y, 0] });
1017
+ joints.push({ a: `board_${i + 1}`, b: "upright_1", kind: "screw" }, { a: `board_${i + 1}`, b: "upright_2", kind: "screw" });
1018
+ }
1019
+ return { name: "shelf", parts, joints, tags: ["furniture"] };
1020
+ };
1021
+ function createArena(THREE, size, scene) {
1022
+ const group = new THREE.Group();
1023
+ group.name = "test-room-arena";
1024
+ scene.background = new THREE.Color(10466504);
1025
+ group.add(new THREE.HemisphereLight(13624319, 7037527, 0.75));
1026
+ const sun = new THREE.DirectionalLight(16777215, 2.1);
1027
+ sun.position.set(7, 13, 8);
1028
+ sun.castShadow = true;
1029
+ sun.shadow.mapSize.set(1024, 1024);
1030
+ const sc = sun.shadow.camera;
1031
+ sc.left = -16;
1032
+ sc.right = 16;
1033
+ sc.top = 16;
1034
+ sc.bottom = -16;
1035
+ sc.near = 1;
1036
+ sc.far = 40;
1037
+ sun.shadow.bias = -6e-4;
1038
+ group.add(sun);
1039
+ const floor = new THREE.Mesh(new THREE.PlaneGeometry(size * 2, size * 2), new THREE.MeshStandardMaterial({ color: 9342602, roughness: 0.95 }));
1040
+ floor.rotation.x = -Math.PI / 2;
1041
+ floor.receiveShadow = true;
1042
+ floor.name = "floor";
1043
+ group.add(floor);
1044
+ const grid = new THREE.GridHelper(size * 2, size * 2, 5000274, 7829371);
1045
+ grid.position.y = 4e-3;
1046
+ group.add(grid);
1047
+ const wallZ = -11;
1048
+ const playerStart = [0, 1.7, 5];
1049
+ const pts = [];
1050
+ const rx = -1.6;
1051
+ for (let z = Math.round(playerStart[2]); z >= wallZ; z--) {
1052
+ const d = playerStart[2] - z;
1053
+ const len = d % 5 === 0 ? 0.5 : 0.2;
1054
+ pts.push(rx, 0.01, z, rx + len, 0.01, z);
1055
+ }
1056
+ pts.push(rx, 0.01, playerStart[2], rx, 0.01, wallZ);
1057
+ const rg = new THREE.BufferGeometry();
1058
+ rg.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3));
1059
+ group.add(new THREE.LineSegments(rg, new THREE.LineBasicMaterial({ color: 16765503 })));
1060
+ for (let d = 5; playerStart[2] - d >= wallZ; d += 5) {
1061
+ try {
1062
+ const canvas = document.createElement("canvas");
1063
+ canvas.width = 128;
1064
+ canvas.height = 64;
1065
+ const ctx = canvas.getContext("2d");
1066
+ if (!ctx) break;
1067
+ ctx.fillStyle = "#ffd23f";
1068
+ ctx.font = "bold 44px sans-serif";
1069
+ ctx.textAlign = "center";
1070
+ ctx.fillText(`${d} m`, 64, 48);
1071
+ const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(canvas), transparent: true, depthWrite: false }));
1072
+ sprite.scale.set(1.2, 0.6, 1);
1073
+ sprite.position.set(rx - 0.8, 0.45, playerStart[2] - d);
1074
+ group.add(sprite);
1075
+ } catch {
1076
+ break;
1077
+ }
1078
+ }
1079
+ const tableX = 3.5;
1080
+ const tapMat = new THREE.MeshStandardMaterial({ color: 12172996, metalness: 0.8, roughness: 0.35 });
1081
+ const post = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 1.4, 10), tapMat);
1082
+ post.position.set(tableX + 1.15, 0.7, 0);
1083
+ const arm = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.025, 1.15 - 0.3, 10), tapMat);
1084
+ arm.rotation.z = Math.PI / 2;
1085
+ arm.position.set(tableX + (1.15 + 0.3) / 2, 1.38, 0);
1086
+ const spoutMesh = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.025, 0.1, 10), tapMat);
1087
+ spoutMesh.position.set(tableX + 0.3, 1.33, 0);
1088
+ for (const m of [post, arm, spoutMesh]) {
1089
+ m.castShadow = true;
1090
+ group.add(m);
1091
+ }
1092
+ const tapSpout = [tableX + 0.3, 1.28, 0];
1093
+ const objects = [
1094
+ { id: "target_wall", spec: brickWallSpec(), position: [0, 0, wallZ], colors: { brick: 10831662 } },
1095
+ { id: "table", spec: tableSpec(), position: [tableX, 0, 0] },
1096
+ { id: "shelf", spec: shelfSpec(), position: [-3.5, 0, 0], colors: { wood_soft: 13081186 } }
1097
+ ];
1098
+ return {
1099
+ group,
1100
+ objects,
1101
+ surfaces: [
1102
+ { id: "floor", point: [0, 0, 0], normal: [0, 1, 0], halfSize: [size, size], porosity: 0.05 },
1103
+ { id: "table", point: [tableX, 0.78, 0], normal: [0, 1, 0], halfSize: [0.8, 0.4], porosity: 0.3 }
1104
+ ],
1105
+ colliders: [
1106
+ [tableX - 0.8, -0.4, tableX + 0.8, 0.4],
1107
+ [tableX + 1.1, -0.1, tableX + 1.2, 0.1],
1108
+ [-3.5 - 0.62, -0.2, -3.5 + 0.62, 0.2],
1109
+ [-size, wallZ - 1, size, wallZ + 0.2]
1110
+ ],
1111
+ playerStart,
1112
+ wallZ,
1113
+ table: { minX: tableX - 0.8, maxX: tableX + 0.8, minZ: -0.4, maxZ: 0.4, top: 0.78, id: "table", topPart: "top" },
1114
+ tapSpout,
1115
+ sun
1116
+ };
1117
+ }
1118
+
1119
+ // src/log.ts
1120
+ var MAX_ENTRIES = 5e3;
1121
+ var pad = (n, w = 2) => String(n).padStart(w, "0");
1122
+ var formatTime = (t) => {
1123
+ const m = Math.floor(t / 60);
1124
+ const s = t - m * 60;
1125
+ return `${pad(m)}:${s.toFixed(2).padStart(5, "0")}`;
1126
+ };
1127
+ function createLog(options = {}) {
1128
+ const host = globalThis.claude;
1129
+ let downloadsP = null;
1130
+ if (host && typeof host.use === "function") {
1131
+ try {
1132
+ downloadsP = Promise.resolve(host.use("downloads")).then((d) => d ?? null, () => null);
1133
+ } catch {
1134
+ downloadsP = null;
1135
+ }
1136
+ }
1137
+ let list = [];
1138
+ let seq = 0;
1139
+ let clock = () => 0;
1140
+ let beforeRead = null;
1141
+ const listeners = /* @__PURE__ */ new Set();
1142
+ const keyed = /* @__PURE__ */ new Map();
1143
+ const read = () => {
1144
+ beforeRead?.();
1145
+ return list;
1146
+ };
1147
+ const emit = (e, updated) => {
1148
+ for (const fn of listeners) {
1149
+ try {
1150
+ fn(e, updated);
1151
+ } catch {
1152
+ }
1153
+ }
1154
+ };
1155
+ const add = (entry) => {
1156
+ const { t, ...rest } = entry;
1157
+ const e = { seq: seq++, t: Math.round((t ?? clock()) * 1e3) / 1e3, ...rest };
1158
+ list.push(e);
1159
+ if (list.length > MAX_ENTRIES) list = list.slice(list.length - MAX_ENTRIES);
1160
+ emit(e, false);
1161
+ return e;
1162
+ };
1163
+ const log = {
1164
+ add,
1165
+ coalesce(key, entry, windowS = 1) {
1166
+ const now = entry.t ?? clock();
1167
+ const k = keyed.get(key);
1168
+ if (k && now - k.last <= windowS && list.includes(k.entry)) {
1169
+ const { t: _t, ...rest } = entry;
1170
+ k.count++;
1171
+ Object.assign(k.entry, rest);
1172
+ k.entry.data = { ...rest.data ?? {}, updates: k.count };
1173
+ k.last = now;
1174
+ emit(k.entry, true);
1175
+ return k.entry;
1176
+ }
1177
+ const e = add(entry);
1178
+ keyed.set(key, { entry: e, last: now, count: 1 });
1179
+ return e;
1180
+ },
1181
+ setBeforeRead(fn) {
1182
+ beforeRead = fn;
1183
+ },
1184
+ setClock(fn) {
1185
+ clock = fn;
1186
+ },
1187
+ entries(filter) {
1188
+ const all = read();
1189
+ if (!filter) return all.slice();
1190
+ return all.filter(
1191
+ (e) => (!filter.channel || e.channel === filter.channel) && (!filter.type || e.type === filter.type) && (!filter.objectId || e.objectId === filter.objectId)
1192
+ );
1193
+ },
1194
+ clear() {
1195
+ beforeRead?.();
1196
+ list = [];
1197
+ keyed.clear();
1198
+ },
1199
+ toJSON: () => JSON.stringify(read(), null, 2),
1200
+ toNDJSON: () => read().map((e) => JSON.stringify(e)).join("\n") + (list.length ? "\n" : ""),
1201
+ toText: () => read().map((e) => `[${formatTime(e.t)}] ${e.channel.padEnd(6)} ${e.text}`).join("\n"),
1202
+ async download(format = "ndjson") {
1203
+ const body = format === "json" ? log.toJSON() : format === "txt" ? log.toText() : log.toNDJSON();
1204
+ const downloads = downloadsP ? await downloadsP : null;
1205
+ if (downloads) {
1206
+ const filename = format === "ndjson" ? "test-room-log.ndjson.txt" : `test-room-log.${format}`;
1207
+ try {
1208
+ await downloads.save({ filename, data: body });
1209
+ } catch (err) {
1210
+ const code = err?.code;
1211
+ log.add({ channel: "action", type: "export_cancelled", text: options.exportCancelled?.() ?? "export cancelled", data: { format, ...typeof code === "string" ? { code } : {} } });
1212
+ }
1213
+ return;
1214
+ }
1215
+ if (typeof document === "undefined" || typeof Blob === "undefined" || typeof URL === "undefined" || !URL.createObjectURL) return;
1216
+ const mime = format === "json" ? "application/json" : "text/plain";
1217
+ const url = URL.createObjectURL(new Blob([body], { type: mime }));
1218
+ const a = document.createElement("a");
1219
+ a.href = url;
1220
+ a.download = `test-room-log.${format}`;
1221
+ document.body.appendChild(a);
1222
+ a.click();
1223
+ a.remove();
1224
+ setTimeout(() => URL.revokeObjectURL(url), 1e3);
1225
+ },
1226
+ summary() {
1227
+ const byType = {};
1228
+ const byMaterial = {};
1229
+ const objects = /* @__PURE__ */ new Set();
1230
+ let destroyed = 0;
1231
+ for (const e of read()) {
1232
+ byType[e.type] = (byType[e.type] ?? 0) + 1;
1233
+ if (e.material) byMaterial[e.material] = (byMaterial[e.material] ?? 0) + 1;
1234
+ if (e.objectId) objects.add(e.objectId);
1235
+ if (e.type === "destroyed") destroyed++;
1236
+ }
1237
+ return { byType, byMaterial, objects: objects.size, destroyed };
1238
+ },
1239
+ on(fn) {
1240
+ listeners.add(fn);
1241
+ return () => listeners.delete(fn);
1242
+ }
1243
+ };
1244
+ return log;
1245
+ }
1246
+
1247
+ // src/narrate.ts
1248
+ var SKIP_DAMAGE = /* @__PURE__ */ new Set(["fire", "heat"]);
1249
+ function createNarrator(c) {
1250
+ const { t, num } = c;
1251
+ const materialMemo = /* @__PURE__ */ new Map();
1252
+ const findPart = (objectId, partId) => {
1253
+ const p = c.world.part(objectId, partId) ?? c.locatePart?.(objectId, partId)?.part ?? null;
1254
+ if (p) materialMemo.set(`${objectId}|${partId}`, p.material);
1255
+ return p;
1256
+ };
1257
+ const plural = (key, n) => t(`${key}.${n === 1 ? "one" : "other"}`, { n });
1258
+ const pieces = (n) => plural("count.piece", n);
1259
+ const joints = (n) => plural("count.joint", n);
1260
+ const xz = (x, z) => t("coords", { x: num(x, 1), z: num(z, 1) });
1261
+ const where = (objectId, partId) => {
1262
+ const p = findPart(objectId, partId);
1263
+ const m = p?.material ?? materialMemo.get(`${objectId}|${partId}`);
1264
+ return `${c.nameOf(objectId)} \u203A ${partId}${m ? ` (${c.materialName(m)})` : ""}`;
1265
+ };
1266
+ const stateOf = (objectId, partId) => {
1267
+ const p = findPart(objectId, partId);
1268
+ if (!p) return void 0;
1269
+ const s = {
1270
+ status: p.status,
1271
+ hp: Math.round(p.hp * 10) / 10,
1272
+ maxHp: Math.round(p.maxHp * 10) / 10,
1273
+ temperature: Math.round(p.temperature),
1274
+ char: Math.round(p.char * 100) / 100,
1275
+ melt: Math.round(p.melt * 100) / 100
1276
+ };
1277
+ if (p.liters !== void 0) s.liters = Math.round(p.liters * 1e3) / 1e3;
1278
+ return s;
1279
+ };
1280
+ const base = (ev, part) => ({
1281
+ channel: "event",
1282
+ objectId: ev.object,
1283
+ itemId: c.itemIdOf(ev.object),
1284
+ part,
1285
+ material: part ? findPart(ev.object, part)?.material ?? materialMemo.get(`${ev.object}|${part}`) : void 0,
1286
+ state: part ? stateOf(ev.object, part) : void 0
1287
+ });
1288
+ const hpText = (obj, part) => {
1289
+ const p = findPart(obj, part);
1290
+ return p ? `hp ${num(p.hp, 0)}/${num(p.maxHp, 0)}` : "";
1291
+ };
1292
+ const isLost = (obj, part) => {
1293
+ const st = findPart(obj, part)?.status;
1294
+ return st === "broken" || st === "melted" || st === "ashes";
1295
+ };
1296
+ const crackAcc = /* @__PURE__ */ new Map();
1297
+ const damageAcc = /* @__PURE__ */ new Map();
1298
+ const stressAt = /* @__PURE__ */ new Map();
1299
+ const leakAt = /* @__PURE__ */ new Map();
1300
+ const list = (names) => names.length > 6 ? `${names.slice(0, 6).join(", ")}\u2026` : names.join(", ");
1301
+ const events = (batch) => {
1302
+ const now = c.now();
1303
+ const damage = /* @__PURE__ */ new Map();
1304
+ const holes = /* @__PURE__ */ new Map();
1305
+ const dents = /* @__PURE__ */ new Map();
1306
+ for (const ev of batch) {
1307
+ if (ev.type !== "damage") continue;
1308
+ const k = `${ev.object}|${ev.part}`;
1309
+ const prev = damage.get(k);
1310
+ damage.set(k, { amount: (prev?.amount ?? 0) + ev.amount, type: ev.damageType });
1311
+ }
1312
+ const lostJoints = /* @__PURE__ */ new Map();
1313
+ const openJoints = /* @__PURE__ */ new Map();
1314
+ const fireParts = /* @__PURE__ */ new Map();
1315
+ const outParts = /* @__PURE__ */ new Map();
1316
+ for (const ev of batch) {
1317
+ if (ev.type === "detach") {
1318
+ if (ev.by === "part_lost") {
1319
+ const [a, b] = ev.parts;
1320
+ const lost = isLost(ev.object, a) && !isLost(ev.object, b) ? a : isLost(ev.object, b) && !isLost(ev.object, a) ? b : isLost(ev.object, a) ? a : b;
1321
+ const key = `${ev.object}|${lost}`;
1322
+ const g = lostJoints.get(key) ?? { obj: ev.object, part: lost, joints: 0, groups: 0, merged: false };
1323
+ g.joints++;
1324
+ g.groups = ev.groups.length;
1325
+ lostJoints.set(key, g);
1326
+ } else {
1327
+ const key = `${ev.object}|${ev.by}`;
1328
+ const g = openJoints.get(key) ?? { obj: ev.object, by: ev.by, list: [], groups: 0, done: false };
1329
+ const [a, b] = ev.parts;
1330
+ g.list.push({ joint: ev.joint, a, b });
1331
+ g.groups = ev.groups.length;
1332
+ openJoints.set(key, g);
1333
+ }
1334
+ } else if (ev.type === "ignite") {
1335
+ const g = fireParts.get(ev.object) ?? { obj: ev.object, parts: [], temp: 0, done: false };
1336
+ g.parts.push(ev.part);
1337
+ g.temp = Math.max(g.temp, findPart(ev.object, ev.part)?.temperature ?? 0);
1338
+ fireParts.set(ev.object, g);
1339
+ } else if (ev.type === "extinguish") {
1340
+ const g = outParts.get(ev.object) ?? { obj: ev.object, parts: [], done: false };
1341
+ g.parts.push(ev.part);
1342
+ outParts.set(ev.object, g);
1343
+ }
1344
+ }
1345
+ const lostTail = (key) => {
1346
+ const g = lostJoints.get(key);
1347
+ if (!g) return "";
1348
+ g.merged = true;
1349
+ return t("ev.lost.tail", { joints: joints(g.joints), pieces: pieces(g.groups) });
1350
+ };
1351
+ const lostData = (key) => {
1352
+ const g = lostJoints.get(key);
1353
+ return g ? { jointsOpened: g.joints, pieces: g.groups } : {};
1354
+ };
1355
+ for (const ev of batch) {
1356
+ switch (ev.type) {
1357
+ case "damage":
1358
+ break;
1359
+ case "dent": {
1360
+ const k = `${ev.object}|${ev.part}`;
1361
+ const d = damage.get(k);
1362
+ damage.delete(k);
1363
+ const mm = ev.dent.depth * 1e3;
1364
+ const prev = dents.get(k);
1365
+ if (prev) {
1366
+ const n = Number(prev.data?.count ?? 1) + 1;
1367
+ const deepest = Math.max(Number(prev.data?.depthMm ?? 0), mm);
1368
+ prev.text = t("ev.dents", { where: where(ev.object, ev.part), n, mm: num(deepest, 0), hp: hpText(ev.object, ev.part) });
1369
+ prev.data = { ...prev.data, count: n, depthMm: Math.round(deepest * 10) / 10 };
1370
+ break;
1371
+ }
1372
+ dents.set(
1373
+ k,
1374
+ c.log.add({
1375
+ ...base(ev, ev.part),
1376
+ type: "dent",
1377
+ text: t("ev.dent", { where: where(ev.object, ev.part), mm: num(mm, 0), hp: hpText(ev.object, ev.part) }),
1378
+ data: { count: 1, depthMm: Math.round(mm * 10) / 10, radius: ev.dent.radius, damage: d?.amount, point: ev.worldPoint, source: ev.source }
1379
+ })
1380
+ );
1381
+ break;
1382
+ }
1383
+ case "hole": {
1384
+ const k = `${ev.object}|${ev.part}`;
1385
+ const d = damage.get(k);
1386
+ damage.delete(k);
1387
+ const mm = ev.hole.diameter * 1e3;
1388
+ const prev = holes.get(k);
1389
+ if (prev) {
1390
+ const n = Number(prev.data?.count ?? 1) + 1;
1391
+ const through = Number(prev.data?.throughCount ?? 0) + (ev.hole.through ? 1 : 0);
1392
+ prev.text = t("ev.holes", { where: where(ev.object, ev.part), n, mm: num(mm, 0), thr: through, hp: hpText(ev.object, ev.part) });
1393
+ prev.data = { ...prev.data, count: n, throughCount: through, through: through > 0 };
1394
+ break;
1395
+ }
1396
+ holes.set(
1397
+ k,
1398
+ c.log.add({
1399
+ ...base(ev, ev.part),
1400
+ type: "hole",
1401
+ text: t(ev.hole.through ? "ev.hole.through" : "ev.hole.stuck", { where: where(ev.object, ev.part), mm: num(mm, 0), hp: hpText(ev.object, ev.part) }),
1402
+ data: { count: 1, throughCount: ev.hole.through ? 1 : 0, diameterMm: Math.round(mm * 10) / 10, through: ev.hole.through, damage: d?.amount, point: ev.worldPoint, source: ev.source }
1403
+ })
1404
+ );
1405
+ break;
1406
+ }
1407
+ case "crack": {
1408
+ const k = `${ev.object}|${ev.part}`;
1409
+ const d = damage.get(k);
1410
+ damage.delete(k);
1411
+ const loaded = (findPart(ev.object, ev.part)?.stress ?? 0) > 0.8 && ev.source === void 0;
1412
+ const cm = ev.crack.length * 100;
1413
+ if (loaded) {
1414
+ const acc = crackAcc.get(k);
1415
+ const n = acc && now - acc.last <= 1 ? acc.n + 1 : 1;
1416
+ const longest = acc && n > 1 ? Math.max(acc.cm, cm) : cm;
1417
+ crackAcc.set(k, { n, cm: longest, last: now });
1418
+ c.log.coalesce(
1419
+ `crack:${k}`,
1420
+ {
1421
+ ...base(ev, ev.part),
1422
+ type: "crack",
1423
+ text: t("ev.cracks", { where: where(ev.object, ev.part), n, cm: num(longest, 0), hp: hpText(ev.object, ev.part) }),
1424
+ data: { rays: ev.crack.rays, lengthM: ev.crack.length, count: n, underLoad: true }
1425
+ },
1426
+ 1
1427
+ );
1428
+ break;
1429
+ }
1430
+ c.log.add({
1431
+ ...base(ev, ev.part),
1432
+ type: "crack",
1433
+ text: t("ev.crack", { where: where(ev.object, ev.part), rays: ev.crack.rays, cm: num(cm, 0), hp: hpText(ev.object, ev.part) }),
1434
+ data: { rays: ev.crack.rays, lengthM: ev.crack.length, damage: d?.amount, source: ev.source }
1435
+ });
1436
+ break;
1437
+ }
1438
+ case "ricochet":
1439
+ c.log.add({
1440
+ ...base(ev, ev.part),
1441
+ type: "ricochet",
1442
+ text: t("ev.ricochet", { where: where(ev.object, ev.part), energy: num(ev.energy, 0) }),
1443
+ data: { energy: ev.energy, direction: ev.direction }
1444
+ });
1445
+ break;
1446
+ case "leak": {
1447
+ const k = `${ev.object}|${ev.part}`;
1448
+ const liquid = c.liquidName(ev.fluid);
1449
+ const hole = holes.get(k);
1450
+ const rate = num(ev.rate, 2);
1451
+ leakAt.set(ev.object, now);
1452
+ if (hole) {
1453
+ hole.text += t("ev.leak.tail", { liquid, rate });
1454
+ hole.data = { ...hole.data, leak: { fluid: ev.fluid, ratePerS: ev.rate, pressurized: ev.pressurized } };
1455
+ } else {
1456
+ c.log.add({ ...base(ev, ev.part), type: "leak", text: t("ev.leak.alone", { where: where(ev.object, ev.part), liquid, rate }), data: { fluid: ev.fluid, ratePerS: ev.rate } });
1457
+ }
1458
+ break;
1459
+ }
1460
+ case "fracture": {
1461
+ const k = `${ev.object}|${ev.part}`;
1462
+ const d = damage.get(k);
1463
+ damage.delete(k);
1464
+ c.log.add({
1465
+ ...base(ev, ev.part),
1466
+ type: "fracture",
1467
+ text: t(`ev.fracture.${ev.style}`, { where: where(ev.object, ev.part), n: ev.fragments.length }) + lostTail(k),
1468
+ data: { style: ev.style, fragments: ev.fragments.length, damage: d?.amount, point: ev.point, source: ev.source, ...lostData(k) }
1469
+ });
1470
+ break;
1471
+ }
1472
+ case "detach": {
1473
+ if (ev.by === "part_lost") {
1474
+ const [a2, b2] = ev.parts;
1475
+ const lost = isLost(ev.object, a2) && !isLost(ev.object, b2) ? a2 : isLost(ev.object, b2) && !isLost(ev.object, a2) ? b2 : isLost(ev.object, a2) ? a2 : b2;
1476
+ const key2 = `${ev.object}|${lost}`;
1477
+ const g2 = lostJoints.get(key2);
1478
+ if (!g2 || g2.merged) break;
1479
+ const hasOwnLine = batch.some((e) => (e.type === "fracture" || e.type === "burnout") && e.object === ev.object && e.part === lost);
1480
+ if (hasOwnLine) break;
1481
+ g2.merged = true;
1482
+ const status = findPart(ev.object, lost)?.status;
1483
+ const verb = t(`lost.${status === "ashes" ? "ashes" : status === "melted" ? "melted" : "broken"}`);
1484
+ c.log.add({
1485
+ ...base(ev, lost),
1486
+ type: "detach",
1487
+ text: t("ev.lost.alone", { where: where(ev.object, lost), verb, joints: joints(g2.joints), pieces: pieces(g2.groups) }),
1488
+ data: { parts: [a2, b2], by: ev.by, ...lostData(key2) }
1489
+ });
1490
+ break;
1491
+ }
1492
+ const key = `${ev.object}|${ev.by}`;
1493
+ const g = openJoints.get(key);
1494
+ if (!g || g.done) break;
1495
+ g.done = true;
1496
+ const [a, b] = ev.parts;
1497
+ const many = g.list.length > 1;
1498
+ c.log.add({
1499
+ channel: "event",
1500
+ type: "detach",
1501
+ objectId: ev.object,
1502
+ itemId: c.itemIdOf(ev.object),
1503
+ text: many ? t(`ev.detach.many.${ev.by === "tool" ? "tool" : "break"}`, { obj: c.nameOf(ev.object), joints: joints(g.list.length), pieces: pieces(g.groups) }) : t("ev.detach", { obj: c.nameOf(ev.object), a, b, by: t(`by.${ev.by}`), pieces: pieces(g.groups) }),
1504
+ data: { joint: ev.joint, joints: g.list.map((j) => j.joint), parts: ev.parts, by: ev.by, groups: g.groups }
1505
+ });
1506
+ break;
1507
+ }
1508
+ case "disassemble_progress": {
1509
+ const pct = Math.round(ev.progress * 100);
1510
+ const kind = kindOf(ev.object, ev.joint);
1511
+ const [a, b] = ev.joint.split("~");
1512
+ c.log.coalesce(
1513
+ `dis:${ev.object}:${ev.joint}`,
1514
+ {
1515
+ channel: "event",
1516
+ type: "disassemble_progress",
1517
+ objectId: ev.object,
1518
+ itemId: c.itemIdOf(ev.object),
1519
+ text: t("ev.disassemble", { obj: c.nameOf(ev.object), a: a ?? ev.joint, b: b ?? "", kind: kind ? c.jointKind(kind) : t("joint.generic"), tool: c.toolName(ev.tool), pct }),
1520
+ data: { joint: ev.joint, tool: ev.tool, progress: ev.progress }
1521
+ },
1522
+ 1.5
1523
+ );
1524
+ break;
1525
+ }
1526
+ case "ignite": {
1527
+ const g = fireParts.get(ev.object);
1528
+ if (g && g.parts.length >= 3) {
1529
+ if (g.done) break;
1530
+ g.done = true;
1531
+ c.log.add({
1532
+ channel: "event",
1533
+ type: "ignite",
1534
+ objectId: ev.object,
1535
+ itemId: c.itemIdOf(ev.object),
1536
+ text: t("ev.ignite.many", { obj: c.nameOf(ev.object), n: g.parts.length, temp: num(g.temp, 0), parts: list(g.parts) }),
1537
+ data: { parts: g.parts, temperature: Math.round(g.temp) }
1538
+ });
1539
+ break;
1540
+ }
1541
+ c.log.add({ ...base(ev, ev.part), type: "ignite", text: t("ev.ignite", { where: where(ev.object, ev.part), temp: num(findPart(ev.object, ev.part)?.temperature ?? 0, 0) }) });
1542
+ break;
1543
+ }
1544
+ case "extinguish": {
1545
+ const g = outParts.get(ev.object);
1546
+ if (g && g.parts.length >= 3) {
1547
+ if (g.done) break;
1548
+ g.done = true;
1549
+ c.log.add({
1550
+ channel: "event",
1551
+ type: "extinguish",
1552
+ objectId: ev.object,
1553
+ itemId: c.itemIdOf(ev.object),
1554
+ text: t("ev.extinguish.many", { obj: c.nameOf(ev.object), n: g.parts.length, parts: list(g.parts) }),
1555
+ data: { parts: g.parts }
1556
+ });
1557
+ break;
1558
+ }
1559
+ c.log.add({ ...base(ev, ev.part), type: "extinguish", text: t("ev.extinguish", { where: where(ev.object, ev.part) }) });
1560
+ break;
1561
+ }
1562
+ case "burnout": {
1563
+ const k = `${ev.object}|${ev.part}`;
1564
+ c.log.add({ ...base(ev, ev.part), type: "burnout", text: t("ev.burnout", { where: where(ev.object, ev.part) }) + lostTail(k), data: lostData(k) });
1565
+ break;
1566
+ }
1567
+ case "melt":
1568
+ c.log.coalesce(
1569
+ `melt:${ev.object}:${ev.part}`,
1570
+ { ...base(ev, ev.part), type: "melt", text: t("ev.melt", { where: where(ev.object, ev.part), pct: Math.round(ev.melt * 100) }), data: { melt: ev.melt } },
1571
+ 2
1572
+ );
1573
+ break;
1574
+ case "drip":
1575
+ c.log.coalesce(`drip:${ev.object}:${ev.part}`, { ...base(ev, ev.part), type: "drip", text: t("ev.drip", { where: where(ev.object, ev.part) }), data: { point: ev.point } }, 2);
1576
+ break;
1577
+ case "explode":
1578
+ c.log.add({
1579
+ ...base(ev, ev.part),
1580
+ type: "explode",
1581
+ text: t("ev.explode", { where: where(ev.object, ev.part), energy: num(ev.energy, 0), radius: num(ev.radius, 1) }),
1582
+ data: { energy: ev.energy, radius: ev.radius, point: ev.point }
1583
+ });
1584
+ break;
1585
+ case "destroyed":
1586
+ c.log.add({ channel: "event", type: "destroyed", objectId: ev.object, itemId: c.itemIdOf(ev.object), text: t("ev.destroyed", { obj: c.nameOf(ev.object) }) });
1587
+ break;
1588
+ default: {
1589
+ const x = ev;
1590
+ if (x.type === "stress") {
1591
+ const part = String(x.part ?? "");
1592
+ stressAt.set(`${x.object}|${part}`, now);
1593
+ c.log.coalesce(
1594
+ `stress:${x.object}:${part}`,
1595
+ {
1596
+ ...base(ev, part),
1597
+ type: "stress",
1598
+ text: t("ev.stress", { where: where(x.object, part), stress: num(Number(x.stress ?? 0) * 100, 0), load: num(Number(x.load ?? 0), 0) }),
1599
+ data: { stress: x.stress, load: x.load }
1600
+ },
1601
+ 1
1602
+ );
1603
+ } else if (x.type === "collapse") {
1604
+ const parts = Array.isArray(x.parts) ? x.parts.map(String) : [];
1605
+ c.log.add({
1606
+ channel: "event",
1607
+ type: "collapse",
1608
+ objectId: x.object,
1609
+ itemId: c.itemIdOf(x.object),
1610
+ text: t("ev.collapse", { obj: c.nameOf(x.object), pieces: pieces(parts.length), mass: num(Number(x.mass ?? 0), 0), parts: list(parts) }),
1611
+ data: { parts: parts.length, partIds: parts, massKg: x.mass, cause: x.cause }
1612
+ });
1613
+ }
1614
+ }
1615
+ }
1616
+ }
1617
+ for (const [k, d] of damage) {
1618
+ if (SKIP_DAMAGE.has(d.type) || d.amount < 0.5) continue;
1619
+ const [obj, part] = k.split("|");
1620
+ const overload = d.type === "blunt" && now - (stressAt.get(k) ?? -99) <= 3;
1621
+ const entry = {
1622
+ channel: "event",
1623
+ type: "damage",
1624
+ objectId: obj,
1625
+ itemId: c.itemIdOf(obj),
1626
+ part,
1627
+ material: findPart(obj, part)?.material ?? materialMemo.get(k),
1628
+ state: stateOf(obj, part)
1629
+ };
1630
+ if (!overload) {
1631
+ c.log.add({ ...entry, text: t("ev.damage", { where: where(obj, part), type: t(`dmg.${d.type}`), amount: num(d.amount, 0), hp: hpText(obj, part) }), data: { amount: d.amount, damageType: d.type } });
1632
+ continue;
1633
+ }
1634
+ const acc = damageAcc.get(k);
1635
+ const total = acc && now - acc.last <= 1 ? acc.total + d.amount : d.amount;
1636
+ damageAcc.set(k, { total, last: now });
1637
+ c.log.coalesce(
1638
+ `damage:${k}`,
1639
+ { ...entry, text: t("ev.damage.overload", { where: where(obj, part), amount: num(total, 0), hp: hpText(obj, part) }), data: { amount: total, damageType: d.type, overload: true } },
1640
+ 1
1641
+ );
1642
+ }
1643
+ };
1644
+ const kindMemo = /* @__PURE__ */ new Map();
1645
+ const kindOf = (objectId, jointId) => {
1646
+ const key = `${objectId}|${jointId}`;
1647
+ const own = c.world.state(objectId)?.joints.find((j) => j.id === jointId)?.kind;
1648
+ if (own) {
1649
+ kindMemo.set(key, own);
1650
+ return own;
1651
+ }
1652
+ const known = kindMemo.get(key);
1653
+ if (known) return known;
1654
+ for (const id of c.world.ids()) {
1655
+ const k = c.world.state(id)?.joints.find((j) => j.id === jointId)?.kind;
1656
+ if (k) return k;
1657
+ }
1658
+ return "";
1659
+ };
1660
+ const stainInfo = /* @__PURE__ */ new Map();
1661
+ const stainAcc = /* @__PURE__ */ new Map();
1662
+ const driedAcc = /* @__PURE__ */ new Map();
1663
+ const drinkAcc = /* @__PURE__ */ new Map();
1664
+ const fluid = (ev) => {
1665
+ const who = (id) => c.containerName(id)?.name ?? id;
1666
+ const meta = (id) => {
1667
+ const m = c.containerName(id);
1668
+ return { objectId: m?.objectId, itemId: m?.itemId };
1669
+ };
1670
+ const f = "fluid.";
1671
+ switch (ev.type) {
1672
+ case "fill":
1673
+ c.log.coalesce(`fill:${ev.container}`, { channel: "event", type: f + "fill", ...meta(ev.container), text: t("fl.fill", { who: who(ev.container), liquid: c.liquidName(ev.liquid), liters: num(ev.liters, 2) }), data: { liters: ev.liters } }, 1.5);
1674
+ break;
1675
+ case "pour":
1676
+ c.log.coalesce(`pour:${ev.container}`, { channel: "event", type: f + "pour", ...meta(ev.container), text: t("fl.pour", { who: who(ev.container), liquid: c.liquidName(ev.liquid), rate: num(ev.rate, 2) }), data: { rate: ev.rate } }, 2);
1677
+ break;
1678
+ case "spill":
1679
+ c.log.coalesce(`spill:${ev.container}`, { channel: "event", type: f + "spill", ...meta(ev.container), text: t("fl.spill", { who: who(ev.container), liquid: c.liquidName(ev.liquid), rate: num(ev.rate, 2) }), data: { rate: ev.rate } }, 2);
1680
+ break;
1681
+ case "leak": {
1682
+ const owner = meta(ev.container).objectId;
1683
+ if (owner && c.now() - (leakAt.get(owner) ?? -99) <= 2) break;
1684
+ c.log.coalesce(`fleak:${ev.container}`, { channel: "event", type: f + "leak", ...meta(ev.container), text: t("fl.leak", { who: who(ev.container), liquid: c.liquidName(ev.liquid), rate: num(ev.rate, ev.rate < 0.01 ? 3 : 2) }), data: { rate: ev.rate, point: ev.point } }, 2);
1685
+ break;
1686
+ }
1687
+ case "drink": {
1688
+ const now = c.now();
1689
+ let acc = drinkAcc.get(ev.container);
1690
+ if (!acc || now - acc.last > 0.5) acc = { liters: 0, thirst: 0, burn: false, effects: /* @__PURE__ */ new Set(), last: now, start: now };
1691
+ acc.liters += ev.liters;
1692
+ acc.thirst += ev.thirst;
1693
+ acc.burn = acc.burn || ev.burn;
1694
+ for (const e of ev.effects) acc.effects.add(e);
1695
+ acc.last = now;
1696
+ drinkAcc.set(ev.container, acc);
1697
+ const effects = [...acc.effects].map((e) => t(`effect.${e}`)).join(", ");
1698
+ c.log.coalesce(
1699
+ `drink:${ev.container}:${acc.start}`,
1700
+ {
1701
+ channel: "event",
1702
+ type: f + "drink",
1703
+ ...meta(ev.container),
1704
+ text: t("fl.drink", { who: who(ev.container), liters: num(acc.liters, 2), thirst: num(acc.thirst, 0) }) + (effects ? `, ${effects}` : "") + (acc.burn ? `, ${t("fl.burn")}` : ""),
1705
+ data: { liters: acc.liters, temperature: ev.temperature, thirst: acc.thirst, effects: [...acc.effects], burn: acc.burn }
1706
+ },
1707
+ 0.5
1708
+ );
1709
+ break;
1710
+ }
1711
+ case "empty":
1712
+ c.log.add({ channel: "event", type: f + "empty", ...meta(ev.container), text: t("fl.empty", { who: who(ev.container) }) });
1713
+ break;
1714
+ case "splash":
1715
+ c.log.coalesce(`splash:${ev.liquid}`, { channel: "event", type: f + "splash", text: t("fl.splash", { liquid: c.liquidName(ev.liquid) }), data: { point: ev.point, intensity: ev.intensity } }, 1);
1716
+ break;
1717
+ case "stain": {
1718
+ const surface = t(`surface.${ev.surface}`);
1719
+ const liquid = c.liquidName(ev.liquid);
1720
+ stainInfo.set(ev.stain, { liquid: ev.liquid, surface, x: ev.point[0], z: ev.point[2] });
1721
+ const key = `stain:${ev.liquid}:${ev.surface}`;
1722
+ const acc = stainAcc.get(key);
1723
+ const n = acc && c.now() - acc.last <= 1 ? acc.n + 1 : 1;
1724
+ stainAcc.set(key, { n, last: c.now() });
1725
+ c.log.coalesce(
1726
+ key,
1727
+ {
1728
+ channel: "event",
1729
+ type: f + "stain",
1730
+ text: n > 1 ? t("fl.stains", { n, liquid, surface, where: xz(ev.point[0], ev.point[2]) }) : t("fl.stain", { liquid, surface, where: xz(ev.point[0], ev.point[2]) }),
1731
+ data: { stain: ev.stain, surface: ev.surface, liquid: ev.liquid, point: ev.point, count: n }
1732
+ },
1733
+ 1
1734
+ );
1735
+ break;
1736
+ }
1737
+ case "dried": {
1738
+ const info = stainInfo.get(ev.stain);
1739
+ const key = `dried:${info?.liquid ?? "?"}:${ev.mark}`;
1740
+ const acc = driedAcc.get(key);
1741
+ const n = acc && c.now() - acc.last <= 1.5 ? acc.n + 1 : 1;
1742
+ driedAcc.set(key, { n, last: c.now() });
1743
+ const vars = { liquid: info ? c.liquidName(info.liquid) : "", surface: info?.surface ?? "", where: info ? xz(info.x, info.z) : "", n };
1744
+ const text = !info ? t(ev.mark ? "fl.dried.mark" : "fl.dried") : n > 1 ? t(ev.mark ? "fl.dried.many.mark" : "fl.dried.many", vars) : t(ev.mark ? "fl.dried.known.mark" : "fl.dried.known", vars);
1745
+ c.log.coalesce(key, { channel: "event", type: f + "dried", text, data: { stain: ev.stain, mark: ev.mark, count: n } }, 1.5);
1746
+ break;
1747
+ }
1748
+ case "mix":
1749
+ c.log.add({ channel: "event", type: f + "mix", ...meta(ev.container), text: t("fl.mix", { who: who(ev.container), from: c.liquidName(ev.from), into: c.liquidName(ev.into) }) });
1750
+ break;
1751
+ default:
1752
+ break;
1753
+ }
1754
+ };
1755
+ return { events, fluid };
1756
+ }
1757
+
1758
+ // src/player.ts
1759
+ var EYE = 1.7;
1760
+ var RADIUS = 0.3;
1761
+ var WALK = 3.4;
1762
+ var RUN = 6.2;
1763
+ var JUMP = 4.6;
1764
+ var GRAVITY = 13;
1765
+ var LOOK = 22e-4;
1766
+ function createPlayer(THREE, o) {
1767
+ const cam = o.camera;
1768
+ let yaw = 0;
1769
+ let pitch = 0;
1770
+ let y = 0;
1771
+ let eye = EYE;
1772
+ let vy = 0;
1773
+ const px = { x: o.start[0], z: o.start[2] };
1774
+ let locked = false;
1775
+ const keys = /* @__PURE__ */ new Set();
1776
+ const euler = new THREE.Euler(0, 0, 0, "YXZ");
1777
+ const apply = () => {
1778
+ euler.set(pitch, yaw, 0, "YXZ");
1779
+ cam.quaternion.setFromEuler(euler);
1780
+ cam.position.set(px.x, eye + y, px.z);
1781
+ cam.updateMatrixWorld(true);
1782
+ };
1783
+ apply();
1784
+ const blocked = (x, z) => {
1785
+ if (Math.abs(x) > o.limit - RADIUS || Math.abs(z) > o.limit - RADIUS) return true;
1786
+ for (const c of o.colliders) if (x > c[0] - RADIUS && x < c[2] + RADIUS && z > c[1] - RADIUS && z < c[3] + RADIUS) return true;
1787
+ return false;
1788
+ };
1789
+ const move = (dx, dz) => {
1790
+ if (!blocked(px.x + dx, px.z)) px.x += dx;
1791
+ if (!blocked(px.x, px.z + dz)) px.z += dz;
1792
+ };
1793
+ let dragging = false;
1794
+ let moved = 0;
1795
+ let downAt = 0;
1796
+ let useStarted = false;
1797
+ let holdTimer = null;
1798
+ const look = (dx, dy) => {
1799
+ yaw -= dx * LOOK;
1800
+ pitch = Math.max(-1.5, Math.min(1.5, pitch - dy * LOOK));
1801
+ };
1802
+ const onLockChange = () => {
1803
+ locked = document.pointerLockElement === o.dom;
1804
+ };
1805
+ const onMouseDown = (e) => {
1806
+ o.dom.focus?.();
1807
+ if (e.button === 2) {
1808
+ e.preventDefault();
1809
+ o.hooks.secondary();
1810
+ return;
1811
+ }
1812
+ if (e.button !== 0) return;
1813
+ if (!locked) {
1814
+ try {
1815
+ const r = o.dom.requestPointerLock?.call(o.dom);
1816
+ if (r && typeof r.catch === "function") r.catch(() => void 0);
1817
+ } catch {
1818
+ }
1819
+ dragging = true;
1820
+ moved = 0;
1821
+ downAt = performance.now();
1822
+ useStarted = false;
1823
+ holdTimer = setTimeout(() => {
1824
+ if (dragging && moved < 4 && !useStarted) {
1825
+ useStarted = true;
1826
+ o.hooks.primaryDown();
1827
+ }
1828
+ }, 220);
1829
+ return;
1830
+ }
1831
+ useStarted = true;
1832
+ o.hooks.primaryDown();
1833
+ };
1834
+ const onMouseUp = (e) => {
1835
+ if (e.button !== 0) return;
1836
+ if (holdTimer) clearTimeout(holdTimer);
1837
+ if (dragging) {
1838
+ dragging = false;
1839
+ if (!useStarted && moved < 4 && performance.now() - downAt < 400) {
1840
+ o.hooks.primaryDown();
1841
+ o.hooks.primaryUp();
1842
+ } else if (useStarted) o.hooks.primaryUp();
1843
+ useStarted = false;
1844
+ return;
1845
+ }
1846
+ if (useStarted) o.hooks.primaryUp();
1847
+ useStarted = false;
1848
+ };
1849
+ const onMouseMove = (e) => {
1850
+ if (locked) look(e.movementX, e.movementY);
1851
+ else if (dragging) {
1852
+ moved += Math.abs(e.movementX) + Math.abs(e.movementY);
1853
+ if (moved >= 4) {
1854
+ if (useStarted) {
1855
+ useStarted = false;
1856
+ o.hooks.primaryUp();
1857
+ }
1858
+ look(e.movementX, e.movementY);
1859
+ }
1860
+ }
1861
+ };
1862
+ const onWheel = (e) => {
1863
+ e.preventDefault();
1864
+ o.hooks.wheel(e.deltaY > 0 ? 1 : -1);
1865
+ };
1866
+ const typing = (e) => {
1867
+ const t = e.target;
1868
+ return !!t && (t.tagName === "INPUT" || t.tagName === "SELECT" || t.tagName === "TEXTAREA");
1869
+ };
1870
+ const onKeyDown = (e) => {
1871
+ if (typing(e)) return;
1872
+ if (e.code === "Space" || e.code === "Tab") e.preventDefault();
1873
+ if (!e.repeat) keys.add(e.code);
1874
+ o.hooks.key(e.code, true, e);
1875
+ };
1876
+ const onKeyUp = (e) => {
1877
+ keys.delete(e.code);
1878
+ if (typing(e)) return;
1879
+ o.hooks.key(e.code, false, e);
1880
+ };
1881
+ const onContext = (e) => e.preventDefault();
1882
+ const onBlur = () => keys.clear();
1883
+ o.dom.addEventListener("mousedown", onMouseDown);
1884
+ window.addEventListener("mouseup", onMouseUp);
1885
+ window.addEventListener("mousemove", onMouseMove);
1886
+ o.dom.addEventListener("wheel", onWheel, { passive: false });
1887
+ o.dom.addEventListener("contextmenu", onContext);
1888
+ document.addEventListener("pointerlockchange", onLockChange);
1889
+ window.addEventListener("keydown", onKeyDown);
1890
+ window.addEventListener("keyup", onKeyUp);
1891
+ window.addEventListener("blur", onBlur);
1892
+ return {
1893
+ get yaw() {
1894
+ return yaw;
1895
+ },
1896
+ get pitch() {
1897
+ return pitch;
1898
+ },
1899
+ get locked() {
1900
+ return locked;
1901
+ },
1902
+ keys,
1903
+ update(dt) {
1904
+ const run = keys.has("ShiftLeft") || keys.has("ShiftRight");
1905
+ const speed = run ? RUN : WALK;
1906
+ let f = 0;
1907
+ let r = 0;
1908
+ if (keys.has("KeyW") || keys.has("ArrowUp")) f += 1;
1909
+ if (keys.has("KeyS") || keys.has("ArrowDown")) f -= 1;
1910
+ if (keys.has("KeyD") || keys.has("ArrowRight")) r += 1;
1911
+ if (keys.has("KeyA") || keys.has("ArrowLeft")) r -= 1;
1912
+ if ((f || r || keys.has("Space")) && eye !== EYE) eye = EYE;
1913
+ if (f || r) {
1914
+ const len = Math.hypot(f, r);
1915
+ const fx = -Math.sin(yaw);
1916
+ const fz = -Math.cos(yaw);
1917
+ const rx = Math.cos(yaw);
1918
+ const rz = -Math.sin(yaw);
1919
+ move((fx * f + rx * r) / len * speed * dt, (fz * f + rz * r) / len * speed * dt);
1920
+ }
1921
+ if (y <= 0 && keys.has("Space")) vy = JUMP;
1922
+ if (y > 0 || vy > 0) {
1923
+ vy -= GRAVITY * dt;
1924
+ y += vy * dt;
1925
+ if (y <= 0) {
1926
+ y = 0;
1927
+ vy = 0;
1928
+ }
1929
+ }
1930
+ apply();
1931
+ },
1932
+ setPose(position, yw, pt) {
1933
+ px.x = position[0];
1934
+ px.z = position[2];
1935
+ eye = position[1];
1936
+ y = 0;
1937
+ vy = 0;
1938
+ yaw = yw;
1939
+ pitch = pt;
1940
+ apply();
1941
+ },
1942
+ look(position, target) {
1943
+ px.x = position[0];
1944
+ px.z = position[2];
1945
+ eye = position[1];
1946
+ y = 0;
1947
+ vy = 0;
1948
+ const dx = target[0] - position[0];
1949
+ const dy = target[1] - position[1];
1950
+ const dz = target[2] - position[2];
1951
+ yaw = Math.atan2(-dx, -dz);
1952
+ pitch = Math.atan2(dy, Math.hypot(dx, dz));
1953
+ apply();
1954
+ },
1955
+ kick(pitchDeg, yawDeg) {
1956
+ pitch = Math.max(-1.5, Math.min(1.5, pitch + pitchDeg * Math.PI / 180));
1957
+ yaw += yawDeg * Math.PI / 180;
1958
+ apply();
1959
+ },
1960
+ moveBy(dx, dz) {
1961
+ move(dx, dz);
1962
+ apply();
1963
+ },
1964
+ walk(forward, right) {
1965
+ move(-Math.sin(yaw) * forward + Math.cos(yaw) * right, -Math.cos(yaw) * forward - Math.sin(yaw) * right);
1966
+ apply();
1967
+ },
1968
+ dispose() {
1969
+ if (holdTimer) clearTimeout(holdTimer);
1970
+ o.dom.removeEventListener("mousedown", onMouseDown);
1971
+ window.removeEventListener("mouseup", onMouseUp);
1972
+ window.removeEventListener("mousemove", onMouseMove);
1973
+ o.dom.removeEventListener("wheel", onWheel);
1974
+ o.dom.removeEventListener("contextmenu", onContext);
1975
+ document.removeEventListener("pointerlockchange", onLockChange);
1976
+ window.removeEventListener("keydown", onKeyDown);
1977
+ window.removeEventListener("keyup", onKeyUp);
1978
+ window.removeEventListener("blur", onBlur);
1979
+ if (document.pointerLockElement === o.dom) document.exitPointerLock?.();
1980
+ }
1981
+ };
1982
+ }
1983
+
1984
+ // src/scenarios.ts
1985
+ import { JOINT_DEFAULTS, rotateVec, toWorld } from "@adatechnology/game-destruction";
1986
+ function makeScenarios(t, num, _locale = "en") {
1987
+ const yn = (b) => t(b ? "yes" : "no");
1988
+ const cn = (what, n) => t(`count.${what}.${n === 1 ? "one" : "other"}`, { n });
1989
+ const pc = (n) => t(n === 1 ? "count.piece.one" : "count.piece.other", { n });
1990
+ const sc = (id, run) => ({ id, name: t(`sc.${id}`), run });
1991
+ const at = (room, itemId, x, z, y = 0, rot = 0) => room.spawn(itemId, [x, y, z], rot).objectId;
1992
+ const look = (room, from, to) => room.look(from, to);
1993
+ const part = (room, id, p) => room.world.part(id, p);
1994
+ const count = (room, type, objectId) => room.log.entries({ type, objectId }).length;
1995
+ const anyBurning = (room, id) => (room.world.state(id)?.parts ?? []).some((p) => p.burning);
1996
+ const igniteHit = (room, id, point, temperature = 900, seconds = 3) => {
1997
+ room.world.hit(id, { type: "fire", point, temperature, radius: 0.6, duration: seconds });
1998
+ };
1999
+ 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] };
2000
+ const killColumns = (room, id, ids) => {
2001
+ for (const c of ids) room.world.hitPart(id, c, { type: "blunt", point: COLUMN[c], energy: 3e6, radius: 0.05, source: "test" });
2002
+ };
2003
+ const heavyCollapse = (room) => room.log.entries({ type: "collapse" }).reduce((m, e) => Math.max(m, Number(e.data?.massKg ?? 0)), 0);
2004
+ return [
2005
+ sc("steel_through_hole", ({ room, step, expect }) => {
2006
+ const id = at(room, "steel_sheet", 0, -4);
2007
+ room.setTool("pistol_9mm");
2008
+ look(room, [0, 1.1, -1.5], [0, 1.1, -4]);
2009
+ room.use();
2010
+ step(0.2);
2011
+ const p = part(room, id, "sheet");
2012
+ const hole = p?.holes[0];
2013
+ expect(
2014
+ !!hole?.through,
2015
+ 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)}` }),
2016
+ { holes: p?.holes.length, through: hole?.through }
2017
+ );
2018
+ }),
2019
+ sc("concrete_stops_bullet", ({ room, step, expect }) => {
2020
+ const id = at(room, "concrete_block", 0, -4);
2021
+ room.setTool("pistol_9mm");
2022
+ look(room, [0, 0.5, -1.5], [0, 0.2, -3.8]);
2023
+ room.use();
2024
+ step(0.2);
2025
+ const p = part(room, id, "block");
2026
+ const wall = part(room, "target_wall", "brick_1_4");
2027
+ const through = p?.holes.some((h) => h.through) ?? true;
2028
+ expect(
2029
+ !through && (p?.holes.length ?? 0) + (p?.dents.length ?? 0) + (p?.cracks.length ?? 0) > 0 && (wall?.holes.length ?? 0) === 0,
2030
+ 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)}` }),
2031
+ { through }
2032
+ );
2033
+ }),
2034
+ sc("hammer_dents_drum", ({ room, step, expect }) => {
2035
+ const id = at(room, "steel_drum", 0, -4);
2036
+ room.setTool("hammer");
2037
+ look(room, [0, 0.6, -3.35], [0, 0.5, -4]);
2038
+ let swings = 0;
2039
+ for (; swings < 8 && (part(room, id, "drum_body")?.dents.length ?? 0) === 0; swings++) {
2040
+ room.use();
2041
+ step(1);
2042
+ }
2043
+ const p = part(room, id, "drum_body");
2044
+ expect(
2045
+ (p?.dents.length ?? 0) > 0 && p?.status !== "broken",
2046
+ 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)}` }),
2047
+ { swings }
2048
+ );
2049
+ }),
2050
+ sc("glass_shatters", ({ room, step, expect }) => {
2051
+ const id = at(room, "glass_pane", 0, -4);
2052
+ room.setTool("pistol_9mm");
2053
+ look(room, [0, 1.3, -1.5], [0, 1.05, -4]);
2054
+ let shots = 0;
2055
+ for (; shots < 10 && part(room, id, "pane")?.status !== "broken"; shots++) {
2056
+ room.use();
2057
+ step(0.25);
2058
+ }
2059
+ step(0.5);
2060
+ const fr = room.log.entries({ type: "fracture", objectId: id }).find((e) => e.part === "pane");
2061
+ const n = Number(fr?.data?.fragments ?? 0);
2062
+ expect(part(room, id, "pane")?.status === "broken" && n >= 4, t("sc.glass_shatters.check", { shots, n }), { shots, fragments: n });
2063
+ }),
2064
+ sc("shotgun_splinters_crate", ({ room, step, expect }) => {
2065
+ const id = at(room, "wooden_crate", 0, -4);
2066
+ room.setTool("shotgun_pump_12");
2067
+ look(room, [0, 0.6, -1.8], [0, 0.25, -3.77]);
2068
+ let shots = 0;
2069
+ const splinters = () => room.log.entries({ type: "fracture", objectId: id }).filter((e) => e.data?.style === "splinter").length;
2070
+ for (; shots < 4 && splinters() === 0; shots++) {
2071
+ room.use();
2072
+ step(1.1);
2073
+ }
2074
+ const holes = (room.world.state(id)?.parts ?? []).reduce((n, p) => n + p.holes.length, 0);
2075
+ expect(splinters() > 0, t("sc.shotgun_splinters_crate.check", { shots, splinters: splinters(), holes }), { shots });
2076
+ }),
2077
+ sc("kick_chair_leg", ({ room, step, expect }) => {
2078
+ const id = at(room, "chair", 0, -4);
2079
+ const before = room.renderer.pieces().length;
2080
+ const leg = part(room, id, "leg_1");
2081
+ void leg;
2082
+ let kicks = 0;
2083
+ for (; kicks < 6 && room.renderer.pieces().length <= before && part(room, id, "leg_1"); kicks++) {
2084
+ 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" });
2085
+ step(0.3);
2086
+ }
2087
+ step(0.5);
2088
+ const after = room.renderer.pieces().length;
2089
+ const groups = room.world.state(id)?.groups.length ?? 1;
2090
+ expect(
2091
+ after > before,
2092
+ t("sc.kick_chair_leg.check", { kicks, groups, before, after, leg: part(room, id, "leg_1") ? t("leg.still") : t("leg.gone") }),
2093
+ { kicks, before, after }
2094
+ );
2095
+ }),
2096
+ sc("kick_detaches_leg_via_tool", ({ room, step, expect }) => {
2097
+ const id = at(room, "chair", 0, -4);
2098
+ const before = room.renderer.pieces().length;
2099
+ room.setTool("kick");
2100
+ let kicks = 0;
2101
+ for (; kicks < 8 && room.renderer.pieces().length <= before && part(room, id, "leg_1"); kicks++) {
2102
+ const st = room.world.state(id);
2103
+ const leg = toWorld(st.position, st.rotation, [-0.18, 0.2, -0.18]);
2104
+ const side = rotateVec(st.rotation, [-0.8, 0, 0]);
2105
+ look(room, [leg[0] + side[0], leg[1] + side[1], leg[2] + side[2]], leg);
2106
+ room.use();
2107
+ step(1.05);
2108
+ }
2109
+ step(0.5);
2110
+ const after = room.renderer.pieces().length;
2111
+ 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 });
2112
+ }),
2113
+ sc("screwdriver_shelf_board", ({ room, step, expect }) => {
2114
+ const id = at(room, "shelf", 0, -4);
2115
+ room.setTool("screwdriver");
2116
+ look(room, [0, 0.8, -3.5], [0, 0.75, -3.86]);
2117
+ const t0 = room.log.entries().length ? room.log.entries().slice(-1)[0].t : 0;
2118
+ room.use(5);
2119
+ const done = room.log.entries({ type: "detach", objectId: id })[0];
2120
+ const elapsed = done ? done.t - t0 : 5;
2121
+ const want = JOINT_DEFAULTS.screw.seconds;
2122
+ const groups = room.renderer.pieces().length;
2123
+ 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 });
2124
+ step(0.2);
2125
+ }),
2126
+ sc("wrong_tool_refused", ({ room, expect }) => {
2127
+ const id = at(room, "steel_sheet", 0, -4);
2128
+ room.setTool("screwdriver");
2129
+ look(room, [0.2, 1.05, -3.6], [0.34, 1, -4]);
2130
+ room.use(0.5);
2131
+ const refused = count(room, "tool_refused");
2132
+ const opened = (room.world.state(id)?.joints ?? []).filter((j) => j.open).length;
2133
+ expect(refused > 0 && opened === 0, t("sc.wrong_tool_refused.check", { refused, opened }), { refused, opened });
2134
+ }),
2135
+ sc("lighter_burns_crate", ({ room, step, expect }) => {
2136
+ const id = at(room, "wooden_crate", 0, -4);
2137
+ room.setTool("lighter");
2138
+ look(room, [0, 0.3, -3.65], [0, 0.25, -3.76]);
2139
+ room.use(5);
2140
+ const lit = anyBurning(room, id);
2141
+ expect(lit, t("sc.lighter_burns_crate.lit", { lit: yn(lit) }));
2142
+ step(500);
2143
+ const parts = room.world.state(id)?.parts ?? [];
2144
+ const ash = parts.filter((p) => p.status === "ashes").length;
2145
+ const charred = Math.max(...parts.map((p) => p.char));
2146
+ 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 });
2147
+ }),
2148
+ sc("torch_melts_steel", ({ room, step, expect }) => {
2149
+ const drum2 = at(room, "steel_drum", 0, -4);
2150
+ const crate2 = at(room, "wooden_crate", 0.7, -4);
2151
+ igniteHit(room, crate2, [0.7, 0.25, -3.8], 900, 2);
2152
+ let peak = 0;
2153
+ for (let i = 0; i < 40; i++) {
2154
+ step(1);
2155
+ peak = Math.max(peak, part(room, drum2, "drum_body")?.temperature ?? 0);
2156
+ }
2157
+ const melt0 = part(room, drum2, "drum_body")?.melt ?? 0;
2158
+ 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 });
2159
+ room.setTool("cutting_torch");
2160
+ look(room, [-0.15, 0.55, -3.55], [-0.15, 0.5, -3.72]);
2161
+ room.use(20);
2162
+ const p = part(room, drum2, "drum_body");
2163
+ 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 });
2164
+ }),
2165
+ sc("bucket_melts_drips", ({ room, step, expect }) => {
2166
+ const id = at(room, "plastic_bucket", 0, -4);
2167
+ room.setTool("flamethrower");
2168
+ look(room, [0, 0.5, -2.4], [0, 0.15, -4]);
2169
+ room.use(2.5);
2170
+ step(10);
2171
+ const p = part(room, id, "bucket_body");
2172
+ const drips = count(room, "drip", id);
2173
+ 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 });
2174
+ }),
2175
+ sc("gas_bottle_explodes", ({ room, step, expect }) => {
2176
+ const bottle2 = at(room, "gas_bottle", 0, -4);
2177
+ const crate2 = at(room, "wooden_crate", 1.3, -4);
2178
+ const c0 = room.spawned().find((s) => s.objectId === crate2)?.root.position.clone();
2179
+ let seconds = 0;
2180
+ for (; seconds < 60 && count(room, "explode", bottle2) === 0; seconds++) {
2181
+ igniteHit(room, bottle2, [0, 0.4, -3.9], 1e3, 1);
2182
+ step(1);
2183
+ }
2184
+ step(1.5);
2185
+ const c1 = room.spawned().find((s) => s.objectId === crate2)?.root.position;
2186
+ const moved = c0 && c1 ? c0.distanceTo(c1) : 0;
2187
+ const state = room.world.state(crate2);
2188
+ const lost = (state?.parts ?? []).filter((p) => p.status === "broken").length + (state && state.groups.length > 1 ? 1 : 0);
2189
+ expect(count(room, "explode", bottle2) > 0, t("sc.gas_bottle_explodes.explode", { seconds }), { seconds });
2190
+ expect(moved > 0.3 || lost > 0, t("sc.gas_bottle_explodes.push", { moved: num(moved, 1), lost: pc(lost) }), { moved, lost });
2191
+ }),
2192
+ sc("bottle_drop_shatters", ({ room, step, expect }) => {
2193
+ const id = at(room, "glass_bottle", 0, -3, 1.5);
2194
+ step(2);
2195
+ const p = part(room, id, "bottle_body");
2196
+ const stain = room.fluids.stains().find((s) => s.liquid === "water");
2197
+ expect(p?.status === "broken", t("sc.bottle_drop_shatters.check", { status: p?.status ?? "?", cracks: p?.cracks.length ?? 0, stain: yn(!!stain) }), { status: p?.status });
2198
+ }),
2199
+ sc("throw_chair_at_wall", ({ room, step, expect }) => {
2200
+ const id = at(room, "chair", 0, -5.2);
2201
+ look(room, [0, 1.7, -4.2], [0, 0.35, -5.2]);
2202
+ room.grab();
2203
+ look(room, [0, 1.7, -4.2], [0, 2.3, -11]);
2204
+ step(0.3);
2205
+ room.throwHeld(16);
2206
+ step(2.5);
2207
+ const hits = room.log.entries({ type: "impact" });
2208
+ const wallDamage = room.log.entries().filter((e) => e.objectId === "target_wall" && (e.type === "dent" || e.type === "damage" || e.type === "crack" || e.type === "fracture")).length;
2209
+ 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 });
2210
+ }),
2211
+ sc("coffee_three_sips", ({ room, step, expect }) => {
2212
+ const id = at(room, "coffee_cup", 0, -3);
2213
+ look(room, [0, 1, -1.8], [0, 0.05, -3]);
2214
+ const cid = `${id}_fluid`;
2215
+ const before = room.fluids.container(cid)?.liters ?? 0;
2216
+ room.grab();
2217
+ for (let i = 0; i < 3; i++) {
2218
+ room.useItem("sip");
2219
+ step(1.4);
2220
+ }
2221
+ const after = room.fluids.container(cid)?.liters ?? 0;
2222
+ const drinks = room.log.entries().filter((e) => e.type === "fluid.drink");
2223
+ const caffeine = drinks.some((e) => Array.isArray(e.data?.effects) && (e.data?.effects).includes("caffeine"));
2224
+ 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 });
2225
+ }),
2226
+ sc("tilt_cup_spills", ({ room, step, expect }) => {
2227
+ const id = at(room, "coffee_cup", 0, -3);
2228
+ look(room, [0, 1, -1.8], [0, 0.05, -3]);
2229
+ const cid = `${id}_fluid`;
2230
+ const before = room.fluids.container(cid)?.liters ?? 0;
2231
+ room.grab();
2232
+ step(0.6);
2233
+ room.tilt(2);
2234
+ step(2.5);
2235
+ const after = room.fluids.container(cid)?.liters ?? 0;
2236
+ const stains = room.fluids.stains().filter((s) => s.liquid === "coffee").length;
2237
+ expect(before - after > 0.03 && stains > 0, t("sc.tilt_cup_spills.check", { before: num(before, 2), after: num(after, 2), stains }), { before, after });
2238
+ }),
2239
+ sc("jerrycan_leaks_fuel", ({ room, step, expect }) => {
2240
+ const id = at(room, "jerrycan", 0, -3.5);
2241
+ room.setTool("pistol_9mm");
2242
+ look(room, [0, 0.4, -1.5], [0, 0.12, -3.43]);
2243
+ room.use();
2244
+ step(1.5);
2245
+ const streams = room.fluids.streams().filter((s) => s.cause === "leak" && s.liquid === "fuel");
2246
+ step(5);
2247
+ const stain = room.fluids.stains().find((s) => s.liquid === "fuel");
2248
+ const left = room.fluids.container(`${id}_fluid`)?.liters ?? 20;
2249
+ const hole = room.log.entries({ type: "hole", objectId: id })[0];
2250
+ 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 });
2251
+ }),
2252
+ sc("fuel_stain_burns", ({ room, step, expect }) => {
2253
+ const id = at(room, "jerrycan", 0, -3.5);
2254
+ const crate2 = at(room, "wooden_crate", 0.9, -3.2);
2255
+ room.setTool("pistol_9mm");
2256
+ look(room, [0, 0.4, -1.5], [0, 0.12, -3.43]);
2257
+ room.use();
2258
+ step(6);
2259
+ const stain = room.fluids.stains().find((s) => s.liquid === "fuel");
2260
+ expect(!!stain, t("sc.fuel_stain_burns.stain", { stain: yn(!!stain) }));
2261
+ if (!stain) return;
2262
+ room.setTool("lighter");
2263
+ look(room, [stain.center[0], 0.1, stain.center[2] + 0.06], [stain.center[0], 0, stain.center[2]]);
2264
+ room.use(1);
2265
+ step(4);
2266
+ const ignited = count(room, "stain_ignite");
2267
+ const fireHits = (room.world.state(id)?.parts ?? []).some((p) => p.burning || p.temperature > 100) || anyBurning(room, crate2);
2268
+ expect(ignited > 0, t("sc.fuel_stain_burns.ignite", { ignited, near: yn(fireHits) }), { ignited });
2269
+ step(30);
2270
+ 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") }));
2271
+ }),
2272
+ sc("one_column_roof_stands", ({ room, step, expect }) => {
2273
+ const id = at(room, "building_frame", 0, -6);
2274
+ killColumns(room, id, ["column_0_0_0"]);
2275
+ step(40);
2276
+ const roof = part(room, id, "roof");
2277
+ const neighbour = part(room, id, "column_0_0_1")?.stress ?? 0;
2278
+ const heavy = Math.max(heavyCollapse(room), ...room.renderer.pieces().map((p) => p.mass));
2279
+ 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 });
2280
+ }),
2281
+ sc("two_columns_roof_collapses", ({ room, step, expect }) => {
2282
+ const id = at(room, "building_frame", 0, -6);
2283
+ killColumns(room, id, ["column_0_0_0", "column_0_1_0"]);
2284
+ const stress0 = room.world.part(id, "column_0_0_1")?.stress ?? 0;
2285
+ step(40);
2286
+ const pieces = room.renderer.pieces().filter((p) => p.mass >= 2e3);
2287
+ const logged = heavyCollapse(room) >= 2e3;
2288
+ const kg = pieces.reduce((m, p) => m + p.mass, 0);
2289
+ 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 });
2290
+ }),
2291
+ sc("extinguisher_puts_out_fire", ({ room, step, expect }) => {
2292
+ const id = at(room, "wooden_crate", 0, -4);
2293
+ igniteHit(room, id, [0, 0.25, -3.8], 900, 4);
2294
+ step(6);
2295
+ const before = anyBurning(room, id);
2296
+ expect(before, t("sc.extinguisher_puts_out_fire.burning", { burning: yn(before) }));
2297
+ room.setTool("extinguisher");
2298
+ look(room, [0, 0.9, -2.2], [0, 0.25, -4]);
2299
+ room.use(2.5);
2300
+ step(1);
2301
+ const after = anyBurning(room, id);
2302
+ expect(!after, t("sc.extinguisher_puts_out_fire.out", { burning: yn(after), events: count(room, "extinguish", id) }), { events: count(room, "extinguish", id) });
2303
+ })
2304
+ ];
2305
+ }
2306
+ var makeT = (locale) => (key, vars) => {
2307
+ let s = resolveStrings(locale).text[key] ?? key;
2308
+ if (vars) for (const k of Object.keys(vars)) s = s.split(`{${k}}`).join(String(vars[k]));
2309
+ return s;
2310
+ };
2311
+ function scenariosFor(locale = "en") {
2312
+ const pt = locale.toLowerCase().startsWith("pt");
2313
+ 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);
2314
+ }
2315
+ var SCENARIOS = scenariosFor("en");
2316
+
2317
+ // src/ui.ts
2318
+ var CSS = `
2319
+ .tr-root{position:absolute;inset:0;pointer-events:none;font:12px/1.35 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;color:#e8eaee;overflow:hidden}
2320
+ .tr-root *{box-sizing:border-box}
2321
+ .tr-root button,.tr-root select,.tr-root label{pointer-events:auto}
2322
+ .tr-cross{position:absolute;left:50%;top:50%;width:18px;height:18px;margin:-9px 0 0 -9px}
2323
+ .tr-cross:before,.tr-cross:after{content:"";position:absolute;background:#fff;opacity:.85;box-shadow:0 0 2px #000}
2324
+ .tr-cross:before{left:8px;top:0;width:2px;height:18px}.tr-cross:after{top:8px;left:0;height:2px;width:18px}
2325
+ .tr-cross i{position:absolute;left:6px;top:6px;width:6px;height:6px;background:#0b0c0e;border-radius:50%;z-index:1}
2326
+ .tr-top{position:absolute;top:8px;left:50%;transform:translateX(-50%);text-align:center;max-width:46vw}
2327
+ .tr-tool{display:inline-block;background:rgba(10,12,16,.72);padding:4px 12px;border-radius:6px;font-size:14px;font-weight:600}
2328
+ .tr-tool small{font-weight:400;opacity:.8;margin-left:8px}
2329
+ .tr-status{margin-top:4px;min-height:16px;color:#ffd23f;text-shadow:0 1px 2px #000}
2330
+ .tr-prog{margin:4px auto 0;width:260px;background:rgba(10,12,16,.72);border-radius:6px;padding:4px 8px;display:none}
2331
+ .tr-prog b{display:block;font-weight:500;margin-bottom:3px}
2332
+ .tr-bar{height:7px;background:#2a2e36;border-radius:4px;overflow:hidden}
2333
+ .tr-bar i{display:block;height:100%;background:#4fc3f7;width:0}
2334
+ .tr-bar.hp i{background:#66bb6a}.tr-bar.warn i{background:#ffb300}.tr-bar.bad i{background:#ef5350}
2335
+ .tr-bottom{position:absolute;bottom:8px;left:50%;transform:translateX(-50%);text-align:center;max-width:56vw}
2336
+ .tr-held{margin-bottom:6px;background:rgba(10,12,16,.72);padding:4px 10px;border-radius:6px;display:none}
2337
+ .tr-held kbd,.tr-help kbd{background:#2a2e36;border:1px solid #4a505c;border-radius:3px;padding:0 4px;font:inherit;font-weight:700}
2338
+ .tr-hotbar{display:inline-flex;gap:3px;flex-wrap:wrap;justify-content:center}
2339
+ .tr-slot{pointer-events:auto;min-width:64px;max-width:92px;padding:3px 5px;background:rgba(10,12,16,.68);border:1px solid #3a404c;border-radius:5px;color:#cfd3da;cursor:pointer;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2340
+ .tr-slot b{color:#ffd23f;margin-right:3px}
2341
+ .tr-slot.on{border-color:#ffd23f;color:#fff;background:rgba(60,50,10,.8)}
2342
+ .tr-help{position:absolute;left:8px;bottom:8px;max-width:360px;opacity:.75;font-size:11px;background:rgba(10,12,16,.55);padding:4px 8px;border-radius:6px}
2343
+ .tr-left{position:absolute;top:8px;left:8px;bottom:70px;width:min(400px,36vw);display:flex;flex-direction:column;gap:8px}
2344
+ .tr-right{position:absolute;top:8px;right:8px;bottom:8px;width:min(310px,34vw);display:flex;flex-direction:column;gap:8px}
2345
+ .tr-panel{background:rgba(12,14,19,.82);border:1px solid #2c313b;border-radius:8px;padding:8px;pointer-events:auto;overflow:auto}
2346
+ .tr-panel h3{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;color:#9aa3b2;font-weight:600}
2347
+ .tr-insp{flex:0 0 auto;max-height:46%;min-height:60px}
2348
+ .tr-insp .r{display:flex;justify-content:space-between;gap:8px}.tr-insp .r span:first-child{color:#9aa3b2}
2349
+ .tr-insp .t{font-weight:600;font-size:13px;margin-bottom:3px}
2350
+ .tr-log{flex:1 1 auto;display:flex;flex-direction:column;min-height:120px;overflow:hidden}
2351
+ .tr-logbar{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:5px;align-items:center}
2352
+ .tr-logbody{flex:1;overflow:auto;font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;background:#090a0d;border-radius:5px;padding:4px 6px}
2353
+ .tr-logbody div{white-space:pre-wrap;word-break:break-word;border-bottom:1px solid #15171c}
2354
+ .tr-logbody .action{color:#8fd3ff}.tr-logbody .event{color:#dfe3ea}.tr-logbody .check{color:#c8e6c9}.tr-logbody .check.fail{color:#ff8a80;font-weight:700}.tr-logbody .check.pass{color:#9be59f}
2355
+ .tr-logbody time{color:#6b7280;margin-right:6px}
2356
+ .tr-btn,.tr-root button.tr-btn{background:#232831;border:1px solid #3a404c;color:#e8eaee;border-radius:5px;padding:3px 8px;cursor:pointer;font:inherit}
2357
+ .tr-btn:hover{background:#2d3440}.tr-btn.on{background:#3b3a14;border-color:#ffd23f;color:#fff}
2358
+ .tr-btn.pass{border-color:#4caf50}.tr-btn.fail{border-color:#ef5350;background:#3a1d1d}
2359
+ .tr-tabs{display:flex;flex-wrap:wrap;gap:3px;margin-bottom:6px}
2360
+ .tr-items{display:grid;grid-template-columns:1fr 1fr;gap:4px}
2361
+ .tr-item{display:flex;gap:2px}.tr-item .tr-btn:first-child{flex:1;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2362
+ .tr-cat{flex:0 1 auto;max-height:40%}
2363
+ .tr-anim{flex:0 0 auto;max-height:18%}
2364
+ .tr-anim .list{display:flex;flex-wrap:wrap;gap:4px}.tr-anim .sub{color:#9aa3b2;margin-bottom:4px}
2365
+ .tr-tests{flex:1 1 auto;min-height:100px}
2366
+ .tr-tests .list{display:flex;flex-direction:column;gap:3px;margin-top:6px}.tr-tests .list .tr-btn{text-align:left}
2367
+ .tr-row{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px;align-items:center}
2368
+ .tr-toggle{position:absolute;top:8px;right:8px;display:none}
2369
+ .tr-summary{margin-top:6px;color:#9aa3b2}
2370
+ @media (max-width:980px){.tr-left,.tr-right{display:none}.tr-root.open .tr-left,.tr-root.open .tr-right{display:flex}.tr-toggle{display:block;pointer-events:auto}.tr-root.open .tr-toggle{right:min(310px,34vw)}.tr-left{width:min(400px,46vw)}.tr-help{display:none}}
2371
+ `;
2372
+ var mk = (tag, cls, text) => {
2373
+ const e = document.createElement(tag);
2374
+ if (cls) e.className = cls;
2375
+ if (text !== void 0) e.textContent = text;
2376
+ return e;
2377
+ };
2378
+ function createUi(o) {
2379
+ const { t } = o;
2380
+ const style = mk("style");
2381
+ style.textContent = CSS;
2382
+ const root = mk("div", "tr-root");
2383
+ const pos = getComputedStyle?.(o.container)?.position;
2384
+ if (!pos || pos === "static") o.container.style.position = "relative";
2385
+ root.appendChild(style);
2386
+ o.container.appendChild(root);
2387
+ const cross = mk("div", "tr-cross");
2388
+ cross.appendChild(mk("i"));
2389
+ const top = mk("div", "tr-top");
2390
+ const toolBox = mk("div", "tr-tool");
2391
+ const status = mk("div", "tr-status");
2392
+ const prog = mk("div", "tr-prog");
2393
+ const progLabel = mk("b");
2394
+ const progBar = mk("div", "tr-bar");
2395
+ const progFill = mk("i");
2396
+ progBar.appendChild(progFill);
2397
+ prog.append(progLabel, progBar);
2398
+ top.append(toolBox, status, prog);
2399
+ const bottom = mk("div", "tr-bottom");
2400
+ const held = mk("div", "tr-held");
2401
+ const hotbar = mk("div", "tr-hotbar");
2402
+ bottom.append(held, hotbar);
2403
+ const help = mk("div", "tr-help");
2404
+ help.textContent = t("ui.help");
2405
+ const left = mk("div", "tr-left");
2406
+ const insp = mk("div", "tr-panel tr-insp");
2407
+ const logPanel = mk("div", "tr-panel tr-log");
2408
+ left.append(insp, logPanel);
2409
+ const logTitle = mk("h3", void 0, t("ui.log"));
2410
+ const logBar = mk("div", "tr-logbar");
2411
+ const logBody = mk("div", "tr-logbody");
2412
+ const filters = { action: true, event: true, check: true };
2413
+ const filterBtns = [];
2414
+ for (const ch of ["action", "event", "check"]) {
2415
+ const b = mk("button", "tr-btn on", t(`ui.filter.${ch}`));
2416
+ b.type = "button";
2417
+ b.onclick = () => {
2418
+ filters[ch] = !filters[ch];
2419
+ b.classList.toggle("on", filters[ch]);
2420
+ rebuildLog();
2421
+ };
2422
+ filterBtns.push(b);
2423
+ logBar.appendChild(b);
2424
+ }
2425
+ let autoscroll = true;
2426
+ const asLabel = mk("label");
2427
+ const asBox = mk("input");
2428
+ asBox.type = "checkbox";
2429
+ asBox.checked = true;
2430
+ asBox.onchange = () => {
2431
+ autoscroll = asBox.checked;
2432
+ };
2433
+ asLabel.append(asBox, document.createTextNode(" " + t("ui.autoscroll")));
2434
+ logBar.appendChild(asLabel);
2435
+ const logBtn = (label, fn) => {
2436
+ const b = mk("button", "tr-btn", label);
2437
+ b.type = "button";
2438
+ b.onclick = fn;
2439
+ logBar.appendChild(b);
2440
+ };
2441
+ logBtn(t("ui.copy_json"), () => {
2442
+ const text = o.log.toJSON();
2443
+ const select = () => {
2444
+ const ta = mk("textarea");
2445
+ ta.value = text;
2446
+ ta.setAttribute("readonly", "");
2447
+ ta.style.cssText = "position:fixed;left:8px;bottom:8px;width:min(480px,80vw);height:120px;z-index:99999";
2448
+ document.body.appendChild(ta);
2449
+ ta.focus();
2450
+ ta.select();
2451
+ let copied = false;
2452
+ try {
2453
+ copied = !!document.execCommand?.("copy");
2454
+ } catch {
2455
+ copied = false;
2456
+ }
2457
+ if (copied) ta.remove();
2458
+ else ta.onblur = () => ta.remove();
2459
+ };
2460
+ if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) navigator.clipboard.writeText(text).catch(select);
2461
+ else select();
2462
+ });
2463
+ logBtn(t("ui.download_ndjson"), () => o.log.download("ndjson"));
2464
+ logBtn(t("ui.download_txt"), () => o.log.download("txt"));
2465
+ logBtn(t("ui.clear_log"), () => {
2466
+ o.log.clear();
2467
+ logBody.textContent = "";
2468
+ nodes.clear();
2469
+ });
2470
+ logPanel.append(logTitle, logBar, logBody);
2471
+ const MAX_LINES = 200;
2472
+ const nodes = /* @__PURE__ */ new Map();
2473
+ const fmtTime = (s) => `${Math.floor(s / 60).toString().padStart(2, "0")}:${(s % 60).toFixed(1).padStart(4, "0")}`;
2474
+ const lineClass = (e) => `${e.channel}${e.type === "fail" || e.type === "scenario_fail" ? " fail" : e.type === "pass" || e.type === "scenario_pass" ? " pass" : ""}`;
2475
+ const fill = (n, e) => {
2476
+ n.className = lineClass(e);
2477
+ n.textContent = "";
2478
+ const tm = mk("time", void 0, fmtTime(e.t));
2479
+ n.append(tm, document.createTextNode(e.text));
2480
+ };
2481
+ const addNode = (e) => {
2482
+ if (!filters[e.channel]) return;
2483
+ const n = mk("div");
2484
+ fill(n, e);
2485
+ nodes.set(e.seq, n);
2486
+ logBody.appendChild(n);
2487
+ while (logBody.childElementCount > MAX_LINES) {
2488
+ const first = logBody.firstElementChild;
2489
+ for (const [k, v] of nodes) if (v === first) nodes.delete(k);
2490
+ first.remove();
2491
+ }
2492
+ if (autoscroll) logBody.scrollTop = logBody.scrollHeight;
2493
+ };
2494
+ function rebuildLog() {
2495
+ logBody.textContent = "";
2496
+ nodes.clear();
2497
+ for (const e of o.log.entries().slice(-MAX_LINES * 3)) addNode(e);
2498
+ }
2499
+ const offLog = o.log.on((e, updated) => {
2500
+ if (updated) {
2501
+ const n = nodes.get(e.seq);
2502
+ if (n) fill(n, e);
2503
+ } else addNode(e);
2504
+ });
2505
+ const right = mk("div", "tr-right");
2506
+ const cat = mk("div", "tr-panel tr-cat");
2507
+ const anim = mk("div", "tr-panel tr-anim");
2508
+ const tests = mk("div", "tr-panel tr-tests");
2509
+ right.append(cat, anim, tests);
2510
+ cat.appendChild(mk("h3", void 0, t("ui.catalog")));
2511
+ const tabs = mk("div", "tr-tabs");
2512
+ const items = mk("div", "tr-items");
2513
+ cat.append(tabs, items);
2514
+ let activeGroup = "";
2515
+ const renderItems = () => {
2516
+ items.textContent = "";
2517
+ for (const it of o.groups().get(activeGroup) ?? []) {
2518
+ const wrap = mk("div", "tr-item");
2519
+ const b = mk("button", "tr-btn", it.name);
2520
+ b.type = "button";
2521
+ b.title = it.note ?? it.name;
2522
+ b.onclick = () => o.onSpawn(it.id);
2523
+ wrap.appendChild(b);
2524
+ if (it.weapon) {
2525
+ const w = mk("button", "tr-btn", "\u2694");
2526
+ w.type = "button";
2527
+ w.title = t("ui.equip");
2528
+ w.onclick = () => o.onEquip(it.id);
2529
+ wrap.appendChild(w);
2530
+ }
2531
+ items.appendChild(wrap);
2532
+ }
2533
+ };
2534
+ const renderTabs = () => {
2535
+ tabs.textContent = "";
2536
+ const groupNames = [...o.groups().keys()];
2537
+ if (!groupNames.includes(activeGroup)) activeGroup = groupNames[0] ?? "";
2538
+ for (const g of groupNames) {
2539
+ const b = mk("button", g === activeGroup ? "tr-btn on" : "tr-btn", g);
2540
+ b.type = "button";
2541
+ b.onclick = () => {
2542
+ activeGroup = g;
2543
+ renderTabs();
2544
+ };
2545
+ tabs.appendChild(b);
2546
+ }
2547
+ renderItems();
2548
+ };
2549
+ renderTabs();
2550
+ const controls = mk("div", "tr-row");
2551
+ const ctl = (label, fn) => {
2552
+ const b = mk("button", "tr-btn", label);
2553
+ b.type = "button";
2554
+ b.onclick = () => fn(b);
2555
+ controls.appendChild(b);
2556
+ return b;
2557
+ };
2558
+ ctl(t("ui.clear"), () => o.onClear());
2559
+ ctl(t("ui.reset_all"), () => o.onResetAll());
2560
+ let slow = false;
2561
+ ctl(t("ui.slow"), (b) => {
2562
+ slow = !slow;
2563
+ b.classList.toggle("on", slow);
2564
+ o.onSlow(slow);
2565
+ });
2566
+ let fast = false;
2567
+ ctl(t("ui.fast"), (b) => {
2568
+ fast = !fast;
2569
+ b.classList.toggle("on", fast);
2570
+ o.onFast(fast);
2571
+ });
2572
+ const tapBtn = ctl(t("ui.tap"), () => o.onTap());
2573
+ const qSel = mk("select");
2574
+ for (const q of ["low", "medium", "high"]) {
2575
+ const op = mk("option", void 0, t(`ui.quality.${q}`));
2576
+ op.value = q;
2577
+ qSel.appendChild(op);
2578
+ }
2579
+ qSel.value = o.quality;
2580
+ qSel.onchange = () => o.onQuality(qSel.value);
2581
+ const qLabel = mk("label", void 0, t("ui.quality") + " ");
2582
+ qLabel.appendChild(qSel);
2583
+ controls.appendChild(qLabel);
2584
+ const count = mk("span");
2585
+ controls.appendChild(count);
2586
+ cat.appendChild(controls);
2587
+ tests.appendChild(mk("h3", void 0, t("ui.tests")));
2588
+ const runAll = mk("button", "tr-btn", t("ui.run_all"));
2589
+ runAll.type = "button";
2590
+ const summary = mk("div", "tr-summary");
2591
+ const list = mk("div", "list");
2592
+ tests.append(runAll, summary, list);
2593
+ const scBtns = /* @__PURE__ */ new Map();
2594
+ let busy = false;
2595
+ const mark = (rep) => {
2596
+ if (!rep) return;
2597
+ for (const r of rep.results) {
2598
+ const b = scBtns.get(r.id);
2599
+ if (!b) continue;
2600
+ b.classList.remove("pass", "fail");
2601
+ b.classList.add(r.ok ? "pass" : "fail");
2602
+ b.textContent = `${r.ok ? "\u2713" : "\u2717"} ${r.name}`;
2603
+ }
2604
+ summary.textContent = t("ui.report", { passed: rep.passed, failed: rep.failed });
2605
+ };
2606
+ const renderScenarios = () => {
2607
+ list.textContent = "";
2608
+ scBtns.clear();
2609
+ for (const s of o.scenarios()) {
2610
+ const b = mk("button", "tr-btn", s.name);
2611
+ b.type = "button";
2612
+ b.onclick = async () => {
2613
+ if (busy) return;
2614
+ busy = true;
2615
+ try {
2616
+ mark(await o.onRun(s));
2617
+ } finally {
2618
+ busy = false;
2619
+ }
2620
+ };
2621
+ scBtns.set(s.id, b);
2622
+ list.appendChild(b);
2623
+ }
2624
+ };
2625
+ renderScenarios();
2626
+ runAll.onclick = async () => {
2627
+ if (busy) return;
2628
+ busy = true;
2629
+ summary.textContent = t("ui.running");
2630
+ try {
2631
+ mark(await o.onRunAll());
2632
+ } finally {
2633
+ busy = false;
2634
+ }
2635
+ };
2636
+ const toggle = mk("button", "tr-btn tr-toggle", t("ui.panel"));
2637
+ toggle.type = "button";
2638
+ const togglePanels = () => {
2639
+ root.classList.toggle("open");
2640
+ o.onResize();
2641
+ };
2642
+ toggle.onclick = togglePanels;
2643
+ root.append(cross, top, bottom, help, left, right, toggle);
2644
+ const bar = (value, max, cls) => {
2645
+ const b = mk("div", `tr-bar ${cls}`);
2646
+ const i = mk("i");
2647
+ i.style.width = `${Math.max(0, Math.min(100, value / Math.max(1e-9, max) * 100))}%`;
2648
+ b.appendChild(i);
2649
+ return b;
2650
+ };
2651
+ const row = (k, v) => {
2652
+ const r = mk("div", "r");
2653
+ r.append(mk("span", void 0, k), mk("span", void 0, v));
2654
+ return r;
2655
+ };
2656
+ const num = (n, d = 1) => {
2657
+ const s = (Math.round(n * 10 ** d) / 10 ** d).toFixed(d);
2658
+ return t("locale.decimal") === "," ? s.replace(".", ",") : s;
2659
+ };
2660
+ const renderInspector = (i) => {
2661
+ insp.textContent = "";
2662
+ insp.appendChild(mk("h3", void 0, t("ui.inspector")));
2663
+ if (!i) {
2664
+ insp.appendChild(mk("div", void 0, t("ui.aim_at_something")));
2665
+ return;
2666
+ }
2667
+ insp.appendChild(mk("div", "t", `${i.object} \u203A ${i.part}`));
2668
+ insp.appendChild(row(t("ins.material"), i.material));
2669
+ insp.appendChild(row(t("ins.status"), i.status + (i.burning ? ` \xB7 ${t("ins.burning")}` : "")));
2670
+ insp.appendChild(row(t("ins.hp"), `${num(i.hp, 0)} / ${num(i.maxHp, 0)}`));
2671
+ const frac = i.hp / Math.max(1e-9, i.maxHp);
2672
+ insp.appendChild(bar(i.hp, i.maxHp, frac < 0.25 ? "bad" : frac < 0.6 ? "warn" : "hp"));
2673
+ insp.appendChild(row(t("ins.temperature"), `${num(i.temperature, 0)} \xB0C`));
2674
+ if (i.char > 0.01) insp.appendChild(row(t("ins.char"), `${num(i.char * 100, 0)} %`));
2675
+ if (i.melt > 0.01) insp.appendChild(row(t("ins.melt"), `${num(i.melt * 100, 0)} %`));
2676
+ if (i.liters !== void 0) insp.appendChild(row(t("ins.liters"), `${num(i.liters, 2)} l`));
2677
+ const cn = (what, n) => t(`count.${what}.${n === 1 ? "one" : "other"}`, { n });
2678
+ insp.appendChild(row(t("ins.damage"), t("ins.damage_counts", { dents: cn("dent", i.dents), holes: cn("hole", i.holes), cracks: cn("crack", i.cracks) })));
2679
+ insp.appendChild(row(t("ins.mass"), `${num(i.mass, 1)} kg`));
2680
+ if (i.load !== void 0) insp.appendChild(row(t("ins.load"), `${num(i.load, 0)} kg`));
2681
+ if (i.stress !== void 0) {
2682
+ insp.appendChild(row(t("ins.stress"), `${num(i.stress * 100, 0)} %`));
2683
+ insp.appendChild(bar(Math.min(i.stress, 1.2), 1.2, i.stress > 1 ? "bad" : i.stress > 0.7 ? "warn" : "hp"));
2684
+ }
2685
+ if (i.joints.length) {
2686
+ insp.appendChild(mk("div", void 0, t("ins.joints") + ":"));
2687
+ for (const j of i.joints) {
2688
+ const tools = j.tools.length ? j.tools.join(", ") : t("tool.none");
2689
+ insp.appendChild(mk("div", void 0, `\u2022 ${j.kind} (${j.id})${j.open ? ` \xB7 ${t("ins.open")}` : ` \u2192 ${tools}`}`));
2690
+ }
2691
+ }
2692
+ if (i.note) insp.appendChild(mk("div", void 0, i.note));
2693
+ };
2694
+ let lastAnim = "";
2695
+ const renderAnimations = (a) => {
2696
+ anim.textContent = "";
2697
+ anim.appendChild(mk("h3", void 0, t("ui.animations")));
2698
+ if (!a) {
2699
+ anim.appendChild(mk("div", "sub", t("ui.animations_aim")));
2700
+ return;
2701
+ }
2702
+ anim.appendChild(mk("div", "sub", a.name));
2703
+ if (a.clips.length === 0) {
2704
+ anim.appendChild(mk("div", "sub", t("ui.animations_none")));
2705
+ return;
2706
+ }
2707
+ const l = mk("div", "list");
2708
+ for (const c of a.clips) {
2709
+ const b = mk("button", a.playing === c.id ? "tr-btn on" : "tr-btn", `\u25B6 ${t(`anim.${c.id}`) === `anim.${c.id}` ? c.id : t(`anim.${c.id}`)} (${num(c.duration, 1)} s)`);
2710
+ b.type = "button";
2711
+ b.onclick = () => o.onPlay(a.objectId, c.id);
2712
+ l.appendChild(b);
2713
+ }
2714
+ anim.appendChild(l);
2715
+ };
2716
+ renderAnimations(null);
2717
+ let toastTimer = null;
2718
+ const slotsEl = [];
2719
+ let lastSlotsKey = "";
2720
+ let lastInspect = "";
2721
+ let hudPage = 0;
2722
+ const api = {
2723
+ hud(s) {
2724
+ toolBox.textContent = "";
2725
+ toolBox.append(document.createTextNode(s.toolName));
2726
+ if (s.ammo) toolBox.append(mk("small", void 0, s.ammo));
2727
+ if (s.status) status.textContent = s.status;
2728
+ else if (!toastTimer) status.textContent = "";
2729
+ if (s.progress) {
2730
+ prog.style.display = "block";
2731
+ progLabel.textContent = s.progress.label;
2732
+ progFill.style.width = `${Math.round(s.progress.value * 100)}%`;
2733
+ } else prog.style.display = "none";
2734
+ if (s.held) {
2735
+ held.style.display = "block";
2736
+ held.textContent = "";
2737
+ held.append(document.createTextNode(`${s.held.name} \xB7 `));
2738
+ const keys = [
2739
+ ["E", t("hud.drop")],
2740
+ ["R", t("hud.rotate")],
2741
+ ["G", t("hud.throw")],
2742
+ ["Z", t("hud.tilt")],
2743
+ ...s.held.uses.map((u) => [u.key, u.label])
2744
+ ];
2745
+ if (s.held.weapon) keys.push(["H", t("hud.equip", { weapon: s.held.weapon })]);
2746
+ for (const [k, label] of keys) {
2747
+ held.append(mk("kbd", void 0, k), document.createTextNode(` ${label} `));
2748
+ }
2749
+ } else held.style.display = "none";
2750
+ const key = s.slots.join("|") + s.page;
2751
+ if (key !== lastSlotsKey) {
2752
+ lastSlotsKey = key;
2753
+ hotbar.textContent = "";
2754
+ slotsEl.length = 0;
2755
+ hudPage = s.page - 1;
2756
+ s.slots.forEach((name, i) => {
2757
+ const b = mk("button", "tr-slot");
2758
+ b.type = "button";
2759
+ b.append(mk("b", void 0, String((i + 1) % 10)), document.createTextNode(name));
2760
+ b.title = name;
2761
+ b.onclick = () => o.onTool(hudPage * 10 + i);
2762
+ hotbar.appendChild(b);
2763
+ slotsEl.push(b);
2764
+ });
2765
+ }
2766
+ slotsEl.forEach((b, i) => b.classList.toggle("on", i === s.slot));
2767
+ tapBtn.classList.toggle("on", s.tap);
2768
+ count.textContent = t("ui.objects", { n: s.objects });
2769
+ const ak = JSON.stringify(s.animations);
2770
+ if (ak !== lastAnim) {
2771
+ lastAnim = ak;
2772
+ renderAnimations(s.animations);
2773
+ }
2774
+ const ik = JSON.stringify(s.inspect);
2775
+ if (ik !== lastInspect) {
2776
+ lastInspect = ik;
2777
+ renderInspector(s.inspect);
2778
+ }
2779
+ },
2780
+ toast(text) {
2781
+ status.textContent = text;
2782
+ if (toastTimer) clearTimeout(toastTimer);
2783
+ toastTimer = setTimeout(() => {
2784
+ toastTimer = null;
2785
+ }, 2e3);
2786
+ },
2787
+ refresh() {
2788
+ renderTabs();
2789
+ renderScenarios();
2790
+ },
2791
+ togglePanels,
2792
+ dispose() {
2793
+ offLog();
2794
+ if (toastTimer) clearTimeout(toastTimer);
2795
+ root.remove();
2796
+ }
2797
+ };
2798
+ rebuildLog();
2799
+ return api;
2800
+ }
2801
+
2802
+ // src/room.ts
2803
+ var FIXED_DT = 1 / 60;
2804
+ var v3 = (v) => [v.x, v.y, v.z];
2805
+ var dist3 = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
2806
+ function createTestRoom(options) {
2807
+ const THREE = options.THREE;
2808
+ const locale = options.locale ?? "en";
2809
+ const S = resolveStrings(locale);
2810
+ const dStrings = resolveDestructionStrings(locale);
2811
+ const fStrings = resolveFluidStrings(locale);
2812
+ const t = (key, vars) => {
2813
+ let s = S.text[key] ?? key;
2814
+ if (vars) for (const k of Object.keys(vars)) s = s.split(`{${k}}`).join(String(vars[k]));
2815
+ return s;
2816
+ };
2817
+ const num = (n, digits = 1) => {
2818
+ const s = (Math.round(n * 10 ** digits) / 10 ** digits).toFixed(digits);
2819
+ return locale.toLowerCase().startsWith("pt") ? s.replace(".", ",") : s;
2820
+ };
2821
+ const ownRenderer = !options.renderer;
2822
+ const gl = options.renderer ?? new THREE.WebGLRenderer({ antialias: true });
2823
+ const container = options.container;
2824
+ const width = () => container.clientWidth || 960;
2825
+ const height = () => container.clientHeight || 600;
2826
+ if (ownRenderer) {
2827
+ const r = gl;
2828
+ r.setPixelRatio(Math.min(typeof devicePixelRatio === "number" ? devicePixelRatio : 1, 2));
2829
+ r.shadowMap.enabled = true;
2830
+ r.shadowMap.type = THREE.PCFSoftShadowMap;
2831
+ r.toneMapping = THREE.ACESFilmicToneMapping;
2832
+ r.toneMappingExposure = 1;
2833
+ r.outputColorSpace = THREE.SRGBColorSpace;
2834
+ container.appendChild(r.domElement);
2835
+ }
2836
+ gl.setSize(width(), height());
2837
+ const dom = gl.domElement;
2838
+ if (dom && !dom.hasAttribute?.("tabindex")) dom.setAttribute?.("tabindex", "0");
2839
+ const scene = options.scene ?? new THREE.Scene();
2840
+ const camera = options.camera ?? new THREE.PerspectiveCamera(72, width() / height(), 0.05, 140);
2841
+ if (!options.camera) scene.add(camera);
2842
+ const resize = () => {
2843
+ const w = width();
2844
+ const h = height();
2845
+ gl.setSize(w, h);
2846
+ camera.aspect = w / h;
2847
+ camera.updateProjectionMatrix();
2848
+ };
2849
+ const onResize = () => resize();
2850
+ window.addEventListener("resize", onResize);
2851
+ const arenaSize = options.arenaSize ?? 14;
2852
+ const arena = createArena(THREE, arenaSize, scene);
2853
+ scene.add(arena.group);
2854
+ const ownWorld = !options.world;
2855
+ const world = options.world ?? createDestruction({ seed: 1, ...options.worldOptions, materials: [...DEMO_MATERIALS, ...options.worldOptions?.materials ?? []] });
2856
+ const pristineWorld = ownWorld ? world.serialize() : null;
2857
+ const tableOk = () => {
2858
+ const p = world.part(arena.table.id, arena.table.topPart);
2859
+ return !!p && p.status !== "broken" && p.status !== "melted" && p.status !== "ashes";
2860
+ };
2861
+ const groundAt = (x, z) => x > arena.table.minX && x < arena.table.maxX && z > arena.table.minZ && z < arena.table.maxZ && tableOk() ? arena.table.top : 0;
2862
+ const ownFluids = !options.fluids;
2863
+ const fluids = options.fluids ?? createFluids({ seed: 1, locale, liquids: options.liquids, groundAt });
2864
+ const pristineFluids = ownFluids ? fluids.serialize() : null;
2865
+ for (const s of [...arena.surfaces, ...options.surfaces ?? []]) fluids.addSurface(s);
2866
+ const itemUseDefs = [...ITEM_USES, ...options.itemUses ?? []];
2867
+ const quality = options.quality ?? "medium";
2868
+ const pointScale = height() / (2 * Math.tan(camera.fov * Math.PI / 360));
2869
+ const rendererPieces = /* @__PURE__ */ new Map();
2870
+ const entries = /* @__PURE__ */ new Map();
2871
+ const splitOf = /* @__PURE__ */ new Map();
2872
+ let simTime = 0;
2873
+ const log = createLog({ exportCancelled: () => t("log.export_cancelled") });
2874
+ log.setClock(() => simTime);
2875
+ const renderer = createDestructionRenderer(THREE, {
2876
+ scene,
2877
+ world,
2878
+ quality,
2879
+ groundAt,
2880
+ pointScale,
2881
+ onSound: options.onSound,
2882
+ onPiece: (piece) => {
2883
+ rendererPieces.set(piece.objectId, piece);
2884
+ loose.push(piece);
2885
+ const st = world.state(piece.objectId);
2886
+ if (!st) return;
2887
+ let best = null;
2888
+ let bestD = Infinity;
2889
+ for (const e of entries.values()) {
2890
+ const spec = specs.get(e.so.objectId);
2891
+ if (!spec) continue;
2892
+ const ids = new Set(spec.parts.map((p) => p.id));
2893
+ if (!st.parts.every((p) => ids.has(p.id))) continue;
2894
+ const d = e.so.root.position.distanceTo(piece.root.position);
2895
+ if (d < bestD) {
2896
+ bestD = d;
2897
+ best = e;
2898
+ }
2899
+ }
2900
+ if (best) splitOf.set(piece.objectId, best);
2901
+ }
2902
+ });
2903
+ const fluidView = createFluidRenderer(THREE, { scene, fluids, quality, pointScale });
2904
+ const specs = /* @__PURE__ */ new Map();
2905
+ const loose = [];
2906
+ const arsenal = options.arsenal ?? createArsenal({ locale, weapons: options.weapons, seed: 1 });
2907
+ const loadout = (options.loadout ?? DEFAULT_LOADOUT).filter((id) => arsenal.weapons.has(id));
2908
+ const tools = /* @__PURE__ */ new Map();
2909
+ let toolIndex = Math.max(0, loadout.indexOf("hand"));
2910
+ let toolId = loadout[toolIndex] ?? "hand";
2911
+ const toolState = (id) => {
2912
+ let s = tools.get(id);
2913
+ if (!s) {
2914
+ s = arsenal.create(id, { seed: 7, reserve: 999 });
2915
+ s.refill();
2916
+ tools.set(id, s);
2917
+ }
2918
+ return s;
2919
+ };
2920
+ let tool = toolState(toolId);
2921
+ const catalogMap = /* @__PURE__ */ new Map();
2922
+ for (const i of demoItems(locale)) catalogMap.set(i.id, i);
2923
+ for (const i of options.catalog) catalogMap.set(i.id, i);
2924
+ const catalog = [...options.catalog];
2925
+ const entryOf = (objectId) => entries.get(objectId) ?? splitOf.get(objectId);
2926
+ const nameOf = (objectId) => {
2927
+ const e = entries.get(objectId);
2928
+ if (e) return e.name;
2929
+ const s = splitOf.get(objectId);
2930
+ return s ? `${s.name} (${t("piece")})` : objectId;
2931
+ };
2932
+ const narrator = createNarrator({
2933
+ world,
2934
+ fluids,
2935
+ log,
2936
+ now: () => simTime,
2937
+ t,
2938
+ num,
2939
+ nameOf,
2940
+ itemIdOf: (id) => entryOf(id)?.so.itemId,
2941
+ materialName: (id) => S.text[`material.${id}`] ?? dStrings.materialNames[id] ?? id,
2942
+ liquidName: (id) => fStrings.liquids[id] ?? id,
2943
+ jointKind: (k) => dStrings.jointKinds[k] ?? k,
2944
+ toolName: (k) => dStrings.toolKinds[k] ?? k,
2945
+ locatePart: (objectId, partId) => {
2946
+ const own = world.part(objectId, partId);
2947
+ if (own) return { objectId, part: own };
2948
+ for (const [id, owner] of splitOf) {
2949
+ if (owner.so.objectId !== objectId) continue;
2950
+ const p = world.part(id, partId);
2951
+ if (p) return { objectId: id, part: p };
2952
+ }
2953
+ return null;
2954
+ },
2955
+ containerName: (cid) => {
2956
+ for (const e of entries.values()) if (e.containerId === cid) return { name: e.name, objectId: e.so.objectId, itemId: e.so.itemId };
2957
+ return void 0;
2958
+ }
2959
+ });
2960
+ let pending = [];
2961
+ const eventListeners = /* @__PURE__ */ new Set();
2962
+ const listeners = { spawn: /* @__PURE__ */ new Set(), tool: /* @__PURE__ */ new Set(), grab: /* @__PURE__ */ new Set(), throw: /* @__PURE__ */ new Set(), event: eventListeners };
2963
+ const fire = (type, data) => {
2964
+ for (const fn of listeners[type] ?? []) fn(data);
2965
+ };
2966
+ world.on("*", (ev) => {
2967
+ pending.push(ev);
2968
+ });
2969
+ fluids.on("*", (ev) => {
2970
+ narrator.fluid(ev);
2971
+ fire("event", ev);
2972
+ });
2973
+ const action = (type, text, extra = {}) => {
2974
+ log.add({ channel: "action", type, text, ...extra });
2975
+ };
2976
+ const tmpV = new THREE.Vector3();
2977
+ const tmpV2 = new THREE.Vector3();
2978
+ const tmpQ = new THREE.Quaternion();
2979
+ const tmpM = new THREE.Matrix4();
2980
+ const hostOf = (e) => {
2981
+ if (e.fluidPart && !e.arena && !world.part(e.so.objectId, e.fluidPart)) {
2982
+ for (const [id, owner] of splitOf) {
2983
+ if (owner === e && world.part(id, e.fluidPart)) {
2984
+ const p = rendererPieces.get(id);
2985
+ if (p) return { objectId: id, root: p.root };
2986
+ }
2987
+ }
2988
+ }
2989
+ return { objectId: e.so.objectId, root: e.so.root };
2990
+ };
2991
+ const puncture = (ev) => {
2992
+ const e = entryOf(ev.object);
2993
+ if (!e?.containerId || e.fluidPart !== ev.part) return;
2994
+ const host = hostOf(e);
2995
+ host.root.updateMatrixWorld(true);
2996
+ tmpM.copy(host.root.matrixWorld).invert();
2997
+ const local = tmpV.set(ev.point[0], ev.point[1], ev.point[2]).applyMatrix4(tmpM);
2998
+ local.x -= e.fluidOffset[0];
2999
+ local.y -= e.fluidOffset[1];
3000
+ local.z -= e.fluidOffset[2];
3001
+ host.root.getWorldQuaternion(tmpQ).invert();
3002
+ const n = tmpV2.set(ev.direction[0], ev.direction[1], ev.direction[2]).applyQuaternion(tmpQ);
3003
+ const part = world.part(host.objectId, ev.part);
3004
+ const hole = part?.holes[part.holes.length - 1];
3005
+ fluids.puncture(e.containerId, [local.x, local.y, local.z], [n.x, n.y, n.z], hole?.diameter ?? 9e-3);
3006
+ };
3007
+ const flush = () => {
3008
+ if (pending.length === 0) return;
3009
+ const batch = pending;
3010
+ pending = [];
3011
+ narrator.events(batch);
3012
+ for (const ev of batch) {
3013
+ if (ev.type === "leak") puncture(ev);
3014
+ else if (ev.type === "ignite") igniteNear(ev.point, 0.9);
3015
+ fire("event", ev);
3016
+ options.onEvent?.(ev);
3017
+ }
3018
+ };
3019
+ log.setBeforeRead(flush);
3020
+ let useHeld = false;
3021
+ let useFirst = false;
3022
+ const player = createPlayer(THREE, {
3023
+ camera,
3024
+ dom,
3025
+ colliders: arena.colliders,
3026
+ limit: arenaSize,
3027
+ start: arena.playerStart,
3028
+ hooks: {
3029
+ primaryDown: () => {
3030
+ useHeld = true;
3031
+ useFirst = true;
3032
+ },
3033
+ primaryUp: () => {
3034
+ useHeld = false;
3035
+ tool.release();
3036
+ },
3037
+ secondary: () => {
3038
+ if (heldEntry) throwHeld();
3039
+ else grab();
3040
+ },
3041
+ key: (code, down, e) => onKey(code, down, e),
3042
+ wheel: (dir) => cycleTool(dir)
3043
+ }
3044
+ });
3045
+ player.look(arena.playerStart, [0, 1.5, arena.wallZ]);
3046
+ let counter = 0;
3047
+ let slot = 0;
3048
+ const maxCarry = options.maxCarryKg ?? 60;
3049
+ const massOfObject = (id) => {
3050
+ const st = world.state(id);
3051
+ let m = 0;
3052
+ for (const p of st?.parts ?? []) if (p.status !== "broken" && p.status !== "melted" && p.status !== "ashes") m += p.mass;
3053
+ return m;
3054
+ };
3055
+ const registerEntry = (objectId, itemId, root, spec, name, parts, item, arenaObject, movable) => {
3056
+ specs.set(objectId, spec);
3057
+ renderer.bind(objectId, root, parts ? { parts } : void 0);
3058
+ const so = { objectId, itemId, root, movable };
3059
+ const e = { so, item, name, arena: arenaObject, fluidOffset: [0, 0, 0], uses: [], piece: null, last: [root.position.x, root.position.y, root.position.z], velocity: [0, 0, 0] };
3060
+ entries.set(objectId, e);
3061
+ if (movable) {
3062
+ e.piece = renderer.makePiece(objectId, { mass: Math.max(0.2, massOfObject(objectId)) });
3063
+ rendererPieces.set(objectId, e.piece);
3064
+ }
3065
+ const f = item?.fluid;
3066
+ if (f) {
3067
+ e.fluidPart = f.part;
3068
+ e.fluidOffset = f.offset ?? [0, 0, 0];
3069
+ e.uses = f.uses ?? ["drink", "sip", "pour"];
3070
+ root.updateMatrixWorld(true);
3071
+ const q = root.getWorldQuaternion(tmpQ);
3072
+ const p = tmpV.set(e.fluidOffset[0], e.fluidOffset[1], e.fluidOffset[2]).applyMatrix4(root.matrixWorld);
3073
+ e.containerId = fluids.addContainer(f.container, { id: `${objectId}_fluid`, position: [p.x, p.y, p.z], rotation: [q.x, q.y, q.z, q.w] });
3074
+ fluidView.bindContainer(e.containerId, root, { innerOffset: e.fluidOffset });
3075
+ }
3076
+ return e;
3077
+ };
3078
+ const resolveSpec = (item, root) => {
3079
+ if (typeof item.spec === "function") return { spec: structuredCloneSpec(item.spec(root)) };
3080
+ if (item.spec) return { spec: structuredCloneSpec(item.spec) };
3081
+ const r = specFromObject3D(THREE, root, { name: item.id, ...item.specOptions });
3082
+ return { spec: r.spec, parts: r.parts };
3083
+ };
3084
+ const addArena = () => {
3085
+ for (const o of arena.objects) {
3086
+ const root = buildFromSpec(THREE, o.spec, { colors: o.colors });
3087
+ root.position.set(o.position[0], o.position[1], o.position[2]);
3088
+ scene.add(root);
3089
+ root.updateMatrixWorld(true);
3090
+ world.add(structuredCloneSpec(o.spec), { id: o.id, position: o.position });
3091
+ registerEntry(o.id, o.id, root, o.spec, S.text[`arena.${o.id}`] ?? o.id, void 0, void 0, true, false);
3092
+ }
3093
+ };
3094
+ addArena();
3095
+ const spawn = (itemId, position, rotationY) => {
3096
+ const item = catalogMap.get(itemId);
3097
+ if (!item) throw new Error(`game-test-room: unknown item "${itemId}"`);
3098
+ const root = item.build(THREE);
3099
+ let pos;
3100
+ if (position) pos = [position[0], position[1], position[2]];
3101
+ else {
3102
+ const fx = -Math.sin(player.yaw);
3103
+ const fz = -Math.cos(player.yaw);
3104
+ const rx = Math.cos(player.yaw);
3105
+ const rz = -Math.sin(player.yaw);
3106
+ const side = (slot++ % 5 - 2) * 0.55;
3107
+ const x = camera.position.x + fx * 2.4 + rx * side;
3108
+ const z = camera.position.z + fz * 2.4 + rz * side;
3109
+ const lim = arenaSize - 1;
3110
+ pos = [Math.max(-lim, Math.min(lim, x)), groundAt(x, z), Math.max(-lim, Math.min(lim, z))];
3111
+ }
3112
+ root.position.set(pos[0], pos[1], pos[2]);
3113
+ root.rotation.y = rotationY ?? player.yaw;
3114
+ root.name = item.id;
3115
+ scene.add(root);
3116
+ root.updateMatrixWorld(true);
3117
+ const objectId = `${item.id}_${++counter}`;
3118
+ let own;
3119
+ let resolved;
3120
+ if (item.impl) {
3121
+ own = item.impl(root, objectId);
3122
+ if (own.id !== objectId) throw new Error(`game-test-room: item "${item.id}": DestructibleImpl.id is "${own.id}", the room gave it "${objectId}" (use the id passed to impl)`);
3123
+ resolved = { spec: structuredCloneSpec(own.describe()) };
3124
+ } else resolved = resolveSpec(item, root);
3125
+ const { spec, parts } = resolved;
3126
+ root.updateMatrixWorld(true);
3127
+ const mass = spec.parts.reduce((m, p) => m + (p.mass ?? 0), 0);
3128
+ const heavy = (mass || massGuess(spec)) > maxCarry;
3129
+ const movable = own ? false : item.movable ?? !heavy;
3130
+ if (movable) {
3131
+ const box2 = new THREE.Box3().setFromObject(root);
3132
+ const base = Math.max(pos[1], groundAt(pos[0], pos[2]));
3133
+ pos = [pos[0], pos[1] + (base - box2.min.y), pos[2]];
3134
+ root.position.set(pos[0], pos[1], pos[2]);
3135
+ root.updateMatrixWorld(true);
3136
+ }
3137
+ const q = root.getWorldQuaternion(tmpQ);
3138
+ if (own) world.register(own, { id: objectId, position: pos, rotation: [q.x, q.y, q.z, q.w] });
3139
+ else world.add(spec, { id: objectId, position: pos, rotation: [q.x, q.y, q.z, q.w] });
3140
+ const e = registerEntry(objectId, item.id, root, spec, item.name, parts, item, false, movable);
3141
+ 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 } });
3142
+ fire("spawn", e.so);
3143
+ return e.so;
3144
+ };
3145
+ const massGuess = (spec) => {
3146
+ let m = 0;
3147
+ for (const p of spec.parts) {
3148
+ const s = p.shape;
3149
+ const vol = s.kind === "box" ? s.size[0] * s.size[1] * s.size[2] : s.kind === "cylinder" ? Math.PI * s.radius * s.radius * s.height : 4 / 3 * Math.PI * s.radius ** 3;
3150
+ m += p.mass ?? vol * (world.materials.get(p.material)?.density ?? 500) * (p.fill ?? 1);
3151
+ }
3152
+ return m;
3153
+ };
3154
+ const removeEntry = (id) => {
3155
+ const e = entries.get(id);
3156
+ if (!e) return;
3157
+ if (heldEntry === e) releaseHeld();
3158
+ for (let i = playing.length - 1; i >= 0; i--) if (playing[i].entry === e) playing.splice(i, 1);
3159
+ if (e.containerId) {
3160
+ fluidView.unbindContainer(e.containerId);
3161
+ fluids.removeContainer(e.containerId);
3162
+ }
3163
+ try {
3164
+ renderer.unbind(id);
3165
+ } catch {
3166
+ }
3167
+ if (world.has(id)) world.remove(id);
3168
+ e.so.root.removeFromParent();
3169
+ entries.delete(id);
3170
+ rendererPieces.delete(id);
3171
+ specs.delete(id);
3172
+ };
3173
+ const clearLoose = () => {
3174
+ for (const [id, owner] of [...splitOf]) {
3175
+ void owner;
3176
+ const p = rendererPieces.get(id);
3177
+ try {
3178
+ renderer.unbind(id);
3179
+ } catch {
3180
+ }
3181
+ if (world.has(id)) world.remove(id);
3182
+ p?.root.removeFromParent();
3183
+ rendererPieces.delete(id);
3184
+ splitOf.delete(id);
3185
+ }
3186
+ loose.length = 0;
3187
+ };
3188
+ const clearEverything = () => {
3189
+ if (heldEntry || heldPiece) releaseHeld();
3190
+ useItemPlayer = null;
3191
+ playing.length = 0;
3192
+ projectiles.length = 0;
3193
+ clearProjectileMeshes();
3194
+ fireZones.length = 0;
3195
+ burningStains.length = 0;
3196
+ for (const id of [...entries.keys()]) if (!entries.get(id)?.arena) removeEntry(id);
3197
+ clearLoose();
3198
+ for (const e of entries.values()) {
3199
+ world.reset(e.so.objectId);
3200
+ renderer.restore(e.so.objectId);
3201
+ }
3202
+ renderer.clear();
3203
+ fluids.clearStains([0, 0, 0], 1e4);
3204
+ fluidView.clear();
3205
+ fluids.setTap(TAP_ID, false);
3206
+ };
3207
+ const clear = () => {
3208
+ clearEverything();
3209
+ pending = [];
3210
+ action("clear", t("act.clear"));
3211
+ };
3212
+ const resetAll = () => {
3213
+ for (const e of entries.values()) {
3214
+ if (heldEntry === e) continue;
3215
+ world.reset(e.so.objectId);
3216
+ renderer.restore(e.so.objectId);
3217
+ if (e.containerId) {
3218
+ const c = fluids.container(e.containerId);
3219
+ const f = e.item?.fluid?.container;
3220
+ if (c && f && f.liquid) fluids.fill(e.containerId, f.liquid, Math.max(0, (f.liters ?? 0) - c.liters), f.temperature);
3221
+ }
3222
+ }
3223
+ action("reset", t("act.reset"));
3224
+ };
3225
+ const hardReset = () => {
3226
+ clearEverything();
3227
+ for (const id of [...entries.keys()]) removeEntry(id);
3228
+ tools.clear();
3229
+ tool = toolState(toolId);
3230
+ if (pristineWorld) {
3231
+ for (const id of world.ids()) world.remove(id);
3232
+ world.load(pristineWorld);
3233
+ }
3234
+ if (pristineFluids) {
3235
+ fluids.load(pristineFluids);
3236
+ for (const s of [...arena.surfaces, ...options.surfaces ?? []]) fluids.addSurface(s);
3237
+ }
3238
+ pending = [];
3239
+ rng = 12345;
3240
+ counter = 0;
3241
+ slot = 0;
3242
+ prevVy.clear();
3243
+ lastImpact.clear();
3244
+ addArena();
3245
+ timeScale = 1;
3246
+ fastForward = false;
3247
+ walkPlan = null;
3248
+ };
3249
+ const aimO = [0, 0, 0];
3250
+ const aimD = [0, 0, -1];
3251
+ const aim = () => {
3252
+ camera.updateMatrixWorld(true);
3253
+ camera.getWorldPosition(tmpV);
3254
+ camera.getWorldDirection(tmpV2);
3255
+ aimO[0] = tmpV.x;
3256
+ aimO[1] = tmpV.y;
3257
+ aimO[2] = tmpV.z;
3258
+ aimD[0] = tmpV2.x;
3259
+ aimD[1] = tmpV2.y;
3260
+ aimD[2] = tmpV2.z;
3261
+ };
3262
+ const FAR = [0, 0, 0, 1];
3263
+ const withoutHeld = (fn) => {
3264
+ const e = heldEntry;
3265
+ if (!e || !world.has(e.so.objectId)) return fn();
3266
+ const p = e.so.root.position;
3267
+ const q = e.so.root.quaternion;
3268
+ world.setTransform(e.so.objectId, [0, -5e3, 0], FAR);
3269
+ try {
3270
+ return fn();
3271
+ } finally {
3272
+ world.setTransform(e.so.objectId, [p.x, p.y, p.z], [q.x, q.y, q.z, q.w]);
3273
+ }
3274
+ };
3275
+ const rayWorld = (o, d, max) => withoutHeld(() => world.raycast(o, d, max));
3276
+ let rng = 12345;
3277
+ const rnd = () => {
3278
+ rng = rng * 1664525 + 1013904223 >>> 0;
3279
+ return rng / 4294967296;
3280
+ };
3281
+ const muzzle = () => {
3282
+ aim();
3283
+ const e = camera.matrixWorld.elements;
3284
+ return [
3285
+ aimO[0] + aimD[0] * 0.45 + e[0] * 0.16 - e[4] * 0.1,
3286
+ aimO[1] + aimD[1] * 0.45 + e[1] * 0.16 - e[5] * 0.1,
3287
+ aimO[2] + aimD[2] * 0.45 + e[2] * 0.16 - e[6] * 0.1
3288
+ ];
3289
+ };
3290
+ const tracers = [];
3291
+ const tracerMat = new THREE.LineBasicMaterial({ color: 16771496, transparent: true, opacity: 0.9 });
3292
+ const tracer = (a, b) => {
3293
+ let tr = tracers.find((x) => x.life <= 0);
3294
+ if (!tr) {
3295
+ if (tracers.length >= 12) return;
3296
+ const g = new THREE.BufferGeometry();
3297
+ g.setAttribute("position", new THREE.Float32BufferAttribute(6, 3));
3298
+ const line = new THREE.Line(g, tracerMat);
3299
+ line.frustumCulled = false;
3300
+ scene.add(line);
3301
+ tr = { line, life: 0 };
3302
+ tracers.push(tr);
3303
+ }
3304
+ const attr = tr.line.geometry.getAttribute("position");
3305
+ attr.setXYZ(0, a[0], a[1], a[2]);
3306
+ attr.setXYZ(1, b[0], b[1], b[2]);
3307
+ attr.needsUpdate = true;
3308
+ tr.line.visible = true;
3309
+ tr.life = 0.07;
3310
+ };
3311
+ const fireZones = [];
3312
+ const burningStains = [];
3313
+ const stainRadius = (s) => {
3314
+ let sum = 0;
3315
+ for (const r of s.front) sum += r;
3316
+ return s.front.length ? sum / s.front.length : 0.1;
3317
+ };
3318
+ const stainBurning = (s) => burningStains.some((b) => dist3(b.center, s.center) < 0.05);
3319
+ function igniteNear(point, reach) {
3320
+ let lit = false;
3321
+ let water = false;
3322
+ for (const s of fluids.stains()) {
3323
+ if (s.wetness < 0.05) continue;
3324
+ if (Math.hypot(s.center[0] - point[0], s.center[2] - point[2]) > reach + stainRadius(s) || Math.abs(s.center[1] - point[1]) > 1.5) continue;
3325
+ if (fluids.liquids.get(s.liquid)?.flammable) {
3326
+ if (stainBurning(s)) continue;
3327
+ const seconds = Math.max(3, Math.min(20, 4 + s.liters * 30 + stainRadius(s) * 6));
3328
+ burningStains.push({ center: [s.center[0], s.center[1], s.center[2]], remaining: seconds, acc: 0 });
3329
+ lit = true;
3330
+ log.add({
3331
+ channel: "event",
3332
+ type: "stain_ignite",
3333
+ text: t("ev.stain_ignite", { liquid: fStrings.liquids[s.liquid] ?? s.liquid, x: num(s.center[0], 1), z: num(s.center[2], 1), seconds: num(seconds, 0) }),
3334
+ data: { stain: s.id, liquid: s.liquid, seconds }
3335
+ });
3336
+ } else water = true;
3337
+ }
3338
+ if (water && !lit) {
3339
+ fluids.clearStains(point, Math.max(0.5, reach));
3340
+ log.coalesce("dry", { channel: "event", type: "stain_dried_by_fire", text: t("ev.fire_dries"), data: { point } }, 2);
3341
+ }
3342
+ }
3343
+ const hitFireAround = (point, radius, temperature, duration) => {
3344
+ for (const id of world.ids()) world.hit(id, { type: "fire", point, temperature, radius, duration, source: "fire" });
3345
+ };
3346
+ const updateFires = (dt) => {
3347
+ for (let i = fireZones.length - 1; i >= 0; i--) {
3348
+ const z = fireZones[i];
3349
+ z.remaining -= dt;
3350
+ z.acc += dt;
3351
+ if (z.acc >= 0.1) {
3352
+ hitFireAround(z.point, z.radius, z.temperature, z.acc);
3353
+ igniteNear(z.point, z.radius);
3354
+ z.acc = 0;
3355
+ }
3356
+ const a = rnd() * Math.PI * 2;
3357
+ const r = Math.sqrt(rnd()) * z.radius;
3358
+ renderer.emit("fire", [z.point[0] + Math.cos(a) * r, z.point[1] + 0.05, z.point[2] + Math.sin(a) * r], [0, 1, 0], 2, 1.5);
3359
+ if (z.remaining <= 0) fireZones.splice(i, 1);
3360
+ }
3361
+ if (burningStains.length) {
3362
+ const stains = fluids.stains();
3363
+ for (let i = burningStains.length - 1; i >= 0; i--) {
3364
+ const b = burningStains[i];
3365
+ b.remaining -= dt;
3366
+ b.acc += dt;
3367
+ const st = stains.find((s) => dist3(s.center, b.center) < 0.05);
3368
+ const r = st ? stainRadius(st) : 0.3;
3369
+ if (b.acc >= 0.1) {
3370
+ hitFireAround([b.center[0], b.center[1] + 0.1, b.center[2]], r + 0.2, 800, b.acc);
3371
+ b.acc = 0;
3372
+ }
3373
+ const a = rnd() * Math.PI * 2;
3374
+ const rr = Math.sqrt(rnd()) * r;
3375
+ renderer.emit("fire", [b.center[0] + Math.cos(a) * rr, b.center[1] + 0.05, b.center[2] + Math.sin(a) * rr], [0, 1, 0], 3, 1.8);
3376
+ if (rnd() < 0.2) renderer.emit("smoke", [b.center[0], b.center[1] + 0.3, b.center[2]], [0, 1, 0], 1, 1);
3377
+ if (b.remaining <= 0 || !st) {
3378
+ fluids.clearStains(b.center, r + 0.1);
3379
+ log.add({ channel: "event", type: "stain_burned", text: t("ev.stain_burned"), data: { point: b.center } });
3380
+ burningStains.splice(i, 1);
3381
+ }
3382
+ }
3383
+ }
3384
+ };
3385
+ let progress = null;
3386
+ let statusLine = "";
3387
+ let statusAt = 0;
3388
+ const say = (msg) => {
3389
+ statusLine = msg;
3390
+ statusAt = simTime;
3391
+ ui?.toast(msg);
3392
+ };
3393
+ const toolName = (id = toolId) => arsenal.name(id);
3394
+ const hitWhere = (r) => {
3395
+ const p = world.part(r.object, r.part);
3396
+ return `${nameOf(r.object)} \u203A ${r.part}${p ? ` (${dStrings.materialNames[p.material] ?? p.material})` : ""}`;
3397
+ };
3398
+ const pushPiece = (objectId, dir, momentum, point) => {
3399
+ if (!rendererPieces.has(objectId)) return;
3400
+ renderer.impulse(objectId, [dir[0] * momentum, dir[1] * momentum, dir[2] * momentum], point);
3401
+ };
3402
+ const fireShots = (shots, recoil) => {
3403
+ if (shots.length === 0) return;
3404
+ const w = toolName();
3405
+ const m = muzzle();
3406
+ renderer.emit("flash", m, [aimD[0], aimD[1], aimD[2]], 1, 0);
3407
+ renderer.emit("sparks", m, [aimD[0], aimD[1], aimD[2]], 4, 6);
3408
+ let hits = 0;
3409
+ let first = null;
3410
+ let energy = 0;
3411
+ withoutHeld(() => {
3412
+ for (const s of shots) {
3413
+ energy = Math.max(energy, s.energy);
3414
+ const results = world.shoot(s.origin, s.direction, s.energy, s.caliber, "player");
3415
+ const r0 = results.find((r) => r.part && r.point);
3416
+ let end = [s.origin[0] + s.direction[0] * 40, s.origin[1] + s.direction[1] * 40, s.origin[2] + s.direction[2] * 40];
3417
+ if (r0?.point && r0.part) {
3418
+ hits++;
3419
+ end = r0.point;
3420
+ if (!first) first = { object: r0.object, part: r0.part, point: r0.point, normal: [0, 1, 0], distance: dist3(s.origin, r0.point) };
3421
+ const speed = Math.max(80, s.speed);
3422
+ pushPiece(r0.object, s.direction, 2 * s.energy / speed, r0.point);
3423
+ }
3424
+ if (shots.length <= 3 || rnd() < 0.34) tracer(m, end);
3425
+ }
3426
+ });
3427
+ player.kick(recoil[0], recoil[1]);
3428
+ const f = first;
3429
+ const d = f ? num(f.distance, 1) : "";
3430
+ let text;
3431
+ if (shots.length === 1) text = t(f ? "act.shoot.hit" : "act.shoot.miss", { weapon: w, energy: num(energy, 0), where: f ? hitWhere(f) : "", dist: d });
3432
+ else text = t("act.shoot.pellets", { weapon: w, n: shots.length, energy: num(energy, 0), hits, where: f ? hitWhere(f) : "", dist: d });
3433
+ action("shoot", text, {
3434
+ objectId: f?.object,
3435
+ itemId: f ? entryOf(f.object)?.so.itemId : void 0,
3436
+ part: f?.part,
3437
+ data: { weapon: toolId, shots: shots.length, hits, energyJ: Math.round(energy), caliberMm: shots[0].caliber, distanceM: f ? Math.round(f.distance * 100) / 100 : null }
3438
+ });
3439
+ };
3440
+ const fireRays = (u) => {
3441
+ const d = new THREE.Vector3(u.direction[0], u.direction[1], u.direction[2]).normalize();
3442
+ const out = [[d.x, d.y, d.z]];
3443
+ if (u.cone) {
3444
+ const a = Math.min(0.2, Math.atan2(u.radius, Math.max(0.1, u.range)));
3445
+ const right = new THREE.Vector3().crossVectors(d, new THREE.Vector3(0, 1, 0));
3446
+ if (right.lengthSq() < 1e-6) right.set(1, 0, 0);
3447
+ right.normalize();
3448
+ const up = new THREE.Vector3().crossVectors(right, d).normalize();
3449
+ for (const [x, y] of [
3450
+ [1, 0],
3451
+ [-1, 0],
3452
+ [0, 1],
3453
+ [0, -1]
3454
+ ]) {
3455
+ const r = d.clone().addScaledVector(right, x * a).addScaledVector(up, y * a).normalize();
3456
+ out.push([r.x, r.y, r.z]);
3457
+ }
3458
+ }
3459
+ return out;
3460
+ };
3461
+ const heatRays = (u, type) => {
3462
+ const w = toolName();
3463
+ const cone = u.kind === "flame" ? u.cone : false;
3464
+ const rays = fireRays({ origin: u.origin, direction: u.direction, range: u.range, cone, radius: u.radius });
3465
+ let target = null;
3466
+ const m = muzzle();
3467
+ withoutHeld(() => {
3468
+ for (const d of rays) {
3469
+ const r = world.raycast(u.origin, d, u.range);
3470
+ if (!r) continue;
3471
+ if (!target) target = r;
3472
+ 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" });
3473
+ if (type === "fire") igniteNear(r.point, 0.25);
3474
+ else renderer.emit("sparks", r.point, r.normal, 2, 4);
3475
+ }
3476
+ });
3477
+ if (type === "fire") {
3478
+ renderer.emit("fire", m, [u.direction[0], u.direction[1], u.direction[2]], cone ? 5 : 1, Math.max(0.6, u.range * 1.1));
3479
+ if (cone) renderer.emit("smoke", m, [u.direction[0], u.direction[1] + 0.3, u.direction[2]], 1, u.range * 0.5);
3480
+ if (!target && u.direction[1] < -0.2) {
3481
+ const t0 = (0.05 - u.origin[1]) / u.direction[1];
3482
+ if (t0 > 0 && t0 < u.range) igniteNear([u.origin[0] + u.direction[0] * t0, 0.05, u.origin[2] + u.direction[2] * t0], 0.3);
3483
+ }
3484
+ }
3485
+ const tg = target;
3486
+ log.coalesce(
3487
+ `use:${toolId}`,
3488
+ {
3489
+ channel: "action",
3490
+ type,
3491
+ text: tg ? t(type === "fire" ? "act.flame.hit" : "act.heat.hit", { weapon: w, where: hitWhere(tg), temp: num(u.temperature, 0) }) : t(type === "fire" ? "act.flame.air" : "act.heat.air", { weapon: w }),
3492
+ objectId: tg?.object,
3493
+ part: tg?.part,
3494
+ data: { weapon: toolId, temperatureC: u.temperature }
3495
+ },
3496
+ 1
3497
+ );
3498
+ };
3499
+ const meleeUse = (u, id = toolId) => {
3500
+ const w = toolName(id);
3501
+ const r = rayWorld(u.origin, u.direction, u.reach);
3502
+ if (!r) {
3503
+ action("melee", t("act.melee.miss", { weapon: w }), { data: { weapon: id } });
3504
+ return;
3505
+ }
3506
+ world.hitPart(r.object, r.part, { type: u.damage, point: r.point, direction: u.direction, energy: u.energy, radius: u.radius, source: "player" });
3507
+ pushPiece(r.object, u.direction, 2 * u.energy / 6, r.point);
3508
+ action("melee", t("act.melee.hit", { weapon: w, energy: num(u.energy, 0), where: hitWhere(r), dist: num(r.distance, 1) }), {
3509
+ objectId: r.object,
3510
+ itemId: entryOf(r.object)?.so.itemId,
3511
+ part: r.part,
3512
+ data: { weapon: id, damage: u.damage, energyJ: u.energy, distanceM: Math.round(r.distance * 100) / 100 }
3513
+ });
3514
+ };
3515
+ const toolWork = (u, dt) => {
3516
+ const w = toolName();
3517
+ const r = rayWorld(u.origin, u.direction, u.reach);
3518
+ if (!r) {
3519
+ progress = null;
3520
+ log.coalesce(`use:${toolId}`, { channel: "action", type: "tool", text: t("act.tool.none", { weapon: w }), data: { weapon: toolId } }, 1);
3521
+ return;
3522
+ }
3523
+ const j = world.nearestJoint(r.object, r.point, 0.45, u.tool);
3524
+ if (!j) {
3525
+ progress = null;
3526
+ log.coalesce(`use:${toolId}`, { channel: "action", type: "tool", text: t("act.tool.nojoint", { weapon: w, where: hitWhere(r) }), objectId: r.object, part: r.part }, 1);
3527
+ return;
3528
+ }
3529
+ const tool_ = u.tool;
3530
+ const p = world.disassemble(r.object, j.id, tool_, dt);
3531
+ const kind = dStrings.jointKinds[j.kind] ?? j.kind;
3532
+ if (p < 0) {
3533
+ const need = JOINT_DEFAULTS2[j.kind].tools.map((k) => dStrings.toolKinds[k] ?? k).join(", ") || t("tool.none");
3534
+ progress = { label: t("hud.refused", { kind }), value: 0, at: simTime };
3535
+ log.coalesce(
3536
+ `refuse:${r.object}:${j.id}:${toolId}`,
3537
+ {
3538
+ channel: "action",
3539
+ type: "tool_refused",
3540
+ text: t("act.tool.refused", { weapon: w, kind, obj: nameOf(r.object), need }),
3541
+ objectId: r.object,
3542
+ itemId: entryOf(r.object)?.so.itemId,
3543
+ data: { weapon: toolId, joint: j.id, kind: j.kind, needs: JOINT_DEFAULTS2[j.kind].tools }
3544
+ },
3545
+ 2
3546
+ );
3547
+ return;
3548
+ }
3549
+ progress = { label: `${kind} (${j.id})`, value: p, at: simTime };
3550
+ renderer.emit("sparks", r.point, r.normal, 1, 2);
3551
+ };
3552
+ const extinguishUse = (u) => {
3553
+ const w = toolName();
3554
+ const rays = fireRays({ origin: u.origin, direction: u.direction, range: u.range, cone: true, radius: 0.6 });
3555
+ const m = muzzle();
3556
+ renderer.emit("foam", m, [u.direction[0], u.direction[1], u.direction[2]], 8, u.range * 1.2);
3557
+ const hit = /* @__PURE__ */ new Set();
3558
+ withoutHeld(() => {
3559
+ for (const d of rays) {
3560
+ const r = world.raycast(u.origin, d, u.range);
3561
+ if (!r) continue;
3562
+ hit.add(r.object);
3563
+ world.extinguish(r.object, void 0, u.coolC * Math.max(u.duration, 0.05) * 6);
3564
+ fluids.clearStains(r.point, 0.5);
3565
+ for (let i = burningStains.length - 1; i >= 0; i--) if (Math.hypot(burningStains[i].center[0] - r.point[0], burningStains[i].center[2] - r.point[2]) < 1.2) burningStains.splice(i, 1);
3566
+ for (let i = fireZones.length - 1; i >= 0; i--) if (Math.hypot(fireZones[i].point[0] - r.point[0], fireZones[i].point[2] - r.point[2]) < 1.5) fireZones.splice(i, 1);
3567
+ }
3568
+ });
3569
+ log.coalesce(
3570
+ `use:${toolId}`,
3571
+ { channel: "action", type: "extinguish", text: hit.size ? t("act.extinguish.hit", { weapon: w, what: [...hit].map(nameOf).join(", ") }) : t("act.extinguish.air", { weapon: w }), data: { objects: [...hit] } },
3572
+ 1
3573
+ );
3574
+ };
3575
+ const projectiles = [];
3576
+ const projMeshes = [];
3577
+ const clearProjectileMeshes = () => {
3578
+ for (const m of projMeshes) m.removeFromParent();
3579
+ projMeshes.length = 0;
3580
+ };
3581
+ const throwUse = (u) => {
3582
+ const w = toolName();
3583
+ const molotov = !!u.fire;
3584
+ const mesh = new THREE.Mesh(
3585
+ molotov ? new THREE.CylinderGeometry(0.035, 0.04, 0.2, 8) : new THREE.SphereGeometry(0.045, 8, 6),
3586
+ new THREE.MeshStandardMaterial({ color: molotov ? 3897915 : 4936480, roughness: 0.6, metalness: molotov ? 0 : 0.4 })
3587
+ );
3588
+ mesh.castShadow = true;
3589
+ mesh.position.set(u.origin[0], u.origin[1], u.origin[2]);
3590
+ scene.add(mesh);
3591
+ projMeshes.push(mesh);
3592
+ projectiles.push({
3593
+ mesh,
3594
+ pos: new THREE.Vector3(u.origin[0], u.origin[1], u.origin[2]),
3595
+ vel: new THREE.Vector3(u.velocity[0], u.velocity[1], u.velocity[2]),
3596
+ weapon: toolId,
3597
+ fuse: u.fuse,
3598
+ burstOnImpact: molotov && u.fuse <= 0,
3599
+ energy: u.energy,
3600
+ radius: u.radius,
3601
+ fire: u.fire,
3602
+ age: 0
3603
+ });
3604
+ action("throw_projectile", t(molotov ? "act.throw.molotov" : "act.throw.grenade", { weapon: w, fuse: num(u.fuse, 1) }), { data: { weapon: toolId, fuse: u.fuse } });
3605
+ };
3606
+ const detonate = (p, at, impact) => {
3607
+ const w = toolName(p.weapon);
3608
+ const results = world.explode(at, p.energy, p.radius, "player");
3609
+ action("explode", t("act.explode", { weapon: w, energy: num(p.energy, 0), radius: num(p.radius, 1), n: results.length }), { data: { weapon: p.weapon, energyJ: p.energy, radius: p.radius, point: at } });
3610
+ if (p.fire) {
3611
+ fluids.splash("fuel", [at[0], Math.max(at[1], groundAt(at[0], at[2])), at[2]], 0.5);
3612
+ fireZones.push({ point: [at[0], Math.max(0.02, groundAt(at[0], at[2])), at[2]], radius: p.fire.radius, temperature: p.fire.temperature, remaining: p.fire.seconds, acc: 0 });
3613
+ renderer.emit("fire", at, [0, 1, 0], 24, 3);
3614
+ action("molotov_fire", t("act.molotov_fire", { weapon: w, seconds: num(p.fire.seconds, 0), radius: num(p.fire.radius, 1) }), { data: { weapon: p.weapon, impact } });
3615
+ }
3616
+ };
3617
+ const updateProjectiles = (dt) => {
3618
+ for (let i = projectiles.length - 1; i >= 0; i--) {
3619
+ const p = projectiles[i];
3620
+ p.age += dt;
3621
+ p.vel.y -= 9.81 * dt;
3622
+ const speed = p.vel.length();
3623
+ const step2 = speed * dt;
3624
+ let burst = false;
3625
+ if (step2 > 1e-6) {
3626
+ const d = [p.vel.x / speed, p.vel.y / speed, p.vel.z / speed];
3627
+ const r = withoutHeld(() => world.raycast(v3(p.pos), d, step2 + 0.05));
3628
+ if (r && r.distance <= step2 + 0.04) {
3629
+ p.pos.set(r.point[0] - d[0] * 0.03, r.point[1] - d[1] * 0.03, r.point[2] - d[2] * 0.03);
3630
+ if (p.burstOnImpact) burst = true;
3631
+ else {
3632
+ const n = r.normal;
3633
+ const vn = p.vel.x * n[0] + p.vel.y * n[1] + p.vel.z * n[2];
3634
+ p.vel.x = (p.vel.x - 2 * vn * n[0]) * 0.35;
3635
+ p.vel.y = (p.vel.y - 2 * vn * n[1]) * 0.35;
3636
+ p.vel.z = (p.vel.z - 2 * vn * n[2]) * 0.35;
3637
+ }
3638
+ } else p.pos.addScaledVector(p.vel, dt);
3639
+ }
3640
+ const g = groundAt(p.pos.x, p.pos.z) + 0.045;
3641
+ if (p.pos.y < g) {
3642
+ p.pos.y = g;
3643
+ if (p.burstOnImpact) burst = true;
3644
+ else {
3645
+ p.vel.y = Math.abs(p.vel.y) * 0.35;
3646
+ p.vel.x *= 0.7;
3647
+ p.vel.z *= 0.7;
3648
+ }
3649
+ }
3650
+ p.mesh.position.copy(p.pos);
3651
+ p.mesh.rotation.x += dt * 8;
3652
+ p.fuse -= dt;
3653
+ if (burst || !p.burstOnImpact && p.fuse <= 0 || p.age > 30) {
3654
+ detonate(p, v3(p.pos), burst);
3655
+ p.mesh.removeFromParent();
3656
+ projMeshes.splice(projMeshes.indexOf(p.mesh), 1);
3657
+ projectiles.splice(i, 1);
3658
+ }
3659
+ }
3660
+ };
3661
+ const useTool = (dt, held) => {
3662
+ aim();
3663
+ const cat = tool.def.category;
3664
+ if (cat === "hand") return;
3665
+ const res = tool.trigger([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], dt, held, "player");
3666
+ if (res.status === "empty") {
3667
+ if (!tool.reloading && tool.reserve > 0) tool.reload();
3668
+ say(t(tool.reloading ? "hud.reloading" : "hud.empty"));
3669
+ return;
3670
+ }
3671
+ if (res.status !== "ok") {
3672
+ if (res.status === "reloading") say(t("hud.reloading"));
3673
+ else if (res.status === "no_fuel") say(t("hud.no_fuel"));
3674
+ return;
3675
+ }
3676
+ if (res.shots.length) fireShots(res.shots, res.recoil);
3677
+ for (const u of res.uses) {
3678
+ switch (u.kind) {
3679
+ case "melee":
3680
+ meleeUse(u);
3681
+ break;
3682
+ case "flame":
3683
+ heatRays(u, "fire");
3684
+ break;
3685
+ case "heat":
3686
+ heatRays(u, "heat");
3687
+ break;
3688
+ case "throw":
3689
+ throwUse(u);
3690
+ break;
3691
+ case "tool":
3692
+ toolWork(u, dt);
3693
+ break;
3694
+ case "extinguish":
3695
+ extinguishUse(u);
3696
+ break;
3697
+ default:
3698
+ break;
3699
+ }
3700
+ }
3701
+ };
3702
+ const kick = () => {
3703
+ if (!arsenal.weapons.has("kick")) return;
3704
+ aim();
3705
+ const res = toolState("kick").trigger([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], 0, false, "player");
3706
+ if (res.status !== "ok") return;
3707
+ for (const u of res.uses) if (u.kind === "melee") meleeUse(u, "kick");
3708
+ };
3709
+ const setTool = (id) => {
3710
+ if (!arsenal.weapons.has(id)) throw new Error(`game-test-room: unknown weapon "${id}"`);
3711
+ tool.release();
3712
+ toolId = id;
3713
+ const i = loadout.indexOf(id);
3714
+ if (i >= 0) toolIndex = i;
3715
+ tool = toolState(id);
3716
+ progress = null;
3717
+ action("tool", t("act.tool", { weapon: toolName(id) }), { data: { weapon: id } });
3718
+ fire("tool", id);
3719
+ };
3720
+ const cycleTool = (dir) => {
3721
+ if (!loadout.length) return;
3722
+ toolIndex = (toolIndex + dir + loadout.length) % loadout.length;
3723
+ setTool(loadout[toolIndex]);
3724
+ };
3725
+ let heldEntry = null;
3726
+ let heldPiece = null;
3727
+ let heldName = "";
3728
+ let heldYaw = 0;
3729
+ let heldTilt = 0;
3730
+ let tiltKey = 0;
3731
+ const heldLocal = new THREE.Vector3();
3732
+ const heldVel = [0, 0, 0];
3733
+ let useItemPlayer = null;
3734
+ let useItemName = "";
3735
+ const releaseHeld = () => {
3736
+ if (heldPiece) {
3737
+ heldPiece.held = false;
3738
+ heldPiece.sleeping = false;
3739
+ heldPiece.velocity = [heldVel[0], heldVel[1], heldVel[2]];
3740
+ }
3741
+ useItemPlayer?.cancel();
3742
+ useItemPlayer = null;
3743
+ heldEntry = null;
3744
+ heldPiece = null;
3745
+ heldTilt = 0;
3746
+ };
3747
+ const grab = () => {
3748
+ if (heldPiece) {
3749
+ const name = heldName;
3750
+ releaseHeld();
3751
+ action("drop", t("act.drop", { name }));
3752
+ fire("grab", { held: false });
3753
+ return true;
3754
+ }
3755
+ aim();
3756
+ const r = rayWorld([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], 3.2);
3757
+ if (!r) {
3758
+ say(t("hud.nothing_to_grab"));
3759
+ return false;
3760
+ }
3761
+ const piece = rendererPieces.get(r.object);
3762
+ if (!piece) {
3763
+ say(t("hud.cannot_grab", { name: nameOf(r.object) }));
3764
+ return false;
3765
+ }
3766
+ const kg = piece.mass;
3767
+ if (kg > maxCarry) {
3768
+ say(t("hud.too_heavy", { name: nameOf(r.object), kg: num(kg, 0) }));
3769
+ return false;
3770
+ }
3771
+ heldPiece = piece;
3772
+ heldEntry = entries.get(r.object) ?? null;
3773
+ heldName = nameOf(r.object);
3774
+ piece.held = true;
3775
+ piece.sleeping = false;
3776
+ const box2 = new THREE.Box3().setFromObject(piece.root);
3777
+ const c = box2.getCenter(new THREE.Vector3());
3778
+ piece.root.updateMatrixWorld(true);
3779
+ heldLocal.copy(c).sub(piece.root.position);
3780
+ heldLocal.applyQuaternion(piece.root.quaternion.clone().invert());
3781
+ heldYaw = 0;
3782
+ heldTilt = 0;
3783
+ heldVel[0] = heldVel[1] = heldVel[2] = 0;
3784
+ action("grab", t("act.grab", { name: heldName, kg: num(kg, 1) }), { objectId: r.object, itemId: entryOf(r.object)?.so.itemId, data: { massKg: Math.round(kg * 10) / 10 } });
3785
+ fire("grab", { held: true, objectId: r.object });
3786
+ return true;
3787
+ };
3788
+ const throwHeld = (speed = 9) => {
3789
+ if (!heldPiece) return false;
3790
+ const piece = heldPiece;
3791
+ const name = heldName;
3792
+ const id = piece.objectId;
3793
+ aim();
3794
+ releaseHeld();
3795
+ piece.velocity = [0, 0, 0];
3796
+ renderer.impulse(id, [aimD[0] * speed * piece.mass, (aimD[1] * speed + 1.2) * piece.mass, aimD[2] * speed * piece.mass]);
3797
+ action("throw", t("act.throw", { name, speed: num(speed, 1) }), { objectId: id, itemId: entryOf(id)?.so.itemId, data: { speedMs: speed, massKg: piece.mass } });
3798
+ fire("throw", { objectId: id, speed });
3799
+ return true;
3800
+ };
3801
+ const followHeld = (dt) => {
3802
+ const piece = heldPiece;
3803
+ if (!piece) return;
3804
+ camera.updateMatrixWorld(true);
3805
+ const root = piece.root;
3806
+ let pos;
3807
+ const targetQ = tmpQ;
3808
+ if (useItemPlayer) {
3809
+ const pose = useItemPlayer.pose();
3810
+ const handQ = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), player.yaw);
3811
+ pos = tmpV.set(pose.position[0], pose.position[1], pose.position[2]).applyQuaternion(handQ).add(camera.position);
3812
+ const eq = new THREE.Quaternion().setFromEuler(new THREE.Euler(pose.rotation[0], pose.rotation[1], pose.rotation[2], "XYZ"));
3813
+ targetQ.copy(handQ).multiply(eq);
3814
+ targetQ.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), heldYaw));
3815
+ } else {
3816
+ pos = tmpV.set(0.05, -0.28, -1.15).applyMatrix4(camera.matrixWorld);
3817
+ targetQ.setFromAxisAngle(new THREE.Vector3(0, 1, 0), player.yaw + heldYaw);
3818
+ targetQ.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), -heldTilt));
3819
+ }
3820
+ const k = Math.min(1, (useItemPlayer ? 30 : 16) * dt);
3821
+ const px = root.position.x;
3822
+ const py = root.position.y;
3823
+ const pz = root.position.z;
3824
+ root.quaternion.slerp(targetQ, Math.min(1, k * 0.9));
3825
+ const off2 = tmpV2.copy(heldLocal).applyQuaternion(root.quaternion);
3826
+ root.position.set(root.position.x + (pos.x - off2.x - root.position.x) * k, root.position.y + (pos.y - off2.y - root.position.y) * k, root.position.z + (pos.z - off2.z - root.position.z) * k);
3827
+ if (dt > 0) {
3828
+ heldVel[0] = (root.position.x - px) / dt;
3829
+ heldVel[1] = (root.position.y - py) / dt;
3830
+ heldVel[2] = (root.position.z - pz) / dt;
3831
+ }
3832
+ root.updateMatrixWorld(true);
3833
+ };
3834
+ const useItem = (useId) => {
3835
+ const e = heldEntry;
3836
+ if (!e?.containerId) return false;
3837
+ const def = itemUseDefs.find((u) => u.id === useId);
3838
+ if (!def) return false;
3839
+ const c = fluids.container(e.containerId);
3840
+ if (!c) return false;
3841
+ if (def.liquids && def.liquids.length > 0 && (!c.liquid || !def.liquids.includes(c.liquid))) return false;
3842
+ if ((useId === "drink" || useId === "sip" || useId === "chug" || useId === "pour" || useId === "pour_out") && c.liters <= 1e-6) {
3843
+ say(t("hud.empty_container", { name: e.name }));
3844
+ return false;
3845
+ }
3846
+ if (useItemPlayer && !useItemPlayer.done) return false;
3847
+ useItemPlayer = createItemUse(fluids, e.containerId, def);
3848
+ useItemName = def.id;
3849
+ action("use_item", t("act.use_item", { name: e.name, use: S.text[`use.${def.id}`] ?? def.id }), { objectId: e.so.objectId, itemId: e.so.itemId, data: { use: def.id } });
3850
+ return true;
3851
+ };
3852
+ const updateItemUse = (dt) => {
3853
+ if (!useItemPlayer) return;
3854
+ useItemPlayer.update(dt);
3855
+ if (useItemPlayer.done) {
3856
+ useItemPlayer = null;
3857
+ useItemName = "";
3858
+ }
3859
+ };
3860
+ const TAP_ID = "room_tap";
3861
+ const TAP_RATE = 0.25;
3862
+ fluids.addTap({ id: TAP_ID, position: arena.tapSpout, liquid: "water", rate: TAP_RATE, temperature: 16, on: false });
3863
+ const isTapOn = () => fluids.taps().some((x) => x.id === TAP_ID && x.on);
3864
+ const toggleTap = (on = !isTapOn()) => {
3865
+ fluids.setTap(TAP_ID, on);
3866
+ action("tap", t(on ? "act.tap.on" : "act.tap.off"));
3867
+ return on;
3868
+ };
3869
+ const lastImpact = /* @__PURE__ */ new Map();
3870
+ const MIN_LANDING_LOG = 3;
3871
+ const updateImpacts = () => {
3872
+ const check = (piece) => {
3873
+ if (piece.held || piece.sleeping) return;
3874
+ const v = piece.velocity;
3875
+ const speed = Math.hypot(v[0], v[1], v[2]);
3876
+ if (speed < 3.5) return;
3877
+ if ((lastImpact.get(piece.objectId) ?? -9) > simTime - 0.25) return;
3878
+ const dir = [v[0] / speed, v[1] / speed, v[2] / speed];
3879
+ const c = tmpV.copy(new THREE.Vector3(0, 0, 0));
3880
+ void c;
3881
+ const box2 = tmpBox.setFromObject(piece.root);
3882
+ const centre = box2.getCenter(tmpV);
3883
+ const size = box2.getSize(tmpV2);
3884
+ const reach = Math.max(size.x, size.y, size.z) * 0.5;
3885
+ const origin = [centre.x + dir[0] * (reach + 0.02), centre.y + dir[1] * (reach + 0.02), centre.z + dir[2] * (reach + 0.02)];
3886
+ const r = withoutHeld(() => world.raycast(origin, dir, speed * FIXED_DT * 2 + 0.06));
3887
+ if (!r || r.object === piece.objectId) return;
3888
+ const energy = 0.5 * piece.mass * speed * speed;
3889
+ lastImpact.set(piece.objectId, simTime);
3890
+ world.hitPart(r.object, r.part, { type: "blunt", point: r.point, direction: dir, energy, radius: 0.08, source: "thrown" });
3891
+ if (world.has(piece.objectId)) world.hit(piece.objectId, { type: "blunt", point: r.point, direction: [-dir[0], -dir[1], -dir[2]], energy: energy * 0.4, radius: 0.08, source: "thrown" });
3892
+ pushPiece(r.object, dir, piece.mass * speed * 0.5, r.point);
3893
+ piece.velocity = [-v[0] * 0.25, Math.abs(v[1]) * 0.3 + 0.5, -v[2] * 0.25];
3894
+ action("impact", t("act.impact", { name: nameOf(piece.objectId), where: hitWhere(r), energy: num(energy, 0), speed: num(speed, 1) }), {
3895
+ objectId: piece.objectId,
3896
+ itemId: entryOf(piece.objectId)?.so.itemId,
3897
+ data: { energyJ: Math.round(energy), speedMs: Math.round(speed * 10) / 10, target: r.object, targetPart: r.part }
3898
+ });
3899
+ };
3900
+ const land = (piece) => {
3901
+ const vy = piece.velocity[1];
3902
+ const prev = prevVy.get(piece.objectId) ?? 0;
3903
+ prevVy.set(piece.objectId, vy);
3904
+ if (piece.held || prev > -MIN_LANDING_LOG || vy < prev + 2 || !world.has(piece.objectId)) return;
3905
+ const energy = 0.5 * piece.mass * prev * prev;
3906
+ action("landing", t("act.landing", { name: nameOf(piece.objectId), speed: num(-prev, 1), energy: num(energy, energy < 100 ? 1 : 0) }), {
3907
+ objectId: piece.objectId,
3908
+ itemId: entryOf(piece.objectId)?.so.itemId,
3909
+ data: { energyJ: Math.round(energy * 10) / 10, speedMs: Math.round(-prev * 10) / 10 }
3910
+ });
3911
+ };
3912
+ for (const e of entries.values()) if (e.piece) {
3913
+ check(e.piece);
3914
+ land(e.piece);
3915
+ }
3916
+ for (const p of loose) {
3917
+ check(p);
3918
+ land(p);
3919
+ }
3920
+ };
3921
+ const prevVy = /* @__PURE__ */ new Map();
3922
+ const tmpBox = new THREE.Box3();
3923
+ const onKey = (code, down, e) => {
3924
+ if (!down) {
3925
+ if (code === "KeyZ") tiltKey = 0;
3926
+ return;
3927
+ }
3928
+ const digit = /^Digit(\d)$/.exec(code);
3929
+ if (digit && !e.ctrlKey && !e.metaKey) {
3930
+ const n = (Number(digit[1]) + 9) % 10;
3931
+ const page = Math.floor(toolIndex / 10);
3932
+ const idx = page * 10 + n;
3933
+ if (idx < loadout.length) setTool(loadout[idx]);
3934
+ return;
3935
+ }
3936
+ switch (code) {
3937
+ case "KeyE":
3938
+ grab();
3939
+ break;
3940
+ case "KeyG":
3941
+ throwHeld();
3942
+ break;
3943
+ case "KeyR":
3944
+ if (heldPiece) heldYaw += Math.PI / 8;
3945
+ else {
3946
+ tool.reload();
3947
+ say(t("hud.reloading"));
3948
+ }
3949
+ break;
3950
+ case "KeyQ":
3951
+ tool.cycleMode();
3952
+ break;
3953
+ case "KeyZ":
3954
+ tiltKey = 1;
3955
+ break;
3956
+ case "KeyF":
3957
+ if (heldEntry?.uses[0]) useItem(heldEntry.uses[0]);
3958
+ break;
3959
+ case "KeyC":
3960
+ if (heldEntry?.uses[1]) useItem(heldEntry.uses[1]);
3961
+ break;
3962
+ case "KeyV":
3963
+ if (heldEntry?.uses[2]) useItem(heldEntry.uses[2]);
3964
+ else kick();
3965
+ break;
3966
+ case "KeyT":
3967
+ toggleTap();
3968
+ break;
3969
+ case "KeyH": {
3970
+ const w = (heldEntry ?? entryOf(inspectTarget()?.object ?? ""))?.item?.weapon;
3971
+ if (w) equip(w);
3972
+ break;
3973
+ }
3974
+ case "BracketRight":
3975
+ toolIndex = Math.min(loadout.length - 1, (Math.floor(toolIndex / 10) + 1) * 10);
3976
+ setTool(loadout[toolIndex]);
3977
+ break;
3978
+ case "BracketLeft":
3979
+ toolIndex = Math.max(0, (Math.floor(toolIndex / 10) - 1) * 10);
3980
+ setTool(loadout[toolIndex]);
3981
+ break;
3982
+ case "Tab":
3983
+ ui?.togglePanels();
3984
+ break;
3985
+ default:
3986
+ break;
3987
+ }
3988
+ };
3989
+ const equip = (weaponId) => {
3990
+ if (!arsenal.weapons.has(weaponId)) return;
3991
+ if (!loadout.includes(weaponId)) loadout.push(weaponId);
3992
+ setTool(weaponId);
3993
+ };
3994
+ const inspectTarget = () => {
3995
+ aim();
3996
+ return rayWorld(aimO, aimD, 14);
3997
+ };
3998
+ const inspectInfo = () => {
3999
+ const r = inspectTarget();
4000
+ if (!r) return null;
4001
+ const st = world.state(r.object);
4002
+ const p = world.part(r.object, r.part);
4003
+ if (!st || !p) return null;
4004
+ const e = entryOf(r.object);
4005
+ const joints = st.joints.filter((j) => j.a === r.part || j.b === r.part).map((j) => ({
4006
+ id: j.id,
4007
+ kind: dStrings.jointKinds[j.kind] ?? j.kind,
4008
+ tools: JOINT_DEFAULTS2[j.kind].tools.map((k) => dStrings.toolKinds[k] ?? k),
4009
+ open: j.open,
4010
+ progress: j.progress
4011
+ }));
4012
+ const ext = p;
4013
+ return {
4014
+ object: nameOf(r.object),
4015
+ objectId: r.object,
4016
+ part: r.part,
4017
+ material: S.text[`material.${p.material}`] ?? dStrings.materialNames[p.material] ?? p.material,
4018
+ status: dStrings.partStatus[p.status] ?? p.status,
4019
+ hp: p.hp,
4020
+ maxHp: p.maxHp,
4021
+ temperature: p.temperature,
4022
+ burning: p.burning,
4023
+ char: p.char,
4024
+ melt: p.melt,
4025
+ liters: p.liters,
4026
+ dents: p.dents.length,
4027
+ holes: p.holes.length,
4028
+ cracks: p.cracks.length,
4029
+ mass: p.mass,
4030
+ load: ext.load,
4031
+ stress: ext.stress,
4032
+ joints,
4033
+ note: e?.item?.note,
4034
+ distance: r.distance
4035
+ };
4036
+ };
4037
+ let timeScale = 1;
4038
+ let fastForward = false;
4039
+ let hudAcc = 1;
4040
+ let qualityName = quality;
4041
+ const syncContainers = (sdt) => {
4042
+ for (const e of entries.values()) {
4043
+ if (!e.containerId) continue;
4044
+ const host = hostOf(e);
4045
+ host.root.updateMatrixWorld(true);
4046
+ const q = host.root.getWorldQuaternion(tmpQ);
4047
+ const p = tmpV.set(e.fluidOffset[0], e.fluidOffset[1], e.fluidOffset[2]).applyMatrix4(host.root.matrixWorld);
4048
+ const vel = sdt > 0 ? [(p.x - e.last[0]) / sdt, (p.y - e.last[1]) / sdt, (p.z - e.last[2]) / sdt] : void 0;
4049
+ e.last = [p.x, p.y, p.z];
4050
+ fluids.setTransform(e.containerId, [p.x, p.y, p.z], [q.x, q.y, q.z, q.w], vel);
4051
+ }
4052
+ };
4053
+ const syncLiters = () => {
4054
+ for (const e of entries.values()) {
4055
+ if (!e.containerId || !e.fluidPart) continue;
4056
+ const c = fluids.container(e.containerId);
4057
+ if (!c) continue;
4058
+ const host = hostOf(e);
4059
+ const part = world.part(host.objectId, e.fluidPart);
4060
+ if (part && part.liters !== void 0 && Math.abs(part.liters - c.liters) > 1e-4) world.setLiters(host.objectId, e.fluidPart, c.liters);
4061
+ }
4062
+ };
4063
+ let paused = false;
4064
+ let walkPlan = null;
4065
+ const stepWalk = (dt) => {
4066
+ if (!walkPlan) return;
4067
+ const f = walkPlan.remaining <= dt ? 1 : dt / walkPlan.remaining;
4068
+ player.moveBy((walkPlan.x - camera.position.x) * f, (walkPlan.z - camera.position.z) * f);
4069
+ walkPlan.remaining -= dt;
4070
+ if (walkPlan.remaining <= 1e-9) walkPlan = null;
4071
+ };
4072
+ const stepSim = (dt) => {
4073
+ if (paused) return;
4074
+ simTime += dt;
4075
+ stepWalk(dt);
4076
+ const reps = fastForward ? 10 : 1;
4077
+ updateAnimations(dt);
4078
+ updateItemUse(dt);
4079
+ updateProjectiles(dt);
4080
+ syncContainers(dt);
4081
+ for (let i = 0; i < reps; i++) {
4082
+ world.update(dt);
4083
+ if (i === 0) updateFires(dt);
4084
+ fluids.update(dt);
4085
+ }
4086
+ syncLiters();
4087
+ renderer.update(dt);
4088
+ updateImpacts();
4089
+ fluidView.update(dt);
4090
+ for (const s of tools.values()) s.update(dt);
4091
+ for (const tr of tracers) {
4092
+ if (tr.life > 0) {
4093
+ tr.life -= dt;
4094
+ if (tr.life <= 0) tr.line.visible = false;
4095
+ }
4096
+ }
4097
+ flush();
4098
+ };
4099
+ const update = (dt) => {
4100
+ player.update(dt);
4101
+ if (tiltKey || heldTilt > 0) {
4102
+ heldTilt = Math.max(0, Math.min(2.4, heldTilt + (tiltKey ? 2.2 : -3) * dt));
4103
+ }
4104
+ if (useHeld && !paused) {
4105
+ if (tool.def.category === "hand") {
4106
+ if (useFirst) grab();
4107
+ } else useTool(dt, !useFirst);
4108
+ useFirst = false;
4109
+ }
4110
+ if (!paused) followHeld(dt);
4111
+ stepSim(dt * timeScale);
4112
+ if (progress && simTime - progress.at > 0.25) progress = null;
4113
+ hudAcc += dt;
4114
+ if (hudAcc >= 0.1 && ui) {
4115
+ hudAcc = 0;
4116
+ ui.hud(hudState());
4117
+ }
4118
+ };
4119
+ const hudState = () => {
4120
+ const page = Math.floor(toolIndex / 10);
4121
+ const slots = loadout.slice(page * 10, page * 10 + 10).map((id) => arsenal.name(id));
4122
+ const ammo = tool.def.action.kind === "firearm" ? `${tool.ammo}/${tool.capacity}${tool.reserve ? ` \xB7 ${tool.reserve}` : ""}` : tool.def.action.kind === "hand" ? "" : tool.def.action.kind === "melee" || tool.def.action.kind === "tool" ? "" : `${num(tool.ammo, 0)}`;
4123
+ return {
4124
+ toolName: toolName(),
4125
+ toolId,
4126
+ ammo,
4127
+ slots,
4128
+ slot: toolIndex - page * 10,
4129
+ page: page + 1,
4130
+ pages: Math.max(1, Math.ceil(loadout.length / 10)),
4131
+ status: simTime - statusAt < 2 ? statusLine : "",
4132
+ progress: progress ? { label: progress.label, value: progress.value } : null,
4133
+ held: heldEntry || heldPiece ? { name: heldName, uses: (heldEntry?.uses ?? []).map((u, i) => ({ key: ["F", "C", "V"][i] ?? "", label: S.text[`use.${u}`] ?? u })), weapon: heldEntry?.item?.weapon ? arsenal.name(heldEntry.item.weapon) : void 0, using: useItemName } : null,
4134
+ inspect: inspectInfo(),
4135
+ animations: animationsOf(),
4136
+ locked: player.locked,
4137
+ timeScale,
4138
+ fast: fastForward,
4139
+ tap: isTapOn(),
4140
+ quality: qualityName,
4141
+ objects: [...entries.values()].filter((e) => !e.arena).length
4142
+ };
4143
+ };
4144
+ const playing = [];
4145
+ const restPose = /* @__PURE__ */ new WeakMap();
4146
+ const ZERO = [0, 0, 0];
4147
+ const animTarget = () => {
4148
+ if (heldEntry) return heldEntry;
4149
+ const r = inspectTarget();
4150
+ return r ? entryOf(r.object) : void 0;
4151
+ };
4152
+ const poseAt = (clip, id, time) => {
4153
+ const pick = (k) => id === null ? k : k.parts?.[id];
4154
+ const pts = clip.keys.map((k) => ({ t: k.t, position: pick(k)?.position ?? ZERO, rotation: pick(k)?.rotation ?? ZERO }));
4155
+ if (pts.length === 0 || pts[0].t > 0) pts.unshift({ t: 0, position: ZERO, rotation: ZERO });
4156
+ let a = pts[0];
4157
+ let b = pts[pts.length - 1];
4158
+ if (time >= b.t) a = b;
4159
+ else for (let i = 0; i < pts.length - 1; i++) if (time >= pts[i].t && time < pts[i + 1].t) [a, b] = [pts[i], pts[i + 1]];
4160
+ const f = a === b || b.t <= a.t ? 0 : (time - a.t) / (b.t - a.t);
4161
+ const mix = (x, y) => [x[0] + (y[0] - x[0]) * f, x[1] + (y[1] - x[1]) * f, x[2] + (y[2] - x[2]) * f];
4162
+ return { position: mix(a.position, b.position), rotation: mix(a.rotation, b.rotation) };
4163
+ };
4164
+ const applyClip = (pl) => {
4165
+ for (const [id, obj] of pl.targets) {
4166
+ const rest = restPose.get(obj);
4167
+ if (!rest) continue;
4168
+ const pose = poseAt(pl.clip, id === "" ? null : id, pl.t);
4169
+ obj.position.set(rest.p[0] + pose.position[0], rest.p[1] + pose.position[1], rest.p[2] + pose.position[2]);
4170
+ obj.rotation.set(rest.r[0] + pose.rotation[0], rest.r[1] + pose.rotation[1], rest.r[2] + pose.rotation[2]);
4171
+ const ps = id === "" ? void 0 : specs.get(pl.entry.so.objectId)?.parts.find((p) => p.id === id);
4172
+ if (ps) {
4173
+ const r0 = ps.rotation ?? ZERO;
4174
+ world.setPartPose(
4175
+ pl.entry.so.objectId,
4176
+ id,
4177
+ [ps.position[0] + pose.position[0], ps.position[1] + pose.position[1], ps.position[2] + pose.position[2]],
4178
+ [r0[0] + pose.rotation[0], r0[1] + pose.rotation[1], r0[2] + pose.rotation[2]]
4179
+ );
4180
+ }
4181
+ }
4182
+ };
4183
+ const updateAnimations = (dt) => {
4184
+ for (let i = playing.length - 1; i >= 0; i--) {
4185
+ const pl = playing[i];
4186
+ pl.t = Math.min(pl.clip.duration, pl.t + dt);
4187
+ applyClip(pl);
4188
+ const evs = pl.clip.events ?? [];
4189
+ while (pl.fired < evs.length && evs[pl.fired].t <= pl.t) {
4190
+ const ev = evs[pl.fired++];
4191
+ action("animation_event", t("act.anim_event", { name: pl.entry.name, clip: pl.clip.id, type: ev.type }), { objectId: pl.entry.so.objectId, itemId: pl.entry.so.itemId, data: { clip: pl.clip.id, type: ev.type, at: ev.t, ...ev.data ?? {} } });
4192
+ const key = ev.type === "sound" ? ev.data?.key : void 0;
4193
+ if (typeof key === "string") options.onSound?.(key, v3(pl.entry.so.root.getWorldPosition(tmpV)), 1);
4194
+ }
4195
+ if (pl.t >= pl.clip.duration) {
4196
+ playing.splice(i, 1);
4197
+ action("animation_end", t("act.anim_end", { name: pl.entry.name, clip: pl.clip.id }), { objectId: pl.entry.so.objectId, itemId: pl.entry.so.itemId, data: { clip: pl.clip.id } });
4198
+ }
4199
+ }
4200
+ };
4201
+ const animationsOf = () => {
4202
+ const e = animTarget();
4203
+ if (!e || e.arena) return null;
4204
+ const clips = (e.item?.animations ?? []).map((c) => ({ id: c.id, duration: c.duration }));
4205
+ return { objectId: e.so.objectId, itemId: e.so.itemId, name: e.name, clips, playing: playing.find((p) => p.entry === e)?.clip.id ?? null };
4206
+ };
4207
+ const playAnimation = (clipId, objectId) => {
4208
+ const e = objectId ? entries.get(objectId) : animTarget();
4209
+ const clip = e?.item?.animations?.find((c) => c.id === clipId);
4210
+ if (!e || !clip) return false;
4211
+ for (let i = playing.length - 1; i >= 0; i--) if (playing[i].entry === e && playing[i].clip.id === clipId) playing.splice(i, 1);
4212
+ const targets = /* @__PURE__ */ new Map();
4213
+ const ids = /* @__PURE__ */ new Set();
4214
+ for (const k of clip.keys) for (const id of Object.keys(k.parts ?? {})) ids.add(id);
4215
+ const grab2 = (key, obj) => {
4216
+ if (!obj) return;
4217
+ if (!restPose.has(obj)) restPose.set(obj, { p: [obj.position.x, obj.position.y, obj.position.z], r: [obj.rotation.x, obj.rotation.y, obj.rotation.z] });
4218
+ targets.set(key, obj);
4219
+ };
4220
+ if (clip.keys.some((k) => k.position || k.rotation)) grab2("", e.so.root);
4221
+ for (const id of ids) grab2(id, e.so.root.getObjectByName(id) ?? void 0);
4222
+ const pl = { entry: e, clip, t: 0, fired: 0, targets };
4223
+ playing.push(pl);
4224
+ applyClip(pl);
4225
+ action("animation", t("act.anim_start", { name: e.name, clip: clip.id, seconds: num(clip.duration, 1) }), { objectId: e.so.objectId, itemId: e.so.itemId, data: { clip: clip.id, duration: clip.duration } });
4226
+ return true;
4227
+ };
4228
+ const extraScenarios = [...options.scenarios ?? []];
4229
+ const moduleCheck = (ok, text, data, itemId) => {
4230
+ log.add({ channel: "check", type: ok ? "pass" : "fail", text: `${ok ? "\u2713" : "\u2717"} ${text}`, itemId, data: { ok, ...data } });
4231
+ };
4232
+ const loadModule = (mod) => {
4233
+ const report = { module: mod.id, items: [], scenarios: [], ok: true, problems: [] };
4234
+ const fail = (problem) => {
4235
+ report.ok = false;
4236
+ report.problems.push(problem);
4237
+ };
4238
+ const added = mod.items(locale);
4239
+ for (const ti of added) {
4240
+ const item = {
4241
+ id: ti.id,
4242
+ name: ti.name,
4243
+ group: mod.group,
4244
+ build: (T) => ti.build(T),
4245
+ spec: ti.spec,
4246
+ movable: ti.movable,
4247
+ fluid: ti.fluid,
4248
+ weapon: ti.weapon,
4249
+ impl: ti.impl,
4250
+ animations: ti.animations
4251
+ };
4252
+ catalogMap.set(item.id, item);
4253
+ const at = catalog.findIndex((c) => c.id === item.id);
4254
+ if (at >= 0) catalog[at] = item;
4255
+ else catalog.push(item);
4256
+ report.items.push(item.id);
4257
+ let partIds = [];
4258
+ const base = { module: mod.id, item: item.id };
4259
+ try {
4260
+ const root = item.build(THREE);
4261
+ root.updateMatrixWorld(true);
4262
+ if (item.impl) {
4263
+ const probe = item.impl(root, `${item.id}_check`);
4264
+ const r = checkDestructible(probe, { materials: world.materials.keys() });
4265
+ partIds = probe.describe().parts.map((p) => p.id);
4266
+ moduleCheck(r.ok, r.ok ? t("mod.destructible_ok", { name: item.name, parts: partIds.length }) : t("mod.destructible_fail", { name: item.name, problems: r.problems.join("; ") }), { ...base, check: "destructible", problems: r.problems }, item.id);
4267
+ for (const p of r.problems) fail(`${item.id}: ${p}`);
4268
+ }
4269
+ if (!item.impl || item.spec) {
4270
+ const spec = resolveSpec(item, root).spec;
4271
+ if (!item.impl) partIds = spec.parts.map((p) => p.id);
4272
+ const r = checkSpec(spec, { materials: world.materials.keys() });
4273
+ moduleCheck(r.ok, r.ok ? t("mod.spec_ok", { name: item.name, parts: spec.parts.length }) : t("mod.spec_fail", { name: item.name, problems: r.problems.join("; ") }), { ...base, check: "spec", problems: r.problems, warnings: r.warnings }, item.id);
4274
+ for (const p of r.problems) fail(`${item.id}: ${p}`);
4275
+ for (const w of r.warnings) log.add({ channel: "check", type: "warn", text: `\u26A0 ${t("mod.spec_warn", { name: item.name, warning: w })}`, itemId: item.id, data: { ok: true, ...base, check: "spec", warning: w } });
4276
+ }
4277
+ } catch (err) {
4278
+ const msg = err instanceof Error ? err.message : String(err);
4279
+ moduleCheck(false, t("mod.destructible_fail", { name: item.name, problems: msg }), { ...base, check: "destructible", problems: [msg] }, item.id);
4280
+ fail(`${item.id}: ${msg}`);
4281
+ }
4282
+ for (const clip of item.animations ?? []) {
4283
+ const r = checkAnimation(clip, partIds);
4284
+ 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);
4285
+ for (const p of r.problems) fail(`${item.id}: ${p}`);
4286
+ }
4287
+ }
4288
+ for (const ms of mod.scenarios?.(locale) ?? []) {
4289
+ const sc = { id: ms.id, name: ms.name, run: ms.run };
4290
+ const at = extraScenarios.findIndex((x) => x.id === sc.id);
4291
+ if (at >= 0) extraScenarios[at] = sc;
4292
+ else extraScenarios.push(sc);
4293
+ report.scenarios.push(sc.id);
4294
+ }
4295
+ action("module", t("act.module_loaded", { name: mod.name, items: report.items.length, scenarios: report.scenarios.length, result: t(report.ok ? "mod.all_ok" : "mod.some_failed") }), { data: { module: mod.id, items: report.items, scenarios: report.scenarios, ok: report.ok } });
4296
+ ui?.refresh();
4297
+ return report;
4298
+ };
4299
+ const allScenarios = () => [...makeScenarios(t, num, locale), ...extraScenarios];
4300
+ let scenarioRunning = false;
4301
+ const step = (seconds) => {
4302
+ const dt = seconds > 6 ? 1 / 30 : FIXED_DT;
4303
+ const n = Math.max(1, Math.round(seconds / dt));
4304
+ for (let i = 0; i < n; i++) {
4305
+ player.update(0);
4306
+ followHeld(dt);
4307
+ stepSim(dt);
4308
+ }
4309
+ };
4310
+ const runScenarios = async (list) => {
4311
+ const all = list ?? allScenarios();
4312
+ const report = { passed: 0, failed: 0, results: [] };
4313
+ scenarioRunning = true;
4314
+ const wasPaused = paused;
4315
+ paused = false;
4316
+ try {
4317
+ for (const sc of all) {
4318
+ hardReset();
4319
+ player.look(arena.playerStart, [0, 1.5, arena.wallZ]);
4320
+ useHeld = false;
4321
+ const checks = [];
4322
+ log.add({ channel: "check", type: "scenario", text: t("sc.start", { name: sc.name }), data: { id: sc.id } });
4323
+ const ctx = {
4324
+ room,
4325
+ step,
4326
+ expect(condition, label, data) {
4327
+ checks.push({ label, ok: !!condition });
4328
+ log.add({ channel: "check", type: condition ? "pass" : "fail", text: `${condition ? "\u2713" : "\u2717"} ${label}`, data: { scenario: sc.id, ok: !!condition, ...data ?? {} } });
4329
+ }
4330
+ };
4331
+ try {
4332
+ await sc.run(ctx);
4333
+ } catch (err) {
4334
+ const msg = err instanceof Error ? err.message : String(err);
4335
+ ctx.expect(false, t("sc.error", { msg }));
4336
+ }
4337
+ flush();
4338
+ const ok = checks.length > 0 && checks.every((c) => c.ok);
4339
+ report.results.push({ id: sc.id, name: sc.name, ok, checks });
4340
+ if (ok) report.passed++;
4341
+ else report.failed++;
4342
+ log.add({ channel: "check", type: ok ? "scenario_pass" : "scenario_fail", text: t(ok ? "sc.pass" : "sc.fail", { name: sc.name }), data: { id: sc.id, ok } });
4343
+ await new Promise((res) => setTimeout(res, 0));
4344
+ }
4345
+ } finally {
4346
+ scenarioRunning = false;
4347
+ paused = wasPaused;
4348
+ }
4349
+ return report;
4350
+ };
4351
+ const room = {
4352
+ world,
4353
+ renderer,
4354
+ scene,
4355
+ camera,
4356
+ arsenal,
4357
+ fluids,
4358
+ fluidRenderer: fluidView,
4359
+ log,
4360
+ get tool() {
4361
+ return tool;
4362
+ },
4363
+ get scenarios() {
4364
+ return allScenarios();
4365
+ },
4366
+ spawn,
4367
+ clear,
4368
+ resetAll,
4369
+ setTool,
4370
+ useItem,
4371
+ use(hold = 0) {
4372
+ if (tool.def.category === "hand") {
4373
+ grab();
4374
+ return;
4375
+ }
4376
+ useTool(FIXED_DT, false);
4377
+ flush();
4378
+ let t0 = 0;
4379
+ while (t0 < hold) {
4380
+ useTool(FIXED_DT, true);
4381
+ stepSim(FIXED_DT);
4382
+ t0 += FIXED_DT;
4383
+ }
4384
+ tool.release();
4385
+ },
4386
+ grab,
4387
+ throwHeld,
4388
+ look(position, target) {
4389
+ player.look(position, target);
4390
+ },
4391
+ inspect() {
4392
+ const r = inspectTarget();
4393
+ if (!r) return null;
4394
+ return { objectId: r.object, partId: r.part, itemId: entryOf(r.object)?.so.itemId ?? "" };
4395
+ },
4396
+ inspectInfo,
4397
+ spawned() {
4398
+ return [...entries.values()].filter((e) => !e.arena).map((e) => e.so);
4399
+ },
4400
+ update,
4401
+ on(type, fn) {
4402
+ listeners[type].add(fn);
4403
+ return () => listeners[type].delete(fn);
4404
+ },
4405
+ runScenarios,
4406
+ tilt(rad) {
4407
+ heldTilt = rad;
4408
+ },
4409
+ toggleTap,
4410
+ pause() {
4411
+ paused = true;
4412
+ },
4413
+ resume() {
4414
+ paused = false;
4415
+ },
4416
+ get paused() {
4417
+ return paused;
4418
+ },
4419
+ walk(to, seconds) {
4420
+ walkPlan = seconds > 0 ? { x: to[0], z: to[2], remaining: seconds } : null;
4421
+ if (!walkPlan) player.moveBy(to[0] - camera.position.x, to[2] - camera.position.z);
4422
+ },
4423
+ setSlowMotion(on) {
4424
+ timeScale = on ? 0.25 : 1;
4425
+ },
4426
+ setFastForward(on) {
4427
+ fastForward = on;
4428
+ },
4429
+ equip,
4430
+ loadModule,
4431
+ animations: animationsOf,
4432
+ playAnimation,
4433
+ dispose() {
4434
+ cancelAnimationFrame?.(raf);
4435
+ window.removeEventListener("resize", onResize);
4436
+ player.dispose();
4437
+ ui?.dispose();
4438
+ renderer.dispose();
4439
+ fluidView.dispose();
4440
+ for (const tr of tracers) tr.line.removeFromParent();
4441
+ clearProjectileMeshes();
4442
+ if (ownRenderer) {
4443
+ gl.dispose();
4444
+ dom.remove();
4445
+ }
4446
+ }
4447
+ };
4448
+ let ui = null;
4449
+ if (options.ui !== false) {
4450
+ const groups = () => {
4451
+ const m = /* @__PURE__ */ new Map();
4452
+ for (const i of catalog) {
4453
+ const g = m.get(i.group) ?? [];
4454
+ g.push(i);
4455
+ m.set(i.group, g);
4456
+ }
4457
+ return m;
4458
+ };
4459
+ ui = createUi({
4460
+ container,
4461
+ strings: S,
4462
+ t,
4463
+ log,
4464
+ groups,
4465
+ loadout: () => loadout.map((id) => ({ id, name: arsenal.name(id) })),
4466
+ onSpawn: (id) => {
4467
+ spawn(id);
4468
+ },
4469
+ onEquip: (itemId) => {
4470
+ const w = catalogMap.get(itemId)?.weapon;
4471
+ if (w) equip(w);
4472
+ },
4473
+ onClear: clear,
4474
+ onResetAll: resetAll,
4475
+ onSlow: (on) => room.setSlowMotion(on),
4476
+ onFast: (on) => room.setFastForward(on),
4477
+ onQuality: (q) => {
4478
+ qualityName = q;
4479
+ if (ownRenderer) {
4480
+ gl.shadowMap.enabled = q !== "low";
4481
+ gl.setPixelRatio(q === "low" ? 1 : Math.min(typeof devicePixelRatio === "number" ? devicePixelRatio : 1, 2));
4482
+ arena.sun.shadow.mapSize.set(q === "high" ? 2048 : 1024, q === "high" ? 2048 : 1024);
4483
+ arena.sun.shadow.map?.dispose();
4484
+ arena.sun.shadow.map = null;
4485
+ resize();
4486
+ }
4487
+ },
4488
+ quality: qualityName,
4489
+ scenarios: allScenarios,
4490
+ onRun: async (sc) => {
4491
+ await runScenarios([sc]);
4492
+ },
4493
+ onRunAll: async () => {
4494
+ await runScenarios();
4495
+ },
4496
+ onTool: (index) => {
4497
+ toolIndex = index;
4498
+ setTool(loadout[index]);
4499
+ },
4500
+ onTap: () => toggleTap(),
4501
+ onPlay: (objectId, clipId) => {
4502
+ playAnimation(clipId, objectId);
4503
+ },
4504
+ onResize: resize
4505
+ });
4506
+ ui.hud(hudState());
4507
+ }
4508
+ let raf = 0;
4509
+ if (ownRenderer && typeof requestAnimationFrame === "function") {
4510
+ let last = performance.now();
4511
+ const loop = (now) => {
4512
+ raf = requestAnimationFrame(loop);
4513
+ const dt = Math.min(0.05, (now - last) / 1e3);
4514
+ last = now;
4515
+ if (!scenarioRunning) update(dt);
4516
+ gl.render(scene, camera);
4517
+ };
4518
+ raf = requestAnimationFrame(loop);
4519
+ }
4520
+ if (options.initial) {
4521
+ for (const [i, id] of options.initial.entries()) spawn(id, [(i - (options.initial.length - 1) / 2) * 1.3, 0, 0.5]);
4522
+ }
4523
+ return room;
4524
+ }
4525
+ function structuredCloneSpec(spec) {
4526
+ return JSON.parse(JSON.stringify(spec));
4527
+ }
4528
+ export {
4529
+ DEMO_ITEMS,
4530
+ DEMO_MATERIALS,
4531
+ MATERIAL_COLORS,
4532
+ SCENARIOS,
4533
+ brickWallSpec,
4534
+ buildFromSpec,
4535
+ createLog,
4536
+ createTestRoom,
4537
+ demoItems,
4538
+ locales,
4539
+ makeScenarios,
4540
+ resolveStrings,
4541
+ scenariosFor
4542
+ };
4543
+ //# sourceMappingURL=index.js.map