@jgengine/shell 0.8.0 → 0.10.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.
Files changed (152) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +11 -0
  3. package/dist/GamePlayerShell.d.ts +10 -22
  4. package/dist/GamePlayerShell.js +709 -264
  5. package/dist/GameUiPreview.js +2 -1
  6. package/dist/audio/AudioComponents.js +25 -8
  7. package/dist/audio/audioEngine.d.ts +9 -0
  8. package/dist/audio/audioEngine.js +37 -0
  9. package/dist/audio/musicDirector.d.ts +31 -0
  10. package/dist/audio/musicDirector.js +125 -0
  11. package/dist/audio/musicVoices.d.ts +10 -0
  12. package/dist/audio/musicVoices.js +338 -0
  13. package/dist/audio/synthEngine.d.ts +9 -0
  14. package/dist/audio/synthEngine.js +57 -0
  15. package/dist/behaviour.d.ts +5 -24
  16. package/dist/behaviour.js +14 -50
  17. package/dist/behaviourAttach.d.ts +9 -0
  18. package/dist/behaviourAttach.js +32 -0
  19. package/dist/behaviourDriver.d.ts +7 -0
  20. package/dist/behaviourDriver.js +21 -0
  21. package/dist/camera/GameCameraRig.js +1 -1
  22. package/dist/camera/GameFirstPersonCamera.d.ts +3 -0
  23. package/dist/camera/GameFirstPersonCamera.js +35 -4
  24. package/dist/camera/GameInspectionCamera.js +12 -1
  25. package/dist/camera/GameOrbitCamera.js +44 -1
  26. package/dist/camera/PlayerFov.d.ts +18 -0
  27. package/dist/camera/PlayerFov.js +48 -0
  28. package/dist/camera/cameraBlendMath.d.ts +13 -0
  29. package/dist/camera/cameraBlendMath.js +36 -0
  30. package/dist/camera/cameraRigs.d.ts +3 -0
  31. package/dist/camera/cameraRigs.js +36 -33
  32. package/dist/camera/fovPreference.d.ts +25 -0
  33. package/dist/camera/fovPreference.js +73 -0
  34. package/dist/camera/index.d.ts +3 -0
  35. package/dist/camera/index.js +3 -0
  36. package/dist/camera/orbitCameraMath.d.ts +18 -0
  37. package/dist/camera/orbitCameraMath.js +6 -1
  38. package/dist/camera/rigMath.d.ts +10 -0
  39. package/dist/camera/rigMath.js +36 -1
  40. package/dist/camera/shakeChannel.d.ts +3 -17
  41. package/dist/camera/shakeChannel.js +2 -21
  42. package/dist/camera/shakeChannelMath.d.ts +8 -0
  43. package/dist/camera/shakeChannelMath.js +21 -0
  44. package/dist/cartridge.d.ts +145 -0
  45. package/dist/cartridge.js +246 -0
  46. package/dist/commandSink.d.ts +20 -0
  47. package/dist/commandSink.js +27 -0
  48. package/dist/defineGame.js +4 -1
  49. package/dist/devtools/CollisionDebugWorld.d.ts +5 -0
  50. package/dist/devtools/CollisionDebugWorld.js +181 -0
  51. package/dist/devtools/DevtoolsOverlay.d.ts +65 -1
  52. package/dist/devtools/DevtoolsOverlay.js +383 -41
  53. package/dist/devtools/collisionDebug.d.ts +57 -0
  54. package/dist/devtools/collisionDebug.js +127 -0
  55. package/dist/devtools/collisionDebugMath.d.ts +103 -0
  56. package/dist/devtools/collisionDebugMath.js +129 -0
  57. package/dist/environment/Daylight.d.ts +20 -8
  58. package/dist/environment/Daylight.js +63 -15
  59. package/dist/environment/EnvironmentScene.js +91 -35
  60. package/dist/environment/RoadRibbons.d.ts +7 -0
  61. package/dist/environment/RoadRibbons.js +57 -0
  62. package/dist/environment/groundPadMath.d.ts +2 -2
  63. package/dist/environment/groundPadMath.js +1 -1
  64. package/dist/environment/index.d.ts +2 -1
  65. package/dist/environment/index.js +1 -0
  66. package/dist/environment/skyLightingPolicy.d.ts +10 -0
  67. package/dist/environment/skyLightingPolicy.js +6 -0
  68. package/dist/input/mouseLook.d.ts +27 -0
  69. package/dist/input/mouseLook.js +35 -0
  70. package/dist/inputSink.d.ts +18 -0
  71. package/dist/inputSink.js +35 -0
  72. package/dist/materialOverride.d.ts +4 -6
  73. package/dist/materialOverride.js +12 -16
  74. package/dist/multiplayer.js +7 -3
  75. package/dist/pointer/PointerProbe.js +6 -2
  76. package/dist/pointer/pointerService.d.ts +3 -0
  77. package/dist/pointer/pointerService.js +6 -0
  78. package/dist/postfx/PostProcessing.d.ts +10 -0
  79. package/dist/postfx/PostProcessing.js +82 -0
  80. package/dist/postfx/gradeShader.d.ts +4 -0
  81. package/dist/postfx/gradeShader.js +64 -0
  82. package/dist/render/modelRender.d.ts +10 -1
  83. package/dist/render/modelRender.js +32 -5
  84. package/dist/render/resolveModel.d.ts +14 -0
  85. package/dist/render/resolveModel.js +24 -0
  86. package/dist/settings/QuickControls.d.ts +4 -0
  87. package/dist/settings/QuickControls.js +42 -0
  88. package/dist/settings/SettingsChrome.d.ts +1 -0
  89. package/dist/settings/SettingsChrome.js +10 -0
  90. package/dist/settings/SettingsMenu.d.ts +6 -0
  91. package/dist/settings/SettingsMenu.js +148 -0
  92. package/dist/settings/SettingsRuntime.d.ts +11 -0
  93. package/dist/settings/SettingsRuntime.js +19 -0
  94. package/dist/settings/appliedSettings.d.ts +14 -0
  95. package/dist/settings/appliedSettings.js +27 -0
  96. package/dist/settings/settingsController.d.ts +20 -0
  97. package/dist/settings/settingsController.js +165 -0
  98. package/dist/structures/GeneratedBuilding.d.ts +10 -0
  99. package/dist/structures/GeneratedBuilding.js +96 -8
  100. package/dist/structures/index.d.ts +1 -1
  101. package/dist/structures/index.js +1 -1
  102. package/dist/terrain/CarvedTerrain.d.ts +5 -1
  103. package/dist/terrain/CarvedTerrain.js +6 -5
  104. package/dist/terrain/GrassField.d.ts +3 -1
  105. package/dist/terrain/GrassField.js +4 -2
  106. package/dist/terrain/grassBudget.d.ts +3 -0
  107. package/dist/terrain/grassBudget.js +6 -0
  108. package/dist/terrain/grassGeometry.js +1 -1
  109. package/dist/terrain/terrainDetailMaterial.d.ts +14 -0
  110. package/dist/terrain/terrainDetailMaterial.js +90 -0
  111. package/dist/terrain/terrainMath.d.ts +8 -0
  112. package/dist/terrain/terrainMath.js +9 -0
  113. package/dist/touch/OrientationHint.d.ts +3 -0
  114. package/dist/touch/OrientationHint.js +13 -0
  115. package/dist/touch/TouchControlsOverlay.d.ts +17 -1
  116. package/dist/touch/TouchControlsOverlay.js +125 -9
  117. package/dist/visibility/CullingProvider.d.ts +21 -0
  118. package/dist/visibility/CullingProvider.js +134 -0
  119. package/dist/vision/FrustumSensorHud.d.ts +1 -6
  120. package/dist/vision/FrustumSensorHud.js +25 -27
  121. package/dist/vision/frustumSampleEqual.d.ts +2 -0
  122. package/dist/vision/frustumSampleEqual.js +10 -0
  123. package/dist/water/Ocean.js +1 -1
  124. package/dist/water/OceanConfig.d.ts +13 -0
  125. package/dist/water/OceanConfig.js +25 -17
  126. package/dist/water/index.d.ts +1 -1
  127. package/dist/water/index.js +1 -1
  128. package/dist/weather/FireSpreadLayer.js +7 -2
  129. package/dist/weather/RainField.d.ts +3 -1
  130. package/dist/weather/RainField.js +4 -4
  131. package/dist/weather/SnowField.d.ts +3 -1
  132. package/dist/weather/SnowField.js +4 -4
  133. package/dist/weather/fireSpreadPose.d.ts +2 -0
  134. package/dist/weather/fireSpreadPose.js +4 -0
  135. package/dist/weather/weatherMath.d.ts +5 -1
  136. package/dist/weather/weatherMath.js +7 -2
  137. package/dist/world/DataObjects.d.ts +1 -1
  138. package/dist/world/DataObjects.js +3 -1
  139. package/dist/world/SpriteBatch.d.ts +44 -0
  140. package/dist/world/SpriteBatch.js +112 -0
  141. package/dist/world/WorldHud.d.ts +6 -1
  142. package/dist/world/WorldHud.js +95 -48
  143. package/dist/world/entityPose.d.ts +14 -0
  144. package/dist/world/entityPose.js +10 -0
  145. package/dist/world/telegraphPulse.d.ts +1 -0
  146. package/dist/world/telegraphPulse.js +4 -0
  147. package/dist/world/worldBarSamples.d.ts +30 -0
  148. package/dist/world/worldBarSamples.js +57 -0
  149. package/dist/worldSync.d.ts +10 -0
  150. package/dist/worldSync.js +19 -0
  151. package/llms.txt +1522 -1143
  152. package/package.json +4 -4
