@volter/editor-game 0.5.65

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 (302) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +23 -0
  3. package/contributions/asset-budget-asset.menu.ts +26 -0
  4. package/contributions/asset-budget.action.ts +18 -0
  5. package/contributions/asset-budget.document.tsx +21 -0
  6. package/contributions/asset-budget.menu.ts +22 -0
  7. package/contributions/audio-unlock.service.ts +17 -0
  8. package/contributions/audio.utility.tsx +15 -0
  9. package/contributions/autoplay.service.ts +48 -0
  10. package/contributions/bridge.command.ts +172 -0
  11. package/contributions/build-profiles.document.tsx +21 -0
  12. package/contributions/build-progress.status.tsx +45 -0
  13. package/contributions/build.action.ts +26 -0
  14. package/contributions/build.command.ts +27 -0
  15. package/contributions/build.header.tsx +37 -0
  16. package/contributions/build.menu.ts +31 -0
  17. package/contributions/build.service.ts +31 -0
  18. package/contributions/connection.status.tsx +43 -0
  19. package/contributions/coverage.service.ts +118 -0
  20. package/contributions/edit-mode-audio.service.ts +23 -0
  21. package/contributions/edit-mode-networking.service.ts +30 -0
  22. package/contributions/game-document.service.ts +25 -0
  23. package/contributions/game-eval.command.ts +210 -0
  24. package/contributions/game.layout.ts +19 -0
  25. package/contributions/gameplay.command.ts +255 -0
  26. package/contributions/generation.service.ts +96 -0
  27. package/contributions/generations.status.tsx +53 -0
  28. package/contributions/ingest.service.ts +80 -0
  29. package/contributions/instances.command.ts +75 -0
  30. package/contributions/navmesh.menu.ts +39 -0
  31. package/contributions/navmesh.service.ts +19 -0
  32. package/contributions/network.utility.tsx +17 -0
  33. package/contributions/play.command.ts +355 -0
  34. package/contributions/profiler.action.ts +16 -0
  35. package/contributions/profiler.menu.ts +22 -0
  36. package/contributions/profiler.utility.tsx +17 -0
  37. package/contributions/react/component-board.service.ts +21 -0
  38. package/contributions/react/design-time-mount.service.ts +60 -0
  39. package/contributions/react/pasteboard.action.ts +41 -0
  40. package/contributions/react/react-inspector.service.ts +85 -0
  41. package/contributions/react/story-documents.service.ts +44 -0
  42. package/contributions/scene-document.service.ts +32 -0
  43. package/contributions/state-watch.action.ts +17 -0
  44. package/contributions/state-watch.menu.ts +23 -0
  45. package/contributions/state-watch.utility.tsx +20 -0
  46. package/contributions/team-playtest.service.ts +124 -0
  47. package/contributions/three/camera-runtime.inspector.tsx +34 -0
  48. package/contributions/three/component-board.service.ts +24 -0
  49. package/contributions/three/component-verbs.command.ts +110 -0
  50. package/contributions/three/component-verbs.service.ts +92 -0
  51. package/contributions/three/constraints.inspector.tsx +34 -0
  52. package/contributions/three/model-asset-sections.service.ts +26 -0
  53. package/contributions/three/reflection-probe-capture.inspector.tsx +32 -0
  54. package/contributions/three/story-documents.service.ts +31 -0
  55. package/contributions/three/three-authoring.service.ts +66 -0
  56. package/contributions/transport.header.tsx +19 -0
  57. package/contributions/xstate-behavior.action.ts +42 -0
  58. package/contributions/xstate-behavior.document.tsx +73 -0
  59. package/contributions/xstate-behavior.inspector.tsx +28 -0
  60. package/contributions/xstate-behavior.menu.ts +23 -0
  61. package/package.json +144 -0
  62. package/src/asset-budget/AssetBudgetPanel.tsx +1172 -0
  63. package/src/asset-budget/asset-budget-model.ts +799 -0
  64. package/src/asset-budget/basis-encoder.ts +165 -0
  65. package/src/asset-budget/gltf-io.ts +154 -0
  66. package/src/asset-budget/gltf-optimize.ts +384 -0
  67. package/src/asset-budget/image-dims.ts +78 -0
  68. package/src/asset-budget/optimize-apply.ts +221 -0
  69. package/src/audio/AudioDebuggerPanel.tsx +457 -0
  70. package/src/audio/audio-debugger-model.ts +87 -0
  71. package/src/bridge/call.ts +60 -0
  72. package/src/bridge/dispatch.ts +459 -0
  73. package/src/bridge/live-frames.ts +25 -0
  74. package/src/bridge/screenshot.ts +316 -0
  75. package/src/build/BuildProfilesPanel.tsx +476 -0
  76. package/src/build/build-session.ts +247 -0
  77. package/src/build/format-bytes.ts +14 -0
  78. package/src/command-results.ts +36 -0
  79. package/src/coverage/live-authoring-surface.ts +30 -0
  80. package/src/coverage/live-project-verbs.ts +162 -0
  81. package/src/coverage/native-system-coverage.ts +143 -0
  82. package/src/coverage/root-coverage.ts +79 -0
  83. package/src/coverage/session-coverage.ts +193 -0
  84. package/src/design-system-stories/ApplicationChrome.stories.tsx +100 -0
  85. package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +406 -0
  86. package/src/edit-mode/edit-mode-audio.ts +56 -0
  87. package/src/edit-mode/edit-mode-networking.ts +112 -0
  88. package/src/game-document/DevicePresetPicker.tsx +84 -0
  89. package/src/game-document/GameCaptureFrameButton.tsx +45 -0
  90. package/src/game-document/GameDocument.tsx +215 -0
  91. package/src/game-document/GamePanel.tsx +662 -0
  92. package/src/game-document/InstanceInspectorPicker.tsx +183 -0
  93. package/src/game-document/crowd-debug.ts +183 -0
  94. package/src/game-document/device-preview.ts +336 -0
  95. package/src/game-document/game-view-store.ts +107 -0
  96. package/src/game-document/physics-debug.ts +187 -0
  97. package/src/generation/GenerationActivity.tsx +421 -0
  98. package/src/generation/GenerationGallery.css +231 -0
  99. package/src/generation/generation-documents.tsx +338 -0
  100. package/src/generation/generation-jobs.ts +128 -0
  101. package/src/generation/generation-presentation.ts +257 -0
  102. package/src/host/adapter-reach.ts +445 -0
  103. package/src/host/adapter-runtime-bindings.ts +303 -0
  104. package/src/host/after-paint.ts +112 -0
  105. package/src/host/api/configurations.ts +66 -0
  106. package/src/host/authoring/babylon-authoring-adapter.ts +703 -0
  107. package/src/host/authoring/canvas-runtime-recognition.ts +50 -0
  108. package/src/host/authoring/contract-hierarchy-authoring.ts +144 -0
  109. package/src/host/authoring/contract-scenes-stories.ts +204 -0
  110. package/src/host/authoring/creation-site-related.ts +55 -0
  111. package/src/host/authoring/ephemeral-persistence.ts +29 -0
  112. package/src/host/authoring/gesture-persist.ts +84 -0
  113. package/src/host/authoring/ingest-data-writer.ts +232 -0
  114. package/src/host/authoring/ingest-source-persistence.ts +826 -0
  115. package/src/host/authoring/mount-isolated-pixi-screen.ts +250 -0
  116. package/src/host/authoring/mounted-authoring.ts +41 -0
  117. package/src/host/authoring/owned-pixi-ticker-listeners.ts +96 -0
  118. package/src/host/authoring/phaser-live-authoring-adapter.ts +265 -0
  119. package/src/host/authoring/pixi-authoring-adapter.ts +1554 -0
  120. package/src/host/authoring/pixi-creation-site-write-target.ts +60 -0
  121. package/src/host/authoring/pixi-isolation-assets.ts +25 -0
  122. package/src/host/authoring/pixi-live-write-target.ts +979 -0
  123. package/src/host/authoring/pixi-source-identity.ts +141 -0
  124. package/src/host/authoring/pixi-still-presentation.ts +71 -0
  125. package/src/host/authoring/pixi-structure-history.ts +237 -0
  126. package/src/host/authoring/pixi-transform-channels.ts +205 -0
  127. package/src/host/authoring/selection-remount-handoff.ts +23 -0
  128. package/src/host/authoring/source-persistence-backend.ts +373 -0
  129. package/src/host/authoring/source-refresh-revisions.ts +81 -0
  130. package/src/host/authoring/struct-write-pipe.ts +143 -0
  131. package/src/host/auto-frame-window.ts +89 -0
  132. package/src/host/binding-resolver.ts +393 -0
  133. package/src/host/browser-transpile.ts +631 -0
  134. package/src/host/canvas-entry-runtime.ts +95 -0
  135. package/src/host/components/CameraAuthoringOverlay.tsx +216 -0
  136. package/src/host/components/HeaderTelemetry.tsx +341 -0
  137. package/src/host/components/PixiIsolationSceneContent.tsx +280 -0
  138. package/src/host/components/ResolutionPicker.tsx +89 -0
  139. package/src/host/components/ThreeIsolationSceneContent.tsx +180 -0
  140. package/src/host/components/frame-debugger-model.ts +579 -0
  141. package/src/host/components/header-telemetry-model.ts +74 -0
  142. package/src/host/components/scene-document.tsx +447 -0
  143. package/src/host/components/utility-view-state.ts +87 -0
  144. package/src/host/components/world-root-stage-binding.tsx +133 -0
  145. package/src/host/components/world-root-stage.ts +1046 -0
  146. package/src/host/coverage/authoring-read-probe.ts +583 -0
  147. package/src/host/coverage/capability-coverage.ts +1587 -0
  148. package/src/host/coverage/coverage-accounting.ts +261 -0
  149. package/src/host/coverage/game-contract-seam-evidence.ts +82 -0
  150. package/src/host/coverage/project-verb-coverage.ts +148 -0
  151. package/src/host/coverage/system-adapter-coverage.ts +373 -0
  152. package/src/host/design-system-stories/StoryLayout.tsx +104 -0
  153. package/src/host/design-system-stories/fixtures/authoring.ts +247 -0
  154. package/src/host/design-system-stories/fixtures/editor-runtime.tsx +115 -0
  155. package/src/host/document-preview-three.ts +110 -0
  156. package/src/host/entry-adjudication.ts +89 -0
  157. package/src/host/game-location-guard.ts +150 -0
  158. package/src/host/game-module-access.ts +196 -0
  159. package/src/host/game-realm-page.ts +358 -0
  160. package/src/host/game-realm-reclaim.ts +55 -0
  161. package/src/host/game-realm-storage.ts +104 -0
  162. package/src/host/gameplay-export.ts +288 -0
  163. package/src/host/gameplay-recording.ts +717 -0
  164. package/src/host/gated-globals.ts +1511 -0
  165. package/src/host/history/json-history-resource.ts +254 -0
  166. package/src/host/ingest/registry.ts +261 -0
  167. package/src/host/instance-extract-actions.ts +120 -0
  168. package/src/host/instance-fork-actions.ts +120 -0
  169. package/src/host/play-control-hook.ts +26 -0
  170. package/src/host/playwright-shim.ts +479 -0
  171. package/src/host/projection/dom.ts +295 -0
  172. package/src/host/projection/pixi.ts +288 -0
  173. package/src/host/r3f-entry-runtime.ts +77 -0
  174. package/src/host/react-mount-runtime.ts +162 -0
  175. package/src/host/realm-services.ts +148 -0
  176. package/src/host/recording-preview.ts +106 -0
  177. package/src/host/roots/module-root.ts +203 -0
  178. package/src/host/roots/react-root.ts +181 -0
  179. package/src/host/same-realm-loop-gate.ts +544 -0
  180. package/src/host/scene-view-drawability.ts +69 -0
  181. package/src/host/sdk/tools.ts +31 -0
  182. package/src/host/served-bundle-runtime-modules.ts +331 -0
  183. package/src/host/server-log-bridge.ts +60 -0
  184. package/src/host/staged-projects.ts +24 -0
  185. package/src/host/stories/mounted-story-viewport-source.ts +64 -0
  186. package/src/host/stories/story-arg-descriptors.ts +50 -0
  187. package/src/host/stories/story-media-presence.ts +152 -0
  188. package/src/host/surface-content.ts +87 -0
  189. package/src/host/take-named-export.ts +23 -0
  190. package/src/host/three-ingest-runtime.ts +76 -0
  191. package/src/host/types-fastnoise-lite.d.ts +7 -0
  192. package/src/host/types-mikktspace.d.ts +20 -0
  193. package/src/host/types-troika-three-text.d.ts +7 -0
  194. package/src/host/use-active-performance-source.ts +53 -0
  195. package/src/host/viewport-pose-memory.ts +48 -0
  196. package/src/host/viewport-root-presentation.ts +40 -0
  197. package/src/ingest/active-ingest.ts +251 -0
  198. package/src/ingest/active-scene-navigation.ts +43 -0
  199. package/src/ingest/authoring/ingest-dom-surface-authoring.ts +191 -0
  200. package/src/ingest/authoring/ingest-root-adapter.ts +897 -0
  201. package/src/ingest/capture-wait-report.ts +122 -0
  202. package/src/ingest/deferred-ingest-play.ts +216 -0
  203. package/src/ingest/deferred-ingest-session.ts +23 -0
  204. package/src/ingest/discovery-public-ingest.ts +242 -0
  205. package/src/ingest/dom-stub-mark.ts +7 -0
  206. package/src/ingest/entry-load.ts +56 -0
  207. package/src/ingest/game-contract-realm.ts +34 -0
  208. package/src/ingest/game-pointer-lock.ts +89 -0
  209. package/src/ingest/held-scene-repaint.ts +73 -0
  210. package/src/ingest/host-surface-box.ts +174 -0
  211. package/src/ingest/ingest-boot-viewport.ts +54 -0
  212. package/src/ingest/ingest-canvas-scene-document.tsx +177 -0
  213. package/src/ingest/ingest-canvas-scene.ts +50 -0
  214. package/src/ingest/ingest-evidence-hook.ts +131 -0
  215. package/src/ingest/ingest-frame-snapshot.ts +183 -0
  216. package/src/ingest/ingest-play-commands.ts +134 -0
  217. package/src/ingest/ingest-play-control.ts +299 -0
  218. package/src/ingest/ingest-render-debug.ts +320 -0
  219. package/src/ingest/ingest-siblings.ts +487 -0
  220. package/src/ingest/ingest-status.ts +62 -0
  221. package/src/ingest/live-ingest-facet.ts +46 -0
  222. package/src/ingest/module-mode.ts +229 -0
  223. package/src/ingest/mount-canvas-ingest-root.ts +899 -0
  224. package/src/ingest/mount-coverage.ts +296 -0
  225. package/src/ingest/mount-dom-ingest-root.ts +282 -0
  226. package/src/ingest/mount-ingest-root.ts +744 -0
  227. package/src/ingest/mount-three-ingest-root.ts +366 -0
  228. package/src/ingest/resolve-canvas.ts +47 -0
  229. package/src/ingest/resolve-three.ts +123 -0
  230. package/src/ingest/served-bundle.ts +109 -0
  231. package/src/ingest/served-html-boot.ts +292 -0
  232. package/src/ingest/surface-canvas.ts +84 -0
  233. package/src/ingest/surface-dom.ts +84 -0
  234. package/src/ingest/surface-three.ts +128 -0
  235. package/src/ingest/types.ts +76 -0
  236. package/src/ingest/unmount-ingest-root.ts +224 -0
  237. package/src/navmesh/navmesh-actions.ts +27 -0
  238. package/src/navmesh/navmesh-handler.ts +237 -0
  239. package/src/navmesh/navmesh-workflow-store.ts +78 -0
  240. package/src/network/NetworkInspectorPanel.tsx +644 -0
  241. package/src/network/network-inspector-model.ts +225 -0
  242. package/src/play/play-boot-stall.ts +118 -0
  243. package/src/play/play-log-events.ts +26 -0
  244. package/src/play/play-mode.ts +2553 -0
  245. package/src/play/play-recording.ts +335 -0
  246. package/src/play/react-play-live-authoring.ts +165 -0
  247. package/src/play-bar/PlayBar.tsx +488 -0
  248. package/src/play-bar/PlayerCountPicker.tsx +100 -0
  249. package/src/profiler/FrameDebuggerPanel.tsx +458 -0
  250. package/src/profiler/PerformancePanel.tsx +1068 -0
  251. package/src/profiler/ProfilerPanel.tsx +53 -0
  252. package/src/profiler/frame-debugger-store.ts +97 -0
  253. package/src/profiler/main-thread-busy.ts +90 -0
  254. package/src/react/design-time-react-mount.ts +782 -0
  255. package/src/react/dom-authoring-adapter.ts +764 -0
  256. package/src/react/pasteboard-materialize.ts +154 -0
  257. package/src/react/react-inspector-section.tsx +2496 -0
  258. package/src/react/react-world-authoring-adapter.ts +3795 -0
  259. package/src/react/story-documents/story-args-section.ts +19 -0
  260. package/src/react/story-documents/story-documents.tsx +1380 -0
  261. package/src/react/story-paint-bounds.ts +94 -0
  262. package/src/react/ui-board-document.tsx +101 -0
  263. package/src/react/ui-board-title.ts +9 -0
  264. package/src/react/ui-component-board.ts +72 -0
  265. package/src/services/audio-pose-guard.ts +81 -0
  266. package/src/services/game-audio-unlock.ts +48 -0
  267. package/src/state-watch/StateWatchPanel.tsx +535 -0
  268. package/src/three/authoring/camera-runtime-inspector-section.tsx +135 -0
  269. package/src/three/authoring/constraint-inspector-section.tsx +189 -0
  270. package/src/three/authoring/design-time-renderer.ts +189 -0
  271. package/src/three/authoring/model-asset-inspector-section.css +41 -0
  272. package/src/three/authoring/model-asset-inspector-section.tsx +869 -0
  273. package/src/three/authoring/oid-source-persistence.ts +557 -0
  274. package/src/three/authoring/r3f-design-session.ts +1174 -0
  275. package/src/three/authoring/r3f-source-authoring-adapter.ts +5949 -0
  276. package/src/three/authoring/reflection-probe-inspector-section.tsx +76 -0
  277. package/src/three/authoring/spatial-audio-handles.ts +215 -0
  278. package/src/three/authoring/spatial-collider-handles.ts +229 -0
  279. package/src/three/authoring/spatial-joint-handles.ts +114 -0
  280. package/src/three/authoring/spatial-light-handles.ts +178 -0
  281. package/src/three/authoring/spatial-lod-handles.ts +93 -0
  282. package/src/three/authoring/spatial-particle-handles.ts +368 -0
  283. package/src/three/authoring/three-authoring-adapter.ts +1953 -0
  284. package/src/three/authoring/three-scene-identity.ts +19 -0
  285. package/src/three/authoring/three-spatial-handles.ts +161 -0
  286. package/src/three/authoring/typed-three-inspector.ts +528 -0
  287. package/src/three/component-verbs/extract-menu.ts +71 -0
  288. package/src/three/component-verbs/fork-menu.ts +78 -0
  289. package/src/three/component-verbs/internals-menu.ts +91 -0
  290. package/src/three/story-documents/three-story-documents.tsx +607 -0
  291. package/src/three/three-board/ThreeBoardDocument.tsx +888 -0
  292. package/src/three/three-board/board-framing.ts +412 -0
  293. package/src/three/three-board/board-layout.ts +401 -0
  294. package/src/three/three-board/board-scene.ts +901 -0
  295. package/src/three/three-board/three-component-board.ts +62 -0
  296. package/src/xstate/XStateBehaviorSection.tsx +130 -0
  297. package/src/xstate/XStateMachineInspector.tsx +566 -0
  298. package/src/xstate/character-animation-machine.fixture.ts +74 -0
  299. package/src/xstate/live-behaviors.ts +106 -0
  300. package/src/xstate/use-live-actor-state.ts +44 -0
  301. package/src/xstate/xstate-graph.ts +235 -0
  302. package/src/xstate/xstate-layout.ts +76 -0
