@adatechnology/game-test-room 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -110,7 +110,7 @@ function resolveScenery(scenery) {
110
110
  if (!position) throw new Error(`game-test-room: scenery "${id}" is new, so it needs a position`);
111
111
  merged.set(id, { ...obj, position });
112
112
  }
113
- return [...merged.entries()].map(([id, o]) => ({ id, spec: o.spec, position: o.position, rotationY: o.rotationY ?? 0, colors: o.colors, build: o.build, label: o.label, top: o.top, spout: o.spout }));
113
+ return [...merged.entries()].map(([id, o]) => ({ id, spec: o.spec, position: o.position, rotationY: o.rotationY ?? 0, colors: o.colors, build: o.build, follow: o.follow, label: o.label, top: o.top, spout: o.spout, free: o.free }));
114
114
  }
115
115
  function createArena(THREE, size, scene, scenery, tapOption) {
116
116
  const group = markKitOrigin(new THREE.Group(), { owner: "game-test-room", kind: "room" });
@@ -471,7 +471,7 @@ function buildFromSpec(THREE, spec, options = {}) {
471
471
  }
472
472
 
473
473
  // src/explosives.ts
474
- import { backblastEffect, blastEffect, createExplosiveBody, tripwireSegment } from "@adatechnology/game-weapons";
474
+ import { ExplosiveBody, backblastEffect, blastEffect, createExplosiveBody, tripwireSegment } from "@adatechnology/game-weapons";
475
475
 
476
476
  // src/segment-box.ts
477
477
  var segmentTouchesBox = (from, to, min, max) => {
@@ -714,6 +714,14 @@ function createExplosives(context) {
714
714
  ...projectile.fire ? { fire: projectile.fire } : {}
715
715
  });
716
716
  };
717
+ const blastAt = (weapon, explosive, point) => {
718
+ const def = arsenal.explosives.get(explosive);
719
+ if (!def) return false;
720
+ const event = new ExplosiveBody({ explosive: def, origin: point, triggers: ["remote"], rules: arsenal.rules.explosives }).trigger("remote");
721
+ if (event?.type !== "detonate") return false;
722
+ detonate({ weapon }, event.detonation);
723
+ return true;
724
+ };
717
725
  const detonateCharges = () => {
718
726
  let count = 0;
719
727
  for (const projectile of [...projectiles]) {
@@ -805,7 +813,172 @@ function createExplosives(context) {
805
813
  }
806
814
  }
807
815
  };
808
- return { throwUse, placeUse, backblastUse, detonateCharges, update, clear };
816
+ return { throwUse, placeUse, backblastUse, detonateCharges, blastAt, update, clear };
817
+ }
818
+
819
+ // src/test-table.ts
820
+ var TRIAL_KINDS = ["shot", "punch", "stab", "fire", "blast"];
821
+ var DEFAULT_TRIALS = {
822
+ shot: { weapon: "pistol_9mm", distance: 2.5, lift: 0.5 },
823
+ punch: { weapon: "fists", distance: 0.3, lift: 0.15 },
824
+ stab: { weapon: "knife", distance: 0.22, lift: 0.1 },
825
+ fire: { weapon: "flamethrower", distance: 1.6, lift: 0.4, hold: 0.8 },
826
+ blast: { weapon: "frag_grenade", distance: 0.3, lift: 1, watch: 6 }
827
+ };
828
+ var resolveTrials = (overrides = {}) => Object.fromEntries(TRIAL_KINDS.map((kind) => [kind, { ...DEFAULT_TRIALS[kind], ...overrides[kind] }]));
829
+ function fitRow(width, start, total, length, gap) {
830
+ let used = 0;
831
+ let count = 0;
832
+ for (let i = start; i < total; i++) {
833
+ const w = width(i);
834
+ const next = count === 0 ? w : used + gap + w;
835
+ if (count > 0 && next > length) break;
836
+ used = next;
837
+ count++;
838
+ }
839
+ return count;
840
+ }
841
+ function rowCentres(widths, centreX, gap) {
842
+ const length = widths.reduce((sum, w) => sum + w, 0) + gap * Math.max(0, widths.length - 1);
843
+ let x = centreX - length / 2;
844
+ return widths.map((w) => {
845
+ const centre = x + w / 2;
846
+ x += w + gap;
847
+ return centre;
848
+ });
849
+ }
850
+ function trialView(tool, kind, min, max) {
851
+ const target = [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2];
852
+ const away = kind === "blast" ? tool.watch ?? 6 : tool.distance;
853
+ return { eye: [target[0], target[1] + tool.lift, max[2] + away], target };
854
+ }
855
+ function createTestTable(context, options = {}) {
856
+ const gap = options.gap ?? 0.12;
857
+ const margin = options.margin ?? 0.1;
858
+ const trials = resolveTrials(options.trials);
859
+ const listeners = /* @__PURE__ */ new Set();
860
+ let current = null;
861
+ const itemsOf = (group) => context.groups().get(group) ?? [];
862
+ const emit = () => {
863
+ const page = state();
864
+ for (const listener of [...listeners]) listener(page);
865
+ return page;
866
+ };
867
+ const state = () => {
868
+ if (!current) return null;
869
+ const total = itemsOf(current.group).length;
870
+ const live = current.objects.filter((o) => context.box(o.objectId));
871
+ return {
872
+ group: current.group,
873
+ page: current.page,
874
+ first: current.first,
875
+ total,
876
+ objects: live,
877
+ selected: current.selected,
878
+ hasPrev: current.page > 0,
879
+ hasNext: current.first + current.objects.length < total
880
+ };
881
+ };
882
+ const bounds = /* @__PURE__ */ new Map();
883
+ const widthOf = (itemId) => context.measure(itemId).w;
884
+ const boundsUntil = (group, length, until) => {
885
+ const items = itemsOf(group);
886
+ const known = bounds.get(group) ?? [0];
887
+ bounds.set(group, known);
888
+ while (known[known.length - 1] < items.length && !until(known)) {
889
+ const first = known[known.length - 1];
890
+ known.push(first + fitRow((i) => widthOf(items[i].id), first, items.length, length, gap));
891
+ }
892
+ return known;
893
+ };
894
+ const lengthOf = (top) => top.maxX - top.minX - 2 * margin;
895
+ const lay = (group, page, top) => {
896
+ const items = itemsOf(group);
897
+ const known = boundsUntil(group, lengthOf(top), (b) => b.length > page + 1);
898
+ const index = Math.max(0, Math.min(page, known.length - 2));
899
+ const first = known[index];
900
+ const laid = items.slice(first, known[index + 1]);
901
+ context.clear();
902
+ const sizes = laid.map((item) => context.measure(item.id));
903
+ const centres = rowCentres(sizes.map((size) => size.w), (top.minX + top.maxX) / 2, gap);
904
+ const objects = laid.map((item, i) => {
905
+ const d = Math.min(sizes[i].d, top.maxZ - top.minZ);
906
+ const { objectId } = context.spawn(item.id, [centres[i], top.top, top.maxZ - margin / 2 - d / 2]);
907
+ return { objectId, itemId: item.id, name: item.name };
908
+ });
909
+ current = { group, page: index, first, objects, selected: objects[0]?.objectId ?? null };
910
+ context.log("test_table", context.translate("act.test_table", { group, from: first + 1, to: first + laid.length, total: items.length }), { group, page: index, items: laid.map((i) => i.id) });
911
+ return emit();
912
+ };
913
+ const open = (group, page = 0) => {
914
+ const top = context.top();
915
+ if (!itemsOf(group).length || !top) return null;
916
+ return lay(group, page, top);
917
+ };
918
+ const find = (itemId) => {
919
+ const top = context.top();
920
+ const group = [...context.groups()].find(([, items]) => items.some((i) => i.id === itemId))?.[0];
921
+ if (group === void 0 || !top) return null;
922
+ const index = itemsOf(group).findIndex((i) => i.id === itemId);
923
+ const known = boundsUntil(group, lengthOf(top), (b) => b[b.length - 1] > index);
924
+ lay(group, known.findIndex((bound, i) => bound <= index && index < known[i + 1]), top);
925
+ const target = current?.objects.find((o) => o.itemId === itemId);
926
+ if (current && target) current.selected = target.objectId;
927
+ return emit();
928
+ };
929
+ const selected = (objectId) => {
930
+ const id = objectId ?? current?.selected ?? null;
931
+ return id && context.box(id) ? id : null;
932
+ };
933
+ const trial = (kind, objectId) => {
934
+ const id = selected(objectId);
935
+ const box2 = id ? context.box(id) : null;
936
+ if (!id || !box2) return false;
937
+ const tool = trials[kind];
938
+ if (!context.equip(tool.weapon)) return false;
939
+ const view = trialView(tool, kind, box2.min, box2.max);
940
+ context.look(view.eye, view.target);
941
+ const name = current?.objects.find((o) => o.objectId === id)?.name ?? id;
942
+ context.log("trial", context.translate(`act.trial.${kind}`, { name }), { kind, objectId: id, weapon: tool.weapon });
943
+ if (kind === "blast") {
944
+ const point = [view.target[0], box2.min[1] + 0.05, box2.min[2] - tool.distance];
945
+ return context.blastAt(tool.weapon, point);
946
+ }
947
+ context.use(tool.hold ?? 0);
948
+ emit();
949
+ return true;
950
+ };
951
+ return {
952
+ groups: () => [...context.groups()].filter(([, items]) => items.length > 0).map(([group]) => group),
953
+ items: () => [...context.groups()].flatMap(([group, items]) => items.map((item) => ({ id: item.id, name: item.name, group }))),
954
+ open,
955
+ find,
956
+ next: () => {
957
+ const page = state();
958
+ return page?.hasNext ? open(page.group, page.page + 1) : page;
959
+ },
960
+ prev: () => {
961
+ const page = state();
962
+ return page?.hasPrev ? open(page.group, page.page - 1) : page;
963
+ },
964
+ relay: () => {
965
+ if (!current) return null;
966
+ context.resetAll();
967
+ return open(current.group, current.page);
968
+ },
969
+ state,
970
+ select: (objectId) => {
971
+ if (!current || !current.objects.some((o) => o.objectId === objectId) || !context.box(objectId)) return false;
972
+ current.selected = objectId;
973
+ emit();
974
+ return true;
975
+ },
976
+ trial,
977
+ on: (listener) => {
978
+ listeners.add(listener);
979
+ return () => listeners.delete(listener);
980
+ }
981
+ };
809
982
  }