@@ -1,10 +1,22 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useFrame } from "@react-three/fiber";
3
3
  import { useEffect, useRef, useState, useSyncExternalStore } from "react";
4
- import { devtools, instrumentLatency, } from "@jgengine/core/devtools/devtools";
4
+ import { convertAngle, devtools, instrumentLatency, LONG_FRAME_MS, parseColor, parseOverridesPayload, } from "@jgengine/core/devtools/devtools";
5
5
  import { bindingLabel } from "@jgengine/core/input/actionBindings";
6
+ import { MOVEMENT_TUNING } from "@jgengine/core/movement/movementModel";
7
+ import { COLLISION_DEBUG_LAYERS, collisionDebug, } from "./collisionDebug.js";
6
8
  const REFRESH_MS = 250;
7
- const LONG_FRAME_MS = 33.4;
9
+ const PHASE_BAR_BUDGET_MS = 16.7;
10
+ const CULPRIT_HINTS = {
11
+ "outside-sim": "Cost is outside the sim driver — three.js render, React commit, GPU, GC, or tab throttle.",
12
+ sim: "Sim is slow but no named phase stands out — wrap hot onTick work with measure(\"name\", fn).",
13
+ onTick: "Game loop.onTick is the hotspot — profile inside it with measure(\"physics\"|\"ai\"|…, fn).",
14
+ pose: "Shell movement / collision / voxel step is expensive.",
15
+ actions: "Input → command dispatch / hotbar / interact is expensive.",
16
+ presence: "Multiplayer pose sync is expensive.",
17
+ pickup: "Auto-pickup nearest scan is expensive.",
18
+ "time+input": "Clock advance or input publish is unexpectedly heavy.",
19
+ };
8
20
  function overridesStorageKey(gameName) {
9
21
  return `jg-devtools:${gameName}`;
10
22
  }
@@ -13,8 +25,15 @@ function readStoredOverrides(gameName) {
13
25
  const raw = localStorage.getItem(overridesStorageKey(gameName));
14
26
  if (raw === null)
15
27
  return null;
16
- const parsed = JSON.parse(raw);
17
- return Array.isArray(parsed.enabled) && typeof parsed.values === "object" ? parsed : null;
28
+ const parsed = parseOverridesPayload(JSON.parse(raw));
29
+ if (parsed.overrides === null) {
30
+ for (const message of parsed.diagnostics)
31
+ console.warn(`[jgengine:devtools] ${message}`);
32
+ return null;
33
+ }
34
+ for (const message of parsed.diagnostics)
35
+ console.info(`[jgengine:devtools] ${message}`);
36
+ return parsed.overrides;
18
37
  }
