@jgengine/shell 0.7.0 → 0.9.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 (168) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +37 -1
  3. package/dist/GameHost.d.ts +12 -0
  4. package/dist/GameHost.js +33 -0
  5. package/dist/GamePlayer.d.ts +11 -0
  6. package/dist/GamePlayer.js +24 -0
  7. package/dist/GamePlayerShell.d.ts +36 -1
  8. package/dist/GamePlayerShell.js +751 -80
  9. package/dist/GameUiPreview.js +2 -1
  10. package/dist/audio/AudioComponents.js +25 -8
  11. package/dist/audio/audioEngine.d.ts +1 -0
  12. package/dist/audio/audioEngine.js +4 -0
  13. package/dist/behaviour.d.ts +12 -0
  14. package/dist/behaviour.js +27 -0
  15. package/dist/behaviourAttach.d.ts +9 -0
  16. package/dist/behaviourAttach.js +32 -0
  17. package/dist/behaviourDriver.d.ts +7 -0
  18. package/dist/behaviourDriver.js +21 -0
  19. package/dist/camera/GameCameraRig.d.ts +8 -3
  20. package/dist/camera/GameCameraRig.js +28 -14
  21. package/dist/camera/GameFirstPersonCamera.js +16 -0
  22. package/dist/camera/GameInspectionCamera.d.ts +6 -0
  23. package/dist/camera/GameInspectionCamera.js +24 -0
  24. package/dist/camera/GameOrbitCamera.js +10 -1
  25. package/dist/camera/PlayerFov.d.ts +18 -0
  26. package/dist/camera/PlayerFov.js +48 -0
  27. package/dist/camera/cameraBlendMath.d.ts +13 -0
  28. package/dist/camera/cameraBlendMath.js +36 -0
  29. package/dist/camera/cameraRigs.d.ts +8 -1
  30. package/dist/camera/cameraRigs.js +78 -38
  31. package/dist/camera/fovPreference.d.ts +25 -0
  32. package/dist/camera/fovPreference.js +73 -0
  33. package/dist/camera/index.d.ts +7 -2
  34. package/dist/camera/index.js +7 -2
  35. package/dist/camera/inspectionCameraMath.d.ts +25 -0
  36. package/dist/camera/inspectionCameraMath.js +44 -0
  37. package/dist/camera/rigMath.d.ts +50 -1
  38. package/dist/camera/rigMath.js +91 -1
  39. package/dist/camera/rigResolve.d.ts +15 -0
  40. package/dist/camera/rigResolve.js +52 -0
  41. package/dist/camera/shakeChannel.d.ts +3 -17
  42. package/dist/camera/shakeChannel.js +2 -21
  43. package/dist/camera/shakeChannelMath.d.ts +8 -0
  44. package/dist/camera/shakeChannelMath.js +21 -0
  45. package/dist/cartridge.d.ts +145 -0
  46. package/dist/cartridge.js +245 -0
  47. package/dist/defineGame.d.ts +16 -0
  48. package/dist/defineGame.js +52 -0
  49. package/dist/devtools/CollisionDebugWorld.d.ts +5 -0
  50. package/dist/devtools/CollisionDebugWorld.js +180 -0
  51. package/dist/devtools/DevtoolsOverlay.d.ts +78 -0
  52. package/dist/devtools/DevtoolsOverlay.js +676 -0
  53. package/dist/devtools/collisionDebug.d.ts +57 -0
  54. package/dist/devtools/collisionDebug.js +127 -0
  55. package/dist/devtools/collisionDebugMath.d.ts +102 -0
  56. package/dist/devtools/collisionDebugMath.js +128 -0
  57. package/dist/environment/Daylight.d.ts +56 -0
  58. package/dist/environment/Daylight.js +101 -0
  59. package/dist/environment/EnvironmentScene.js +87 -48
  60. package/dist/environment/GroundPad.d.ts +7 -0
  61. package/dist/environment/GroundPad.js +12 -0
  62. package/dist/environment/daylightCycle.d.ts +26 -0
  63. package/dist/environment/daylightCycle.js +116 -0
  64. package/dist/environment/groundPadMath.d.ts +13 -0
  65. package/dist/environment/groundPadMath.js +10 -0
  66. package/dist/environment/index.d.ts +3 -0
  67. package/dist/environment/index.js +3 -0
  68. package/dist/environment/skyLightingPolicy.d.ts +10 -0
  69. package/dist/environment/skyLightingPolicy.js +6 -0
  70. package/dist/input/mouseLook.d.ts +27 -0
  71. package/dist/input/mouseLook.js +35 -0
  72. package/dist/materialOverride.d.ts +6 -0
  73. package/dist/materialOverride.js +28 -0
  74. package/dist/multiplayer.d.ts +16 -9
  75. package/dist/multiplayer.js +62 -12
  76. package/dist/pointer/PointerProbe.js +20 -3
  77. package/dist/pointer/pointerService.d.ts +5 -0
  78. package/dist/pointer/pointerService.js +51 -24
  79. package/dist/registry.d.ts +4 -1
  80. package/dist/registry.js +3 -1
  81. package/dist/render/modelRender.d.ts +23 -0
  82. package/dist/render/modelRender.js +106 -0
  83. package/dist/render/resolveModel.d.ts +14 -0
  84. package/dist/render/resolveModel.js +24 -0
  85. package/dist/settings/QuickControls.d.ts +4 -0
  86. package/dist/settings/QuickControls.js +42 -0
  87. package/dist/settings/SettingsChrome.d.ts +1 -0
  88. package/dist/settings/SettingsChrome.js +10 -0
  89. package/dist/settings/SettingsMenu.d.ts +6 -0
  90. package/dist/settings/SettingsMenu.js +148 -0
  91. package/dist/settings/SettingsRuntime.d.ts +11 -0
  92. package/dist/settings/SettingsRuntime.js +19 -0
  93. package/dist/settings/appliedSettings.d.ts +14 -0
  94. package/dist/settings/appliedSettings.js +27 -0
  95. package/dist/settings/settingsController.d.ts +20 -0
  96. package/dist/settings/settingsController.js +165 -0
  97. package/dist/structures/GeneratedBuilding.d.ts +10 -0
  98. package/dist/structures/GeneratedBuilding.js +96 -8
  99. package/dist/structures/index.d.ts +1 -1
  100. package/dist/structures/index.js +1 -1
  101. package/dist/terrain/CarvedTerrain.d.ts +2 -1
  102. package/dist/terrain/CarvedTerrain.js +3 -3
  103. package/dist/terrain/GrassField.d.ts +3 -1
  104. package/dist/terrain/GrassField.js +4 -2
  105. package/dist/terrain/grassBudget.d.ts +3 -0
  106. package/dist/terrain/grassBudget.js +6 -0
  107. package/dist/terrain/grassGeometry.js +1 -1
  108. package/dist/terrain/index.d.ts +1 -1
  109. package/dist/terrain/index.js +1 -1
  110. package/dist/terrain/terrainMath.d.ts +9 -0
  111. package/dist/terrain/terrainMath.js +17 -2
  112. package/dist/touch/OrientationHint.d.ts +3 -0
  113. package/dist/touch/OrientationHint.js +13 -0
  114. package/dist/touch/TouchControlsOverlay.d.ts +34 -0
  115. package/dist/touch/TouchControlsOverlay.js +279 -0
  116. package/dist/visibility/CullingProvider.d.ts +21 -0
  117. package/dist/visibility/CullingProvider.js +134 -0
  118. package/dist/vision/FrustumSensorHud.d.ts +1 -6
  119. package/dist/vision/FrustumSensorHud.js +25 -27
  120. package/dist/vision/frustumSampleEqual.d.ts +2 -0
  121. package/dist/vision/frustumSampleEqual.js +10 -0
  122. package/dist/water/Ocean.js +1 -1
  123. package/dist/water/OceanConfig.d.ts +13 -0
  124. package/dist/water/OceanConfig.js +25 -17
  125. package/dist/water/OceanShader.d.ts +1 -1
  126. package/dist/water/OceanShader.js +3 -3
  127. package/dist/water/index.d.ts +1 -1
  128. package/dist/water/index.js +1 -1
  129. package/dist/weather/FireSpreadLayer.js +7 -2
  130. package/dist/weather/RainField.d.ts +3 -1
  131. package/dist/weather/RainField.js +4 -4
  132. package/dist/weather/SnowField.d.ts +3 -1
  133. package/dist/weather/SnowField.js +4 -4
  134. package/dist/weather/fireSpreadPose.d.ts +2 -0
  135. package/dist/weather/fireSpreadPose.js +4 -0
  136. package/dist/weather/weatherMath.d.ts +5 -1
  137. package/dist/weather/weatherMath.js +7 -2
  138. package/dist/world/DataObjects.d.ts +44 -0
  139. package/dist/world/DataObjects.js +77 -0
  140. package/dist/world/GridWorldScene.d.ts +10 -0
  141. package/dist/world/GridWorldScene.js +54 -0
  142. package/dist/world/SpriteBatch.d.ts +44 -0
  143. package/dist/world/SpriteBatch.js +112 -0
  144. package/dist/world/WorldHud.d.ts +8 -1
  145. package/dist/world/WorldHud.js +90 -39
  146. package/dist/world/WorldItems.js +21 -2
  147. package/dist/world/entityPose.d.ts +14 -0
  148. package/dist/world/entityPose.js +10 -0
  149. package/dist/world/telegraphPulse.d.ts +1 -0
  150. package/dist/world/telegraphPulse.js +4 -0
  151. package/dist/world/worldBarSamples.d.ts +30 -0
  152. package/dist/world/worldBarSamples.js +51 -0
  153. package/llms.txt +2643 -0
  154. package/package.json +6 -5
  155. package/dist/demo/builderDemo.d.ts +0 -2
  156. package/dist/demo/builderDemo.js +0 -189
  157. package/dist/demo/demoGame.d.ts +0 -2
  158. package/dist/demo/demoGame.js +0 -208
  159. package/dist/demo/environmentShowcase.d.ts +0 -2
  160. package/dist/demo/environmentShowcase.js +0 -15
  161. package/dist/demo/mapDemo.d.ts +0 -2
  162. package/dist/demo/mapDemo.js +0 -206
  163. package/dist/demo/pointerDemo.d.ts +0 -2
  164. package/dist/demo/pointerDemo.js +0 -151
  165. package/dist/demo/sensorShowcase.d.ts +0 -2
  166. package/dist/demo/sensorShowcase.js +0 -131
  167. package/dist/demo/survivalDemo.d.ts +0 -2
  168. package/dist/demo/survivalDemo.js +0 -291