810
983
 
811
984
  // src/host-events.ts
@@ -2173,6 +2346,9 @@ var en = {
2173
2346
  "act.strike.leg": "{weapon}: {clip} with the {leg} leg begins, impact in {seconds} s",
2174
2347
  "leg.left": "left",
2175
2348
  "leg.right": "right",
2349
+ "act.strike.hand": "{weapon}: {clip} with the {hand} hand begins, impact in {seconds} s",
2350
+ "hand.left": "left",
2351
+ "hand.right": "right",
2176
2352
  "act.flame.hit": "{weapon}: flame at {temp} \xB0C on {where}",
2177
2353
  "act.flame.air": "{weapon}: flame, nothing in range",
2178
2354
  "act.heat.hit": "{weapon}: heating {where} at {temp} \xB0C",
@@ -2204,6 +2380,7 @@ var en = {
2204
2380
  "loading.open_area": "Opening {area}\u2026",
2205
2381
  "loading.spawn": "Building {name}\u2026",
2206
2382
  "act.loosened": "{name} was heavy but free: the blast moved it",
2383
+ "act.shoved": "{name} stands free: the shots moved it",
2207
2384
  "act.impact": "{name} hit {where} at {speed} m/s: {energy} J of blunt damage",
2208
2385
  "act.use_item": "{name}: {use}",
2209
2386
  "act.tap.on": "Tap opened",
@@ -2237,7 +2414,7 @@ var en = {
2237
2414
  "hud.throw": "throw",
2238
2415
  "hud.tilt": "tilt (hold)",
2239
2416
  "hud.equip": "equip {weapon}",
2240
- "ui.help": "Click: look \xB7 WASD: walk \xB7 Shift: run \xB7 C: crouch \xB7 Space: jump \xB7 1-0 / wheel: tool \xB7 LMB: use \xB7 E: grab/drop \xB7 G or RMB: throw \xB7 R: reload \xB7 Q: fire mode \xB7 X: clear a jam \xB7 B: safety \xB7 Y: detonate charges set down \xB7 Tab: panels \xB7 Esc: free the mouse",
2417
+ "ui.help": "Click: look \xB7 WASD: walk \xB7 Shift: run \xB7 C: crouch \xB7 Space: jump \xB7 1-0 / wheel: tool \xB7 LMB: use (bare hands: punch) \xB7 E: grab/drop \xB7 G or RMB: throw \xB7 R: reload \xB7 Q: fire mode \xB7 X: clear a jam \xB7 B: safety \xB7 Y: detonate charges set down \xB7 Tab: panels \xB7 Esc: free the mouse",
2241
2418
  "ui.help.camera": "V: 1st/3rd person (Shift+V: kick)",
2242
2419
  "camera.first": "1st person",
2243
2420
  "camera.third": "3rd person",
@@ -2251,6 +2428,32 @@ var en = {
2251
2428
  "ui.copy_json": "Copy JSON",
2252
2429
  "ui.download_ndjson": "Download NDJSON",
2253
2430
  "ui.download_txt": "Download TXT",
2431
+ "ui.test_table": "Test table",
2432
+ "ui.test_table.group": "Group to lay on the table",
2433
+ "ui.test_table.lay": "Lay",
2434
+ "ui.test_table.prev": "Previous items",
2435
+ "ui.test_table.next": "Next items",
2436
+ "ui.test_table.relay": "Put back",
2437
+ "ui.test_table.search": "Find an item to put on the table",
2438
+ "ui.test_table.hint": "Pick a group and lay it on the table, then pick an item and try it.",
2439
+ "ui.test_table.page": "Items {from}\u2013{to} of {total}. Pick one, then a trial:",
2440
+ "ui.trial.shot": "Shot",
2441
+ "ui.trial.punch": "Punch",
2442
+ "ui.trial.stab": "Knife",
2443
+ "ui.trial.fire": "Fire",
2444
+ "ui.trial.blast": "Blast",
2445
+ "ui.trial.shot.hint": "A 9 mm round from 2.5 m",
2446
+ "ui.trial.punch.hint": "A punch, up close",
2447
+ "ui.trial.stab.hint": "A knife blow, up close",
2448
+ "ui.trial.fire.hint": "A flamethrower burst from 1.6 m",
2449
+ "ui.trial.blast.hint": "A fragmentation grenade goes off just behind it (it reaches the neighbours too)",
2450
+ "act.test_table": "Test table: {group}, items {from}\u2013{to} of {total}",
2451
+ "act.trial.shot": "Trial: shooting {name}",
2452
+ "act.trial.punch": "Trial: punching {name}",
2453
+ "act.trial.stab": "Trial: stabbing {name}",
2454
+ "act.trial.fire": "Trial: setting fire to {name}",
2455
+ "act.trial.blast": "Trial: a blast beside {name}",
2456
+ "arena.test_table": "Test table",
2254
2457
  "ui.clear_log": "Clear",
2255
2458
  "ui.catalog": "Catalogue",
2256
2459
  "ui.clear": "Clear",
@@ -2454,6 +2657,9 @@ var ptBR = {
2454
2657
  "act.strike.leg": "{weapon}: {clip} com a perna {leg} come\xE7a, impacto em {seconds} s",
2455
2658
  "leg.left": "esquerda",
2456
2659
  "leg.right": "direita",
2660
+ "act.strike.hand": "{weapon}: {clip} com a m\xE3o {hand} come\xE7a, impacto em {seconds} s",
2661
+ "hand.left": "esquerda",
2662
+ "hand.right": "direita",
2457
2663
  "act.flame.hit": "{weapon}: chama a {temp} \xB0C em {where}",
2458
2664
  "act.flame.air": "{weapon}: chama, nada ao alcance",
2459
2665
  "act.heat.hit": "{weapon}: esquentando {where} a {temp} \xB0C",
@@ -2485,6 +2691,7 @@ var ptBR = {
2485
2691
  "loading.open_area": "Abrindo {area}\u2026",
2486
2692
  "loading.spawn": "Montando {name}\u2026",
2487
2693
  "act.loosened": "{name} \xE9 pesado mas estava solto: a explos\xE3o o moveu",
2694
+ "act.shoved": "{name} estava solto no ch\xE3o: os tiros o moveram",
2488
2695
  "act.impact": "{name} bateu em {where} a {speed} m/s: {energy} J de dano de impacto",
2489
2696
  "act.use_item": "{name}: {use}",
2490
2697
  "act.tap.on": "Abriu a torneira",
@@ -2518,7 +2725,7 @@ var ptBR = {
2518
2725
  "hud.throw": "arremessar",
2519
2726
  "hud.tilt": "inclinar (segurar)",
2520
2727
  "hud.equip": "equipar {weapon}",
2521
- "ui.help": "Clique: olhar \xB7 WASD: andar \xB7 Shift: correr \xB7 C: agachar \xB7 Espa\xE7o: pular \xB7 1-0 / roda: ferramenta \xB7 bot\xE3o esquerdo: usar \xB7 E: pegar/soltar \xB7 G ou bot\xE3o direito: arremessar \xB7 R: recarregar \xB7 Q: modo de tiro \xB7 X: destravar \xB7 B: trava \xB7 Y: detonar cargas postas \xB7 Tab: pain\xE9is \xB7 Esc: soltar o mouse",
2728
+ "ui.help": "Clique: olhar \xB7 WASD: andar \xB7 Shift: correr \xB7 C: agachar \xB7 Espa\xE7o: pular \xB7 1-0 / roda: ferramenta \xB7 bot\xE3o esquerdo: usar (m\xE3o livre: soco) \xB7 E: pegar/soltar \xB7 G ou bot\xE3o direito: arremessar \xB7 R: recarregar \xB7 Q: modo de tiro \xB7 X: destravar \xB7 B: trava \xB7 Y: detonar cargas postas \xB7 Tab: pain\xE9is \xB7 Esc: soltar o mouse",
2522
2729
  "ui.help.camera": "V: 1\xAA/3\xAA pessoa (Shift+V: chute)",
2523
2730
  "camera.first": "1\xAA pessoa",
2524
2731
  "camera.third": "3\xAA pessoa",
@@ -2532,6 +2739,32 @@ var ptBR = {
2532
2739
  "ui.copy_json": "Copiar JSON",
2533
2740
  "ui.download_ndjson": "Baixar NDJSON",
2534
2741
  "ui.download_txt": "Baixar TXT",
2742
+ "ui.test_table": "Mesa de testes",
2743
+ "ui.test_table.group": "Grupo para p\xF4r na mesa",
2744
+ "ui.test_table.lay": "P\xF4r na mesa",
2745
+ "ui.test_table.prev": "Itens anteriores",
2746
+ "ui.test_table.next": "Pr\xF3ximos itens",
2747
+ "ui.test_table.relay": "Repor",
2748
+ "ui.test_table.search": "Buscar item para p\xF4r na mesa",
2749
+ "ui.test_table.hint": "Escolha um grupo e ponha na mesa; depois escolha um item e teste.",
2750
+ "ui.test_table.page": "Itens {from}\u2013{to} de {total}. Escolha um e um teste:",
2751
+ "ui.trial.shot": "Tiro",
2752
+ "ui.trial.punch": "Soco",
2753
+ "ui.trial.stab": "Faca",
2754
+ "ui.trial.fire": "Fogo",
2755
+ "ui.trial.blast": "Explos\xE3o",
2756
+ "ui.trial.shot.hint": "Um tiro de 9 mm a 2,5 m",
2757
+ "ui.trial.punch.hint": "Um soco, de perto",
2758
+ "ui.trial.stab.hint": "Um golpe de faca, de perto",
2759
+ "ui.trial.fire.hint": "Uma rajada de lan\xE7a-chamas a 1,6 m",
2760
+ "ui.trial.blast.hint": "Uma granada de fragmenta\xE7\xE3o explode logo atr\xE1s dele (pega os vizinhos tamb\xE9m)",
2761
+ "act.test_table": "Mesa de testes: {group}, itens {from}\u2013{to} de {total}",
2762
+ "act.trial.shot": "Teste: tiro em {name}",
2763
+ "act.trial.punch": "Teste: soco em {name}",
2764
+ "act.trial.stab": "Teste: faca em {name}",
2765
+ "act.trial.fire": "Teste: fogo em {name}",
2766
+ "act.trial.blast": "Teste: explos\xE3o ao lado de {name}",
2767
+ "arena.test_table": "Mesa de testes",
2535
2768
  "ui.clear_log": "Limpar",
2536
2769
  "ui.catalog": "Cat\xE1logo",
2537
2770
  "ui.clear": "Limpar",
@@ -2873,6 +3106,122 @@ function createBenchPanel(o) {
2873
3106
  };
2874
3107
  }
2875
3108
 
3109
+ // src/test-table-panel.ts
3110
+ var TEST_TABLE_CSS = `
3111
+ .tr-table{flex:0 0 auto;max-height:42%}
3112
+ .tr-table .sub{color:#9aa3b2;margin:4px 0}
3113
+ .tr-table .list{display:flex;flex-wrap:wrap;gap:3px}
3114
+ .tr-table .list .tr-btn{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
3115
+ .tr-table select{flex:1;min-width:0;max-width:55%}
3116
+ .tr-table .tr-search{margin:6px 0 0}
3117
+ .tr-root .tr-table button.tr-btn.on{background:#3b3a14;border-color:#ffd23f;color:#fff}
3118
+ `;
3119
+ var mk2 = (tag, cls, text) => {
3120
+ const e = document.createElement(tag);
3121
+ if (cls) e.className = cls;
3122
+ if (text !== void 0) e.textContent = text;
3123
+ return e;
3124
+ };
3125
+ function createTestTablePanel(table, t) {
3126
+ const element = mk2("div", "tr-panel tr-table");
3127
+ element.appendChild(mk2("h3", void 0, t("ui.test_table")));
3128
+ const head = mk2("div", "tr-row");
3129
+ const select = mk2("select");
3130
+ select.setAttribute("aria-label", t("ui.test_table.group"));
3131
+ const lay = mk2("button", "tr-btn", t("ui.test_table.lay"));
3132
+ const prev = mk2("button", "tr-btn", "\u25C0");
3133
+ const next = mk2("button", "tr-btn", "\u25B6");
3134
+ prev.title = t("ui.test_table.prev");
3135
+ next.title = t("ui.test_table.next");
3136
+ for (const b of [lay, prev, next]) b.type = "button";
3137
+ head.append(select, lay, prev, next);
3138
+ const search = mk2("input", "tr-search");
3139
+ search.type = "search";
3140
+ search.placeholder = t("ui.test_table.search");
3141
+ const found = mk2("div", "list");
3142
+ const sub = mk2("div", "sub", t("ui.test_table.hint"));
3143
+ const list = mk2("div", "list");
3144
+ const trials = mk2("div", "tr-row");
3145
+ const trialButtons = TRIAL_KINDS.map((kind) => {
3146
+ const b = mk2("button", "tr-btn", t(`ui.trial.${kind}`));
3147
+ b.type = "button";
3148
+ b.dataset.trial = kind;
3149
+ b.title = t(`ui.trial.${kind}.hint`);
3150
+ b.onclick = () => table.trial(kind);
3151
+ trials.appendChild(b);
3152
+ return b;
3153
+ });
3154
+ const relay = mk2("button", "tr-btn", t("ui.test_table.relay"));
3155
+ relay.type = "button";
3156
+ relay.onclick = () => table.relay();
3157
+ trials.appendChild(relay);
3158
+ element.append(head, search, found, sub, trials, list);
3159
+ const groups = () => {
3160
+ const keep = select.value;
3161
+ select.textContent = "";
3162
+ for (const group of table.groups()) {
3163
+ const op = mk2("option", void 0, group);
3164
+ op.value = group;
3165
+ select.appendChild(op);
3166
+ }
3167
+ if ([...select.options].some((o) => o.value === keep)) select.value = keep;
3168
+ };
3169
+ const render = (page) => {
3170
+ list.textContent = "";
3171
+ prev.disabled = !page?.hasPrev;
3172
+ next.disabled = !page?.hasNext;
3173
+ for (const b of [...trialButtons, relay]) b.disabled = !page?.selected;
3174
+ if (!page) {
3175
+ sub.textContent = t("ui.test_table.hint");
3176
+ return;
3177
+ }
3178
+ if (select.value !== page.group) select.value = page.group;
3179
+ sub.textContent = t("ui.test_table.page", { from: page.first + 1, to: page.first + page.objects.length, total: page.total });
3180
+ for (const o of page.objects) {
3181
+ const b = mk2("button", o.objectId === page.selected ? "tr-btn on" : "tr-btn", o.name);
3182
+ b.type = "button";
3183
+ b.title = o.itemId;
3184
+ b.onclick = () => table.select(o.objectId);
3185
+ list.appendChild(b);
3186
+ }
3187
+ };
3188
+ const fold2 = (text) => text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
3189
+ search.oninput = () => {
3190
+ found.textContent = "";
3191
+ const term = fold2(search.value.trim());
3192
+ if (!term) return;
3193
+ const hits = table.items().filter((item) => fold2(`${item.name} ${item.id}`).includes(term)).slice(0, 8);
3194
+ for (const item of hits) {
3195
+ const b = mk2("button", "tr-btn", item.name);
3196
+ b.type = "button";
3197
+ b.title = `${item.group} \xB7 ${item.id}`;
3198
+ b.onclick = () => {
3199
+ search.value = "";
3200
+ found.textContent = "";
3201
+ table.find(item.id);
3202
+ };
3203
+ found.appendChild(b);
3204
+ }
3205
+ if (!hits.length) found.appendChild(mk2("div", "sub", t("ui.no_results")));
3206
+ };
3207
+ lay.onclick = () => table.open(select.value);
3208
+ prev.onclick = () => table.prev();
3209
+ next.onclick = () => table.next();
3210
+ select.onchange = () => table.open(select.value);
3211
+ for (const box2 of [select, search]) box2.addEventListener("keydown", (e) => e.stopPropagation());
3212
+ const off = table.on(render);
3213
+ groups();
3214
+ render(table.state());
3215
+ return {
3216
+ element,
3217
+ refresh: () => {
3218
+ groups();
3219
+ render(table.state());
3220
+ },
3221
+ dispose: off
3222
+ };
3223
+ }
3224
+
2876
3225
  // src/ui.ts
2877
3226
  var CSS = `
2878
3227
  .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}
@@ -2951,7 +3300,7 @@ var CSS = `
2951
3300
  @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}}
2952
3301
  `;
2953
3302
  var fold = (text) => text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
2954
- var mk2 = (tag, cls, text) => {
3303
+ var mk3 = (tag, cls, text) => {
2955
3304
  const e = document.createElement(tag);
2956
3305
  if (cls) e.className = cls;
2957
3306
  if (text !== void 0) e.textContent = text;
@@ -2962,7 +3311,7 @@ var thumbSource = (thumbnail) => {
2962
3311
  return source.replace(/["\\\n\r]/g, (char) => encodeURIComponent(char));
2963
3312
  };
2964
3313
  var thumb = (thumbnail, credit) => {
2965
- const e = mk2("span", "tr-thumb");
3314
+ const e = mk3("span", "tr-thumb");
2966
3315
  if (credit) e.title = credit;
2967
3316
  else e.setAttribute("aria-hidden", "true");
2968
3317
  e.style.setProperty("--tr-thumb", `url("${thumbSource(thumbnail)}")`);
@@ -2983,42 +3332,43 @@ async function toggleFullscreen() {
2983
3332
  }
2984
3333
  function createUi(o) {
2985
3334
  const { t } = o;
2986
- const style = mk2("style");
2987
- style.textContent = CSS;
2988
- const root = mk2("div", "tr-root");
3335
+ const style = mk3("style");
3336
+ style.textContent = CSS + (o.testTable ? TEST_TABLE_CSS : "");
3337
+ const root = mk3("div", "tr-root");
2989
3338
  const pos = getComputedStyle?.(o.container)?.position;
2990
3339
  if (!pos || pos === "static") o.container.style.position = "relative";
2991
3340
  root.appendChild(style);
2992
3341
  o.container.appendChild(root);
2993
- const cross = mk2("div", "tr-cross");
2994
- cross.appendChild(mk2("i"));
2995
- const top = mk2("div", "tr-top");
2996
- const toolBox = mk2("div", "tr-tool");
2997
- const status = mk2("div", "tr-status");
2998
- const prog = mk2("div", "tr-prog");
2999
- const progLabel = mk2("b");
3000
- const progBar = mk2("div", "tr-bar");
3001
- const progFill = mk2("i");
3342
+ const cross = mk3("div", "tr-cross");
3343
+ cross.appendChild(mk3("i"));
3344
+ const top = mk3("div", "tr-top");
3345
+ const toolBox = mk3("div", "tr-tool");
3346
+ const status = mk3("div", "tr-status");
3347
+ const prog = mk3("div", "tr-prog");
3348
+ const progLabel = mk3("b");
3349
+ const progBar = mk3("div", "tr-bar");
3350
+ const progFill = mk3("i");
3002
3351
  progBar.appendChild(progFill);
3003
3352
  prog.append(progLabel, progBar);
3004
3353
  top.append(toolBox, status, prog);
3005
- const bottom = mk2("div", "tr-bottom");
3006
- const held = mk2("div", "tr-held");
3007
- const hotbar = mk2("div", "tr-hotbar");
3354
+ const bottom = mk3("div", "tr-bottom");
3355
+ const held = mk3("div", "tr-held");
3356
+ const hotbar = mk3("div", "tr-hotbar");
3008
3357
  bottom.append(held, hotbar);
3009
- const help = mk2("div", "tr-help");
3358
+ const help = mk3("div", "tr-help");
3010
3359
  help.textContent = t("ui.help");
3011
- const left = mk2("div", "tr-left");
3012
- const insp = mk2("div", "tr-panel tr-insp");
3013
- const logPanel = mk2("div", "tr-panel tr-log");
3014
- left.append(insp, logPanel);
3015
- const logTitle = mk2("h3", void 0, t("ui.log"));
3016
- const logBar = mk2("div", "tr-logbar");
3017
- const logBody = mk2("div", "tr-logbody");
3360
+ const left = mk3("div", "tr-left");
3361
+ const insp = mk3("div", "tr-panel tr-insp");
3362
+ const logPanel = mk3("div", "tr-panel tr-log");
3363
+ const tablePanel = o.testTable ? createTestTablePanel(o.testTable, t) : null;
3364
+ left.append(insp, ...tablePanel ? [tablePanel.element] : [], logPanel);
3365
+ const logTitle = mk3("h3", void 0, t("ui.log"));
3366
+ const logBar = mk3("div", "tr-logbar");
3367
+ const logBody = mk3("div", "tr-logbody");
3018
3368
  const filters = { action: true, event: true, check: true };
3019
3369
  const filterBtns = [];
3020
3370
  for (const ch of ["action", "event", "check"]) {
3021
- const b = mk2("button", "tr-btn on", t(`ui.filter.${ch}`));
3371
+ const b = mk3("button", "tr-btn on", t(`ui.filter.${ch}`));
3022
3372
  b.type = "button";
3023
3373
  b.onclick = () => {
3024
3374
  filters[ch] = !filters[ch];
@@ -3029,8 +3379,8 @@ function createUi(o) {
3029
3379
  logBar.appendChild(b);
3030
3380
  }
3031
3381
  let autoscroll = true;
3032
- const asLabel = mk2("label");
3033
- const asBox = mk2("input");
3382
+ const asLabel = mk3("label");
3383
+ const asBox = mk3("input");
3034
3384
  asBox.type = "checkbox";
3035
3385
  asBox.checked = true;
3036
3386
  asBox.onchange = () => {
@@ -3039,7 +3389,7 @@ function createUi(o) {
3039
3389
  asLabel.append(asBox, document.createTextNode(" " + t("ui.autoscroll")));
3040
3390
  logBar.appendChild(asLabel);
3041
3391
  const logBtn = (label, fn) => {
3042
- const b = mk2("button", "tr-btn", label);
3392
+ const b = mk3("button", "tr-btn", label);
3043
3393
  b.type = "button";
3044
3394
  b.onclick = fn;
3045
3395
  logBar.appendChild(b);
@@ -3047,7 +3397,7 @@ function createUi(o) {
3047
3397
  logBtn(t("ui.copy_json"), () => {
3048
3398
  const text = o.log.toJSON();
3049
3399
  const select = () => {
3050
- const ta = mk2("textarea");
3400
+ const ta = mk3("textarea");
3051
3401
  ta.value = text;
3052
3402
  ta.setAttribute("readonly", "");
3053
3403
  ta.style.cssText = "position:fixed;left:8px;bottom:8px;width:min(480px,80vw);height:120px;z-index:99999";
@@ -3081,12 +3431,12 @@ function createUi(o) {
3081
3431
  const fill = (n, e) => {
3082
3432
  n.className = lineClass(e);
3083
3433
  n.textContent = "";
3084
- const tm = mk2("time", void 0, fmtTime(e.t));
3434
+ const tm = mk3("time", void 0, fmtTime(e.t));
3085
3435
  n.append(tm, document.createTextNode(e.text));
3086
3436
  };
3087
3437
  const addNode = (e) => {
3088
3438
  if (!filters[e.channel]) return;
3089
- const n = mk2("div");
3439
+ const n = mk3("div");
3090
3440
  fill(n, e);
3091
3441
  nodes.set(e.seq, n);
3092
3442
  logBody.appendChild(n);
@@ -3108,18 +3458,18 @@ function createUi(o) {
3108
3458
  if (n) fill(n, e);
3109
3459
  } else addNode(e);
3110
3460
  });
3111
- const right = mk2("div", "tr-right");
3112
- const cat = mk2("div", "tr-panel tr-cat");
3113
- const anim = mk2("div", "tr-panel tr-anim");
3114
- const tests = mk2("div", "tr-panel tr-tests");
3461
+ const right = mk3("div", "tr-right");
3462
+ const cat = mk3("div", "tr-panel tr-cat");
3463
+ const anim = mk3("div", "tr-panel tr-anim");
3464
+ const tests = mk3("div", "tr-panel tr-tests");
3115
3465
  const benchPanel = createBenchPanel({ t, num: (n, d) => num(n, d), onControl: o.onControl, onLayer: o.onLayer, onAction: o.onAction });
3116
3466
  right.append(cat, anim, benchPanel.element, tests);
3117
- cat.appendChild(mk2("h3", void 0, t("ui.catalog")));
3118
- const itemSearch = mk2("input", "tr-search");
3467
+ cat.appendChild(mk3("h3", void 0, t("ui.catalog")));
3468
+ const itemSearch = mk3("input", "tr-search");
3119
3469
  itemSearch.type = "search";
3120
3470
  itemSearch.placeholder = t("ui.search_items");
3121
- const tabs = mk2("div", "tr-tabs");
3122
- const items = mk2("div", "tr-items");
3471
+ const tabs = mk3("div", "tr-tabs");
3472
+ const items = mk3("div", "tr-items");
3123
3473
  cat.append(itemSearch, tabs, items);
3124
3474
  let activeGroup = "";
3125
3475
  const visibleItems = () => {
@@ -3131,18 +3481,18 @@ function createUi(o) {
3131
3481
  items.textContent = "";
3132
3482
  const searching = itemSearch.value.trim() !== "";
3133
3483
  const found = visibleItems();
3134
- if (!found.length) items.appendChild(mk2("div", "tr-empty", t("ui.no_results")));
3484
+ if (!found.length) items.appendChild(mk3("div", "tr-empty", t("ui.no_results")));
3135
3485
  for (const { item: it, group } of found) {
3136
- const wrap = mk2("div", "tr-item");
3137
- const b = mk2("button", it.thumbnail ? "tr-btn has-thumb" : "tr-btn", it.thumbnail ? void 0 : it.name);
3138
- if (it.thumbnail) b.append(thumb(it.thumbnail), mk2("span", "tr-name", it.name));
3139
- if (searching) b.appendChild(mk2("small", void 0, group));
3486
+ const wrap = mk3("div", "tr-item");
3487
+ const b = mk3("button", it.thumbnail ? "tr-btn has-thumb" : "tr-btn", it.thumbnail ? void 0 : it.name);
3488
+ if (it.thumbnail) b.append(thumb(it.thumbnail), mk3("span", "tr-name", it.name));
3489
+ if (searching) b.appendChild(mk3("small", void 0, group));
3140
3490
  b.type = "button";
3141
3491
  b.title = it.note ?? it.name;
3142
3492
  b.onclick = () => o.onSpawn(it.id);
3143
3493
  wrap.appendChild(b);
3144
3494
  if (it.weapon) {
3145
- const w = mk2("button", "tr-btn", "\u2694");
3495
+ const w = mk3("button", "tr-btn", "\u2694");
3146
3496
  w.type = "button";
3147
3497
  w.title = t("ui.equip");
3148
3498
  w.onclick = () => o.onEquip(it.id);
@@ -3156,7 +3506,7 @@ function createUi(o) {
3156
3506
  const groupNames = [...o.groups().keys()];
3157
3507
  if (!groupNames.includes(activeGroup)) activeGroup = groupNames[0] ?? "";
3158
3508
  for (const g of groupNames) {
3159
- const b = mk2("button", g === activeGroup ? "tr-btn on" : "tr-btn", g);
3509
+ const b = mk3("button", g === activeGroup ? "tr-btn on" : "tr-btn", g);
3160
3510
  b.type = "button";
3161
3511
  b.onclick = () => {
3162
3512
  activeGroup = g;
@@ -3169,9 +3519,9 @@ function createUi(o) {
3169
3519
  renderTabs();
3170
3520
  itemSearch.oninput = renderItems;
3171
3521
  for (const box2 of [itemSearch]) box2.addEventListener("keydown", (e) => e.stopPropagation());
3172
- const controls = mk2("div", "tr-row");
3522
+ const controls = mk3("div", "tr-row");
3173
3523
  const ctl = (label, fn) => {
3174
- const b = mk2("button", "tr-btn", label);
3524
+ const b = mk3("button", "tr-btn", label);
3175
3525
  b.type = "button";
3176
3526
  b.onclick = () => fn(b);
3177
3527
  controls.appendChild(b);
@@ -3196,29 +3546,29 @@ function createUi(o) {
3196
3546
  fullscreenBtn.title = t("ui.fullscreen.hint");
3197
3547
  const blastBtns = ["shelter", "fragmentHits", "confinement"].map((key) => ({ key, b: ctl(t(`ui.blast.${key}`), () => o.onBlast(key)) }));
3198
3548
  for (const { key, b } of blastBtns) b.title = t(`ui.blast.${key}.hint`);
3199
- const qSel = mk2("select");
3549
+ const qSel = mk3("select");
3200
3550
  for (const q of ["low", "medium", "high"]) {
3201
- const op = mk2("option", void 0, t(`ui.quality.${q}`));
3551
+ const op = mk3("option", void 0, t(`ui.quality.${q}`));
3202
3552
  op.value = q;
3203
3553
  qSel.appendChild(op);
3204
3554
  }
3205
3555
  qSel.value = o.quality;
3206
3556
  qSel.onchange = () => o.onQuality(qSel.value);
3207
- const qLabel = mk2("label", void 0, t("ui.quality") + " ");
3557
+ const qLabel = mk3("label", void 0, t("ui.quality") + " ");
3208
3558
  qLabel.appendChild(qSel);
3209
3559
  controls.appendChild(qLabel);
3210
- const count = mk2("span");
3560
+ const count = mk3("span");
3211
3561
  controls.appendChild(count);
3212
3562
  cat.appendChild(controls);
3213
- tests.appendChild(mk2("h3", void 0, t("ui.tests")));
3214
- const runAll = mk2("button", "tr-btn", t("ui.run_all"));
3563
+ tests.appendChild(mk3("h3", void 0, t("ui.tests")));
3564
+ const runAll = mk3("button", "tr-btn", t("ui.run_all"));
3215
3565
  runAll.type = "button";
3216
- const summary = mk2("div", "tr-summary");
3217
- const testSearch = mk2("input", "tr-search");
3566
+ const summary = mk3("div", "tr-summary");
3567
+ const testSearch = mk3("input", "tr-search");
3218
3568
  testSearch.type = "search";
3219
3569
  testSearch.placeholder = t("ui.search_tests");
3220
3570
  testSearch.addEventListener("keydown", (e) => e.stopPropagation());
3221
- const list = mk2("div", "list");
3571
+ const list = mk3("div", "list");
3222
3572
  tests.append(runAll, summary, testSearch, list);
3223
3573
  testSearch.oninput = () => {
3224
3574
  const term = fold(testSearch.value.trim());
@@ -3241,7 +3591,7 @@ function createUi(o) {
3241
3591
  list.textContent = "";
3242
3592
  scBtns.clear();
3243
3593
  for (const s of o.scenarios()) {
3244
- const b = mk2("button", "tr-btn", s.name);
3594
+ const b = mk3("button", "tr-btn", s.name);
3245
3595
  b.type = "button";
3246
3596
  b.onclick = async () => {
3247
3597
  if (busy) return;
@@ -3267,7 +3617,7 @@ function createUi(o) {
3267
3617
  busy = false;
3268
3618
  }
3269
3619
  };
3270
- const toggle = mk2("button", "tr-btn tr-toggle", t("ui.panel"));
3620
+ const toggle = mk3("button", "tr-btn tr-toggle", t("ui.panel"));
3271
3621
  toggle.type = "button";
3272
3622
  const togglePanels = () => {
3273
3623
  root.classList.toggle("open");
@@ -3276,15 +3626,15 @@ function createUi(o) {
3276
3626
  toggle.onclick = togglePanels;
3277
3627
  root.append(cross, top, bottom, help, left, right, toggle);
3278
3628
  const bar = (value, max, cls) => {
3279
- const b = mk2("div", `tr-bar ${cls}`);
3280
- const i = mk2("i");
3629
+ const b = mk3("div", `tr-bar ${cls}`);
3630
+ const i = mk3("i");
3281
3631
  i.style.width = `${Math.max(0, Math.min(100, value / Math.max(1e-9, max) * 100))}%`;
3282
3632
  b.appendChild(i);
3283
3633
  return b;
3284
3634
  };
3285
3635
  const row = (k, v) => {
3286
- const r = mk2("div", "r");
3287
- r.append(mk2("span", void 0, k), mk2("span", void 0, v));
3636
+ const r = mk3("div", "r");
3637
+ r.append(mk3("span", void 0, k), mk3("span", void 0, v));
3288
3638
  return r;
3289
3639
  };
3290
3640
  const num = (n, d = 1) => {
@@ -3293,13 +3643,13 @@ function createUi(o) {
3293
3643
  };
3294
3644
  const renderInspector = (i) => {
3295
3645
  insp.textContent = "";
3296
- insp.appendChild(mk2("h3", void 0, t("ui.inspector")));
3646
+ insp.appendChild(mk3("h3", void 0, t("ui.inspector")));
3297
3647
  if (!i) {
3298
- insp.appendChild(mk2("div", void 0, t("ui.aim_at_something")));
3648
+ insp.appendChild(mk3("div", void 0, t("ui.aim_at_something")));
3299
3649
  return;
3300
3650
  }
3301
3651
  if (i.thumbnail) insp.appendChild(thumb(i.thumbnail, i.thumbnailCredit));
3302
- insp.appendChild(mk2("div", "t", `${i.object} \u203A ${i.part}`));
3652
+ insp.appendChild(mk3("div", "t", `${i.object} \u203A ${i.part}`));
3303
3653
  insp.appendChild(row(t("ins.material"), i.material));
3304
3654
  insp.appendChild(row(t("ins.status"), i.status + (i.burning ? ` \xB7 ${t("ins.burning")}` : "")));
3305
3655
  insp.appendChild(row(t("ins.hp"), `${num(i.hp, 0)} / ${num(i.maxHp, 0)}`));
@@ -3318,30 +3668,30 @@ function createUi(o) {
3318
3668
  insp.appendChild(bar(Math.min(i.stress, 1.2), 1.2, i.stress > 1 ? "bad" : i.stress > 0.7 ? "warn" : "hp"));
3319
3669
  }
3320
3670
  if (i.joints.length) {
3321
- insp.appendChild(mk2("div", void 0, t("ins.joints") + ":"));
3671
+ insp.appendChild(mk3("div", void 0, t("ins.joints") + ":"));
3322
3672
  for (const j of i.joints) {
3323
3673
  const tools = j.tools.length ? j.tools.join(", ") : t("tool.none");
3324
- insp.appendChild(mk2("div", void 0, `\u2022 ${j.kind} (${j.id})${j.open ? ` \xB7 ${t("ins.open")}` : ` \u2192 ${tools}`}`));
3674
+ insp.appendChild(mk3("div", void 0, `\u2022 ${j.kind} (${j.id})${j.open ? ` \xB7 ${t("ins.open")}` : ` \u2192 ${tools}`}`));
3325
3675
  }
3326
3676
  }
3327
- if (i.note) insp.appendChild(mk2("div", void 0, i.note));
3677
+ if (i.note) insp.appendChild(mk3("div", void 0, i.note));
3328
3678
  };
3329
3679
  let lastAnim = "";
3330
3680
  const renderAnimations = (a) => {
3331
3681
  anim.textContent = "";
3332
- anim.appendChild(mk2("h3", void 0, t("ui.animations")));
3682
+ anim.appendChild(mk3("h3", void 0, t("ui.animations")));
3333
3683
  if (!a) {
3334
- anim.appendChild(mk2("div", "sub", t("ui.animations_aim")));
3684
+ anim.appendChild(mk3("div", "sub", t("ui.animations_aim")));
3335
3685
  return;
3336
3686
  }
3337
- anim.appendChild(mk2("div", "sub", a.name));
3687
+ anim.appendChild(mk3("div", "sub", a.name));
3338
3688
  if (a.clips.length === 0) {
3339
- anim.appendChild(mk2("div", "sub", t("ui.animations_none")));
3689
+ anim.appendChild(mk3("div", "sub", t("ui.animations_none")));
3340
3690
  return;
3341
3691
  }
3342
- const l = mk2("div", "list");
3692
+ const l = mk3("div", "list");
3343
3693
  for (const c of a.clips) {
3344
- 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)`);
3694
+ const b = mk3("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)`);
3345
3695
  b.type = "button";
3346
3696
  b.onclick = () => o.onPlay(a.objectId, c.id);
3347
3697
  l.appendChild(b);
@@ -3358,8 +3708,8 @@ function createUi(o) {
3358
3708
  hud(s) {
3359
3709
  toolBox.textContent = "";
3360
3710
  toolBox.append(document.createTextNode(s.toolName));
3361
- if (s.ammo) toolBox.append(mk2("small", void 0, s.ammo));
3362
- if (s.camera) toolBox.append(mk2("small", void 0, t(`camera.${s.camera}`)));
3711
+ if (s.ammo) toolBox.append(mk3("small", void 0, s.ammo));
3712
+ if (s.camera) toolBox.append(mk3("small", void 0, t(`camera.${s.camera}`)));
3363
3713
  const helpText = s.camera ? `${t("ui.help")} \xB7 ${t("ui.help.camera")}` : t("ui.help");
3364
3714
  if (help.textContent !== helpText) help.textContent = helpText;
3365
3715
  if (s.status) status.textContent = s.status;
@@ -3382,7 +3732,7 @@ function createUi(o) {
3382
3732
  ];
3383
3733
  if (s.held.weapon) keys.push(["H", t("hud.equip", { weapon: s.held.weapon })]);
3384
3734
  for (const [k, label] of keys) {
3385
- held.append(mk2("kbd", void 0, k), document.createTextNode(` ${label} `));
3735
+ held.append(mk3("kbd", void 0, k), document.createTextNode(` ${label} `));
3386
3736
  }
3387
3737
  } else held.style.display = "none";
3388
3738
  const key = s.slots.join("|") + s.page;
@@ -3392,9 +3742,9 @@ function createUi(o) {
3392
3742
  slotsEl.length = 0;
3393
3743
  hudPage = s.page - 1;
3394
3744
  s.slots.forEach((name, i) => {
3395
- const b = mk2("button", "tr-slot");
3745
+ const b = mk3("button", "tr-slot");
3396
3746
  b.type = "button";
3397
- b.append(mk2("b", void 0, String((i + 1) % 10)), document.createTextNode(name));
3747
+ b.append(mk3("b", void 0, String((i + 1) % 10)), document.createTextNode(name));
3398
3748
  b.title = name;
3399
3749
  b.onclick = () => o.onTool(hudPage * 10 + i);
3400
3750
  hotbar.appendChild(b);
@@ -3426,6 +3776,7 @@ function createUi(o) {
3426
3776
  refresh() {
3427
3777
  renderTabs();
3428
3778
  renderScenarios();
3779
+ tablePanel?.refresh();
3429
3780
  },
3430
3781
  showGroup(group) {
3431
3782
  activeGroup = group;
@@ -3440,6 +3791,7 @@ function createUi(o) {
3440
3791
  },
3441
3792
  dispose() {
3442
3793
  offLog();
3794
+ tablePanel?.dispose();
3443
3795
  if (toastTimer) clearTimeout(toastTimer);
3444
3796
  root.remove();
3445
3797
  }
@@ -3450,6 +3802,7 @@ function createUi(o) {
3450
3802
 
3451
3803
  // src/room.ts
3452
3804
  var FIXED_DT = 1 / 60;
3805
+ var SHOVE_SPEED = 0.04;
3453
3806
  var METRICS_INTERVAL_S = 0.25;
3454
3807
  var CARRY_NEAR = 0.62;
3455
3808
  var CARRY_PALM = 0.012;
@@ -3812,7 +4165,9 @@ function createTestRoom(options) {
3812
4165
  scene.add(root);
3813
4166
  root.updateMatrixWorld(true);
3814
4167
  world.add(structuredCloneSpec(o.spec), { id: o.id, position: o.position, ...o.rotationY ? { rotation: [0, Math.sin(o.rotationY / 2), 0, Math.cos(o.rotationY / 2)] } : {} });
3815
- registerEntry(o.id, o.id, root, o.spec, o.label ?? S.text[`arena.${o.id}`] ?? o.id, void 0, void 0, true, false);
4168
+ const e = registerEntry(o.id, o.id, root, o.spec, o.label ?? S.text[`arena.${o.id}`] ?? o.id, void 0, void 0, true, false);
4169
+ if (o.free) e.free = true;
4170
+ e.follower = o.follow?.(root, world, o.id) || null;
3816
4171
  }
3817
4172
  };
3818
4173
  addArena();
@@ -3907,6 +4262,7 @@ function createTestRoom(options) {
3907
4262
  renderer.unbind(id);
3908
4263
  } catch {
3909
4264
  }
4265
+ e.follower?.dispose?.();
3910
4266
  if (world.has(id)) world.remove(id);
3911
4267
  e.so.root.removeFromParent();
3912
4268
  letGo(e.so.root);
@@ -3941,6 +4297,7 @@ function createTestRoom(options) {
3941
4297
  for (const e of entries.values()) {
3942
4298
  world.reset(e.so.objectId);
3943
4299
  renderer.restore(e.so.objectId);
4300
+ e.follower?.restore?.();
3944
4301
  }
3945
4302
  renderer.clear();
3946
4303
  vfx.world.clear();
@@ -3962,6 +4319,7 @@ function createTestRoom(options) {
3962
4319
  if (heldEntry === e) continue;
3963
4320
  world.reset(e.so.objectId);
3964
4321
  renderer.restore(e.so.objectId);
4322
+ e.follower?.restore?.();
3965
4323
  if (e.containerId) {
3966
4324
  const c = fluids.container(e.containerId);
3967
4325
  const f = e.item?.fluid?.container;
@@ -4265,6 +4623,19 @@ function createTestRoom(options) {
4265
4623
  if (!rendererPieces.has(objectId)) return;
4266
4624
  renderer.impulse(objectId, [dir[0] * momentum, dir[1] * momentum, dir[2] * momentum], point);
4267
4625
  };
4626
+ const shoveFree = (pushes) => {
4627
+ for (const [objectId, push] of pushes) {
4628
+ const e = entries.get(objectId);
4629
+ if (!e || e.piece || !e.free) continue;
4630
+ const mass = Math.max(0.2, massOfObject(objectId));
4631
+ if (Math.hypot(...push.impulse) / mass < SHOVE_SPEED) continue;
4632
+ const piece = renderer.makePiece(objectId, { mass });
4633
+ e.piece = piece;
4634
+ rendererPieces.set(objectId, piece);
4635
+ action("loosened", t("act.shoved", { name: e.name }), { objectId, itemId: e.so.itemId });
4636
+ renderer.impulse(objectId, push.impulse, push.point);
4637
+ }
4638
+ };
4268
4639
  const fireShots = (shots, recoil) => {
4269
4640
  if (shots.length === 0) return;
4270
4641
  const w = toolName();
@@ -4274,6 +4645,7 @@ function createTestRoom(options) {
4274
4645
  let hits = 0;
4275
4646
  let first = null;
4276
4647
  let energy = 0;
4648
+ const pushes = /* @__PURE__ */ new Map();
4277
4649
  withoutHeld(() => {
4278
4650
  for (const s of shots) {
4279
4651
  energy = Math.max(energy, s.energy);
@@ -4285,11 +4657,16 @@ function createTestRoom(options) {
4285
4657
  end = r0.point;
4286
4658
  if (!first) first = { object: r0.object, part: r0.part, point: r0.point, normal: [0, 1, 0], distance: dist3(s.origin, r0.point) };
4287
4659
  const speed = Math.max(80, s.speed);
4288
- pushPiece(r0.object, s.direction, 2 * s.energy / speed, r0.point);
4660
+ const momentum = 2 * s.energy / speed;
4661
+ pushPiece(r0.object, s.direction, momentum, r0.point);
4662
+ const push = pushes.get(r0.object) ?? { impulse: [0, 0, 0], point: r0.point };
4663
+ for (let k = 0; k < 3; k++) push.impulse[k] += s.direction[k] * momentum;
4664
+ pushes.set(r0.object, push);
4289
4665
  }
4290
4666
  if (shots.length <= 3 || rnd() < 0.34) tracer(m, end);
4291
4667
  }
4292
4668
  });
4669
+ shoveFree(pushes);
4293
4670
  player.kick(recoil[0], recoil[1]);
4294
4671
  const f = first;
4295
4672
  const d = f ? num(f.distance, 1) : "";
@@ -4381,16 +4758,25 @@ function createTestRoom(options) {
4381
4758
  let strikes = [];
4382
4759
  const kickLeg = options.kickLeg ?? "alternate";
4383
4760
  let nextLeg = "right";
4761
+ const punchHand = options.punchHand ?? "alternate";
4762
+ const punchTools = new Set(options.punchTools ?? ["hand", "fists"]);
4763
+ let nextHand = "right";
4384
4764
  const strikeUse = (u, id = toolId) => {
4385
4765
  const clip = strikeClips[id];
4386
4766
  const leg = id === "kick" ? kickLeg === "alternate" ? nextLeg : kickLeg : void 0;
4387
- const seconds = clip === void 0 ? null : avatar?.strike?.(clip, leg ? { leg } : void 0) ?? null;
4767
+ const hand = punchTools.has(id) ? punchHand === "alternate" ? nextHand : punchHand : void 0;
4768
+ const side = leg ? { leg } : hand ? { hand } : void 0;
4769
+ const seconds = clip === void 0 ? null : avatar?.strike?.(clip, side) ?? null;
4388
4770
  if (leg && seconds !== null && kickLeg === "alternate") nextLeg = leg === "right" ? "left" : "right";
4771
+ if (hand && seconds !== null && punchHand === "alternate") nextHand = hand === "right" ? "left" : "right";
4389
4772
  if (clip === void 0 || seconds === null) {
4390
4773
  meleeUse(u, id);
4391
4774
  return;
4392
4775
  }
4393
- action("strike", t(leg ? "act.strike.leg" : "act.strike", { weapon: toolName(id), clip, seconds: num(seconds, 2), leg: leg ? t(`leg.${leg}`) : "" }), { data: { weapon: id, clip, impactIn: seconds, ...leg ? { leg } : {} } });
4776
+ const text = leg ? "act.strike.leg" : hand ? "act.strike.hand" : "act.strike";
4777
+ action("strike", t(text, { weapon: toolName(id), clip, seconds: num(seconds, 2), leg: leg ? t(`leg.${leg}`) : "", hand: hand ? t(`hand.${hand}`) : "" }), {
4778
+ data: { weapon: id, clip, impactIn: seconds, ...leg ? { leg } : {}, ...hand ? { hand } : {} }
4779
+ });
4394
4780
  strikes.push({
4395
4781
  key: id,
4396
4782
  remaining: seconds,
@@ -4629,6 +5015,10 @@ function createTestRoom(options) {
4629
5015
  heldPiece = null;
4630
5016
  heldTilt = 0;
4631
5017
  };
5018
+ const handUse = () => {
5019
+ if (heldPiece) grab();
5020
+ else useTool(FIXED_DT, false);
5021
+ };
4632
5022
  const grab = () => {
4633
5023
  if (heldPiece) {
4634
5024
  const name = heldName;
@@ -5097,10 +5487,10 @@ function createTestRoom(options) {
5097
5487
  if (tiltKey || heldTilt > 0) {
5098
5488
  heldTilt = Math.max(0, Math.min(2.4, heldTilt + (tiltKey ? 2.2 : -3) * dt));
5099
5489
  }
5100
- if (useHeld && !paused) {
5490
+ if ((useHeld || useFirst) && !paused) {
5101
5491
  if (tool.def.category === "hand") {
5102
- if (useFirst) grab();
5103
- } else useTool(dt, !useFirst);
5492
+ if (useFirst) handUse();
5493
+ } else if (useHeld) useTool(dt, !useFirst);
5104
5494
  useFirst = false;
5105
5495
  }
5106
5496
  if (!paused) followHeld(dt);
@@ -5655,6 +6045,63 @@ function createTestRoom(options) {
5655
6045
  const row = (list, gap, z) => list.map((item, index) => spawn(item.id, [(index - (list.length - 1) / 2) * gap, 0, z], 0));
5656
6046
  return [...row(front, 1.6, 1.5), ...row(back, 4.5, -4)];
5657
6047
  };
6048
+ const catalogGroups = () => {
6049
+ const m = /* @__PURE__ */ new Map();
6050
+ for (const i of catalog) {
6051
+ const g = m.get(i.group) ?? [];
6052
+ g.push(i);
6053
+ m.set(i.group, g);
6054
+ }
6055
+ return m;
6056
+ };
6057
+ const tableOptions = options.testTable === false ? {} : options.testTable ?? {};
6058
+ const tableScenery = tableOptions.scenery ?? "test_table";
6059
+ const measured = /* @__PURE__ */ new Map();
6060
+ const testTable = createTestTable(
6061
+ {
6062
+ groups: catalogGroups,
6063
+ top: () => {
6064
+ const top = arena.tops.find((x) => x.id === tableScenery);
6065
+ return top && topOk(top) ? top : null;
6066
+ },
6067
+ measure: (itemId) => {
6068
+ let size = measured.get(itemId);
6069
+ if (!size) {
6070
+ const item = catalogMap.get(itemId);
6071
+ const box2 = item ? new THREE.Box3().setFromObject(buildRoot(item)) : null;
6072
+ size = box2 && !box2.isEmpty() ? { w: box2.max.x - box2.min.x, d: box2.max.z - box2.min.z } : { w: 0.3, d: 0.3 };
6073
+ measured.set(itemId, size);
6074
+ }
6075
+ return size;
6076
+ },
6077
+ spawn: (itemId, position) => spawn(itemId, position, 0),
6078
+ clear,
6079
+ resetAll,
6080
+ box: (objectId) => {
6081
+ const e = entries.get(objectId);
6082
+ if (!e || !world.has(objectId)) return null;
6083
+ const box2 = new THREE.Box3().setFromObject(e.so.root);
6084
+ return box2.isEmpty() ? null : { min: [box2.min.x, box2.min.y, box2.min.z], max: [box2.max.x, box2.max.y, box2.max.z] };
6085
+ },
6086
+ equip: (weapon) => {
6087
+ if (!arsenal.weapons.has(weapon)) return false;
6088
+ if (heldEntry) releaseHeld();
6089
+ equip(weapon);
6090
+ room.waitUntilReady();
6091
+ return true;
6092
+ },
6093
+ look: (eye, target) => player.look(eye, target),
6094
+ use: (hold) => room.use(hold),
6095
+ blastAt: (weapon, point) => {
6096
+ const def = arsenal.weapons.get(weapon);
6097
+ const explosive = def?.action.kind === "throwable" ? def.action.explosive : def?.action.kind === "launcher" ? def.action.projectile : void 0;
6098
+ return explosive !== void 0 && explosives.blastAt(weapon, explosive, point);
6099
+ },
6100
+ log: (type, text, data) => action(type, text, { data }),
6101
+ translate: t
6102
+ },
6103
+ tableOptions
6104
+ );
5658
6105
  const room = {
5659
6106
  world,
5660
6107
  renderer,
@@ -5674,6 +6121,7 @@ function createTestRoom(options) {
5674
6121
  get scenarios() {
5675
6122
  return allScenarios();
5676
6123
  },
6124
+ testTable,
5677
6125
  spawn,
5678
6126
  clear,
5679
6127
  resetAll,
@@ -5681,7 +6129,8 @@ function createTestRoom(options) {
5681
6129
  useItem,
5682
6130
  use(hold = 0) {
5683
6131
  if (tool.def.category === "hand") {
5684
- grab();
6132
+ handUse();
6133
+ flush();
5685
6134
  return;
5686
6135
  }
5687
6136
  useTool(FIXED_DT, false);
@@ -5914,7 +6363,8 @@ function createTestRoom(options) {
5914
6363
  },
5915
6364
  onAction: (id) => {
5916
6365
  if (activeBench) slowly(activeBench, id, () => benches.get(activeBench)?.act(id));
5917
- }
6366
+ },
6367
+ ...options.testTable !== false && arena.tops.some((x) => x.id === tableScenery) ? { testTable } : {}
5918
6368
  });
5919
6369
  ui.hud(hudState());
5920
6370
  showBench();
@@ -5967,19 +6417,25 @@ function structuredCloneSpec(spec) {
5967
6417
  export {
5968
6418
  DEFAULT_HAND_STANCES,
5969
6419
  DEFAULT_STRIKE_CLIPS,
6420
+ DEFAULT_TRIALS,
5970
6421
  LOADING_CLASS,
5971
6422
  LOADING_CSS,
5972
6423
  MATERIAL_COLORS,
6424
+ TRIAL_KINDS,
5973
6425
  brickWallSpec,
5974
6426
  buildFromSpec,
5975
6427
  createLoading,
5976
6428
  createLog,
5977
6429
  createTestRoom,
6430
+ createTestTable,
5978
6431
  decodeBenchState,
5979
6432
  encodeBenchState,
6433
+ fitRow,
5980
6434
  loadingMarkup,
5981
6435
  locales,
5982
6436
  nextPaint,
5983
- resolveStrings
6437
+ resolveStrings,
6438
+ rowCentres,
6439
+ trialView
5984
6440
  };
5985
6441
  //# sourceMappingURL=index.js.map