19
38
  catch {
20
39
  return null;
@@ -34,11 +53,14 @@ export function applyStoredDevtoolsOverrides(gameName) {
34
53
  const stored = readStoredOverrides(gameName);
35
54
  if (stored === null)
36
55
  return;
37
- const applied = stored.enabled.length + Object.keys(stored.values).length;
38
- if (applied === 0)
56
+ const result = devtools.overrides.apply(stored);
57
+ if (result.applied === 0 && result.skipped.length === 0)
39
58
  return;
40
- devtools.overrides.apply(stored);
41
- console.info(`[jgengine:devtools] applied ${applied} stored override(s) for ${gameName}`);
59
+ console.info(`[jgengine:devtools] applied ${result.applied} stored override(s) for ${gameName}` +
60
+ (result.skipped.length > 0 ? ` · skipped ${result.skipped.length}` : ""));
61
+ for (const entry of result.skipped) {
62
+ console.warn(`[jgengine:devtools] skipped override ${entry.id}: ${entry.reason}`);
63
+ }
42
64
  }
43
65
  export function withDevtoolsLatency(multiplayer) {
44
66
  return {
@@ -91,6 +113,7 @@ function flattenCodes(codes) {
91
113
  const TABS = [
92
114
  { id: "perf", label: "Perf" },
93
115
  { id: "tune", label: "Tune" },
116
+ { id: "col", label: "Col" },
94
117
  { id: "logs", label: "Logs" },
95
118
  { id: "net", label: "Net" },
96
119
  { id: "keys", label: "Keys" },
@@ -102,12 +125,74 @@ function StatRow({ name, value, alert }) {
102
125
  return (_jsxs("div", { className: "flex items-baseline justify-between gap-3", children: [_jsx("span", { className: "text-neutral-400", children: name }), _jsx("span", { className: `font-mono ${alert === true ? "text-red-400" : "text-neutral-100"}`, children: value })] }));
103
126
  }
104
127
  function FrameBars({ frames }) {
105
- return (_jsx("div", { className: "flex h-10 items-end gap-px", children: frames.map((frameMs, index) => (_jsx("div", { className: `w-1 rounded-sm ${frameMs > LONG_FRAME_MS ? "bg-red-500" : "bg-emerald-500/80"}`, style: { height: `${Math.min(100, (frameMs / (LONG_FRAME_MS * 2)) * 100)}%` } }, index))) }));
128
+ return (_jsx("div", { className: "flex h-10 items-end gap-px", children: frames.map((frameMs, index) => (_jsx("div", { className: `w-1 rounded-sm ${frameMs > LONG_FRAME_MS ? "bg-red-500" : "bg-emerald-500/80"}`, style: { height: `${Math.min(100, (frameMs / (LONG_FRAME_MS * 2)) * 100)}%` }, title: `${frameMs.toFixed(1)}ms` }, index))) }));
129
+ }
130
+ function SectionLabel({ children }) {
131
+ return _jsx("div", { className: "text-[9px] uppercase tracking-wide text-neutral-500", children: children });
132
+ }
133
+ function PhaseBars({ phases, avgSimMs }) {
134
+ if (phases.length === 0) {
135
+ return _jsx("div", { className: "text-neutral-500", children: "No phase samples yet." });
136
+ }
137
+ const budget = Math.max(PHASE_BAR_BUDGET_MS, avgSimMs, ...phases.map((phase) => phase.avgMs));
138
+ return (_jsx("div", { className: "space-y-1", children: phases.slice(0, 8).map((phase) => {
139
+ const hot = phase.avgMs > 4 || phase.maxMs > 8;
140
+ return (_jsxs("div", { className: "space-y-0.5", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-2", children: [_jsx("span", { className: `truncate ${hot ? "text-amber-300" : "text-neutral-300"}`, title: phase.name, children: phase.name }), _jsxs("span", { className: `shrink-0 font-mono ${hot ? "text-amber-300" : "text-neutral-100"}`, children: [ms(phase.avgMs), _jsx("span", { className: "text-neutral-500", children: " avg" }), _jsx("span", { className: "text-neutral-600", children: " \u00B7 " }), ms(phase.maxMs), _jsx("span", { className: "text-neutral-500", children: " max" }), _jsx("span", { className: "text-neutral-600", children: " \u00B7 " }), phase.pctOfSim.toFixed(0), "%"] })] }), _jsx("div", { className: "h-1.5 overflow-hidden rounded-full bg-neutral-800", children: _jsx("div", { className: `h-full rounded-full ${hot ? "bg-amber-400" : "bg-emerald-500/80"}`, style: { width: `${Math.min(100, (phase.avgMs / budget) * 100)}%` } }) })] }, phase.name));
141
+ }) }));
142
+ }
143
+ function BudgetSplit({ simMs, outsideMs }) {
144
+ const total = Math.max(1e-6, simMs + outsideMs);
145
+ const simPct = (simMs / total) * 100;
146
+ const outsideHot = outsideMs > simMs && outsideMs > 4;
147
+ const simHot = simMs > 8;
148
+ return (_jsxs("div", { className: "space-y-1", children: [_jsxs("div", { className: "flex h-2 overflow-hidden rounded-full bg-neutral-800", children: [_jsx("div", { className: `h-full ${simHot ? "bg-amber-400" : "bg-sky-400"}`, style: { width: `${simPct}%` }, title: `sim ${ms(simMs)}` }), _jsx("div", { className: `h-full ${outsideHot ? "bg-red-500" : "bg-violet-400/80"}`, style: { width: `${100 - simPct}%` }, title: `outside sim ${ms(outsideMs)}` })] }), _jsxs("div", { className: "flex justify-between gap-2 font-mono text-[10px]", children: [_jsxs("span", { className: simHot ? "text-amber-300" : "text-sky-300", children: ["sim ", ms(simMs)] }), _jsxs("span", { className: outsideHot ? "text-red-400" : "text-violet-300", children: ["outside ", ms(outsideMs)] })] }), _jsx("div", { className: "text-[10px] text-neutral-500", children: "outside = render / React / GPU / GC / missed vsync \u2014 not measured inside the sim driver" })] }));
149
+ }
150
+ function diagnose(frame, longs) {
151
+ if (frame.fps >= 55 && frame.longFrames === 0 && longs.length === 0)
152
+ return null;
153
+ const recent = longs.slice(-5);
154
+ const culpritCounts = new Map();
155
+ for (const event of recent) {
156
+ culpritCounts.set(event.culprit, (culpritCounts.get(event.culprit) ?? 0) + 1);
157
+ }
158
+ let topCulprit = null;
159
+ let topCount = 0;
160
+ for (const [name, count] of culpritCounts) {
161
+ if (count > topCount) {
162
+ topCulprit = name;
163
+ topCount = count;
164
+ }
165
+ }
166
+ if (topCulprit === null && frame.phases[0] !== undefined && frame.avgSimMs > 8) {
167
+ topCulprit = frame.phases[0].name;
168
+ }
169
+ if (topCulprit === null && frame.avgOutsideMs > frame.avgSimMs && frame.avgOutsideMs > 4) {
170
+ topCulprit = "outside-sim";
171
+ }
172
+ if (topCulprit === null) {
173
+ if (frame.p95FrameMs > LONG_FRAME_MS)
174
+ return `p95 ${ms(frame.p95FrameMs)} — hitching without a clear phase; check long-frame log as it fills.`;
175
+ return null;
176
+ }
177
+ const hint = CULPRIT_HINTS[topCulprit] ?? `Hotspot “${topCulprit}” — dig into that phase with measure() or reduce its work.`;
178
+ const countText = topCount > 0 ? ` (${topCount}/${recent.length || topCount} recent long frames)` : "";
179
+ return `${topCulprit}${countText}: ${hint}`;
180
+ }
181
+ function LongFrameList({ events }) {
182
+ if (events.length === 0) {
183
+ return _jsxs("div", { className: "text-neutral-500", children: ["No long frames (>", ms(LONG_FRAME_MS), ") captured yet."] });
184
+ }
185
+ const newest = [...events].reverse().slice(0, 12);
186
+ return (_jsx("div", { className: "jg-devtools-scroll max-h-40 space-y-1.5 overflow-auto", children: newest.map((event, index) => (_jsxs("div", { className: "rounded border border-neutral-800 bg-neutral-900/60 px-1.5 py-1", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-2", children: [_jsx("span", { className: "font-mono text-red-400", children: ms(event.frameMs) }), _jsx("span", { className: "truncate text-amber-300", title: event.culprit, children: event.culprit }), _jsx("span", { className: "shrink-0 text-neutral-500", children: new Date(event.at).toLocaleTimeString() })] }), _jsx("div", { className: "mt-0.5 text-[10px] leading-snug text-neutral-300", children: event.reason }), event.phases.length > 0 ? (_jsxs("div", { className: "mt-0.5 font-mono text-[9px] text-neutral-500", children: [event.phases
187
+ .slice(0, 4)
188
+ .map((phase) => `${phase.name} ${phase.ms.toFixed(1)}`)
189
+ .join(" · "), event.outsideMs >= 2 ? ` · outside ${event.outsideMs.toFixed(1)}` : ""] })) : event.outsideMs >= 2 ? (_jsxs("div", { className: "mt-0.5 font-mono text-[9px] text-neutral-500", children: ["outside ", event.outsideMs.toFixed(1)] })) : null, event.render !== null ? (_jsxs("div", { className: "mt-0.5 font-mono text-[9px] text-neutral-600", children: ["draws ", event.render.drawCalls, " \u00B7 tris ", event.render.triangles.toLocaleString()] })) : null] }, `${event.at}-${index}`))) }));
106
190
  }
107
191
  function PerfPanel({ ctx }) {
108
192
  const rateRef = useRef({ version: 0, at: 0, perSecond: 0 });
109
193
  const frame = devtools.frame.stats();
110
194
  const render = devtools.render.latest();
195
+ const longs = devtools.frame.longFrames();
111
196
  const now = performance.now();
112
197
  const rate = rateRef.current;
113
198
  if (rate.at === 0) {
@@ -117,7 +202,8 @@ function PerfPanel({ ctx }) {
117
202
  const perSecond = ((ctx.version() - rate.version) / (now - rate.at)) * 1000;
118
203
  rateRef.current = { version: ctx.version(), at: now, perSecond };
119
204
  }
120
- return (_jsxs("div", { className: "space-y-2", children: [frame === null ? (_jsx("div", { className: "text-neutral-400", children: "Waiting for frames\u2026" })) : (_jsxs(_Fragment, { children: [_jsx(FrameBars, { frames: frame.recentFrameMs }), _jsx(StatRow, { name: "fps", value: frame.fps.toFixed(0), alert: frame.fps < 50 }), _jsx(StatRow, { name: "frame avg / p95 / max", value: `${ms(frame.avgFrameMs)} / ${ms(frame.p95FrameMs)} / ${ms(frame.maxFrameMs)}`, alert: frame.p95FrameMs > LONG_FRAME_MS }), _jsx(StatRow, { name: "sim avg / max", value: `${ms(frame.avgSimMs)} / ${ms(frame.maxSimMs)}`, alert: frame.avgSimMs > 8 }), _jsx(StatRow, { name: `long frames (of ${frame.samples})`, value: String(frame.longFrames), alert: frame.longFrames > 5 })] })), render !== null ? (_jsxs(_Fragment, { children: [_jsx(StatRow, { name: "draw calls", value: String(render.drawCalls), alert: render.drawCalls > 1000 }), _jsx(StatRow, { name: "triangles", value: render.triangles.toLocaleString() }), _jsx(StatRow, { name: "geometries / textures", value: `${render.geometries} / ${render.textures}` })] })) : null, _jsx(StatRow, { name: "state notifies /s", value: rateRef.current.perSecond.toFixed(0), alert: rateRef.current.perSecond > 90 }), Object.entries(devtools.probes.read()).map(([name, value]) => (_jsx(StatRow, { name: name, value: String(value) }, name)))] }));
205
+ const diagnosis = frame !== null ? diagnose(frame, longs) : null;
206
+ return (_jsxs("div", { className: "jg-devtools-scroll max-h-[28rem] space-y-3 overflow-auto", children: [frame === null ? (_jsx("div", { className: "text-neutral-400", children: "Waiting for frames\u2026" })) : (_jsxs(_Fragment, { children: [_jsx(FrameBars, { frames: frame.recentFrameMs }), _jsx(StatRow, { name: "fps", value: frame.fps.toFixed(0), alert: frame.fps < 50 }), _jsx(StatRow, { name: "frame avg / p95 / max", value: `${ms(frame.avgFrameMs)} / ${ms(frame.p95FrameMs)} / ${ms(frame.maxFrameMs)}`, alert: frame.p95FrameMs > LONG_FRAME_MS }), _jsx(StatRow, { name: "sim avg / max", value: `${ms(frame.avgSimMs)} / ${ms(frame.maxSimMs)}`, alert: frame.avgSimMs > 8 }), _jsx(StatRow, { name: "outside avg / max", value: `${ms(frame.avgOutsideMs)} / ${ms(frame.maxOutsideMs)}`, alert: frame.avgOutsideMs > 12 }), _jsx(StatRow, { name: `long frames (of ${frame.samples})`, value: String(frame.longFrames), alert: frame.longFrames > 5 }), diagnosis !== null ? (_jsxs("div", { className: "rounded border border-amber-500/40 bg-amber-500/10 px-2 py-1.5 text-[10px] leading-snug text-amber-100", children: [_jsx("span", { className: "font-semibold text-amber-300", children: "Why slow \u00B7 " }), diagnosis] })) : (_jsx("div", { className: "text-[10px] text-emerald-400/90", children: "Frame budget healthy \u2014 no dominant hitch pattern." })), _jsxs("div", { className: "space-y-1", children: [_jsx(SectionLabel, { children: "frame budget" }), _jsx(BudgetSplit, { simMs: frame.avgSimMs, outsideMs: frame.avgOutsideMs })] }), _jsxs("div", { className: "space-y-1", children: [_jsx(SectionLabel, { children: "sim phases (avg)" }), _jsx(PhaseBars, { phases: frame.phases, avgSimMs: frame.avgSimMs })] })] })), _jsxs("div", { className: "space-y-1", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx(SectionLabel, { children: "long frames" }), longs.length > 0 ? (_jsx("button", { type: "button", className: "rounded border border-neutral-700 px-1.5 py-0.5 text-[9px] text-neutral-400 hover:bg-neutral-800", onClick: () => devtools.frame.clearLongFrames(), children: "Clear" })) : null] }), _jsx(LongFrameList, { events: longs })] }), render !== null ? (_jsxs("div", { className: "space-y-1", children: [_jsx(SectionLabel, { children: "render sample" }), _jsx(StatRow, { name: "draw calls", value: String(render.drawCalls), alert: render.drawCalls > 1000 }), _jsx(StatRow, { name: "triangles", value: render.triangles.toLocaleString() }), _jsx(StatRow, { name: "geometries / textures", value: `${render.geometries} / ${render.textures}` })] })) : null, _jsxs("div", { className: "space-y-1", children: [_jsx(SectionLabel, { children: "probes" }), _jsx(StatRow, { name: "state notifies /s", value: rateRef.current.perSecond.toFixed(0), alert: rateRef.current.perSecond > 90 }), Object.entries(devtools.probes.read()).map(([name, value]) => (_jsx(StatRow, { name: name, value: String(value) }, name)))] }), _jsx("div", { className: "text-[9px] leading-snug text-neutral-600", children: "Game code: measure(\"physics\", () => \u2026) inside onTick. Agents: snapshot().longFrames + frame.phases" })] }));
121
207
  }
