@adatechnology/game-test-room 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,7 +29,9 @@ var GameTestRoom = (() => {
29
29
  buildFromSpec: () => buildFromSpec,
30
30
  createLog: () => createLog,
31
31
  createTestRoom: () => createTestRoom,
32
+ decodeBenchState: () => decodeBenchState,
32
33
  demoItems: () => demoItems,
34
+ encodeBenchState: () => encodeBenchState,
33
35
  locales: () => locales4,
34
36
  makeScenarios: () => makeScenarios,
35
37
  resolveStrings: () => resolveStrings4,
@@ -240,6 +242,47 @@ var GameTestRoom = (() => {
240
242
  });
241
243
  return { ok: problems.length === 0, problems };
242
244
  };
245
+ var SNAKE_CASE = /^[a-z][a-z0-9_]*$/;
246
+ var finite2 = (n) => typeof n === "number" && Number.isFinite(n);
247
+ function checkControl(control, problems) {
248
+ const label = `control "${control.id}"`;
249
+ if (control.kind === "number") {
250
+ if (!finite2(control.min) || !finite2(control.max) || control.min >= control.max) problems.push(`${label} needs min < max`);
251
+ if (control.step !== void 0 && !(finite2(control.step) && control.step > 0)) problems.push(`${label} needs a step > 0`);
252
+ if (!finite2(control.default) || control.default < control.min || control.default > control.max) problems.push(`${label} default ${String(control.default)} is outside ${control.min}..${control.max}`);
253
+ return;
254
+ }
255
+ if (control.kind !== "choice") return;
256
+ if (control.options.length === 0) {
257
+ problems.push(`${label} needs at least one option`);
258
+ return;
259
+ }
260
+ const seen = /* @__PURE__ */ new Set();
261
+ for (const option of control.options) {
262
+ if (seen.has(option.id)) problems.push(`${label} repeats option "${option.id}"`);
263
+ seen.add(option.id);
264
+ }
265
+ if (!seen.has(control.default)) problems.push(`${label} default "${control.default}" is not among its options`);
266
+ }
267
+ function checkBench(declared) {
268
+ const problems = [];
269
+ const ids = /* @__PURE__ */ new Set();
270
+ const claim = (kind, id) => {
271
+ if (!SNAKE_CASE.test(id)) problems.push(`${kind} id "${id}" must be snake_case`);
272
+ if (ids.has(id)) problems.push(`duplicate id "${id}" across controls, layers and metrics`);
273
+ ids.add(id);
274
+ };
275
+ for (const control of declared.controls ?? []) {
276
+ claim("control", control.id);
277
+ checkControl(control, problems);
278
+ }
279
+ for (const layer of declared.layers ?? []) claim("layer", layer.id);
280
+ for (const metric of declared.metrics ?? []) {
281
+ claim("metric", metric.id);
282
+ if (metric.max !== void 0 && !(finite2(metric.max) && metric.max > 0)) problems.push(`metric "${metric.id}" needs a max > 0`);
283
+ }
284
+ return { ok: problems.length === 0, problems };
285
+ }
243
286
 
244
287
  // ../game-destruction/dist/index.js
245
288
  var EPS = 1e-9;
@@ -2940,13 +2983,13 @@ var GameTestRoom = (() => {
2940
2983
  ...impls.size ? { impls: [...impls.values()].map((im) => ({ id: im.impl.id, position: [...im.position], rotation: [...im.rotation], data: im.impl.serialize?.() })) } : {}
2941
2984
  };
2942
2985
  },
2943
- load(snap) {
2944
- if (snap.version !== 1) throw new Error(`unsupported snapshot version ${String(snap.version)}`);
2986
+ load(snap2) {
2987
+ if (snap2.version !== 1) throw new Error(`unsupported snapshot version ${String(snap2.version)}`);
2945
2988
  objects.clear();
2946
- time = snap.time;
2947
- rng.set(snap.rng);
2948
- counter = snap.counter ?? 0;
2949
- for (const e of snap.objects) {
2989
+ time = snap2.time;
2990
+ rng.set(snap2.rng);
2991
+ counter = snap2.counter ?? 0;
2992
+ for (const e of snap2.objects) {
2950
2993
  const state = clone(e.state);
2951
2994
  const carry = {
2952
2995
  parts: new Map(
@@ -2957,7 +3000,7 @@ var GameTestRoom = (() => {
2957
3000
  };
2958
3001
  objects.set(e.id, build(clone(e.spec), e.id, state.position, state.rotation, carry));
2959
3002
  }
2960
- for (const e of snap.impls ?? []) {
3003
+ for (const e of snap2.impls ?? []) {
2961
3004
  const im = impls.get(e.id);
2962
3005
  if (!im) continue;
2963
3006
  im.position = [e.position[0], e.position[1], e.position[2]];
@@ -3644,6 +3687,8 @@ void main() {
3644
3687
  __publicField(this, "alphaMul");
3645
3688
  __publicField(this, "tag");
3646
3689
  __publicField(this, "colorHex");
3690
+ __publicField(this, "rgbStart");
3691
+ __publicField(this, "rgbEnd");
3647
3692
  __publicField(this, "bounced");
3648
3693
  const n = Math.max(1, capacity);
3649
3694
  this.pos = new Float32Array(n * 3);
@@ -3660,6 +3705,8 @@ void main() {
3660
3705
  this.alphaMul = new Float32Array(n);
3661
3706
  this.tag = new Int32Array(n);
3662
3707
  this.colorHex = new Int32Array(n);
3708
+ this.rgbStart = new Float32Array(n * 3);
3709
+ this.rgbEnd = new Float32Array(n * 3);
3663
3710
  this.bounced = new Uint8Array(n);
3664
3711
  this.geometry = new THREE.BufferGeometry();
3665
3712
  const attr = (a, size) => {
@@ -3707,6 +3754,8 @@ void main() {
3707
3754
  this.alphaMul[i] = o?.alpha ?? 1;
3708
3755
  this.tag[i] = o?.tag ?? 0;
3709
3756
  this.colorHex[i] = o?.color ?? -1;
3757
+ this.rgbStart.set(hexToRgb(o?.color ?? def.c0), i * 3);
3758
+ this.rgbEnd.set(hexToRgb(o?.color ?? def.c1), i * 3);
3710
3759
  this.bounced[i] = 0;
3711
3760
  this.siz[i] = this.size0[i];
3712
3761
  this.alp[i] = 0;
@@ -3800,13 +3849,9 @@ void main() {
3800
3849
  this.pos[i3 + 1] = y;
3801
3850
  this.pos[i3 + 2] = z;
3802
3851
  this.siz[i] = this.size0[i] * lerp(1, def.grow, t);
3803
- const c0 = this.colorHex[i] >= 0 ? this.colorHex[i] : def.c0;
3804
- const c1 = this.colorHex[i] >= 0 ? this.colorHex[i] : def.c1;
3805
- const a = hexToRgb(c0);
3806
- const b = hexToRgb(c1);
3807
- this.col[i3] = lerp(a[0], b[0], t);
3808
- this.col[i3 + 1] = lerp(a[1], b[1], t);
3809
- this.col[i3 + 2] = lerp(a[2], b[2], t);
3852
+ this.col[i3] = lerp(this.rgbStart[i3], this.rgbEnd[i3], t);
3853
+ this.col[i3 + 1] = lerp(this.rgbStart[i3 + 1], this.rgbEnd[i3 + 1], t);
3854
+ this.col[i3 + 2] = lerp(this.rgbStart[i3 + 2], this.rgbEnd[i3 + 2], t);
3810
3855
  let env = t < def.fadeIn ? t / def.fadeIn : 1 - (t - def.fadeIn) / (1 - def.fadeIn);
3811
3856
  if (def.flicker) env *= 1 - def.flicker * (0.5 + 0.5 * Math.sin(age * 38 + this.seed[i] * 50));
3812
3857
  this.alp[i] = clamp3(env, 0, 1) * def.alpha * this.alphaMul[i];
@@ -6078,6 +6123,9 @@ void main() {
6078
6123
  emit(kind, point, normal, count, speed) {
6079
6124
  this.particles.burst(kind, point, normal, count, speed ?? DEFAULT_SPEED[kind] ?? 1, kind === "smoke" ? { color: SMOKE_TONES.grey } : void 0);
6080
6125
  }
6126
+ explode(point, energy, radius) {
6127
+ this.onExplode({ type: "explode", object: "", part: "", time: this.time, point, energy, radius });
6128
+ }
6081
6129
  restore(objectId) {
6082
6130
  const homes = /* @__PURE__ */ new Set([objectId]);
6083
6131
  const self = this.bounds.get(objectId);
@@ -9275,22 +9323,22 @@ void main() {
9275
9323
  return snapshot;
9276
9324
  },
9277
9325
  load(input) {
9278
- const snap = clone2(input);
9279
- if (!snap || snap.version !== 1) throw new Error("game-fluids: unsupported snapshot");
9280
- time = snap.time;
9326
+ const snap2 = clone2(input);
9327
+ if (!snap2 || snap2.version !== 1) throw new Error("game-fluids: unsupported snapshot");
9328
+ time = snap2.time;
9281
9329
  rng = new Rng(0);
9282
- rng.state = snap.rng;
9283
- counters.container = snap.counters.container;
9284
- counters.stream = snap.counters.stream;
9285
- counters.stain = snap.counters.stain;
9330
+ rng.state = snap2.rng;
9331
+ counters.container = snap2.counters.container;
9332
+ counters.stream = snap2.counters.stream;
9333
+ counters.stain = snap2.counters.stain;
9286
9334
  containers = /* @__PURE__ */ new Map();
9287
- for (const raw of snap.containers) containers.set(raw.id, { ...raw, cols: makeColumns(raw.spec.shape) });
9335
+ for (const raw of snap2.containers) containers.set(raw.id, { ...raw, cols: makeColumns(raw.spec.shape) });
9288
9336
  surfaces = /* @__PURE__ */ new Map();
9289
- for (const s of snap.surfaces) world.addSurface(s);
9290
- counters.tap = snap.counters.tap ?? 0;
9291
- streams = new Map(snap.streams.map((s) => [s.id, s]));
9292
- taps = new Map((snap.taps ?? []).map((t) => [t.id, t]));
9293
- stains = new Map(snap.stains.map((s) => [s.id, s]));
9337
+ for (const s of snap2.surfaces) world.addSurface(s);
9338
+ counters.tap = snap2.counters.tap ?? 0;
9339
+ streams = new Map(snap2.streams.map((s) => [s.id, s]));
9340
+ taps = new Map((snap2.taps ?? []).map((t) => [t.id, t]));
9341
+ stains = new Map(snap2.stains.map((s) => [s.id, s]));
9294
9342
  queue = [];
9295
9343
  }
9296
9344
  };
@@ -11170,6 +11218,21 @@ void main() {
11170
11218
  "ui.animations": "Animations",
11171
11219
  "ui.animations_aim": "Aim at an item or hold it to see its animations.",
11172
11220
  "ui.animations_none": "This item brings no animations.",
11221
+ "mod.bench_ok": "{name}: bench ok ({controls} controls, {layers} layers, {metrics} metrics)",
11222
+ "mod.bench_fail": "{name}: bench broken: {problems}",
11223
+ "mod.metric_failed": '{name}: metric "{metric}" threw and shows n/a: {error}',
11224
+ "mod.listener_failed": "{name}: a bench listener threw: {error}",
11225
+ "ui.bench": "Bench",
11226
+ "ui.layers": "Layers",
11227
+ "ui.metrics": "Counters",
11228
+ "metric.fps": "FPS",
11229
+ "metric.frame_ms": "ms/frame",
11230
+ "metric.draw_calls": "Draw calls",
11231
+ "metric.triangles": "Triangles",
11232
+ "metric.objects": "Objects",
11233
+ "metric.fragments": "Fragments",
11234
+ "metric.particles": "Particles",
11235
+ "metric.decals": "Decals",
11173
11236
  "act.spawn": "Spawned {name} at x {x} \xB7 z {z}",
11174
11237
  "act.clear": "Cleared the room",
11175
11238
  "act.reset": "Repaired every object",
@@ -11462,6 +11525,21 @@ void main() {
11462
11525
  "ui.animations": "Anima\xE7\xF5es",
11463
11526
  "ui.animations_aim": "Mire num item ou segure-o para ver as anima\xE7\xF5es.",
11464
11527
  "ui.animations_none": "Este item n\xE3o traz anima\xE7\xF5es.",
11528
+ "mod.bench_ok": "{name}: bancada ok ({controls} controles, {layers} camadas, {metrics} contadores)",
11529
+ "mod.bench_fail": "{name}: bancada quebrada: {problems}",
11530
+ "mod.metric_failed": '{name}: o contador "{metric}" lan\xE7ou erro e mostra n/a: {error}',
11531
+ "mod.listener_failed": "{name}: um ouvinte da bancada lan\xE7ou erro: {error}",
11532
+ "ui.bench": "Bancada",
11533
+ "ui.layers": "Camadas",
11534
+ "ui.metrics": "Contadores",
11535
+ "metric.fps": "FPS",
11536
+ "metric.frame_ms": "ms/quadro",
11537
+ "metric.draw_calls": "Chamadas de desenho",
11538
+ "metric.triangles": "Tri\xE2ngulos",
11539
+ "metric.objects": "Objetos",
11540
+ "metric.fragments": "Fragmentos",
11541
+ "metric.particles": "Part\xEDculas",
11542
+ "metric.decals": "Decalques",
11465
11543
  "act.spawn": "Criou {name} em x {x} \xB7 z {z}",
11466
11544
  "act.clear": "Limpou a sala",
11467
11545
  "act.reset": "Consertou todos os objetos",
@@ -12073,6 +12151,154 @@ void main() {
12073
12151
  };
12074
12152
  }
12075
12153
 
12154
+ // src/bench.ts
12155
+ var snap = (control, value) => {
12156
+ const clamped = Math.min(control.max, Math.max(control.min, value));
12157
+ if (!control.step) return clamped;
12158
+ const stepped = control.min + Math.round((clamped - control.min) / control.step) * control.step;
12159
+ return Number(Math.min(control.max, stepped).toFixed(10));
12160
+ };
12161
+ function fitValue(control, value) {
12162
+ if (control.kind === "number") return typeof value === "number" && Number.isFinite(value) ? snap(control, value) : void 0;
12163
+ if (control.kind === "boolean") return typeof value === "boolean" ? value : void 0;
12164
+ if (control.kind === "choice") return typeof value === "string" && control.options.some((option) => option.id === value) ? value : void 0;
12165
+ return void 0;
12166
+ }
12167
+ var fromText = (control, text) => {
12168
+ if (control.kind === "number") return text.trim() === "" ? Number.NaN : Number(text);
12169
+ if (control.kind === "boolean") return text === "1" ? true : text === "0" ? false : void 0;
12170
+ return text;
12171
+ };
12172
+ var toText = (value) => typeof value === "boolean" ? value ? "1" : "0" : String(value);
12173
+ function encodeBenchState(controls, layers) {
12174
+ const pairs = [];
12175
+ for (const control of controls) if (control.value !== void 0) pairs.push(`${control.id}=${encodeURIComponent(toText(control.value))}`);
12176
+ for (const layer of layers) pairs.push(`${layer.id}=${layer.on ? "1" : "0"}`);
12177
+ return pairs.join(";");
12178
+ }
12179
+ function decodeBenchState(encoded, controls, layers) {
12180
+ const out = /* @__PURE__ */ new Map();
12181
+ for (const pair of encoded.split(";")) {
12182
+ const at = pair.indexOf("=");
12183
+ if (at <= 0) continue;
12184
+ const id = pair.slice(0, at);
12185
+ let text;
12186
+ try {
12187
+ text = decodeURIComponent(pair.slice(at + 1));
12188
+ } catch {
12189
+ continue;
12190
+ }
12191
+ const control = controls.find((c) => c.id === id);
12192
+ if (control) {
12193
+ const value = fitValue(control, fromText(control, text));
12194
+ if (value !== void 0) out.set(id, value);
12195
+ continue;
12196
+ }
12197
+ if (layers.some((layer) => layer.id === id) && (text === "1" || text === "0")) out.set(id, text === "1");
12198
+ }
12199
+ return out;
12200
+ }
12201
+ function createBenchStore(module, locale, onListenerError) {
12202
+ const declared = { controls: module.controls?.(locale) ?? [], layers: module.layers?.(locale) ?? [], metrics: module.metrics?.(locale) ?? [] };
12203
+ const controls = declared.controls.map((control) => ({ ...control, disabled: false }));
12204
+ const values = /* @__PURE__ */ new Map();
12205
+ for (const control of controls) if (control.kind !== "action") values.set(control.id, control.default);
12206
+ const layers = new Map(declared.layers.map((layer) => [layer.id, layer.default !== false]));
12207
+ const listeners = /* @__PURE__ */ new Set();
12208
+ const watchers = /* @__PURE__ */ new Set();
12209
+ const emit = (change) => {
12210
+ for (const listener of listeners) {
12211
+ try {
12212
+ listener(change);
12213
+ } catch (error) {
12214
+ onListenerError?.(error);
12215
+ }
12216
+ }
12217
+ };
12218
+ const changed = () => {
12219
+ for (const watcher of watchers) watcher();
12220
+ };
12221
+ const controlOf = (id) => controls.find((control) => control.id === id);
12222
+ const store = {
12223
+ moduleId: module.id,
12224
+ metrics: declared.metrics,
12225
+ declared,
12226
+ controls: () => controls.map((control) => ({ ...control, value: values.get(control.id) })),
12227
+ layers: () => declared.layers.map((layer) => ({ ...layer, on: layers.get(layer.id) === true })),
12228
+ value: (id) => values.get(id),
12229
+ layer: (id) => layers.get(id) === true,
12230
+ set(id, value) {
12231
+ const control = controlOf(id);
12232
+ if (!control || control.disabled) return false;
12233
+ const fitted = fitValue(control, value);
12234
+ if (fitted === void 0) return false;
12235
+ values.set(id, fitted);
12236
+ emit({ kind: "control", id, value: fitted });
12237
+ changed();
12238
+ return true;
12239
+ },
12240
+ setLayer(id, on) {
12241
+ if (!layers.has(id)) return false;
12242
+ layers.set(id, on);
12243
+ emit({ kind: "layer", id, on });
12244
+ changed();
12245
+ return true;
12246
+ },
12247
+ act(id) {
12248
+ const control = controlOf(id);
12249
+ if (!control || control.kind !== "action" || control.disabled) return false;
12250
+ emit({ kind: "action", id });
12251
+ return true;
12252
+ },
12253
+ encode: () => encodeBenchState(store.controls(), store.layers()),
12254
+ onChange(listener) {
12255
+ listeners.add(listener);
12256
+ return () => listeners.delete(listener);
12257
+ },
12258
+ watch(listener) {
12259
+ watchers.add(listener);
12260
+ return () => watchers.delete(listener);
12261
+ },
12262
+ patch(id, patch) {
12263
+ const index = controls.findIndex((control2) => control2.id === id);
12264
+ if (index < 0) return;
12265
+ let control = controls[index];
12266
+ if (patch.hidden !== void 0) control = { ...control, hidden: patch.hidden };
12267
+ if (patch.disabled !== void 0) control = { ...control, disabled: patch.disabled };
12268
+ if (patch.options && control.kind === "choice" && patch.options.length > 0) {
12269
+ control = { ...control, options: [...patch.options] };
12270
+ const offered = control.options;
12271
+ if (!offered.some((option) => option.id === values.get(id))) values.set(id, offered[0].id);
12272
+ }
12273
+ controls[index] = control;
12274
+ if (patch.value !== void 0) {
12275
+ const fitted = fitValue(control, patch.value);
12276
+ if (fitted !== void 0) values.set(id, fitted);
12277
+ }
12278
+ changed();
12279
+ },
12280
+ sendInitial() {
12281
+ for (const control of controls) {
12282
+ const value = values.get(control.id);
12283
+ if (value !== void 0) emit({ kind: "control", id: control.id, value });
12284
+ }
12285
+ for (const layer of declared.layers) emit({ kind: "layer", id: layer.id, on: layers.get(layer.id) === true });
12286
+ changed();
12287
+ },
12288
+ restore(encoded) {
12289
+ for (const [id, value] of decodeBenchState(encoded, controls, declared.layers)) {
12290
+ if (typeof value === "boolean" && layers.has(id)) layers.set(id, value);
12291
+ else values.set(id, value);
12292
+ }
12293
+ },
12294
+ dispose() {
12295
+ listeners.clear();
12296
+ watchers.clear();
12297
+ }
12298
+ };
12299
+ return store;
12300
+ }
12301
+
12076
12302
  // src/log.ts
12077
12303
  var MAX_ENTRIES = 5e3;
12078
12304
  var pad = (n, w = 2) => String(n).padStart(w, "0");
@@ -12201,6 +12427,56 @@ void main() {
12201
12427
  return log;
12202
12428
  }
12203
12429
 
12430
+ // src/metrics.ts
12431
+ var ROOM_METRICS = ["fps", "frame_ms", "draw_calls", "triangles", "objects", "fragments", "particles", "decals"];
12432
+ var WINDOW_S = 0.5;
12433
+ function createRoomMetrics(sources) {
12434
+ const wanted = new Set(sources.ids);
12435
+ const gl = sources.gl;
12436
+ let windowS = 0;
12437
+ let frames = 0;
12438
+ let fps;
12439
+ let frameMs;
12440
+ let calls;
12441
+ let triangles;
12442
+ let ownsRender = false;
12443
+ return {
12444
+ frame(dt) {
12445
+ frames++;
12446
+ windowS += dt;
12447
+ if (windowS < WINDOW_S) return;
12448
+ fps = frames / windowS;
12449
+ frameMs = windowS / frames * 1e3;
12450
+ frames = 0;
12451
+ windowS = 0;
12452
+ },
12453
+ rendered() {
12454
+ ownsRender = true;
12455
+ calls = gl.info?.render?.calls;
12456
+ triangles = gl.info?.render?.triangles;
12457
+ },
12458
+ sample() {
12459
+ const out = {};
12460
+ const put = (id, value) => {
12461
+ if (wanted.has(id) && value !== void 0) out[id] = value;
12462
+ };
12463
+ put("fps", fps);
12464
+ put("frame_ms", frameMs);
12465
+ const render = ownsRender ? { calls, triangles } : gl.info?.render;
12466
+ put("draw_calls", render?.calls);
12467
+ put("triangles", render?.triangles);
12468
+ if (!["objects", "fragments", "particles", "decals"].some((id) => wanted.has(id))) return out;
12469
+ const destruction = sources.destruction.stats();
12470
+ const fluids = sources.fluids.stats();
12471
+ put("objects", destruction.pieces);
12472
+ put("fragments", destruction.fragments);
12473
+ put("particles", destruction.particles + fluids.particles);
12474
+ put("decals", destruction.decals + fluids.stains);
12475
+ return out;
12476
+ }
12477
+ };
12478
+ }
12479
+
12204
12480
  // src/narrate.ts
12205
12481
  var SKIP_DAMAGE = /* @__PURE__ */ new Set(["fire", "heat"]);
12206
12482
  function createNarrator(c) {
@@ -13270,6 +13546,209 @@ void main() {
13270
13546
  }
13271
13547
  var SCENARIOS = scenariosFor("en");
13272
13548
 
13549
+ // src/bench-panel.ts
13550
+ var SEGMENTED_MAX = 4;
13551
+ var mk = (tag, cls, text) => {
13552
+ const e = document.createElement(tag);
13553
+ if (cls) e.className = cls;
13554
+ if (text !== void 0) e.textContent = text;
13555
+ return e;
13556
+ };
13557
+ var shapeOf = (view) => JSON.stringify([view.title, view.controls.map((c) => ({ ...c, value: void 0 })), view.layers.map((l) => [l.id, l.name])]);
13558
+ function createBenchPanel(o) {
13559
+ const { t } = o;
13560
+ const element = mk("div", "tr-panel tr-bench");
13561
+ element.style.display = "none";
13562
+ element.addEventListener("keydown", (e) => e.stopPropagation());
13563
+ const roomLine = mk("div", "sub tr-bench-room");
13564
+ const body = mk("div");
13565
+ const metricTitle = mk("div", "sub", t("ui.metrics"));
13566
+ metricTitle.style.display = "none";
13567
+ const metricList = mk("dl", "tr-metrics");
13568
+ element.append(mk("h3", void 0, t("ui.bench")), roomLine, body, metricTitle, metricList);
13569
+ let shape = "";
13570
+ let hasRoom = false;
13571
+ let hasBench = false;
13572
+ const refreshers = [];
13573
+ const metricNodes = /* @__PURE__ */ new Map();
13574
+ let metricKey = "";
13575
+ const syncDisplay = () => {
13576
+ element.style.display = hasRoom || hasBench ? "" : "none";
13577
+ };
13578
+ const numberControl = (control) => {
13579
+ const wrap = mk("label", "tr-ctl");
13580
+ const input = mk("input");
13581
+ input.type = "range";
13582
+ input.min = String(control.min);
13583
+ input.max = String(control.max);
13584
+ input.step = control.step ? String(control.step) : "any";
13585
+ input.disabled = control.disabled;
13586
+ const reading = mk("output");
13587
+ input.oninput = () => o.onControl(control.id, Number(input.value));
13588
+ wrap.append(mk("span", void 0, control.name), input, reading);
13589
+ refreshers.push((view) => {
13590
+ const value = view.controls.find((c) => c.id === control.id)?.value;
13591
+ if (typeof value !== "number") return;
13592
+ if (Number(input.value) !== value) input.value = String(value);
13593
+ reading.textContent = `${o.num(value, control.step && control.step >= 1 ? 0 : 2)}${control.unit ? ` ${control.unit}` : ""}`;
13594
+ });
13595
+ return wrap;
13596
+ };
13597
+ const booleanControl = (control) => {
13598
+ const wrap = mk("label", "tr-ctl");
13599
+ const box2 = mk("input");
13600
+ box2.type = "checkbox";
13601
+ box2.disabled = control.disabled;
13602
+ box2.onchange = () => o.onControl(control.id, box2.checked);
13603
+ wrap.append(box2, document.createTextNode(` ${control.name}`));
13604
+ refreshers.push((view) => {
13605
+ box2.checked = view.controls.find((c) => c.id === control.id)?.value === true;
13606
+ });
13607
+ return wrap;
13608
+ };
13609
+ const choiceControl = (control) => {
13610
+ const wrap = mk("div", "tr-ctl");
13611
+ wrap.appendChild(mk("span", void 0, control.name));
13612
+ if (control.options.length <= SEGMENTED_MAX) {
13613
+ const group = mk("div", "tr-seg");
13614
+ group.setAttribute("role", "group");
13615
+ const buttons = control.options.map((option) => {
13616
+ const b = mk("button", "tr-btn", option.name);
13617
+ b.type = "button";
13618
+ b.disabled = control.disabled;
13619
+ b.onclick = () => o.onControl(control.id, option.id);
13620
+ group.appendChild(b);
13621
+ return { b, id: option.id };
13622
+ });
13623
+ wrap.appendChild(group);
13624
+ refreshers.push((view) => {
13625
+ const value = view.controls.find((c) => c.id === control.id)?.value;
13626
+ for (const { b, id } of buttons) {
13627
+ b.setAttribute("aria-pressed", String(id === value));
13628
+ b.classList.toggle("on", id === value);
13629
+ }
13630
+ });
13631
+ return wrap;
13632
+ }
13633
+ const select = mk("select");
13634
+ select.disabled = control.disabled;
13635
+ for (const option of control.options) {
13636
+ const op = mk("option", void 0, option.name);
13637
+ op.value = option.id;
13638
+ select.appendChild(op);
13639
+ }
13640
+ select.onchange = () => o.onControl(control.id, select.value);
13641
+ wrap.appendChild(select);
13642
+ refreshers.push((view) => {
13643
+ const value = view.controls.find((c) => c.id === control.id)?.value;
13644
+ if (typeof value === "string") select.value = value;
13645
+ });
13646
+ return wrap;
13647
+ };
13648
+ const actionControl = (control) => {
13649
+ const b = mk("button", "tr-btn", control.name);
13650
+ b.type = "button";
13651
+ b.disabled = control.disabled;
13652
+ b.onclick = () => o.onAction(control.id);
13653
+ return b;
13654
+ };
13655
+ const controlElement = (control) => {
13656
+ const el = control.kind === "number" ? numberControl(control) : control.kind === "boolean" ? booleanControl(control) : control.kind === "choice" ? choiceControl(control) : actionControl(control);
13657
+ if (control.hint) el.title = control.hint;
13658
+ return el;
13659
+ };
13660
+ const layerList = (layers) => {
13661
+ const wrap = mk("div", "tr-layers");
13662
+ wrap.appendChild(mk("div", "sub", t("ui.layers")));
13663
+ for (const layer of layers) {
13664
+ const label = mk("label", "tr-ctl");
13665
+ const box2 = mk("input");
13666
+ box2.type = "checkbox";
13667
+ box2.onchange = () => o.onLayer(layer.id, box2.checked);
13668
+ label.append(box2, document.createTextNode(` ${layer.name}`));
13669
+ wrap.appendChild(label);
13670
+ refreshers.push((view) => {
13671
+ box2.checked = view.layers.find((l) => l.id === layer.id)?.on === true;
13672
+ });
13673
+ }
13674
+ return wrap;
13675
+ };
13676
+ const rebuild = (view) => {
13677
+ body.textContent = "";
13678
+ refreshers.length = 0;
13679
+ body.appendChild(mk("div", "sub", view.title));
13680
+ const list = mk("div", "tr-ctls");
13681
+ for (const control of view.controls) if (!control.hidden) list.appendChild(controlElement(control));
13682
+ body.appendChild(list);
13683
+ if (view.layers.length) body.appendChild(layerList(view.layers));
13684
+ };
13685
+ const renderMetrics = (rows) => {
13686
+ metricList.textContent = "";
13687
+ metricNodes.clear();
13688
+ metricTitle.style.display = rows.length ? "" : "none";
13689
+ for (const row of rows) {
13690
+ const dd = mk("dd");
13691
+ const text = mk("span", void 0, row.text);
13692
+ dd.appendChild(text);
13693
+ let bar = null;
13694
+ if (row.ratio !== void 0) {
13695
+ bar = mk("div", "tr-bar");
13696
+ bar.appendChild(mk("i"));
13697
+ dd.appendChild(bar);
13698
+ }
13699
+ metricList.append(mk("dt", void 0, row.name), dd);
13700
+ metricNodes.set(row.id, { text, bar });
13701
+ }
13702
+ };
13703
+ const fillBar = (bar, ratio) => {
13704
+ bar.className = `tr-bar${ratio > 1 ? " bad" : ratio >= 0.8 ? " warn" : ""}`;
13705
+ const fill = bar.firstElementChild;
13706
+ fill.style.width = `${Math.round(Math.max(0, Math.min(1, ratio)) * 100)}%`;
13707
+ };
13708
+ return {
13709
+ element,
13710
+ show(view) {
13711
+ hasBench = view !== null;
13712
+ syncDisplay();
13713
+ if (!view) {
13714
+ shape = "";
13715
+ body.textContent = "";
13716
+ refreshers.length = 0;
13717
+ renderMetrics([]);
13718
+ metricKey = "";
13719
+ return;
13720
+ }
13721
+ const next = shapeOf(view);
13722
+ if (next !== shape) {
13723
+ shape = next;
13724
+ rebuild(view);
13725
+ }
13726
+ for (const refresh of refreshers) refresh(view);
13727
+ },
13728
+ metrics(rows) {
13729
+ hasRoom = rows.room.length > 0;
13730
+ syncDisplay();
13731
+ roomLine.textContent = rows.room.map((row) => `${row.name} ${row.text}`).join(" \xB7 ");
13732
+ const key = rows.module.map((row) => `${row.id}:${row.ratio !== void 0}`).join("|");
13733
+ if (key !== metricKey) {
13734
+ metricKey = key;
13735
+ renderMetrics(rows.module);
13736
+ }
13737
+ for (const row of rows.module) {
13738
+ const node = metricNodes.get(row.id);
13739
+ if (!node) continue;
13740
+ node.text.textContent = row.text;
13741
+ if (node.bar && row.ratio !== void 0) fillBar(node.bar, row.ratio);
13742
+ }
13743
+ },
13744
+ get visible() {
13745
+ if (element.style.display === "none") return false;
13746
+ const parent = element.parentElement;
13747
+ return !parent || getComputedStyle(parent).display !== "none";
13748
+ }
13749
+ };
13750
+ }
13751
+
13273
13752
  // src/ui.ts
13274
13753
  var CSS = `
13275
13754
  .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}
@@ -13322,6 +13801,13 @@ void main() {
13322
13801
  .tr-cat{flex:0 1 auto;max-height:40%}
13323
13802
  .tr-anim{flex:0 0 auto;max-height:18%}
13324
13803
  .tr-anim .list{display:flex;flex-wrap:wrap;gap:4px}.tr-anim .sub{color:#9aa3b2;margin-bottom:4px}
13804
+ .tr-bench{flex:0 1 auto;max-height:34%}
13805
+ .tr-bench .sub{color:#9aa3b2;margin-bottom:4px}
13806
+ .tr-ctls{display:flex;flex-direction:column;gap:5px}
13807
+ .tr-ctl{display:flex;flex-wrap:wrap;align-items:center;gap:6px}.tr-ctl input[type=range]{flex:1;min-width:90px}.tr-ctl output{min-width:48px;text-align:right;color:#cfd3da}
13808
+ .tr-seg{display:inline-flex;flex-wrap:wrap;gap:2px}
13809
+ .tr-layers{margin-top:6px;display:flex;flex-direction:column;gap:2px}
13810
+ .tr-metrics{display:grid;grid-template-columns:auto 1fr;gap:2px 8px;margin:6px 0 0}.tr-metrics dt{color:#9aa3b2}.tr-metrics dd{margin:0}
13325
13811
  .tr-tests{flex:1 1 auto;min-height:100px}
13326
13812
  .tr-tests .list{display:flex;flex-direction:column;gap:3px;margin-top:6px}.tr-tests .list .tr-btn{text-align:left}
13327
13813
  .tr-row{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px;align-items:center}
@@ -13330,7 +13816,7 @@ void main() {
13330
13816
  @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}}
13331
13817
  `;
13332
13818
  var fold = (text) => text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
13333
- var mk = (tag, cls, text) => {
13819
+ var mk2 = (tag, cls, text) => {
13334
13820
  const e = document.createElement(tag);
13335
13821
  if (cls) e.className = cls;
13336
13822
  if (text !== void 0) e.textContent = text;
@@ -13338,42 +13824,42 @@ void main() {
13338
13824
  };
13339
13825
  function createUi(o) {
13340
13826
  const { t } = o;
13341
- const style = mk("style");
13827
+ const style = mk2("style");
13342
13828
  style.textContent = CSS;
13343
- const root = mk("div", "tr-root");
13829
+ const root = mk2("div", "tr-root");
13344
13830
  const pos = getComputedStyle?.(o.container)?.position;
13345
13831
  if (!pos || pos === "static") o.container.style.position = "relative";
13346
13832
  root.appendChild(style);
13347
13833
  o.container.appendChild(root);
13348
- const cross4 = mk("div", "tr-cross");
13349
- cross4.appendChild(mk("i"));
13350
- const top = mk("div", "tr-top");
13351
- const toolBox = mk("div", "tr-tool");
13352
- const status = mk("div", "tr-status");
13353
- const prog = mk("div", "tr-prog");
13354
- const progLabel = mk("b");
13355
- const progBar = mk("div", "tr-bar");
13356
- const progFill = mk("i");
13834
+ const cross4 = mk2("div", "tr-cross");
13835
+ cross4.appendChild(mk2("i"));
13836
+ const top = mk2("div", "tr-top");
13837
+ const toolBox = mk2("div", "tr-tool");
13838
+ const status = mk2("div", "tr-status");
13839
+ const prog = mk2("div", "tr-prog");
13840
+ const progLabel = mk2("b");
13841
+ const progBar = mk2("div", "tr-bar");
13842
+ const progFill = mk2("i");
13357
13843
  progBar.appendChild(progFill);
13358
13844
  prog.append(progLabel, progBar);
13359
13845
  top.append(toolBox, status, prog);
13360
- const bottom = mk("div", "tr-bottom");
13361
- const held = mk("div", "tr-held");
13362
- const hotbar = mk("div", "tr-hotbar");
13846
+ const bottom = mk2("div", "tr-bottom");
13847
+ const held = mk2("div", "tr-held");
13848
+ const hotbar = mk2("div", "tr-hotbar");
13363
13849
  bottom.append(held, hotbar);
13364
- const help = mk("div", "tr-help");
13850
+ const help = mk2("div", "tr-help");
13365
13851
  help.textContent = t("ui.help");
13366
- const left = mk("div", "tr-left");
13367
- const insp = mk("div", "tr-panel tr-insp");
13368
- const logPanel = mk("div", "tr-panel tr-log");
13852
+ const left = mk2("div", "tr-left");
13853
+ const insp = mk2("div", "tr-panel tr-insp");
13854
+ const logPanel = mk2("div", "tr-panel tr-log");
13369
13855
  left.append(insp, logPanel);
13370
- const logTitle = mk("h3", void 0, t("ui.log"));
13371
- const logBar = mk("div", "tr-logbar");
13372
- const logBody = mk("div", "tr-logbody");
13856
+ const logTitle = mk2("h3", void 0, t("ui.log"));
13857
+ const logBar = mk2("div", "tr-logbar");
13858
+ const logBody = mk2("div", "tr-logbody");
13373
13859
  const filters = { action: true, event: true, check: true };
13374
13860
  const filterBtns = [];
13375
13861
  for (const ch of ["action", "event", "check"]) {
13376
- const b = mk("button", "tr-btn on", t(`ui.filter.${ch}`));
13862
+ const b = mk2("button", "tr-btn on", t(`ui.filter.${ch}`));
13377
13863
  b.type = "button";
13378
13864
  b.onclick = () => {
13379
13865
  filters[ch] = !filters[ch];
@@ -13384,8 +13870,8 @@ void main() {
13384
13870
  logBar.appendChild(b);
13385
13871
  }
13386
13872
  let autoscroll = true;
13387
- const asLabel = mk("label");
13388
- const asBox = mk("input");
13873
+ const asLabel = mk2("label");
13874
+ const asBox = mk2("input");
13389
13875
  asBox.type = "checkbox";
13390
13876
  asBox.checked = true;
13391
13877
  asBox.onchange = () => {
@@ -13394,7 +13880,7 @@ void main() {
13394
13880
  asLabel.append(asBox, document.createTextNode(" " + t("ui.autoscroll")));
13395
13881
  logBar.appendChild(asLabel);
13396
13882
  const logBtn = (label, fn) => {
13397
- const b = mk("button", "tr-btn", label);
13883
+ const b = mk2("button", "tr-btn", label);
13398
13884
  b.type = "button";
13399
13885
  b.onclick = fn;
13400
13886
  logBar.appendChild(b);
@@ -13402,7 +13888,7 @@ void main() {
13402
13888
  logBtn(t("ui.copy_json"), () => {
13403
13889
  const text = o.log.toJSON();
13404
13890
  const select = () => {
13405
- const ta = mk("textarea");
13891
+ const ta = mk2("textarea");
13406
13892
  ta.value = text;
13407
13893
  ta.setAttribute("readonly", "");
13408
13894
  ta.style.cssText = "position:fixed;left:8px;bottom:8px;width:min(480px,80vw);height:120px;z-index:99999";
@@ -13436,12 +13922,12 @@ void main() {
13436
13922
  const fill = (n, e) => {
13437
13923
  n.className = lineClass(e);
13438
13924
  n.textContent = "";
13439
- const tm = mk("time", void 0, fmtTime(e.t));
13925
+ const tm = mk2("time", void 0, fmtTime(e.t));
13440
13926
  n.append(tm, document.createTextNode(e.text));
13441
13927
  };
13442
13928
  const addNode = (e) => {
13443
13929
  if (!filters[e.channel]) return;
13444
- const n = mk("div");
13930
+ const n = mk2("div");
13445
13931
  fill(n, e);
13446
13932
  nodes.set(e.seq, n);
13447
13933
  logBody.appendChild(n);
@@ -13463,17 +13949,18 @@ void main() {
13463
13949
  if (n) fill(n, e);
13464
13950
  } else addNode(e);
13465
13951
  });
13466
- const right = mk("div", "tr-right");
13467
- const cat = mk("div", "tr-panel tr-cat");
13468
- const anim = mk("div", "tr-panel tr-anim");
13469
- const tests = mk("div", "tr-panel tr-tests");
13470
- right.append(cat, anim, tests);
13471
- cat.appendChild(mk("h3", void 0, t("ui.catalog")));
13472
- const itemSearch = mk("input", "tr-search");
13952
+ const right = mk2("div", "tr-right");
13953
+ const cat = mk2("div", "tr-panel tr-cat");
13954
+ const anim = mk2("div", "tr-panel tr-anim");
13955
+ const tests = mk2("div", "tr-panel tr-tests");
13956
+ const benchPanel = createBenchPanel({ t, num: (n, d) => num(n, d), onControl: o.onControl, onLayer: o.onLayer, onAction: o.onAction });
13957
+ right.append(cat, anim, benchPanel.element, tests);
13958
+ cat.appendChild(mk2("h3", void 0, t("ui.catalog")));
13959
+ const itemSearch = mk2("input", "tr-search");
13473
13960
  itemSearch.type = "search";
13474
13961
  itemSearch.placeholder = t("ui.search_items");
13475
- const tabs = mk("div", "tr-tabs");
13476
- const items = mk("div", "tr-items");
13962
+ const tabs = mk2("div", "tr-tabs");
13963
+ const items = mk2("div", "tr-items");
13477
13964
  cat.append(itemSearch, tabs, items);
13478
13965
  let activeGroup = "";
13479
13966
  const visibleItems = () => {
@@ -13485,17 +13972,17 @@ void main() {
13485
13972
  items.textContent = "";
13486
13973
  const searching = itemSearch.value.trim() !== "";
13487
13974
  const found = visibleItems();
13488
- if (!found.length) items.appendChild(mk("div", "tr-empty", t("ui.no_results")));
13975
+ if (!found.length) items.appendChild(mk2("div", "tr-empty", t("ui.no_results")));
13489
13976
  for (const { item: it, group } of found) {
13490
- const wrap = mk("div", "tr-item");
13491
- const b = mk("button", "tr-btn", it.name);
13492
- if (searching) b.appendChild(mk("small", void 0, group));
13977
+ const wrap = mk2("div", "tr-item");
13978
+ const b = mk2("button", "tr-btn", it.name);
13979
+ if (searching) b.appendChild(mk2("small", void 0, group));
13493
13980
  b.type = "button";
13494
13981
  b.title = it.note ?? it.name;
13495
13982
  b.onclick = () => o.onSpawn(it.id);
13496
13983
  wrap.appendChild(b);
13497
13984
  if (it.weapon) {
13498
- const w = mk("button", "tr-btn", "\u2694");
13985
+ const w = mk2("button", "tr-btn", "\u2694");
13499
13986
  w.type = "button";
13500
13987
  w.title = t("ui.equip");
13501
13988
  w.onclick = () => o.onEquip(it.id);
@@ -13509,7 +13996,7 @@ void main() {
13509
13996
  const groupNames = [...o.groups().keys()];
13510
13997
  if (!groupNames.includes(activeGroup)) activeGroup = groupNames[0] ?? "";
13511
13998
  for (const g of groupNames) {
13512
- const b = mk("button", g === activeGroup ? "tr-btn on" : "tr-btn", g);
13999
+ const b = mk2("button", g === activeGroup ? "tr-btn on" : "tr-btn", g);
13513
14000
  b.type = "button";
13514
14001
  b.onclick = () => {
13515
14002
  activeGroup = g;
@@ -13522,9 +14009,9 @@ void main() {
13522
14009
  renderTabs();
13523
14010
  itemSearch.oninput = renderItems;
13524
14011
  for (const box2 of [itemSearch]) box2.addEventListener("keydown", (e) => e.stopPropagation());
13525
- const controls = mk("div", "tr-row");
14012
+ const controls = mk2("div", "tr-row");
13526
14013
  const ctl = (label, fn) => {
13527
- const b = mk("button", "tr-btn", label);
14014
+ const b = mk2("button", "tr-btn", label);
13528
14015
  b.type = "button";
13529
14016
  b.onclick = () => fn(b);
13530
14017
  controls.appendChild(b);
@@ -13545,29 +14032,29 @@ void main() {
13545
14032
  o.onFast(fast);
13546
14033
  });
13547
14034
  const tapBtn = ctl(t("ui.tap"), () => o.onTap());
13548
- const qSel = mk("select");
14035
+ const qSel = mk2("select");
13549
14036
  for (const q of ["low", "medium", "high"]) {
13550
- const op = mk("option", void 0, t(`ui.quality.${q}`));
14037
+ const op = mk2("option", void 0, t(`ui.quality.${q}`));
13551
14038
  op.value = q;
13552
14039
  qSel.appendChild(op);
13553
14040
  }
13554
14041
  qSel.value = o.quality;
13555
14042
  qSel.onchange = () => o.onQuality(qSel.value);
13556
- const qLabel = mk("label", void 0, t("ui.quality") + " ");
14043
+ const qLabel = mk2("label", void 0, t("ui.quality") + " ");
13557
14044
  qLabel.appendChild(qSel);
13558
14045
  controls.appendChild(qLabel);
13559
- const count = mk("span");
14046
+ const count = mk2("span");
13560
14047
  controls.appendChild(count);
13561
14048
  cat.appendChild(controls);
13562
- tests.appendChild(mk("h3", void 0, t("ui.tests")));
13563
- const runAll = mk("button", "tr-btn", t("ui.run_all"));
14049
+ tests.appendChild(mk2("h3", void 0, t("ui.tests")));
14050
+ const runAll = mk2("button", "tr-btn", t("ui.run_all"));
13564
14051
  runAll.type = "button";
13565
- const summary = mk("div", "tr-summary");
13566
- const testSearch = mk("input", "tr-search");
14052
+ const summary = mk2("div", "tr-summary");
14053
+ const testSearch = mk2("input", "tr-search");
13567
14054
  testSearch.type = "search";
13568
14055
  testSearch.placeholder = t("ui.search_tests");
13569
14056
  testSearch.addEventListener("keydown", (e) => e.stopPropagation());
13570
- const list = mk("div", "list");
14057
+ const list = mk2("div", "list");
13571
14058
  tests.append(runAll, summary, testSearch, list);
13572
14059
  testSearch.oninput = () => {
13573
14060
  const term = fold(testSearch.value.trim());
@@ -13590,7 +14077,7 @@ void main() {
13590
14077
  list.textContent = "";
13591
14078
  scBtns.clear();
13592
14079
  for (const s of o.scenarios()) {
13593
- const b = mk("button", "tr-btn", s.name);
14080
+ const b = mk2("button", "tr-btn", s.name);
13594
14081
  b.type = "button";
13595
14082
  b.onclick = async () => {
13596
14083
  if (busy) return;
@@ -13616,7 +14103,7 @@ void main() {
13616
14103
  busy = false;
13617
14104
  }
13618
14105
  };
13619
- const toggle = mk("button", "tr-btn tr-toggle", t("ui.panel"));
14106
+ const toggle = mk2("button", "tr-btn tr-toggle", t("ui.panel"));
13620
14107
  toggle.type = "button";
13621
14108
  const togglePanels = () => {
13622
14109
  root.classList.toggle("open");
@@ -13625,15 +14112,15 @@ void main() {
13625
14112
  toggle.onclick = togglePanels;
13626
14113
  root.append(cross4, top, bottom, help, left, right, toggle);
13627
14114
  const bar = (value, max, cls) => {
13628
- const b = mk("div", `tr-bar ${cls}`);
13629
- const i = mk("i");
14115
+ const b = mk2("div", `tr-bar ${cls}`);
14116
+ const i = mk2("i");
13630
14117
  i.style.width = `${Math.max(0, Math.min(100, value / Math.max(1e-9, max) * 100))}%`;
13631
14118
  b.appendChild(i);
13632
14119
  return b;
13633
14120
  };
13634
14121
  const row = (k, v) => {
13635
- const r = mk("div", "r");
13636
- r.append(mk("span", void 0, k), mk("span", void 0, v));
14122
+ const r = mk2("div", "r");
14123
+ r.append(mk2("span", void 0, k), mk2("span", void 0, v));
13637
14124
  return r;
13638
14125
  };
13639
14126
  const num = (n, d = 1) => {
@@ -13642,12 +14129,12 @@ void main() {
13642
14129
  };
13643
14130
  const renderInspector = (i) => {
13644
14131
  insp.textContent = "";
13645
- insp.appendChild(mk("h3", void 0, t("ui.inspector")));
14132
+ insp.appendChild(mk2("h3", void 0, t("ui.inspector")));
13646
14133
  if (!i) {
13647
- insp.appendChild(mk("div", void 0, t("ui.aim_at_something")));
14134
+ insp.appendChild(mk2("div", void 0, t("ui.aim_at_something")));
13648
14135
  return;
13649
14136
  }
13650
- insp.appendChild(mk("div", "t", `${i.object} \u203A ${i.part}`));
14137
+ insp.appendChild(mk2("div", "t", `${i.object} \u203A ${i.part}`));
13651
14138
  insp.appendChild(row(t("ins.material"), i.material));
13652
14139
  insp.appendChild(row(t("ins.status"), i.status + (i.burning ? ` \xB7 ${t("ins.burning")}` : "")));
13653
14140
  insp.appendChild(row(t("ins.hp"), `${num(i.hp, 0)} / ${num(i.maxHp, 0)}`));
@@ -13666,30 +14153,30 @@ void main() {
13666
14153
  insp.appendChild(bar(Math.min(i.stress, 1.2), 1.2, i.stress > 1 ? "bad" : i.stress > 0.7 ? "warn" : "hp"));
13667
14154
  }
13668
14155
  if (i.joints.length) {
13669
- insp.appendChild(mk("div", void 0, t("ins.joints") + ":"));
14156
+ insp.appendChild(mk2("div", void 0, t("ins.joints") + ":"));
13670
14157
  for (const j of i.joints) {
13671
14158
  const tools = j.tools.length ? j.tools.join(", ") : t("tool.none");
13672
- insp.appendChild(mk("div", void 0, `\u2022 ${j.kind} (${j.id})${j.open ? ` \xB7 ${t("ins.open")}` : ` \u2192 ${tools}`}`));
14159
+ insp.appendChild(mk2("div", void 0, `\u2022 ${j.kind} (${j.id})${j.open ? ` \xB7 ${t("ins.open")}` : ` \u2192 ${tools}`}`));
13673
14160
  }
13674
14161
  }
13675
- if (i.note) insp.appendChild(mk("div", void 0, i.note));
14162
+ if (i.note) insp.appendChild(mk2("div", void 0, i.note));
13676
14163
  };
13677
14164
  let lastAnim = "";
13678
14165
  const renderAnimations = (a) => {
13679
14166
  anim.textContent = "";
13680
- anim.appendChild(mk("h3", void 0, t("ui.animations")));
14167
+ anim.appendChild(mk2("h3", void 0, t("ui.animations")));
13681
14168
  if (!a) {
13682
- anim.appendChild(mk("div", "sub", t("ui.animations_aim")));
14169
+ anim.appendChild(mk2("div", "sub", t("ui.animations_aim")));
13683
14170
  return;
13684
14171
  }
13685
- anim.appendChild(mk("div", "sub", a.name));
14172
+ anim.appendChild(mk2("div", "sub", a.name));
13686
14173
  if (a.clips.length === 0) {
13687
- anim.appendChild(mk("div", "sub", t("ui.animations_none")));
14174
+ anim.appendChild(mk2("div", "sub", t("ui.animations_none")));
13688
14175
  return;
13689
14176
  }
13690
- const l = mk("div", "list");
14177
+ const l = mk2("div", "list");
13691
14178
  for (const c of a.clips) {
13692
- 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)`);
14179
+ const b = mk2("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)`);
13693
14180
  b.type = "button";
13694
14181
  b.onclick = () => o.onPlay(a.objectId, c.id);
13695
14182
  l.appendChild(b);
@@ -13706,7 +14193,7 @@ void main() {
13706
14193
  hud(s) {
13707
14194
  toolBox.textContent = "";
13708
14195
  toolBox.append(document.createTextNode(s.toolName));
13709
- if (s.ammo) toolBox.append(mk("small", void 0, s.ammo));
14196
+ if (s.ammo) toolBox.append(mk2("small", void 0, s.ammo));
13710
14197
  if (s.status) status.textContent = s.status;
13711
14198
  else if (!toastTimer) status.textContent = "";
13712
14199
  if (s.progress) {
@@ -13727,7 +14214,7 @@ void main() {
13727
14214
  ];
13728
14215
  if (s.held.weapon) keys.push(["H", t("hud.equip", { weapon: s.held.weapon })]);
13729
14216
  for (const [k, label] of keys) {
13730
- held.append(mk("kbd", void 0, k), document.createTextNode(` ${label} `));
14217
+ held.append(mk2("kbd", void 0, k), document.createTextNode(` ${label} `));
13731
14218
  }
13732
14219
  } else held.style.display = "none";
13733
14220
  const key = s.slots.join("|") + s.page;
@@ -13737,9 +14224,9 @@ void main() {
13737
14224
  slotsEl.length = 0;
13738
14225
  hudPage = s.page - 1;
13739
14226
  s.slots.forEach((name, i) => {
13740
- const b = mk("button", "tr-slot");
14227
+ const b = mk2("button", "tr-slot");
13741
14228
  b.type = "button";
13742
- b.append(mk("b", void 0, String((i + 1) % 10)), document.createTextNode(name));
14229
+ b.append(mk2("b", void 0, String((i + 1) % 10)), document.createTextNode(name));
13743
14230
  b.title = name;
13744
14231
  b.onclick = () => o.onTool(hudPage * 10 + i);
13745
14232
  hotbar.appendChild(b);
@@ -13777,6 +14264,11 @@ void main() {
13777
14264
  renderTabs();
13778
14265
  },
13779
14266
  togglePanels,
14267
+ bench: benchPanel.show,
14268
+ metrics: benchPanel.metrics,
14269
+ get benchVisible() {
14270
+ return benchPanel.visible;
14271
+ },
13780
14272
  dispose() {
13781
14273
  offLog();
13782
14274
  if (toastTimer) clearTimeout(toastTimer);
@@ -13789,6 +14281,7 @@ void main() {
13789
14281
 
13790
14282
  // src/room.ts
13791
14283
  var FIXED_DT = 1 / 60;
14284
+ var METRICS_INTERVAL_S = 0.25;
13792
14285
  var v3 = (v) => [v.x, v.y, v.z];
13793
14286
  var dist3 = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
13794
14287
  function createTestRoom(options) {
@@ -14420,7 +14913,7 @@ void main() {
14420
14913
  }
14421
14914
  });
14422
14915
  player.kick(recoil[0], recoil[1]);
14423
- emitHost({ type: "shot", weapon: toolId, shots: shots.length, recoil });
14916
+ emitHost({ type: "shot", weapon: toolId, shots: shots.length, recoil, rounds: shots.filter((s) => s.pellet === 0).length, mode: tool.mode });
14424
14917
  const f = first;
14425
14918
  const d = f ? num(f.distance, 1) : "";
14426
14919
  let text;
@@ -14602,6 +15095,7 @@ void main() {
14602
15095
  const detonate = (p, at, impact) => {
14603
15096
  const w = toolName(p.weapon);
14604
15097
  const results = world.explode(at, p.energy, p.radius, "player");
15098
+ renderer.explode(at, p.energy, p.radius);
14605
15099
  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 } });
14606
15100
  if (p.fire) {
14607
15101
  fluids.splash("fuel", [at[0], Math.max(at[1], groundAt(at[0], at[2])), at[2]], 0.5);
@@ -14945,9 +15439,12 @@ void main() {
14945
15439
  say(t("hud.reloading"));
14946
15440
  }
14947
15441
  break;
14948
- case "KeyQ":
14949
- tool.cycleMode();
15442
+ case "KeyQ": {
15443
+ const before = tool.mode;
15444
+ const mode = tool.cycleMode();
15445
+ if (mode !== before) emitHost({ type: "mode", weapon: toolId, mode });
14950
15446
  break;
15447
+ }
14951
15448
  case "KeyZ":
14952
15449
  tiltKey = 1;
14953
15450
  break;
@@ -15035,6 +15532,10 @@ void main() {
15035
15532
  let timeScale = 1;
15036
15533
  let fastForward = false;
15037
15534
  let hudAcc = 1;
15535
+ let metricsAcc = 0;
15536
+ let ownsLoop = false;
15537
+ const roomMetricIds = options.metrics === false ? [] : options.metrics ?? ROOM_METRICS;
15538
+ const roomMetrics = createRoomMetrics({ gl, destruction: renderer, fluids: fluidView, ids: roomMetricIds });
15038
15539
  let qualityName = quality;
15039
15540
  const syncContainers = (sdt) => {
15040
15541
  for (const e of entries.values()) {
@@ -15098,6 +15599,7 @@ void main() {
15098
15599
  flush();
15099
15600
  };
15100
15601
  const update = (dt) => {
15602
+ if (!ownsLoop) roomMetrics.frame(dt);
15101
15603
  player.update(dt);
15102
15604
  if (tiltKey || heldTilt > 0) {
15103
15605
  heldTilt = Math.max(0, Math.min(2.4, heldTilt + (tiltKey ? 2.2 : -3) * dt));
@@ -15116,6 +15618,11 @@ void main() {
15116
15618
  hudAcc = 0;
15117
15619
  ui.hud(hudState());
15118
15620
  }
15621
+ metricsAcc += dt;
15622
+ if (metricsAcc >= METRICS_INTERVAL_S && ui) {
15623
+ metricsAcc = 0;
15624
+ if (ui.benchVisible) ui.metrics(metricRows());
15625
+ }
15119
15626
  };
15120
15627
  const hudState = () => {
15121
15628
  const page = Math.floor(toolIndex / 10);
@@ -15231,7 +15738,57 @@ void main() {
15231
15738
  log.add({ channel: "check", type: ok ? "pass" : "fail", text: `${ok ? "\u2713" : "\u2717"} ${text}`, itemId, data: { ok, ...data } });
15232
15739
  };
15233
15740
  const modules = /* @__PURE__ */ new Map();
15234
- const detachers = [];
15741
+ const detachers = /* @__PURE__ */ new Map();
15742
+ const benches = /* @__PURE__ */ new Map();
15743
+ let activeBench = null;
15744
+ const lastBench = () => [...benches.keys()].pop() ?? null;
15745
+ const benchView = () => {
15746
+ const store = activeBench ? benches.get(activeBench) : void 0;
15747
+ if (!store) return null;
15748
+ return { title: modules.get(store.moduleId)?.name ?? store.moduleId, controls: store.controls(), layers: store.layers() };
15749
+ };
15750
+ const METRIC_UNAVAILABLE = "n/a";
15751
+ const metricText = (value, digits, unit) => typeof value === "number" ? `${num(value, digits)}${unit ? ` ${unit}` : ""}` : value ?? METRIC_UNAVAILABLE;
15752
+ const reportedFailures = /* @__PURE__ */ new Set();
15753
+ const reportOnce = (key, text) => {
15754
+ if (reportedFailures.has(key)) return;
15755
+ reportedFailures.add(key);
15756
+ host.log(text);
15757
+ };
15758
+ const failureText = (error) => error instanceof Error ? error.message : String(error);
15759
+ const moduleMetrics = () => {
15760
+ const store = activeBench ? benches.get(activeBench) : void 0;
15761
+ const out = {};
15762
+ for (const metric of store?.metrics ?? []) {
15763
+ try {
15764
+ out[metric.id] = metric.read();
15765
+ } catch (error) {
15766
+ out[metric.id] = METRIC_UNAVAILABLE;
15767
+ reportOnce(`${store?.moduleId}:metric:${metric.id}`, t("mod.metric_failed", { name: modules.get(store?.moduleId ?? "")?.name ?? "", metric: metric.id, error: failureText(error) }));
15768
+ }
15769
+ }
15770
+ return out;
15771
+ };
15772
+ const metricRows = () => {
15773
+ const sampled = roomMetrics.sample();
15774
+ const room2 = roomMetricIds.flatMap((id) => {
15775
+ const value = sampled[id];
15776
+ return value === void 0 ? [] : [{ id, name: t(`metric.${id}`), text: num(value, id === "frame_ms" ? 1 : 0) }];
15777
+ });
15778
+ const values = moduleMetrics();
15779
+ const declared = (activeBench ? benches.get(activeBench)?.metrics : void 0) ?? [];
15780
+ const module = declared.map((metric) => {
15781
+ const value = values[metric.id];
15782
+ const ratio = metric.max && typeof value === "number" ? value / metric.max : void 0;
15783
+ return { id: metric.id, name: metric.name, text: metricText(value, metric.digits ?? 0, metric.unit), ...ratio === void 0 ? {} : { ratio } };
15784
+ });
15785
+ return { room: room2, module };
15786
+ };
15787
+ const showBench = () => {
15788
+ if (!ui) return;
15789
+ ui.bench(benchView());
15790
+ ui.metrics(metricRows());
15791
+ };
15235
15792
  const hostListeners = /* @__PURE__ */ new Set();
15236
15793
  const frameListeners = /* @__PURE__ */ new Set();
15237
15794
  let reloadingSeen = false;
@@ -15247,7 +15804,7 @@ void main() {
15247
15804
  camera,
15248
15805
  locale,
15249
15806
  groundAt,
15250
- tool: () => ({ weapon: toolId, category: tool.def.category, ammo: tool.ammo, capacity: tool.capacity, reserve: tool.reserve, reloading: tool.reloading }),
15807
+ tool: () => ({ weapon: toolId, category: tool.def.category, ammo: tool.ammo, capacity: tool.capacity, reserve: tool.reserve, reloading: tool.reloading, mode: tool.mode }),
15251
15808
  on(listener) {
15252
15809
  hostListeners.add(listener);
15253
15810
  return () => hostListeners.delete(listener);
@@ -15260,6 +15817,23 @@ void main() {
15260
15817
  action("module_note", text, { data });
15261
15818
  }
15262
15819
  };
15820
+ const lendBench = (store) => ({
15821
+ value: store.value,
15822
+ layer: store.layer,
15823
+ onChange: store.onChange,
15824
+ patch: store.patch,
15825
+ look: (eye, target) => room.look(eye, target)
15826
+ });
15827
+ const checkModuleBench = (mod, fail) => {
15828
+ const store = createBenchStore(mod, locale, (error) => reportOnce(`${mod.id}:listener`, t("mod.listener_failed", { name: mod.name, error: failureText(error) })));
15829
+ const { controls, layers, metrics } = store.declared;
15830
+ if (controls.length + layers.length + metrics.length === 0) return null;
15831
+ const r = checkBench(store.declared);
15832
+ const text = r.ok ? t("mod.bench_ok", { name: mod.name, controls: controls.length, layers: layers.length, metrics: metrics.length }) : t("mod.bench_fail", { name: mod.name, problems: r.problems.join("; ") });
15833
+ moduleCheck(r.ok, text, { module: mod.id, check: "bench", problems: r.problems });
15834
+ for (const p of r.problems) fail(`bench: ${p}`);
15835
+ return r.ok ? store : null;
15836
+ };
15263
15837
  const loadModule = (mod) => {
15264
15838
  const report = { module: mod.id, items: [], scenarios: [], ok: true, problems: [] };
15265
15839
  const fail = (problem) => {
@@ -15324,8 +15898,29 @@ void main() {
15324
15898
  report.scenarios.push(sc.id);
15325
15899
  }
15326
15900
  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 } });
15901
+ const bench = checkModuleBench(mod, fail);
15902
+ detachers.get(mod.id)?.();
15903
+ detachers.delete(mod.id);
15904
+ const previous = benches.get(mod.id);
15905
+ const kept = previous?.encode();
15906
+ previous?.dispose();
15907
+ benches.delete(mod.id);
15908
+ for (const key of [...reportedFailures]) if (key.startsWith(`${mod.id}:`)) reportedFailures.delete(key);
15327
15909
  modules.set(mod.id, mod);
15328
- if (mod.attach) detachers.push(mod.attach(host));
15910
+ if (bench) {
15911
+ const seed = options.bench?.state?.[mod.id];
15912
+ const restored = kept ?? (typeof seed === "string" ? seed : void 0);
15913
+ if (restored !== void 0) bench.restore(restored);
15914
+ benches.set(mod.id, bench);
15915
+ bench.watch(() => {
15916
+ options.bench?.onChange?.(mod.id, bench.encode());
15917
+ if (activeBench === mod.id) ui?.bench(benchView());
15918
+ });
15919
+ if (!activeBench || !benches.has(activeBench)) activeBench = mod.id;
15920
+ } else if (activeBench === mod.id) activeBench = lastBench();
15921
+ if (mod.attach) detachers.set(mod.id, mod.attach(bench ? { ...host, bench: lendBench(bench) } : host));
15922
+ bench?.sendInitial();
15923
+ showBench();
15329
15924
  ui?.refresh();
15330
15925
  return report;
15331
15926
  };
@@ -15404,6 +15999,8 @@ void main() {
15404
15999
  const mod = [...modules.values()].find((m) => m.id === area || m.group === area);
15405
16000
  const label = mod?.group ?? S.groups[area] ?? area;
15406
16001
  ui?.showGroup(label);
16002
+ activeBench = mod && benches.has(mod.id) ? mod.id : lastBench();
16003
+ showBench();
15407
16004
  const layout = mod?.area?.(locale);
15408
16005
  if (layout) return layArea(layout);
15409
16006
  const items = catalog.filter((item) => item.group === label);
@@ -15495,8 +16092,18 @@ void main() {
15495
16092
  loadModule,
15496
16093
  animations: animationsOf,
15497
16094
  playAnimation,
16095
+ bench(moduleId) {
16096
+ const id = moduleId ?? activeBench;
16097
+ return (id ? benches.get(id) : void 0) ?? null;
16098
+ },
16099
+ metrics() {
16100
+ return { room: roomMetrics.sample(), module: moduleMetrics() };
16101
+ },
15498
16102
  dispose() {
15499
- for (const detach of detachers.splice(0)) detach();
16103
+ for (const detach of detachers.values()) detach();
16104
+ detachers.clear();
16105
+ for (const store of benches.values()) store.dispose();
16106
+ benches.clear();
15500
16107
  cancelAnimationFrame?.(raf);
15501
16108
  window.removeEventListener("resize", onResize);
15502
16109
  player.dispose();
@@ -15567,19 +16174,33 @@ void main() {
15567
16174
  onPlay: (objectId, clipId) => {
15568
16175
  playAnimation(clipId, objectId);
15569
16176
  },
15570
- onResize: resize
16177
+ onResize: resize,
16178
+ onControl: (id, value) => {
16179
+ if (activeBench) benches.get(activeBench)?.set(id, value);
16180
+ },
16181
+ onLayer: (id, on) => {
16182
+ if (activeBench) benches.get(activeBench)?.setLayer(id, on);
16183
+ },
16184
+ onAction: (id) => {
16185
+ if (activeBench) benches.get(activeBench)?.act(id);
16186
+ }
15571
16187
  });
15572
16188
  ui.hud(hudState());
16189
+ showBench();
15573
16190
  }
15574
16191
  let raf = 0;
15575
16192
  if (ownRenderer && typeof requestAnimationFrame === "function") {
15576
16193
  let last = performance.now();
16194
+ ownsLoop = true;
15577
16195
  const loop = (now) => {
15578
16196
  raf = requestAnimationFrame(loop);
15579
- const dt = Math.min(0.05, (now - last) / 1e3);
16197
+ const realDelta = (now - last) / 1e3;
16198
+ roomMetrics.frame(realDelta);
16199
+ const dt = Math.min(0.05, realDelta);
15580
16200
  last = now;
15581
16201
  if (!scenarioRunning) update(dt);
15582
16202
  gl.render(scene, camera);
16203
+ roomMetrics.rendered();
15583
16204
  };
15584
16205
  raf = requestAnimationFrame(loop);
15585
16206
  }