@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.
- package/LICENSE +661 -0
- package/NOTICE +23 -0
- package/contributions/asset-budget-asset.menu.ts +26 -0
- package/contributions/asset-budget.action.ts +18 -0
- package/contributions/asset-budget.document.tsx +21 -0
- package/contributions/asset-budget.menu.ts +22 -0
- package/contributions/audio-unlock.service.ts +17 -0
- package/contributions/audio.utility.tsx +15 -0
- package/contributions/autoplay.service.ts +48 -0
- package/contributions/bridge.command.ts +172 -0
- package/contributions/build-profiles.document.tsx +21 -0
- package/contributions/build-progress.status.tsx +45 -0
- package/contributions/build.action.ts +26 -0
- package/contributions/build.command.ts +27 -0
- package/contributions/build.header.tsx +37 -0
- package/contributions/build.menu.ts +31 -0
- package/contributions/build.service.ts +31 -0
- package/contributions/connection.status.tsx +43 -0
- package/contributions/coverage.service.ts +118 -0
- package/contributions/edit-mode-audio.service.ts +23 -0
- package/contributions/edit-mode-networking.service.ts +30 -0
- package/contributions/game-document.service.ts +25 -0
- package/contributions/game-eval.command.ts +210 -0
- package/contributions/game.layout.ts +19 -0
- package/contributions/gameplay.command.ts +255 -0
- package/contributions/generation.service.ts +96 -0
- package/contributions/generations.status.tsx +53 -0
- package/contributions/ingest.service.ts +80 -0
- package/contributions/instances.command.ts +75 -0
- package/contributions/navmesh.menu.ts +39 -0
- package/contributions/navmesh.service.ts +19 -0
- package/contributions/network.utility.tsx +17 -0
- package/contributions/play.command.ts +355 -0
- package/contributions/profiler.action.ts +16 -0
- package/contributions/profiler.menu.ts +22 -0
- package/contributions/profiler.utility.tsx +17 -0
- package/contributions/react/component-board.service.ts +21 -0
- package/contributions/react/design-time-mount.service.ts +60 -0
- package/contributions/react/pasteboard.action.ts +41 -0
- package/contributions/react/react-inspector.service.ts +85 -0
- package/contributions/react/story-documents.service.ts +44 -0
- package/contributions/scene-document.service.ts +32 -0
- package/contributions/state-watch.action.ts +17 -0
- package/contributions/state-watch.menu.ts +23 -0
- package/contributions/state-watch.utility.tsx +20 -0
- package/contributions/team-playtest.service.ts +124 -0
- package/contributions/three/camera-runtime.inspector.tsx +34 -0
- package/contributions/three/component-board.service.ts +24 -0
- package/contributions/three/component-verbs.command.ts +110 -0
- package/contributions/three/component-verbs.service.ts +92 -0
- package/contributions/three/constraints.inspector.tsx +34 -0
- package/contributions/three/model-asset-sections.service.ts +26 -0
- package/contributions/three/reflection-probe-capture.inspector.tsx +32 -0
- package/contributions/three/story-documents.service.ts +31 -0
- package/contributions/three/three-authoring.service.ts +66 -0
- package/contributions/transport.header.tsx +19 -0
- package/contributions/xstate-behavior.action.ts +42 -0
- package/contributions/xstate-behavior.document.tsx +73 -0
- package/contributions/xstate-behavior.inspector.tsx +28 -0
- package/contributions/xstate-behavior.menu.ts +23 -0
- package/package.json +144 -0
- package/src/asset-budget/AssetBudgetPanel.tsx +1172 -0
- package/src/asset-budget/asset-budget-model.ts +799 -0
- package/src/asset-budget/basis-encoder.ts +165 -0
- package/src/asset-budget/gltf-io.ts +154 -0
- package/src/asset-budget/gltf-optimize.ts +384 -0
- package/src/asset-budget/image-dims.ts +78 -0
- package/src/asset-budget/optimize-apply.ts +221 -0
- package/src/audio/AudioDebuggerPanel.tsx +457 -0
- package/src/audio/audio-debugger-model.ts +87 -0
- package/src/bridge/call.ts +60 -0
- package/src/bridge/dispatch.ts +459 -0
- package/src/bridge/live-frames.ts +25 -0
- package/src/bridge/screenshot.ts +316 -0
- package/src/build/BuildProfilesPanel.tsx +476 -0
- package/src/build/build-session.ts +247 -0
- package/src/build/format-bytes.ts +14 -0
- package/src/command-results.ts +36 -0
- package/src/coverage/live-authoring-surface.ts +30 -0
- package/src/coverage/live-project-verbs.ts +162 -0
- package/src/coverage/native-system-coverage.ts +143 -0
- package/src/coverage/root-coverage.ts +79 -0
- package/src/coverage/session-coverage.ts +193 -0
- package/src/design-system-stories/ApplicationChrome.stories.tsx +100 -0
- package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +406 -0
- package/src/edit-mode/edit-mode-audio.ts +56 -0
- package/src/edit-mode/edit-mode-networking.ts +112 -0
- package/src/game-document/DevicePresetPicker.tsx +84 -0
- package/src/game-document/GameCaptureFrameButton.tsx +45 -0
- package/src/game-document/GameDocument.tsx +215 -0
- package/src/game-document/GamePanel.tsx +662 -0
- package/src/game-document/InstanceInspectorPicker.tsx +183 -0
- package/src/game-document/crowd-debug.ts +183 -0
- package/src/game-document/device-preview.ts +336 -0
- package/src/game-document/game-view-store.ts +107 -0
- package/src/game-document/physics-debug.ts +187 -0
- package/src/generation/GenerationActivity.tsx +421 -0
- package/src/generation/GenerationGallery.css +231 -0
- package/src/generation/generation-documents.tsx +338 -0
- package/src/generation/generation-jobs.ts +128 -0
- package/src/generation/generation-presentation.ts +257 -0
- package/src/host/adapter-reach.ts +445 -0
- package/src/host/adapter-runtime-bindings.ts +303 -0
- package/src/host/after-paint.ts +112 -0
- package/src/host/api/configurations.ts +66 -0
- package/src/host/authoring/babylon-authoring-adapter.ts +703 -0
- package/src/host/authoring/canvas-runtime-recognition.ts +50 -0
- package/src/host/authoring/contract-hierarchy-authoring.ts +144 -0
- package/src/host/authoring/contract-scenes-stories.ts +204 -0
- package/src/host/authoring/creation-site-related.ts +55 -0
- package/src/host/authoring/ephemeral-persistence.ts +29 -0
- package/src/host/authoring/gesture-persist.ts +84 -0
- package/src/host/authoring/ingest-data-writer.ts +232 -0
- package/src/host/authoring/ingest-source-persistence.ts +826 -0
- package/src/host/authoring/mount-isolated-pixi-screen.ts +250 -0
- package/src/host/authoring/mounted-authoring.ts +41 -0
- package/src/host/authoring/owned-pixi-ticker-listeners.ts +96 -0
- package/src/host/authoring/phaser-live-authoring-adapter.ts +265 -0
- package/src/host/authoring/pixi-authoring-adapter.ts +1554 -0
- package/src/host/authoring/pixi-creation-site-write-target.ts +60 -0
- package/src/host/authoring/pixi-isolation-assets.ts +25 -0
- package/src/host/authoring/pixi-live-write-target.ts +979 -0
- package/src/host/authoring/pixi-source-identity.ts +141 -0
- package/src/host/authoring/pixi-still-presentation.ts +71 -0
- package/src/host/authoring/pixi-structure-history.ts +237 -0
- package/src/host/authoring/pixi-transform-channels.ts +205 -0
- package/src/host/authoring/selection-remount-handoff.ts +23 -0
- package/src/host/authoring/source-persistence-backend.ts +373 -0
- package/src/host/authoring/source-refresh-revisions.ts +81 -0
- package/src/host/authoring/struct-write-pipe.ts +143 -0
- package/src/host/auto-frame-window.ts +89 -0
- package/src/host/binding-resolver.ts +393 -0
- package/src/host/browser-transpile.ts +631 -0
- package/src/host/canvas-entry-runtime.ts +95 -0
- package/src/host/components/CameraAuthoringOverlay.tsx +216 -0
- package/src/host/components/HeaderTelemetry.tsx +341 -0
- package/src/host/components/PixiIsolationSceneContent.tsx +280 -0
- package/src/host/components/ResolutionPicker.tsx +89 -0
- package/src/host/components/ThreeIsolationSceneContent.tsx +180 -0
- package/src/host/components/frame-debugger-model.ts +579 -0
- package/src/host/components/header-telemetry-model.ts +74 -0
- package/src/host/components/scene-document.tsx +447 -0
- package/src/host/components/utility-view-state.ts +87 -0
- package/src/host/components/world-root-stage-binding.tsx +133 -0
- package/src/host/components/world-root-stage.ts +1046 -0
- package/src/host/coverage/authoring-read-probe.ts +583 -0
- package/src/host/coverage/capability-coverage.ts +1587 -0
- package/src/host/coverage/coverage-accounting.ts +261 -0
- package/src/host/coverage/game-contract-seam-evidence.ts +82 -0
- package/src/host/coverage/project-verb-coverage.ts +148 -0
- package/src/host/coverage/system-adapter-coverage.ts +373 -0
- package/src/host/design-system-stories/StoryLayout.tsx +104 -0
- package/src/host/design-system-stories/fixtures/authoring.ts +247 -0
- package/src/host/design-system-stories/fixtures/editor-runtime.tsx +115 -0
- package/src/host/document-preview-three.ts +110 -0
- package/src/host/entry-adjudication.ts +89 -0
- package/src/host/game-location-guard.ts +150 -0
- package/src/host/game-module-access.ts +196 -0
- package/src/host/game-realm-page.ts +358 -0
- package/src/host/game-realm-reclaim.ts +55 -0
- package/src/host/game-realm-storage.ts +104 -0
- package/src/host/gameplay-export.ts +288 -0
- package/src/host/gameplay-recording.ts +717 -0
- package/src/host/gated-globals.ts +1511 -0
- package/src/host/history/json-history-resource.ts +254 -0
- package/src/host/ingest/registry.ts +261 -0
- package/src/host/instance-extract-actions.ts +120 -0
- package/src/host/instance-fork-actions.ts +120 -0
- package/src/host/play-control-hook.ts +26 -0
- package/src/host/playwright-shim.ts +479 -0
- package/src/host/projection/dom.ts +295 -0
- package/src/host/projection/pixi.ts +288 -0
- package/src/host/r3f-entry-runtime.ts +77 -0
- package/src/host/react-mount-runtime.ts +162 -0
- package/src/host/realm-services.ts +148 -0
- package/src/host/recording-preview.ts +106 -0
- package/src/host/roots/module-root.ts +203 -0
- package/src/host/roots/react-root.ts +181 -0
- package/src/host/same-realm-loop-gate.ts +544 -0
- package/src/host/scene-view-drawability.ts +69 -0
- package/src/host/sdk/tools.ts +31 -0
- package/src/host/served-bundle-runtime-modules.ts +331 -0
- package/src/host/server-log-bridge.ts +60 -0
- package/src/host/staged-projects.ts +24 -0
- package/src/host/stories/mounted-story-viewport-source.ts +64 -0
- package/src/host/stories/story-arg-descriptors.ts +50 -0
- package/src/host/stories/story-media-presence.ts +152 -0
- package/src/host/surface-content.ts +87 -0
- package/src/host/take-named-export.ts +23 -0
- package/src/host/three-ingest-runtime.ts +76 -0
- package/src/host/types-fastnoise-lite.d.ts +7 -0
- package/src/host/types-mikktspace.d.ts +20 -0
- package/src/host/types-troika-three-text.d.ts +7 -0
- package/src/host/use-active-performance-source.ts +53 -0
- package/src/host/viewport-pose-memory.ts +48 -0
- package/src/host/viewport-root-presentation.ts +40 -0
- package/src/ingest/active-ingest.ts +251 -0
- package/src/ingest/active-scene-navigation.ts +43 -0
- package/src/ingest/authoring/ingest-dom-surface-authoring.ts +191 -0
- package/src/ingest/authoring/ingest-root-adapter.ts +897 -0
- package/src/ingest/capture-wait-report.ts +122 -0
- package/src/ingest/deferred-ingest-play.ts +216 -0
- package/src/ingest/deferred-ingest-session.ts +23 -0
- package/src/ingest/discovery-public-ingest.ts +242 -0
- package/src/ingest/dom-stub-mark.ts +7 -0
- package/src/ingest/entry-load.ts +56 -0
- package/src/ingest/game-contract-realm.ts +34 -0
- package/src/ingest/game-pointer-lock.ts +89 -0
- package/src/ingest/held-scene-repaint.ts +73 -0
- package/src/ingest/host-surface-box.ts +174 -0
- package/src/ingest/ingest-boot-viewport.ts +54 -0
- package/src/ingest/ingest-canvas-scene-document.tsx +177 -0
- package/src/ingest/ingest-canvas-scene.ts +50 -0
- package/src/ingest/ingest-evidence-hook.ts +131 -0
- package/src/ingest/ingest-frame-snapshot.ts +183 -0
- package/src/ingest/ingest-play-commands.ts +134 -0
- package/src/ingest/ingest-play-control.ts +299 -0
- package/src/ingest/ingest-render-debug.ts +320 -0
- package/src/ingest/ingest-siblings.ts +487 -0
- package/src/ingest/ingest-status.ts +62 -0
- package/src/ingest/live-ingest-facet.ts +46 -0
- package/src/ingest/module-mode.ts +229 -0
- package/src/ingest/mount-canvas-ingest-root.ts +899 -0
- package/src/ingest/mount-coverage.ts +296 -0
- package/src/ingest/mount-dom-ingest-root.ts +282 -0
- package/src/ingest/mount-ingest-root.ts +744 -0
- package/src/ingest/mount-three-ingest-root.ts +366 -0
- package/src/ingest/resolve-canvas.ts +47 -0
- package/src/ingest/resolve-three.ts +123 -0
- package/src/ingest/served-bundle.ts +109 -0
- package/src/ingest/served-html-boot.ts +292 -0
- package/src/ingest/surface-canvas.ts +84 -0
- package/src/ingest/surface-dom.ts +84 -0
- package/src/ingest/surface-three.ts +128 -0
- package/src/ingest/types.ts +76 -0
- package/src/ingest/unmount-ingest-root.ts +224 -0
- package/src/navmesh/navmesh-actions.ts +27 -0
- package/src/navmesh/navmesh-handler.ts +237 -0
- package/src/navmesh/navmesh-workflow-store.ts +78 -0
- package/src/network/NetworkInspectorPanel.tsx +644 -0
- package/src/network/network-inspector-model.ts +225 -0
- package/src/play/play-boot-stall.ts +118 -0
- package/src/play/play-log-events.ts +26 -0
- package/src/play/play-mode.ts +2553 -0
- package/src/play/play-recording.ts +335 -0
- package/src/play/react-play-live-authoring.ts +165 -0
- package/src/play-bar/PlayBar.tsx +488 -0
- package/src/play-bar/PlayerCountPicker.tsx +100 -0
- package/src/profiler/FrameDebuggerPanel.tsx +458 -0
- package/src/profiler/PerformancePanel.tsx +1068 -0
- package/src/profiler/ProfilerPanel.tsx +53 -0
- package/src/profiler/frame-debugger-store.ts +97 -0
- package/src/profiler/main-thread-busy.ts +90 -0
- package/src/react/design-time-react-mount.ts +782 -0
- package/src/react/dom-authoring-adapter.ts +764 -0
- package/src/react/pasteboard-materialize.ts +154 -0
- package/src/react/react-inspector-section.tsx +2496 -0
- package/src/react/react-world-authoring-adapter.ts +3795 -0
- package/src/react/story-documents/story-args-section.ts +19 -0
- package/src/react/story-documents/story-documents.tsx +1380 -0
- package/src/react/story-paint-bounds.ts +94 -0
- package/src/react/ui-board-document.tsx +101 -0
- package/src/react/ui-board-title.ts +9 -0
- package/src/react/ui-component-board.ts +72 -0
- package/src/services/audio-pose-guard.ts +81 -0
- package/src/services/game-audio-unlock.ts +48 -0
- package/src/state-watch/StateWatchPanel.tsx +535 -0
- package/src/three/authoring/camera-runtime-inspector-section.tsx +135 -0
- package/src/three/authoring/constraint-inspector-section.tsx +189 -0
- package/src/three/authoring/design-time-renderer.ts +189 -0
- package/src/three/authoring/model-asset-inspector-section.css +41 -0
- package/src/three/authoring/model-asset-inspector-section.tsx +869 -0
- package/src/three/authoring/oid-source-persistence.ts +557 -0
- package/src/three/authoring/r3f-design-session.ts +1174 -0
- package/src/three/authoring/r3f-source-authoring-adapter.ts +5949 -0
- package/src/three/authoring/reflection-probe-inspector-section.tsx +76 -0
- package/src/three/authoring/spatial-audio-handles.ts +215 -0
- package/src/three/authoring/spatial-collider-handles.ts +229 -0
- package/src/three/authoring/spatial-joint-handles.ts +114 -0
- package/src/three/authoring/spatial-light-handles.ts +178 -0
- package/src/three/authoring/spatial-lod-handles.ts +93 -0
- package/src/three/authoring/spatial-particle-handles.ts +368 -0
- package/src/three/authoring/three-authoring-adapter.ts +1953 -0
- package/src/three/authoring/three-scene-identity.ts +19 -0
- package/src/three/authoring/three-spatial-handles.ts +161 -0
- package/src/three/authoring/typed-three-inspector.ts +528 -0
- package/src/three/component-verbs/extract-menu.ts +71 -0
- package/src/three/component-verbs/fork-menu.ts +78 -0
- package/src/three/component-verbs/internals-menu.ts +91 -0
- package/src/three/story-documents/three-story-documents.tsx +607 -0
- package/src/three/three-board/ThreeBoardDocument.tsx +888 -0
- package/src/three/three-board/board-framing.ts +412 -0
- package/src/three/three-board/board-layout.ts +401 -0
- package/src/three/three-board/board-scene.ts +901 -0
- package/src/three/three-board/three-component-board.ts +62 -0
- package/src/xstate/XStateBehaviorSection.tsx +130 -0
- package/src/xstate/XStateMachineInspector.tsx +566 -0
- package/src/xstate/character-animation-machine.fixture.ts +74 -0
- package/src/xstate/live-behaviors.ts +106 -0
- package/src/xstate/use-live-actor-state.ts +44 -0
- package/src/xstate/xstate-graph.ts +235 -0
- 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
|
+
}
|