122
208
  function LogsPanel() {
123
209
  const entries = devtools.logs.list().slice(-60);
@@ -142,35 +228,181 @@ function KeysPanel({ input }) {
142
228
  return _jsx("div", { className: "text-neutral-400", children: "This game declares no keybinds." });
143
229
  return (_jsx("div", { className: "jg-devtools-scroll max-h-64 space-y-0.5 overflow-auto", children: rows.map((row, index) => (_jsxs("div", { className: "flex items-baseline justify-between gap-3", children: [_jsx("span", { className: "text-neutral-300", children: row.action }), _jsxs("span", { className: "font-mono text-neutral-100", children: [row.codes, row.mode !== "press" ? _jsx("span", { className: "ml-1 text-[9px] uppercase text-neutral-500", children: row.mode }) : null] })] }, index))) }));
144
230
  }
231
+ const LAYER_LABELS = {
232
+ hitboxes: "Damage hitboxes",
233
+ bodies: "Physical bodies",
234
+ projectiles: "Projectile paths",
235
+ muzzles: "Muzzle / shot origins",
236
+ aimLaser: "Aim laser (authoritative)",
237
+ };
238
+ function ColPanel() {
239
+ const state = useSyncExternalStore(collisionDebug.subscribe, () => collisionDebug.getState(), () => collisionDebug.getState());
240
+ return (_jsxs("div", { className: "space-y-2", children: [_jsx("div", { className: "text-[10px] text-neutral-500", children: "F2 world collision debugger \u00B7 layers stay on when panel closes \u00B7 zero cost when all off" }), _jsxs("div", { className: "flex gap-1", children: [_jsx("button", { type: "button", className: "rounded border border-neutral-600 px-2 py-0.5 text-neutral-300 hover:bg-neutral-800", onClick: () => collisionDebug.setAllLayers(true), children: "All on" }), _jsx("button", { type: "button", className: "rounded border border-neutral-600 px-2 py-0.5 text-neutral-300 hover:bg-neutral-800", onClick: () => collisionDebug.setAllLayers(false), children: "All off" })] }), _jsx("div", { className: "space-y-1", children: COLLISION_DEBUG_LAYERS.map((layer) => (_jsxs("label", { className: "flex cursor-pointer items-center gap-2 text-neutral-200", children: [_jsx("input", { type: "checkbox", checked: state.layers[layer], onChange: (event) => collisionDebug.setLayer(layer, event.target.checked) }), _jsx("span", { children: LAYER_LABELS[layer] })] }, layer))) }), _jsx("div", { className: "border-t border-neutral-800 pt-1.5 font-mono text-[9px] text-neutral-500", children: "hitbox pink \u00B7 body cyan \u00B7 muzzle red \u00B7 laser lime \u00B7 X damage \u00B7 \u25CB solid \u00B7 \u00B7 miss" })] }));
241
+ }
145
242
  function ControlInput({ control, onWrite }) {
146
243
  const value = control.read();
147
244
  const write = (next) => {
148
- control.write(next);
149
- onWrite();
245
+ if (control.write(next))
246
+ onWrite();
150
247
  };
151
- if (control.kind === "slider") {
152
- return (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("input", { type: "range", className: "h-1 w-28 accent-emerald-400", min: control.min, max: control.max, step: control.step, value: Number(value), onChange: (event) => write(Number(event.target.value)) }), _jsx("input", { type: "number", className: "w-16 rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 font-mono text-neutral-100", step: control.step, value: Number(value), onChange: (event) => {
248
+ if (control.kind === "slider" || control.kind === "angle") {
249
+ const unit = control.unit ?? "rad";
250
+ const displayUnit = control.displayUnit ?? (control.kind === "angle" ? "deg" : unit);
251
+ const displayValue = control.kind === "angle" && typeof value === "number"
252
+ ? convertAngle(value, unit, displayUnit)
253
+ : Number(value);
254
+ const displayMin = control.min !== undefined && control.kind === "angle"
255
+ ? convertAngle(control.min, unit, displayUnit)
256
+ : control.min;
257
+ const displayMax = control.max !== undefined && control.kind === "angle"
258
+ ? convertAngle(control.max, unit, displayUnit)
259
+ : control.max;
260
+ const displayStep = control.step !== undefined && control.kind === "angle" && unit !== displayUnit
261
+ ? convertAngle(control.step, unit, displayUnit)
262
+ : control.step;
263
+ return (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("input", { type: "range", className: "h-1 w-28 accent-emerald-400", min: displayMin, max: displayMax, step: displayStep, value: displayValue, onChange: (event) => {
153
264
  const next = Number(event.target.value);
154
- if (!Number.isNaN(next))
155
- write(next);
156
- } })] }));
265
+ write(control.kind === "angle" ? convertAngle(next, displayUnit, unit) : next);
266
+ } }), _jsx("input", { type: "number", className: "w-16 rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 font-mono text-neutral-100", step: displayStep, value: Number(displayValue.toFixed(4)), onChange: (event) => {
267
+ const next = Number(event.target.value);
268
+ if (!Number.isNaN(next)) {
269
+ write(control.kind === "angle" ? convertAngle(next, displayUnit, unit) : next);
270
+ }
271
+ } }), control.kind === "angle" ? (_jsx("span", { className: "text-[10px] text-neutral-500", children: displayUnit })) : null] }));
157
272
  }
