@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,662 @@
1
+ /**
2
+ * THE GAME DOCUMENT'S PANEL — the container element a runtime mounts into,
3
+ * with the device/letterbox presentation around it and the split-screen
4
+ * instance slots beside it. This is what `live-document.ts` opens and what
5
+ * every lane's `acquire()` waits for the commit of.
6
+ *
7
+ * It moved out of `components/CenterDocuments.tsx` when Play left the host
8
+ * (WORK.md §The workbench, P3b). The panel reports its container through the
9
+ * door (`workspace.liveDocument.setContainer` / `.releaseContainer`); the
10
+ * editor internals it still reads are reached through the `@editor/*` alias.
11
+ */
12
+
13
+ import {
14
+ getMountFailureReports,
15
+ subscribeToMountFailures,
16
+ } from '@volter/editor-core/authoring/mount-failure-report';
17
+ import { SurfaceStateOverlay } from '@volter/editor-core/components/SurfaceStateOverlay';
18
+ import { editorConsole } from '@volter/editor-core/editor-console';
19
+ import { useEditorStore } from '@volter/editor-core/editor-runtime';
20
+ import { getCurrentProject, onProjectChange } from '@volter/editor-core/project-manager';
21
+ import { readinessFacet, subscribeRootReadiness } from '@volter/editor-core/readiness';
22
+ import { domHasRenderableContent } from '../host/surface-content';
23
+ import { explainSurface } from '@volter/editor-core/surface-state';
24
+ import { editorHost } from '@volter/editor-sdk/host';
25
+ import { themeVars } from '@volter/editor-sdk/widgets';
26
+ import {
27
+ fitPresentation,
28
+ type PresentedSize,
29
+ resolvePresentedSize,
30
+ } from '@volter/game-runtime/runtime/presentation';
31
+ import {
32
+ type ReactNode,
33
+ useCallback,
34
+ useEffect,
35
+ useRef,
36
+ useState,
37
+ useSyncExternalStore,
38
+ } from 'react';
39
+ import { activeIngest } from '../ingest/active-ingest';
40
+ import {
41
+ desiredExtraInstances,
42
+ focusedInstanceId,
43
+ gameRootSurfaceFacts,
44
+ instanceNameAt,
45
+ isPlayModeActive,
46
+ mountAdditionalInstance,
47
+ primaryInstanceId,
48
+ resizeGame,
49
+ setFocusedInstance,
50
+ subscribeExtraInstances,
51
+ subscribeFocusedInstance,
52
+ subscribeGameSession,
53
+ unmountAdditionalInstance,
54
+ } from '../play/play-mode';
55
+ import {
56
+ attachDeviceTouchTarget,
57
+ detachDeviceTouchTarget,
58
+ devicePreset,
59
+ devicePreviewVersion,
60
+ hostDefaultPixelRatio,
61
+ restoreDevicePresetForProject,
62
+ subscribeDevicePreview,
63
+ } from './device-preview';
64
+ import {
65
+ applyGameDevicePreset,
66
+ gameDocumentResolution,
67
+ gameViewVersion,
68
+ notifyGameView,
69
+ setGameScale,
70
+ subscribeGameView,
71
+ } from './game-view-store';
72
+
73
+ /** The play-mode-owned children of a game mount container: stamped root
74
+ * surfaces (stacked canvases + React DOM layers). */
75
+ export function collectGameSurfaces(container: HTMLElement): Element[] {
76
+ return Array.from(container.children).filter(
77
+ (node) => (node as HTMLElement).dataset?.['vgaiRootSurface'] === 'true',
78
+ );
79
+ }
80
+
81
+ /** Re-adopt stashed game surfaces (in order — surface stacking IS DOM order)
82
+ * into a freshly mounted container; also drains `prev` if it is somehow
83
+ * still live in the same call (defensive, see `canvasRef` below). */
84
+ function adoptGameSurfaces(el: HTMLElement, stash: Element[], prev: HTMLElement | null): void {
85
+ for (const node of stash) {
86
+ if (!node.contains(el)) el.appendChild(node);
87
+ }
88
+ if (prev) {
89
+ for (const node of collectGameSurfaces(prev)) el.appendChild(node);
90
+ }
91
+ }
92
+
93
+ /** Game panel — a container div that play-mode.ts fills with a canvas. */
94
+ /**
95
+ * One split-screen viewport for an instance mounted BESIDE the primary
96
+ * (multiplayer authoring). Container-first, because play-mode must never own a
97
+ * detached DOM node (the same reason the primary's mount is a React ref): on
98
+ * attach it asks play-mode to `mountAdditionalInstance` into this element; on
99
+ * detach it tears that instance down. The async mount is guarded so a viewport
100
+ * unmounted before its mount resolves still cleans up, and
101
+ * `unmountAdditionalInstance` is idempotent so `exitPlayMode`'s bulk teardown
102
+ * and this per-viewport teardown can both fire for one instance.
103
+ */
104
+ /** The instance's name bar — a header STRIP above the view (chrome, not an
105
+ * overlay) so the "Instance 1"/"Instance 2" label never covers game pixels. */
106
+ function InstanceNameBar({ name }: { name: string }) {
107
+ return (
108
+ <div
109
+ data-testid="game-instance-name"
110
+ style={{
111
+ flex: '0 0 auto',
112
+ padding: '3px 10px',
113
+ background: themeVars.surface.chrome,
114
+ color: themeVars.content.primary,
115
+ borderBottom: `1px solid ${themeVars.boundary.default}`,
116
+ fontSize: 11,
117
+ fontWeight: 600,
118
+ userSelect: 'none',
119
+ }}
120
+ >
121
+ {name}
122
+ </div>
123
+ );
124
+ }
125
+
126
+ /** A split slot: the name bar above, its game area below. Shared by the primary
127
+ * and every extra viewport so both frame their game identically. `focused`
128
+ * draws the keyboard-focus ring; `onFocus` fires on a click anywhere in the
129
+ * slot so a player picks which seat the keyboard drives. */
130
+ function InstanceSlot({
131
+ name,
132
+ testId,
133
+ borderLeft,
134
+ focused,
135
+ onFocus,
136
+ children,
137
+ }: {
138
+ name: string;
139
+ testId: string;
140
+ borderLeft?: boolean;
141
+ focused?: boolean | undefined;
142
+ onFocus?: (() => void) | undefined;
143
+ children: ReactNode;
144
+ }) {
145
+ return (
146
+ <div
147
+ data-testid={testId}
148
+ data-focused={focused || undefined}
149
+ // Pointer-down (not click) so focus lands before the game consumes the
150
+ // event; capture so it fires even though the game canvas is inside.
151
+ onPointerDownCapture={onFocus}
152
+ style={{
153
+ flex: 1,
154
+ // min-width:0 lets flex actually divide the row; without it a flex item
155
+ // refuses to shrink below its content's min size.
156
+ minWidth: 0,
157
+ height: '100%',
158
+ display: 'flex',
159
+ flexDirection: 'column',
160
+ overflow: 'hidden',
161
+ position: 'relative',
162
+ cursor: onFocus && !focused ? 'pointer' : undefined,
163
+ ...(borderLeft ? { borderLeft: `1px solid ${themeVars.boundary.default}` } : {}),
164
+ }}
165
+ >
166
+ <InstanceNameBar name={name} />
167
+ {/* The game area — min-height:0 is the column-flex sibling of the row's
168
+ min-width:0, letting this shrink so the bar is never squeezed out. */}
169
+ <div
170
+ style={{
171
+ flex: 1,
172
+ minHeight: 0,
173
+ position: 'relative',
174
+ display: 'flex',
175
+ alignItems: 'center',
176
+ justifyContent: 'center',
177
+ }}
178
+ >
179
+ {children}
180
+ </div>
181
+ {/* Keyboard/runtime-focus ring — the editor accent is the established
182
+ selected-context color across 2D/3D viewports. Inset so it never
183
+ shifts layout or eats pointer events. */}
184
+ {focused && (
185
+ <div
186
+ aria-hidden
187
+ style={{
188
+ position: 'absolute',
189
+ inset: 0,
190
+ pointerEvents: 'none',
191
+ // Above the game canvas/DOM layers (which set their own stacking),
192
+ // or the ring hides behind the running game.
193
+ zIndex: 40,
194
+ border: `2px solid ${themeVars.accent.default}`,
195
+ boxShadow: `inset 0 0 0 1px ${themeVars.accent.default}`,
196
+ }}
197
+ />
198
+ )}
199
+ </div>
200
+ );
201
+ }
202
+
203
+ function InstanceViewport({ index }: { index: number }) {
204
+ const ref = useRef<HTMLDivElement>(null);
205
+ // index 0 is the primary, so an extra viewport at list-index `index` is
206
+ // instance `index + 1`.
207
+ const name = instanceNameAt(index + 1);
208
+ // An additional instance can only mount ONCE the primary session is live —
209
+ // `mountAdditionalInstance` throws otherwise. At play-start the split slot
210
+ // renders as soon as `playState` flips to 'playing', ~a frame before the
211
+ // primary's async composition finishes mounting, so this effect must WAIT for
212
+ // the primary and re-run when it arrives. `subscribeGameSession` fires when
213
+ // the primary mounts (and on stop); gating on it — instead of mounting the
214
+ // extra eagerly — is what stops the "no primary play session is running" error.
215
+ const primaryLive = useSyncExternalStore(subscribeGameSession, isPlayModeActive);
216
+ const [mountedId, setMountedId] = useState<string | null>(null);
217
+ const focused = useSyncExternalStore(subscribeFocusedInstance, focusedInstanceId);
218
+ useEffect(() => {
219
+ const el = ref.current;
220
+ if (!el || !primaryLive) return;
221
+ let id: string | null = null;
222
+ let disposed = false;
223
+ void mountAdditionalInstance(el, name).then(
224
+ (mid) => {
225
+ if (disposed) unmountAdditionalInstance(mid);
226
+ else {
227
+ id = mid;
228
+ setMountedId(mid);
229
+ }
230
+ },
231
+ (err) => {
232
+ // A failed extra instance is a real defect a human must see, not a
233
+ // silently blank panel.
234
+ editorConsole.error(`Failed to mount ${name}: ${err}`, 'play-mode');
235
+ },
236
+ );
237
+ return () => {
238
+ disposed = true;
239
+ setMountedId(null);
240
+ if (id) unmountAdditionalInstance(id);
241
+ };
242
+ // `name` is derived from index and stable for this slot; index is the key.
243
+ // `primaryLive` gates the mount on the primary session existing.
244
+ }, [index, primaryLive]);
245
+ return (
246
+ <InstanceSlot
247
+ name={name}
248
+ testId={`game-instance-slot-${index + 1}`}
249
+ borderLeft
250
+ focused={mountedId != null && focused === mountedId}
251
+ onFocus={mountedId ? () => setFocusedInstance(mountedId) : undefined}
252
+ >
253
+ <div ref={ref} data-vgai-instance-viewport="" style={{ position: 'absolute', inset: 0 }} />
254
+ </InstanceSlot>
255
+ );
256
+ }
257
+
258
+ export function GamePanel({ style }: { style?: React.CSSProperties }) {
259
+ const project = useSyncExternalStore(onProjectChange, getCurrentProject);
260
+ const store = useEditorStore();
261
+ const isRunning = store.playState !== 'stopped';
262
+ const outerRef = useRef<HTMLDivElement>(null);
263
+ // Resolution comes from the shared game-view store above — the
264
+ // document-local toolbar writes it, this content applies it.
265
+ useSyncExternalStore(subscribeGameView, gameViewVersion);
266
+ // The device preset (DPR / safe-area / touch emulation) rides on top of
267
+ // the resolution mechanism — the preset's CSS resolution is written INTO
268
+ // `_gameResolution` by `applyGameDevicePreset`, so all letterbox/scale
269
+ // math below is unchanged.
270
+ useSyncExternalStore(subscribeDevicePreview, devicePreviewVersion);
271
+ // Multiplayer authoring: how many instances BESIDE the primary to show
272
+ // split-screen. 0 (the common case) renders exactly as before — no wrapper,
273
+ // no layout change, so the device-preview path is untouched.
274
+ const extraInstances = useSyncExternalStore(subscribeExtraInstances, desiredExtraInstances);
275
+ // The split is decided by the INSTANCE COUNT alone. This document exists
276
+ // only while a runtime is live or booting, and play-mode captures the
277
+ // primary container BEFORE it flips the play state: gating the split on
278
+ // `isRunning` mounted the bare container first, then re-rendered it into a
279
+ // slot once the state flipped — React threw the captured element away and
280
+ // the primary's runtime appended its canvas into a detached node, so
281
+ // instance 1 stayed dark for the whole session while instance 2 rendered
282
+ // (runhuman pass 143; traced append-by-append on production build 67).
283
+ const showSplit = extraInstances > 0;
284
+ // Which seat the keyboard drives (for the primary slot's focus ring/click).
285
+ const focusedId = useSyncExternalStore(subscribeFocusedInstance, focusedInstanceId);
286
+ const preset = devicePreset();
287
+ const resolution = gameDocumentResolution();
288
+ const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
289
+ const [contentProbe, setContentProbe] = useState(0);
290
+ const prevRef = useRef<HTMLDivElement | null>(null);
291
+ const rootReadiness = useSyncExternalStore(subscribeRootReadiness, readinessFacet);
292
+ const mountFailures = useSyncExternalStore(subscribeToMountFailures, getMountFailureReports);
293
+ const gameFacts = gameRootSurfaceFacts();
294
+ const ingest = activeIngest();
295
+ const gameRootIds =
296
+ gameFacts.length > 0
297
+ ? gameFacts.map((fact) => fact.rootId)
298
+ : ingest
299
+ ? [ingest.worldId]
300
+ : rootReadiness
301
+ .filter((entry) => entry.mechanism === 'host-mount')
302
+ .map((entry) => entry.rootId);
303
+ const mountedContainerHasContent = Boolean(
304
+ prevRef.current && domHasRenderableContent(prevRef.current),
305
+ );
306
+ const gameHasContent =
307
+ gameFacts.length > 0
308
+ ? gameFacts.some((fact) => fact.hasRenderableContent)
309
+ : mountedContainerHasContent;
310
+ const rootsMounted = gameFacts.length > 0 || ingest !== null;
311
+ const gameExplanation = isRunning
312
+ ? explainSurface({
313
+ surface: 'Game',
314
+ rootIds: gameRootIds,
315
+ phase: rootsMounted ? 'ready' : 'loading',
316
+ content: rootsMounted ? (gameHasContent ? 'present' : 'empty') : 'unknown',
317
+ readiness: rootReadiness,
318
+ failures: mountFailures,
319
+ })
320
+ : null;
321
+
322
+ // A setup may intentionally finish before its first visible object appears.
323
+ // Re-check only while the mounted composition is empty; once pixels exist,
324
+ // the overlay is gone and this probe stops entirely.
325
+ useEffect(() => {
326
+ if (!isRunning || !rootsMounted || gameHasContent) return;
327
+ const timeout = window.setTimeout(() => setContentProbe((value) => value + 1), 250);
328
+ return () => window.clearTimeout(timeout);
329
+ }, [contentProbe, gameHasContent, isRunning, rootsMounted]);
330
+
331
+ useEffect(() => {
332
+ const outer = outerRef.current;
333
+ if (!outer || typeof MutationObserver === 'undefined') return;
334
+ const observer = new MutationObserver(() => setContentProbe((value) => value + 1));
335
+ observer.observe(outer, { childList: true, subtree: true, characterData: true });
336
+ return () => observer.disconnect();
337
+ }, []);
338
+
339
+ // W2c: per-project persisted device preset — restore once per workspace
340
+ // mount (project switches tear the whole workspace down, so this re-runs
341
+ // with the new project's stored choice).
342
+ useEffect(() => {
343
+ const restored = restoreDevicePresetForProject();
344
+ if (restored.width !== null) {
345
+ applyGameDevicePreset(restored);
346
+ notifyGameView();
347
+ }
348
+ }, []);
349
+
350
+ // Callback ref so setGameContainer is called even when the element swaps
351
+ // (fill ↔ device). React detaches the OLD element's ref (`null`) before the
352
+ // new element's ref fires, so the live game canvases must be STASHED at
353
+ // detach time and re-adopted at attach time. A single `if (prevRef.current
354
+ // && el)` move branch cannot work here — both sides are never non-null in
355
+ // the same call — and switching resolution DURING play would silently drop
356
+ // the running game's canvases with the removed element. Only nodes the game
357
+ // HOST mounted are
358
+ // stashed (world surfaces carry `data-vgai-root-surface`): the fill/device branches are keyed below so
359
+ // React really remounts, but if it ever aliases the container DOM node
360
+ // again, a blind childNodes copy would capture React-owned children too —
361
+ // that exact aliasing put the incoming inner div in the stash and made
362
+ // `appendChild` throw a cycle error (first run of this fix).
363
+ const orphanedGameNodes = useRef<Element[]>([]);
364
+ const canvasRef = useCallback((el: HTMLDivElement | null) => {
365
+ if (el === prevRef.current) return;
366
+ if (!el && prevRef.current) {
367
+ // Old container is being unmounted — rescue the play-mode-owned
368
+ // canvases/DOM layers before the element leaves the document.
369
+ orphanedGameNodes.current = collectGameSurfaces(prevRef.current);
370
+ }
371
+ if (el) {
372
+ adoptGameSurfaces(el, orphanedGameNodes.current, prevRef.current);
373
+ orphanedGameNodes.current = [];
374
+ }
375
+ const prev = prevRef.current;
376
+ prevRef.current = el;
377
+ // ATTACH names the element; DETACH names the element it lets go of. A
378
+ // bare `setGameContainer(null)` here is what a STALE panel's cleanup used
379
+ // to run AFTER the replacing panel had already attached — two GamePanels
380
+ // coexist across a close/reopen gesture, and React orders their refs by
381
+ // its own schedule, not by which panel is current. `releaseGameContainer`
382
+ // is keyed so only the owner can empty the slot (see its doc for the
383
+ // measured mount refusal).
384
+ if (el) editorHost().workspace.liveDocument.setContainer(el);
385
+ else if (prev) editorHost().workspace.liveDocument.releaseContainer(prev);
386
+ // W2c: the same element is the pointer-as-touch emulation seam — same
387
+ // ownership rule on detach.
388
+ if (el) attachDeviceTouchTarget(el);
389
+ else if (prev) detachDeviceTouchTarget(prev);
390
+ }, []);
391
+
392
+ // Track outer container size for scale calculation
393
+ useEffect(() => {
394
+ const el = outerRef.current;
395
+ if (!el) return;
396
+ const observer = new ResizeObserver(([entry]) => {
397
+ if (!entry) return;
398
+ const { width, height } = entry.contentRect;
399
+ setContainerSize((current) =>
400
+ current.width === width && current.height === height ? current : { width, height },
401
+ );
402
+ });
403
+ observer.observe(el);
404
+ return () => observer.disconnect();
405
+ }, []);
406
+
407
+ // Resize the game renderer when resolution or container size changes.
408
+ // W2c: a device preset also pins the renderer pixel ratio; without one we
409
+ // pass the host default explicitly so leaving a preset restores it.
410
+ // (`isRunning` is a dep so the DPR re-lands on a session that restarts at
411
+ // an unchanged size; the boot-time application lives in `enterPlayMode`,
412
+ // which consults `deviceEmulatedPixelRatio()` once the session exists.)
413
+ // What size this game is PRESENTED at. Two things can name one and the precedence lives in
414
+ // `resolvePresentedSize`: a device preset the user picked wins, and otherwise the project's own
415
+ // declared `resolution` (`vgai.project.json`) puts the game on screen at its authored logical
416
+ // size, uniformly scaled and letterboxed by the branch below — presentation scaling, never a
417
+ // stretch mode. A project that declares neither fills the panel exactly as before.
418
+ const declaredResolution = project?.config.resolution;
419
+ const presented: PresentedSize = resolvePresentedSize(resolution, declaredResolution);
420
+ const isFill = presented.kind === 'fill';
421
+ // Under split the primary renders FILL (each instance owns an equal slot);
422
+ // the device/logical letterbox is a single-instance authoring aid, and its
423
+ // fixed-size wrapper would otherwise dominate the flex row (min-width:auto)
424
+ // and starve the extra slots to 0 (the live-verified failure mode).
425
+ const primaryFill = isFill || showSplit;
426
+ const logicalWidth = presented.kind === 'logical' ? presented.width : null;
427
+ const logicalHeight = presented.kind === 'logical' ? presented.height : null;
428
+ const presetDpr = preset.dpr;
429
+ useEffect(() => {
430
+ const dpr = presetDpr ?? hostDefaultPixelRatio();
431
+ if (showSplit) {
432
+ // The primary fills its equal slot (width / instance count). Size its
433
+ // render buffer to that slot so it is not horizontally squished, rather
434
+ // than to the full panel.
435
+ if (containerSize.width > 0 && containerSize.height > 0) {
436
+ resizeGame(containerSize.width / (extraInstances + 1), containerSize.height, dpr);
437
+ }
438
+ } else if (logicalWidth === null || logicalHeight === null) {
439
+ // Fill mode: resize to match available container space
440
+ if (containerSize.width > 0 && containerSize.height > 0) {
441
+ resizeGame(containerSize.width, containerSize.height, dpr);
442
+ }
443
+ } else {
444
+ // The game renders AT its logical size — that is what makes the camera's aspect and the DOM
445
+ // overlay's design pixels the ones the game was authored against. The panel-fitting is the
446
+ // CSS transform below, not a different render size.
447
+ resizeGame(logicalWidth, logicalHeight, dpr);
448
+ }
449
+ }, [
450
+ logicalWidth,
451
+ logicalHeight,
452
+ containerSize.width,
453
+ containerSize.height,
454
+ presetDpr,
455
+ isRunning,
456
+ showSplit,
457
+ extraInstances,
458
+ ]);
459
+
460
+ // Fit the logical rect into the available space. A device preview never upscales (you are
461
+ // looking at the device's own pixels); a declared resolution does, or a small game sits as a
462
+ // postage stamp in a maximized panel.
463
+ const fit = fitPresentation(
464
+ containerSize,
465
+ { width: logicalWidth ?? 0, height: logicalHeight ?? 0 },
466
+ { allowUpscale: presented.kind === 'logical' && presented.allowUpscale },
467
+ );
468
+ const scale = isFill ? 1 : fit.scale;
469
+ // Publish for the toolbar's percentage readout (effect: setState-in-render
470
+ // on ANOTHER component's store is a React error; post-commit is correct).
471
+ useEffect(() => {
472
+ setGameScale(scale);
473
+ }, [scale]);
474
+
475
+ // Safe-area contract: the game MOUNT container always carries the
476
+ // inset CSS vars (0px unless the active preset declares insets), so game
477
+ // UI can style with `var(--vgai-safe-area-inset-top, 0px)` etc. Real
478
+ // `env(safe-area-inset-*)` cannot be injected from a same-document host
479
+ // (env() is UA-supplied for the top-level viewport only) — the vars + the
480
+ // visual overlay below ARE the emulation.
481
+ const safeArea = preset.safeArea;
482
+ const safeAreaVars = {
483
+ '--vgai-safe-area-inset-top': `${safeArea?.top ?? 0}px`,
484
+ '--vgai-safe-area-inset-right': `${safeArea?.right ?? 0}px`,
485
+ '--vgai-safe-area-inset-bottom': `${safeArea?.bottom ?? 0}px`,
486
+ '--vgai-safe-area-inset-left': `${safeArea?.left ?? 0}px`,
487
+ } as React.CSSProperties;
488
+
489
+ return (
490
+ <div style={{ overflow: 'hidden', position: 'relative', flexDirection: 'column', ...style }}>
491
+ <div
492
+ ref={outerRef}
493
+ style={{
494
+ flex: 1,
495
+ overflow: 'hidden',
496
+ display: 'flex',
497
+ alignItems: 'center',
498
+ justifyContent: 'center',
499
+ position: 'relative',
500
+ }}
501
+ >
502
+ {(() => {
503
+ // The primary mount, unchanged. Split-screen (multiplayer authoring)
504
+ // renders it as the FIRST slot of a flex row and adds one
505
+ // `InstanceViewport` per extra instance; with no extras this returns
506
+ // it bare, so the single-instance layout and its device-preview path
507
+ // are byte-for-byte what they were.
508
+ const primaryMount = primaryFill ? (
509
+ <div
510
+ key="game-mount-fill"
511
+ ref={canvasRef}
512
+ data-testid="game-runtime-container"
513
+ aria-hidden="true"
514
+ style={{
515
+ position: 'absolute',
516
+ inset: 0,
517
+ // INHERIT, never assert `visible`. Every panel in this
518
+ // workspace is a rect-synced overlay sharing one z-index, so
519
+ // The layout host expresses "this tab is not the one on screen" as
520
+ // `visibility: hidden` on the panel's overlay ancestor and
521
+ // nothing else. `visibility` is inherited, and a descendant
522
+ // that names `visible` re-shows itself out of a hidden
523
+ // ancestor — which is how a running game came to paint over
524
+ // whichever panel the reader was actually looking at. Stating
525
+ // only the hidden half leaves the ancestor's word final.
526
+ ...(isRunning ? {} : { visibility: 'hidden' as const }),
527
+ ...safeAreaVars,
528
+ }}
529
+ />
530
+ ) : (
531
+ /* Wrapper sized to the scaled dimensions so flexbox centering works.
532
+ The inner canvasRef is full device resolution, CSS-scaled down.
533
+ Keyed (like the fill branch) so the fill ↔ device switch REMOUNTS
534
+ instead of morphing one DOM node into the other — the canvasRef
535
+ stash/adopt handoff above depends on a real detach/attach pair. */
536
+ <div
537
+ key="game-mount-device"
538
+ style={{
539
+ position: 'relative',
540
+ width: fit.width,
541
+ height: fit.height,
542
+ overflow: 'hidden',
543
+ // Inherit while running — see the fill branch above.
544
+ ...(isRunning ? {} : { visibility: 'hidden' as const }),
545
+ }}
546
+ >
547
+ <div
548
+ ref={canvasRef}
549
+ data-testid="game-runtime-container"
550
+ aria-hidden="true"
551
+ style={{
552
+ width: logicalWidth ?? 0,
553
+ height: logicalHeight ?? 0,
554
+ transformOrigin: 'top left',
555
+ transform: `scale(${scale})`,
556
+ ...safeAreaVars,
557
+ }}
558
+ />
559
+ {/* W2c: visual safe-area (notch / home-indicator) bands — a
560
+ SIBLING of the mount container (play-mode owns that element's
561
+ children), scaled-space sized, hit-transparent. */}
562
+ {safeArea && (
563
+ <div
564
+ data-testid="device-safe-area-overlay"
565
+ style={{ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 2 }}
566
+ >
567
+ {(
568
+ [
569
+ [
570
+ 'top',
571
+ safeArea.top,
572
+ { top: 0, left: 0, right: 0, height: safeArea.top * scale },
573
+ ],
574
+ [
575
+ 'bottom',
576
+ safeArea.bottom,
577
+ { bottom: 0, left: 0, right: 0, height: safeArea.bottom * scale },
578
+ ],
579
+ [
580
+ 'left',
581
+ safeArea.left,
582
+ { top: 0, bottom: 0, left: 0, width: safeArea.left * scale },
583
+ ],
584
+ [
585
+ 'right',
586
+ safeArea.right,
587
+ { top: 0, bottom: 0, right: 0, width: safeArea.right * scale },
588
+ ],
589
+ ] as const
590
+ ).map(([side, inset, rect]) =>
591
+ inset > 0 ? (
592
+ <div
593
+ key={side}
594
+ data-testid={`device-safe-area-${side}`}
595
+ style={{
596
+ position: 'absolute',
597
+ ...rect,
598
+ background: themeVars.semantic.dangerFaint,
599
+ boxShadow: `inset 0 0 0 1px ${themeVars.semantic.dangerMuted}`,
600
+ }}
601
+ />
602
+ ) : null,
603
+ )}
604
+ </div>
605
+ )}
606
+ </div>
607
+ );
608
+ if (!showSplit) return primaryMount;
609
+ // Flex row: primary first, then one viewport per extra instance. The
610
+ // primary keeps its own positioning inside a relative slot.
611
+ return (
612
+ <>
613
+ <InstanceSlot
614
+ name={instanceNameAt(0)}
615
+ testId="game-instance-slot-0"
616
+ focused={focusedId === primaryInstanceId()}
617
+ onFocus={() => setFocusedInstance(primaryInstanceId())}
618
+ >
619
+ {primaryMount}
620
+ </InstanceSlot>
621
+ {Array.from({ length: extraInstances }, (_v, i) => (
622
+ <InstanceViewport key={i} index={i} />
623
+ ))}
624
+ </>
625
+ );
626
+ })()}
627
+ {!isRunning && (
628
+ <div
629
+ data-testid="game-stopped-placeholder"
630
+ style={{
631
+ position: 'absolute',
632
+ inset: 0,
633
+ display: 'flex',
634
+ flexDirection: 'column',
635
+ alignItems: 'center',
636
+ justifyContent: 'center',
637
+ gap: 6,
638
+ background: themeVars.surface.shell,
639
+ color: themeVars.content.dim,
640
+ fontSize: 11,
641
+ pointerEvents: 'none',
642
+ }}
643
+ >
644
+ <span style={{ color: themeVars.content.primary, fontSize: 13 }}>Game is stopped</span>
645
+ <span>Press Play to start the composed runtime.</span>
646
+ </div>
647
+ )}
648
+ {isRunning && (
649
+ <SurfaceStateOverlay
650
+ explanation={gameExplanation}
651
+ testId="game-surface-status"
652
+ style={{ zIndex: 30, background: themeVars.surface.shell }}
653
+ />
654
+ )}
655
+ </div>
656
+ {/* Transport lives in the global command header (`ProjectHeader.tsx`),
657
+ not here, so it is document-independent; device, resolution,
658
+ shading, and render-debug controls stay in this document's local
659
+ toolbar. */}
660
+ </div>
661
+ );
662
+ }