@@ -0,0 +1,183 @@
1
+ import {
2
+ getInspectedNetworking,
3
+ inspectedInstanceId,
4
+ inspectedInstanceVersion,
5
+ setInspectedInstance,
6
+ subscribeInspectedInstance,
7
+ } from '@volter/editor-core/authoring/active-systems';
8
+ import { useEditorStore } from '@volter/editor-core/editor-runtime';
9
+ import { gameRealmDiagnostics } from '../host/gated-globals';
10
+ import { faEye } from '@fortawesome/free-solid-svg-icons';
11
+ import {
12
+ AnchoredMenu,
13
+ Button,
14
+ DisclosureIcon,
15
+ EditorIcon,
16
+ MenuItem,
17
+ MenuSeparator,
18
+ Text,
19
+ themeVars,
20
+ } from '@volter/editor-sdk/widgets';
21
+ import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
22
+ import {
23
+ focusedInstanceId,
24
+ instanceEntries,
25
+ subscribeFocusedInstance,
26
+ subscribeGameSession,
27
+ } from '../play/play-mode';
28
+
29
+ function instancesSnapshot(): string {
30
+ return instanceEntries()
31
+ .map(({ id, name }) => `${id}\u0000${name}`)
32
+ .join('\u0001');
33
+ }
34
+
35
+ function Diagnostics({ instanceId }: { instanceId: string }) {
36
+ const store = useEditorStore();
37
+ useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
38
+ const focused = useSyncExternalStore(
39
+ subscribeFocusedInstance,
40
+ focusedInstanceId,
41
+ focusedInstanceId,
42
+ );
43
+ const [, refresh] = useState(0);
44
+ useEffect(() => {
45
+ const timer = window.setInterval(() => refresh((value) => value + 1), 250);
46
+ return () => window.clearInterval(timer);
47
+ }, []);
48
+
49
+ const realm = gameRealmDiagnostics(instanceId);
50
+ const networking = getInspectedNetworking();
51
+ const timers = realm
52
+ ? (realm.loop.activeTimeouts ?? 0) +
53
+ (realm.loop.activeIntervals ?? 0) +
54
+ (realm.loop.activeAnimationFrames ?? 0) +
55
+ realm.loop.pending
56
+ : 0;
57
+ const listeners = realm ? realm.listeners.window + realm.listeners.document : 0;
58
+ const rows: [string, string][] = [
59
+ ['Mount', instanceId],
60
+ ['Simulation', store.playState === 'paused' ? 'Paused' : 'Running'],
61
+ ['Input', focused === instanceId ? 'Keyboard focus' : 'Inactive'],
62
+ [
63
+ 'Input events',
64
+ realm
65
+ ? `${realm.loop.input ?? 0} observed · ${realm.loop.inputBlocked ?? 0} blocked`
66
+ : 'Not measured',
67
+ ],
68
+ // Two different nothings: no adapter at all, and an adapter that cannot
69
+ // read connection state. Neither is "disconnected".
70
+ [
71
+ 'Network',
72
+ !networking
73
+ ? 'Not registered'
74
+ : (networking.getConnectionState?.() ?? 'Not reported by this adapter'),
75
+ ],
76
+ ['Owned callbacks', String(timers)],
77
+ ['Owned listeners', String(listeners)],
78
+ ['Realm globals', String(realm?.globals ?? 0)],
79
+ // Same-origin browser storage is intentionally disclosed rather than
80
+ // presented as isolated. A durable virtual profile would require a stable
81
+ // project-owned identity beyond the transient mount id.
82
+ [
83
+ 'Browser storage',
84
+ realm
85
+ ? `Shared origin · ${realm.storage.local.reads + realm.storage.session.reads} reads · ${realm.storage.local.writes + realm.storage.session.writes} writes`
86
+ : 'Shared origin',
87
+ ],
88
+ ];
89
+
90
+ return (
91
+ <div
92
+ role="none"
93
+ style={{
94
+ display: 'grid',
95
+ gridTemplateColumns: '112px minmax(120px, 1fr)',
96
+ gap: '4px 12px',
97
+ padding: '8px 10px',
98
+ fontSize: 10,
99
+ lineHeight: '15px',
100
+ }}
101
+ >
102
+ {rows.map(([label, value]) => (
103
+ <div key={label} style={{ display: 'contents' }}>
104
+ <span style={{ color: themeVars.content.dim }}>{label}</span>
105
+ <span
106
+ style={{
107
+ color: themeVars.content.primary,
108
+ fontFamily: label === 'Mount' ? themeVars.typography.mono : undefined,
109
+ overflow: 'hidden',
110
+ textOverflow: 'ellipsis',
111
+ }}
112
+ >
113
+ {value}
114
+ </span>
115
+ </div>
116
+ ))}
117
+ </div>
118
+ );
119
+ }
120
+
121
+ /** Runtime context selector for a split Game document. This changes only
122
+ * editor instruments; it never retargets scene authoring or agent commands. */
123
+ export function InstanceInspectorPicker() {
124
+ useSyncExternalStore(subscribeGameSession, instancesSnapshot, instancesSnapshot);
125
+ useSyncExternalStore(
126
+ subscribeInspectedInstance,
127
+ inspectedInstanceVersion,
128
+ inspectedInstanceVersion,
129
+ );
130
+ const entries = instanceEntries();
131
+ const inspectedId = inspectedInstanceId();
132
+ const selected = entries.find((entry) => entry.id === inspectedId) ?? entries[0];
133
+ const [open, setOpen] = useState(false);
134
+ const ref = useRef<HTMLButtonElement>(null);
135
+
136
+ if (entries.length < 2 || !selected) return null;
137
+ const diagnostics = gameRealmDiagnostics(selected.id);
138
+ const warning = (diagnostics?.loop.firedWhileHeld ?? 0) > 0;
139
+
140
+ return (
141
+ <div className="vgai-playbar-popover-anchor">
142
+ <Button
143
+ ref={ref}
144
+ variant="ghost"
145
+ size="compact"
146
+ aria-haspopup="menu"
147
+ aria-expanded={open}
148
+ onClick={() => setOpen((value) => !value)}
149
+ title={`Runtime instruments inspect ${selected.name}; agent-addressed commands are unchanged`}
150
+ >
151
+ <EditorIcon
152
+ icon={faEye}
153
+ size="sm"
154
+ style={{ color: warning ? themeVars.semantic.warning : undefined }}
155
+ />
156
+ <Text variant="code">Inspect {selected.name}</Text>
157
+ <DisclosureIcon direction="down" />
158
+ </Button>
159
+ {open && (
160
+ <AnchoredMenu
161
+ anchorRef={ref}
162
+ gap={3}
163
+ onDismiss={() => setOpen(false)}
164
+ style={{ minWidth: 270 }}
165
+ >
166
+ {entries.map((entry) => (
167
+ <MenuItem
168
+ key={entry.id}
169
+ role="menuitemradio"
170
+ aria-checked={entry.id === selected.id}
171
+ onSelect={() => setInspectedInstance(entry.id)}
172
+ >
173
+ {entry.id === selected.id ? '✓ ' : ' '}
174
+ {entry.name}
175
+ </MenuItem>
176
+ ))}
177
+ <MenuSeparator />
178
+ <Diagnostics instanceId={selected.id} />
179
+ </AnchoredMenu>
180
+ )}
181
+ </div>
182
+ );
183
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Play-mode crowd debug draw (W1a) — agent-clearance cylinders + velocity
3
+ * arrows for every live crowd agent, drawn INTO the running game's scene so
4
+ * the Game tab shows them over the actual gameplay.
5
+ *
6
+ * Reads only the `NavigationAdapter.crowdAgents()` capability off the active
7
+ * game's `SystemAdapters` (installed by play-mode's `setActiveSystems`) — the
8
+ * editor never touches recast's `Crowd` directly. A game whose navigation
9
+ * adapter omits `crowdAgents` simply draws nothing (capability-absence
10
+ * degradation, same doctrine as the rest of the seam).
11
+ *
12
+ * Toggled from the Game tab's Debug menu (`CenterDocuments.tsx`), mirroring
13
+ * the active-document debug-control pattern there.
14
+ */
15
+
16
+ import { getActiveSystems } from '@volter/editor-core/authoring/active-systems';
17
+ import type { NavCrowdAgentState } from '@volter/editor-project/adapter';
18
+ import { setUserData } from '@volter/threejs-runtime/ecs/user-data';
19
+ import * as THREE from 'three';
20
+ import { getGameScene } from '../play/play-mode';
21
+
22
+ let _enabled = false;
23
+ let _raf = 0;
24
+ let _group: THREE.Group | null = null;
25
+ let _attachedScene: THREE.Scene | null = null;
26
+
27
+ /** Unit cylinder scaled per-agent to (radius, height, radius). */
28
+ const _cylGeo = new THREE.CylinderGeometry(1, 1, 1, 16, 1, true);
29
+ const _cylMat = new THREE.MeshBasicMaterial({
30
+ color: 0x22ccee,
31
+ transparent: true,
32
+ opacity: 0.45,
33
+ side: THREE.DoubleSide,
34
+ depthWrite: false,
35
+ });
36
+
37
+ interface AgentVisual {
38
+ root: THREE.Group;
39
+ cylinder: THREE.Mesh;
40
+ /** Per-visual material clone, so a FAILED agent can flush red without
41
+ * repainting the whole crowd. */
42
+ cylinderMaterial: THREE.MeshBasicMaterial;
43
+ arrow: THREE.ArrowHelper;
44
+ /** The agent's REQUESTED PATH — its local corridor corners, drawn in scene
45
+ * space (not under `root`, which follows the agent). */
46
+ path: THREE.Line;
47
+ /** The move target, a small marker at the corridor's end. */
48
+ target: THREE.Mesh;
49
+ }
50
+
51
+ const _pathMat = new THREE.LineBasicMaterial({ color: 0xffaa00, transparent: true, opacity: 0.9 });
52
+ const _targetGeo = new THREE.SphereGeometry(0.12, 12, 8);
53
+ const _targetMat = new THREE.MeshBasicMaterial({ color: 0xffaa00, depthWrite: false });
54
+ const FAILED_COLOR = 0xff3344;
55
+ const OK_COLOR = 0x22ccee;
56
+
57
+ let _visuals: AgentVisual[] = [];
58
+
59
+ export function crowdDebugEnabled(): boolean {
60
+ return _enabled;
61
+ }
62
+
63
+ export function setCrowdDebugEnabled(enabled: boolean): void {
64
+ if (enabled === _enabled) return;
65
+ _enabled = enabled;
66
+ if (enabled) {
67
+ _raf = requestAnimationFrame(_tick);
68
+ } else {
69
+ cancelAnimationFrame(_raf);
70
+ _teardown();
71
+ }
72
+ }
73
+
74
+ function _tick(): void {
75
+ if (!_enabled) return;
76
+ _raf = requestAnimationFrame(_tick);
77
+ const scene = getGameScene();
78
+ const agents = scene ? (getActiveSystems().navigation?.crowdAgents?.() ?? []) : [];
79
+
80
+ // (Re)attach the group to whatever game scene is currently live — play
81
+ // restarts swap scenes out from under a long-lived toggle.
82
+ if (scene !== _attachedScene) {
83
+ if (_attachedScene && _group) _attachedScene.remove(_group);
84
+ _attachedScene = scene;
85
+ if (scene) {
86
+ if (!_group) {
87
+ _group = new THREE.Group();
88
+ _group.name = 'crowd-debug-draw';
89
+ setUserData(_group, 'editorHelper', true);
90
+ }
91
+ scene.add(_group);
92
+ }
93
+ }
94
+ if (!_group || !scene) return;
95
+
96
+ _syncVisualCount(_group, agents.length);
97
+ for (let i = 0; i < agents.length; i++) {
98
+ _updateAgentVisual(_visuals[i]!, agents[i]!);
99
+ }
100
+ }
101
+
102
+ /** Grow/shrink the pooled per-agent visuals to match the live agent count. */
103
+ function _syncVisualCount(group: THREE.Group, count: number): void {
104
+ while (_visuals.length < count) {
105
+ const root = new THREE.Group();
106
+ const cylinderMaterial = _cylMat.clone();
107
+ const cylinder = new THREE.Mesh(_cylGeo, cylinderMaterial);
108
+ const arrow = new THREE.ArrowHelper(
109
+ new THREE.Vector3(1, 0, 0),
110
+ new THREE.Vector3(),
111
+ 1,
112
+ 0xffaa00,
113
+ );
114
+ const path = new THREE.Line(new THREE.BufferGeometry(), _pathMat);
115
+ const target = new THREE.Mesh(_targetGeo, _targetMat);
116
+ root.add(cylinder);
117
+ root.add(arrow);
118
+ group.add(root);
119
+ group.add(path);
120
+ group.add(target);
121
+ _visuals.push({ root, cylinder, cylinderMaterial, arrow, path, target });
122
+ }
123
+ while (_visuals.length > count) {
124
+ const v = _visuals.pop()!;
125
+ group.remove(v.root);
126
+ group.remove(v.path);
127
+ group.remove(v.target);
128
+ v.arrow.dispose();
129
+ v.path.geometry.dispose();
130
+ v.cylinderMaterial.dispose();
131
+ }
132
+ }
133
+
134
+ function _updateAgentVisual(v: AgentVisual, a: NavCrowdAgentState): void {
135
+ v.root.position.set(a.position.x, a.position.y, a.position.z);
136
+ v.cylinder.scale.set(a.radius, a.height, a.radius);
137
+ v.cylinder.position.y = a.height / 2;
138
+ // A FAILED agent — off the navmesh, going nowhere — flushes red; everything
139
+ // else keeps the crowd colour. An adapter that predates `state` reports
140
+ // undefined and draws as walking.
141
+ v.cylinderMaterial.color.setHex(a.state === 'invalid' ? FAILED_COLOR : OK_COLOR);
142
+ // The REQUESTED PATH: the agent's local corridor, drawn from its own feet
143
+ // through the corners, with the move target marked at its own position.
144
+ const corners = a.corners ?? [];
145
+ if (corners.length > 0) {
146
+ v.path.visible = true;
147
+ v.path.geometry.setFromPoints([
148
+ new THREE.Vector3(a.position.x, a.position.y + 0.05, a.position.z),
149
+ ...corners.map((c) => new THREE.Vector3(c.x, c.y + 0.05, c.z)),
150
+ ]);
151
+ } else {
152
+ v.path.visible = false;
153
+ }
154
+ if (a.target && corners.length > 0) {
155
+ v.target.visible = true;
156
+ v.target.position.set(a.target.x, a.target.y + 0.05, a.target.z);
157
+ } else {
158
+ v.target.visible = false;
159
+ }
160
+ const speed = Math.hypot(a.velocity.x, a.velocity.y, a.velocity.z);
161
+ if (speed > 0.01) {
162
+ v.arrow.visible = true;
163
+ v.arrow.position.y = a.height / 2;
164
+ v.arrow.setDirection(
165
+ new THREE.Vector3(a.velocity.x / speed, a.velocity.y / speed, a.velocity.z / speed),
166
+ );
167
+ // Arrow length = half a second of travel, min 0.2 so it stays legible.
168
+ v.arrow.setLength(Math.max(speed * 0.5, 0.2));
169
+ } else {
170
+ v.arrow.visible = false;
171
+ }
172
+ }
173
+
174
+ function _teardown(): void {
175
+ if (_group) {
176
+ _attachedScene?.remove(_group);
177
+ for (const v of _visuals) v.arrow.dispose();
178
+ _group.clear();
179
+ }
180
+ _visuals = [];
181
+ _group = null;
182
+ _attachedScene = null;
183
+ }
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Device preview (W2c, F13 — critique O4). Chrome-device-mode-style presets
3
+ * for the Game tab: a preset constrains the game mount to a device's CSS
4
+ * resolution (letterboxed/centered in the panel, scaled down to fit — the
5
+ * scale/letterbox math is `GamePanel`'s existing device-resolution path in
6
+ * `CenterDocuments.tsx`), applies the device's DPR to the running renderers
7
+ * (`resizeGame(w, h, dpr)` → `GameSession.resize`'s optional third arg), and
8
+ * for phone/tablet presets:
9
+ *
10
+ * - SAFE AREA — the preset carries notch/home-indicator insets. `GamePanel`
11
+ * draws a visual overlay (`data-testid="device-safe-area-overlay"`) and
12
+ * sets CSS custom properties on the game mount container
13
+ * (`data-testid="game-runtime-container"`), the element game canvases and
14
+ * react-world DOM layers mount into:
15
+ * --vgai-safe-area-inset-top / -right / -bottom / -left (CSS px)
16
+ * Game UI reads them with `var(--vgai-safe-area-inset-top, 0px)`.
17
+ * Recorded choice: real `env(safe-area-inset-*)` injection is NOT
18
+ * possible from a same-document host (env() values come from the UA for
19
+ * the top-level viewport only), so the overlay + CSS vars ARE the
20
+ * contract here.
21
+ *
22
+ * - POINTER-AS-TOUCH — mouse input reaches the game as pointer events with
23
+ * `pointerType: 'touch'`, emulated at the game-mount DOM boundary
24
+ * (`attachDeviceTouchTarget` below): a capture-phase listener on the
25
+ * mount container swallows mouse-typed pointer events and re-dispatches a
26
+ * clone with `pointerType: 'touch'` on the same target — so anything the
27
+ * game registered (raw canvas listeners, gated `window`/`document`
28
+ * listeners via `gated-globals.ts` — the clone bubbles to the real window
29
+ * exactly like the original would, so the T6.3 input gate still applies)
30
+ * sees touch. The engine `InputManager` is untouched: it listens for
31
+ * compatibility `mouse*` events on `window`, which the browser still
32
+ * fires (we never `preventDefault()` the pointer event). Fidelity limits,
33
+ * recorded: real mobile fires compat mousedown/up AFTER a tap completes
34
+ * and never streams hover mousemove — here the InputManager sees
35
+ * desktop-timed mouse events alongside the touch clones, so games mixing
36
+ * pointer handlers with InputManager mouse state get simultaneous rather
37
+ * than sequenced input, and hover-dependent logic that breaks on device
38
+ * still works in emulation. No `TouchEvent` (`touchstart`/`touchmove`)
39
+ * synthesis (Chrome device mode does synthesize these) — raw-touch-event
40
+ * games see nothing. A game capture-phase window/document pointer
41
+ * listener registers ahead of this seam and sees the original mouse
42
+ * event before the clone.
43
+ *
44
+ * Persistence: preset + touch override are the PROJECT settings layer's
45
+ * `devicePreview` (`settings-store.ts` → `.vgai/settings.json`, committed):
46
+ * the frame a game is designed for is a fact about the project, shared by
47
+ * everyone who opens it.
48
+ */
49
+
50
+ import { projectSettings, updateProjectSettings } from '@volter/editor-core/settings-store';
51
+
52
+ export type DevicePresetKind = 'fit' | 'phone' | 'tablet' | 'desktop';
53
+
54
+ export interface SafeAreaInsets {
55
+ readonly top: number;
56
+ readonly right: number;
57
+ readonly bottom: number;
58
+ readonly left: number;
59
+ }
60
+
61
+ export interface DevicePreset {
62
+ readonly id: string;
63
+ readonly label: string;
64
+ readonly kind: DevicePresetKind;
65
+ /** CSS resolution; null for the default fit-panel preset. */
66
+ readonly width: number | null;
67
+ readonly height: number | null;
68
+ /** Device pixel ratio applied to the game renderers; null → host default. */
69
+ readonly dpr: number | null;
70
+ /** Notch/home-indicator insets in CSS px; null → no safe-area treatment. */
71
+ readonly safeArea: SafeAreaInsets | null;
72
+ }
73
+
74
+ export const FIT_PRESET_ID = 'fit';
75
+
76
+ /** Chrome-device-mode-class presets. Insets are the real devices' values
77
+ * (iPhone 14: 47pt notch + 34pt home indicator; landscape rotates the notch
78
+ * to the sides; Android: 24dp status bar + 24dp gesture bar; iPad: 24pt
79
+ * status bar + 20pt home indicator). */
80
+ export const DEVICE_PRESETS: readonly DevicePreset[] = [
81
+ {
82
+ id: FIT_PRESET_ID,
83
+ label: 'Fit panel',
84
+ kind: 'fit',
85
+ width: null,
86
+ height: null,
87
+ dpr: null,
88
+ safeArea: null,
89
+ },
90
+ {
91
+ id: 'iphone-portrait',
92
+ label: 'iPhone 14 — 390×844 @3x',
93
+ kind: 'phone',
94
+ width: 390,
95
+ height: 844,
96
+ dpr: 3,
97
+ safeArea: { top: 47, right: 0, bottom: 34, left: 0 },
98
+ },
99
+ {
100
+ id: 'iphone-landscape',
101
+ label: 'iPhone 14 landscape — 844×390 @3x',
102
+ kind: 'phone',
103
+ width: 844,
104
+ height: 390,
105
+ dpr: 3,
106
+ safeArea: { top: 0, right: 47, bottom: 21, left: 47 },
107
+ },
108
+ {
109
+ id: 'android-portrait',
110
+ label: 'Android — 412×915 @2.6x',
111
+ kind: 'phone',
112
+ width: 412,
113
+ height: 915,
114
+ dpr: 2.6,
115
+ safeArea: { top: 24, right: 0, bottom: 24, left: 0 },
116
+ },
117
+ {
118
+ id: 'ipad-portrait',
119
+ label: 'iPad — 820×1180 @2x',
120
+ kind: 'tablet',
121
+ width: 820,
122
+ height: 1180,
123
+ dpr: 2,
124
+ safeArea: { top: 24, right: 0, bottom: 20, left: 0 },
125
+ },
126
+ {
127
+ id: 'desktop-1080p',
128
+ label: 'Desktop — 1920×1080 @1x',
129
+ kind: 'desktop',
130
+ width: 1920,
131
+ height: 1080,
132
+ dpr: 1,
133
+ safeArea: null,
134
+ },
135
+ ];
136
+
137
+ const FIT_PRESET = DEVICE_PRESETS[0] as DevicePreset;
138
+
139
+ function presetById(id: string): DevicePreset {
140
+ return DEVICE_PRESETS.find((p) => p.id === id) ?? FIT_PRESET;
141
+ }
142
+
143
+ // --- store (house module-scope useSyncExternalStore shape) ------------------
144
+
145
+ let _preset: DevicePreset = FIT_PRESET;
146
+ /** null → auto (on for phone/tablet presets); boolean → explicit override. */
147
+ let _touchOverride: boolean | null = null;
148
+ let _version = 0;
149
+ const _listeners = new Set<() => void>();
150
+
151
+ function notify(): void {
152
+ _version++;
153
+ for (const fn of _listeners) fn();
154
+ }
155
+
156
+ export function subscribeDevicePreview(fn: () => void): () => void {
157
+ _listeners.add(fn);
158
+ return () => {
159
+ _listeners.delete(fn);
160
+ };
161
+ }
162
+
163
+ export const devicePreviewVersion = (): number => _version;
164
+
165
+ export function devicePreset(): DevicePreset {
166
+ return _preset;
167
+ }
168
+
169
+ /** Effective pointer-as-touch state: explicit override, else auto-on for
170
+ * phone/tablet presets. */
171
+ export function touchEmulationActive(): boolean {
172
+ return _touchOverride ?? (_preset.kind === 'phone' || _preset.kind === 'tablet');
173
+ }
174
+
175
+ export function touchEmulationOverride(): boolean | null {
176
+ return _touchOverride;
177
+ }
178
+
179
+ export function setDevicePreset(id: string): void {
180
+ const next = presetById(id);
181
+ if (next === _preset) return;
182
+ _preset = next;
183
+ // A new device's auto touch policy takes over; the override was a choice
184
+ // about the PREVIOUS preset.
185
+ _touchOverride = null;
186
+ persist();
187
+ notify();
188
+ }
189
+
190
+ export function setTouchEmulationOverride(enabled: boolean | null): void {
191
+ if (enabled === _touchOverride) return;
192
+ _touchOverride = enabled;
193
+ persist();
194
+ notify();
195
+ }
196
+
197
+ /** The renderer pixel ratio the active preset emulates, or `null` for the
198
+ * host default (`min(devicePixelRatio, 2)` — `create-runtime.ts`'s mount
199
+ * policy). `play-mode.ts` consults this on session boot so a preset chosen
200
+ * BEFORE Play still lands its DPR. */
201
+ export function deviceEmulatedPixelRatio(): number | null {
202
+ return _preset.dpr;
203
+ }
204
+
205
+ /** The host-default pixel ratio `GamePanel` passes to restore after a preset
206
+ * is cleared — the same formula the runtime mounts with. */
207
+ export function hostDefaultPixelRatio(): number {
208
+ return Math.min(typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1, 2);
209
+ }
210
+
211
+ // --- project persistence (the PROJECT settings layer) ----------------------
212
+
213
+ function persist(): void {
214
+ updateProjectSettings({ devicePreview: { preset: _preset.id, touch: _touchOverride } });
215
+ }
216
+
217
+ /** Load the active project's declared preset into the store (no persist
218
+ * write-back) and return it — `GamePanel` calls this on mount and applies
219
+ * the returned preset's resolution to the shared game-resolution store. */
220
+ export function restoreDevicePresetForProject(): DevicePreset {
221
+ const stored = projectSettings().devicePreview;
222
+ if (stored?.preset) {
223
+ _preset = presetById(stored.preset);
224
+ _touchOverride = typeof stored.touch === 'boolean' ? stored.touch : null;
225
+ } else {
226
+ // Nothing declared for THIS project: reset module state, or a preset
227
+ // carried over from a previously-open project would silently apply here
228
+ // (and a later toggle would persist it under this project).
229
+ _preset = FIT_PRESET;
230
+ _touchOverride = null;
231
+ }
232
+ notify();
233
+ return _preset;
234
+ }
235
+
236
+ // --- pointer-as-touch emulation at the game-mount DOM boundary --------------
237
+
238
+ const EMULATED_TYPES = ['pointerdown', 'pointermove', 'pointerup', 'pointercancel'] as const;
239
+
240
+ /** Clones this module dispatched — never re-intercept our own events. */
241
+ const _synthetic = new WeakSet<Event>();
242
+
243
+ let _touchTarget: HTMLElement | null = null;
244
+ let _detach: (() => void) | null = null;
245
+
246
+ function interceptPointer(e: Event): void {
247
+ if (!touchEmulationActive()) return;
248
+ if (_synthetic.has(e)) return;
249
+ if (!(e instanceof PointerEvent) || e.pointerType !== 'mouse') return;
250
+ const target = e.target;
251
+ if (!target) return;
252
+ // Swallow the mouse-typed pointer event before it reaches the game (canvas
253
+ // listeners, gated window/document listeners) …
254
+ e.stopImmediatePropagation();
255
+ // … and deliver the identical gesture as touch. Compat mouse events are
256
+ // deliberately NOT suppressed (no preventDefault): the engine InputManager
257
+ // listens for window `mouse*` and keeps working, exactly as on a real
258
+ // mobile browser where compat mouse events accompany touches.
259
+ const clone = new PointerEvent(e.type, {
260
+ bubbles: true,
261
+ cancelable: e.cancelable,
262
+ composed: true,
263
+ view: e.view,
264
+ detail: e.detail,
265
+ clientX: e.clientX,
266
+ clientY: e.clientY,
267
+ screenX: e.screenX,
268
+ screenY: e.screenY,
269
+ button: e.button,
270
+ buttons: e.buttons,
271
+ ctrlKey: e.ctrlKey,
272
+ shiftKey: e.shiftKey,
273
+ altKey: e.altKey,
274
+ metaKey: e.metaKey,
275
+ pointerId: e.pointerId,
276
+ pointerType: 'touch',
277
+ isPrimary: true,
278
+ pressure: e.type === 'pointerup' || e.type === 'pointercancel' ? 0 : Math.max(e.pressure, 0.5),
279
+ width: 20,
280
+ height: 20,
281
+ });
282
+ _synthetic.add(clone);
283
+ target.dispatchEvent(clone);
284
+ if (clone.defaultPrevented) e.preventDefault();
285
+ }
286
+
287
+ /**
288
+ * (Re)bind the emulation seam to the game mount container. `GamePanel`'s
289
+ * container callback ref calls this alongside `setGameContainer`; `null`
290
+ * detaches. Listeners are capture-phase so they run before the canvas (the
291
+ * event target) and before any bubble listener the game installed; they
292
+ * no-op unless {@link touchEmulationActive}.
293
+ */
294
+ export function attachDeviceTouchTarget(el: HTMLElement | null): void {
295
+ if (el === _touchTarget) return;
296
+ _detach?.();
297
+ _detach = null;
298
+ _touchTarget = el;
299
+ if (!el) return;
300
+ for (const type of EMULATED_TYPES) {
301
+ el.addEventListener(type, interceptPointer, { capture: true });
302
+ }
303
+ _detach = () => {
304
+ for (const type of EMULATED_TYPES) {
305
+ el.removeEventListener(type, interceptPointer, { capture: true });
306
+ }
307
+ };
308
+ }
309
+
310
+ /**
311
+ * The detach half, keyed by the element letting go — the same ownership rule
312
+ * as `play-mode.ts`'s `releaseGameContainer`, for the same overlap: two
313
+ * GamePanels coexist across a close/reopen gesture, and a STALE panel's
314
+ * cleanup calling `attachDeviceTouchTarget(null)` after the new panel
315
+ * attached would strip the CURRENT element's listeners.
316
+ */
317
+ export function detachDeviceTouchTarget(el: HTMLElement): void {
318
+ if (_touchTarget !== el) return;
319
+ attachDeviceTouchTarget(null);
320
+ }
321
+
322
+ // --- e2e state hook (house rule: assert state, never screenshot-diff) -------
323
+
324
+ function _stateForE2E() {
325
+ return {
326
+ presetId: _preset.id,
327
+ presetLabel: _preset.label,
328
+ dpr: _preset.dpr,
329
+ safeArea: _preset.safeArea,
330
+ touchActive: touchEmulationActive(),
331
+ };
332
+ }
333
+
334
+ if (typeof window !== 'undefined') {
335
+ (window as unknown as Record<string, unknown>)['__vgaiDevicePreview'] = _stateForE2E;
336
+ }