158
273
  if (control.kind === "toggle") {
159
274
  return (_jsx("input", { type: "checkbox", className: "h-4 w-4 accent-emerald-400", checked: Boolean(value), onChange: (event) => write(event.target.checked) }));
160
275
  }
161
276
  if (control.kind === "color") {
162
- return (_jsx("input", { type: "color", className: "h-6 w-10 cursor-pointer rounded border border-neutral-600 bg-transparent", value: String(value), onChange: (event) => write(event.target.value) }));
277
+ const parsed = parseColor(value);
278
+ const rgb = parsed?.rgb ?? "#000000";
279
+ const alpha = parsed?.alpha ?? 1;
280
+ const showAlpha = control.hasAlpha === true || parsed?.hasAlpha === true;
281
+ return (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("input", { type: "color", className: "h-6 w-10 cursor-pointer rounded border border-neutral-600 bg-transparent", value: rgb, onChange: (event) => {
282
+ if (showAlpha) {
283
+ const a = Math.round(alpha * 255)
284
+ .toString(16)
285
+ .padStart(2, "0");
286
+ write(`${event.target.value}${a}`);
287
+ }
288
+ else {
289
+ write(event.target.value);
290
+ }
291
+ } }), showAlpha ? (_jsx("input", { type: "range", className: "h-1 w-16 accent-emerald-400", min: 0, max: 1, step: 0.01, value: alpha, onChange: (event) => {
292
+ const nextAlpha = Number(event.target.value);
293
+ const a = Math.round(nextAlpha * 255)
294
+ .toString(16)
295
+ .padStart(2, "0");
296
+ write(`${rgb}${a}`);
297
+ } })) : null] }));
163
298
  }
164
- if (control.kind === "select") {
299
+ if (control.kind === "select" || control.kind === "enum") {
300
+ const choices = control.choices ??
301
+ control.options?.map((option) => ({ value: option, label: String(option) })) ??
302
+ [];
165
303
  return (_jsx("select", { className: "rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-neutral-100", value: String(value), onChange: (event) => {
166
- const match = control.options?.find((option) => String(option) === event.target.value);
167
- write(match ?? event.target.value);
168
- }, children: control.options?.map((option) => (_jsx("option", { value: String(option), children: String(option) }, String(option)))) }));
304
+ const match = choices.find((choice) => String(choice.value) === event.target.value);
305
+ if (match !== undefined)
306
+ write(match.value);
307
+ }, children: choices.map((choice) => (_jsx("option", { value: String(choice.value), children: choice.label ?? String(choice.value) }, String(choice.value)))) }));
308
+ }
309
+ if (control.kind === "vec2" || control.kind === "vec3" || control.kind === "vec4") {
310
+ const axes = Array.isArray(value) ? value : [];
311
+ const labels = control.axisLabels ?? ["x", "y", "z", "w"];
312
+ return (_jsx("div", { className: "flex flex-wrap items-center gap-1", children: axes.map((axis, index) => (_jsxs("label", { className: "flex items-center gap-0.5 text-[10px] text-neutral-400", children: [_jsx("span", { children: labels[index] }), _jsx("input", { type: "number", className: "w-14 rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 font-mono text-neutral-100", min: control.axisMin?.[index], max: control.axisMax?.[index], step: control.axisStep?.[index] ?? control.step, value: axis, onChange: (event) => {
313
+ const next = Number(event.target.value);
314
+ if (Number.isNaN(next))
315
+ return;
316
+ const copy = axes.slice();
317
+ copy[index] = next;
318
+ write(copy);
319
+ } })] }, labels[index] ?? index))) }));
320
+ }
321
+ if (control.kind === "interval") {
322
+ const interval = value !== null && typeof value === "object" && !Array.isArray(value)
323
+ ? value
324
+ : { min: 0, max: 0 };
325
+ const writeInterval = (min, max) => write({ min, max });
326
+ return (_jsxs("div", { className: "flex items-center gap-1", children: [_jsx("input", { type: "number", className: "w-14 rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 font-mono text-neutral-100", min: control.min, max: control.max, step: control.step, value: interval.min, onChange: (event) => {
327
+ const next = Number(event.target.value);
328
+ if (!Number.isNaN(next))
329
+ writeInterval(next, interval.max);
330
+ } }), _jsx("span", { className: "text-neutral-500", children: "\u2026" }), _jsx("input", { type: "number", className: "w-14 rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 font-mono text-neutral-100", min: control.min, max: control.max, step: control.step, value: interval.max, onChange: (event) => {
331
+ const next = Number(event.target.value);
332
+ if (!Number.isNaN(next))
333
+ writeInterval(interval.min, next);
334
+ } })] }));
169
335
  }
170
336
  return (_jsx("input", { type: "text", className: "w-32 rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 font-mono text-neutral-100", value: String(value), onChange: (event) => write(event.target.value) }));
171
337
  }