@@ -0,0 +1,676 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useFrame } from "@react-three/fiber";
3
+ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
4
+ import { convertAngle, devtools, instrumentLatency, LONG_FRAME_MS, parseColor, parseOverridesPayload, } from "@jgengine/core/devtools/devtools";
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";
8
+ const REFRESH_MS = 250;
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
+ };
20
+ function overridesStorageKey(gameName) {
21
+ return `jg-devtools:${gameName}`;
22
+ }
23
+ function readStoredOverrides(gameName) {
24
+ try {
25
+ const raw = localStorage.getItem(overridesStorageKey(gameName));
26
+ if (raw === null)
27
+ return 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;
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ export function persistDevtoolsOverrides(gameName) {
43
+ const overrides = devtools.overrides.export();
44
+ try {
45
+ localStorage.setItem(overridesStorageKey(gameName), JSON.stringify(overrides));
46
+ }
47
+ catch {
48
+ return overrides;
49
+ }
50
+ return overrides;
51
+ }
52
+ export function applyStoredDevtoolsOverrides(gameName) {
53
+ const stored = readStoredOverrides(gameName);
54
+ if (stored === null)
55
+ return;
56
+ const result = devtools.overrides.apply(stored);
57
+ if (result.applied === 0 && result.skipped.length === 0)
58
+ return;
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
+ }
64
+ }
65
+ export function withDevtoolsLatency(multiplayer) {
66
+ return {
67
+ ...multiplayer,
68
+ backend: {
69
+ ...instrumentLatency(multiplayer.backend, ["pushFeedEntry"]),
70
+ transport: instrumentLatency(multiplayer.backend.transport, ["joinServer", "leaveServer", "runCommand"]),
71
+ },
72
+ };
73
+ }
74
+ export function DevtoolsRendererProbe() {
75
+ const frameCounter = useRef(0);
76
+ useFrame((state) => {
77
+ frameCounter.current += 1;
78
+ if (frameCounter.current % 30 !== 0)
79
+ return;
80
+ const info = state.gl.info;
81
+ devtools.render.record({
82
+ drawCalls: info.render.calls,
83
+ triangles: info.render.triangles,
84
+ geometries: info.memory.geometries,
85
+ textures: info.memory.textures,
86
+ });
87
+ });
88
+ return null;
89
+ }
90
+ function keybindRows(input) {
91
+ const rows = [];
92
+ for (const [action, codes] of Object.entries(input ?? {})) {
93
+ const entries = flattenCodes(codes);
94
+ for (const entry of entries)
95
+ rows.push({ action, ...entry });
96
+ }
97
+ return rows;
98
+ }
99
+ function flattenCodes(codes) {
100
+ if (Array.isArray(codes)) {
101
+ return [{ codes: codes.map(bindingLabel).join(" / "), mode: "press" }];
102
+ }
103
+ const modes = codes;
104
+ const result = [];
105
+ if (modes.hold !== undefined && modes.hold.length > 0) {
106
+ result.push({ codes: modes.hold.map(bindingLabel).join(" / "), mode: "hold" });
107
+ }
108
+ if (modes.toggle !== undefined && modes.toggle.length > 0) {
109
+ result.push({ codes: modes.toggle.map(bindingLabel).join(" / "), mode: "toggle" });
110
+ }
111
+ return result;
112
+ }
113
+ const TABS = [
114
+ { id: "perf", label: "Perf" },
115
+ { id: "tune", label: "Tune" },
116
+ { id: "col", label: "Col" },
117
+ { id: "logs", label: "Logs" },
118
+ { id: "net", label: "Net" },
119
+ { id: "keys", label: "Keys" },
120
+ ];
121
+ function ms(value) {
122
+ return `${value.toFixed(1)}ms`;
123
+ }
124
+ function StatRow({ name, value, alert }) {
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 })] }));
126
+ }
127
+ function FrameBars({ frames }) {
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}`))) }));
190
+ }
191
+ function PerfPanel({ ctx }) {
192
+ const rateRef = useRef({ version: 0, at: 0, perSecond: 0 });
193
+ const frame = devtools.frame.stats();
194
+ const render = devtools.render.latest();
195
+ const longs = devtools.frame.longFrames();
196
+ const now = performance.now();
197
+ const rate = rateRef.current;
198
+ if (rate.at === 0) {
199
+ rateRef.current = { version: ctx.version(), at: now, perSecond: 0 };
200
+ }
201
+ else if (now - rate.at >= 900) {
202
+ const perSecond = ((ctx.version() - rate.version) / (now - rate.at)) * 1000;
203
+ rateRef.current = { version: ctx.version(), at: now, perSecond };
204
+ }
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" })] }));
207
+ }
208
+ function LogsPanel() {
209
+ const entries = devtools.logs.list().slice(-60);
210
+ const colors = {
211
+ error: "text-red-400",
212
+ warn: "text-amber-300",
213
+ info: "text-sky-300",
214
+ log: "text-neutral-300",
215
+ };
216
+ return (_jsxs("div", { className: "space-y-2", children: [_jsx("button", { type: "button", className: "rounded border border-neutral-600 px-2 py-0.5 text-neutral-300 hover:bg-neutral-800", onClick: () => devtools.logs.clear(), children: "Clear" }), _jsxs("div", { className: "jg-devtools-scroll max-h-64 space-y-0.5 overflow-auto font-mono text-[10px]", children: [entries.length === 0 ? _jsx("div", { className: "text-neutral-400", children: "No logs captured yet." }) : null, entries.map((entry, index) => (_jsxs("div", { className: colors[entry.level], children: [_jsxs("span", { className: "text-neutral-500", children: [new Date(entry.at).toLocaleTimeString(), " "] }), entry.message] }, index)))] })] }));
217
+ }
218
+ function NetPanel({ multiplayer }) {
219
+ const stats = devtools.latency.stats();
220
+ if (multiplayer === null) {
221
+ return _jsx("div", { className: "text-neutral-400", children: "Offline \u2014 no multiplayer backend attached." });
222
+ }
223
+ return (_jsxs("div", { className: "space-y-2", children: [_jsx(StatRow, { name: "game / user", value: `${multiplayer.gameId} / ${multiplayer.userId}` }), stats === null ? (_jsx("div", { className: "text-neutral-400", children: "No round trips observed yet \u2014 latency is sampled from real backend calls." })) : (_jsxs(_Fragment, { children: [_jsx(StatRow, { name: "last round trip", value: ms(stats.lastMs), alert: stats.lastMs > 250 }), _jsx(StatRow, { name: "avg / min / max", value: `${ms(stats.avgMs)} / ${ms(stats.minMs)} / ${ms(stats.maxMs)}` }), _jsx(StatRow, { name: "samples", value: String(stats.samples) })] }))] }));
224
+ }
225
+ function KeysPanel({ input }) {
226
+ const rows = keybindRows(input);
227
+ if (rows.length === 0)
228
+ return _jsx("div", { className: "text-neutral-400", children: "This game declares no keybinds." });
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))) }));
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
+ }
242
+ function ControlInput({ control, onWrite }) {
243
+ const value = control.read();
244
+ const write = (next) => {
245
+ if (control.write(next))
246
+ onWrite();
247
+ };
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) => {
264
+ const next = Number(event.target.value);
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] }));
272
+ }
273
+ if (control.kind === "toggle") {
274
+ return (_jsx("input", { type: "checkbox", className: "h-4 w-4 accent-emerald-400", checked: Boolean(value), onChange: (event) => write(event.target.checked) }));
275
+ }
276
+ if (control.kind === "color") {
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] }));
298
+ }
299
+ if (control.kind === "select" || control.kind === "enum") {
300
+ const choices = control.choices ??
301
+ control.options?.map((option) => ({ value: option, label: String(option) })) ??
302
+ [];
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) => {
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
+ } })] }));
335
+ }
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) }));
337
+ }
338
+ function formatPreview(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)}`;
406
+ }
407
+ function deltaSnippet(discovered) {
408
+ const overrides = devtools.overrides.export();
409
+ if (Object.keys(overrides.values).length === 0)
410
+ return null;
411
+ const discoveredById = new Map(discovered.map((entry) => [entry.id, entry]));
412
+ const tables = new Map();
413
+ const flat = {};
414
+ for (const [name, value] of Object.entries(overrides.values)) {
415
+ const entry = discoveredById.get(name);
416
+ if (entry !== undefined) {
417
+ const values = tables.get(entry.table) ?? {};
418
+ values[entry.key] = value;
419
+ tables.set(entry.table, values);
420
+ }
421
+ else {
422
+ flat[name] = value;
423
+ }
424
+ }
425
+ const parts = [];
426
+ for (const [table, values] of tables)
427
+ parts.push(`${table}: ${JSON.stringify(values, null, 2)}`);
428
+ if (Object.keys(flat).length > 0)
429
+ parts.push(`controls: ${JSON.stringify(flat, null, 2)}`);
430
+ return parts.join("\n\n");
431
+ }
432
+ function TunePanel({ gameName }) {
433
+ const [deltasCopied, setDeltasCopied] = useState(false);
434
+ const [query, setQuery] = useState("");
435
+ const controls = devtools.controls.list();
436
+ const allDiscovered = devtools.discover.list();
437
+ const persist = () => persistDevtoolsOverrides(gameName);
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));
443
+ const controlByName = new Map(controls.map((control) => [control.name, control]));
444
+ const snippet = deltaSnippet(allDiscovered);
445
+ const copyDeltas = () => {
446
+ if (snippet === null)
447
+ return;
448
+ const clipboard = navigator.clipboard;
449
+ if (clipboard !== undefined) {
450
+ void clipboard.writeText(snippet).catch(() => console.log(snippet));
451
+ }
452
+ else {
453
+ console.log(snippet);
454
+ }
455
+ setDeltasCopied(true);
456
+ setTimeout(() => setDeltasCopied(false), 1500);
457
+ };
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."] })] }));
460
+ }
461
+ const groups = new Map();
462
+ for (const entry of discovered) {
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
+ }
471
+ }
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: () => {
473
+ devtools.controls.resetAll();
474
+ persist();
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) => {
476
+ const control = entry.enabled ? controlByName.get(entry.id) : undefined;
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) => {
478
+ if (event.target.checked)
479
+ devtools.discover.enable(entry.id);
480
+ else
481
+ devtools.discover.disable(entry.id);
482
+ persist();
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;
489
+ }
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) {
580
+ return { game: playable.game.name, ...devtools.snapshot() };
581
+ }
582
+ export function DevtoolsOverlay({ open, ctx, playable, multiplayer, }) {
583
+ const [tab, setTab] = useState("perf");
584
+ const [, setTick] = useState(0);
585
+ const [copied, setCopied] = useState(false);
586
+ useSyncExternalStore(devtools.signal.subscribe, devtools.signal.version, devtools.signal.version);
587
+ useEffect(() => {
588
+ devtools.logs.captureConsole();
589
+ globalThis.__JG_DEVTOOLS = {
590
+ snapshot: () => buildLeanReport(playable),
591
+ snapshotFull: () => buildFullReport(playable),
592
+ controls: devtools.controls,
593
+ discover: devtools.discover,
594
+ frame: devtools.frame,
595
+ profile: devtools.profile,
596
+ collisionDebug,
597
+ };
598
+ }, [playable]);
599
+ useEffect(() => {
600
+ devtools.discover.scanTable("game", playable);
601
+ devtools.discover.scanTable("engine", { movement: MOVEMENT_TUNING });
602
+ }, [playable]);
603
+ useEffect(() => {
604
+ const stored = readStoredOverrides(playable.game.name);
605
+ if (stored === null)
606
+ return;
607
+ const appliedEnables = new Set();
608
+ const appliedValues = new Set();
609
+ const applyLateRegistrations = () => {
610
+ const discovered = new Map(devtools.discover.list().map((entry) => [entry.id, entry]));
611
+ const needEnable = stored.enabled.filter((id) => !appliedEnables.has(id) && discovered.get(id)?.enabled === false);
612
+ const needValue = Object.entries(stored.values).filter(([name]) => !appliedValues.has(name) && devtools.controls.get(name) !== null);
613
+ if (needEnable.length === 0 && needValue.length === 0)
614
+ return;
615
+ for (const id of needEnable)
616
+ appliedEnables.add(id);
617
+ for (const [name] of needValue)
618
+ appliedValues.add(name);
619
+ devtools.overrides.apply({ enabled: needEnable, values: Object.fromEntries(needValue) });
620
+ };
621
+ applyLateRegistrations();
622
+ return devtools.signal.subscribe(applyLateRegistrations);
623
+ }, [playable]);
624
+ useEffect(() => {
625
+ const disposers = [
626
+ devtools.probes.register("entities", () => ctx.scene.entity.list().length),
627
+ devtools.probes.register("objects", () => ctx.scene.object.list().length),
628
+ ];
629
+ return () => {
630
+ for (const dispose of disposers)
631
+ dispose();
632
+ };
633
+ }, [ctx]);
634
+ useEffect(() => {
635
+ if (!open)
636
+ return;
637
+ const interval = setInterval(() => setTick((current) => current + 1), REFRESH_MS);
638
+ return () => clearInterval(interval);
639
+ }, [open]);
640
+ if (!open)
641
+ return null;
642
+ const copyReport = () => {
643
+ const report = JSON.stringify(buildLeanReport(playable), null, 2);
644
+ const clipboard = navigator.clipboard;
645
+ if (clipboard !== undefined) {
646
+ void clipboard.writeText(report).catch(() => console.log(report));
647
+ }
648
+ else {
649
+ console.log(report);
650
+ }
651
+ setCopied(true);
652
+ setTimeout(() => setCopied(false), 1500);
653
+ };
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: `
655
+ .jg-devtools-scroll {
656
+ scrollbar-width: thin;
657
+ scrollbar-color: #52525b #18181b;
658
+ }
659
+ .jg-devtools-scroll::-webkit-scrollbar {
660
+ width: 8px;
661
+ height: 8px;
662
+ }
663
+ .jg-devtools-scroll::-webkit-scrollbar-track {
664
+ background: #18181b;
665
+ border-radius: 9999px;
666
+ }
667
+ .jg-devtools-scroll::-webkit-scrollbar-thumb {
668
+ background-color: #52525b;
669
+ border-radius: 9999px;
670
+ border: 2px solid #18181b;
671
+ }
672
+ .jg-devtools-scroll::-webkit-scrollbar-thumb:hover {
673
+ background-color: #71717a;
674
+ }
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" })] }));
676
+ }