172
338
  function formatPreview(value) {
173
- return typeof value === "number" ? String(Math.round(value * 1000) / 1000) : String(value);
339
+ if (typeof value === "number")
340
+ return String(Math.round(value * 1000) / 1000);
341
+ if (Array.isArray(value))
342
+ return value.map((entry) => formatPreview(entry)).join(", ");
343
+ if (value !== null && typeof value === "object") {
344
+ try {
345
+ return JSON.stringify(value);
346
+ }
347
+ catch {
348
+ return String(value);
349
+ }
350
+ }
351
+ return String(value);
352
+ }
353
+ const TUNABLE_ACRONYMS = new Set([
354
+ "fov",
355
+ "fps",
356
+ "ms",
357
+ "ui",
358
+ "ai",
359
+ "id",
360
+ "uv",
361
+ "rgb",
362
+ "rgba",
363
+ "hp",
364
+ "mp",
365
+ "xp",
366
+ "npc",
367
+ "pvp",
368
+ "pve",
369
+ "rts",
370
+ "hud",
371
+ "lod",
372
+ "gpu",
373
+ "cpu",
374
+ ]);
375
+ function humanizeTunableSegment(segment) {
376
+ if (/^\d+$/.test(segment))
377
+ return `#${segment}`;
378
+ return segment
379
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
380
+ .replace(/[_-]+/g, " ")
381
+ .split(/\s+/)
382
+ .filter((part) => part.length > 0)
383
+ .map((part) => {
384
+ const lower = part.toLowerCase();
385
+ if (TUNABLE_ACRONYMS.has(lower))
386
+ return lower.toUpperCase();
387
+ return part.charAt(0).toUpperCase() + part.slice(1);
388
+ })
389
+ .join(" ");
390
+ }
391
+ function tunableGroupTitle(table, key) {
392
+ const dot = key.lastIndexOf(".");
393
+ if (dot < 0)
394
+ return humanizeTunableSegment(table);
395
+ const parent = key.slice(0, dot);
396
+ const parts = parent.split(".").map(humanizeTunableSegment);
397
+ return `${humanizeTunableSegment(table)} · ${parts.join(" · ")}`;
398
+ }
399
+ function tunableRowLabel(key) {
400
+ const leaf = key.includes(".") ? key.slice(key.lastIndexOf(".") + 1) : key;
401
+ return humanizeTunableSegment(leaf);
402
+ }
403
+ function tunableGroupKey(table, key) {
404
+ const dot = key.lastIndexOf(".");
405
+ return dot < 0 ? table : `${table}/${key.slice(0, dot)}`;
174
406
  }
175
407
  function deltaSnippet(discovered) {
176
408
  const overrides = devtools.overrides.export();
@@ -199,13 +431,17 @@ function deltaSnippet(discovered) {
199
431
  }
200
432
  function TunePanel({ gameName }) {
201
433
  const [deltasCopied, setDeltasCopied] = useState(false);
434
+ const [query, setQuery] = useState("");
202
435
  const controls = devtools.controls.list();
203
- const discovered = devtools.discover.list();
436
+ const allDiscovered = devtools.discover.list();
204
437
  const persist = () => persistDevtoolsOverrides(gameName);
205
- const discoveredIds = new Set(discovered.map((entry) => entry.id));
206
- const explicit = controls.filter((control) => !discoveredIds.has(control.name));
438
+ const discoveredIds = new Set(allDiscovered.map((entry) => entry.id));
439
+ const needle = query.trim().toLowerCase();
440
+ const matches = (id) => needle === "" || id.toLowerCase().includes(needle);
441
+ const discovered = allDiscovered.filter((entry) => entry.enabled || matches(entry.id));
442
+ const explicit = controls.filter((control) => !discoveredIds.has(control.name) && matches(control.name));
207
443
  const controlByName = new Map(controls.map((control) => [control.name, control]));
208
- const snippet = deltaSnippet(discovered);
444
+ const snippet = deltaSnippet(allDiscovered);
209
445
  const copyDeltas = () => {
210
446
  if (snippet === null)
211
447
  return;
@@ -219,19 +455,24 @@ function TunePanel({ gameName }) {
219
455
  setDeltasCopied(true);
220
456
  setTimeout(() => setDeltasCopied(false), 1500);
221
457
  };
222
- if (explicit.length === 0 && discovered.length === 0) {
223
- return (_jsxs("div", { className: "space-y-2 text-neutral-400", children: [_jsx("div", { children: "Nothing discovered." }), _jsxs("div", { className: "font-mono text-[10px] text-neutral-500", children: ["export const TUNING = { gravity: -22, skyColor: \"#87ceeb\" };", _jsx("br", {}), "Exported tables of numbers, booleans, and colors appear here", _jsx("br", {}), "automatically check one to control it live."] })] }));
458
+ if (controls.length === 0 && allDiscovered.length === 0) {
459
+ return (_jsxs("div", { className: "space-y-2 text-neutral-400", children: [_jsx("div", { children: "Nothing discovered." }), _jsxs("div", { className: "font-mono text-[10px] text-neutral-500", children: ["export const TUNING = { gravity: -22, skyColor: \"#87ceeb\" };", _jsx("br", {}), "Nested numbers/booleans/colors auto-discover. Schema kinds:", _jsx("br", {}), "vec2/3/4 · interval · angle · enum · color+alpha via tunable() or scan meta."] })] }));
224
460
  }
225
- const tables = new Map();
461
+ const groups = new Map();
226
462
  for (const entry of discovered) {
227
- const list = tables.get(entry.table) ?? [];
228
- list.push(entry);
229
- tables.set(entry.table, list);
463
+ const groupKey = tunableGroupKey(entry.table, entry.key);
464
+ const existing = groups.get(groupKey);
465
+ if (existing !== undefined) {
466
+ existing.entries.push(entry);
467
+ }
468
+ else {
469
+ groups.set(groupKey, { title: tunableGroupTitle(entry.table, entry.key), entries: [entry] });
470
+ }
230
471
  }
231
472
  return (_jsxs("div", { className: "jg-devtools-scroll max-h-72 space-y-3 overflow-auto", children: [_jsxs("div", { className: "flex gap-1.5", children: [_jsx("button", { type: "button", className: "rounded border border-neutral-600 px-2 py-0.5 text-neutral-300 hover:bg-neutral-800", onClick: () => {
232
473
  devtools.controls.resetAll();
233
474
  persist();
234
- }, children: "Reset all" }), _jsx("button", { type: "button", disabled: snippet === null, className: "rounded border border-neutral-600 px-2 py-0.5 text-neutral-300 hover:bg-neutral-800 disabled:opacity-40 disabled:hover:bg-transparent", onClick: copyDeltas, title: "Copy changed values as source snippets to paste upstream", children: deltasCopied ? "Copied" : "Copy deltas" })] }), explicit.length > 0 ? (_jsxs("div", { className: "space-y-1.5", children: [_jsx("div", { className: "text-[9px] uppercase tracking-wide text-neutral-500", children: "registered" }), explicit.map((control) => (_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx("span", { className: "text-neutral-300", title: control.name, children: control.label }), _jsx(ControlInput, { control: control, onWrite: persist })] }, control.name)))] })) : null, [...tables.entries()].map(([table, entries]) => (_jsxs("div", { className: "space-y-1.5", children: [_jsx("div", { className: "text-[9px] uppercase tracking-wide text-neutral-500", children: table }), entries.map((entry) => {
475
+ }, children: "Reset all" }), _jsx("button", { type: "button", disabled: snippet === null, className: "rounded border border-neutral-600 px-2 py-0.5 text-neutral-300 hover:bg-neutral-800 disabled:opacity-40 disabled:hover:bg-transparent", onClick: copyDeltas, title: "Copy changed values as source snippets to paste upstream", children: deltasCopied ? "Copied" : "Copy deltas" }), _jsx("input", { type: "text", value: query, onChange: (event) => setQuery(event.target.value), placeholder: "filter\u2026", className: "min-w-0 flex-1 rounded border border-neutral-600 bg-transparent px-2 py-0.5 text-neutral-200 placeholder:text-neutral-600 focus:outline-none" })] }), explicit.length === 0 && discovered.length === 0 ? (_jsxs("div", { className: "text-neutral-500", children: ["No tunables match \u201C", query, "\u201D."] })) : null, explicit.length > 0 ? (_jsxs("div", { className: "space-y-1.5", children: [_jsx("div", { className: "text-[9px] uppercase tracking-wide text-neutral-500", children: "registered" }), explicit.map((control) => (_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx("span", { className: "text-neutral-300", title: control.name, children: control.label.includes(".") ? tunableRowLabel(control.label) : control.label }), _jsx(ControlInput, { control: control, onWrite: persist })] }, control.name)))] })) : null, [...groups.entries()].map(([groupKey, group]) => (_jsxs("div", { className: "space-y-1.5", children: [_jsx("div", { className: "text-[9px] uppercase tracking-wide text-neutral-500", children: group.title }), group.entries.map((entry) => {
235
476
  const control = entry.enabled ? controlByName.get(entry.id) : undefined;
236
477
  return (_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsxs("label", { className: "flex min-w-0 items-center gap-1.5 text-neutral-300", title: entry.id, children: [_jsx("input", { type: "checkbox", className: "h-3 w-3 shrink-0 accent-emerald-400", checked: entry.enabled, onChange: (event) => {
237
478
  if (event.target.checked)
@@ -239,10 +480,103 @@ function TunePanel({ gameName }) {
239
480
  else
240
481
  devtools.discover.disable(entry.id);
241
482
  persist();
242
- } }), _jsx("span", { className: "truncate", children: entry.key })] }), control !== undefined ? (_jsx(ControlInput, { control: control, onWrite: persist })) : (_jsx("span", { className: "font-mono text-neutral-500", children: formatPreview(entry.read()) }))] }, entry.id));
243
- })] }, table)))] }));
483
+ } }), _jsx("span", { className: "truncate", children: tunableRowLabel(entry.key) })] }), control !== undefined ? (_jsx(ControlInput, { control: control, onWrite: persist })) : (_jsx("span", { className: "font-mono text-neutral-500", children: formatPreview(entry.read()) }))] }, entry.id));
484
+ })] }, groupKey)))] }));
485
+ }
486
+ function roundMs(value, digits = 1) {
487
+ const scale = 10 ** digits;
488
+ return Math.round(value * scale) / scale;
244
489
  }
245
- function buildReport(playable) {
490
+ function compactLongFrame(event) {
491
+ return {
492
+ at: event.at,
493
+ frameMs: roundMs(event.frameMs),
494
+ simMs: roundMs(event.simMs),
495
+ outsideMs: roundMs(event.outsideMs),
496
+ culprit: event.culprit,
497
+ reason: event.reason,
498
+ phases: event.phases.slice(0, 4).map((phase) => ({
499
+ name: phase.name,
500
+ ms: roundMs(phase.ms),
501
+ })),
502
+ probes: event.probes,
503
+ render: event.render,
504
+ };
505
+ }
506
+ function summarizeLongFrames(events) {
507
+ if (events.length === 0)
508
+ return null;
509
+ const byCulprit = new Map();
510
+ let maxFrameMs = 0;
511
+ let maxDraws = 0;
512
+ let maxGeometries = 0;
513
+ for (const event of events) {
514
+ byCulprit.set(event.culprit, (byCulprit.get(event.culprit) ?? 0) + 1);
515
+ maxFrameMs = Math.max(maxFrameMs, event.frameMs);
516
+ if (event.render !== null) {
517
+ maxDraws = Math.max(maxDraws, event.render.drawCalls);
518
+ maxGeometries = Math.max(maxGeometries, event.render.geometries);
519
+ }
520
+ }
521
+ const culprits = [...byCulprit.entries()]
522
+ .sort((a, b) => b[1] - a[1])
523
+ .map(([culprit, count]) => ({ culprit, count }));
524
+ return {
525
+ count: events.length,
526
+ culprits,
527
+ maxFrameMs: roundMs(maxFrameMs),
528
+ maxDrawCalls: maxDraws > 0 ? maxDraws : undefined,
529
+ maxGeometries: maxGeometries > 0 ? maxGeometries : undefined,
530
+ };
531
+ }
532
+ export function buildLeanReport(playable) {
533
+ const snap = devtools.snapshot();
534
+ const frame = snap.frame;
535
+ const longs = snap.longFrames;
536
+ const why = frame !== null ? diagnose(frame, longs) : null;
537
+ const changedControls = snap.controls
538
+ .filter((control) => !Object.is(control.value, control.initial))
539
+ .map((control) => ({ name: control.name, value: control.value, initial: control.initial }));
540
+ const enabledDiscovered = snap.discovered
541
+ .filter((entry) => entry.enabled)
542
+ .map((entry) => ({ id: entry.id, value: entry.value }));
543
+ const hotLogs = snap.logs.filter((entry) => entry.level === "warn" || entry.level === "error").slice(-20);
544
+ return {
545
+ game: playable.game.name,
546
+ at: snap.at,
547
+ why,
548
+ frame: frame === null
549
+ ? null
550
+ : {
551
+ fps: roundMs(frame.fps, 1),
552
+ avgFrameMs: roundMs(frame.avgFrameMs),
553
+ p95FrameMs: roundMs(frame.p95FrameMs),
554
+ maxFrameMs: roundMs(frame.maxFrameMs),
555
+ avgSimMs: roundMs(frame.avgSimMs),
556
+ maxSimMs: roundMs(frame.maxSimMs),
557
+ avgOutsideMs: roundMs(frame.avgOutsideMs),
558
+ maxOutsideMs: roundMs(frame.maxOutsideMs),
559
+ longFrames: frame.longFrames,
560
+ samples: frame.samples,
561
+ phases: frame.phases.slice(0, 8).map((phase) => ({
562
+ name: phase.name,
563
+ avgMs: roundMs(phase.avgMs),
564
+ maxMs: roundMs(phase.maxMs),
565
+ pctOfSim: Math.round(phase.pctOfSim),
566
+ })),
567
+ },
568
+ render: snap.render,
569
+ latency: snap.latency,
570
+ longFrameSummary: summarizeLongFrames(longs),
571
+ longFrames: longs.slice(-6).map(compactLongFrame),
572
+ probes: snap.probes,
573
+ logs: hotLogs.length > 0 ? hotLogs : undefined,
574
+ controls: changedControls.length > 0 ? changedControls : undefined,
575
+ discovered: enabledDiscovered.length > 0 ? enabledDiscovered : undefined,
576
+ discoveredCount: snap.discovered.length,
577
+ };
578
+ }
579
+ export function buildFullReport(playable) {
246
580
  return { game: playable.game.name, ...devtools.snapshot() };
247
581
  }
248
582
  export function DevtoolsOverlay({ open, ctx, playable, multiplayer, }) {
@@ -253,11 +587,19 @@ export function DevtoolsOverlay({ open, ctx, playable, multiplayer, }) {
253
587
  useEffect(() => {
254
588
  devtools.logs.captureConsole();
255
589
  globalThis.__JG_DEVTOOLS = {
256
- snapshot: () => buildReport(playable),
590
+ snapshot: () => buildLeanReport(playable),
591
+ snapshotFull: () => buildFullReport(playable),
257
592
  controls: devtools.controls,
258
593
  discover: devtools.discover,
594
+ frame: devtools.frame,
595
+ profile: devtools.profile,
596
+ collisionDebug,
259
597
  };
260
598
  }, [playable]);
599
+ useEffect(() => {
600
+ devtools.discover.scanTable("game", playable);
601
+ devtools.discover.scanTable("engine", { movement: MOVEMENT_TUNING });
602
+ }, [playable]);
261
603
  useEffect(() => {
262
604
  const stored = readStoredOverrides(playable.game.name);
263
605
  if (stored === null)
@@ -298,7 +640,7 @@ export function DevtoolsOverlay({ open, ctx, playable, multiplayer, }) {
298
640
  if (!open)
299
641
  return null;
300
642
  const copyReport = () => {
301
- const report = JSON.stringify(buildReport(playable), null, 2);
643
+ const report = JSON.stringify(buildLeanReport(playable), null, 2);
302
644
  const clipboard = navigator.clipboard;
303
645
  if (clipboard !== undefined) {
304
646
  void clipboard.writeText(report).catch(() => console.log(report));
@@ -309,7 +651,7 @@ export function DevtoolsOverlay({ open, ctx, playable, multiplayer, }) {
309
651
  setCopied(true);
310
652
  setTimeout(() => setCopied(false), 1500);
311
653
  };
312
- return (_jsxs("div", { className: "pointer-events-auto absolute left-4 top-4 z-50 w-80 rounded border border-neutral-700 bg-neutral-950/95 p-3 text-xs text-neutral-100 shadow-2xl", children: [_jsx("style", { children: `
654
+ return (_jsxs("div", { className: "pointer-events-auto absolute left-4 top-4 z-50 w-[22rem] rounded border border-neutral-700 bg-neutral-950/95 p-3 text-xs text-neutral-100 shadow-2xl", children: [_jsx("style", { children: `
313
655
  .jg-devtools-scroll {
314
656
  scrollbar-width: thin;
315
657
  scrollbar-color: #52525b #18181b;
@@ -330,5 +672,5 @@ export function DevtoolsOverlay({ open, ctx, playable, multiplayer, }) {
330
672
  .jg-devtools-scroll::-webkit-scrollbar-thumb:hover {
331
673
  background-color: #71717a;
332
674
  }
333
- ` }), _jsxs("div", { className: "mb-2 flex items-center justify-between", children: [_jsxs("span", { className: "font-semibold uppercase tracking-wide text-neutral-300", children: [playable.game.name, " devtools"] }), _jsx("button", { type: "button", className: "rounded border border-neutral-600 px-2 py-0.5 text-neutral-300 hover:bg-neutral-800", onClick: copyReport, children: copied ? "Copied" : "Copy report" })] }), _jsx("div", { className: "mb-2 flex gap-1", children: TABS.map((entry) => (_jsx("button", { type: "button", className: `rounded px-2 py-0.5 ${tab === entry.id ? "bg-neutral-100 text-neutral-950" : "text-neutral-400 hover:bg-neutral-800"}`, onClick: () => setTab(entry.id), children: entry.label }, entry.id))) }), tab === "perf" ? _jsx(PerfPanel, { ctx: ctx }) : null, tab === "logs" ? _jsx(LogsPanel, {}) : null, tab === "net" ? _jsx(NetPanel, { multiplayer: multiplayer }) : null, tab === "keys" ? _jsx(KeysPanel, { input: playable.game.input }) : null, tab === "tune" ? _jsx(TunePanel, { gameName: playable.game.name }) : null, _jsx("div", { className: "mt-2 border-t border-neutral-800 pt-1.5 text-[9px] text-neutral-500", children: "F2 toggles \u00B7 agents: window.__JG_DEVTOOLS.snapshot()" })] }));
675
+ ` }), _jsxs("div", { className: "mb-2 flex items-center justify-between", children: [_jsxs("span", { className: "font-semibold uppercase tracking-wide text-neutral-300", children: [playable.game.name, " devtools"] }), _jsx("button", { type: "button", className: "rounded border border-neutral-600 px-2 py-0.5 text-neutral-300 hover:bg-neutral-800", onClick: copyReport, children: copied ? "Copied" : "Copy report" })] }), _jsx("div", { className: "mb-2 flex gap-1", children: TABS.map((entry) => (_jsx("button", { type: "button", className: `rounded px-2 py-0.5 ${tab === entry.id ? "bg-neutral-100 text-neutral-950" : "text-neutral-400 hover:bg-neutral-800"}`, onClick: () => setTab(entry.id), children: entry.label }, entry.id))) }), tab === "perf" ? _jsx(PerfPanel, { ctx: ctx }) : null, tab === "logs" ? _jsx(LogsPanel, {}) : null, tab === "net" ? _jsx(NetPanel, { multiplayer: multiplayer }) : null, tab === "keys" ? _jsx(KeysPanel, { input: playable.game.input }) : null, tab === "tune" ? _jsx(TunePanel, { gameName: playable.game.name }) : null, tab === "col" ? _jsx(ColPanel, {}) : null, _jsx("div", { className: "mt-2 border-t border-neutral-800 pt-1.5 text-[9px] text-neutral-500", children: "F2 toggles \u00B7 Col = collision \u00B7 agents: __JG_DEVTOOLS.snapshot() \u00B7 .collisionDebug" })] }));
334
676
  }
@@ -0,0 +1,57 @@
1
+ import type { Aim } from "@jgengine/core/scene/spatial";
2
+ import type { ShotOriginPolicy } from "@jgengine/core/combat/shotOrigin";
3
+ import type { EntityPosition } from "@jgengine/core/scene/entityStore";
4
+ export type CollisionDebugLayer = "hitboxes" | "bodies" | "projectiles" | "muzzles" | "aimLaser";
5
+ export declare const COLLISION_DEBUG_LAYERS: readonly CollisionDebugLayer[];
6
+ export type CollisionDebugLayers = Record<CollisionDebugLayer, boolean>;
7
+ export interface AimProbeConfig {
8
+ from: string;
9
+ aim: Aim;
10
+ originPolicy?: ShotOriginPolicy;
11
+ maxDistance?: number;
12
+ }
13
+ export interface ProjectileDebugTrace {
14
+ id: number;
15
+ origin: EntityPosition;
16
+ at: EntityPosition;
17
+ hit: boolean;
18
+ bornMs: number;
19
+ }
20
+ export interface CollisionDebugState {
21
+ layers: CollisionDebugLayers;
22
+ aimProbe: AimProbeConfig | null;
23
+ projectileTraces: readonly ProjectileDebugTrace[];
24
+ /** Caps retained projectile paths/muzzle marks. */
25
+ maxProjectileTraces: number;
26
+ /** How long a projectile path stays visible (ms). */
27
+ projectileTraceLifeMs: number;
28
+ }
29
+ export type CollisionDebugListener = () => void;
30
+ export declare function createDefaultCollisionDebugState(): CollisionDebugState;
31
+ export declare function anyCollisionLayerOn(layers: CollisionDebugLayers): boolean;
32
+ export declare function colliderScanNeeded(layers: CollisionDebugLayers): boolean;
33
+ export declare function projectileListenNeeded(layers: CollisionDebugLayers): boolean;
34
+ export declare function aimProbeNeeded(layers: CollisionDebugLayers): boolean;
35
+ export interface CollisionDebugController {
36
+ getState(): CollisionDebugState;
37
+ subscribe(listener: CollisionDebugListener): () => void;
38
+ setLayer(layer: CollisionDebugLayer, on: boolean): void;
39
+ toggleLayer(layer: CollisionDebugLayer): void;
40
+ setLayers(partial: Partial<CollisionDebugLayers>): void;
41
+ setAllLayers(on: boolean): void;
42
+ /** Updates aim probe without notifying subscribers (per-frame safe). */
43
+ setAimProbe(probe: AimProbeConfig | null): void;
44
+ getAimProbe(): AimProbeConfig | null;
45
+ pushProjectileTrace(input: {
46
+ origin: EntityPosition;
47
+ at: EntityPosition;
48
+ hit: boolean;
49
+ nowMs?: number;
50
+ }): void;
51
+ pruneProjectileTraces(nowMs: number): void;
52
+ reset(): void;
53
+ /** True when any overlay work is scheduled. */
54
+ isActive(): boolean;
55
+ }
56
+ export declare function createCollisionDebugController(initial?: CollisionDebugState): CollisionDebugController;
57
+ export declare const collisionDebug: CollisionDebugController;